Final whole-branch review fixes. 1 (Important). Plot::measurement() cached on (series pointer, sample count) only, so a saturated StreamSeries -- whose size() stays pinned while x0() advances every acquire() -- froze its readout on the first saturated frame until a cursor moved. The cache key now carries the series' x0() (new MeasureSlot::last_x0), so a sliding window recomputes and a static series still does not: asserted with a new macro-gated counter hook (plot/detail/measure_count.hpp, PLOT_COUNT_MEASURE_RECOMPUTES, wired like the pyramid and ring counters, absent from the release library). New case saturated_stream_measurement_tracks_the_sliding_window: saturated ring, stationary cursors, ramp in absolute x -- zero recomputes for repeated reads with nothing moving, one per acquire while it slides. 2-3, 9. Spec: 4.3 described a GL_LINE_STRIP path that does not exist (there is one rasteriser -- instanced anti-aliased quads -- and dense_threshold_spp selects the gather level, not a primitive); 8 named a PLOT_GL_CHECK() macro that exists nowhere, and stated no exception policy. The table, the field's comment and 8 now describe the real gather-level threshold, the real glGetError() drain plus the stores' drop-to-orphan fallbacks, and the real policy: library failures return Status/bool, allocating entry points propagate std::bad_alloc. 4. The frame handed back neither GL_UNPACK_ALIGNMENT (init() sets 1 for the atlas) nor the clear colour (queried once with a new glGetFloatv entry). end_frame() restores both and renderer.hpp lists them in the residue contract. 5. draw() kept a host TextBackend for layout while drawing with its own font. The renderer now installs its font unconditionally -- strips and labels measure and draw with one backend -- with a debug-build diagnostic if a different one was set, and Plot::set_text_backend() is documented as the no-renderer path. New case renderer_measures_the_labels_it_draws asserts layout, measuring and the drawn label's ink agree. 6-10. draw()'s style overwrite stated at the call site; the load-sensitive wall-clock bound in test_measure.cpp replaced by a counter assertion (same interval, same fold count over a 512x length ratio); dump_frame_ppm drops noexcept and says why it may allocate; error.txt ignored (not this project's, not generated here) rather than deleted. Verified: debug and release build clean (-Wall -Wextra -Wpedantic -Wconversion -Wshadow), 4/4 ctest entries pass, 130 cases pass (was 127), umbrella and header self-containment checks pass, and no counter symbol in the release archive.
428 lines
21 KiB
Markdown
428 lines
21 KiB
Markdown
# `plot` — embeddable OpenGL time-series plotting library
|
||
|
||
**Date:** 2026-09-27
|
||
**Status:** approved (design reviewed section-by-section with the requester)
|
||
|
||
## Goal
|
||
|
||
A C++20 library that renders real-time, high-sample-rate time-series plots with
|
||
grid, axes, cursors and measurements, and that can be embedded into an existing
|
||
Qt6, GTK4, Dear ImGui, or raw GLFW application without owning the window, the GL
|
||
context, or the event loop.
|
||
|
||
## Non-goals (this cut)
|
||
|
||
- 3D surfaces, scatter/bar/pie charts, polar plots.
|
||
- Data I/O (CSV/HDF5 readers), file formats, network sources.
|
||
- Font rendering beyond the built-in bitmap atlas.
|
||
- Any host API not listed above.
|
||
|
||
These are explicitly out of scope so the first cut is a coherent vertical slice.
|
||
The layering (§3) leaves room for them without redesign.
|
||
|
||
## Constraints (fixed by the requester)
|
||
|
||
| Constraint | Value |
|
||
|---|---|
|
||
| Core dependencies | **zero.** No GLM, glad, GLEW, or bundled Khronos headers. |
|
||
| GL baseline | **3.3 core**, with a runtime-selected 4.3+ fast path. |
|
||
| Reference host | GLFW, plus ImGui and Qt6 and GTK4 adapters in the same cut. |
|
||
| First deliverable | buildable **end-to-end** skeleton: no stub bodies on the demo path. |
|
||
| Language | C++20, GCC/Clang, Linux first (no portability blockers introduced). |
|
||
|
||
## 1. Environment (verified on the development machine)
|
||
|
||
GCC 16.2.1, CMake 4.4.3, Ninja 1.13.2. Qt6 6.11.2 with `Qt6OpenGLWidgets`,
|
||
GTK4 4.22.5 with `gtkglarea.h`, GLFW 3.5.1, EGL 1.5 + `libEGL_mesa` (surfaceless
|
||
headless contexts available), `glm` and `GL/glew.h` installed but **unused**.
|
||
Dear ImGui is absent and is provided via `FetchContent` (network verified).
|
||
|
||
## 2. The embeddability contract
|
||
|
||
The library never creates a window, never creates or shares a GL context, and
|
||
never runs an event loop. Its render entry point is:
|
||
|
||
```cpp
|
||
plot::Renderer r; // constructed with the caller's context current
|
||
r.init();
|
||
r.begin_frame(fb_w, fb_h, viewport_rect_in_pixels); // in the caller's FBO
|
||
r.draw(plot);
|
||
r.end_frame();
|
||
```
|
||
|
||
Input arrives as plain value types (`PointerEvent`, `WheelEvent`, `KeyEvent`) that
|
||
each adapter translates from its host toolkit. Nothing in `plot::core` names a
|
||
toolkit, so a consumer links only the adapter they need.
|
||
|
||
## 3. Layering
|
||
|
||
```
|
||
adapters/{glfw,imgui,qt6,gtk4} own the surface; translate host events
|
||
↓
|
||
plot/ Plot · Axis · Trace · Grid · Cursor · Measure · Annotation
|
||
↓
|
||
render/ Renderer · Style · TextBackend · primitive batches
|
||
↓
|
||
data/ Series · ArraySeries · StreamSeries · MinMaxPyramid
|
||
↓
|
||
gl/ runtime loader · RAII handles · capability probe
|
||
↓
|
||
core/ + math/ types · Color · Rect · vec2 · mat3 · Transform
|
||
```
|
||
|
||
Each layer depends only downward. `adapters/*` and `examples/*` are optional
|
||
CMake targets guarded by `PLOT_BUILD_*` options and by dependency discovery.
|
||
|
||
## 4. Performance architecture
|
||
|
||
These four decisions are what make 1M+ samples/frame viable. Each replaces an
|
||
approach that fails at high sample rates.
|
||
|
||
### 4.1 Data-space vertices, uniform-only pan and zoom
|
||
|
||
Vertex buffers hold raw *data* coordinates. Screen mapping is
|
||
`x' = x * sx + tx`, `y' = y * sy + ty` evaluated in the vertex shader from a
|
||
`Transform` uniform. The CPU never transforms a vertex, and a pan or zoom changes
|
||
one uniform and no vertex *values*.
|
||
|
||
The staged window is view-dependent: each frame the renderer gathers just the
|
||
visible sample range (or the visible buckets of the chosen LOD level) and uploads
|
||
it. That per-frame cost is **O(visible pixels)** — about two segments per pixel
|
||
column, flat in series length — so the property this section exists for holds.
|
||
A staging cache keyed on `(series, level, first, last, viewport)` would let a
|
||
static plot skip the redundant gather and upload entirely; it is not implemented,
|
||
because it changes no asymptotics and would add an invalidation surface (new
|
||
samples, LOD changes, series mutation, streaming window slide) whose bugs are
|
||
worse than the redundant work it removes. Recorded here as deliberate future
|
||
work, not as a missing feature.
|
||
|
||
Log axes are handled inside the same transform (`log10` before scaling) so no
|
||
separate code path or pre-transformed buffer is needed.
|
||
|
||
### 4.2 Min/max LOD pyramid
|
||
|
||
Every series owns a mip chain: level 0 buckets `kBucket = 64` consecutive samples
|
||
and stores one entry per bucket; each subsequent level halves the bucket count.
|
||
Each entry is 24 bytes — a `MinMax` pair (8 bytes) plus the exact `sum` and `sumsq`
|
||
doubles the range queries fold over (16 bytes) — so level 0 costs
|
||
`24n/64 = 0.375n` bytes and the geometric sum `1 + ½ + ¼ + …` brings the pyramid to
|
||
≈`0.75n` bytes, about 19% of the `4n` bytes of raw samples, not 2×. A min/max-only
|
||
index would be `n/4`, ≈6%: the two doubles are the trade that makes an interior
|
||
sum/mean/RMS/integral O(log n) whole-bucket additions instead of a scan of raw
|
||
samples, at every level, for every series that uses the measurement index.
|
||
|
||
- Built incrementally, in **absolute bucket space**: level `l` bucket `k` covers
|
||
samples `[k · (kBucket << l), (k + 1) · (kBucket << l))`. A bucket's span
|
||
therefore never depends on where the current window starts, which is what lets a
|
||
window slide over a pyramid instead of forcing it to be rebuilt. `append()`
|
||
folds a sample into the single in-progress level-0 bucket and, exactly when that
|
||
bucket completes, pushes it and merges upward, stopping at the first level that
|
||
gains no new entry: level `l` gains one entry per `kBucket << l` samples, so an
|
||
append costs amortised O(1) (`1/kBucket + 1/(2·kBucket) + … = 2/kBucket` merges)
|
||
and never walks the live levels.
|
||
- A sliding window (a `StreamSeries` ring) drops the leading buckets it no longer
|
||
holds and appends only the newly produced samples, so nothing is recomputed and
|
||
memory stays bounded by the retained window rather than the total ever pushed.
|
||
- The renderer picks the level where samples-per-pixel ≈ 2, so a 100M-sample
|
||
series rasterises ≈ `2 · viewport_width` vertices instead of 100M.
|
||
- Buckets preserve extremes, so spikes are never dropped by decimation.
|
||
|
||
### 4.3 One rasteriser, two sampling levels
|
||
|
||
Every trace, every frame, is drawn by one path: instanced anti-aliased quads.
|
||
One instance per segment, four corners expanded from `gl_VertexID` in the vertex
|
||
shader, a fragment shader that does distance-to-segment SDF for smooth edges and
|
||
joins. There is no second primitive anywhere in the renderer — in particular no
|
||
`GL_LINE_STRIP` path — so a sparse trace and a dense one differ in the geometry
|
||
*staged*, never in the call that draws it.
|
||
|
||
What the samples-per-pixel threshold selects is therefore the gather level:
|
||
|
||
| samples/px | geometry staged |
|
||
|---|---|
|
||
| `< dense_threshold_spp` (1.5) | the visible window's raw samples: a level-0 `Series::gather()`, a polyline through the window, one instance per sample pair |
|
||
| `>= dense_threshold_spp` (1.5) | the pyramid level whose buckets are ~1 per pixel (`pyramid().level_for(n, samples_per_pixel)`): a min/max envelope pair per bucket, so O(pixels) instances instead of O(samples) |
|
||
|
||
`Style::dense_threshold_spp` is that threshold and nothing more: it picks the
|
||
gather level, changes no draw call, and the two sides of it are the same shader.
|
||
The dense side is where the work is saved (a 100M-sample window stages O(pixels)
|
||
vertices); the sparse side stages the window itself, which at ~1 sample per pixel
|
||
is ~1 instance per column anyway.
|
||
|
||
A geometry-shader expansion path was rejected: poor performance and it is the
|
||
one stage macOS cannot be relied on for.
|
||
|
||
### 4.4 Interval reduction reuses the pyramid
|
||
|
||
Cursor measurements (`Δx`, `Δy`, slope, RMS, mean, min/max, integral over a
|
||
dragged region) are answered from the same pyramid instead of by scanning the
|
||
series. `Series::reduce`/`sum`/`sumsq` cover the bucket-aligned interior with
|
||
pyramid aggregates and scan at most two partial edge buckets (≤ 2 × kBucket = 128
|
||
samples), so a query's cost is independent of series length. `Series::reduce` is
|
||
the single implementation used by measurement, auto-scale and hit-testing.
|
||
|
||
The split matters: a min/max pyramid stores *aggregates per bucket*, so it can
|
||
only answer exactly for whole buckets — a range with partial edge buckets would
|
||
otherwise silently include samples outside the range. Exactness therefore lives
|
||
on `Series` (which owns the raw samples), and `MinMaxPyramid` exposes only
|
||
bucket-exact queries, named `*_buckets` so they cannot be mistaken for exact ones.
|
||
|
||
The bucket grid is absolute while a sliding window's base is not, so that split is
|
||
computed in absolute coordinates — `Series` asks the pyramid for `window_base()`
|
||
and aligns `ceil`/`floor` there before mapping back to window indices. That is
|
||
also how a stream's `y_bounds()` stays exact in O(log n + kBucket) per frame: it
|
||
is `reduce(0, size())` over the window rather than a scan of it.
|
||
|
||
### 4.5 Decimation approach — decision
|
||
|
||
| | Approach | Verdict |
|
||
|---|---|---|
|
||
| **A** | **CPU min/max pyramid, incremental** | **chosen** — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈19% memory (24-byte buckets: extremes plus exact per-bucket sums). |
|
||
| B | GPU compute decimation every frame | rejected — re-reads the whole series each frame (real bandwidth at 1M samples/frame), no 3.3 fallback, untestable headlessly. |
|
||
| C | no decimation | rejected — dies above ~500k samples. |
|
||
|
||
The 4.3+ fast path therefore applies to *transfer*, not to decimation: a
|
||
persistent-mapped, `glFenceSync`-guarded triple-buffered ring replaces the
|
||
staging-upload path. Both paths share identical semantics and are selected by
|
||
the capability probe.
|
||
|
||
## 5. Zero-dependency mechanisms
|
||
|
||
- **Math** — hand-written `vec2`, `mat3`, `Transform`: ~250 lines, no templates
|
||
in public headers, no consumer-visible dependency.
|
||
- **GL loader** — a hand-maintained X-macro list `PLOT_GL_FUNCS(X)` covering only
|
||
the ≈60 entry points actually used, with our own typedefs and enums. No
|
||
`glcorearb.h` bundling and no code-generation step. Resolution order:
|
||
context-provided `getProcAddress` (glX / EGL / wgl) → `dlsym`/`GetProcAddress`
|
||
on the system GL library.
|
||
- **Text** — a `TextBackend` interface plus a built-in 8×16 bitmap-glyph atlas.
|
||
Axis labelling needs no third-party code; a FreeType or stb_truetype backend
|
||
can be added behind the same interface without touching callers.
|
||
|
||
There is no `third_party/` directory. Adapters use `find_package` first and
|
||
`FetchContent` only as a fallback (ImGui; GLFW when absent).
|
||
|
||
## 6. Public API sketch
|
||
|
||
Signatures here are the contract the implementation plan and adapters rely on.
|
||
|
||
```cpp
|
||
namespace plot {
|
||
|
||
using f32 = float; using f64 = double;
|
||
using u32 = std::uint32_t; using u64 = std::uint64_t;
|
||
constexpr u64 kInvalid = ~u64{0};
|
||
|
||
struct Range { f64 min, max; };
|
||
struct Rect { f32 x, y, w, h; }; // pixels, origin top-left
|
||
struct Color { f32 r, g, b, a; static Color hex(u32 rgb, f32 a = 1.0f); };
|
||
|
||
enum class ErrorCode { Ok, InvalidArgument, OutOfMemory, GLFailure, Unsupported, NotImplemented };
|
||
struct Status { ErrorCode code = ErrorCode::Ok; const char* message = ""; explicit operator bool() const; };
|
||
|
||
// math/transform.hpp
|
||
struct Transform {
|
||
f64 sx, tx, sy, ty; bool log_x = false, log_y = false;
|
||
f64 to_screen_x(f64) const; f64 to_data_x(f64) const;
|
||
f64 to_screen_y(f64) const; f64 to_data_y(f64) const;
|
||
};
|
||
|
||
namespace gl {
|
||
using ProcLoader = void* (*)(const char*);
|
||
bool load(ProcLoader); // nullptr → platform default
|
||
struct Caps { int major, minor; bool compute, persistent_map, buffer_storage; };
|
||
const Caps& caps();
|
||
class Buffer; class VertexArray; class Program; class Texture; class Framebuffer; class Fence;
|
||
}
|
||
|
||
namespace data {
|
||
struct MinMax { f32 min, max; };
|
||
constexpr u32 kBucket = 64;
|
||
|
||
class MinMaxPyramid {
|
||
public:
|
||
void reserve(u64 n); void append(f32 v); void clear();
|
||
// Absolute bucket space: level l entry i covers the absolute samples
|
||
// [i * (kBucket << l), (i + 1) * (kBucket << l)). The window slides over the
|
||
// same buckets; queries are in window coordinates (index 0 = oldest retained).
|
||
void drop_before(u64 absolute_sample);
|
||
u64 size() const; // retained window size
|
||
u64 window_base() const; // abs index of index 0
|
||
u64 window_end() const; // abs index past the last
|
||
u32 level_count() const;
|
||
u64 level_base(u32 l) const, level_size(u32 l) const;
|
||
MinMax bucket(u32 l, u64 absolute_bucket) const;
|
||
u32 level_for(u64 n, f64 samples_per_pixel) const;
|
||
u64 snap(f64 x, f64 x0, f64 dx) const; // nearest sample index
|
||
// Whole-bucket only: aggregates over the level-0 buckets fully contained in
|
||
// [begin, end). Partial edges are excluded, not approximated. Callers wanting
|
||
// an exact answer over an arbitrary range use Series::reduce/sum/sumsq.
|
||
Range reduce_buckets(u64 begin, u64 end) const;
|
||
f64 sum_buckets(u64 begin, u64 end) const;
|
||
f64 sumsq_buckets(u64 begin, u64 end) const;
|
||
};
|
||
|
||
class Series { // abstract
|
||
public:
|
||
virtual ~Series() = default;
|
||
virtual u64 size() const = 0;
|
||
virtual f64 x0() const = 0; // x of sample 0
|
||
virtual f64 dx() const = 0; // uniform x spacing
|
||
virtual Range x_bounds() const = 0;
|
||
virtual Range y_bounds() const = 0;
|
||
virtual const MinMaxPyramid& pyramid() const = 0;
|
||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
|
||
|
||
// Exact, O(kBucket) worst case regardless of series length: the bucket-aligned
|
||
// interior comes from the pyramid, the at-most-two partial edge buckets are
|
||
// read through copy_raw() and scanned. Implemented once, non-virtually, in
|
||
// src/data/series.cpp. Preconditions: begin < end, end <= size().
|
||
Range reduce(u64 begin, u64 end) const;
|
||
f64 sum(u64 begin, u64 end) const;
|
||
f64 sumsq(u64 begin, u64 end) const;
|
||
|
||
protected:
|
||
// Copies raw y samples for [begin, end) into `out`, which the caller sized to
|
||
// (end - begin). Handles ring wraparound. Only ever called with small edge
|
||
// spans by the queries above; not on a per-sample path.
|
||
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
|
||
};
|
||
|
||
class ArraySeries final : public Series { // own storage, uniform dx
|
||
public:
|
||
ArraySeries(std::vector<f32> y, f64 x0, f64 dx);
|
||
void reset(std::vector<f32> y, f64 x0, f64 dx);
|
||
void commit(); // rebuild the pyramid
|
||
};
|
||
|
||
class StreamSeries final : public Series { // SPSC, producer + consumer
|
||
public:
|
||
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
|
||
void push(f32 y); // producer thread only
|
||
void push(const f32* ys, u64 n); // producer thread only
|
||
u64 produced() const; // atomic, monotonic
|
||
void acquire(); // consumer: snapshot + extend pyramid
|
||
};
|
||
}
|
||
|
||
enum class AxisPlace { Bottom, Top, Left, Right };
|
||
|
||
struct AxisDesc { std::string label; Range range; bool auto_scale = true; AxisPlace place; bool log = false; };
|
||
struct TraceDesc { const data::Series* series; Color color; f32 width = 1.5f; u32 axis_x = 0, axis_y = 0; };
|
||
|
||
class Plot {
|
||
public:
|
||
u32 add_x_axis(AxisDesc); u32 add_y_axis(AxisDesc);
|
||
u32 add_trace(TraceDesc);
|
||
u32 add_cursor(f64 x, Color);
|
||
u32 add_measure(u32 cursor_a, u32 cursor_b);
|
||
void set_viewport(Rect px);
|
||
void set_x_range(u32 axis, Range); void set_y_range(u32 axis, Range);
|
||
void auto_fit_x(); void auto_fit_y(u32 axis);
|
||
struct Measurement { f64 dx, dy, slope, rms, mean, ymin, ymax, integral; bool valid; };
|
||
const Measurement& measurement(u32 id) const; // recomputed on cursor move
|
||
};
|
||
|
||
class Renderer {
|
||
public:
|
||
bool init(Status* = nullptr); // context must be current
|
||
void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px);
|
||
void draw(Plot&);
|
||
void end_frame();
|
||
Style& style();
|
||
};
|
||
|
||
namespace input {
|
||
enum class Button { Left, Middle, Right };
|
||
enum class Mod { None = 0, Shift = 1, Ctrl = 2, Alt = 4, Super = 8 };
|
||
struct PointerEvent { f32 x, y, dx, dy; Button button; u32 mods; bool down, pressed, released; };
|
||
struct WheelEvent { f32 x, y, dx, dy; u32 mods; };
|
||
struct KeyEvent { int key; u32 mods; bool down; };
|
||
|
||
enum class DragMode { None, Pan, BoxZoom, MoveCursor, AxisZoomX, AxisZoomY };
|
||
class Controller {
|
||
public:
|
||
explicit Controller(Plot&);
|
||
void set_viewport(Rect);
|
||
void on_pointer(const PointerEvent&);
|
||
void on_wheel(const WheelEvent&);
|
||
void on_key(const KeyEvent&);
|
||
DragMode drag_mode() const;
|
||
Rect selection_rect() const; // valid while BoxZoom
|
||
u64 hovered_cursor() const; // kInvalid when none
|
||
};
|
||
}
|
||
} // namespace plot
|
||
```
|
||
|
||
## 7. Threading model
|
||
|
||
- All GL calls happen on the thread that has the context current; `Renderer` is
|
||
not thread-safe and does not need to be.
|
||
- Producers write to `data::StreamSeries` from an acquisition thread through a
|
||
lock-free SPSC ring. No mutex on the sample path.
|
||
- A frame begins with `StreamSeries::acquire()`, which publishes a consistent
|
||
`[0, produced)` snapshot: it slides the pyramid's retained window to the new
|
||
base, dropping the leading buckets the ring has overwritten, then extends the
|
||
pyramid by exactly the newly produced samples. Its cost is **O(samples produced
|
||
since the last acquire)** — independent of both the window size and the series
|
||
length — and so is its memory, because the drops bound the pyramid to the
|
||
retained window instead of the total ever pushed. A frame's total cost is
|
||
therefore O(new samples + visible pixels), and no part of the frame path is
|
||
proportional to the window. The one exception is a producer that laps the
|
||
consumer by more than `capacity()` samples between two acquires: the samples in
|
||
between were never appended, so that acquire re-reads the retained window from
|
||
scratch. Readers use the snapshot; the producer never blocks.
|
||
- `Renderer`, `Plot`, and `Controller` are confined to the render thread.
|
||
|
||
## 8. Error handling
|
||
|
||
- Library-originated failures never throw: every one of them returns `Status`
|
||
(or `bool` plus an out-parameter `Status*`), and no code path reports an error
|
||
by throwing. An allocation failure is the host's process running out of memory,
|
||
not a library error, and the entry points that allocate host memory --
|
||
constructing a series, `Plot::add_*`, a frame that stages geometry,
|
||
`Renderer::dump_frame_ppm()` -- propagate `std::bad_alloc` from the container
|
||
that ran out. They are therefore not annotated `noexcept`; the entries that can
|
||
neither fail nor allocate are, so the surface says per entry point what it does
|
||
rather than claiming a blanket `noexcept`.
|
||
- GL failures are surfaced as `ErrorCode::GLFailure` from `Renderer::init` and
|
||
recorded for the frame: `Renderer::end_frame()` drains `glGetError()` in a loop
|
||
and sets the flag `last_frame_had_gl_error()` reports, so the first error cannot
|
||
hide the ones after it and a stale error cannot be attributed to a later frame.
|
||
The instance stores report their own refusals through the same flag: a storage,
|
||
map or fence the driver refuses drops that store to the orphan-and-refill
|
||
baseline (see §4.5) and marks the frame. There is no `PLOT_GL_CHECK()` macro and
|
||
no build-dependent error checking -- the drain runs in every build, and it is
|
||
the flag, not a macro, that a host observes.
|
||
- Unsupported-but-optional capabilities (compute, persistent mapping) degrade
|
||
silently by selecting the 3.3 path; unsupported-and-required ones return
|
||
`ErrorCode::Unsupported` from `init`.
|
||
|
||
## 9. Verification
|
||
|
||
| Layer | How it is verified |
|
||
|---|---|
|
||
| core/math, data, plot/ticks, input | CTest unit tests, no GL required: ring-buffer wrap/overwrite/tearing, pyramid build and `reduce()` vs. brute force, level selection, tick generation, transform round-trip, zoom-anchor invariance, pan invariance. |
|
||
| render, plot | **headless EGL surfaceless context + FBO**, rasterise a known sine, `glReadPixels` and assert ink present in expected columns and background elsewhere; assert the trace covers ≥95% of viewport width at 1M samples. |
|
||
| adapters | Launch each demo (`glfw`, `imgui`, `qt`, `gtk`) on the development display, screenshot, confirm grid + axes + trace + cursor + measurement readout render. |
|
||
|
||
Headless-EGL risk: if Mesa surfaceless context creation fails on this machine the
|
||
fallback is GLX on a hidden window; this is validated in the first GL task, before
|
||
three of the four adapters depend on it.
|
||
|
||
## 10. Milestones
|
||
|
||
1. Repo skeleton, CMake, core+math with tests.
|
||
2. GL loader, handles, capability probe; headless EGL harness proven.
|
||
3. Data layer: ring buffer, pyramid, series.
|
||
4. Render layer: programs, buffers, batching, bitmap text.
|
||
5. Plot layer: axes, ticks, grid, trace, cursor, measurement.
|
||
6. Input layer: events, controller.
|
||
7. Renderer + headless render smoke test (pixel assertions).
|
||
8. GLFW adapter + 1M-sample demo.
|
||
9. ImGui adapter + demo.
|
||
10. Qt6 adapter + demo.
|
||
11. GTK4 adapter + demo.
|
||
12. Install/export, full verification pass, screenshots.
|