fix(render): clip grid, traces, regions and cursors to the plot area

This commit is contained in:
Martino Ferrari committed 2026-09-28 17:00:02 +02:00
1 parent d853db62ae
commit 2d819c4228
3 files changed
+610 -7

No files matched your search

+474
View File
@@ -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<plot::f32> off_scale_wave(plot::u32 n) {
std::vector<plot::f32> 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<plot::f32> in_range_wave(plot::u32 n) {
std::vector<plot::f32> 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<plot::u8>& 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<plot::f32>(x);
const plot::f32 cy = static_cast<plot::f32>(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<plot::u32>(axes.x); x < static_cast<plot::u32>(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<plot::f64>(kCoarseSamples));
plot::data::ArraySeries comb(off_scale_wave(kCombSamples), 0.0,
kOffScaleSpan / static_cast<plot::f64>(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<plot::u8> 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<plot::f64>(axes.y) - ty.to_screen_y(50.0),
49.0 * static_cast<plot::f64>(axes.h), 1e-3);
PLOT_CHECK_NEAR(ty.to_screen_y(-50.0) - static_cast<plot::f64>(axes.bottom()),
50.0 * static_cast<plot::f64>(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<double>(outside.left),
static_cast<double>(outside.right), static_cast<double>(outside.above),
static_cast<double>(outside.below), static_cast<double>(axes.x),
static_cast<double>(axes.y), static_cast<double>(axes.w),
static_cast<double>(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<plot::u32>(std::ceil(axes.x)), axes, trace_pixel));
PLOT_CHECK(
column_has(ctx, static_cast<plot::u32>(std::floor(axes.right())) - 1u, axes, trace_pixel));
PLOT_CHECK(axes_row_has(ctx, axes, static_cast<plot::u32>(std::ceil(axes.y)), trace_pixel));
PLOT_CHECK(axes_row_has(ctx, axes, static_cast<plot::u32>(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<plot::f32> 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<plot::f64>(kCombSamples));
plot::Figure figure(2u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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();
}