fix(render): clip grid, traces, regions and cursors to the plot area
This commit is contained in:
1 parent
d853db62ae
commit
2d819c4228
3 files changed
+610
-7
No files matched your search
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user