From 7d2349555dc99dec30532ea06463eca0c33eeefc Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 13:14:50 +0200 Subject: [PATCH] fix(plot): a degenerate range cannot strand a live axis, and the demo's follow check fails the run --- examples/glfw_demo.cpp | 39 ++++++++++++++++++++++++++++---- include/plot/plot/axis.hpp | 7 ++++-- include/plot/plot/plot.hpp | 13 ++++++++++- src/plot/plot.cpp | 21 +++++++++++++---- tests/test_controller.cpp | 33 +++++++++++++++++++++++++-- tests/test_plot.cpp | 46 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 13 deletions(-) diff --git a/examples/glfw_demo.cpp b/examples/glfw_demo.cpp index 4afe36c..3910311 100644 --- a/examples/glfw_demo.cpp +++ b/examples/glfw_demo.cpp @@ -20,7 +20,9 @@ // (`XFollow::Live`), driving three wheel notches and a pan through the host's own // controller and printing, at each step, the range's width, its distance behind // the newest sample and the follow state -- the interaction spec ยง12 is about, -// measured on a real stream rather than in a unit test. +// measured on a real stream rather than in a unit test. It is a *check*, not a +// readout: the run exits non-zero if a measured property fails to hold, or if the +// sequence did not get far enough to measure all four. // // The producer is joined before the stream it writes into is destroyed, and // before the host closes the GL context (main's destruction order does that). @@ -183,10 +185,17 @@ struct Meter { /// Each measurement prints the range's width, how far its right edge sits behind the /// newest sample, and whether following is still armed. Only the zoom may change the /// width, only the pan may change the distance behind, and following must be armed -/// throughout; the last line of the run says whether that held, so the run is a check -/// rather than a printout to be read. +/// throughout. The verdict is carried out of the run in `failed`, so `main()` exits +/// non-zero on a failed check -- a check whose result only stdout knows is not a check +/// -- and also when the sequence never completed, since a run that measured nothing +/// has not passed. struct FollowScript { bool enabled = false; + /// Set by report(); read by main(). False until the checks have all held. + bool failed = false; + /// Set by report(): the sequence reached a verdict at all. A run that ended before + /// the fourth measurement has not passed, it just has not finished. + bool completed = false; plot::u32 step = 0; plot::f64 width[4]{}; plot::f64 behind[4]{}; @@ -250,7 +259,8 @@ struct FollowScript { const plot::Rect axes = p.layout().axes; const f32 x = axes.x + 0.5f * axes.w; const f32 y = axes.y + 0.5f * axes.h; - host.controller().on_pointer({x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false}); + host.controller().on_pointer( + {x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false}); host.controller().on_pointer( {x + 100.0f, y, 100.0f, 0.0f, plot::input::Button::Left, 0, true, false, false}); host.controller().on_pointer( @@ -282,6 +292,8 @@ struct FollowScript { const bool pan_only_behind = at_edge(behind[0]) && at_edge(behind[1]) && behind[2] > 1e-6 && same(behind[2], behind[3]); const bool following = live[0] && live[1] && live[2] && live[3]; + failed = !(zoom_only_width && pan_only_behind && following); + completed = true; std::printf( "[glfw_demo] follow checks: width changed only by the zoom: %s; distance behind only" " by the pan: %s; following armed throughout: %s\n", @@ -404,6 +416,25 @@ int main() { return 1; } + // The live-follow script's verdict, before the summary: a run that asked for the + // verification and did not get a passing one fails, whether a check said NO or the + // sequence never got far enough to say anything (a deadline too short to reach the + // fourth measurement -- a run that measured nothing has not passed). + if (script.enabled) { + if (script.failed) { + std::fprintf(stderr, + "plot_glfw_demo: the live-follow checks failed (see the 'follow checks'" + " line above)\n"); + return 1; + } + if (!script.completed) { + std::fprintf(stderr, + "plot_glfw_demo: the live-follow script did not complete (raise" + " PLOT_DEMO_EXIT_AFTER_MS)\n"); + return 1; + } + } + const plot::Renderer* r = host.renderer(); const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{}; const plot::Rect axes = std::as_const(p).layout().axes; diff --git a/include/plot/plot/axis.hpp b/include/plot/plot/axis.hpp index 1486d93..c2649f7 100644 --- a/include/plot/plot/axis.hpp +++ b/include/plot/plot/axis.hpp @@ -57,8 +57,11 @@ struct Axis { /// units, under `XFollow::Live`. Stored rather than implied by the range, /// because the range is recomputed from the series' newest x on every frame: /// this is the part of the view the user chose, so it is the part that survives - /// the recompute. `Plot::set_x_offset()` is the only way in, and clamps it to - /// what the traces still retain. + /// the recompute. Three things write it, all of them through one clamp: a pan or + /// a zoom through `Plot::set_x_range()`, which derives it from the range the + /// gesture asked for, `Plot::set_x_offset()` for a caller that states it + /// directly, and `Plot::layout()`'s placement, which re-clamps it against the + /// retained window every frame. `Plot::x_offset()` reads it back. f64 offset = 0.0; /// Ticks for the last `refresh()`, ascending, majors and (when asked) minors. std::vector ticks; diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 6a68179..af76139 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -170,6 +170,13 @@ class Plot { void set_y_range(u32 axis, Range r); /// Re-arms (or disarms) the per-layout auto scaling of one axis. + /// + /// On an x axis that is armed `XFollow::Live` and has a live edge, this switch is + /// inert: `layout()` leaves that axis to the follow path and never fits it (see + /// `set_x_follow()`), so an axis that reads `auto_scale == true` -- including the + /// `AxisDesc` default -- can still be one whose window is not being fitted. The + /// follow state wins because following is the more specific statement about that + /// axis, and `auto_fit_x()` is what frames such an axis. void set_x_auto_scale(u32 axis, bool on); void set_y_auto_scale(u32 axis, bool on); @@ -184,7 +191,11 @@ class Plot { /// Arming an axis that has a live edge places its window at the live edge at /// once, so `x_range()` reports the window the next layout will produce; the span /// it starts from is the span the axis already had, so a caller that wants the - /// retained window framed calls `auto_fit_x()`. Turning `Live` off leaves the + /// retained window framed calls `auto_fit_x()`. An axis whose stored span is not + /// a span -- a range a caller left degenerate while the axis was `Off`, which this + /// setter does not second-guess -- has no window to place and nothing to follow + /// with; arm it and call `auto_fit_x()`, and note that a range set *under* `Live` + /// cannot leave it in that state (see `set_x_range()`). Turning `Live` off leaves the /// axis exactly where it is: the last live window becomes an ordinary stored /// range, and `auto_scale` is untouched. /// diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 7751d74..98e70a2 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -167,8 +167,7 @@ void Plot::set_x_range(u32 axis, Range r) { if (axis >= x_axes_.size()) return; Axis& a = x_axes_[axis]; Range live{}; - if (a.follow == XFollow::Live && live_x_bounds(axis, live) && r.span() > 0.0 && - std::isfinite(r.min) && std::isfinite(r.max)) { + if (a.follow == XFollow::Live && live_x_bounds(axis, live)) { // Under Live the caller is stating a *span* and where the window sits relative // to the live edge, not a pair of absolute positions: the next layout() // recomputes the window from the series' newest x, so storing `r` verbatim @@ -178,8 +177,22 @@ void Plot::set_x_range(u32 axis, Range r) { // drag's data delta -- and place_live_x_window() clamps it and stores the // window that follows from it, so the state is consistent before the next // layout rather than only after it. - a.range = r; - a.offset = live.max - r.max; + // + // This branch is taken unconditionally on a live axis, and what it refuses to + // store is what would strand it: `place_live_x_window()` needs a positive span, + // and every gesture keeps a non-positive one out of its arithmetic, so a + // degenerate range stored here would leave the axis following nothing, movable + // by nothing, while `x_follow()` still said Live. An inverted range is a + // caller's ordering slip, not a request to break the axis' `min <= max` + // invariant -- the rest of the library reads a range as a range -- so it is + // normalised; a span that is not a span is ignored outright, leaving the span + // and offset the axis holds. `auto_fit_x()` is how a window is framed either + // way. + const f64 lo = std::min(r.min, r.max); + const f64 hi = std::max(r.min, r.max); + if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return; + a.range = Range{lo, hi}; + a.offset = live.max - hi; place_live_x_window(axis); a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit return; diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 929838e..f1b92f8 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -681,8 +681,8 @@ PLOT_TEST(drag_zoom_is_logical_the_wheel_is_not_scaled) { const f32 drag_px = 20.0f * scale; // 20 *logical* px at both scales c.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomX); - c.on_pointer({strip_x, strip_y + drag_px, 0, drag_px, plot::input::Button::Middle, 0, true, false, - false}); + c.on_pointer({strip_x, strip_y + drag_px, 0, drag_px, plot::input::Button::Middle, 0, true, + false, false}); c.on_pointer({strip_x, strip_y + drag_px, 0, 0, plot::input::Button::Middle, 0, false, false, true}); PLOT_CHECK_NEAR(p.x_range(ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6); @@ -692,3 +692,32 @@ PLOT_TEST(drag_zoom_is_logical_the_wheel_is_not_scaled) { PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before * std::exp(-0.15), 1e-9); } } + +/// The consequences of a degenerate range on a live axis, from the gesture side: the +/// wheel, the pan and the axis zoom all refuse a span that is not a span, so the +/// frozen axis a stored zero-span left behind was one no interaction could move -- +/// while `x_follow()` said `Live`. With the guard in `set_x_range` the slips a caller +/// can actually make leave the gestures working. +PLOT_TEST(live_gestures_survive_a_degenerate_range) { + LiveFixture f(200, 40); + f.live(); + const plot::Rect axes = std::as_const(f.graph).layout().axes; + const f64 span = f.xr().span(); + const f64 newest = f.newest(); + const plot::input::WheelEvent wheel = strip_wheel(f.graph, 1.0f); + + f.graph.set_x_range(f.ax, {newest, newest}); // the programmatic slip + PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9); + f.graph.set_x_range(f.ax, {newest - 5.0, newest - 15.0}); // and an inverted one + PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); // normalised, not stored inverted + + f.controller.on_wheel(wheel); + PLOT_CHECK_NEAR(f.xr().span(), 10.0 * std::exp(-0.15), 1e-9); // the wheel still zooms + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); + const f64 span_after_wheel = f.xr().span(); + drag(f.controller, axes, 60.0f); // a left drag: the pan gesture + PLOT_CHECK(f.graph.x_offset(f.ax) > 0.0); // the pan still pans + PLOT_CHECK_NEAR(f.xr().span(), span_after_wheel, 1e-9); + PLOT_CHECK_NEAR(f.xr().max, f.newest() - f.graph.x_offset(f.ax), 1e-9); + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index b44c2c1..b44e33e 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -1365,3 +1365,49 @@ PLOT_TEST(a_static_plot_is_unaffected) { PLOT_CHECK_NEAR(f.xr().min, frozen.min, 1e-9); PLOT_CHECK_NEAR(f.xr().max, frozen.max, 1e-9); } + +/// A degenerate range must not be able to strand a live axis. `set_x_range` under +/// `Live` is read as a span and an offset, and a span that is not a span is not a +/// window: storing it would leave the axis with a range nothing can place -- every +/// layout rejects a non-positive span -- and, because every gesture refuses one too, +/// with a range nothing can move either, while `x_follow()` still said `Live`. So the +/// one setter that could produce that state refuses to. +PLOT_TEST(a_degenerate_range_cannot_freeze_a_live_axis) { + LiveFixture f(200, 40); + f.live(); + const f64 span = f.xr().span(); + const f64 newest = f.newest(); + + // A zero-width range is ignored: the axis keeps the span and the offset it holds. + f.graph.set_x_range(f.ax, {newest, newest}); + PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9); + PLOT_CHECK_NEAR(f.xr().max, newest, 1e-9); + PLOT_CHECK(f.graph.x_axis(f.ax).range.max > f.graph.x_axis(f.ax).range.min); + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); + // ...and the stream carries on being followed, which is what the state was for. + f.feed(10); + f.graph.layout(); + PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9); + + // A non-finite endpoint is ignored for the same reason. + f.graph.set_x_range(f.ax, {f.newest() - 5.0, std::numeric_limits::quiet_NaN()}); + PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9); + PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9); + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); + + // An inverted range is a caller's ordering slip, not a request to break the axis' + // `min <= max` invariant -- every consumer of a range reads it as one -- so it is + // normalised, and the window still spans what the two endpoints enclose. + const f64 edge = f.newest(); + f.graph.set_x_range(f.ax, {edge - 5.0, edge - 15.0}); + PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); + PLOT_CHECK_NEAR(f.xr().max, edge - 5.0, 1e-9); + PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 5.0, 1e-9); + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); + + // The offset that came with it is the one the next frames keep. + f.feed(20); + f.graph.layout(); + PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9); + PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); +}