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

+292 -2
View File
@@ -1939,10 +1939,11 @@ class CountingSeries final : public plot::data::Series {
}
plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out) const override {
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
++gather_calls_;
samples_read_ += end - begin;
gather_impl(level, begin, end, out);
gather_impl(level, begin, end, out, mode);
}
u64 gather_calls() const noexcept { return gather_calls_; }
@@ -2294,3 +2295,292 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
// quarter of a million of them.
PLOT_CHECK(series.size() == kSamples);
}
// --- hardening wave: R1, R2, B4, and the Series-virtual routing -------------
namespace {
/// A minimal **XY-like** series: the sample positions are this test's own array,
/// not a uniform grid, and only the lookups the plot layer routes through the
/// `Series` virtuals -- `x_at`/`neighbours`/`snap_index` -- are overridden. Any
/// plot-layer answer that assumed `x0() + i*dx()` is wrong here by construction,
/// which is what makes the fixture a regression test for the *routing* rather than
/// for the series: the real XY series (Stream A) must behave the same way, but
/// these tests stand on their own.
class XyFixture final : public plot::data::Series {
public:
XyFixture(std::vector<f64> x, std::vector<f32> y)
: x_(std::move(x)), y_(std::move(y)) {
pyramid_.reserve(y_.size());
for (const f32 v : y_) pyramid_.append(v);
}
u64 size() const override { return y_.size(); }
f64 x0() const override { return x_.empty() ? 0.0 : x_.front(); }
f64 dx() const override { return 1.0; } // nominal; the real positions are x_
plot::Range x_bounds() const override {
return x_.empty() ? plot::Range{} : plot::Range{x_.front(), x_.back()};
}
plot::Range y_bounds() const override { return {0.0, 20.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
gather_impl(level, begin, end, out, mode);
}
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x_.empty() || x < x_.front() || x > x_.back()) return false;
const auto upper = std::lower_bound(x_.begin(), x_.end(), x);
const auto first = static_cast<u64>(upper - x_.begin());
if (upper != x_.end() && *upper == x) {
i0 = i1 = first; // the query landed on a sample
return true;
}
i1 = first;
i0 = first == 0u ? 0u : first - 1u;
return true;
}
u64 snap_index(f64 x) const override {
if (x_.empty()) return 0u;
u64 i0 = 0;
u64 i1 = 0;
if (!neighbours(x, i0, i1)) {
return x < x_.front() ? 0u : static_cast<u64>(x_.size() - 1u);
}
if (i0 == i1) return i0;
const f64 before = x - x_at(i0);
const f64 after = x_at(i1) - x;
return after < before ? i1 : i0;
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end && i < y_.size(); ++i) out[i - begin] = y_[i];
}
private:
std::vector<f64> x_;
std::vector<f32> y_;
plot::data::MinMaxPyramid pyramid_;
};
/// The XY scene: samples at x = 0, 1, 10 (deliberately non-uniform), y = 0, 10, 20,
/// and a window wide enough to frame them all.
struct XyScene {
XyFixture series{{0.0, 1.0, 10.0}, {0.0f, 10.0f, 20.0f}};
plot::Plot plot;
u32 ax = 0;
u32 ay = 0;
XyScene() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {0.0, 20.0}, false, plot::AxisPlace::Left, false});
}
};
/// A streaming series with decade-wide spacing on a log axis: sample k sits at
/// x = 1 + 10k, so feeding samples walks the axis through decades.
struct LogLiveFixture {
plot::data::StreamSeries series;
plot::Plot graph;
u32 ax = 0;
explicit LogLiveFixture(u64 push) : series(256u, 1.0, 10.0) {
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = graph.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
graph.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, 0u});
feed(push);
graph.set_x_follow(ax, plot::XFollow::Live);
graph.auto_fit_x();
graph.layout();
}
void feed(u64 n) {
for (u64 i = 0; i < n; ++i) series.push(0.5f);
series.acquire();
}
f64 newest() const { return series.x_bounds().max; }
/// The visible window in decades -- what a log follow must hold constant.
f64 decades() const {
const plot::Range r = graph.x_range(ax);
return std::log10(r.max) - std::log10(r.min);
}
};
} // namespace
/// R1: the layout clamps are defined even for a hostile viewport. `set_viewport()`
/// normalises negative dimensions at the door; the belt-and-braces guard inside
/// `layout()` means the two can never drift out of step -- and this pair is what a
/// caller that hands the plot a negative rect straight away exercises, in a build
/// where the undefined clamp would be visible (asan).
PLOT_TEST(layout_survives_a_negative_viewport) {
plot::Plot p;
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
p.set_viewport({10.0f, 10.0f, -200.0f, -100.0f}); // normalised to 10x10 zero-size
const plot::PlotLayout& lay = p.layout();
PLOT_CHECK(lay.axes.w >= 0.0f);
PLOT_CHECK(lay.axes.h >= 0.0f);
// And a plot laid out at zero size draws nothing but says nothing either: the
// same call again on the guarded dimensions stays well-defined.
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
PLOT_CHECK(p.layout().axes.w > 0.0f);
}
/// R2: the range setters refuse non-finite endpoints. A stored NaN or infinity
/// would hand every transform a garbage mapping *and* clear `auto_scale` -- the
/// one way back -- so the call is refused outright: the axis keeps the range it
/// had and the auto-scale state it had.
PLOT_TEST(range_setters_refuse_non_finite_bounds) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
// A user-chosen range first, so the auto-scale state the refusal has to preserve
// is the "cleared by a gesture" one, which is the state a real caller is in.
p.set_x_range(ax, {10.0, 20.0});
PLOT_CHECK(!p.x_axis(ax).auto_scale);
p.set_x_range(ax, {std::nan(""), 30.0});
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
PLOT_CHECK(!p.x_axis(ax).auto_scale); // kept as it was, neither lost nor re-armed
p.set_x_range(ax, {5.0, std::numeric_limits<f64>::infinity()});
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
// The y setter's own refusal, with the same preservation both ways.
p.set_y_auto_scale(ay, true);
p.set_y_range(ay, {std::nan(""), 0.5});
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK(p.y_axis(ay).auto_scale); // still armed: the fit is not lost
p.set_y_range(ay, {-1.0, std::numeric_limits<f64>::infinity()});
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK(p.y_axis(ay).auto_scale);
// A finite range still works, and still disarms.
p.set_y_range(ay, {-2.0, 2.0});
PLOT_CHECK_NEAR(p.y_range(ay).min, -2.0, 1e-9);
PLOT_CHECK(!p.y_axis(ay).auto_scale);
}
/// B4, on a log axis: the live window's span and offset are *decades*, so new
/// samples slide the window without rescaling it -- the visible decades stay
/// exactly what the user zoomed to, whatever the live edge does. (A linear span on
/// a log axis is the defect: ten units of span at 1e0 is ten decades, at 1e9 it is
/// nothing.)
PLOT_TEST(live_log_axis_holds_decades_constant) {
LogLiveFixture f(5u); // newest = 41
const f64 decades = f.decades();
PLOT_CHECK(decades > 0.5);
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-9);
for (int i = 0; i < 12; ++i) {
f.feed(2u); // newest += 20 per round
f.graph.layout();
PLOT_CHECK_NEAR(f.decades(), decades, 1e-9); // the zoom is preserved
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-8); // the edge is too
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
}
}
/// B4, scrolled back: a window parked behind the live edge keeps its span *in
/// decades* as the stream advances, and the distance behind the edge -- the offset,
/// also in decades -- is what survives, not an absolute position.
PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
LogLiveFixture f(5u); // newest = 41
f.graph.set_x_range(f.ax, {1.0, 10.0}); // one decade wide, behind the edge
const f64 decades = f.decades();
PLOT_CHECK_NEAR(decades, 1.0, 1e-9);
const f64 offset = f.graph.x_offset(f.ax);
PLOT_CHECK(offset > 0.5); // one decade behind the 41 edge, in log10
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, 10.0, 1e-9);
f.feed(6u); // newest = 101: the edge has moved a whole decade
f.graph.layout();
PLOT_CHECK_NEAR(f.decades(), 1.0, 1e-9); // the span the user chose
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), offset, 1e-12); // the scroll the user chose
const plot::Range r = f.graph.x_range(f.ax);
PLOT_CHECK_NEAR(std::log10(r.max), std::log10(f.newest()) - offset, 1e-9);
PLOT_CHECK(r.max < f.newest()); // genuinely parked behind the edge
}
/// The XY routing, crosshair half: the span brackets the samples the *array*
/// names, and the interpolation fraction is measured on the samples' own positions
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read
/// samples that do not exist at the positions it assumed.
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
XyScene scene;
const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
const plot::CrosshairSpan span = plot::crosshair_span(scene.series, 5.0);
PLOT_CHECK(span.valid);
PLOT_CHECK(span.begin == 1u);
PLOT_CHECK(span.end == 2u);
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9);
// On a sample, at both ends of the array's window, and outside it: the same
// closed-window rule a uniform series follows, on the array's own coordinates.
const plot::CrosshairSpan on = plot::crosshair_span(scene.series, 1.0);
PLOT_CHECK(on.valid);
PLOT_CHECK(on.begin == 1u);
PLOT_CHECK(on.end == 1u);
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.0, y));
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
PLOT_CHECK(scene.plot.crosshair_value(trace, 10.0, y));
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 10.0 + 1e-9).valid);
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.5, y));
PLOT_CHECK(!scene.plot.crosshair_value(trace, -1.0, y));
}
/// The XY routing, measurement half: a cursor snaps to the sample that is really
/// nearest -- 5.0 is nearer sample 1 (distance 4) than sample 2 (distance 5), where
/// the old uniform-grid index arithmetic named sample 2 -- and the measurement
/// covers exactly the samples the snapped pair brackets.
PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
XyScene scene;
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
// Free cursors first: a cursor's stored x is verbatim under the default snap,
// and the *measurement* is what maps a cursor x onto samples -- through the
// series' own `snap_index()`, so 5.0 measures from the sample that is really
// nearest (x = 1, distance 4) and not from where a uniform `dx()` grid guessed.
const u32 ca = scene.plot.add_cursor(5.0, plot::Color::hex(0xFFFFFF));
const u32 cb = scene.plot.add_cursor(9.0, plot::Color::hex(0xFFFFFF));
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 5.0, 1e-9); // Free: stored verbatim
const u32 measure = scene.plot.add_measure(ca, cb);
const plot::Measurement& m = scene.plot.measurement(measure);
PLOT_CHECK(m.valid);
PLOT_CHECK_NEAR(m.ymin, 10.0, 1e-9);
PLOT_CHECK_NEAR(m.ymax, 20.0, 1e-9);
PLOT_CHECK_NEAR(m.mean, 15.0, 1e-9);
// The snap, armed explicitly, lands on the same true nearest sample: 5.0 pulls
// onto x = 1 (distance 4 beats 5), 9.0 onto x = 10 (distance 1 beats 9).
scene.plot.set_cursor_snap(ca, plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 1.0, 1e-9);
scene.plot.set_cursor_snap(cb, plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(scene.plot.cursor(cb).x, 10.0, 1e-9);
// Region coverage is the same routing: [0.5, 5.0] covers exactly the sample at
// x = 1 -- the sample at x = 10 is not inside the interval, whatever a `dx()`
// grid would have guessed. The region is `Free`: a snapping region would land on
// a sample and change the interval under test.
const u32 region = scene.plot.add_region({0.5, 5.0, plot::RegionSnap::Free});
const plot::RegionStats& stats = scene.plot.region_stats(region, 0u);
PLOT_CHECK(stats.valid);
PLOT_CHECK(stats.count == 1u);
PLOT_CHECK_NEAR(stats.mean, 10.0, 1e-9);
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
}