diff --git a/examples/multiplot_demo.cpp b/examples/multiplot_demo.cpp index 8021124..a0b523e 100644 --- a/examples/multiplot_demo.cpp +++ b/examples/multiplot_demo.cpp @@ -545,6 +545,18 @@ int main() { /// cursor or region drag on any row stops it where the user put it. bool overlay_follows = true; + /// Optional scripted crosshair (spec §17): `PLOT_DEMO_CROSSHAIR=` (default row 1) + /// places the figure crosshair every frame with the pointer in that row and that row's own + /// y -- the shared x going to all three rows, the horizontal line only to the hovered one. + /// Off unless the environment asks for it, like the sync and region scripts, so a headless + /// screenshot can show the one horizontal line the hovered row draws and none in the others. + u32 crosshair_row = kRows; + if (const char* v = std::getenv("PLOT_DEMO_CROSSHAIR"); + v != nullptr && v[0] != '\0' && v[0] != '0') { + crosshair_row = static_cast(std::strtoul(v, nullptr, 10)); + if (crosshair_row >= kRows) crosshair_row = 1u; // a row that does not exist means row 1 + } + const plot::glfw::FrameFn frame = [&](plot::Plot&, plot::f64 dt) { for (std::unique_ptr& s : series) s->acquire(); // once per frame @@ -556,6 +568,18 @@ int main() { figure.plot(0).set_cursor_x(cursor_b, xr.min + 0.70 * xr.span()); figure.plot(0).set_region(region, xr.min + 0.42 * xr.span(), xr.min + 0.58 * xr.span()); } + + if (crosshair_row < kRows) { + // The pointer at a fixed screen position in the hovered row, read back to *its* data + // coordinates: one x, spread to every row by the figure, and the hovered row's own y. + const plot::Plot& hovered = std::as_const(figure.plot(crosshair_row)); + const plot::Rect a = hovered.layout().axes; + figure.set_crosshair( + hovered.x_transform(0u).to_data_x(static_cast(a.x) + 0.60 * static_cast(a.w)), + crosshair_row, + hovered.y_transform(0u).to_data_y(static_cast(a.y) + 0.45 * static_cast(a.h)), + true); + } for (u32 row = 0; row < kRows; ++row) { const plot::input::Controller* c = host.row_controller(row); if (c == nullptr) continue; @@ -682,6 +706,17 @@ int main() { p.trace_count(), static_cast(p.layout().legend.rows.size()), figure.x_link(row)); } + // The crosshair's geometry per row (spec §17), which is what the headless screenshot's ink is + // read against: with the scripted crosshair on row `PLOT_DEMO_CROSSHAIR`, that row has a + // horizontal line and the others do not, while every row has the shared vertical one. + for (u32 row = 0; row < kRows; ++row) { + const plot::CrosshairLayout& c = std::as_const(figure.plot(row)).layout().crosshair; + std::printf("[multiplot] crosshair row %u: active %d, vertical box w %.2f at x %.1f," + " horizontal box h %.2f at y %.1f\n", + row, c.active ? 1 : 0, static_cast(c.vertical.w), + static_cast(c.vertical.x), static_cast(c.horizontal.h), + static_cast(c.horizontal.y)); + } std::printf("[multiplot] region %u: x [%g, %g] s, n %llu, mean %.4f, rms %.4f, min" " %.4f, max %.4f, integral %.4f\n", region, rs.x0, rs.x1, static_cast(rs.count), rs.mean, rs.rms, diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index 7396557..3ab9d8e 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -860,3 +860,103 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 2.0, 1e-12); } } + +/// Spec §17, the reported defect: a figure's crosshair showed **no horizontal line** because its +/// sync was vertical-only. The fix is not a figure-wide y -- which would mean a different value in +/// every row -- but the *pointer row's own* y: the shared x goes to every row, and the horizontal +/// line to the hovered row alone, where a y is a value of that row's own scale. +/// +/// The rows here carry **different** y ranges on purpose, so "the hovered row's y" and "a sibling's +/// y" are genuinely different pixel rows; the exclusivity asserted below is therefore about the +/// fix and not a coincidence of equal axes. +PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) { + plot::Figure figure(3u); + figure.set_viewport(plot::Rect{0.0f, 0.0f, 640.0f, 480.0f}); + static plot::data::ArraySeries ramp(std::vector{0.0f, 10.0f, 20.0f, 30.0f, 40.0f, + 50.0f, 60.0f, 70.0f, 80.0f, 90.0f}, + 0.0, 1.0); + static plot::render::EmbeddedMonoFont font; + const plot::Range y_range[3] = {{0.0, 100.0}, {0.0, 1000.0}, {-50.0, 50.0}}; + for (plot::u32 row = 0; row < 3u; ++row) { + plot::Plot& p = figure.plot(row); + p.set_text_backend(&font); + p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", y_range[row], false, plot::AxisPlace::Left, false}); + p.add_trace({&ramp, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, "ramp"}); + } + figure.layout(); + + // The pointer in row 1 at y = 150: inside row 1's [0, 1000] scale, and *outside* rows 0 and 2. + figure.set_crosshair(3.0, 1u, 150.0, true); + figure.layout(); + + // Every row carries the shared x's vertical line, all at one pixel column; no row but the + // hovered one draws a horizontal line at all (a zero-sized box, not a line at y = 0). + plot::f32 column = 0.0f; + for (plot::u32 row = 0; row < 3u; ++row) { + const plot::CrosshairLayout& c = figure.plot(row).layout().crosshair; + PLOT_CHECK(c.active); + PLOT_CHECK(c.vertical.w > 0.0f); + PLOT_CHECK(c.vertical.h > 0.0f); + if (row == 0u) { + column = c.vertical.x; + } else { + PLOT_CHECK_NEAR(c.vertical.x, column, 1e-6); + } + if (row != 1u) { + PLOT_CHECK(c.horizontal.w == 0.0f); + PLOT_CHECK(c.horizontal.h == 0.0f); + } + } + + // Row 1 draws it, at *its own* y: the stroke box is centred where 150 maps on row 1's y axis. + const plot::CrosshairLayout& hovered = figure.plot(1u).layout().crosshair; + PLOT_CHECK(hovered.horizontal.w > 0.0f); + PLOT_CHECK(hovered.horizontal.h > 0.0f); + const plot::Transform ty1 = figure.plot(1u).y_transform(0u); + const plot::f32 row1_at = hovered.horizontal.y + 0.5f * hovered.horizontal.h; + PLOT_CHECK_NEAR(row1_at, static_cast(ty1.to_screen_y(150.0)), 0.75); + + // The pointer moves to row 2: the horizontal line moves *there*, to row 2's own y, and the + // vertical lines are left exactly where they were -- one hover, one row's y, N rows' x. + figure.set_crosshair(3.0, 2u, 0.0, true); + figure.layout(); + PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f); + PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.w == 0.0f); + const plot::CrosshairLayout& second = figure.plot(2u).layout().crosshair; + PLOT_CHECK(second.horizontal.h > 0.0f); + const plot::Transform ty2 = figure.plot(2u).y_transform(0u); + const plot::f32 row2_at = second.horizontal.y + 0.5f * second.horizontal.h; + PLOT_CHECK_NEAR(row2_at, static_cast(ty2.to_screen_y(0.0)), 0.75); + PLOT_CHECK(row2_at != row1_at); // a different row of the figure, not the same line re-placed + for (plot::u32 row = 0; row < 3u; ++row) { + PLOT_CHECK_NEAR(figure.plot(row).layout().crosshair.vertical.x, column, 1e-6); + } + + // A row that does not exist -- the pointer in the gap between rows -- is "no row under the + // pointer": every row keeps the vertical line and none gets a horizontal one. + figure.set_crosshair(3.0, 9u, 10.0, true); + figure.layout(); + for (plot::u32 row = 0; row < 3u; ++row) { + const plot::CrosshairLayout& c = figure.plot(row).layout().crosshair; + PLOT_CHECK(c.vertical.w > 0.0f); + PLOT_CHECK(c.horizontal.h == 0.0f); + } + + // The two-argument overload stays vertical-only on every row -- a caller with only a coordinate + // gets no horizontal line, because there is a y it never gave. + figure.set_crosshair(3.0, true); + figure.layout(); + for (plot::u32 row = 0; row < 3u; ++row) { + const plot::CrosshairLayout& c = figure.plot(row).layout().crosshair; + PLOT_CHECK(c.vertical.w > 0.0f); + PLOT_CHECK(c.horizontal.h == 0.0f); + } + + // A y that is not a number leaves the hovered row vertical-only rather than clearing the whole + // hover: the pointer is still in that row, it just has no y to mark. + figure.set_crosshair(3.0, 1u, std::nan(""), true); + figure.layout(); + PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f); + PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f); +} diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 890a7f8..f57dabe 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -4907,3 +4907,374 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) { ctx.destroy(); } + +// ------------------------------------------------- the crosshair's style (spec §17) + +namespace { + +/// The `mod(v_along, on + off)` pattern is a statement about a *line's length*, so the helpers below +/// read the frame one dimensionally: a boolean row (which columns hold ink) whose maximal `true` +/// runs are the dashes. +bool column_has_ink(const std::vector& frame, plot::u32 fb_w, plot::i32 x, plot::i32 y0, + plot::i32 y1, bool (*pred)(const plot::u8[4])) { + if (x < 0 || x >= static_cast(fb_w)) return false; + for (plot::i32 y = std::max(0, y0); y < y1; ++y) { + if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) return true; + } + return false; +} + +/// How many maximal `true` runs a boolean row holds: one for a solid line, and about `w / (on + off)` +/// for a dash of period `on + off` across a width `w`. +plot::u32 run_count(const std::vector& on) { + plot::u32 runs = 0; + for (plot::usize i = 0; i < on.size(); ++i) { + if (on[i] && (i == 0u || !on[i - 1u])) ++runs; + } + return runs; +} + +/// The boolean row `[x0, x1)` in which each entry is whether that column holds any `pred` pixel in +/// rows `[y0, y1)`, plus a one-column count of the rows it is true in. +std::vector ink_row(const std::vector& frame, plot::u32 fb_w, plot::i32 x0, + plot::i32 x1, plot::i32 y0, plot::i32 y1, + bool (*pred)(const plot::u8[4])) { + std::vector on; + on.reserve(static_cast(std::max(0, x1 - x0))); + for (plot::i32 x = x0; x < x1; ++x) { + on.push_back(column_has_ink(frame, fb_w, x, y0, y1, pred)); + } + return on; +} + +/// The framebuffer column of `[x0, x1)` holding the most pixels `pred` accepts in rows `[y0, y1)`, +/// and that count -- how a case finds the one column a vertical stroke lives in without guessing +/// where a transform put it. +plot::i32 densest_column(const std::vector& frame, plot::u32 fb_w, plot::i32 x0, + plot::i32 x1, plot::i32 y0, plot::i32 y1, + bool (*pred)(const plot::u8[4]), plot::u32* out_count) { + plot::i32 best = -1; + plot::u32 best_n = 0; + for (plot::i32 x = x0; x < x1; ++x) { + plot::u32 n = 0; + for (plot::i32 y = std::max(0, y0); y < y1; ++y) { + if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) ++n; + } + if (n > best_n) { + best_n = n; + best = x; + } + } + if (out_count != nullptr) *out_count = best_n; + return best; +} + +/// The grid *majors* (0x2A3038 over the 0x101418 background), which no other element of these +/// frames reaches: the minors are 0x1C2126 (too dark for the bound), the crosshair is near-white, +/// a trace is saturated and the cursor is the orange accent. +bool grid_pixel(const plot::u8 px[4]) { + const plot::u8 lo = std::min(px[0], std::min(px[1], px[2])); + const plot::u8 hi = std::max(px[0], std::max(px[1], px[2])); + return px[0] >= 30 && px[0] <= 90 && hi <= 100 && static_cast(hi - lo) <= 30; +} + +/// The orange cursor accent 0xFFB020, at full coverage. +bool cursor_pixel(const plot::u8 px[4]) { + return px[0] > 200 && px[1] > 120 && px[1] < px[0] && px[2] < 90; +} + +plot::u32 crosshair_ink_columns(const std::vector& frame, plot::u32 fb_w, + const plot::Rect& axes) { + const plot::i32 row0 = static_cast(axes.y) + static_cast(axes.h) / 2; + plot::u32 cols = 0; + for (plot::i32 x = static_cast(axes.x); x < static_cast(axes.right()); ++x) { + if (column_has_ink(frame, fb_w, x, row0, row0 + 2, crosshair_pixel)) ++cols; + } + return cols; +} + +} // namespace + +/// Spec §17, the style: the crosshair is drawn **dashed** by default -- a passive hover instrument +/// reads as chrome rather than as data -- and the dash is a fragment-shader effect on `v_along`, so +/// nothing about the geometry changes. This case reads the pattern off the pixels: a dashed stroke +/// is many runs along its length, a solid one is a single run, and a longer gap is a longer period, +/// so **fewer** dashes over the same width. `dash_off == 0` is what makes it solid. +PLOT_TEST(crosshair_dash_is_configurable_and_zero_off_means_solid) { + plottest::HeadlessContext ctx(kW, kH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f}; + plot::Plot p; + p.set_viewport(vp); + const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + // A warm-up frame, so the strips have settled and `axes` below is a *drawn* frame's rect. + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + const plot::Rect axes = p.layout().axes; + const plot::Transform tx = p.x_transform(ax); + const plot::Transform ty = p.y_transform(0u); + + // The horizontal line mid-axes, on a *pixel* centre so a 1 device px stroke lands on exactly one + // framebuffer row: the dash is what this case varies, and a fractional coverage would make the + // row's ink ragged for reasons that are not the dash. + const f32 cxp = std::floor(axes.x) + 0.5f + 60.0f; + const f32 cyp = std::floor(axes.y) + 0.5f + 40.0f; + p.set_crosshair(tx.to_data_x(static_cast(cxp)), ty.to_data_y(static_cast(cyp)), true); + + const auto frame_with = [&](f32 off) { + // Width 1 device px: the 0.75 default's sub-pixel coverage the `crosshair_pixel` predicate (a + // full-coverage colour) would not count uniformly, and the dash is this case's subject. + p.set_crosshair_style({1.0f, 4.0f, off}); + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + return read_frame(ctx); + }; + + const plot::i32 row = static_cast(cyp); + const plot::i32 x0 = static_cast(axes.x) + 1; + const plot::i32 x1 = static_cast(axes.right()) - 1; + const auto runs = [&](f32 off) { + return run_count(ink_row(frame_with(off), kW, x0, x1, row - 1, row + 2, crosshair_pixel)); + }; + + // 4 on / 4 off over the axes' width: many dashes, not one line. + const plot::u32 dashed = runs(4.0f); + PLOT_CHECK(dashed > 4u); + // Off == 0 is solid: one run, the whole width, no gap anywhere. + PLOT_CHECK(runs(0.0f) == 1u); + // A longer gap is a longer period, so fewer dashes over the same width. + const plot::u32 sparse = runs(12.0f); + PLOT_CHECK(sparse > 1u); + PLOT_CHECK(sparse < dashed); + + ctx.destroy(); +} + +/// Spec §17, the stroke: "a **thinner** stroke (~0.75 logical px rather than a trace's line +/// width)". The claim is asserted twice -- as the numbers the layout resolves (what the renderer +/// hands the shader) and as the ink those numbers produce on the real driver -- so a change that +/// kept the number but stopped drawing at it, or the reverse, fails here. +PLOT_TEST(crosshair_is_thinner_than_a_trace) { + const plot::render::Style style; + const plot::Trace trace; + PLOT_CHECK(style.crosshair_width > 0.0f); + PLOT_CHECK(style.crosshair_width < trace.width); // 0.75 < a trace's 1.5 + + // The resolved stroke: the style's value through `device()`, doubled at `ui_scale` 2, and the + // crosshair's own override where it has one. + plot::Plot p; + p.set_viewport(plot::Rect{0.0f, 0.0f, 600.0f, 320.0f}); + p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + p.set_crosshair_style({-1.0f, 1.0e6f, 0.0f}); // width from the style; solid, for measurement + p.set_crosshair(1.0, true); // vertical-only: one line to measure + p.set_ui_scale(1.0f); + PLOT_CHECK_NEAR(p.layout().crosshair.stroke, style.crosshair_width, 1e-6); + p.set_ui_scale(2.0f); + PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.0 * style.crosshair_width, 1e-6); + p.set_crosshair_style({1.25f, 1.0e6f, 0.0f}); + PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.5, 1e-6); + + // ... and drawn: the ink width follows the width and the scale. + plottest::HeadlessContext ctx(kW, kH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f}; + plot::Plot q; + q.set_viewport(vp); + const plot::u32 qx = q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + r.style().crosshair_width = style.crosshair_width; + r.begin_frame(kW, kH, vp); + r.draw(q); + r.end_frame(); + const plot::Rect axes = q.layout().axes; + q.set_crosshair_style({3.0f, 1.0e6f, 0.0f}); // solid, explicit width + q.set_crosshair(q.x_transform(qx).to_data_x(static_cast(std::floor(axes.x) + 120.5f)), true); + + const auto ink_columns = [&](f32 scale) { + r.style().ui_scale = scale; + r.begin_frame(kW, kH, vp); + r.draw(q); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + const std::vector frame = read_frame(ctx); + return crosshair_ink_columns(frame, kW, q.layout().axes); + }; + + const plot::u32 wide_1x = ink_columns(1.0f); + const plot::u32 wide_2x = ink_columns(2.0f); + PLOT_CHECK(wide_1x > 0u); + PLOT_CHECK(wide_2x > wide_1x); // `ui_scale` 2 doubles every metric, the stroke included + + q.set_crosshair_style({1.0f, 1.0e6f, 0.0f}); + const plot::u32 thin_1x = ink_columns(1.0f); + PLOT_CHECK(thin_1x >= 1u); + PLOT_CHECK(thin_1x < wide_1x); // a thinner stroke is thinner ink + + ctx.destroy(); +} + +/// Spec §17's shared-pipeline guard: the dash is a *per-draw* uniform and only the crosshair sets +/// it, so every other line -- grid, traces, axis lines, regions, cursors, the legend border -- draws +/// exactly as before. The crosshair is drawn in the middle of the interior passes (after the grid, +/// the axis lines and the traces; before the regions, the cursors and the legend), so an unreset or +/// leaked dash would show up on an element drawn *after* it -- and this case draws one of each kind +/// around it and reads the pixels. +PLOT_TEST(grid_traces_and_cursors_are_unaffected_by_the_dash_uniform) { + plottest::HeadlessContext ctx(kW, kH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f}; + + // --- the grid and the cursor, with a dashed crosshair drawn between them ---------------- + plot::Plot p; + p.set_viewport(vp); + const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + const plot::u32 cur = p.add_cursor(37.0, plot::Color::hex(0xFFB020)); + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + const plot::Rect axes = p.layout().axes; + const plot::Transform tx = p.x_transform(ax); + const plot::Transform ty = p.y_transform(0u); + // A dashed crosshair mid-axes, deliberately *between* ticks in x so its vertical line does not + // land on a grid column and muddy the grid reading below. + p.set_crosshair(tx.to_data_x(static_cast(std::floor(axes.x) + 130.5f)), + ty.to_data_y(static_cast(std::floor(axes.y) + 50.5f)), true); + p.set_crosshair_style({1.0f, 4.0f, 4.0f}); // a real dash: what a leak would carry + + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + const std::vector frame = read_frame(ctx); + + // The cursor is drawn *after* the crosshair, so it is the leak guard that matters: a dash uniform + // that survived the crosshair's flush would break its full-height stroke into a 4/4 pattern. + plot::u32 cursor_rows = 0; + const plot::i32 cursor_col = densest_column(frame, kW, static_cast(axes.x), + static_cast(axes.right()), + static_cast(axes.y), + static_cast(axes.bottom()), cursor_pixel, + &cursor_rows); + PLOT_CHECK(cursor_col >= 0); + PLOT_CHECK(cursor_rows >= static_cast(axes.h) * 3u / 4u); // a solid, full-height line + PLOT_CHECK(run_count(ink_row(frame, kW, cursor_col, cursor_col + 1, static_cast(axes.y), + static_cast(axes.bottom()), cursor_pixel)) == 1u); + + // The grid, drawn *before* the crosshair: its columns are solid but for the one row the crosshair's + // own horizontal line crosses them at (the crosshair overdraws it), so at most one gap per column. + plot::u32 grid_rows = 0; + const plot::i32 grid_col = densest_column(frame, kW, static_cast(axes.x), + static_cast(axes.right()), + static_cast(axes.y), + static_cast(axes.bottom()), grid_pixel, + &grid_rows); + PLOT_CHECK(grid_col >= 0); + PLOT_CHECK(grid_rows >= static_cast(axes.h) * 3u / 4u); + PLOT_CHECK(run_count(ink_row(frame, kW, grid_col, grid_col + 1, static_cast(axes.y), + static_cast(axes.bottom()), grid_pixel)) <= 2u); + (void)cur; + + // --- the trace, with the same dashed crosshair over it ---------------------------------- + plot::Plot q; + q.set_viewport(vp); + q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + std::vector ramp(1000u); + for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast(i) * 0.1f; + plot::data::ArraySeries series(ramp, 0.0, 0.1); + // Unnamed: a named trace would draw a legend panel over the axes' top-right corner, which is + // where this rising trace spends its last columns -- a backplate hiding data for a reason that + // is not the dash. + q.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, ""}); + r.begin_frame(kW, kH, vp); + r.draw(q); + r.end_frame(); + const plot::Rect q_axes = q.layout().axes; + q.set_crosshair(q.x_transform(0u).to_data_x(static_cast(std::floor(q_axes.x) + 130.5f)), + q.y_transform(0u).to_data_y(static_cast(std::floor(q_axes.y) + 50.5f)), + true); + q.set_crosshair_style({1.0f, 4.0f, 4.0f}); + // No readout panel: it is a backplate drawn *over* the data, so it would cover trace columns for + // reasons that have nothing to do with the dash this case is about. + q.set_crosshair_readout(false); + + r.begin_frame(kW, kH, vp); + r.draw(q); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + // Every column of the axes holds trace ink: the polyline rises across the whole width and a dash + // leaked into the trace pipeline would leave the columns in its gaps empty. Four columns of slack + // is where the crosshair's own thin vertical line overdraws the trace. + const plot::u32 covered = covered_columns(ctx, q_axes, static_cast(q_axes.w)); + PLOT_CHECK(covered >= static_cast(q_axes.w) - 4u); + + ctx.destroy(); +} + +/// Spec §17's phase requirement: a dash whose phase depended on the axis transform would crawl as +/// the view panned and look like noise. It is anchored to the segment start -- and both crosshair +/// lines are staged from an *axes edge* (the vertical from `y_range.min`, the horizontal from +/// `x_range.min`), so the phase is measured from a fixed edge of the rect. The observable form of +/// "stable" is that the same state renders the same bytes twice: no per-frame counter, no +/// time source, no layout drift in the phase. +PLOT_TEST(dash_phase_is_stable_across_frames) { + plottest::HeadlessContext ctx(kW, kH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f}; + plot::Plot p; + p.set_viewport(vp); + const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + std::vector ramp(1000u); + for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast(i) * 0.1f; + plot::data::ArraySeries series(ramp, 0.0, 0.1); + p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, "ramp"}); + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + const plot::Rect axes = p.layout().axes; + p.set_crosshair(p.x_transform(ax).to_data_x(static_cast(std::floor(axes.x) + 130.5f)), + p.y_transform(0u).to_data_y(static_cast(std::floor(axes.y) + 50.5f)), true); + p.set_crosshair_style({1.0f, 4.0f, 4.0f}); + + const auto render = [&]() { + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + return read_frame(ctx); + }; + + const std::vector first = render(); + const std::vector second = render(); + PLOT_CHECK(first.size() == second.size()); + PLOT_CHECK(first == second); + + ctx.destroy(); +}