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.
18 KiB
// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari
Hardening and features v0.2 — shared contracts
Design record for the bug-fix + robustness + feature wave. This file is the interface bible for the five parallel streams. File ownership is exclusive: a stream edits only the files listed under it. Everything not listed is the integrator's.
Bug list (B1–B7) and robustness list (R1–R9) being fixed
- B1 f32 staging of large-magnitude x destroys geometry (epoch axes).
- B2 tick major/minor classification ULP-fragile (
ticks.cpp:176). - B3 LOD gather emits buckets straddling
[begin,end)(series.cpp:168). - B4
XFollow::Liveon log x holds linear span (plot.cpp:494,figure.cpp). - B5 zero-notch wheel disarms auto_scale;
factor==infunguarded (controller.cpp:158,glfwforwards dy=0). - B6 single text flush paints legend text over the crosshair readout panel.
- B7 NaN policy incoherent (min/max drop unless seeding; sum propagates).
- R1
std::clampUB on negative viewport (plot.cpp:886). - R2
set_x_range/set_y_rangestore non-finite verbatim (plot.cpp:377-384). - R3
kBucket << levelUB for level >= 58 via publicgather. - R4 ring capacity rounds up but docs/reserve assume raw capacity.
- R5 GLFW figure capture cleared by any button release.
- R6 renderer residue: blend func, UNPACK_ALIGNMENT, no GL shutdown, epoch() wrap, annotation caption overflow.
- R7 GLFW host re-lays out the figure on every event.
- R8 measure accepts horizontal rulers, silently measures [0,0].
- R9 minor-tick cap truncates the far end of the axis, majors included.
Cross-cutting decisions
- NaN policy (library-wide, documented): a non-finite sample is excluded
from every aggregate (min, max, sum, sumsq) and from cached bounds. A window
with no finite samples aggregates to a zero-span Range / zero sums. Applies
in
MinMaxPyramid::append,Series::reduce/sum/sumsqedge folds,ArraySeries::commit,StreamSeries::acquirebounds.docs/data-and-performance.mdgets a "Non-finite samples" paragraph. - Gather x contract (breaking, B1):
Series::gathernow emits x relative tox0()(exactlyi*dxfor uniform series,x_at(i)-x0()for XY), never absolute x. y is unchanged. The renderer reconstructs absolute data x in f64 (x0() + rel) before transforming, so no precision is lost at the f32 narrowing: the staged f32 isto_space(x_abs) - to_space(origin), which is O(visible pixels), not O(epoch). - Live follow on log axes (B4):
Axis::offsetand the live-window span live in axis space (log10whenlog), matching how the controller's gestures already work (to_space/from_space).place_live_x_windowand the figure link-group copies hold the log-space span constant. - Key-repeat policy (library-wide): a held key repeats in every adapter
(GLFW/Qt/GTK already do; ImGui switches to
repeat=true). - Modifiers for box zoom: Shift+Left = both axes (unchanged, default); Ctrl+Left = x-only; Alt+Left = y-only. Other modifier combos = both.
Shared-header changes (made by the integrator before the fan-out)
include/plot/input/events.hppPointerEventgainsf64 time_s = 0.0;(host-supplied monotonic seconds; powers double-click). Hosts that leave 0 simply never double-click.- New
GestureEvent { f32 x, y; f32 scale = 1.0f; f32 dx = 0.0f, dy = 0.0f; }— pinch: zoom about (x, y) byscale, pan by (dx, dy).Controllergainsvoid on_gesture(const GestureEvent&). - New key constants:
kKeyEscape = 27,kKeyLeft = 256,kKeyRight = 257,kKeyUp = 258,kKeyDown = 259(host-translated, like the ASCII ones).
include/plot/data/series.hppenum class GatherMode { Envelope, Nearest, Mean };— downsample policy.Envelope= today's min/max pairs.Nearest= one raw sample per bucket (viasnap), x at the sample.Mean= one point per bucket, y = mean (bucketsum/ count), x at bucket centre.virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, GatherMode mode = GatherMode::Envelope) const— signature change; every override updates; renderer passes the mode explicitly.- New virtuals (uniform default, XY override):
virtual f64 x_at(u64 i) const(x0() + i*dx()),virtual bool neighbours(f64 x, u64& i0, u64& i1) const(false when x is outside[x_at(0), x_at(n-1)]; else the bracketing indices),virtual u64 snap_index(f64 x) const(nearest index, clamped). MinMaxPyramidgainsf64 bucket_sum(u32 l, u64 i) constandf64 bucket_sumsq(u32 l, u64 i) const(Mean/RMS need them).
include/plot/plot/trace.hpp—Trace/TraceDescgain (trailing, defaults keep aggregate init source-compatible):GatherMode mode = GatherMode::Envelope;f32 dash_on = 0.0f, dash_off = 0.0f;(logical px, 0 = solid),enum class Marker { None, Cross, Circle, Square },Marker marker = Marker::None;f32 marker_size_px = 3.0f; u32 marker_every = 0;(0 = auto: strideceil(samples_per_pixel), so markers only appear where samples are sparse).width == 0draws markers only (scatter) — documented.include/plot/render/style.hpp— gainsf32 line_aa_px = 1.0f;(logical, the AA fringe half-width, clamped >= 0.25f at use). No text-colour fields: text colour becomes a per-call parameter (see C).include/plot/plot/plot.hpp—set_viewportnormalises negative w/h to 0 (R1).DROPPED:include/plot/plot/crosshair.hpp—CrosshairRowgainsColor colorCrosshairRowalready names its trace; the renderer resolves the colour asplot.trace(row.trace).colorat draw time. No field added.
Stream A — data layer (exclusive)
Files: include/plot/data/*, src/data/*, tests/test_series.cpp,
tests/test_ring_buffer.cpp, tests/test_minmax_pyramid.cpp,
tests/test_xy_series.cpp (new), plus the Series::gather override
forwarders and docs/data-and-performance.md NaN/policy paragraphs.
- B3: bucket-edge selection uses
first = max(ceil_div(origin+b, span), lbase),last = min((origin+e)/span, lend); lead/trail raw spans cover the rest. Regression: gather at unalignedbegin/endmid-window asserts no emitted x outside[begin, end)and envelope values equal exact reduce over the range. - B7: NaN policy per above; regression: NaN at first/middle/last position — bounds and reduce stay finite, sum skips NaN.
- R3:
level_spancaps at level 57 (span 2^63);gather(level > 57)clamps; no UB/div-zero for any u32 level. - R4: docs say "at most
capacity()rounded up to a power of two";StreamSeriesreserves the roundedring_.capacity(). - GatherMode implementation in
gather_impl(Mean/Nearest via the new pyramid accessors);XY/Array/Streamforwarders updated; relative-x contract implemented everywhere (x emitted asx_at(i) - x0()). - XY series (full feature):
include/plot/data/xy_series.hpp,src/data/xy_series.cpp, targetplot_core. Sorted-ascending x (documented precondition,assertin debug); binary-searchneighbours/snap_index; pyramid over sample index;commit()rebuilds. - Snapshot:
ArraySeries StreamSeries::freeze() const— consumer-thread only; copies the retained window (copy_raw) into a fresh ArraySeries with this window'sx0()/dx(). Freezes against the last acquire (documented). - Incremental append:
void ArraySeries::push(f32)— appends, folds into the pyramid (append), extends bounds incrementally (NaN-aware), extendsx_bounds_.commit()stays the wholesale path. Acceptance: new tests for every item above;plot_testsbuilds and passes.
Stream B — plot + input (exclusive)
Files: src/plot/ticks.cpp, src/plot/plot.cpp, src/plot/figure.cpp,
src/input/controller.cpp, include/plot/input/controller.hpp,
include/plot/plot/axis.hpp (offset doc), tests/test_ticks.cpp,
tests/test_controller.cpp, tests/test_plot.cpp, tests/test_figure.cpp,
tests/test_measure.cpp, and the plot-layer consumers of the new Series
virtuals (crosshair_value, crosshair_span, covered_samples,
measurement — route through x_at/neighbours/snap_index so an XY series
crosshairs and measures correctly).
- B2: major = integer-index test (
k % ratio == 0on the minor index,kderived from(v - first_major)/minor_steprounded); no float tolerance. Regression: epoch range 1.75e9 ± 300 s at 800 px produces labelled majors. - R9: majors are never truncated; only minors count against
kMaxTicks. - B4: live window placement in axis space (log-aware span/offset) in
Plot::place_live_x_window,Plot::set_x_rangelive branch, and the two figure link-group copies. Regression: streaming log axis holds visible decades constant across samples; scrolled-back window keeps span. - B5:
on_wheel—dy == 0 && dx == 0returns;dy == 0, dx != 0pans x only (dxnotches, 5% of the x span per notch, log-aware viato_space); zoom path rejectsfactor == 1and non-finite. Same guard in the middle-dragaxis_zoom. - R1:
layout()clamp guardshi >= lobefore clamping (no UB even for a hostile viewport;set_viewportnormalises, already done by integrator). - R2: range setters refuse non-finite endpoints (Off branch + y), keep the
previous range and
auto_scale. - R8:
add_measure/measurementrefuse horizontal-ruler (and vertical-ruler dead-field) cursors —validstays false,add_measurereturns an id that reports invalid. - Interaction features:
- Keyboard pan: Left/Right/Up/Down pan 10% of the span per press/repeat (x-only for Left/Right, y-only for Up/Down; log-aware).
- Escape (
kKeyEscape): cancels the active drag — restores the ranges the axes had atbegin_drag(captured then), drops box-zoom without applying, clears grab state. - Box-zoom masks per the modifier table above; applied per-axis at release.
- Double-click reset: two presses of the same button within 0.35 s and 4 px
(uses
PointerEvent::time_s) re-fits every axis (auto_scale = true+ fit). Second press does not begin a drag. Acceptance: controller tests for each gesture incl. cancel-restore; tick regressions; XY crosshair/measure unit tests via anXYfixture.
Stream C — render (exclusive)
Files: src/render/*, include/plot/render/* (renderer/primitives/text/buffer
interfaces), src/gl/handle.cpp, include/plot/gl/handle.hpp,
tests/test_render_smoke.cpp, tests/test_primitives.cpp.
- B1: implement the relative-x staging contract (see Cross-cutting):
trace staging reconstructs
x_abs = series.x0() + relin f64, stagesf32(to_space(x_abs) - to_space(origin)); every data-space line draw (grid, cursors, regions, measures, crosshair, annotations) picks a natural origin (its own reference data x/y) andmapping()foldsorigininto the offset uniform in f64 before the single narrowing. Regression: EGL smoke renders an epoch-window series and a zero-origin twin and asserts both rasterise to the same pixel column set (tolerance). - B6: two text batches —
text_under_(legend names, cursor labels, measure labels, annotation captions) flushed immediately beforedraw_crosshair_readout;text_over_(readout text, tick labels, titles) flushed atend_frame. The readout panel occludes under-batch text. Regression: legend + overlapping readout frame — legend name pixels are not lit inside the readout panel rect. - R6:
begin_framesavesGL_BLEND_SRC/_DSTviaglGetIntegervandend_framerestores function + enable bit;Texture::upload_rgbasaves/restoresGL_UNPACK_ALIGNMENT;Renderer::release_gl()(public) destroys all GL handles explicitly, called first by the destructor (safe when the context is current) and documented for host teardown order;epoch()widened to u64 end-to-end (buffer generation + slot epoch + VAO cached epoch); annotation captions clamp their box insideaxes(skip the caption when it cannot fit). - Dash: trace dash reaches
u_dashper trace flush (device px, logical converted); legend swatch samples the dash.dash_off == 0= solid. - Markers: staged as line segments through the existing batch
(Cross 2 segs, Circle 8-gon, Square 4 segs), colour = trace colour, size =
device(marker_size_px), stridemarker_everyor autoceil(spp);width == 0draws markers only. Render-smoke regression: a marker'd trace lights vertices a plain line does not, and amarker_every = 3trace stages 1/3 the marker count. - AA knob:
u_aauniform =max(0.25, device(style.line_aa_px)); shaders drop the hardcoded fringe. Default output unchanged (1.0 logical). - Text colour: per-vertex
a_colour(vec4);stage_text(..., Color);flush_textstops settingu_color; crosshair readout value rows draw in their row's trace colour (CrosshairRow::color), labels inaxis_text. Smoke regression: a coloured readout row differs from the axis-text pass. - GatherMode: renderer passes
trace.modetogather. Acceptance: smoke tests above; full suite green; no uniform/attribute drift (cross-check against GLSL in the same PR).
Stream D — adapters (exclusive)
Files: adapters/** only, tests/test_glfw_host.cpp, tests/test_qt_pointer.cpp,
tests/test_gtk_input.cpp, tests/test_imgui_widget.cpp,
docs/embedding.md (shared conventions: key repeat, touch parity, DPR note).
- R5: GLFW figure capture records the button; only the owning button's release ends it; a second button does not steal or clear the capture (documented single-gesture policy). Test: press left row0, press right row1, release right — left drag still routes to row0.
- R7:
Hostcaches row rects; invalidated on resize +set_figure, refreshed once per frame; event handlers read the cache (one-frame-stale rows during resize — documented). - Key repeat: ImGui
IsKeyPressed(..., repeat=true); policy comment. - Timestamps: all four adapters fill
PointerEvent::time_s(glfwGetTime / QEvent timestamps /1000 / gdk time /1000 / ImGui::GetTime). - New keys: GLFW maps arrows + Escape to the new constants (with
GLFW_KEY_LEFTetc.); Qt mapsKey_Left...; GTK mapsGDK_KEY_Left...; ImGuiImGuiKey_LeftArrow...,ImGuiKey_Escape. - Qt:
wheelEventusespixelDelta()/40notches whenangleDeltais 0 (pixelDelta path);DevicePixelRatioChangerefreshes the controller viewport + scales; pinch viagrabGesture(Qt::PinchGesture)->GestureEvent. - GTK4:
GtkGestureZoomattached in the plot area factory ->GestureEvent(scale + focal delta); scrollScrollUnit::Surfacepath already exists — keep. - GLFW:
on_scrollforwards unchanged (controller owns the dy=0 case). Acceptance: adapter-gated tests updated; toolkit-free seams (qt_pointer, gtk_input) carry the new logic so headless tests cover it.
Stream E — build, packaging, docs, CI (exclusive)
Files: root CMakeLists.txt, cmake/*, CMakePresets.json,
.github/workflows/ci.yml (new), plot.pc.in (new),
docs/building-and-testing.md, docs/getting-started.md, README.md
(feature list + versioning), tests/test_series.cpp (delete the wall-clock
steady-state case only), readme_main.cpp (new, referenced by README).
GNUInstallDirsfor every install path;VERSION/SOVERSIONonplot_corewhen shared;SameMinorVersioncompatibility (0.x reality); generatedinclude/plot/version.hpp(configure_file, installed, added to the umbrella + exemption handling as needed);plot.pcpkg-config file; installLICENSE+licences/OFL-1.1.txtunder${CMAKE_INSTALL_DATADIR}/licenses/plot/;install(DIRECTORY)getsFILES_MATCHING PATTERN "*.hpp".PLOT_BUILD_TTF_FONTmoves tocmake/plot_options.cmake(docs match); coverage-flag detection switches to aPLOT_COVERAGEoption check on the config profile, not aCMAKE_CXX_FLAGSstring match.- CI:
.github/workflows/ci.yml— ubuntu-latest, apt deps (libglfw3-dev, qt6-base-dev, libgtk-4-dev, libegl1, libglvnd-dev, xvfb, lcov, python3-fonttools), jobs: debug (configure/build/xvfb ctest), coverage (tools/coverage.sh, uploads the table), asan (linux-asan preset).continue-on-erroron the ImGui fetch is not needed (network is available on GH runners). - Docs: correct the case-count math (recount from the tree at integration
time), remove the stale hard-coded coverage figures from README and
building-and-testing (coverage.md stays the single source, regenerated),
document the new keys/gestures/touch parity in
interaction.md+embedding.md(note: B/C own feature code; E only documents what the contract above specifies). Acceptance:cmake --installsmoke into a temp prefix findsplotviafind_packagein a throwaway consumer; umbrella tests stay green; suite counts match docs.
Integration (integrator only)
Merge all streams; reconcile include/plot/plot.hpp umbrella; full
configure+build+ctest; regenerate coverage via tools/coverage.sh; update the
per-file coverage table and README numbers from the fresh run; run the GLFW
demo headless under Xvfb as the final smoke; update this design record's
status.
Status: COMPLETE (2026-09-30)
All five streams landed and were integrated on main (uncommitted working
tree). Controller::on_gesture was implemented by the integrator (Stream B was
cut off before it). Incident: the root filesystem hit 100% mid-wave and
truncated src/render/renderer.cpp to 0 bytes; Stream C restored it from its
transcript and re-verified. Final state:
linux-debugsuite: 350 cases, 0 failed (was 283); 4/4 ctest entries pass.- Coverage: 93.9% aggregate (3730/3971) over 24 files, every file >= 80%
(floor 80/90) — regenerated in
docs/coverage.mdbytools/coverage.sh. - Headless smoke:
plot_glfw_demo+plot_multiplot_demounderxvfb-run, exit 0, screenshots contain rendered ink (65.4% / 20.0% non-background at 1280x720); the multiplot run reports 20,901 staged GPU segments for ~4.5M visible samples, GL error 0 (README paragraph updated). - Known follow-ups: real touch hardware has never exercised the Qt/GTK pinch paths; macOS/Windows remain untested; CI YAML is written but has run only locally in structure (no GitHub remote execution during this wave).