feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

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:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+75 -17
View File
@@ -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;