From 2d819c42285bc802cd465e8c1172ffa2221d29d6 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 17:00:02 +0200 Subject: [PATCH] fix(render): clip grid, traces, regions and cursors to the plot area --- include/plot/render/renderer.hpp | 59 +++- src/render/renderer.cpp | 84 +++++- tests/test_render_smoke.cpp | 474 +++++++++++++++++++++++++++++++ 3 files changed, 610 insertions(+), 7 deletions(-) diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 6d17583..f716a02 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -111,6 +111,12 @@ class Renderer { /// bottom-left origin once, clears that region to `style().background` and /// empties the staging batches. /// + /// The scissor that bounds the clear is the frame's *baseline clip*: it stays engaged, + /// set to the same viewport rect, so every pass of the frame is bounded by the region + /// the host gave the renderer -- and `draw()` narrows and releases it again around the + /// passes that draw inside a plot's axes (see `set_clip()`). `end_frame()` turns the + /// scissor test off again, so a host never inherits a clip it did not ask for. + /// /// It is also where the instance stores wait -- once each -- for the previous /// frame's draws to retire the ring they read, before this frame writes any /// slot (see `InstanceStore::begin_frame`): a frame never synchronises with the @@ -152,14 +158,30 @@ class Renderer { /// line geometry staged with everything else, the names are glyphs on the text /// path, and both read the boxes `PlotLayout::legend` measured. It is the last /// line batch, so the legend cannot be drawn under a trace, and it leaves no GL - /// state of its own -- the frame's blend and its neighbours' scissor already say - /// everything a fill and a hairline border need (see end_frame's residue list). + /// state of its own: it is drawn inside the same axes clip the interior passes share, + /// and the frame's blend already says everything a fill and a hairline border need + /// (see end_frame's residue list). /// /// Regions are part of that same evidence: the fill is one instance of the line /// pipeline (`fill_pixels()`, the primitive the legend panel uses), the bounds are /// vertical strokes and the label is a staged glyph run, so a frame with regions /// leaves behind exactly what a frame without them does. void draw(Plot& plot); + /// + /// **The clip** is the one piece of state the frame moves to keep data inside the plot + /// area (spec §13): the passes that draw *inside* `axes` -- grid, traces, regions, + /// cursors, the measurement band, annotations and the legend -- run with the scissor + /// narrowed to `layout().axes`, intersected with the frame's viewport, while the axis + /// lines (chrome drawn on the rect's own edge, which overhangs it by half a stroke) and + /// the tick labels and titles (outside the rect by design) run with the frame's viewport + /// clip. A sample outside the visible range, or a segment the gathered window's slack + /// hangs past the end of the window, therefore cannot be drawn over the label strips -- + /// or, in a `Figure`, into the row above. + /// + /// Nothing moves to make that happen: the clip is a GL state change and not a geometry + /// one, so the vertices staged, the transforms and the data-space invariant are exactly + /// what they were. + /// Draws a whole `Figure`: every row in its own rect, then the figure's one shared /// x axis. @@ -192,6 +214,16 @@ class Renderer { /// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and /// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the /// viewport left at the frame's rect (the caller sets its own for the next pass). + /// + /// The scissor is the other piece of state a frame moves, and it ends the frame *off*: + /// `begin_frame()` engages it on the frame's viewport -- the box the clear uses -- + /// `draw()` narrows it to a plot's `axes` for the interior passes and releases it back to + /// the viewport for the labels and titles, and this turns the test off. A host is + /// therefore never left holding a clip it did not ask for: not the axes rect of the last + /// interior pass, and not the viewport's either. The box itself is left at the viewport's + /// own rect, which is where that release puts it, so a host that enables the scissor + /// again without setting one gets the region the frame drew in. + /// The one piece of global state the frame *changed* is handed back rather than /// left where the frame wanted it: the clear colour, which is /// `style().background` only for the duration of the frame. GL keeps no history @@ -338,6 +370,29 @@ class Renderer { /// is texture-bound, so this runs once per frame, after all line geometry. void flush_text() noexcept; + /// Narrows the frame's clip to `rect` -- framebuffer pixels, top-left origin, like every + /// `Rect` here -- and *intersects* it with the frame's viewport, so the clip can only + /// ever be smaller than the region the frame owns: a rect the layout got wrong, or one + /// that reaches past the frame, clips the interior passes away rather than letting them + /// paint over the host's surface. Enables `GL_SCISSOR_TEST`; `clear_clip()` puts it back. + /// + /// The box is integral and GL's origin is bottom-left, so this is also where a `Rect` + /// becomes a scissor box, and it rounds *inward* -- see `clip_to()`. Called once per + /// plot with `layout().axes`, and released again before the passes that draw outside the + /// axes (see `draw_plot()`). + void set_clip(const Rect& rect) noexcept; + /// Puts the frame's clip back to its viewport: the state `begin_frame()` leaves, and the + /// one the axis lines, the tick labels and the axis titles run with -- chrome that lives + /// outside `axes` by design, and would be erased under the axes clip. + void clear_clip() noexcept; + /// The one place a scissor box is computed from a rect's four edges: clamped to the + /// framebuffer's rows, rounded *inward* (`ceil` the near edges, `floor` the far ones) so + /// the box holds only pixels the rect entirely contains -- rounding outward would let a + /// trace's half-pixel fringe past the edge it is being kept behind -- and flipped to GL's + /// bottom-left origin. An empty or unordered rect (a degenerate axes, or a NaN) sets an + /// empty box, which rejects every fragment: the honest meaning of "clip to nothing". + void clip_to(f32 x0, f32 y0, f32 x1, f32 y1) noexcept; + /// Fills `rect` -- in framebuffer pixels, like `Rect` -- with `color`, in one /// instance: a horizontal stroke whose width is the rect's height *is* the rect, /// because a stroke's quad is its perpendicular width and the fragment shader diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 05cadb6..cbcd32b 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -279,6 +279,10 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept { // framebuffer coordinates and is not affected by glViewport, which is why the // clear happens before the viewport is set. // + // The same clip stays engaged for the whole frame as its baseline -- set here, and put + // back by `clear_clip()` -- so a pass that is not narrowed to the axes is still bounded + // by the region the host gave the renderer. `end_frame()` turns the test off again. + // // The host's clear colour is queried once, before the first frame sets its own: // GL keeps no history for it, so this is the only moment the value can be // recovered for `end_frame()` to hand back. A host that changes its own clear @@ -291,12 +295,10 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept { host_clear_ = Color{rgba[0], rgba[1], rgba[2], rgba[3]}; host_clear_saved_ = true; } - gl::glEnable(gl::GL_SCISSOR_TEST); - gl::glScissor(vx, vy, vw, vh); + set_clip(viewport_px); const Color& background = style_.background; gl::glClearColor(background.r, background.g, background.b, background.a); gl::glClear(gl::GL_COLOR_BUFFER_BIT); - gl::glDisable(gl::GL_SCISSOR_TEST); gl::glViewport(vx, vy, vw, vh); // The pipelines write straight alpha and rely on blending being on; setting it @@ -306,6 +308,53 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept { gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA); } +// --------------------------------------------------------------- the frame's clip + +void Renderer::set_clip(const Rect& rect) noexcept { + // Nested, never replacing: the box is the *intersection* of `rect` with the frame's + // viewport, so the clip can only ever be smaller than the region this frame owns -- + // which is the same region `begin_frame()`'s clear covered and `clear_clip()` puts back. + clip_to(std::max(rect.x, viewport_.x), std::max(rect.y, viewport_.y), + std::min(rect.right(), viewport_.right()), + std::min(rect.bottom(), viewport_.bottom())); +} + +void Renderer::clear_clip() noexcept { + clip_to(viewport_.x, viewport_.y, viewport_.right(), viewport_.bottom()); +} + +void Renderer::clip_to(f32 x0, f32 y0, f32 x1, f32 y1) noexcept { + // The one conversion of a rect to a scissor box, and both halves of it matter here: GL's + // box is integral and its origin is bottom-left, while a `Rect` is fractional and + // top-left. The rounding is *inward* -- `ceil` the near edges, `floor` the far ones -- + // so the box holds only the pixels `rect` entirely contains. Rounding outward would let a + // trace's half-pixel fringe over the edge it is being kept behind (a `Figure` row + // bleeding a single row into its neighbour is exactly that, and so is a marker's fringe + // over a label strip), while rounding inward at most drops the boundary half-pixel of a + // stroke that a fractional axes rect does not fully contain either. + // + // An unordered rect -- empty, inverted, or NaN, whose every comparison is false -- sets + // an empty box, which rejects every fragment: the honest reading of "clip to nothing", + // and the reason a degenerate axes draws no data instead of drawing all of it. + gl::GLint left = 0; + gl::GLint top = 0; + gl::GLsizei w = 0; + gl::GLsizei h = 0; + if (x0 <= x1 && y0 <= y1) { + const gl::GLint x_min = static_cast(std::ceil(x0)); + const gl::GLint x_max = static_cast(std::floor(x1)); + const gl::GLint y_min = static_cast(std::ceil(y0)); + const gl::GLint y_max = static_cast(std::floor(y1)); + left = x_min; + top = y_min; + w = static_cast(std::max(0, x_max - x_min)); + h = static_cast(std::max(0, y_max - y_min)); + } + gl::glEnable(gl::GL_SCISSOR_TEST); + // Top-left to bottom-left: the frame's last row is GL's first. + gl::glScissor(left, static_cast(fb_h_) - top - h, w, h); +} + bool Renderer::begin_frame_text() noexcept { // The plot reserves its label strips from TextBackend metrics, so it must // measure with the size the text pipeline draws with, and re-read the style @@ -416,10 +465,27 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { // after this returns, and `draw(Figure&)` after it has drawn every row and the // figure's shared x axis, so a figure is one text draw call rather than one per // row -- and the shared axis' labels join the same batch as the labels above them. + // + // The clip alternates around those passes, and every transition is one of the two + // helpers -- no scissor is set anywhere else -- so the sequence is this: the passes that + // draw *inside* the axes run narrowed to `layout.axes` (intersected with the frame's + // viewport, which is the only clip a hostile rect could otherwise escape), the passes + // that draw outside it run with the frame's viewport clip. A sample outside the visible + // range, or a segment the gathered window's slack hangs past the end of the window, is + // therefore never drawn over the label strips or, in a `Figure`, into the row above. + // + // Two of the three releases are load-bearing rather than tidiness. The axis lines are + // chrome drawn *on* the axes' edge: half of each stroke lies outside the rect by + // construction, so clipping them would thin them at the frame's border. The tick labels + // and the axis titles live outside `axes` by design, and clipping them would erase them. + // (They are staged last here, but their pixels are not: text is one batch flushed after + // the frame's line geometry, in the order it was staged, so reordering these two calls + // against the line passes would not change a pixel.) + set_clip(layout.axes); draw_grid(plot); + clear_clip(); draw_axis_lines(plot); - draw_tick_labels(plot, layout); - draw_titles(plot, layout); + set_clip(layout.axes); draw_traces(plot, layout); // Regions over the traces and under the cursors: an area of interest the data is // still legible through, with the markers that bound it on top of it. @@ -428,6 +494,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { draw_measures(plot, layout); draw_annotations(plot); draw_legend(plot, layout); + clear_clip(); + draw_tick_labels(plot, layout); + draw_titles(plot, layout); } void Renderer::end_frame() noexcept { @@ -458,6 +527,11 @@ void Renderer::end_frame() noexcept { gl::glActiveTexture(gl::GL_TEXTURE0); gl::glBindTexture(gl::GL_TEXTURE_2D, 0); gl::glDisable(gl::GL_BLEND); + // The scissor, too: the frame engaged it (its baseline clip) and each plot narrowed and + // released it, so what is left behind is the test *off* -- the state the residue list + // promises -- rather than whichever rect the last pass was clipped to. The box stays + // where `clear_clip()` put it, the frame's viewport rect. + gl::glDisable(gl::GL_SCISSOR_TEST); if (host_clear_saved_) { gl::glClearColor(host_clear_.r, host_clear_.g, host_clear_.b, host_clear_.a); } diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 86e41de..7f7a04e 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -3845,3 +3845,477 @@ PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) { ctx.destroy(); } +// ---------------------------------------------------- the axes clip (spec §13) + +// The reported defect this section is about: a trace is staged in *data* space and +// mapped through the axes transform, so a sample outside the visible range maps +// outside `axes` and was drawn there -- over the label strips, over the host's own +// surface outside the viewport, and in a `Figure` straight into the neighbouring row. +// The frame's only scissor was the one around its clear, which bounds that overflow at +// the framebuffer and nowhere else. The fix is a clip to `layout().axes`, intersected +// with the viewport, around every pass that draws inside the axes; the cases below +// assert it from the pixels, and prove it is neither missing nor too small. + +namespace { + +/// `n` samples alternating between +50 and -50. On the [0, 1] y axis these cases fix, +/// every sample maps fifty axes' heights off the rect: the +50 one 49 `axes.h` above its +/// top edge, the -50 one 50 below its bottom edge -- thousands of pixels, i.e. past the +/// target -- so the polyline leaves the axes at its first segment whatever window is +/// framed. +std::vector off_scale_wave(plot::u32 n) { + std::vector y(n); + for (plot::u32 i = 0; i < n; ++i) y[i] = (i % 2u == 0u) ? 50.0f : -50.0f; + return y; +} + +/// `n` samples alternating between 0.25 and 0.75: inside the [0, 1] axis these cases fix, so +/// this trace's overflow is the *window's* alone. The renderer gathers one sample of slack +/// on each side of the visible window -- which is what covers a partially visible end +/// bucket, and why nothing is clamped in CPU -- so a coarse series hangs a whole segment, +/// some fifty pixels wide, over the axes' left edge and across both y strips. +std::vector in_range_wave(plot::u32 n) { + std::vector y(n); + for (plot::u32 i = 0; i < n; ++i) y[i] = (i % 2u == 0u) ? 0.25f : 0.75f; + return y; +} + +/// The x window these cases frame and the two resolutions they sample it at: forty samples +/// over it (a segment some 120 px wide, so the gathered window's one sample of slack hangs a +/// whole segment over the end of the axes) and six thousand (a near-vertical segment per +/// pixel of the axes, i.e. a comb that crosses every column of it). +constexpr plot::f64 kOffScaleSpan = 100.0; +constexpr plot::f64 kOffScaleMin = 10.4; +constexpr plot::f64 kOffScaleMax = 20.6; +constexpr plot::u32 kCoarseSamples = 40u; +constexpr plot::u32 kCombSamples = 6000u; + +/// The trace-coloured ink a frame holds *outside* `axes`: how many pixels, and how far +/// past each edge the furthest of them reached. The assertions in this section are the +/// clip's; this is the defect *measured*, which is where its size is quoted from. +struct OutsideInk { + plot::u32 pixels = 0u; + plot::f32 left = 0.0f; + plot::f32 right = 0.0f; + plot::f32 above = 0.0f; + plot::f32 below = 0.0f; +}; + +OutsideInk outside_ink(const std::vector& frame, plot::u32 fb_w, plot::u32 fb_h, + const plot::Rect& axes) { + OutsideInk out; + for (plot::u32 y = 0; y < fb_h; ++y) { + for (plot::u32 x = 0; x < fb_w; ++x) { + if (!trace_pixel(&frame[(plot::usize(y) * fb_w + x) * 4u])) continue; + const plot::f32 cx = static_cast(x); + const plot::f32 cy = static_cast(y); + // Entirely inside the rect: not an overflow. A pixel straddling an edge is + // counted, because that is a pixel the ink's own extent reached outside. + if (cx >= axes.x && cx + 1.0f <= axes.right() && cy >= axes.y && cy + 1.0f <= axes.bottom()) { + continue; + } + ++out.pixels; + out.left = std::max(out.left, axes.x - cx); + out.right = std::max(out.right, (cx + 1.0f) - axes.right()); + out.above = std::max(out.above, axes.y - cy); + out.below = std::max(out.below, (cy + 1.0f) - axes.bottom()); + } + } + return out; +} + +/// Any pixel of the axes' row `y` (framebuffer rows, top-left origin, like every `Rect` +/// here) that a predicate accepts: the row-wise twin of `column_has` above, so a case can +/// ask whether the clip reaches the rect's own top and bottom rows. +bool axes_row_has(plottest::HeadlessContext& ctx, const plot::Rect& axes, plot::u32 y, + bool (*pred)(const plot::u8*)) { + for (plot::u32 x = static_cast(axes.x); x < static_cast(axes.right()); ++x) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px)); + if (pred(px)) return true; + } + return false; +} + +/// The cursor colour (0xFFB020) at any coverage: warm, with green strictly between red +/// and blue. Nothing else in these frames produces it -- the trace is blue, the region +/// magenta or green, the chrome and the text grey, and the measure band's own tint is +/// 0xFFB020 at 15% over the background, too dark for the red bound below -- so a count of +/// it is a count of cursor strokes. +bool accent_pixel(const plot::u8 px[4]) { + return px[0] > 90 && px[1] < px[0] && px[1] > px[2]; +} + +} // namespace + +/// Spec §13, the defect reported from use: "you can see signals outside the plot area". +/// +/// The three failure modes are asserted separately, so a clip that fixes one of them and +/// not the others cannot pass: no trace ink in the y label strip (the coarse trace's +/// window slack hangs a whole segment over the left edge of the axes), none in the x +/// label strip (the segments cross the bottom edge on their way to the -50 samples), and +/// none anywhere outside `axes` at all -- in a strip, in the band between the axes and the +/// frame's edge, or over the host's own surface outside the viewport. The complement is +/// asserted too: the ink still reaches all four edges of the axes, because "nothing +/// outside" is also satisfied by a clip narrower than the rect. +PLOT_TEST(off_scale_trace_is_clipped_to_the_axes) { + 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)); + + // Two traces, and both directions of the defect: the coarse one is in range, so the ink + // it puts over the y strips is the window slack's alone; the comb is fifty axes' heights + // off scale, one near-vertical segment per pixel, so it crosses the x strips below the + // axes and the row above them. + plot::data::ArraySeries coarse(in_range_wave(kCoarseSamples), 0.0, + kOffScaleSpan / static_cast(kCoarseSamples)); + plot::data::ArraySeries comb(off_scale_wave(kCombSamples), 0.0, + kOffScaleSpan / static_cast(kCombSamples)); + + plot::Plot p; + // A viewport inside the framebuffer, as an embedding host's is, with the host's grey + // painted behind it: the frame may paint inside the rect and nowhere else at all. + const plot::Rect vp{40.0f, 24.0f, 560.0f, 300.0f}; + p.set_viewport(vp); + const plot::u32 ax = + p.add_x_axis({"t", {0.0, kOffScaleSpan}, false, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&coarse, kRowBlue, 1.5f, ax, ay}); + p.add_trace({&comb, kRowBlue, 1.5f, ax, ay}); + p.set_x_range(ax, {kOffScaleMin, kOffScaleMax}); + + plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f); + plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); + + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, "clip_axes.ppm"); + + const std::vector frame = read_frame(ctx); + const plot::PlotLayout layout = p.layout(); + const plot::Rect& axes = layout.axes; + + // The premise, from the transform rather than from the pixels: what a frame shows of + // these samples is bounded by the clip and by nothing else. + const plot::Transform ty = p.y_transform(ay); + PLOT_CHECK_NEAR(static_cast(axes.y) - ty.to_screen_y(50.0), + 49.0 * static_cast(axes.h), 1e-3); + PLOT_CHECK_NEAR(ty.to_screen_y(-50.0) - static_cast(axes.bottom()), + 50.0 * static_cast(axes.h), 1e-3); + + // The overflow, measured: the ink the three checks below are about. Everything it names + // is what the clip has to remove, and its magnitude -- how far past each edge the + // furthest pixel landed -- is the defect's size. + const OutsideInk outside = outside_ink(frame, kW, kH, axes); + // What is left of the defect when it comes back is a strip of ink nobody would see in a + // screenshot, so the failure says where it landed: the size and direction print only + // when there is an overflow to print, immediately before the check that fails on it. + if (outside.pixels != 0u) { + std::printf("[clip] %u trace pixels outside the axes -- left %.1f right %.1f above %.1f" + " below %.1f px (axes %.1f,%.1f %.1fx%.1f); y label strip %u, x label strip" + " %u\n", + outside.pixels, static_cast(outside.left), + static_cast(outside.right), static_cast(outside.above), + static_cast(outside.below), static_cast(axes.x), + static_cast(axes.y), static_cast(axes.w), + static_cast(axes.h), + predicate_pixels(frame, kW, layout.y_label_area, trace_pixel), + predicate_pixels(frame, kW, layout.x_label_area, trace_pixel)); + std::fflush(stdout); + } + + // (1) The y label strip and the y title strip beyond it: the coarse trace's first + // segment lies one whole x unit -- some fifty pixels -- left of the axes' left edge. + PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, trace_pixel) == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, trace_pixel) == 0u); + + // (2) The x label strip and the x title strip below it. + PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, trace_pixel) == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, trace_pixel) == 0u); + + // (3) Nothing outside the axes anywhere in the framebuffer -- including over the host's + // surface outside the viewport, which the viewport-scissored clear left bare for the + // overflow to land on. + PLOT_CHECK(outside.pixels == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) == + predicate_pixels(frame, kW, axes, trace_pixel)); + plot::u8 host[4] = {}; + PLOT_CHECK(ctx.read_pixel(4u, kH - 4u, host)); + PLOT_CHECK(host[0] > 100 && host[0] < 160 && host[1] > 100 && host[2] > 100); + + // And the complement, so the clip cannot be smaller than the rect either: the comb + // crosses every column of the axes, reaches its first and last columns, and its + // segments span its first and last rows. A clip inset by even a strip passes every + // check above and fails here. + PLOT_CHECK(covered_columns(ctx, axes, 24) == 24u); + PLOT_CHECK(column_has(ctx, static_cast(std::ceil(axes.x)), axes, trace_pixel)); + PLOT_CHECK( + column_has(ctx, static_cast(std::floor(axes.right())) - 1u, axes, trace_pixel)); + PLOT_CHECK(axes_row_has(ctx, axes, static_cast(std::ceil(axes.y)), trace_pixel)); + PLOT_CHECK(axes_row_has(ctx, axes, static_cast(std::floor(axes.bottom())) - 1u, + trace_pixel)); + + ctx.destroy(); +} + +/// The same defect across a `Figure`'s rows. Each row is a `Plot` laid out in its own +/// rect but drawn into one framebuffer, so the bottom row's off-scale samples were drawn +/// straight into the row above -- over that row's data and its chrome -- and clearing the +/// framebuffer once per frame did nothing to stop it. The clip is per row, so the ink +/// stops at the axes the row owns. +PLOT_TEST(figure_row_overshoot_stays_out_of_the_neighbouring_row) { + 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)); + + // The top row carries an ordinary in-range wave, so the row the overflow would land in + // has content of its own to be told apart from it; the bottom row carries the comb, + // off scale by fifty axes' heights. + const std::vector top = wave(640u, 0.0f); + plot::data::ArraySeries top_series(top, 0.0, 1.0); + plot::data::ArraySeries bottom_series(off_scale_wave(kCombSamples), 0.0, + kOffScaleSpan / static_cast(kCombSamples)); + + plot::Figure figure(2u); + figure.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + add_row_trace(figure.plot(0u), top_series, kRowBlue); + { + plot::Plot& p = figure.plot(1u); + const plot::u32 ax = + p.add_x_axis({"t", {0.0, kOffScaleSpan}, false, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&bottom_series, kRowMagenta, 1.5f, ax, ay}); + p.set_x_range(ax, {kOffScaleMin, kOffScaleMax}); + } + + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(figure); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, "clip_figure_rows.ppm"); + + const std::vector frame = read_frame(ctx); + const plot::FigureLayout layout = figure.layout(); + PLOT_CHECK(layout.rows.size() == 2u); + const plot::Rect& row0 = layout.rows[0]; + const plot::PlotLayout& bottom = figure.plot(1u).layout(); + const plot::Rect& axes1 = bottom.axes; + + // Not vacuous in either direction: both rows drew their own trace. + PLOT_CHECK(predicate_pixels(frame, kW, axes1, magenta_pixel) > 0u); + PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) > 0u); + // The bottom row's off-scale ink used to be drawn here -- over the top row's data, its + // strips and its chrome -- which is the neighbouring row it does not own. + PLOT_CHECK(predicate_pixels(frame, kW, row0, magenta_pixel) == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, bottom.y_label_area, magenta_pixel) == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, bottom.x_label_area, magenta_pixel) == 0u); + // All of its ink is inside its own axes and none of it is anywhere else in the frame. + PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) == + predicate_pixels(frame, kW, axes1, magenta_pixel)); + // The top row is untouched by the clip as well: its own trace is still in its rect. + PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) == + predicate_pixels(frame, kW, kFrameRect, trace_pixel)); + + ctx.destroy(); +} + +/// The fix's neighbours: the overlay geometry that already declined to draw over the +/// strips did so by its own column rules -- a region's bounds outside the window are +/// skipped by `draw_region_edge`, a cursor scrolled out of it by `draw_cursors`, and a +/// region's fill is clamped to the axes' columns by `Plot::build_regions()`. The clip +/// makes that structural rather than a rule every pass has to remember, and this case +/// pins both halves: the off-window overlay puts no pixel in a strip, and the geometry +/// that *is* on screen is not clipped away with it. +PLOT_TEST(out_of_window_region_and_cursor_stay_inside_the_axes) { + 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)); + + plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.set_x_range(ax, {0.0, 300.0}); // ~1 sample/pixel: every column is crossed + + // A region entirely right of the window: nothing of it may be drawn. + plot::Region reg; + reg.x0 = 500.0; + reg.x1 = 800.0; + reg.snap = plot::RegionSnap::Free; + reg.fill = plot::Color::hex(0x33FF00, 0.35f); + reg.edge = plot::Color::hex(0xFF00FF); + const plot::u32 rid = p.add_region(reg); + // One cursor inside the window and one past its right edge, with a measurement between + // them: the band is bounded by the window's edge rather than by the off-window cursor. + const plot::u32 in_cursor = p.add_cursor(150.0, plot::Color::hex(0xFFB020)); + const plot::u32 out_cursor = p.add_cursor(400.0, plot::Color::hex(0x35D0A8)); + const plot::u32 measure = p.add_measure(in_cursor, out_cursor, 0u); + + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, "clip_neighbours.ppm"); + + const std::vector frame = read_frame(ctx); + const plot::PlotLayout layout = p.layout(); + const plot::Rect& axes = layout.axes; + PLOT_CHECK(layout.regions.size() == 1u); + PLOT_CHECK(layout.regions[0].region == rid); + // The layout agrees with the clip: an off-window region has no fill to shade and no + // column inside the axes for either bound. + PLOT_CHECK(layout.regions[0].fill.w == 0.0f); + + // Nothing of the off-window region was drawn, in a strip or anywhere else. + PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, green_dominant) == 0u); + // The off-window cursor drew nothing; the in-window one drew its stroke, and all of + // that ink is inside the axes. + PLOT_CHECK(predicate_pixels(frame, kW, axes, accent_pixel) > 0u); + PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, accent_pixel) == + predicate_pixels(frame, kW, axes, accent_pixel)); + PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, accent_pixel) == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, accent_pixel) == 0u); + // The measurement band between an on-window and an off-window cursor is drawn, and it + // stops at the axes: it is the one pass whose geometry is built from two cursors, so a + // clamp that forgot one of them would put the tint over the strip. + PLOT_CHECK(predicate_pixels(frame, kW, axes, band_pixel) > 0u); + PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, band_pixel) == + predicate_pixels(frame, kW, axes, band_pixel)); + PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, band_pixel) == 0u); + PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, band_pixel) == 0u); + // And the trace is still whole: the clip is the axes rect, so the data still fills it. + PLOT_CHECK(covered_columns(ctx, axes, 16) >= 15u); + PLOT_CHECK(p.measurement(measure).valid); + + ctx.destroy(); +} + +/// The clip's other half: what it must *not* do. A trace off scale is held inside `axes` +/// in this frame as in the cases above, while the legend -- panel, swatch and name, all +/// measured inside the rect -- survives, and the tick labels and axis titles, which live +/// outside it by design, are still drawn: the clip is released before those passes. All +/// three are asserted from the pixels, so a clip that was never released (which loses the +/// strips) or one released too early (which loses the legend) fails here rather than in a +/// screenshot nobody looks at. +PLOT_TEST(legend_and_strip_labels_survive_the_axes_clip) { + 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)); + + plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + // Titled axes, so both title strips exist and hold something to keep. + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + // A translucent trace whose swatch is drawn opaque: the exact colour of the swatch is + // then one only a swatch can produce, which is what makes the count below a statement + // about the legend rather than about the trace that carries the same colour. + p.add_trace({&s, plot::Color::hex(0x40C0FF, 0.25f), 1.5f, ax, ay, "signal"}); + p.set_x_range(ax, {0.0, 300.0}); + // The trace that has to stay inside the axes -- the same off-scale values as the cases + // above, sampled across this one's whole window -- in a second blue no other element of + // the frame produces, and unnamed, so it adds no legend entry. + plot::data::ArraySeries off_scale(off_scale_wave(3600u), 0.0, 300.0 / 3600.0); + p.add_trace({&off_scale, plot::Color::hex(0x35A0FF), 1.5f, ax, ay}); + + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, "clip_legend.ppm"); + + const std::vector frame = read_frame(ctx); + const plot::PlotLayout layout = p.layout(); + const plot::Color ink = r.style().axis_text; + PLOT_CHECK(layout.legend.rows.size() == 1u); + const plot::LegendRow& row = layout.legend.rows[0]; + PLOT_CHECK(row.swatch.w > 0.0f && row.swatch.h > 0.0f); + // The legend is inside the axes, as its layout promises, and its swatch is still drawn + // -- opaquely, and nowhere outside the rect the clip holds it in. + PLOT_CHECK(row.swatch.x >= layout.axes.x && row.swatch.right() <= layout.axes.right()); + PLOT_CHECK(row.swatch.y >= layout.axes.y && row.swatch.bottom() <= layout.axes.bottom()); + const plot::u32 swatch = exact_pixels(frame, kW, row.swatch, p.trace(0u).color); + PLOT_CHECK(swatch > 0u); + PLOT_CHECK(exact_pixels(frame, kW, kFrameRect, p.trace(0u).color) == + exact_pixels(frame, kW, layout.axes, p.trace(0u).color)); + + // And the off-scale trace is held in: its ink is inside the axes and nowhere else in the + // frame, the same statement the first case makes with the same clip and a different + // frame around it. + PLOT_CHECK(predicate_pixels(frame, kW, layout.axes, trace_pixel) > 0u); + PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) == + predicate_pixels(frame, kW, layout.axes, trace_pixel)); + + // The strips outside the axes are drawn, in all four: the clip was released before the + // tick-label and axis-title passes, which is what keeps them from being erased. + PLOT_CHECK(scan_rect_ink(ctx, layout.y_label_area, ink).pixels > 0u); + PLOT_CHECK(scan_rect_ink(ctx, layout.x_label_area, ink).pixels > 0u); + PLOT_CHECK(scan_rect_ink(ctx, layout.y_title_area, ink).pixels > 0u); + PLOT_CHECK(scan_rect_ink(ctx, layout.x_title_area, ink).pixels > 0u); + + ctx.destroy(); +} + +/// The clip is the frame's, not the host's. `renderer.hpp` documents the GL state a frame +/// leaves behind, and the scissor is now one of the pieces it moves: the frame ends with +/// the scissor test off -- not left enabled on the axes rect of its last interior pass, +/// which would clip whatever the embedding host drew next, and not on the viewport rect +/// either, which would do the same to a host drawing outside its plot's region. Observed +/// rather than documented: a clear the host issues after `end_frame()` has to reach the +/// whole framebuffer. +PLOT_TEST(a_frame_leaves_no_clip_for_the_host_to_inherit) { + 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)); + + plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + const plot::Rect vp{80.0f, 40.0f, 400.0f, 240.0f}; + p.set_viewport(vp); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.set_x_range(ax, {0.0, 300.0}); + + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + + // The host paints its own surface next, as an embedding host does. With the frame's + // clip left engaged -- at the axes rect, or at the viewport's -- this clear would cover + // only that rect, and the corners below would still hold the frame's background. + plot::gl::glClearColor(0.75f, 0.25f, 0.5f, 1.0f); + plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); + const auto host_clear = [&](plot::u32 x, plot::u32 y) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, y, px)); + return px[0] > 180 && px[1] < 90 && px[2] > 100 && px[2] < 160; + }; + PLOT_CHECK(host_clear(2u, 2u)); // a corner, outside the viewport + PLOT_CHECK(host_clear(kW - 3u, kH - 3u)); // the opposite corner + PLOT_CHECK(host_clear(2u, kH - 3u)); // under the viewport, in its columns + + ctx.destroy(); +}