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

+31 -13
View File
@@ -41,9 +41,27 @@ ctest --test-dir build/debug --output-on-failure
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
`plot_umbrella_check`. The `plot_tests` binary itself runs **283 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `283 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`.
`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
## Install layout
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
variables (`CMAKE_INSTALL_LIBDIR`, `CMAKE_INSTALL_INCLUDEDIR`, …) and their distribution
defaults — plain `lib`, `include` and `share` unless a packager overrides them:
| prefix-relative path | what |
|---|---|
| `lib/` | the `plot_core` archive (and `libplot_core.so` with its version and SONAME symlinks when built with `BUILD_SHARED_LIBS=ON`) |
| `include/plot/` | every public header, including the generated `plot/version.hpp` (`plot::version::major/minor/patch/string` and the `PLOT_VERSION_*` macros) |
| `lib/cmake/plot/` | the CMake package: `plotConfig.cmake`, `plotConfigVersion.cmake` (Compatibility **SameMinorVersion**, so `find_package(plot 0.1 …)` accepts 0.1.x only), `plotTargets.cmake` |
| `lib/pkgconfig/plot.pc` | pkg-config metadata for consumers whose build is not CMake |
| `share/licenses/plot/` | `LICENSE` (MIT) and `licences/OFL-1.1.txt` (the embedded font) |
Adapters that were built and exported install themselves alongside the core (their headers
under `include/<adapter>/`, their targets in the same package).
## Headless demo modes
@@ -95,15 +113,15 @@ The floors are enforced by `tools/coverage_floor.py`:
| every file under `src/` | **80%** of its instrumented lines |
| `src/` as a whole | **90%** of instrumented lines |
Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`,
re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate
95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied.
The method, the full per-file table and the reproducibility notes are in
[docs/coverage.md](coverage.md); re-run the command for the live numbers.
Branch coverage is deliberately off. This page carries no measured totals — a number written
here would be stale the moment `src/` moved, and the report is reproducible: the method, the
per-file table and the floor verdict live in [docs/coverage.md](coverage.md), which
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
live numbers.
**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11
that are missing are the adapter cases that need an adapter target to exist — 8 in
`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (2 cases)
and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures
**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17
that are missing are the adapter cases that need an adapter target to exist — 11 in
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
and `test_gtk_input.cpp` (11 cases) compile without Qt or GTK. So the coverage number measures
the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
+15 -14
View File
@@ -43,35 +43,36 @@ when one is missing.
<!-- BEGIN GENERATED: tools/coverage.sh -->
```
coverage_floor: ok -- 23 files under src/, aggregate 95.2% lines (3302/3469), every file at or above 80%, aggregate target 90%
coverage_floor: ok -- 24 files under src/, aggregate 93.9% lines (3730/3971), every file at or above 80%, aggregate target 90%
```
| File | Lines | Line rate | Functions |
| --- | ---: | ---: | ---: |
| `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% |
| `src/data/series.cpp` | 105/107 | 98.1% | 100.0% |
| `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% |
| `src/gl/handle.cpp` | 256/256 | 100.0% | 100.0% |
| `src/data/array_series.cpp` | 56/56 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 170/171 | 99.4% | 96.4% |
| `src/data/series.cpp` | 161/164 | 98.2% | 100.0% |
| `src/data/stream_series.cpp` | 45/45 | 100.0% | 100.0% |
| `src/data/xy_series.cpp` | 102/120 | 85.0% | 100.0% |
| `src/gl/handle.cpp` | 259/259 | 100.0% | 100.0% |
| `src/gl/loader.cpp` | 81/81 | 100.0% | 100.0% |
| `src/input/controller.cpp` | 329/343 | 95.9% | 100.0% |
| `src/input/controller.cpp` | 430/465 | 92.5% | 97.4% |
| `src/math/transform.cpp` | 28/28 | 100.0% | 100.0% |
| `src/plot/axis.cpp` | 12/12 | 100.0% | 100.0% |
| `src/plot/figure.cpp` | 218/223 | 97.8% | 95.5% |
| `src/plot/figure.cpp` | 229/234 | 97.9% | 95.8% |
| `src/plot/grid.cpp` | 15/16 | 93.8% | 100.0% |
| `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% |
| `src/plot/plot.cpp` | 743/763 | 97.4% | 98.9% |
| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% |
| `src/plot/plot.cpp` | 767/793 | 96.7% | 98.9% |
| `src/plot/ticks.cpp` | 105/111 | 94.6% | 100.0% |
| `src/render/buffer.cpp` | 88/98 | 89.8% | 100.0% |
| `src/render/embedded_mono_font.cpp` | 45/45 | 100.0% | 100.0% |
| `src/render/primitives.cpp` | 71/75 | 94.7% | 100.0% |
| `src/render/primitives.cpp` | 75/79 | 94.9% | 100.0% |
| `src/render/program.cpp` | 26/27 | 96.3% | 100.0% |
| `src/render/renderer.cpp` | 530/609 | 87.0% | 93.7% |
| `src/render/renderer.cpp` | 616/722 | 85.3% | 93.9% |
| `src/render/text.cpp` | 27/30 | 90.0% | 100.0% |
| `src/render/text_atlas.cpp` | 40/41 | 97.6% | 100.0% |
| `src/render/vertex_array.cpp` | 59/59 | 100.0% | 100.0% |
| `src/render/vertex_array.cpp` | 62/62 | 100.0% | 100.0% |
| `src/text/true_type_font.cpp` | 270/291 | 92.8% | 96.8% |
| **src/ aggregate** | **3302/3469** | **95.2%** | **98.3%** |
| **src/ aggregate** | **3730/3971** | **93.9%** | **98.0%** |
<!-- END GENERATED: tools/coverage.sh -->
+74 -12
View File
@@ -2,20 +2,27 @@
## The sample stores
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes the sample-indexing contract; a **uniform-grid** series additionally has
sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while
**`XYSeries`** carries its own sorted, ascending x array for data that is not on a
uniform grid.
```cpp
class Series {
public:
virtual u64 size() const;
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
virtual f64 dx() const;
virtual f64 dx() const; // 0 for XYSeries, which owns its x array
virtual Range x_bounds() const;
virtual Range y_bounds() const;
virtual const MinMaxPyramid& pyramid() const;
virtual bool streaming() const noexcept; // default false
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i]
virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples
virtual u64 snap_index(f64 x) const; // nearest sample
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const;
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
@@ -29,11 +36,65 @@ Indices are the series' **own window**: index 0 is the oldest sample it currentl
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
is taken by value and owned, so the samples cannot change behind the series' back; the store
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild) or `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`.
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild), `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which
appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds
extend in place, NaN-aware, instead of rebuilding.
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`.
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `capacity` is rounded up to a power of
two by the ring, `size()` saturates at that rounded `capacity()` (a `StreamSeries(100)`
retains up to 128 samples), and the pyramid is reserved for the rounded value up front.
`freeze()` copies the window published by the last `acquire()` into a fresh `ArraySeries`
(consumer thread only): a stable snapshot that no later production can move.
## Non-finite samples
The library-wide NaN policy: **a non-finite sample is excluded from every aggregate** —
min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the
cached bounds. A window with no finite samples at all aggregates to a **zero-span
`Range` / zero sums**, never to NaN. The policy is applied everywhere an aggregate is
built: `MinMaxPyramid::append` (a NaN occupies its absolute position, so bucket
boundaries stay positional, but folds nothing), the edge folds in `Series::reduce`,
`sum` and `sumsq`, `ArraySeries::commit` and `push` bounds, and
`StreamSeries::acquire` bounds. A bucket whose samples were all non-finite is stored as
an *empty* aggregate (count 0, zero sums) that every reader skips — it is absent, not
zero-valued. `gather()` follows suit: Envelope and Mean emit nothing for such a bucket,
and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw
them with NaN samples and will get exactly that: excluded from statistics, present in
raw geometry.
## Gather x is relative, and what each mode emits
**`Series::gather` emits x relative to `x0()`** — exactly `i * dx` for a uniform
series, `x_at(i) - x0()` for `XYSeries` — never the absolute coordinate. y is
unchanged. The renderer reconstructs absolute data x in f64 (`x0() + emitted`) before
transforming, so no precision is lost at the f32 narrowing: the staged value is
O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it
to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the
relative value is small and exact.) The emitted geometry always spans
`[begin, end)` exactly and never reaches outside it: at level ≥ 1 the bucket edges are
selected ceil/floor (`first` = first bucket starting at or after `begin`, `last` = one
past the last bucket ending at or before `end`) and the two partial edge spans are
drawn from raw samples, which is what keeps an unaligned window envelope-exact
(previously the buckets straddling the range leaked samples from outside it into the
geometry). `level` is clamped to `kMaxPyramidLevel` (57 — the deepest level whose
bucket span fits a `u64`), so any `u32` level is safe.
The `GatherMode` decides what each bucket contributes, at the same level the renderer
already picked:
- **`Envelope`** (the default): one (min, max) pair per bucket, min drawn at the
bucket's first sample and max at its last. The dense, extreme-reading geometry a
time-series trace wants.
- **`Nearest`**: one raw sample per bucket — the centre sample, the pick
`pyramid().snap()` would make — drawn at its own x. Thinner geometry for traces
whose *shape* matters more than their extremes.
- **`Mean`**: one point per bucket, y = the bucket's finite samples' mean (the exact
`bucket_sum()` the pyramid stores divided by its finite count), x at the bucket
centre. A smoothed trace for noisy data.
Level 0 is the raw samples for every mode.
## The SPSC contract
@@ -82,11 +143,12 @@ the visible pixels rather than on the sample count.
and, when it completes, pushes it and walks up, stopping at the first level that gains no
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
edge, bounding the memory by the retained window rather than by everything ever appended.
- An 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 — roughly 19% of the `4n` bytes of raw samples** (`f32`). 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.
- An 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 — roughly 25% of the
`4n` bytes of raw samples** (`f32`). 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.
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
+98 -13
View File
@@ -41,6 +41,48 @@ scale:
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
**Keys, key repeat, timestamps and touch parity.** The controller's keys and gestures
arrive through the adapters translated from each toolkit's own events, and four
conventions hold across all four hosts:
* **The key set is the same everywhere**: `+`/`-` zoom (with the `=`/`_` spellings a
shifted layout produces), `f`/`F` re-fits, the four arrows pan (10% of the span per
press), and Escape cancels the active drag. Each host translates its native key codes
to `input::kKey*` before calling the controller.
* **A held key repeats in every adapter.** GLFW, Qt and GTK deliver the OS auto-repeat
as extra press events and forward each one. ImGui edge-detects a press exactly once,
so its widget asks `IsKeyPressed(..., repeat = true)` for every key it maps and
ImGui's own repeat timer supplies what the OS would have.
* **`PointerEvent::time_s` is the host's monotonic clock, in seconds.** Only
differences are read: the double-click (two presses of the same button within 0.35 s
and 4 px re-fits every axis) compares two stamps, never an epoch. A host that leaves
the field 0 never produces a double-click, by contract.
| host | key repeat | `time_s` source | touch / pinch |
|---|---|---|---|
| GLFW | the OS auto-repeat, forwarded as repeated presses | `glfwGetTime()` | none — GLFW has no touch input |
| ImGui | `IsKeyPressed(..., repeat = true)` on every mapped key | `ImGui::GetTime()` | none — no touch input to forward |
| Qt6 | Qt repeats a held key as press events | `QInputEvent::timestamp()` / 1000 | `Qt::PinchGesture` → `GestureEvent`, folded by `PinchTracker` (`qt_pointer.hpp`) |
| GTK4 | GTK repeats a held key as press events | the GDK event's time / 1000 | `GtkGestureZoom` → `GestureEvent`, folded by `ZoomAnchor` (`gtk_input.hpp`) |
The two pinch folds are the same fold in two namespaces, and both live in
toolkit-free headers precisely so the headless tests can drive them: a toolkit
reports a pinch as **cumulative** values (total scale since the gesture began, the
focal point now), while `GestureEvent` wants the increments (this frame's zoom factor
about the focal point, 1.0 = no change, and the focal point's movement as `dx`/`dy`).
The folds also keep their focal anchor in *logical* pixels, like every other adapter
anchor, so a window dragged onto a display with another scale factor mid-gesture
cannot read the ratio change as a phantom pan.
**DPR / display changes.** The scale each host states to the renderer is re-read
whenever it can change, not latched: GLFW re-derives it in `refresh_sizes()` (every
frame, and from the resize callback), ImGui every frame from
`io.DisplayFramebufferScale`, Qt every `paintGL()` **and** on
`QEvent::DevicePixelRatioChange` — which also refreshes the controller's viewport
before any event queued behind the change maps a gesture through the old geometry —
and GTK in `::resize` through `DeviceMapping`. An input coordinate is always scaled by
the ratio in force at that event, never by one cached at startup.
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM).
This is how the project runs its own GUI demos in CI under Xvfb, with no display access
@@ -93,16 +135,39 @@ What `run()` does, in order:
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`.
reports no delta); `down` comes from `glfwGetMouseButton`, the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask, and
`time_s` is `glfwGetTime()`. Wheel notches are forwarded as `WheelEvent` unchanged
(zero deltas included — the controller owns that case), and keys as `KeyEvent`: the
arrows pan, Escape cancels the active drag, and neither closes the window (the window
manager's close button and Alt+F4 are the way to quit).
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads
in (`controller_at(x, y)`), and draw it with `Renderer::draw(Figure&)`. The host also spreads
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
pointer is in.
Routing has two rules worth knowing from the outside:
* **One gesture at a time (button-aware capture).** A press captures the row it landed
in *and the button that pressed*, and every motion and the release of *that button* go
to the captured row until it comes up — so a drag that crosses into a neighbouring row
still ends where it began. A second button pressed meanwhile (a chord) neither steals
the capture to another row nor ends it; only the owning button's release ends it. What
the second button's events do inside the controller is the controller's business; the
host guarantees they all arrive at the row the first press claimed.
* **Row rects are cached, refreshed once per frame.** The events never call
`Figure::layout()` — a non-const `layout()` recomputes, and a burst of pointer moves
would re-lay every row out per move. The run loop calls `refresh_row_rects()` once per
frame (right after the figure's viewport is set), `set_figure()` fills the cache
immediately so a scripted run routes against the figure it was just handed, and every
handler reads the cache. The consequence across a resize: GLFW delivers the resize and
any events queued behind it in one batch, so events before the resize see the rows the
previous frame drew — at most one frame stale — and the loop's next refresh brings the
cache onto the new geometry. A driver that injects events without running the loop
calls `refresh_row_rects()` itself; it is the scripted equivalent of the frame.
`run()` leaves the window open on success so a producer thread can be joined before the GL
resources go away; `close()` (or the destructor) releases the renderer, context and window
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
@@ -131,7 +196,11 @@ Because ImGui reports button *levels* and the controller wants *edges*, the widg
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
pressed over the child keeps being delivered until every button is up, so a drag that
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
treated as no position update (no movement, buttons still flow).
treated as no position update (no movement, buttons still flow). Every pointer event is
stamped with `ImGui::GetTime()`, which is what makes the controller's double-click
(re-fit) exist on this host; and every key the widget maps is asked with
`IsKeyPressed(..., repeat = true)`, because ImGui does not auto-repeat on its own —
the shared "a held key repeats" policy is opt-in here (see the conventions above).
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
@@ -168,12 +237,22 @@ viewport on the controller, runs the frame callback, sets `ui_scale` to
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`, each stamped with
`QInputEvent::timestamp()` in seconds. Every position is multiplied by
`devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
pixels, so a drag crossing onto a display with another scale factor pans by the distance
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no
vertical component is refused before it reaches the controller.
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). The wheel is
converted from eighths of a degree, 120 to a notch; when a device reports
`pixelDelta()` instead (and the angle deltas are both zero), 40 scroll pixels are taken
to be one notch — a calibration, not a conversion, the same role the GTK adapter's
`kSurfacePixelsPerNotch` plays. A wheel event with neither a vertical nor a horizontal
component is refused before it reaches the controller; anything with one is forwarded,
and the controller pans x by the horizontal component. The widget also grabs
`Qt::PinchGesture`: each pinch update is folded by `PinchTracker` (`qt_pointer.hpp`)
into an incremental `GestureEvent` — this frame's zoom about the focal point, the focal
point's movement as the pan — and a `QEvent::DevicePixelRatioChange` refreshes the
controller's viewport (and, one frame later at the latest, the `ui_scale`) before any
event queued behind it.
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
@@ -202,10 +281,16 @@ and the device size GTK reports, and refreshes the viewport; `::render` sets the
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies
`GtkGestureClick` supplies the press/release edges (stamped with the GDK event's time,
in seconds, like every pointer event this adapter forwards), `GtkGestureZoom` supplies
the two-finger pinch — each `scale-changed` folded by `ZoomAnchor` (`gtk_input.hpp`)
into an incremental `GestureEvent`: this frame's zoom about the fingers' bounding-box
centre, the centre's movement as the pan — `GtkEventControllerMotion` supplies
hover (forwarded with `down == false`, so a drag is not applied twice), and
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and
the keys (`+`/`-`/`f`, the arrows, Escape — the full shared key set, mapped in
`gtk_input.hpp`'s `key_from()` where the headless tests drive it). GTK emits
`drag-begin` before the click's `pressed`, so the adapter opens
its gesture from the first press and closes it from the first release. The area queues a
render after every input event that can change the picture (not on hover).
+26 -2
View File
@@ -51,6 +51,29 @@ directory as its public interface and nothing else. The exported adapters are na
`plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and
there is no ImGui package for an installed config file to point at.
The version you ask for follows the package's own compatibility rule, **SameMinorVersion**:
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses 0.2+, because at 0.x the minor
component is what moves when the surface breaks. What the surface *is* — the version this
header set was generated from — is in `<plot/version.hpp>`, installed with the other public
headers: `plot::version::major`, `::minor`, `::patch` and `::string` (plus the
`PLOT_VERSION_*` macros) for a compile-time answer, no `pkg-config --modversion` round-trip
needed:
```cpp
#include <plot/version.hpp>
static_assert(plot::version::major == 0, "plot 0.x only");
```
A build that is not CMake can use the installed pkg-config file instead:
```sh
pkg-config --cflags --libs plot # -I<includedir> -L<libdir> -lplot_core
```
Both grants travel with the install: `LICENSE` (MIT) and the embedded font's
`licences/OFL-1.1.txt` (SIL OFL 1.1) are under `share/licenses/plot/` in the prefix.
Every public component is reachable through the umbrella header:
```cpp
@@ -68,10 +91,11 @@ and draws one trace. It uses the GLFW toolkit for the window and `plot::core` di
it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the
adapter hides (window, context, loader, renderer, controller, frame loop).
It was compiled and linked as part of writing this page:
It was compiled and linked as part of writing this page (`-I build/debug/include` is where
CMake generates `plot/version.hpp`, which the umbrella pulls in):
```sh
g++ -std=c++20 -I include main.cpp build/debug/libplot_core.a \
g++ -std=c++20 -I include -I build/debug/include main.cpp build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
```
@@ -0,0 +1,305 @@
// 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::Live` on log x holds linear span (`plot.cpp:494`, `figure.cpp`).
- **B5** zero-notch wheel disarms auto_scale; `factor==inf` unguarded
(`controller.cpp:158`, `glfw` forwards 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::clamp` UB on negative viewport (`plot.cpp:886`).
- **R2** `set_x_range`/`set_y_range` store non-finite verbatim (`plot.cpp:377-384`).
- **R3** `kBucket << level` UB for level >= 58 via public `gather`.
- **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/sumsq` edge folds,
`ArraySeries::commit`, `StreamSeries::acquire` bounds. `docs/data-and-performance.md`
gets a "Non-finite samples" paragraph.
- **Gather x contract (breaking, B1):** `Series::gather` now emits x
**relative to `x0()`** (exactly `i*dx` for 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 is `to_space(x_abs) - to_space(origin)`,
which is O(visible pixels), not O(epoch).
- **Live follow on log axes (B4):** `Axis::offset` and the live-window span
live in **axis space** (`log10` when `log`), matching how the controller's
gestures already work (`to_space`/`from_space`). `place_live_x_window` and
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.hpp`
- `PointerEvent` gains `f64 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) by `scale`, pan by (dx, dy). `Controller`
gains `void 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.hpp`
- `enum class GatherMode { Envelope, Nearest, Mean };` — downsample policy.
`Envelope` = today's min/max pairs. `Nearest` = one raw sample per bucket
(via `snap`), x at the sample. `Mean` = one point per bucket, y = mean
(bucket `sum` / 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).
- `MinMaxPyramid` gains `f64 bucket_sum(u32 l, u64 i) const` and
`f64 bucket_sumsq(u32 l, u64 i) const` (Mean/RMS need them).
- `include/plot/plot/trace.hpp` — `Trace`/`TraceDesc` gain (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: stride
`ceil(samples_per_pixel)`, so markers only appear where samples are sparse).
`width == 0` draws markers only (scatter) — documented.
- `include/plot/render/style.hpp` — gains `f32 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_viewport` normalises negative w/h to 0 (R1).
- ~~`include/plot/plot/crosshair.hpp` — `CrosshairRow` gains `Color color`~~
DROPPED: `CrosshairRow` already names its trace; the renderer resolves the
colour as `plot.trace(row.trace).color` at 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.
1. **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 unaligned `begin`/`end` mid-window asserts no emitted
x outside `[begin, end)` and envelope values equal exact reduce over the range.
2. **B7**: NaN policy per above; regression: NaN at first/middle/last position
— bounds and reduce stay finite, sum skips NaN.
3. **R3**: `level_span` caps at level 57 (span 2^63); `gather(level > 57)`
clamps; no UB/div-zero for any u32 level.
4. **R4**: docs say "at most `capacity()` rounded up to a power of two";
`StreamSeries` reserves the *rounded* `ring_.capacity()`.
5. **GatherMode** implementation in `gather_impl` (Mean/Nearest via the new
pyramid accessors); `XY`/`Array`/`Stream` forwarders updated; relative-x
contract implemented everywhere (x emitted as `x_at(i) - x0()`).
6. **XY series** (full feature): `include/plot/data/xy_series.hpp`,
`src/data/xy_series.cpp`, target `plot_core`. Sorted-ascending x
(documented precondition, `assert` in debug); binary-search
`neighbours`/`snap_index`; pyramid over sample index; `commit()` rebuilds.
7. **Snapshot**: `ArraySeries StreamSeries::freeze() const` — consumer-thread
only; copies the retained window (`copy_raw`) into a fresh ArraySeries with
this window's `x0()`/`dx()`. Freezes against the *last acquire* (documented).
8. **Incremental append**: `void ArraySeries::push(f32)` — appends, folds into
the pyramid (`append`), extends bounds incrementally (NaN-aware), extends
`x_bounds_`. `commit()` stays the wholesale path.
Acceptance: new tests for every item above; `plot_tests` builds 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).
1. **B2**: major = integer-index test (`k % ratio == 0` on the minor index,
`k` derived from `(v - first_major)/minor_step` rounded); no float tolerance.
Regression: epoch range 1.75e9 ± 300 s at 800 px produces labelled majors.
2. **R9**: majors are never truncated; only minors count against `kMaxTicks`.
3. **B4**: live window placement in axis space (log-aware span/offset) in
`Plot::place_live_x_window`, `Plot::set_x_range` live branch, and the two
figure link-group copies. Regression: streaming log axis holds visible
decades constant across samples; scrolled-back window keeps span.
4. **B5**: `on_wheel` — `dy == 0 && dx == 0` returns; `dy == 0, dx != 0` pans x
only (`dx` notches, 5% of the x span per notch, log-aware via `to_space`);
zoom path rejects `factor == 1` and non-finite. Same guard in the
middle-drag `axis_zoom`.
5. **R1**: `layout()` clamp guards `hi >= lo` before clamping (no UB even for a
hostile viewport; `set_viewport` normalises, already done by integrator).
6. **R2**: range setters refuse non-finite endpoints (Off branch + y), keep the
previous range and `auto_scale`.
7. **R8**: `add_measure`/`measurement` refuse horizontal-ruler (and
vertical-ruler dead-field) cursors — `valid` stays false, `add_measure`
returns an id that reports invalid.
8. **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 at `begin_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 an `XY` fixture.
## 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`.
1. **B1**: implement the relative-x staging contract (see Cross-cutting):
trace staging reconstructs `x_abs = series.x0() + rel` in f64, stages
`f32(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) and `mapping()` folds
`origin` into 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).
2. **B6**: two text batches — `text_under_` (legend names, cursor labels,
measure labels, annotation captions) flushed *immediately before*
`draw_crosshair_readout`; `text_over_` (readout text, tick labels, titles)
flushed at `end_frame`. The readout panel occludes under-batch text.
Regression: legend + overlapping readout frame — legend name pixels are not
lit inside the readout panel rect.
3. **R6**: `begin_frame` saves `GL_BLEND_SRC/_DST` via `glGetIntegerv` and
`end_frame` restores function + enable bit; `Texture::upload_rgba`
saves/restores `GL_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 inside `axes`
(skip the caption when it cannot fit).
4. **Dash**: trace dash reaches `u_dash` per trace flush (device px, logical
converted); legend swatch samples the dash. `dash_off == 0` = solid.
5. **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)`, stride `marker_every` or auto `ceil(spp)`;
`width == 0` draws markers only. Render-smoke regression: a marker'd trace
lights vertices a plain line does not, and a `marker_every = 3` trace
stages 1/3 the marker count.
6. **AA knob**: `u_aa` uniform = `max(0.25, device(style.line_aa_px))`; shaders
drop the hardcoded fringe. Default output unchanged (1.0 logical).
7. **Text colour**: per-vertex `a_colour` (vec4); `stage_text(..., Color)`;
`flush_text` stops setting `u_color`; crosshair readout value rows draw in
their row's trace colour (`CrosshairRow::color`), labels in `axis_text`.
Smoke regression: a coloured readout row differs from the axis-text pass.
8. **GatherMode**: renderer passes `trace.mode` to `gather`.
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).
1. **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.
2. **R7**: `Host` caches row rects; invalidated on resize + `set_figure`,
refreshed once per frame; event handlers read the cache (one-frame-stale
rows during resize — documented).
3. Key repeat: ImGui `IsKeyPressed(..., repeat=true)`; policy comment.
4. **Timestamps**: all four adapters fill `PointerEvent::time_s`
(glfwGetTime / QEvent timestamps /1000 / gdk time /1000 / ImGui::GetTime).
5. **New keys**: GLFW maps arrows + Escape to the new constants (with
`GLFW_KEY_LEFT` etc.); Qt maps `Key_Left...`; GTK maps `GDK_KEY_Left...`;
ImGui `ImGuiKey_LeftArrow...`, `ImGuiKey_Escape`.
6. **Qt**: `wheelEvent` uses `pixelDelta()/40` notches when `angleDelta` is 0
(pixelDelta path); `DevicePixelRatioChange` refreshes the controller
viewport + scales; pinch via `grabGesture(Qt::PinchGesture)` ->
`GestureEvent`.
7. **GTK4**: `GtkGestureZoom` attached in the plot area factory ->
`GestureEvent` (scale + focal delta); scroll `ScrollUnit::Surface` path
already exists — keep.
8. **GLFW**: `on_scroll` forwards 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).
1. `GNUInstallDirs` for every install path; `VERSION`/`SOVERSION` on
`plot_core` when shared; `SameMinorVersion` compatibility (0.x reality);
generated `include/plot/version.hpp` (`configure_file`, installed, added to
the umbrella + exemption handling as needed); `plot.pc` pkg-config file;
install `LICENSE` + `licences/OFL-1.1.txt` under
`${CMAKE_INSTALL_DATADIR}/licenses/plot/`; `install(DIRECTORY)` gets
`FILES_MATCHING PATTERN "*.hpp"`.
2. `PLOT_BUILD_TTF_FONT` moves to `cmake/plot_options.cmake` (docs match);
coverage-flag detection switches to a `PLOT_COVERAGE` option check on the
config profile, not a `CMAKE_CXX_FLAGS` string match.
3. 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-error`
on the ImGui fetch is not needed (network is available on GH runners).
4. 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 --install` smoke into a temp prefix finds `plot` via
`find_package` in 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-debug` suite: **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.md` by `tools/coverage.sh`.
- Headless smoke: `plot_glfw_demo` + `plot_multiplot_demo` under `xvfb-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).