fix(plot): a degenerate range cannot strand a live axis, and the demo's follow check fails the run
This commit is contained in:
1 parent
77e861f79e
commit
7d2349555d
6 files changed
+146
-13
No files matched your search
+35
-4
@@ -20,7 +20,9 @@
|
|||||||
// (`XFollow::Live`), driving three wheel notches and a pan through the host's own
|
// (`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
|
// 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,
|
// 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
|
// 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).
|
// 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
|
/// 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
|
/// 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
|
/// 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
|
/// throughout. The verdict is carried out of the run in `failed`, so `main()` exits
|
||||||
/// rather than a printout to be read.
|
/// 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 {
|
struct FollowScript {
|
||||||
bool enabled = false;
|
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::u32 step = 0;
|
||||||
plot::f64 width[4]{};
|
plot::f64 width[4]{};
|
||||||
plot::f64 behind[4]{};
|
plot::f64 behind[4]{};
|
||||||
@@ -250,7 +259,8 @@ struct FollowScript {
|
|||||||
const plot::Rect axes = p.layout().axes;
|
const plot::Rect axes = p.layout().axes;
|
||||||
const f32 x = axes.x + 0.5f * axes.w;
|
const f32 x = axes.x + 0.5f * axes.w;
|
||||||
const f32 y = axes.y + 0.5f * axes.h;
|
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(
|
host.controller().on_pointer(
|
||||||
{x + 100.0f, y, 100.0f, 0.0f, plot::input::Button::Left, 0, true, false, false});
|
{x + 100.0f, y, 100.0f, 0.0f, plot::input::Button::Left, 0, true, false, false});
|
||||||
host.controller().on_pointer(
|
host.controller().on_pointer(
|
||||||
@@ -282,6 +292,8 @@ struct FollowScript {
|
|||||||
const bool pan_only_behind =
|
const bool pan_only_behind =
|
||||||
at_edge(behind[0]) && at_edge(behind[1]) && behind[2] > 1e-6 && same(behind[2], behind[3]);
|
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];
|
const bool following = live[0] && live[1] && live[2] && live[3];
|
||||||
|
failed = !(zoom_only_width && pan_only_behind && following);
|
||||||
|
completed = true;
|
||||||
std::printf(
|
std::printf(
|
||||||
"[glfw_demo] follow checks: width changed only by the zoom: %s; distance behind only"
|
"[glfw_demo] follow checks: width changed only by the zoom: %s; distance behind only"
|
||||||
" by the pan: %s; following armed throughout: %s\n",
|
" by the pan: %s; following armed throughout: %s\n",
|
||||||
@@ -404,6 +416,25 @@ int main() {
|
|||||||
return 1;
|
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::Renderer* r = host.renderer();
|
||||||
const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{};
|
const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{};
|
||||||
const plot::Rect axes = std::as_const(p).layout().axes;
|
const plot::Rect axes = std::as_const(p).layout().axes;
|
||||||
|
|||||||
@@ -57,8 +57,11 @@ struct Axis {
|
|||||||
/// units, under `XFollow::Live`. Stored rather than implied by the range,
|
/// units, under `XFollow::Live`. Stored rather than implied by the range,
|
||||||
/// because the range is recomputed from the series' newest x on every frame:
|
/// 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
|
/// 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
|
/// the recompute. Three things write it, all of them through one clamp: a pan or
|
||||||
/// what the traces still retain.
|
/// 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;
|
f64 offset = 0.0;
|
||||||
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
||||||
std::vector<Tick> ticks;
|
std::vector<Tick> ticks;
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ class Plot {
|
|||||||
void set_y_range(u32 axis, Range r);
|
void set_y_range(u32 axis, Range r);
|
||||||
|
|
||||||
/// Re-arms (or disarms) the per-layout auto scaling of one axis.
|
/// 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_x_auto_scale(u32 axis, bool on);
|
||||||
void set_y_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
|
/// 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
|
/// 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
|
/// 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
|
/// axis exactly where it is: the last live window becomes an ordinary stored
|
||||||
/// range, and `auto_scale` is untouched.
|
/// range, and `auto_scale` is untouched.
|
||||||
///
|
///
|
||||||
|
|||||||
+17
-4
@@ -167,8 +167,7 @@ void Plot::set_x_range(u32 axis, Range r) {
|
|||||||
if (axis >= x_axes_.size()) return;
|
if (axis >= x_axes_.size()) return;
|
||||||
Axis& a = x_axes_[axis];
|
Axis& a = x_axes_[axis];
|
||||||
Range live{};
|
Range live{};
|
||||||
if (a.follow == XFollow::Live && live_x_bounds(axis, live) && r.span() > 0.0 &&
|
if (a.follow == XFollow::Live && live_x_bounds(axis, live)) {
|
||||||
std::isfinite(r.min) && std::isfinite(r.max)) {
|
|
||||||
// Under Live the caller is stating a *span* and where the window sits relative
|
// 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()
|
// 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
|
// 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
|
// 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
|
// window that follows from it, so the state is consistent before the next
|
||||||
// layout rather than only after it.
|
// 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);
|
place_live_x_window(axis);
|
||||||
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
|
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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
|
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});
|
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);
|
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,
|
c.on_pointer({strip_x, strip_y + drag_px, 0, drag_px, plot::input::Button::Middle, 0, true,
|
||||||
false});
|
false, false});
|
||||||
c.on_pointer({strip_x, strip_y + drag_px, 0, 0, plot::input::Button::Middle, 0, false, false,
|
c.on_pointer({strip_x, strip_y + drag_px, 0, 0, plot::input::Button::Middle, 0, false, false,
|
||||||
true});
|
true});
|
||||||
PLOT_CHECK_NEAR(p.x_range(ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
|
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);
|
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);
|
||||||
|
}
|
||||||
@@ -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().min, frozen.min, 1e-9);
|
||||||
PLOT_CHECK_NEAR(f.xr().max, frozen.max, 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<f64>::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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user