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

+57 -2
View File
@@ -111,6 +111,12 @@ class Renderer {
/// bottom-left origin once, clears that region to `style().background` and /// bottom-left origin once, clears that region to `style().background` and
/// empties the staging batches. /// 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 /// 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 /// 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 /// 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 /// 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 /// 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 /// 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 /// state of its own: it is drawn inside the same axes clip the interior passes share,
/// everything a fill and a hairline border need (see end_frame's residue list). /// 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 /// 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 /// 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 /// vertical strokes and the label is a staged glyph run, so a frame with regions
/// leaves behind exactly what a frame without them does. /// leaves behind exactly what a frame without them does.
void draw(Plot& plot); 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 /// Draws a whole `Figure`: every row in its own rect, then the figure's one shared
/// x axis. /// x axis.
@@ -192,6 +214,16 @@ class Renderer {
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and /// 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 /// 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). /// 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 /// 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 /// left where the frame wanted it: the clear colour, which is
/// `style().background` only for the duration of the frame. GL keeps no history /// `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. /// is texture-bound, so this runs once per frame, after all line geometry.
void flush_text() noexcept; 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 /// 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, /// 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 /// because a stroke's quad is its perpendicular width and the fragment shader
+79 -5
View File
@@ -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 // framebuffer coordinates and is not affected by glViewport, which is why the
// clear happens before the viewport is set. // 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: // 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 // 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 // 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_ = Color{rgba[0], rgba[1], rgba[2], rgba[3]};
host_clear_saved_ = true; host_clear_saved_ = true;
} }
gl::glEnable(gl::GL_SCISSOR_TEST); set_clip(viewport_px);
gl::glScissor(vx, vy, vw, vh);
const Color& background = style_.background; const Color& background = style_.background;
gl::glClearColor(background.r, background.g, background.b, background.a); gl::glClearColor(background.r, background.g, background.b, background.a);
gl::glClear(gl::GL_COLOR_BUFFER_BIT); gl::glClear(gl::GL_COLOR_BUFFER_BIT);
gl::glDisable(gl::GL_SCISSOR_TEST);
gl::glViewport(vx, vy, vw, vh); gl::glViewport(vx, vy, vw, vh);
// The pipelines write straight alpha and rely on blending being on; setting it // 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); 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<gl::GLint>(std::ceil(x0));
const gl::GLint x_max = static_cast<gl::GLint>(std::floor(x1));
const gl::GLint y_min = static_cast<gl::GLint>(std::ceil(y0));
const gl::GLint y_max = static_cast<gl::GLint>(std::floor(y1));
left = x_min;
top = y_min;
w = static_cast<gl::GLsizei>(std::max(0, x_max - x_min));
h = static_cast<gl::GLsizei>(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<gl::GLint>(fb_h_) - top - h, w, h);
}
bool Renderer::begin_frame_text() noexcept { bool Renderer::begin_frame_text() noexcept {
// The plot reserves its label strips from TextBackend metrics, so it must // 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 // 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 // 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 // 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. // 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); draw_grid(plot);
clear_clip();
draw_axis_lines(plot); draw_axis_lines(plot);
draw_tick_labels(plot, layout); set_clip(layout.axes);
draw_titles(plot, layout);
draw_traces(plot, layout); draw_traces(plot, layout);
// Regions over the traces and under the cursors: an area of interest the data is // 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. // 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_measures(plot, layout);
draw_annotations(plot); draw_annotations(plot);
draw_legend(plot, layout); draw_legend(plot, layout);
clear_clip();
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
} }
void Renderer::end_frame() noexcept { void Renderer::end_frame() noexcept {
@@ -458,6 +527,11 @@ void Renderer::end_frame() noexcept {
gl::glActiveTexture(gl::GL_TEXTURE0); gl::glActiveTexture(gl::GL_TEXTURE0);
gl::glBindTexture(gl::GL_TEXTURE_2D, 0); gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
gl::glDisable(gl::GL_BLEND); 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_) { if (host_clear_saved_) {
gl::glClearColor(host_clear_.r, host_clear_.g, host_clear_.b, host_clear_.a); gl::glClearColor(host_clear_.r, host_clear_.g, host_clear_.b, host_clear_.a);
} }
+474
View File
@@ -3845,3 +3845,477 @@ PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) {
ctx.destroy(); 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();
}