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
@@ -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
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
Reference in new issue
Block a user