From 7cc06f07f55968725f2fc9367b95d512a1231ce6 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 14:08:11 +0200 Subject: [PATCH] fix(input): refuse non-finite region bounds, keep narrow regions movable, republish hot handles --- include/plot/input/controller.hpp | 28 ++++++-- include/plot/plot/plot.hpp | 11 ++++ include/plot/plot/region.hpp | 8 ++- src/input/controller.cpp | 50 ++++++++++++--- src/plot/plot.cpp | 20 ++++++ tests/test_controller.cpp | 102 ++++++++++++++++++++++++++++++ tests/test_plot.cpp | 63 ++++++++++++++++++ 7 files changed, 263 insertions(+), 19 deletions(-) diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp index afe4d6d..bcae5b1 100644 --- a/include/plot/input/controller.hpp +++ b/include/plot/input/controller.hpp @@ -83,16 +83,22 @@ using plot::RegionGrab; /// dragging either of the cursors that bound it, because its edges *are* those /// cursors -- no second path, no second tolerance. /// 2. **Regions next**, resolved nearest-first against the geometry `Plot::layout()` -/// measured: a bound inside the grab radius beats the body (so the bound of a +/// measured: a bound within the grab radius beats the body (so the bound of a /// wide region is grabbable even though the pointer is inside the fill), the /// nearer of the two bounds wins, and with no bound in range the topmost region -/// whose interior holds the pointer is the body. +/// whose interior holds the pointer is the body. The radius is the one above, cut +/// back when a region is too narrow to hold a band at each end *and* an interior: +/// a region narrower than twice it would otherwise have every interior point +/// within a bound's reach, and could be resized but never moved. /// 3. **Nothing hot**, and the press pans. /// /// A region narrower than a few logical pixels is a click rather than an edge grab: /// its two bounds are under the pointer together, so an edge would always win and /// the region could never be moved again once collapsed. Such a region is -/// grabbed by its body instead, widened to that same minimum, and it stays movable. +/// grabbed by its body instead, widened to that same minimum, and it stays movable -- +/// and between that minimum and twice the grab radius, where the two bounds would +/// still cover every interior point, the edge bands are cut back until the middle is +/// left, so a region in that band of widths can be moved as well as resized. /// The *body* drag translates both bounds by one delta -- the width is preserved /// exactly -- while a bound drag moves one end and leaves the other where it was, /// so a drag that crosses one bound over the other swaps them rather than inverting @@ -103,7 +109,8 @@ using plot::RegionGrab; /// both ends back to where they started and never move at all. /// /// The hovered and grabbed handles are published to the plot -/// (`Plot::set_hot_handles()`) after every pointer event, so the renderer draws them +/// (`Plot::set_hot_handles()`) after every event that can move the geometry under the +/// pointer -- a pointer event, a wheel notch, a key stroke -- so the renderer draws them /// highlighted without knowing what a pointer is; `hovered_cursor()`, /// `grabbed_cursor()`, `hovered_region()`, `grabbed_region()` and `region_grab()` are /// the same answer for a host that wants it directly -- a resize cursor, a tooltip, @@ -190,7 +197,8 @@ class Controller { /// The region the pointer is on, or `kInvalid`. `region_grab()` says which zone. u64 hovered_region() const; /// Which zone of `region` the pointer is on: the bound nearest within the grab - /// radius (`Left`/`Right`), else `Body` when the pointer is inside the interval -- + /// radius (`Left`/`Right`) -- cut back for a narrow region, see `zone_hit()` -- else + /// `Body` when the pointer is inside the interval -- /// widened to the minimum a collapsed region keeps -- and `None` when it is on /// neither, when the pointer is outside the plot area, or when no pointer has been /// seen yet. What a host sets its resize cursor from; an id that names no region @@ -229,6 +237,10 @@ class Controller { RegionGrab zone = RegionGrab::None; f32 distance = 0.0f; }; + /// The band a bound answers to is the controller's grab radius, cut back so that a + /// narrow region keeps an interior a body drag can reach (see the class comment): + /// `min(radius, (width - minimum interior) / 2)`, and no band at all when the two + /// bounds are closer together than the minimum width that separates them. ZoneHit zone_hit(const RegionLayout& region, f32 px, f32 py) const; /// Resolves that hit against the geometry `Plot::layout()` measured -- the same /// rects the renderer paints, so what is drawn and what can be grabbed cannot @@ -242,8 +254,10 @@ class Controller { /// the width -- so a changed pointer motion never accumulates error. bool begin_handle_drag(const PointerEvent& e); /// Publishes what the pointer is on to the plot, for the renderer to highlight - /// (see HotHandles). Called after every pointer event, so hover and grab are the - /// same answer the hit test gave. + /// (see HotHandles). Called after every event that can move the geometry under the + /// pointer -- a pointer event, a wheel notch, a key stroke -- so hover and grab are + /// always the answer the hit test gives for the geometry of this frame rather than + /// the geometry of the last time the pointer moved. void publish_hot(); /// The data x a zoom of `axis` holds fixed, for a gesture at (px, py). The diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 1d26bc5..97f0cd1 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -161,6 +161,12 @@ class Plot { /// onto the sample grid as it is added -- exactly as `add_cursor()` places a /// snapping cursor -- so a region handed to a plot that already holds samples is on /// the grid from its first frame. + /// + /// A region whose bounds are not both finite is *refused*: nothing is added, and the + /// returned id names no region (`region()` reads a default for it and `region_count()` + /// does not count it). Storing one would be worse than useless, because a `Free` region + /// holding a bound that is not a number could never be dragged back -- every motion + /// re-derives both bounds from the stored ones. u32 add_region(const Region& region); // --- accessors -------------------------------------------------------- @@ -340,6 +346,11 @@ class Plot { /// bounds under the pointer and put them on the sample grid once, on release, /// instead of both ends sticking as the pointer moves. Also drops the region's /// cached statistics. + /// + /// A pair of bounds that is not both finite is *refused*: `std::min`/`std::max` would + /// propagate a NaN through both stored bounds and a finite partner would be lost with + /// it, so a rejected call changes nothing at all -- the interval stays as it was, and + /// so do the statistics cached for it. void set_region(u32 id, f64 x0, f64 x1); /// Pulls a snapping region's bounds onto the sample grid: onto the nearest sample diff --git a/include/plot/plot/region.hpp b/include/plot/plot/region.hpp index 1440806..ada79c6 100644 --- a/include/plot/plot/region.hpp +++ b/include/plot/plot/region.hpp @@ -20,9 +20,11 @@ enum class RegionSnap { /// The bounds are **ordered**: `Plot::set_region()` stores their min and max, so /// `x0 > x1` cannot be held however the bounds arrived -- which is what keeps a /// drag that crosses one bound over the other a *swap* (the region stays draggable -/// and never turns inside out) rather than a degenerate interval. The interval is -/// inclusive of both ends: the samples a region covers are those whose x is at or -/// after `x0` and at or before `x1`. +/// and never turns inside out) rather than a degenerate interval. A pair that is not +/// both finite is not a pair of bounds and is refused rather than stored (see +/// `Plot::set_region()`); the bounds below are finite once the region is in a plot. +/// The interval is inclusive of both ends: the samples a region covers are those whose x +/// is at or after `x0` and at or before `x1`. /// /// A region names no x axis, unlike a cursor: it shades an interval of the plot's /// data area as a whole -- the rect `PlotLayout::axes` and the grid are built from diff --git a/src/input/controller.cpp b/src/input/controller.cpp index ac4b519..a695df5 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -33,6 +33,15 @@ constexpr f32 kCursorHitRadiusLogical = 5.0f; /// A box-zoom selection narrower than this on either side is a click. Logical for /// the same reason: the gesture is the same hand movement on any display. constexpr f32 kMinBoxLogical = 4.0f; +/// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are +/// cut back until this much of the middle is left, so a region too narrow for the full grab +/// radius on each side is still something a pointer can take hold of and *move*. Without it +/// every interior point of a region narrower than twice the radius (10 logical px) lies +/// within a bound's reach, and the bound branch would always win -- such a region could be +/// resized but never moved. Half of it either side of the midpoint, which is what makes the +/// middle of even the narrowest region with grabbable bounds (3 logical px) reachable. +constexpr f32 kMinRegionBodyLogical = 2.0f; + /// The narrowest region whose bounds are grabbable, in **logical** pixels. Closer /// together than this, the two bounds are one target the pointer cannot separate: /// an edge would win the hit test every time and the region could never be moved @@ -159,12 +168,23 @@ void Controller::on_wheel(const WheelEvent& raw) { zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(e.y)), factor); } } + // The ranges moved under a pointer that did not, so the geometry under it changed: refresh + // the layout -- the region rects the hit test reads were measured for the range this wheel + // has just replaced -- and republish what is hot. A frame drawn right after a scroll then + // emphasizes the handle the pointer is over *now*, rather than the one it was over before, + // without waiting for the mouse to move again. + plot_->layout(); + publish_hot(); } void Controller::on_key(const KeyEvent& e) { if (!e.down) return; // every action below is press-only - if (e.key == kKeyZoomIn || e.key == kKeyZoomOut) { + const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut; + const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper; + if (!zooms && !fits) return; // a key this controller has no gesture for + + if (zooms) { const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches; const f64 factor = zoom_factor(notches); for (u32 i = 0; i < plot_->x_axis_count(); ++i) { @@ -179,13 +199,16 @@ void Controller::on_key(const KeyEvent& e) { const f64 hi = to_space(a.range.max, a.log); zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor); } - return; - } - - if (e.key == kKeyFit || e.key == kKeyFitUpper) { + } else { plot_->auto_fit_x(); for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); } + // A key stroke that moved a range moved the geometry under a stationary pointer, exactly + // as a wheel notch does: refresh the layout and republish what is hot, so the highlight a + // frame draws is the handle the pointer is over now (see on_wheel). A key this controller + // has no gesture for changes nothing and returns above. + plot_->layout(); + publish_hot(); } u64 Controller::hovered_cursor() const { @@ -217,14 +240,23 @@ Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32 const f32 left_x = handle_column(region.left_handle); const f32 right_x = handle_column(region.right_handle); const f32 min_w = plot_->style().device(kMinRegionEdgeGrabLogical); - // Grabbable bounds: the nearer of the two, when it is within the radius every - // other handle in this controller is hit-tested with -- one tolerance, so what - // `hovered_cursor()` calls a hit and what a region bound calls a hit agree. + // Grabbable bounds: the bounds are told apart only while the region is wide enough to + // hold a band at each end and an interior between them -- below that the body is the + // only handle it has, which is the collapsed-region rule above -- and each band is cut + // back so that interior always exists. The radius every other handle in this controller + // is hit-tested with is kept wherever a region can afford it: the band is that radius + // while twice it plus the minimum interior still fits, and half the remainder after + // that, so the middle of a 6 px region is a body rather than inside both bounds. One + // tolerance, so what `hovered_cursor()` calls a hit and what a region bound calls a hit + // agree wherever there is room for both. if (right_x - left_x >= min_w) { + const f32 radius = plot_->style().device(kCursorHitRadiusLogical); + const f32 band = std::min(radius, 0.5f * (right_x - left_x - + plot_->style().device(kMinRegionBodyLogical))); const f32 dl = std::fabs(px - left_x); const f32 dr = std::fabs(px - right_x); const f32 d = std::min(dl, dr); - if (d <= plot_->style().device(kCursorHitRadiusLogical)) { + if (d <= band) { hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right; hit.distance = d; return hit; diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 34af6ff..4dd9986 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -65,6 +65,14 @@ bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64 return true; } +/// True when a pair of bounds can be a region's bounds: both of them finite. The one place +/// this is decided, so `add_region()` and `set_region()` cannot disagree about what they +/// will hold -- and so no NaN ever reaches the `std::min`/`std::max` below, which propagate +/// it into *both* stored bounds and would leave a region no drag could recover from. +bool finite_bounds(f64 x0, f64 x1) noexcept { + return std::isfinite(x0) && std::isfinite(x1); +} + const Measurement& invalid_measurement() noexcept { static const Measurement measurement{}; return measurement; @@ -175,6 +183,12 @@ u32 Plot::add_annotation(const Annotation& annotation) { } u32 Plot::add_region(const Region& region) { + // A region whose bounds are not numbers is not an interval this plot can hold, and a + // `Free` one stored that way could never be dragged back (every motion re-derives both + // bounds from the stored ones), so it is refused rather than stored: nothing is added, + // and the id handed back names no region -- `region()` reads a default for it and + // `region_count()` does not count it. + if (!finite_bounds(region.x0, region.x1)) return static_cast(kInvalid); // Ordered on the way in, so no caller -- and no drag -- can leave `x0 > x1` // behind: the store is the one place this invariant is enforced. Region stored = region; @@ -505,6 +519,12 @@ void Plot::invalidate_measurements() noexcept { void Plot::set_region(u32 id, f64 x0, f64 x1) { if (id >= regions_.size()) return; + // A bound that is not a number is not a bound, and `std::min`/`std::max` would carry it + // through the whole pair: accepting one would replace the finite partner as well, so the + // region could never be dragged back. Refused outright -- as the other bound setters in + // this file refuse one -- which leaves the interval it already held, and with it the + // statistics cached for that interval. + if (!finite_bounds(x0, x1)) return; // min/max, so an inverted pair is stored ordered rather than clamped: a drag that // crosses one bound over the other *swaps* the interval, which is what keeps the // bound the pointer holds movable instead of pinned at the other one. diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 7f0c89d..1803f4c 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -1204,3 +1204,105 @@ PLOT_TEST(region_snap_is_applied_once_on_release) { PLOT_CHECK_NEAR(f.plot.region(rid).x1, 22.0, 1e-9); PLOT_CHECK(f.plot.region(rid).x1 > f.plot.region(rid).x0); // the interval survived both ends moving } + +/// A region the brief promises a pointer can still *move*: one whose bounds sit closer +/// together than the fixed grab radius allows on both sides. With that radius kept on each +/// bound, every point of a region narrower than twice it (10 logical px) lies within a +/// bound's reach, so the edge branch always wins and the interior is unreachable -- such a +/// region could be resized but never moved, and the collapsed-region rescue only engages +/// below 3 px. The bands are therefore cut back until the middle is left, while a region +/// wide enough for two full bands and an interior keeps the radius it always had. +PLOT_TEST(narrow_region_stays_movable_by_its_body) { + Fixture f; + const f32 radius = f.plot.style().device(5.0f); // the controller's own grab radius, logical + f32 column = 300.0f; // one strip of columns per region below + + for (const f32 width_px : {4.0f, 6.0f, 9.0f}) { + const f64 a = data_at(f.plot, f.ax, column); + const f64 b = data_at(f.plot, f.ax, column + width_px); + const u32 rid = f.plot.add_region(free_region(a, b)); + const f32 middle = column + 0.5f * width_px; + + // Both bounds are grabbable: the edge still wins where the pointer is on it ... + hover(f.controller, column); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); + hover(f.controller, column + width_px); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right); + // ... and the middle is the body, so the region can be moved as well as resized. + hover(f.controller, middle); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body); + press(f.controller, middle); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion); + PLOT_CHECK(f.controller.grabbed_region() == rid); + move_to(f.controller, middle + 30.0f); + const f64 delta = data_at(f.plot, f.ax, middle + 30.0f) - data_at(f.plot, f.ax, middle); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, a + delta, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1, b + delta, 1e-9); // moved, width intact + release(f.controller, middle + 30.0f); + column += 80.0f; + } + + // Where there is room the radius is unchanged: a press a few pixels inside a wide + // region's bound is that bound and not the body. + const u32 wide = f.plot.add_region( + free_region(data_at(f.plot, f.ax, 600.0f), data_at(f.plot, f.ax, 700.0f))); + hover(f.controller, 600.0f + radius - 1.0f); + PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left); + press(f.controller, 600.0f + radius - 1.0f); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft); + release(f.controller, 600.0f + radius - 1.0f); +} + +/// `publish_hot()` is the single writer of `HotHandles`, and the wheel and the keyboard +/// change the geometry under a *stationary* pointer. Without a publish of their own, a +/// frame drawn right after a scroll would highlight a handle the pointer has left -- or +/// miss one it has just landed on -- until the next mouse move, which is exactly the +/// stale-highlight defect the publish exists to prevent. The layout has to be refreshed +/// with it: the region rects the hit test reads were measured for the range the zoom has +/// just replaced. +PLOT_TEST(hot_handles_follow_a_gesture_that_moves_the_geometry) { + Fixture f; + const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00)); + const u32 off_centre = f.plot.add_cursor(75.0, plot::Color::hex(0xFF00FF)); + const u32 rid = f.plot.add_region(free_region(70.0, 90.0)); + const f32 radius = f.plot.style().device(5.0f); + + // The pointer sits four pixels to the right of the marker: on it, and published as such. + const f32 px = screen_x_of(f.plot, f.ax, 50.0) + 4.0f; + hover(f.controller, px); + PLOT_CHECK(f.plot.hot_handles().cursor == cid); + + // A zoom *in* about the pointer grows the marker's offset from it by the zoom factor -- + // 4 px becomes 6.3 -- so the marker leaves without the pointer moving at all. + f.controller.on_wheel({px, 200.0f, 0.0f, 3.0f, 0u}); + PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) > radius); + PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid); + PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid); + + // And the other way round: a zoom out about the same column brings it back under the + // stationary pointer, where the published state has to be the marker again. + f.controller.on_wheel({px, 200.0f, 0.0f, -3.0f, 0u}); + PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) <= radius); + PLOT_CHECK(f.controller.hovered_cursor() == cid); + PLOT_CHECK(f.plot.hot_handles().cursor == cid); + + // The keyboard path: a zoom about the plot centre moves the *other* marker, which the + // pointer is on, out from under it. + const f32 on_marker = screen_x_of(f.plot, f.ax, 75.0); + hover(f.controller, on_marker); + PLOT_CHECK(f.plot.hot_handles().cursor == off_centre); + f.controller.on_key({plot::input::kKeyZoomIn, 0u, true}); + PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 75.0) - on_marker) > radius); + PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid); + + // And a region bound: the zone that is published after the gesture is the zone the + // geometry now gives, not the one the bound had before the zoom. + const f32 bound = bound_x(f.plot, rid, true); + hover(f.controller, bound); + PLOT_CHECK(f.plot.hot_handles().region == rid); + PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left); + f.controller.on_key({plot::input::kKeyZoomIn, 0u, true}); + PLOT_CHECK(std::fabs(bound_x(f.plot, rid, true) - bound) > radius); // the bound moved away + PLOT_CHECK(f.plot.hot_handles().zone == f.controller.region_grab(rid)); + PLOT_CHECK(f.plot.hot_handles().zone != plot::RegionGrab::Left); +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index 0b54602..fe754dc 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -1641,3 +1641,66 @@ PLOT_TEST(removing_a_region_shifts_the_ids_and_keeps_the_statistics) { PLOT_CHECK(!p.region_stats(2u, tr).valid); PLOT_CHECK(!p.region_stats(9u, tr).valid); } + +/// A bound that is not a number is not a bound. `std::min`/`std::max` propagate a NaN +/// through *both* ends of the pair, so accepting one would not merely invert the interval: +/// it would replace the finite partner too, and a `Free` region stored that way could never +/// be dragged back -- every motion re-derives both bounds from the stored ones. The other +/// bound setters in the file refuse a non-finite value outright, and these two write paths +/// have to as well: `set_region` leaves the region on the interval it already held, and +/// `add_region`, which does not route through it, adds nothing at all. +PLOT_TEST(non_finite_region_bounds_are_refused) { + plot::data::ArraySeries s(ramp(100), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false}); + const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); + + plot::Region reg; + reg.x0 = 10.0; + reg.x1 = 20.0; + reg.snap = plot::RegionSnap::Free; + const u32 rid = p.add_region(reg); + const f64 nan = std::numeric_limits::quiet_NaN(); + const f64 inf = std::numeric_limits::infinity(); + + // Each of the four ways a pair can be unusable, and the interval is the same one after + // every one of them -- both bounds, not just the bound that arrived bad. + p.set_region(rid, nan, 20.0); + PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12); + PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12); + p.set_region(rid, 10.0, nan); + PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12); + PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12); + p.set_region(rid, 30.0, inf); + PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12); + PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12); + p.set_region(rid, -inf, 5.0); + PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12); + PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12); + // The interval it still holds is the one its statistics are over. + PLOT_CHECK(p.region_stats(rid, tr).valid); + PLOT_CHECK_NEAR(p.region_stats(rid, tr).min, 10.0, 1e-9); + PLOT_CHECK_NEAR(p.region_stats(rid, tr).max, 20.0, 1e-9); + + // A region whose bounds are not numbers is refused rather than stored: the count does not + // move, no id is handed out, and the id that came back names nothing. + const u32 before = p.region_count(); + plot::Region bad = reg; + bad.x0 = nan; // the finite partner goes with it, which is why the pair is refused + const u32 refused = p.add_region(bad); + PLOT_CHECK(p.region_count() == before); + PLOT_CHECK(refused != before); + PLOT_CHECK_NEAR(p.region(refused).x0, 0.0, 1e-12); + PLOT_CHECK_NEAR(p.region(refused).x1, 0.0, 1e-12); + PLOT_CHECK(!p.region_stats(refused, tr).valid); + // The next region a caller adds still takes the next free id, so a refusal leaves the plot + // exactly as it was rather than a hole a later id would fall into. + plot::Region good = reg; + good.x0 = 30.0; + good.x1 = 40.0; + PLOT_CHECK(p.add_region(good) == before); + PLOT_CHECK(p.region_count() == before + 1u); + PLOT_CHECK_NEAR(p.region(before).x0, 30.0, 1e-12); +}