feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
@@ -22,6 +22,10 @@ class ArraySeries final : public Series {
|
||||
void reset(std::vector<f32> y, f64 x0, f64 dx);
|
||||
/// Rebuilds the pyramid and cached bounds from y_ as it stands now.
|
||||
void commit();
|
||||
/// Appends one sample incrementally: the pyramid folds it (`append`, amortised
|
||||
/// O(1)) and the cached bounds extend in O(1), NaN-aware, instead of commit()
|
||||
/// rebuilding the whole store. The wholesale path stays `commit()`.
|
||||
void push(f32 y);
|
||||
|
||||
u64 size() const override;
|
||||
f64 x0() const override;
|
||||
@@ -29,7 +33,8 @@ class ArraySeries final : public Series {
|
||||
Range x_bounds() const override;
|
||||
Range y_bounds() const override;
|
||||
const MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode = GatherMode::Envelope) const override;
|
||||
|
||||
protected:
|
||||
void copy_raw(u64 begin, u64 end, f32* out) const override;
|
||||
@@ -41,6 +46,11 @@ class ArraySeries final : public Series {
|
||||
MinMaxPyramid pyramid_;
|
||||
Range x_bounds_{};
|
||||
Range y_bounds_{};
|
||||
/// False until a finite sample has been seen: `y_bounds_` is then a
|
||||
/// placeholder (zero-span), not an aggregate, and the next finite sample
|
||||
/// seeds it. This is what makes push()'s incremental extension and commit()'s
|
||||
/// rebuild agree on a window that holds non-finite samples.
|
||||
bool y_has_finite_ = false;
|
||||
};
|
||||
|
||||
} // namespace plot::data
|
||||
@@ -19,6 +19,12 @@ struct MinMax {
|
||||
/// series and the level maths is a shift.
|
||||
inline constexpr u32 kBucket = 64;
|
||||
|
||||
/// Deepest pyramid level. `kBucket << 57` is `2^63`, the widest bucket span a
|
||||
/// u64 sample count can name; past it the shift would overflow (UB), so every
|
||||
/// level computation clamps here and a `gather()` asked for a deeper level
|
||||
/// simply gets this one (whose buckets are already wider than any window).
|
||||
inline constexpr u32 kMaxPyramidLevel = 57;
|
||||
|
||||
/// Incremental min/max LOD pyramid, indexed in absolute bucket space.
|
||||
///
|
||||
/// Level `l` bucket `k` covers the absolute samples
|
||||
@@ -37,14 +43,14 @@ inline constexpr u32 kBucket = 64;
|
||||
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
|
||||
/// level discards the buckets whose span starts before `u`, so the memory is
|
||||
/// bounded by the retained window and not by the number of samples ever appended.
|
||||
/// A bucket entry is 24 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
|
||||
/// range queries fold over -- so level 0 costs 0.375n bytes and the geometric tail
|
||||
/// brings the whole pyramid to about 0.75n bytes, ~19% of the 4n bytes of raw
|
||||
/// samples. (A min/max-only index would be 0.25n, ~6%: the two doubles are the
|
||||
/// price of answering mean/RMS/integral from whole buckets instead of raw
|
||||
/// samples.) Every stored entry is the complete aggregate of its own span and
|
||||
/// every stored span lies inside the retained window, so no aggregate is ever
|
||||
/// recomputed or invalidated.
|
||||
/// A bucket entry is 32 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
|
||||
/// range queries fold over and the finite-sample `count` the mean folds by -- so
|
||||
/// level 0 costs 0.5n bytes and the geometric tail brings the whole pyramid to
|
||||
/// about 1n bytes, ~25% of the 4n bytes of raw samples. (A min/max-only index
|
||||
/// would be 0.25n, ~6%: the extra words are the price of answering mean/RMS/
|
||||
/// integral from whole buckets instead of raw samples.) Every stored entry is
|
||||
/// the complete aggregate of its own span and every stored span lies inside the
|
||||
/// retained window, so no aggregate is ever recomputed or invalidated.
|
||||
///
|
||||
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
|
||||
/// `size()` is the retained sample count, and `window_base()` is the absolute
|
||||
@@ -64,6 +70,11 @@ class MinMaxPyramid {
|
||||
/// appends never grows the level vector. The bucket deques themselves grow in
|
||||
/// chunks: a sliding window cannot know their final length up front.
|
||||
void reserve(u64 n);
|
||||
/// Folds one sample into the pyramid. A non-finite sample advances the
|
||||
/// absolute sample count (bucket boundaries are positional) but is excluded
|
||||
/// from the aggregates: min/max/sum/sumsq/count never see it, and a bucket
|
||||
/// whose samples were all non-finite is stored as an empty aggregate
|
||||
/// (count 0, zero sums) that every reader treats as absent.
|
||||
void append(f32 v);
|
||||
/// Slides the retained window's leading edge to absolute sample index
|
||||
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
||||
@@ -100,6 +111,15 @@ class MinMaxPyramid {
|
||||
/// Aggregate of the absolute level-`l` bucket `i`. Precondition:
|
||||
/// level_base(l) <= i < level_base(l) + level_size(l).
|
||||
MinMax bucket(u32 l, u64 i) const;
|
||||
/// The bucket's exact `sum`/`sumsq` folds -- what `GatherMode::Mean` needs to
|
||||
/// average a bucket and what an RMS readout folds without touching raw
|
||||
/// samples. Same precondition as `bucket()`.
|
||||
f64 bucket_sum(u32 l, u64 i) const;
|
||||
f64 bucket_sumsq(u32 l, u64 i) const;
|
||||
/// How many *finite* samples the bucket aggregated: the divisor that makes
|
||||
/// `bucket_sum()` a mean under the non-finite-exclusion policy, and 0 exactly
|
||||
/// when the bucket holds no usable sample. Same precondition as `bucket()`.
|
||||
u32 bucket_count(u32 l, u64 i) const;
|
||||
|
||||
/// Min/max over the whole level-0 buckets fully contained in the window range
|
||||
/// [begin, end). Partial edge buckets are excluded, never approximated; an
|
||||
@@ -119,13 +139,18 @@ class MinMaxPyramid {
|
||||
u64 snap(f64 x, f64 x0, f64 dx) const;
|
||||
|
||||
private:
|
||||
/// One bucket: its extremes plus its own exact sum and sum of squares. The two
|
||||
/// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/
|
||||
/// integral come from whole buckets instead of raw samples.
|
||||
/// One bucket: its extremes plus its own exact sum and sum of squares, and
|
||||
/// how many finite samples produced them. The extra words take a bucket from
|
||||
/// 8 bytes to 32, which is what lets mean/RMS/integral come from whole
|
||||
/// buckets instead of raw samples -- and the count is what keeps a bucket of
|
||||
/// nothing (all its samples non-finite) empty rather than zero-valued: an
|
||||
/// entry with count 0 has zero sums and a meaningless mm, and every reader
|
||||
/// skips it.
|
||||
struct Entry {
|
||||
MinMax mm;
|
||||
f64 sum = 0.0;
|
||||
f64 sumsq = 0.0;
|
||||
u32 count = 0; ///< finite samples folded in; 0 = an empty bucket
|
||||
};
|
||||
/// One level's complete buckets, in absolute bucket-index order.
|
||||
struct Level {
|
||||
|
||||
@@ -12,8 +12,13 @@ namespace plot::data {
|
||||
|
||||
/// Single-producer / single-consumer ring of at most `capacity` elements.
|
||||
///
|
||||
/// The capacity is rounded up to a power of two so slot addressing is a mask
|
||||
/// instead of a modulo. The producer writes a slot and then publishes the new
|
||||
/// The capacity is rounded up to a power of two -- `capacity()` reports the
|
||||
/// *rounded* value, not the requested one, and `size()` saturates there -- so
|
||||
/// slot addressing is a mask instead of a modulo. Callers that need the
|
||||
/// retained window's exact bound must read `capacity()`, never their own
|
||||
/// constructor argument (see `StreamSeries`, which reserves the rounded value).
|
||||
///
|
||||
/// The producer writes a slot and then publishes the new
|
||||
/// sample count with a release store; a consumer that observes that count with an
|
||||
/// acquire load is guaranteed to see the slot contents written before it.
|
||||
///
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
|
||||
namespace plot::data {
|
||||
|
||||
/// What geometry a gather level produces for its buckets. The LOD *level* is
|
||||
/// chosen exactly as before; the mode decides what each bucket contributes.
|
||||
enum class GatherMode {
|
||||
/// One (min, max) envelope pair per bucket: the default, and what a dense
|
||||
/// time-series trace wants -- the eye reads the extremes.
|
||||
Envelope,
|
||||
/// One raw sample per bucket (the pyramid's `snap` pick), x at the sample:
|
||||
/// a thinner geometry for traces whose *shape* matters more than extremes.
|
||||
Nearest,
|
||||
/// One point per bucket, y = the bucket mean (`sum` / count), x at the
|
||||
/// bucket centre: a smoothed trace for noisy data.
|
||||
Mean,
|
||||
};
|
||||
|
||||
/// A uniform-grid series of y samples: sample i lives at x = x0() + i*dx().
|
||||
///
|
||||
/// Every concrete series answers range queries exactly, and never by scanning the
|
||||
@@ -43,21 +57,52 @@ class Series {
|
||||
/// so the series that owns the sliding window is the one that answers.
|
||||
virtual bool streaming() const noexcept { return false; }
|
||||
|
||||
/// Appends interleaved (x, y) pairs in data coordinates for level `level`.
|
||||
/// Appends interleaved (x, y) pairs for level `level`.
|
||||
///
|
||||
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one (min, max)
|
||||
/// envelope pair per *complete* level bucket wholly inside the retained window,
|
||||
/// bounded by the two partial buckets at the edges of the requested range — the
|
||||
/// bucket the window starts inside and the bucket the newest samples are in —
|
||||
/// which it emits as raw samples through copy_raw(). A bucket that is only partly
|
||||
/// retained holds aggregates of samples the window no longer has and a partial
|
||||
/// trailing bucket is not stored at all, so neither may be drawn as an envelope.
|
||||
/// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as
|
||||
/// f32, never the absolute coordinate. A consumer reconstructs the absolute
|
||||
/// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an
|
||||
/// absolute epoch-time x as f32 would quantise it to a grid coarser than
|
||||
/// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative
|
||||
/// value `i*dx` is small and exact. y is unchanged.
|
||||
///
|
||||
/// The emitted geometry therefore always spans [begin, end) exactly, for at most
|
||||
/// two buckets of extra raw work (about two pixels at the level level_for()
|
||||
/// picks), and never reaches outside the retained window. Pairs are appended to
|
||||
/// out_xy, which is never cleared.
|
||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
|
||||
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
|
||||
/// geometry element per *complete* level bucket wholly inside the retained
|
||||
/// window, bounded by the two partial buckets at the edges of the requested
|
||||
/// range -- the bucket the window starts inside and the bucket the newest
|
||||
/// samples are in -- which it emits as raw samples through copy_raw(). A
|
||||
/// bucket that is only partly retained holds aggregates of samples the
|
||||
/// window no longer has and a partial trailing bucket is not stored at all,
|
||||
/// so neither may be drawn from aggregates. `mode` picks what a bucket
|
||||
/// contributes (see `GatherMode`); the edge raw samples are the same for
|
||||
/// every mode. A bucket whose samples are all non-finite is empty, not
|
||||
/// zero-valued (the library-wide NaN policy): Envelope and Mean emit nothing
|
||||
/// for it, Nearest draws the raw centre sample. `level` is clamped to
|
||||
/// `kMaxPyramidLevel`.
|
||||
///
|
||||
/// The emitted geometry therefore always spans [begin, end) exactly, for at
|
||||
/// most two buckets of extra raw work (about two pixels at the level
|
||||
/// level_for() picks), and never reaches outside the retained window -- nor
|
||||
/// outside [begin, end). Pairs are appended to out_xy, which is never
|
||||
/// cleared.
|
||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode = GatherMode::Envelope) const = 0;
|
||||
|
||||
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
|
||||
/// Clamping is the caller's business: out-of-range `i` is the caller's bug.
|
||||
virtual f64 x_at(u64 i) const;
|
||||
|
||||
/// The samples bracketing data-space `x`: sets `i0` to the last index with
|
||||
/// `x_at(i0) <= x` and `i1` to the first with `x_at(i1) >= x` (equal when x
|
||||
/// lands exactly on a sample). False when x lies outside the series' x
|
||||
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
|
||||
/// paths read coordinates only through this and `x_at()`, so a series with
|
||||
/// its own x array interpolates exactly.
|
||||
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
|
||||
|
||||
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
|
||||
/// 0 for an empty series.
|
||||
virtual u64 snap_index(f64 x) const;
|
||||
|
||||
/// Exact min/max, sum and sum-of-squares over [begin, end).
|
||||
/// Preconditions: begin < end, end <= size(); a degenerate range yields a
|
||||
@@ -75,8 +120,9 @@ class Series {
|
||||
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
|
||||
|
||||
/// The shared body of the concrete gather() overrides: raw samples at level 0,
|
||||
/// bucket min/max envelope above it.
|
||||
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
||||
/// bucket geometry above it (per `mode`).
|
||||
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode) const;
|
||||
|
||||
private:
|
||||
/// [begin, end) split into the bucket-aligned interior the pyramid can answer
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/data/array_series.hpp>
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
#include <plot/data/ring_buffer.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
@@ -57,6 +58,11 @@ namespace plot::data {
|
||||
/// class specifies.
|
||||
class StreamSeries final : public Series {
|
||||
public:
|
||||
/// `capacity` is the sample count to retain; it is rounded up to a power of
|
||||
/// two by the ring (see `RingBuffer`), and `size()` saturates at that rounded
|
||||
/// `capacity()` -- a `StreamSeries(100)` retains up to 128 samples. The
|
||||
/// pyramid's level table is reserved for the rounded capacity up front, so a
|
||||
/// saturated stream never grows it.
|
||||
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
|
||||
|
||||
/// Producer thread only.
|
||||
@@ -68,6 +74,17 @@ class StreamSeries final : public Series {
|
||||
/// Consumer thread only: snapshots the produced count and brings the pyramid
|
||||
/// and bounds up to it.
|
||||
void acquire();
|
||||
/// Consumer thread only: copies the window published by the *last acquire()*
|
||||
/// into a fresh `ArraySeries` with this window's `x0()`/`dx()`. The frozen
|
||||
/// series never changes again, no matter what the producer pushes afterwards
|
||||
/// -- it answers from its own copy, not from the ring -- which is what a
|
||||
/// consumer that needs a stable window for longer than one read wants. (The
|
||||
/// ring itself keeps overwriting; only the copy is frozen, so this costs one
|
||||
/// window's worth of samples. It reads through `copy_raw()`, so like every
|
||||
/// read it is a snapshot of *which* absolute indices to walk, not a guarantee
|
||||
/// about slots a producer is overwriting -- call it on the consumer thread,
|
||||
/// interleaved with `acquire()`, per the class note.)
|
||||
ArraySeries freeze() const;
|
||||
|
||||
u64 size() const override;
|
||||
f64 x0() const override;
|
||||
@@ -75,7 +92,8 @@ class StreamSeries final : public Series {
|
||||
Range x_bounds() const override;
|
||||
Range y_bounds() const override;
|
||||
const MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode = GatherMode::Envelope) const override;
|
||||
/// Always true: this series' `x_bounds()` is the retained ring window, which
|
||||
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
|
||||
bool streaming() const noexcept override { return true; }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
|
||||
namespace plot::data {
|
||||
|
||||
/// Series over its own x array: sample i lives at `x()[i]`, and the samples are
|
||||
/// sorted by ascending x. This is the store for data whose x is *not* on a
|
||||
/// uniform grid -- scattered measurements, event times, a log-swept frequency
|
||||
/// axis -- where `ArraySeries`' fixed `dx` cannot hold.
|
||||
///
|
||||
/// **Precondition: `x` is sorted ascending.** The O(log n) crosshair and
|
||||
/// nearest-sample lookups are binary searches, which are only correct on sorted
|
||||
/// input; the constructor and `reset()` assert sortedness in debug builds and
|
||||
/// the behaviour with unsorted input is undefined in release ones. Callers with
|
||||
/// a possibly-unsorted source sort it first (sorting the *pairs*, not the
|
||||
/// arrays independently, or the samples get scrambled).
|
||||
///
|
||||
/// Everything the base `Series` contracts to holds, with the sample index as
|
||||
/// the grid: the pyramid buckets samples by *index* (`kBucket` samples per
|
||||
/// level-0 bucket, exactly as for a uniform series) and `gather()` emits x
|
||||
/// relative to `x0()` -- `f32(x_at(i) - x0())`, never the absolute
|
||||
/// coordinate -- so an epoch-scale x array narrows to f32 the same safe,
|
||||
/// O(visible pixels) way a uniform one does. The queries that need a *uniform*
|
||||
/// grid are overridden with the binary-search answer instead: `x_at()` reads
|
||||
/// the array, `neighbours()` brackets with `upper_bound`/`lower_bound`, and
|
||||
/// `snap_index()` compares the two candidates the search leaves. `dx()` is
|
||||
/// meaningless here and returns 0: nothing that routes through
|
||||
/// `x_at`/`neighbours`/`snap_index` consults it.
|
||||
class XYSeries final : public Series {
|
||||
public:
|
||||
/// Takes both arrays by value. `x.size() == y.size()` and `x` sorted
|
||||
/// ascending (asserted in debug).
|
||||
XYSeries(std::vector<f64> x, std::vector<f32> y);
|
||||
/// Replaces both arrays, then rebuilds derived state (== commit()). Same
|
||||
/// preconditions as the constructor.
|
||||
void reset(std::vector<f64> x, std::vector<f32> y);
|
||||
/// Rebuilds the pyramid and the cached bounds from the arrays as they stand.
|
||||
void commit();
|
||||
|
||||
u64 size() const override;
|
||||
/// x of sample 0: the first (smallest) x in the array.
|
||||
f64 x0() const override;
|
||||
/// Always 0: the grid is the x array, not a uniform step.
|
||||
f64 dx() const override;
|
||||
/// From the first and last x; a zero span for an empty series.
|
||||
Range x_bounds() const override;
|
||||
Range y_bounds() const override;
|
||||
const MinMaxPyramid& pyramid() const override;
|
||||
/// Same contract as `Series::gather`, with x emitted relative to `x0()`.
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
GatherMode mode = GatherMode::Envelope) const override;
|
||||
|
||||
/// The sample's own x.
|
||||
f64 x_at(u64 i) const override;
|
||||
/// Binary-search bracket of data-space `x`: false outside
|
||||
/// `[x_at(0), x_at(size() - 1)]`, else the last sample at or left of `x` and
|
||||
/// the first at or right of it (equal on an exact hit).
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||
/// Binary-search nearest sample, clamped to `[0, size() - 1]`; ties go to the
|
||||
/// lower index, and an empty series returns 0.
|
||||
u64 snap_index(f64 x) const override;
|
||||
|
||||
/// The x array itself (sorted ascending).
|
||||
const std::vector<f64>& x() const noexcept { return x_; }
|
||||
|
||||
protected:
|
||||
void copy_raw(u64 begin, u64 end, f32* out) const override;
|
||||
|
||||
private:
|
||||
std::vector<f64> x_;
|
||||
std::vector<f32> y_;
|
||||
MinMaxPyramid pyramid_;
|
||||
Range x_bounds_{};
|
||||
Range y_bounds_{};
|
||||
/// False until a finite sample has been seen (see `ArraySeries`): the cached
|
||||
/// y bounds are then a placeholder, and the first finite sample seeds them.
|
||||
bool y_has_finite_ = false;
|
||||
};
|
||||
|
||||
} // namespace plot::data
|
||||
@@ -58,8 +58,14 @@ enum : GLenum {
|
||||
|
||||
// Blending.
|
||||
GL_BLEND = 0x0BE2,
|
||||
GL_ZERO = 0x0000,
|
||||
GL_ONE = 0x0001,
|
||||
GL_SRC_ALPHA = 0x0302,
|
||||
GL_ONE_MINUS_SRC_ALPHA = 0x0303,
|
||||
GL_BLEND_SRC_RGB = 0x80C9 /* 1.4 */,
|
||||
GL_BLEND_DST_RGB = 0x80C8 /* 1.4 */,
|
||||
GL_BLEND_SRC_ALPHA = 0x80CB /* 1.4 */,
|
||||
GL_BLEND_DST_ALPHA = 0x80CA /* 1.4 */,
|
||||
|
||||
// Pixel store state.
|
||||
GL_UNPACK_ALIGNMENT = 0x0CF5,
|
||||
@@ -127,7 +133,9 @@ enum : GLenum {
|
||||
X(void, glClearColor, (float, float, float, float)) \
|
||||
X(void, glClear, (GLbitfield)) \
|
||||
X(void, glEnable, (GLenum)) \
|
||||
X(GLboolean, glIsEnabled, (GLenum)) \
|
||||
X(void, glBlendFunc, (GLenum, GLenum)) \
|
||||
X(void, glBlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum)) /* 1.4 */ \
|
||||
X(void, glPixelStorei, (GLenum, GLint)) \
|
||||
X(void, glDisable, (GLenum)) \
|
||||
X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \
|
||||
|
||||
@@ -36,7 +36,10 @@ class Handle {
|
||||
/// already used, so state that captured the old object -- the attribute
|
||||
/// pointers of a vertex array above all -- must compare this counter rather
|
||||
/// than the name (see `render::detail::InstanceStore::epoch()`).
|
||||
u32 generation() const noexcept { return generation_; }
|
||||
///
|
||||
/// 64-bit on purpose: `epoch()` sums this with a slot counter, and a long-lived
|
||||
/// streaming frame loop must not be able to wrap a u32 sum into a stale value.
|
||||
u64 generation() const noexcept { return generation_; }
|
||||
|
||||
/// Deletes the GL object, if any, and empties the handle.
|
||||
void reset() noexcept {
|
||||
@@ -73,7 +76,7 @@ class Handle {
|
||||
GLuint id_ = 0;
|
||||
|
||||
private:
|
||||
u32 generation_ = 0;
|
||||
u64 generation_ = 0;
|
||||
};
|
||||
|
||||
/// Vertex buffer object. All fallible operations report through `Status`;
|
||||
@@ -172,6 +175,9 @@ class Texture final : public Handle<Texture> {
|
||||
bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept;
|
||||
|
||||
/// Replaces the whole image with `width * height * 4` bytes of RGBA data.
|
||||
/// Sets `GL_UNPACK_ALIGNMENT` to 4 for the upload and restores the caller's
|
||||
/// value afterwards, so a host that shares the context never inherits this
|
||||
/// call's pixel-store state.
|
||||
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
|
||||
|
||||
/// Replaces the whole image with `width * height` bytes of single-channel
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <plot/core/rect.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/input/events.hpp>
|
||||
@@ -47,14 +49,21 @@ using plot::RegionGrab;
|
||||
/// | gesture | mode | effect |
|
||||
/// |---|---|---|
|
||||
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
|
||||
/// | ctrl + left drag | `BoxZoom` | x range only from the selection rect |
|
||||
/// | alt + left drag | `BoxZoom` | y range only from the selection rect |
|
||||
/// | left drag on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed |
|
||||
/// | left drag elsewhere | `Pan` | translate both ranges |
|
||||
/// | right drag | `Pan` | same, for hosts that reserve left |
|
||||
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
|
||||
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
|
||||
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
|
||||
/// | wheel `dx` (no `dy`) | -- | pan x, 5% of the span per notch |
|
||||
/// | `+` / `-` | -- | zoom about the plot centre |
|
||||
/// | `f` | -- | auto-fit every axis |
|
||||
/// | Left/Right | -- | pan every x axis, 10% of its span |
|
||||
/// | Up/Down | -- | pan every y axis, 10% of its span |
|
||||
/// | Escape | -- | cancel the active drag: the ranges captured at `begin_drag` are restored, a box zoom applies nothing |
|
||||
/// | double click (same button, 0.35 s, 4 px, `PointerEvent::time_s`) | -- | re-fit every axis; the second press begins no drag |
|
||||
///
|
||||
/// The right button pans *and* grabs: both are buttons a host drives drags with, so
|
||||
/// neither one is singled out for handles. Middle keeps its own role on the label
|
||||
@@ -74,9 +83,10 @@ using plot::RegionGrab;
|
||||
/// Cursors and regions are *grabbable*: a press on one enters a drag that moves it,
|
||||
/// with no modifier, and a press anywhere else pans exactly as it always has -- so
|
||||
/// the new gesture is a superset of the old one and every host that drives a drag
|
||||
/// keeps panning. `Ctrl` is no longer needed for a cursor move and does not change
|
||||
/// what a press on one does; it only means the same thing it always did (a press
|
||||
/// away from a handle pans, with or without it).
|
||||
/// keeps panning. `Ctrl` and `Alt` no longer fall through to that press: they are
|
||||
/// the modifier box zoom's masks now (see the gesture table -- Ctrl frames the x
|
||||
/// range only, Alt the y range only, from the selection, over whatever was under
|
||||
/// the press), which is why a *plain* press is the one that grabs a handle or pans.
|
||||
///
|
||||
/// One hit test runs for both, in this order, and that precedence is the whole rule:
|
||||
///
|
||||
@@ -203,9 +213,18 @@ class Controller {
|
||||
/// zeroed: a zero-notch zoom changes no range but still goes through the range
|
||||
/// setters, which clear auto scale -- so a sanitised amount would quietly disarm
|
||||
/// auto-fit. An unusable amount leaves the plot exactly as it was; an unusable
|
||||
/// *position* only loses its anchor, falling back to the last known one.
|
||||
/// *position* only loses its anchor, falling back to the last known one. The
|
||||
/// same refusal covers `dx`, which pans when `dy` is zero.
|
||||
void on_wheel(const WheelEvent& e);
|
||||
void on_key(const KeyEvent& e);
|
||||
/// A pinch frame from a touch host (see `GestureEvent`): `scale` zooms about
|
||||
/// `(x, y)` exactly like a wheel notch pair would -- the same setters, the
|
||||
/// same live-axis behaviour, the same guards -- and `(dx, dy)` pans by the
|
||||
/// focal point's movement. A frame with `scale == 1` and no movement is
|
||||
/// refused outright, for the same reason a zero-notch wheel is: the setters
|
||||
/// would clear `auto_scale` for no visible change. An amount that is not a
|
||||
/// number is refused rather than sanitised.
|
||||
void on_gesture(const GestureEvent& e);
|
||||
|
||||
DragMode drag_mode() const noexcept { return mode_; }
|
||||
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
|
||||
@@ -237,6 +256,35 @@ class Controller {
|
||||
void update_drag(const PointerEvent& e);
|
||||
void end_drag(const PointerEvent& e);
|
||||
|
||||
/// The ranges (and, for x, the live offsets and auto-scale flags) every axis held
|
||||
/// when a drag began -- what Escape restores. Captured at `begin_drag`, before the
|
||||
/// gesture can move anything.
|
||||
void capture_ranges();
|
||||
/// Escape: the active drag is cancelled -- the ranges captured at `begin_drag`
|
||||
/// are restored, a box zoom applies nothing, and the grab state is cleared. The
|
||||
/// restore goes through the plot's own setters, so a live axis is restored as the
|
||||
/// span and offset it held (the setters' native reading) rather than as positions
|
||||
/// that the next frame's follow would overwrite anyway.
|
||||
void cancel_drag();
|
||||
/// Re-fits every axis (`auto_fit_x()` plus one `auto_fit_y()` per y axis), which
|
||||
/// re-arms auto scale with the fit: the double-click reset.
|
||||
void fit_all();
|
||||
/// True when `e` is the second press of a double-click: the same button, within
|
||||
/// 0.35 s (both timestamps > 0 -- a host that leaves `time_s` at 0 never produces
|
||||
/// one) and within 4 logical px of the recorded press. Records `e` as the press a
|
||||
/// third one would be measured against either way; a detected double-click clears
|
||||
/// the record, so a third press starts a fresh gesture rather than re-firing.
|
||||
bool double_click(const PointerEvent& e);
|
||||
|
||||
/// Pans every x axis by `fraction` of its own span, in axis space (log-aware:
|
||||
/// a log axis translates multiplicatively). A positive fraction scrolls toward
|
||||
/// larger x. The wheel's `dx` path and the Left/Right arrows share it.
|
||||
void shift_x_axes(f64 fraction);
|
||||
/// Same for every y axis: a positive fraction scrolls toward larger y (the Up
|
||||
/// arrow). Screen y is inverted, so this is *not* the sign `pan()` derives from a
|
||||
/// drag's `dy` -- an arrow says where to look, a drag says where the data goes.
|
||||
void shift_y_axes(f64 fraction);
|
||||
|
||||
void pan(const PointerEvent& e);
|
||||
void box_zoom_update(const PointerEvent& e);
|
||||
void apply_box_zoom();
|
||||
@@ -317,6 +365,34 @@ class Controller {
|
||||
Rect selection_{};
|
||||
f32 origin_x_ = 0.0f;
|
||||
f32 origin_y_ = 0.0f;
|
||||
/// The box-zoom mask, resolved once at `begin_drag` from the modifier table
|
||||
/// (Shift = both, Ctrl = x-only, Alt = y-only, any other combo = both): which
|
||||
/// axes the selection's release frames. `apply_box_zoom()` reads these for the
|
||||
/// per-axis application *and* for the click threshold -- a masked-out dimension
|
||||
/// of a thin selection does not make the gesture a click.
|
||||
bool box_zoom_x_ = true;
|
||||
bool box_zoom_y_ = true;
|
||||
/// The range state a drag began with, per axis in order: what `cancel_drag()`
|
||||
/// restores. Empty while no drag is open.
|
||||
struct SavedX {
|
||||
Range range{};
|
||||
f64 offset = 0.0;
|
||||
bool auto_scale = false;
|
||||
};
|
||||
struct SavedY {
|
||||
Range range{};
|
||||
bool auto_scale = false;
|
||||
};
|
||||
std::vector<SavedX> saved_x_;
|
||||
std::vector<SavedY> saved_y_;
|
||||
/// The last press seen, for the double-click reset: the button, the host
|
||||
/// timestamp, and the position it was made at. `has_click_` is false until the
|
||||
/// first press arrives.
|
||||
bool has_click_ = false;
|
||||
int click_button_ = -1;
|
||||
f64 click_time_s_ = 0.0;
|
||||
f32 click_x_ = 0.0f;
|
||||
f32 click_y_ = 0.0f;
|
||||
u64 drag_cursor_ = kInvalid;
|
||||
/// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone
|
||||
/// the press resolved, kept for the whole gesture so the highlight a grab started
|
||||
|
||||
@@ -35,6 +35,26 @@ struct PointerEvent {
|
||||
bool down = false; ///< the button is held after this event
|
||||
bool pressed = false; ///< a press transition happened on this event
|
||||
bool released = false; ///< a release transition happened on this event
|
||||
/// Host-supplied monotonic seconds (the host's own clock, only differences
|
||||
/// are read). Two presses of the same button within 0.35 s and 4 px are a
|
||||
/// double-click and re-fit every axis. A host that leaves 0 never produces
|
||||
/// one -- the field is optional, not another synchronisation contract.
|
||||
f64 time_s = 0.0;
|
||||
};
|
||||
|
||||
/// A pinch/two-finger gesture, in viewport pixels. The toolkit adapters
|
||||
/// translate their native pinch state into one of these per frame of the
|
||||
/// gesture: `scale` is this frame's zoom factor about `(x, y)` (1.0 = no
|
||||
/// change), and `(dx, dy)` the focal point's movement since the previous
|
||||
/// gesture event (a two-finger pan). GLFW has no touch input, so only hosts
|
||||
/// that can deliver one feed this; the controller treats it exactly like the
|
||||
/// equivalent wheel + pointer pair.
|
||||
struct GestureEvent {
|
||||
f32 x = 0.0f;
|
||||
f32 y = 0.0f;
|
||||
f32 scale = 1.0f;
|
||||
f32 dx = 0.0f;
|
||||
f32 dy = 0.0f;
|
||||
};
|
||||
|
||||
/// A wheel/scroll event at a pixel position. A positive `dy` (scroll up) zooms
|
||||
@@ -65,4 +85,15 @@ inline constexpr int kKeyFit = 'f';
|
||||
/// Hosts that report shifted keys uppercase can pass this unchanged.
|
||||
inline constexpr int kKeyFitUpper = 'F';
|
||||
|
||||
/// Escape cancels the active drag (pan, box zoom, cursor/region drag): the
|
||||
/// ranges the axes had when the drag began are restored and no box is applied.
|
||||
inline constexpr int kKeyEscape = 27;
|
||||
/// Arrow keys pan: Left/Right move every x axis by 10% of its span per press
|
||||
/// (repeat included), Up/Down move every y axis. Hosts translate their native
|
||||
/// key codes to these, exactly as they do for the ASCII set above.
|
||||
inline constexpr int kKeyLeft = 256;
|
||||
inline constexpr int kKeyRight = 257;
|
||||
inline constexpr int kKeyUp = 258;
|
||||
inline constexpr int kKeyDown = 259;
|
||||
|
||||
} // namespace plot::input
|
||||
@@ -37,6 +37,7 @@
|
||||
#include <plot/data/ring_buffer.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
#include <plot/data/stream_series.hpp>
|
||||
#include <plot/data/xy_series.hpp>
|
||||
|
||||
#include <plot/render/embedded_mono_font.hpp>
|
||||
#include <plot/render/buffer.hpp>
|
||||
@@ -64,3 +65,9 @@
|
||||
|
||||
#include <plot/input/controller.hpp>
|
||||
#include <plot/input/events.hpp>
|
||||
|
||||
// The library version. The one public header configure_file() generates into
|
||||
// the build tree (see include/plot/version.hpp.in), so it is not under the
|
||||
// source include/ root with the layers above -- last here so the layer order
|
||||
// stays the story of the list.
|
||||
#include <plot/version.hpp>
|
||||
@@ -56,15 +56,21 @@ struct Axis {
|
||||
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
|
||||
/// caller who never asks for it. See XFollow.
|
||||
XFollow follow = XFollow::Off;
|
||||
/// How far behind the live edge the visible window's right edge sits, in data
|
||||
/// units, under `XFollow::Live`. Stored rather than implied by the range,
|
||||
/// because the range is recomputed from the series' newest x on every frame:
|
||||
/// this is the part of the view the user chose, so it is the part that survives
|
||||
/// the recompute. Three things write it, all of them through one clamp: a pan or
|
||||
/// a zoom through `Plot::set_x_range()`, which derives it from the range the
|
||||
/// gesture asked for, `Plot::set_x_offset()` for a caller that states it
|
||||
/// directly, and `Plot::layout()`'s placement, which re-clamps it against the
|
||||
/// retained window every frame. `Plot::x_offset()` reads it back.
|
||||
/// How far behind the live edge the visible window's right edge sits, in **axis
|
||||
/// space**, under `XFollow::Live`: data units on a linear axis, `log10` decades
|
||||
/// on a logarithmic one -- the same space the controller's pan/zoom gestures run
|
||||
/// in (`to_space`/`from_space`), so a log axis scrolls and holds *decades* the
|
||||
/// way a linear axis scrolls and holds units. (`to_space(live.max) -
|
||||
/// to_space(hi)` is the definition: the distance the axis' own transform
|
||||
/// measures between the live edge and the window's right edge.) Stored rather
|
||||
/// than implied by the range, because the range is recomputed from the series'
|
||||
/// newest x on every frame: this is the part of the view the user chose, so it
|
||||
/// is the part that survives the recompute. Three things write it, all of them
|
||||
/// through one clamp: a pan or a zoom through `Plot::set_x_range()`, which
|
||||
/// derives it from the range the gesture asked for, `Plot::set_x_offset()` for a
|
||||
/// caller that states it directly, and `Plot::layout()`'s placement, which
|
||||
/// re-clamps it against the retained window -- measured in the same space --
|
||||
/// every frame. `Plot::x_offset()` reads it back.
|
||||
f64 offset = 0.0;
|
||||
/// Set by an *owner* that is following this axis' window on the plot's behalf: a
|
||||
/// `Figure` link group, while it follows, writes its own live edge here every frame.
|
||||
|
||||
@@ -152,8 +152,16 @@ class Plot {
|
||||
|
||||
// --- viewport, style, text metrics ------------------------------------
|
||||
|
||||
/// The rectangle this plot occupies on screen, in framebuffer pixels.
|
||||
void set_viewport(Rect px) noexcept { viewport_ = px; }
|
||||
/// The rectangle this plot occupies on screen, in framebuffer pixels. A
|
||||
/// negative width or height is normalised to 0: the layout clamps against
|
||||
/// the viewport's dimensions and a negative would make those clamps
|
||||
/// ill-formed (`std::clamp` with `lo > hi`), so the invariant is repaired
|
||||
/// at the one place a hostile rect can enter.
|
||||
void set_viewport(Rect px) noexcept {
|
||||
if (px.w < 0.0f) px.w = 0.0f;
|
||||
if (px.h < 0.0f) px.h = 0.0f;
|
||||
viewport_ = px;
|
||||
}
|
||||
const Rect& viewport() const noexcept { return viewport_; }
|
||||
|
||||
/// Set from the renderer, which owns presentation: `Renderer::draw()` copies
|
||||
|
||||
@@ -68,8 +68,10 @@ inline constexpr usize kMaxTicks = 256;
|
||||
/// `include_minors` adds, for grid geometry, the minor ticks subdividing each
|
||||
/// major step by 5 (by 4 when the step's leading digit is 2). Without it `out`
|
||||
/// holds exactly the labelled major ticks -- the labelled set a caller draws
|
||||
/// numbers for, with no unlabelled entries to filter. Either way the total is
|
||||
/// capped at kMaxTicks.
|
||||
/// numbers for, with no unlabelled entries to filter. The cap counts **minor**
|
||||
/// ticks only: a range dense enough to exceed it loses its subdivisions, never
|
||||
/// its labelled majors -- every major at or below `hi` is emitted, so the far
|
||||
/// end of the axis keeps its numbers.
|
||||
///
|
||||
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
|
||||
/// (a zero or negative span, which includes the collapsed range of a
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
|
||||
namespace plot {
|
||||
|
||||
/// How a trace's geometry is gathered from its LOD pyramid. The level choice
|
||||
/// is unchanged; this picks what a bucket contributes (see
|
||||
/// `data::GatherMode`).
|
||||
using TraceMode = data::GatherMode;
|
||||
|
||||
/// A marker shape for per-sample points. Drawn with the trace's own colour
|
||||
/// through the shared line pipeline; `marker_every == 0` strides
|
||||
/// automatically (one marker per pixel column at most).
|
||||
enum class Marker { None, Cross, Circle, Square };
|
||||
|
||||
/// How a caller asks for a trace. The series pointer is borrowed and must outlive
|
||||
/// the plot's use of it; `axis_x`/`axis_y` index the plot's x and y axis lists.
|
||||
struct TraceDesc {
|
||||
@@ -16,7 +26,8 @@ struct TraceDesc {
|
||||
Color color = Color::hex(0x4FA3FF);
|
||||
/// The stroke's width in **logical** pixels, like every stroke in `Style` (see
|
||||
/// that struct's unit rule): the renderer converts it once, so a trace has the
|
||||
/// same weight against the axis lines whatever the display's DPI.
|
||||
/// same weight against the axis lines whatever the display's DPI. `0` draws
|
||||
/// the markers only -- a scatter trace.
|
||||
f32 width = 1.5f;
|
||||
u32 axis_x = 0, axis_y = 0;
|
||||
/// What the trace is called, for the legend. Empty means "do not list it", which
|
||||
@@ -26,6 +37,20 @@ struct TraceDesc {
|
||||
/// unchanged, exactly as `AxisDesc`'s trailing format does (and, in a build with
|
||||
/// `-Wextra`, warning-clean).
|
||||
std::string name{};
|
||||
/// Downsample policy for the LOD buckets (Envelope by default).
|
||||
TraceMode mode = TraceMode::Envelope;
|
||||
/// Dash pattern in logical pixels: ink, then gap. `dash_off <= 0` (and the
|
||||
/// default) is a solid line -- no geometry is added, the dash is a
|
||||
/// fragment-shader discard.
|
||||
f32 dash_on = 0.0f;
|
||||
f32 dash_off = 0.0f;
|
||||
/// Per-sample markers (see `Marker`). `marker_every == 0` strides
|
||||
/// automatically -- at most about one marker per pixel column -- so a dense
|
||||
/// trace never stages more markers than it has columns; a positive value
|
||||
/// draws every n-th gathered sample literally.
|
||||
Marker marker = Marker::None;
|
||||
f32 marker_size_px = 3.0f;
|
||||
u32 marker_every = 0;
|
||||
};
|
||||
|
||||
/// A series bound to a colour, width and axis pair. The series pointer may be
|
||||
@@ -35,13 +60,21 @@ struct Trace {
|
||||
const data::Series* series = nullptr;
|
||||
Color color = Color::hex(0x4FA3FF);
|
||||
/// Logical pixels, as in `TraceDesc::width`: converted to device pixels by the
|
||||
/// renderer's one `Style::device()` call per stroke.
|
||||
/// renderer's one `Style::device()` call per stroke. `0` = scatter (markers
|
||||
/// only, if any).
|
||||
f32 width = 1.5f;
|
||||
u32 axis_x = 0, axis_y = 0;
|
||||
/// The legend's entry for this trace, empty when it has none. A name does not
|
||||
/// make a trace drawable: the legend lists a trace only when it is visible, so a
|
||||
/// null series is skipped there exactly as it is by every other consumer.
|
||||
std::string name{};
|
||||
/// Same fields as `TraceDesc`, applied by the renderer.
|
||||
TraceMode mode = TraceMode::Envelope;
|
||||
f32 dash_on = 0.0f;
|
||||
f32 dash_off = 0.0f;
|
||||
Marker marker = Marker::None;
|
||||
f32 marker_size_px = 3.0f;
|
||||
u32 marker_every = 0;
|
||||
|
||||
/// True when the trace has a series with at least one sample to draw.
|
||||
bool visible() const noexcept { return series != nullptr && series->size() > 0; }
|
||||
|
||||
@@ -35,10 +35,11 @@ struct LineInstance {
|
||||
/// rect onto the two the same way round, so a cell is never mirrored. `(1, 0)`
|
||||
/// is unrotated text, and `(0, -1)` is a run advancing up the screen, i.e. a
|
||||
/// 90 degree counter-clockwise label such as a y-axis title.
|
||||
/// - `(r, g, b, a)` is the run's tint, straight alpha. Per instance rather than
|
||||
/// a uniform, so one draw can carry a readout whose values are coloured per
|
||||
/// row (each row's own trace) while its labels stay the axis text colour.
|
||||
///
|
||||
/// Six floats, so the whole text pipeline still fits one instanced draw call per
|
||||
/// frame with rotation free: a matrix per glyph would be three times the bytes
|
||||
/// for the same pixels.
|
||||
/// Ten floats, still one instanced draw call per frame.
|
||||
struct TextInstance {
|
||||
f32 x;
|
||||
f32 y;
|
||||
@@ -46,10 +47,14 @@ struct TextInstance {
|
||||
f32 v;
|
||||
f32 ax;
|
||||
f32 ay;
|
||||
f32 r;
|
||||
f32 g;
|
||||
f32 b;
|
||||
f32 a;
|
||||
};
|
||||
|
||||
static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats");
|
||||
static_assert(sizeof(TextInstance) == 6 * sizeof(f32), "text instance must stay 6 floats");
|
||||
static_assert(sizeof(TextInstance) == 10 * sizeof(f32), "text instance must stay 10 floats");
|
||||
|
||||
/// How a store's staged instances reach the GPU. Semantics are identical either
|
||||
/// way -- only the transfer differs, and `Renderer::init()` picks one from
|
||||
@@ -137,7 +142,9 @@ class InstanceStore {
|
||||
/// buffer cannot leave the vertex array bound to a deleted one), and `slot_epoch_`
|
||||
/// advances on every upload that lands in a new slot. Both counters only ever
|
||||
/// increase, so their sum changes whenever either does, and never repeats.
|
||||
u32 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
|
||||
/// 64-bit: the sum of two counters that advance every upload of a streaming
|
||||
/// frame loop must not be able to wrap into a stale value.
|
||||
u64 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
|
||||
|
||||
void bind() const noexcept { buffer_.bind(); }
|
||||
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
|
||||
@@ -165,7 +172,7 @@ class InstanceStore {
|
||||
u32 frame_uploads_ = 0;
|
||||
/// Times an upload landed in a new slot. Kept apart from the buffer's own
|
||||
/// identity (see `epoch()`) so neither has to know about the other.
|
||||
u32 slot_epoch_ = 0;
|
||||
u64 slot_epoch_ = 0;
|
||||
u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode
|
||||
/// Guards every slot the frame just ended wrote. Recorded once, after its last
|
||||
/// draw; waited on once, at the start of the next frame.
|
||||
@@ -186,7 +193,7 @@ class LineBuffer {
|
||||
}
|
||||
bool mark_drawn() noexcept { return store_.mark_drawn(); }
|
||||
usize slot_offset() const noexcept { return store_.slot_offset(); }
|
||||
u32 epoch() const noexcept { return store_.epoch(); }
|
||||
u64 epoch() const noexcept { return store_.epoch(); }
|
||||
bool persistent() const noexcept { return store_.persistent(); }
|
||||
void bind() const noexcept { store_.bind(); }
|
||||
|
||||
@@ -206,7 +213,7 @@ class TextBuffer {
|
||||
}
|
||||
bool mark_drawn() noexcept { return store_.mark_drawn(); }
|
||||
usize slot_offset() const noexcept { return store_.slot_offset(); }
|
||||
u32 epoch() const noexcept { return store_.epoch(); }
|
||||
u64 epoch() const noexcept { return store_.epoch(); }
|
||||
bool persistent() const noexcept { return store_.persistent(); }
|
||||
void bind() const noexcept { store_.bind(); }
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/color.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
#include <plot/render/text.hpp>
|
||||
@@ -60,7 +61,7 @@ class LineBatch {
|
||||
/// Like `LineBatch` it keeps its capacity across frames. Each staged glyph is
|
||||
/// one instance: the glyph cell's top-left corner in pixels, the glyph rect's
|
||||
/// normalised top-left in the atlas and the cell's rotation basis, interleaved as
|
||||
/// `x,y,u,v,ax,ay` — the layout the text shader declares (see `TextInstance` in
|
||||
/// `x,y,u,v,ax,ay,r,g,b,a` — the layout the text shader declares (see `TextInstance` in
|
||||
/// buffer.hpp).
|
||||
///
|
||||
/// Every glyph a batch stages is read through one atlas rect of one size — its
|
||||
@@ -92,8 +93,9 @@ class TextBatch {
|
||||
/// rasterised them. `axis` is the direction the run advances in, in pixel
|
||||
/// space; it rotates the cell (see `TextInstance`), and is normalised here, so
|
||||
/// any non-zero direction works and a zero or non-finite one means "unrotated".
|
||||
void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f,
|
||||
Vec2 axis = {1.0f, 0.0f});
|
||||
/// `color` tints this run's glyphs (per-vertex `a_colour`, see `TextInstance`).
|
||||
void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f, Vec2 axis = {1.0f, 0.0f},
|
||||
Color color = {1.0f, 1.0f, 1.0f, 1.0f});
|
||||
|
||||
/// Stages every codepoint of `utf8` from `pen_px` and returns the pen past the
|
||||
/// last glyph, so the run is exactly `font.measure(utf8) * px` long measured
|
||||
@@ -104,14 +106,14 @@ class TextBatch {
|
||||
/// `next_codepoint()` and `glyph()`, so a run stages exactly what `measure()`
|
||||
/// summed. A codepoint the backend has no glyph for stages nothing and moves
|
||||
/// the pen nowhere — a run is never wider than the glyphs it can draw. A null
|
||||
/// or empty string stages nothing and returns `pen_px`.
|
||||
/// or empty string stages nothing and returns `pen_px`. `color` tints the run.
|
||||
Vec2 add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
|
||||
Vec2 axis = {1.0f, 0.0f});
|
||||
Vec2 axis = {1.0f, 0.0f}, Color color = {1.0f, 1.0f, 1.0f, 1.0f});
|
||||
|
||||
/// Glyphs staged so far, one instance each.
|
||||
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 6u); }
|
||||
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 10u); }
|
||||
|
||||
/// Interleaved `x,y,u,v,ax,ay`, six floats per staged glyph.
|
||||
/// Interleaved `x,y,u,v,ax,ay,r,g,b,a`, ten floats per staged glyph.
|
||||
const std::vector<f32>& data() const noexcept { return v_; }
|
||||
|
||||
/// The cell the staged glyphs are drawn in: what `set_cell()` declared, or --
|
||||
@@ -124,7 +126,7 @@ class TextBatch {
|
||||
private:
|
||||
/// Stages one cell whose basis the caller has already normalised, so a run and
|
||||
/// the pen it advances share one rounding of the axis.
|
||||
void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux);
|
||||
void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color);
|
||||
|
||||
std::vector<f32> v_;
|
||||
Vec2 cell_uv_{};
|
||||
|
||||
@@ -35,6 +35,9 @@ class LineProgram {
|
||||
/// discard on the interpolated `v_along`, so it costs no geometry, and it is the one uniform
|
||||
/// that distinguishes the crosshair's stroke from every other line in the library.
|
||||
void set_dash(Vec2 on_off) const noexcept;
|
||||
/// `u_aa`: the AA fringe half-width in pixels. The style's `line_aa_px` in
|
||||
/// device pixels, clamped at 0.25; 1.0 reproduces the historical fringe.
|
||||
void set_aa(f32 px) const noexcept;
|
||||
/// `u_color`: stroke colour, alpha included.
|
||||
void set_color(Color color) const noexcept;
|
||||
|
||||
@@ -66,8 +69,8 @@ class TextProgram {
|
||||
void set_glyph_uv(Vec2 uv) const noexcept;
|
||||
/// `u_tex_size`: atlas size in texels.
|
||||
void set_texture_size(Vec2 texels) const noexcept;
|
||||
/// `u_color`: tint colour, alpha included.
|
||||
void set_color(Color color) const noexcept;
|
||||
/// The tint is per instance (`a_colour`), not a uniform, so a draw can carry
|
||||
/// runs of several colours -- there is deliberately no colour setter here.
|
||||
|
||||
private:
|
||||
gl::Program program_;
|
||||
|
||||
@@ -31,6 +31,7 @@ struct FrameStats {
|
||||
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
||||
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
||||
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
||||
u32 draw_calls = 0; ///< draw calls issued, line and text
|
||||
};
|
||||
|
||||
@@ -70,10 +71,26 @@ struct FrameStats {
|
||||
class Renderer {
|
||||
public:
|
||||
Renderer() noexcept = default;
|
||||
~Renderer() = default;
|
||||
/// Releases every GL handle first (`release_gl()`), so the destructor is safe
|
||||
/// whenever the context that owns them is still current -- the normal case for
|
||||
/// a member renderer destroyed by its host's widget teardown.
|
||||
~Renderer();
|
||||
Renderer(const Renderer&) = delete;
|
||||
Renderer& operator=(const Renderer&) = delete;
|
||||
|
||||
/// Destroys every GL object this renderer created: both programs, both vertex
|
||||
/// arrays and their instance buffers, and the glyph atlas texture. The
|
||||
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
|
||||
/// called again on the same object.
|
||||
///
|
||||
/// **Teardown contract:** call this while the context that owns the names is
|
||||
/// still current, before the host destroys that context -- a GL name deleted
|
||||
/// after its context is gone is leaked, not deleted. The destructor calls this
|
||||
/// first for the common case of a member renderer whose context outlives it;
|
||||
/// a host that tears the context down before its renderer must call
|
||||
/// `release_gl()` explicitly.
|
||||
void release_gl() noexcept;
|
||||
|
||||
/// Compiles both programs, uploads the font's atlas and allocates the vertex
|
||||
/// buffers. The calling thread must have the context current and
|
||||
/// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is
|
||||
@@ -215,7 +232,7 @@ class Renderer {
|
||||
///
|
||||
/// The GL state it leaves behind, so a host can embed the plot without guessing:
|
||||
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
|
||||
/// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the
|
||||
/// nothing bound to `GL_TEXTURE_2D` on it, scissoring off, and the
|
||||
/// viewport left at the frame's rect (the caller sets its own for the next pass).
|
||||
///
|
||||
/// The scissor is the other piece of state a frame moves, and it ends the frame *off*:
|
||||
@@ -346,12 +363,19 @@ class Renderer {
|
||||
/// One axis pair's data -> pixel mapping, already shifted so the viewport's
|
||||
/// top-left corner is the origin (the shaders emit NDC relative to the GL
|
||||
/// viewport, not the framebuffer).
|
||||
///
|
||||
/// `origin_x` is the data-space reference the caller staged its geometry
|
||||
/// relative to: every data-space x reaches this layer as the f32
|
||||
/// `to_space(x) - to_space(origin_x)` (B1), so the mapping folds the origin
|
||||
/// back into the offset uniform -- `offset_x = sx*to_space(origin_x) + tx -
|
||||
/// viewport.x`, computed in f64 and narrowed once -- and the staged values
|
||||
/// stay O(visible pixels) however large the absolute x is (an epoch axis).
|
||||
struct Mapping {
|
||||
Vec2 scale{1.0f, 1.0f};
|
||||
Vec2 offset{0.0f, 0.0f};
|
||||
};
|
||||
|
||||
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept;
|
||||
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y, f64 origin_x) const noexcept;
|
||||
/// The identity mapping for geometry already staged in viewport pixels.
|
||||
Mapping pixel_mapping() const noexcept;
|
||||
/// Shifts one framebuffer-space pixel position into viewport space.
|
||||
@@ -370,12 +394,14 @@ class Renderer {
|
||||
///
|
||||
/// `dash` is the pattern in pixels for the fragment shader's discard: `x` the ink
|
||||
/// length, `y` the gap, and a non-positive `y` -- the default every caller but the
|
||||
/// crosshair passes -- means **solid**. It is set on every flush, so one draw's dash
|
||||
/// can never leak into the next.
|
||||
/// crosshair passes and a dashed trace -- means **solid**. It is set on every flush,
|
||||
/// so one draw's dash can never leak into the next. The AA fringe (`u_aa`) is
|
||||
/// likewise set on every flush, from `style().line_aa_px` in device pixels.
|
||||
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}) noexcept;
|
||||
/// Draws every staged glyph in `style().axis_text` and empties the batch. Text
|
||||
/// is texture-bound, so this runs once per frame, after all line geometry.
|
||||
void flush_text() noexcept;
|
||||
/// Draws every staged glyph in `batch` -- each instance carries its own tint
|
||||
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
||||
/// once per text batch, after the line geometry it belongs over.
|
||||
void flush_text(render::TextBatch& batch) noexcept;
|
||||
|
||||
/// Narrows the frame's clip to `rect` -- framebuffer pixels, top-left origin, like every
|
||||
/// `Rect` here -- and *intersects* it with the frame's viewport, so the clip can only
|
||||
@@ -408,13 +434,20 @@ class Renderer {
|
||||
/// measurement band fills, is the wrong primitive here). A rect with no width or
|
||||
/// no height stages nothing.
|
||||
void fill_pixels(const Rect& rect, Color color);
|
||||
/// Stages one data-space segment, taking the two axes' log flags into account.
|
||||
void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
|
||||
/// Stages `n` interleaved `x,y` data-space points (a gathered window).
|
||||
void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y);
|
||||
/// Lays one run of text out at a framebuffer position and stages it, shifting
|
||||
/// it into viewport space. Returns the pen past the run.
|
||||
Vec2 stage_text(Vec2 pos_px, const char* utf8);
|
||||
/// Stages one data-space segment as f32 values **relative to `origin_x`** in
|
||||
/// the axis' pre-affine space (`log10` of it on a log axis): the mapping folds
|
||||
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
|
||||
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
|
||||
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
|
||||
/// Stages `n` interleaved `x,y` points of a gathered window (see
|
||||
/// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The
|
||||
/// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as
|
||||
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
|
||||
void stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, bool log_y);
|
||||
/// Lays one run of text out at a framebuffer position and stages it into
|
||||
/// `batch`, shifting it into viewport space and tinting it `color` (the
|
||||
/// per-instance `a_colour`). Returns the pen past the run.
|
||||
Vec2 stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color);
|
||||
/// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at
|
||||
/// the size the text pipeline draws with: exactly what `stage_text_clipped()`
|
||||
/// stages.
|
||||
@@ -430,7 +463,8 @@ class Renderer {
|
||||
/// horizontal x title, which spends the strip's width, and (0,-1) for the y
|
||||
/// title, which reads bottom-to-top and spends the strip's height. `pos_px` is
|
||||
/// the pen -- for the rotated run, the bottom-left corner of its first cell.
|
||||
f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis);
|
||||
f32 stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8, f32 budget_px,
|
||||
Vec2 axis, Color color);
|
||||
|
||||
/// Pins the text pipeline for a frame: the plot layer's backend at the size the
|
||||
/// style asks for, the cell every staged glyph is drawn in, and the atlas the
|
||||
@@ -531,6 +565,12 @@ class Renderer {
|
||||
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
|
||||
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
|
||||
|
||||
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
|
||||
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
|
||||
/// pixel space, at `size_px` half-extent, so a marker's size is device pixels
|
||||
/// whatever the data scale is.
|
||||
void stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px);
|
||||
|
||||
/// The built-in backend the renderer owns, and the face the frames draw with:
|
||||
/// `set_text_backend()` writes `font_`, which starts here and comes back here
|
||||
/// when a host passes null. Every use of the face goes through the
|
||||
@@ -549,8 +589,16 @@ class Renderer {
|
||||
render::TextVertexArray text_vao_;
|
||||
|
||||
render::LineBatch lines_;
|
||||
render::TextBatch glyphs_;
|
||||
/// The frame's two text batches (B6): `glyphs_under_` collects the text the
|
||||
/// crosshair readout's panel may legitimately occlude (legend names, cursor
|
||||
/// labels, measure labels, annotation captions) and is flushed immediately
|
||||
/// before `draw_crosshair_readout()`; `glyphs_over_` collects what must sit
|
||||
/// over everything (readout text, tick labels, titles) and is flushed at
|
||||
/// `end_frame()`. One batch per z-order, one draw call each.
|
||||
render::TextBatch glyphs_under_;
|
||||
render::TextBatch glyphs_over_;
|
||||
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
||||
std::vector<f32> staged_; ///< scratch for one staged window, re-used
|
||||
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
||||
|
||||
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
|
||||
@@ -559,6 +607,16 @@ class Renderer {
|
||||
Color host_clear_{};
|
||||
bool host_clear_saved_ = false;
|
||||
|
||||
/// The host's blend state, saved by `begin_frame()` and handed back by
|
||||
/// `end_frame()` (R6): a frame sets blending for its straight-alpha pipelines
|
||||
/// and must not leave its function -- or the enable bit -- behind for the
|
||||
/// host's own draws to inherit.
|
||||
gl::GLint blend_src_rgb_ = gl::GL_ONE;
|
||||
gl::GLint blend_src_a_ = gl::GL_ONE;
|
||||
gl::GLint blend_dst_rgb_ = gl::GL_ZERO;
|
||||
gl::GLint blend_dst_a_ = gl::GL_ZERO;
|
||||
bool blend_was_on_ = false;
|
||||
|
||||
Rect viewport_; ///< the frame's region, top-left origin, framebuffer px
|
||||
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
|
||||
u32 fb_w_ = 0;
|
||||
|
||||
@@ -16,7 +16,9 @@ namespace plot::render {
|
||||
/// `v_along`, the distance travelled from `a_p0` in pixels.
|
||||
///
|
||||
/// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, pixels),
|
||||
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_color` (vec4).
|
||||
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_aa` (float, the AA
|
||||
/// fringe half-width in pixels -- the style's `line_aa_px` in device pixels,
|
||||
/// clamped at 0.25), `u_color` (vec4).
|
||||
/// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is
|
||||
/// flipped exactly once, here.
|
||||
///
|
||||
@@ -44,13 +46,14 @@ extern const char* const kLineFS;
|
||||
/// handedness, so a glyph's top row stays its top row however the cell is
|
||||
/// turned. The fragment shader converts back to texels and samples the
|
||||
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST`
|
||||
/// never lands on a cell boundary. `u_color` tints the straight alpha.
|
||||
/// never lands on a cell boundary. The instance's own `a_colour` tints the
|
||||
/// straight alpha -- per vertex, so one draw can carry runs of several colours.
|
||||
///
|
||||
/// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, the glyph cell's
|
||||
/// size on screen in pixels), `u_glyph_uv` (vec2, the glyph cell's size in
|
||||
/// normalised atlas coordinates -- one size for the whole draw, since every
|
||||
/// glyph of a frame comes from one atlas), `u_tex_size` (vec2, atlas size in
|
||||
/// texels), `u_atlas` (sampler2D, unit 0), `u_color` (vec4).
|
||||
/// texels), `u_atlas` (sampler2D, unit 0).
|
||||
extern const char* const kTextVS;
|
||||
extern const char* const kTextFS;
|
||||
|
||||
|
||||
@@ -102,6 +102,12 @@ struct Style {
|
||||
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
||||
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
|
||||
///< (axis lines, cursor markers).
|
||||
/// Logical half-width of the anti-aliased fringe the line shader blends at
|
||||
/// every stroke edge (the smoothstep ramp runs over `|dist| in [w - aa,
|
||||
/// w + aa]`). Clamped to at least 0.25 device px at use: below that the
|
||||
/// ramp is thinner than a fragment and edges alias again. 1.0 is the value
|
||||
/// every default was chosen on.
|
||||
f32 line_aa_px = 1.0f;
|
||||
/// Logical width of a *draggable handle* drawn highlighted: the tab a hovered or
|
||||
/// grabbed cursor marker is widened to, and the tab a region's hot bound is drawn
|
||||
/// as -- the latter sized by `Plot::layout()` with this same metric, so the two
|
||||
|
||||
@@ -54,20 +54,21 @@ class LineVertexArray {
|
||||
gl::VertexArray vao_;
|
||||
u32 instances_ = 0;
|
||||
/// Instance-buffer generation the attribute pointers currently point into.
|
||||
u32 epoch_ = 0;
|
||||
u64 epoch_ = 0;
|
||||
};
|
||||
|
||||
/// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0
|
||||
/// (the cell's top-left corner in pixels), the glyph's atlas rect `a_uv` at
|
||||
/// location 1 and its rotation basis `a_axis` at location 2, two floats each,
|
||||
/// every one advancing once per instance. The layout is the one `TextInstance`
|
||||
/// location 1, its rotation basis `a_axis` at location 2 and the run's tint
|
||||
/// `a_colour` at location 3 (`vec2`, `vec2`, `vec2`, `vec4` floats), each
|
||||
/// advancing once per instance. The layout is the one `TextInstance`
|
||||
/// declares and shaders.hpp consumes; a caller never writes a location, stride or
|
||||
/// offset, it stages a `TextBatch` and uploads its `data()`.
|
||||
class TextVertexArray {
|
||||
public:
|
||||
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept;
|
||||
|
||||
/// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay` floats.
|
||||
/// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay,r,g,b,a` floats.
|
||||
void upload(const f32* interleaved, u32 glyphs) noexcept;
|
||||
|
||||
void bind() const noexcept { vao_.bind(); }
|
||||
@@ -84,7 +85,7 @@ class TextVertexArray {
|
||||
TextBuffer buffer_;
|
||||
gl::VertexArray vao_;
|
||||
u32 instances_ = 0;
|
||||
u32 epoch_ = 0;
|
||||
u64 epoch_ = 0;
|
||||
};
|
||||
|
||||
} // namespace plot::render
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// Template for <plot/version.hpp>: configure_file() expands it into
|
||||
// <build>/include/plot/version.hpp at configure time, and that generated file
|
||||
// -- not this one -- is a public header. The umbrella names it, and the
|
||||
// install rules place it under include/plot/ with the rest of the surface, so
|
||||
// a consumer can ask what it compiled against: the macros for a preprocessor
|
||||
// test, the plot::version constants for everything else. Both come from the
|
||||
// project() version, so there is exactly one place a release edits.
|
||||
|
||||
#pragma once
|
||||
|
||||
#define PLOT_VERSION_MAJOR @plot_VERSION_MAJOR@
|
||||
#define PLOT_VERSION_MINOR @plot_VERSION_MINOR@
|
||||
#define PLOT_VERSION_PATCH @plot_VERSION_PATCH@
|
||||
#define PLOT_VERSION_STRING "@plot_VERSION@"
|
||||
|
||||
namespace plot::version {
|
||||
|
||||
/// The library version: one component per constant, and the whole thing as the
|
||||
/// string the project() declares (e.g. "0.1.0").
|
||||
inline constexpr int major = PLOT_VERSION_MAJOR;
|
||||
inline constexpr int minor = PLOT_VERSION_MINOR;
|
||||
inline constexpr int patch = PLOT_VERSION_PATCH;
|
||||
inline constexpr const char* string = PLOT_VERSION_STRING;
|
||||
|
||||
} // namespace plot::version
|
||||
Reference in new issue
Block a user