Files
plotter/tests/test_render_smoke.cpp
T
Martino Ferrari 01e410bd74 style(grid): the minor grid is thinner, dashed and blended into the background
Subdivisions used to be drawn exactly like the labelled lines -- same width, same
opacity -- so a plot dense enough to need them read as two competing sets of ticks.
The minor class is now three things the style states explicitly:

- grid_minor_width 1.0 -> 0.75, a sub-pixel logical width. At 1x that is expressed as
  reduced coverage through the anti-aliased ramp; on a HiDPI display the line is
  literally narrower.
- grid_minor_dash_on/_off (new, 3/3): dashed by default, off == 0 draws it solid, the
  same convention as the crosshair's and a trace's dash. The dash is a fragment-shader
  discard, so no geometry is added -- the vertices are the line either way.
- grid_minor carries alpha (0x1C2126 at 50%), and the line shader applies u_color.a,
  so it blends into the background instead of sitting on it.

Together that is about a fifth of the ink a major line leaves along its length, which
is what gives the eye one grid to read instead of two. The major class is untouched:
it stays the structure.

Tests: a new pixel test (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major)
asserts a major column is inked top-to-bottom while a minor one alternates, and that
the minor's ink sits closer to the background than the major's. Three existing tests
counted the minor class as "most of eight rows" of its exact colour; two now take
their floor from the style (minor_line_ink: width x alpha x duty), and the width test
states the dash and alpha away because widths are its subject. 361 cases, 0 failed.
2026-10-03 14:31:57 +02:00

6317 lines
303 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The integration test: the whole stack on the real driver. A 1M-sample series,
// the plot layer's layout/ticks/grid, the renderer's visible-range LOD selection
// and both vertex-transfer strategies, all asserted by reading pixels back from
// the headless EGL framebuffer rather than by inspecting anything the renderer
// says about itself.
#include "harness.hpp"
#include "egl_headless.hpp"
#include <plot/gl/api.hpp>
#include <plot/gl/loader.hpp>
#include <plot/data/array_series.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/detail/ring_stats.hpp>
#include <plot/plot/figure.hpp>
#include <plot/render/renderer.hpp>
#include <plot/input/controller.hpp>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <csignal>
#include <cstdlib>
#include <cstdio>
#include <sys/wait.h>
#include <unistd.h>
#include <utility>
#include <vector>
namespace {
constexpr plot::u32 kW = 640;
constexpr plot::u32 kH = 360;
/// The series every case draws: a slow sine whose period is 62 832 samples, so a
/// 1M-wide window holds ~16 of them (the dense envelope spans the full height in
/// every column) while a 640-wide one is a nearly flat line (the sparse path's
/// polyline crosses every column exactly once).
std::vector<plot::f32> sine_samples() {
std::vector<plot::f32> y(1'000'000);
for (plot::u64 i = 0; i < y.size(); ++i) {
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.0001));
}
return y;
}
/// Columns of the axes rect (sampled at `columns` evenly spaced positions) that
/// hold at least one trace-coloured pixel (0x40C0FF over the 0x101418
/// background).
///
/// The predicate is blue *dominance*, not merely "blue-ish": the axis line is
/// 0x8A94A6, which any threshold on blue alone would accept, and the axis line
/// crosses every column of the rect -- a loose predicate would make every
/// coverage assertion here vacuous. Blending the trace over the background keeps
/// blue - red at `alpha * 191`, so even the AA fringe of a 1.5px stroke clears
/// the bound.
bool trace_pixel(const plot::u8 px[4]) {
return px[2] > 120 && px[2] - px[0] > 100;
}
/// Predicates for the overlay layer, each chosen so that no other element of the
/// frame can satisfy it (see the colours the test picks). A marker drawn on a
/// pixel boundary blends at about half coverage, so they are dominance tests with
/// room for that rather than exact colour matches.
bool green_pixel(const plot::u8 px[4]) { return px[1] > px[0] + 60 && px[1] > px[2] + 60; }
bool magenta_pixel(const plot::u8 px[4]) { return px[0] > px[1] + 60 && px[2] > px[1] + 60; }
/// The measure band: 0xFFB020 at 15% over the background, so a warm tint.
bool band_pixel(const plot::u8 px[4]) {
return px[0] > 35 && px[0] > px[1] && px[1] > px[2];
}
/// A chalk pixel: `axis_text` (0xC8D0DC) at close to full coverage, which nothing
/// else in these frames reaches -- the brightest other element is `axis_line`
/// (0x8A94A6), whose dimmest channel is 138.
///
/// The threshold is 160 and not the colour's own 200-220 because the text pipeline
/// filters the atlas: at the default 12 px style on the 13 px bake the brightest
/// fragment of a glyph is 0.844 covered, which puts `axis_text`'s green at 179. A
/// 180 threshold found text only while the sampler was a point fetch, i.e. only
/// while it was also deleting a column and a row of every glyph.
bool text_pixel(const plot::u8 px[4]) { return px[0] > 160 && px[1] > 160 && px[2] > 160; }
/// A grey marker: the annotation colour at any coverage, which no data or chrome
/// colour here matches (they are either dark or strongly coloured).
bool marker_pixel(const plot::u8 px[4]) {
const plot::u8 lo = std::min(px[0], std::min(px[1], px[2]));
const plot::u8 hi = std::max(px[0], std::max(px[1], px[2]));
return lo > 90 && static_cast<plot::u8>(hi - lo) < 30;
}
plot::u32 covered_columns(plottest::HeadlessContext& ctx, const plot::Rect& a,
plot::u32 columns) {
// `a` is a Rect, so its rows are top-down; read_pixel() speaks GL's bottom-left
// convention. Flipping here is the same single conversion the renderer does for
// its viewport, and scanning the rows without it would test the 24 rows above
// the axes instead of the axes itself.
const plot::u32 first_row = static_cast<plot::u32>(a.y);
const plot::u32 rows = static_cast<plot::u32>(a.h);
plot::u32 hit = 0;
for (plot::u32 c = 0; c < columns; ++c) {
const plot::u32 x =
static_cast<plot::u32>(a.x + a.w * (static_cast<plot::f64>(c) + 0.5) /
static_cast<plot::f64>(columns));
bool found = false;
for (plot::u32 yy = 0; yy < rows && !found; ++yy) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (first_row + yy), px));
found = trace_pixel(px);
}
if (found) ++hit;
}
return hit;
}
/// Honours PLOT_DUMP_FRAMES: a non-empty value is the PPM path, an empty one
/// dumps next to the test binary. Nothing is written unless it is set.
void dump_if_requested(const plot::Renderer& r, const char* default_name) {
const char* path = std::getenv("PLOT_DUMP_FRAMES");
if (path == nullptr) return;
plot::Status st;
const bool ok = r.dump_frame_ppm(path[0] != '\0' ? path : default_name, &st);
std::printf("[dump] %s: %s\n", path[0] != '\0' ? path : default_name,
ok ? "written" : st.message);
PLOT_CHECK(ok);
}
/// The public-boundary contract (review finding 3d): `dump_frame_ppm()` reports an
/// allocation failure through its `Status` -- `ErrorCode::OutOfMemory`, as
/// `TrueTypeFont::from_file()` does -- rather than letting `std::bad_alloc` cross the
/// boundary, so it is `noexcept` like every other entry point here.
static_assert(noexcept(std::declval<const plot::Renderer&>().dump_frame_ppm(nullptr)),
"dump_frame_ppm must not throw across the public boundary");
/// True when `px` is (within a couple of levels) the straight-alpha colour `c`,
/// which is what a fully covered text pixel is. The colour comes from the style,
/// so no predicate here has to guess what the renderer draws with.
bool pixel_is(const plot::u8 px[4], plot::Color c) {
const auto near = [](plot::u8 got, plot::f32 want) {
const int diff = static_cast<int>(got) - static_cast<int>(want * 255.0f + 0.5f);
return diff >= -3 && diff <= 3;
};
return near(px[0], c.r) && near(px[1], c.g) && near(px[2], c.b);
}
/// True when `px` holds `c` at some coverage -- the anti-aliased case `pixel_is`
/// cannot see. The embedded atlas is greyscale and the renderer draws it at
/// `Style::axis_font_px` from a 13 px bake, so at the default size a glyph stroke
/// is barely a pixel wide and *no* pixel is ever fully covered: demanding the
/// exact colour finds no text at all.
///
/// A partially covered pixel is `c` scaled toward the frame's dark chrome, so this
/// asks for every channel to be at least a third of `c`'s: bright enough to be
/// glyph ink, dark enough to exclude a background, and deliberately blind to which
/// of two chalk-toned chrome colours a pixel is -- which is why every caller scans
/// a region only one element draws in.
bool text_ink(const plot::u8 px[4], plot::Color c) {
const auto lifted = [](plot::u8 got, plot::f32 want) {
return static_cast<plot::f32>(got) >= want * 255.0f * 0.35f;
};
return lifted(px[0], c.r) && lifted(px[1], c.g) && lifted(px[2], c.b);
}
/// No column held ink.
constexpr plot::u32 kNoColumn = 0xFFFFFFFFu;
/// A host's `TextBackend` that measures one pixel per string, when the labels the
/// renderer draws are tens of pixels wide: installed on a plot, it asks for a label
/// strip far too narrow for anything the text pipeline draws. It publishes no
/// atlas at all -- a backend that draws nothing is still a legal backend to
/// measure with, and the plot layer only ever asks it for metrics.
class OnePxPerStringBackend final : public plot::render::TextBackend {
public:
plot::f32 measure(const char*) const override { return 1.0f; }
plot::f32 height() const override { return 1.0f; }
plot::f32 ascent() const override { return 1.0f; }
plot::f32 em_px() const override { return 1.0f; }
const plot::render::Glyph* glyph(plot::u32) const override { return nullptr; }
plot::Vec2 cell_uv() const override { return plot::Vec2{}; } // no atlas, no cell
const plot::u8* atlas_pixels() const override { return nullptr; }
plot::u32 atlas_width() const override { return 0u; }
plot::u32 atlas_height() const override { return 0u; }
plot::u32 revision() const override { return 0u; }
};
/// A backend the test bakes by hand: one glyph whose cell is the whole atlas, and
/// a revision it bumps on every bake. Baking two atlases of *different sizes* with
/// the ink on opposite sides of the cell is what makes a stale atlas visible -- the
/// frame after a re-bake must read the new bytes, and a quad that is still the old
/// size cannot even reach the new ink.
class BakeableBackend final : public plot::render::TextBackend {
public:
BakeableBackend() noexcept { bake(8u, false); }
/// Bakes a `size` x `size` atlas with ink on the right half when `ink_right`,
/// on the left half otherwise, and bumps the revision. The glyph's cell is the
/// whole atlas, so the drawn quad is `size * px` pixels across.
void bake(plot::u32 size, bool ink_right) {
size_ = size;
pixels_.assign(static_cast<std::size_t>(size) * size, 0u);
for (plot::u32 y = 0; y < size; ++y) {
for (plot::u32 x = 0; x < size; ++x) {
if ((x >= size / 2u) == ink_right) {
pixels_[static_cast<std::size_t>(y) * size + x] = 255u;
}
}
}
glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast<plot::f32>(size)};
++revision_;
}
/// Bakes a `size` x `size` atlas of alternating ink and blank texels, and bumps
/// the revision. It is the artwork that separates a *filtered* sampler from a
/// point one without depending on any face's shapes: halve the scale and a
/// filter must average every 2x2 to mid grey, while a point fetch lands on one
/// parity for every fragment and returns a solid block.
void bake_checkerboard(plot::u32 size) {
size_ = size;
pixels_.assign(static_cast<std::size_t>(size) * size, 0u);
for (plot::u32 y = 0; y < size; ++y) {
for (plot::u32 x = 0; x < size; ++x) {
if (((x + y) & 1u) == 0u) {
pixels_[static_cast<std::size_t>(y) * size + x] = 255u;
}
}
}
glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast<plot::f32>(size)};
++revision_;
}
/// Trims the published glyph's rect to an ink box, which is what breaks the
/// fixed-cell grid (see `TextBackend::cell_uv`): the rect is no longer the cell
/// `cell_uv()` declares, so the staging path's check fails. Used by the case
/// that pins that check.
void trim_glyph_rect(plot::f32 width) {
glyph_.u1 = glyph_.u0 + width;
}
/// Publishes no atlas at all (the "no text" a backend is allowed to be), and
/// bumps the revision -- which is how a backend that *starts* publishing one
/// announces it.
void drop_atlas() {
pixels_.clear();
size_ = 0u;
++revision_;
}
/// Moves the revision without touching the artwork, so a test can drive it
/// backwards -- any change is a change, and "smaller" is still a change.
void set_revision(plot::u32 revision) noexcept { revision_ = revision; }
plot::f32 measure(const char*) const override { return 8.0f; }
plot::f32 height() const override { return static_cast<plot::f32>(size_); }
plot::f32 ascent() const override { return static_cast<plot::f32>(size_); }
/// Its cell is the whole atlas, so its em is the bake size: one unit is one pixel.
plot::f32 em_px() const override { return static_cast<plot::f32>(size_); }
const plot::render::Glyph* glyph(plot::u32) const override { return &glyph_; }
plot::Vec2 cell_uv() const override { return pixels_.empty() ? plot::Vec2{} : plot::Vec2{1.0f, 1.0f}; }
const plot::u8* atlas_pixels() const override { return pixels_.empty() ? nullptr : pixels_.data(); }
plot::u32 atlas_width() const override { return size_; }
plot::u32 atlas_height() const override { return size_; }
plot::u32 revision() const override { return revision_; }
private:
std::vector<plot::u8> pixels_;
plot::render::Glyph glyph_{};
plot::u32 size_ = 8u;
plot::u32 revision_ = 0u;
};
/// The ink one drawn text run left in the framebuffer: its bounding box in Rect
/// pixels (top-down, like every other Rect here). `any()` is false when nothing
/// was drawn.
struct InkBox {
plot::u32 x0 = kNoColumn;
plot::u32 y0 = 0;
plot::u32 x1 = 0;
plot::u32 y1 = 0;
bool any() const noexcept { return x0 != kNoColumn; }
plot::u32 width() const noexcept { return any() ? x1 - x0 + 1u : 0u; }
plot::u32 height() const noexcept { return any() ? y1 - y0 + 1u : 0u; }
};
/// Scans the whole framebuffer for `text`-coloured ink, anti-aliased fringe and
/// all (see `text_ink`). These cases draw nothing but the run, so everything lit
/// in the target's colour is the run.
InkBox scan_ink_box(plottest::HeadlessContext& ctx, plot::Color text) {
InkBox box;
for (plot::u32 y = 0; y < ctx.height; ++y) {
for (plot::u32 x = 0; x < ctx.width; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (!text_ink(px, text)) continue;
if (!box.any()) {
box.x0 = x;
box.y0 = y;
}
box.x1 = x;
box.y1 = y;
}
}
return box;
}
/// Draws one staged text run from `backend`'s atlas into the whole context in
/// `text`, and returns the ink's bounding box. The pipeline is driven directly --
/// atlas, batch, program, vertex array -- so a case can rotate a run, or swap the
/// backend underneath a live atlas, without the renderer in between. `atlas` is
/// the caller's, so a case can bake again, sync again and draw again.
InkBox draw_text_run(plottest::HeadlessContext& ctx, plot::render::TextAtlas& atlas,
const plot::render::TextBackend& backend, const char* utf8,
plot::Vec2 pen, plot::f32 px, plot::Vec2 axis) {
plot::Status st;
PLOT_CHECK(atlas.sync(backend, &st));
PLOT_CHECK(!atlas.empty());
plot::render::TextProgram program;
PLOT_CHECK(program.create(&st));
plot::render::TextVertexArray vao;
PLOT_CHECK(vao.create(&st));
plot::render::TextBatch batch;
batch.begin();
batch.add_text(utf8, pen, px, backend, axis);
PLOT_CHECK(batch.glyph_count() != 0u);
vao.upload(batch.data().data(), batch.glyph_count());
const plot::Color text = plot::Color::hex(0xFFFFFF);
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
program.bind();
program.set_viewport({static_cast<plot::f32>(ctx.width), static_cast<plot::f32>(ctx.height)});
// The same two numbers the renderer's flush computes: the backend's cell, and
// that cell's size on screen at this scale.
const plot::Vec2 cell = backend.cell_uv();
const plot::Vec2 texels = atlas.texel_size();
program.set_glyph_uv(cell);
program.set_px_range({cell.x * texels.x * px, cell.y * texels.y * px});
program.set_texture_size(texels);
atlas.bind();
vao.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<plot::gl::GLsizei>(vao.instance_count()));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
return scan_ink_box(ctx, text);
}
/// What a text strip's pixels say: the leftmost column holding ink, and the widest
/// run of ink columns any single row holds -- one drawn label's extent, since a
/// label has a row band to itself.
struct TextInk {
plot::u32 leftmost = kNoColumn;
plot::u32 widest_row_run = 0;
};
/// Scans the top-down pixels of [x0, x1) x [y, y + rows) for `text`-coloured ink,
/// anti-aliased fringe and all (see `text_ink`).
TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1, plot::u32 y,
plot::u32 rows, plot::Color text) {
TextInk ink;
for (plot::u32 r = 0; r < rows; ++r) {
plot::u32 first = kNoColumn;
plot::u32 last = 0;
for (plot::u32 x = x0; x < x1; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (y + r), px));
if (!text_ink(px, text)) continue;
if (first == kNoColumn) first = x;
last = x;
if (ink.leftmost == kNoColumn) ink.leftmost = x;
}
if (first != kNoColumn && last - first + 1u > ink.widest_row_run) {
ink.widest_row_run = last - first + 1u;
}
}
return ink;
}
/// The ink `text`-coloured pixels form inside `area`: its bounding box in Rect
/// pixels (top-down, like every Rect here) and how many pixels it covers. Asking a
/// sub-rect is how a case reads one strip without the labels beside it -- but a
/// scan can only see ink *inside* the rect it is given, so "nothing was drawn
/// outside" is a scan of the region the overflow would have landed in.
struct RectInk {
InkBox box;
plot::u32 pixels = 0u;
bool any() const noexcept { return box.any(); }
};
RectInk scan_rect_ink(plottest::HeadlessContext& ctx, const plot::Rect& area, plot::Color text) {
RectInk ink;
const plot::u32 x1 = static_cast<plot::u32>(area.right());
const plot::u32 y1 = static_cast<plot::u32>(area.bottom());
for (plot::u32 y = static_cast<plot::u32>(area.y); y < y1; ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x); x < x1; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (!text_ink(px, text)) continue;
// The extremes, not the first and last match: a row-major scan sees a
// rotated run's widest row wherever it happens to be, so the first match's
// column is the box's left edge only for a run that is monotone in x -- which
// an unrotated line of text is, and a quarter-turned one is not. The first
// match also has to seed all four, since the empty box's sentinel is not a
// coordinate.
if (!ink.box.any()) {
ink.box.x0 = ink.box.x1 = x;
ink.box.y0 = ink.box.y1 = y;
} else {
ink.box.x0 = std::min(ink.box.x0, x);
ink.box.x1 = std::max(ink.box.x1, x);
ink.box.y0 = std::min(ink.box.y0, y);
ink.box.y1 = std::max(ink.box.y1, y);
}
++ink.pixels;
}
}
return ink;
}
/// One band of inked lines: `[first, last]`, both inclusive, in the coordinate the
/// band runs across -- framebuffer x for `ink_column_bands()`, framebuffer y for
/// `ink_row_bands()`.
struct InkBand {
plot::u32 first = kNoColumn;
plot::u32 last = 0;
plot::u32 width() const noexcept { return first == kNoColumn ? 0u : last - first + 1u; }
};
/// The bands of inked lines `text`-coloured ink forms in `area`, top to bottom: one
/// band per drawn text line that a gap of blank lines separates from its neighbours,
/// which is how a case counts the rows a strip holds (an x title's rows, a y strip's
/// stacked labels). Two lines drawn over each other make one band.
std::vector<InkBand> ink_row_bands(plottest::HeadlessContext& ctx, const plot::Rect& area,
plot::Color text) {
std::vector<InkBand> bands;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
bool has = false;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()) && !has; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
has = text_ink(px, text);
}
if (!has) continue;
if (bands.empty() || bands.back().last + 1u != y) {
bands.push_back(InkBand{y, y});
} else {
bands.back().last = y;
}
}
return bands;
}
/// The bands of inked columns `text`-coloured ink forms in `area`, left to right:
/// one band per drawn run that a gap of blank columns separates from its
/// neighbours. Two runs drawn over each other make one band; two separated by
/// background make two, so this is how a case asks how many runs a strip holds and
/// where each of them is.
std::vector<InkBand> ink_column_bands(plottest::HeadlessContext& ctx, const plot::Rect& area,
plot::Color text) {
std::vector<InkBand> bands;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
bool has = false;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()) && !has; ++y) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
has = text_ink(px, text);
}
if (!has) continue;
if (bands.empty() || bands.back().last + 1u != x) {
bands.push_back(InkBand{x, x});
} else {
bands.back().last = x;
}
}
return bands;
}
/// Every pixel of the framebuffer, four bytes each, top row first -- the same order
/// the Rect-based helpers above read in. Comparing two frames pixel for pixel is how
/// a case asserts that a change had *no* effect anywhere in the target: nothing was
/// drawn, rather than drawn somewhere else.
std::vector<plot::u8> read_frame(plottest::HeadlessContext& ctx) {
std::vector<plot::u8> frame(plot::usize(ctx.width) * ctx.height * 4u);
for (plot::u32 y = 0; y < ctx.height; ++y) {
for (plot::u32 x = 0; x < ctx.width; ++x) {
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, &frame[(plot::usize(y) * ctx.width + x) * 4u]));
}
}
return frame;
}
/// The pixels of an already-read frame that hold `text`-coloured ink, anti-aliased
/// fringe and all (see `text_ink`). `predicate_pixels` takes a predicate and not a
/// colour, so a count that has to name *one* colour reads the frame this way -- and
/// reading the frame once is what makes counting it twice cheap enough to compare two
/// whole frames.
plot::u32 frame_text_ink(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::u32 fb_h,
plot::Color text) {
plot::u32 count = 0;
for (plot::u32 y = 0; y < fb_h; ++y) {
for (plot::u32 x = 0; x < fb_w; ++x) {
if (text_ink(&frame[(plot::usize(y) * fb_w + x) * 4u], text)) ++count;
}
}
return count;
}
/// Holds one GL entry point at a stub for as long as it lives. The entry points
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
/// refusal -- a map or a fence that fails while the caps still advertise support
/// -- is testable without a broken driver. The pointer is saved on construction
/// and put back in the destructor, which also runs while a failed `PLOT_CHECK`
/// unwinds, so a case can never leak its stub into the next one.
class RefusePersistentMap {
public:
RefusePersistentMap() noexcept : saved_(plot::gl::glMapBufferRange) {
plot::gl::glMapBufferRange = &refuse;
}
~RefusePersistentMap() { plot::gl::glMapBufferRange = saved_; }
RefusePersistentMap(const RefusePersistentMap&) = delete;
RefusePersistentMap& operator=(const RefusePersistentMap&) = delete;
private:
static void* refuse(plot::gl::GLenum, plot::gl::GLintptr, plot::gl::GLsizeiptr,
plot::gl::GLbitfield) noexcept {
return nullptr;
}
decltype(plot::gl::glMapBufferRange) saved_;
};
/// Holds `plot::gl::glGetIntegerv` at null for as long as it lives: the entry
/// point `TextAtlas::sync` reaches first, so a sync with it missing has to report
/// a `Status` rather than call through a null pointer. Saved on construction,
/// restored on destruction, exactly like the two stubs above -- so a failed check
/// cannot leak it into the next case.
class RefuseGetIntegerv {
public:
RefuseGetIntegerv() noexcept : saved_(plot::gl::glGetIntegerv) {
plot::gl::glGetIntegerv = nullptr;
}
~RefuseGetIntegerv() { plot::gl::glGetIntegerv = saved_; }
RefuseGetIntegerv(const RefuseGetIntegerv&) = delete;
RefuseGetIntegerv& operator=(const RefuseGetIntegerv&) = delete;
private:
decltype(plot::gl::glGetIntegerv) saved_;
};
/// Same, for `glFenceSync`: the sync object the ring's slot guard needs is
/// refused while the caps and the entry point still say the mapped path is on.
class RefuseFenceSync {
public:
RefuseFenceSync() noexcept : saved_(plot::gl::glFenceSync) { plot::gl::glFenceSync = &refuse; }
~RefuseFenceSync() { plot::gl::glFenceSync = saved_; }
RefuseFenceSync(const RefuseFenceSync&) = delete;
RefuseFenceSync& operator=(const RefuseFenceSync&) = delete;
private:
static plot::gl::GLsync refuse(plot::gl::GLenum, plot::gl::GLbitfield) noexcept {
return nullptr;
}
decltype(plot::gl::glFenceSync) saved_;
};
/// Counts the calls the ring's fence waits make through `glClientWaitSync` -- the
/// one entry point every wait goes through, whatever the store's own bookkeeping
/// says. The test-only counters in <plot/render/detail/ring_stats.hpp> report how
/// often a store decides to wait; this reports what reached the driver, so the two
/// cannot agree by construction.
u64 g_fence_wait_calls = 0;
decltype(plot::gl::glClientWaitSync) g_fence_wait_saved = nullptr;
plot::gl::GLenum count_fence_wait(plot::gl::GLsync sync, plot::gl::GLbitfield flags,
plot::gl::GLuint64 timeout) {
++g_fence_wait_calls;
return g_fence_wait_saved(sync, flags, timeout);
}
class CountFenceWaits {
public:
CountFenceWaits() noexcept : saved_(plot::gl::glClientWaitSync) {
g_fence_wait_saved = saved_;
plot::gl::glClientWaitSync = &count_fence_wait;
}
~CountFenceWaits() { plot::gl::glClientWaitSync = saved_; }
CountFenceWaits(const CountFenceWaits&) = delete;
CountFenceWaits& operator=(const CountFenceWaits&) = delete;
/// Calls made since the counter was installed.
u64 calls() const noexcept { return g_fence_wait_calls; }
private:
decltype(plot::gl::glClientWaitSync) saved_;
};
} // namespace
// The renderer draws a run at the size the style asked for, not at a whole
// multiple of the backend's cell, and it reports what it drew. `axis_font_px()`
// is that report, and the committed atlas is baked at 13 px, so the default
// style's 12 px is a 12/13 scale -- exactly the case the old rule snapped to
// 16 px. No other case sets a size other than the default, and the ones that
// compare a measure compare values scaled by the same factor, so they hold under
// a snapping rule too; this is the one that does not.
PLOT_TEST(axis_text_is_drawn_at_the_requested_em_not_a_whole_multiple) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
// The renderer's own backend, for the raw metrics the ratio is taken against.
const plot::render::EmbeddedMonoFont font;
const plot::f32 baked = font.em_px();
const plot::f32 cell = font.height();
plot::Renderer r;
r.style().axis_font_px = 12.0f; // the default style's em, against a 13 px bake
PLOT_CHECK(r.init(&st));
PLOT_CHECK_NEAR(r.axis_font_px(), cell * (12.0f / baked), 1e-3);
PLOT_CHECK(std::fabs(r.axis_font_px() - 16.0f) > 0.2f); // 2 x the old 8 px glyph
PLOT_CHECK(std::fabs(r.axis_font_px() - 17.0f) > 0.2f); // 1 x this cell
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
const plot::Rect viewport{0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, viewport);
r.draw(p);
r.end_frame();
// The plot lays out with the backend the renderer installed, so the size it
// measured with is the size it drew with.
const plot::render::TextBackend* backend = p.text_backend();
PLOT_CHECK(backend != nullptr);
PLOT_CHECK_NEAR(backend->em_px(), 12.0f, 1e-3);
PLOT_CHECK_NEAR(backend->height(), cell * (12.0f / baked), 1e-3);
// `em_px()` is in the wrapper's units, like `height()`: a caller drawing at
// `requested_px / em_px()` reads 12/12 = 1 and cannot scale a second time.
PLOT_CHECK_NEAR(backend->em_px() / backend->height(), baked / cell, 1e-6);
// An exact multiple still lands exactly, and the frame re-reads the style.
r.style().axis_font_px = 26.0f; // 2 x 13
r.begin_frame(kW, kH, viewport);
r.draw(p);
r.end_frame();
PLOT_CHECK_NEAR(p.text_backend()->em_px(), 26.0f, 1e-3);
PLOT_CHECK_NEAR(p.text_backend()->height(), cell * 2.0f, 1e-3);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
PLOT_TEST(renders_visible_trace_across_viewport) {
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}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 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();
dump_if_requested(r, "smoke.ppm");
// Trace colour must appear in every one of 16 sampled columns across the plot
// area; background must dominate the corners.
const plot::Rect a = p.layout().axes;
PLOT_CHECK(covered_columns(ctx, a, 16) >= 15); // >= 95% of columns covered
plot::u8 corner[4] = {};
PLOT_CHECK(ctx.read_pixel(2, 2, corner));
PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200); // dark background
// The 1M-sample window is ~1700 samples per pixel, so it must have gone
// through a pyramid level rather than through raw samples.
PLOT_CHECK(r.stats().lod_traces == 1);
PLOT_CHECK(r.stats().raw_traces == 0);
PLOT_CHECK(r.stats().staged_segments < 10000); // O(pixels), not O(samples)
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
/// A series whose `dx()` understates its spacing.
///
/// That is every ring-backed series: x is a streamed timestamp, so `dx()` is only a nominal
/// grid (an LOD level, a coarse snap) while `x_at()`/`neighbours()` answer from the real time
/// ring. The samples here sit ten apart and `dx()` claims one, with the plotted window
/// `[12, 18]` falling strictly *between* two of them -- no sample is inside the window at all,
/// so the only way to light its margins is the segment that crosses them.
class LyingGridSeries final : public plot::data::Series {
public:
LyingGridSeries() {
pyramid_.reserve(kN);
for (u64 i = 0; i < kN; ++i) pyramid_.append(kY);
}
u64 size() const override { return kN; }
f64 x0() const override { return 0.0; }
f64 dx() const override { return 1.0; } // the lie: the real spacing is x_at()'s ten
plot::Range x_bounds() const override { return {0.0, x_at(kN - 1u)}; }
plot::Range y_bounds() const override { return {kY, kY}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
f64 x_at(u64 i) const override { return 10.0 * static_cast<f64>(i); }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x < 0.0 || x > x_at(kN - 1u)) return false;
i0 = static_cast<u64>(x / 10.0);
i1 = i0;
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
return true;
}
void gather(u32, u64 begin, u64 end, std::vector<f32>& out,
plot::data::GatherMode) const override {
++gather_calls_;
for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0()));
out.push_back(kY);
}
}
u64 gather_calls() const noexcept { return gather_calls_; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end; ++i) out[i - begin] = kY;
}
private:
static constexpr u64 kN = 5u;
static constexpr f32 kY = 1.0f;
plot::data::MinMaxPyramid pyramid_;
mutable u64 gather_calls_ = 0;
};
/// The window falls between two samples: the trace must still be drawn to both margins.
///
/// Found by zooming in: the renderer mapped the visible x range onto sample indices with
/// `(x - x0()) / dx()`, and on a series whose `dx()` is nominal that lands *inside* the visible
/// range -- here it lands past the end of a five-sample series entirely, so `last <= first` and
/// the frame held no trace at all. The segment entering the window from the sample before it and
/// leaving to the one after are what draw the margins, and a series answers where they are
/// through `neighbours()`, over its own x.
PLOT_TEST(a_sparse_window_between_samples_still_inks_both_margins) {
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));
LyingGridSeries s;
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
// `auto_scale` off on both: the window has to be the one the test names, or the axis takes
// the series' own bounds and the window falls outside the frame -- a test of nothing.
const plot::u32 ax = p.add_x_axis({"t", {12.0, 18.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 3.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 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();
dump_if_requested(r, "margins.ppm");
const plot::Rect a = p.layout().axes;
// The window's own evidence: the samples that bracket it are sampled even though neither is
// inside it, and the reverted grid walk staged *none* (`(12 - 0) / 1` is past the end of a
// five-sample series, so `last <= first` and the frame held no trace at all).
const plot::Range drawn_x = p.x_range(ax);
const plot::u32 covered = covered_columns(ctx, a, 24);
std::printf("[margins] window=[%.3f..%.3f] axes=[%.1f %.1f %.1f %.1f] visible=%llu "
"raw=%llu lod=%llu segments=%llu gathers=%llu trace_visible=%d covered=%u/24\n",
drawn_x.min, drawn_x.max, a.x, a.y, a.w, a.h,
static_cast<unsigned long long>(r.stats().visible_samples),
static_cast<unsigned long long>(r.stats().raw_traces),
static_cast<unsigned long long>(r.stats().lod_traces),
static_cast<unsigned long long>(r.stats().staged_segments),
static_cast<unsigned long long>(s.gather_calls()), int(p.trace(0).visible()),
covered);
PLOT_CHECK(r.stats().visible_samples >= 2u);
PLOT_CHECK(covered == 24u);
PLOT_CHECK(covered_columns(ctx, a, 24) == 24); // every column, margins included
// The margins by name, on the line's own row: the reported symptom was these two being blank
// while the middle drew. `y = 1` in the window `[-1, 3]` is the middle row of the band.
const plot::u32 row = static_cast<plot::u32>(a.y + a.h / 2.0f);
for (const plot::u32 x : {static_cast<plot::u32>(a.x) + 1u,
static_cast<plot::u32>(a.x + a.w) - 2u}) {
bool inked = false;
for (plot::u32 dy = 0; dy < 4u && !inked; ++dy) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (row + dy), px));
inked = trace_pixel(px);
}
PLOT_CHECK(inked);
}
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
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});
auto covered = [&](plot::Range xr) -> plot::u32 {
p.set_x_range(ax, xr);
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());
return covered_columns(ctx, p.layout().axes, 32);
};
// Sparse first: 640 samples over 640 x-units -> ~1 sample/pixel -> raw polyline.
const plot::u32 fine = covered({0.0, 640.0});
PLOT_CHECK(fine >= 30); // >= 95% of 32 columns
PLOT_CHECK(r.stats().raw_traces == 1 && r.stats().lod_traces == 0);
const plot::u32 fine_segments = r.stats().staged_segments;
const plot::u64 fine_samples = r.stats().visible_samples;
// Then dense: 1M samples over 1M x-units -> ~1700 samples/pixel -> LOD buckets.
// It stages several times what the sparse frame did, so on the persistent path
// this frame also grows the mapped ring -- re-creating the store, dropping the
// fence of the draw that may still be reading it and moving the attribute
// offsets -- rather than only ever allocating on a renderer's first frame.
const plot::u32 coarse = covered({0.0, 1'000'000.0});
PLOT_CHECK(coarse >= 30);
PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0);
const plot::u32 coarse_segments = r.stats().staged_segments;
const plot::u64 coarse_samples = r.stats().visible_samples;
// The two windows really did take different paths: the dense one saw orders of
// magnitude more samples while staging far fewer segments than it saw samples,
// which is the whole point of the LOD pyramid. The sparse one staged the window
// itself.
PLOT_CHECK(coarse_samples > 100 * fine_samples);
PLOT_CHECK(coarse_segments < 10000);
PLOT_CHECK(coarse_segments * 100 < coarse_samples);
PLOT_CHECK(fine_segments + 1 >= fine_samples); // one segment per sample pair
ctx.destroy();
}
// Resolution #1 of the brief: the renderer draws into whatever framebuffer is
// bound and clears only its viewport, so a host that embeds the plot in a
// sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given
// in the plot's top-left pixel space, and the renderer shifts them by the
// viewport origin exactly once.
PLOT_TEST(clears_only_the_viewport_region) {
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}); // ~1 sample / pixel: every column is crossed
// The host paints its whole surface first; the frame must leave all of it that
// is outside the viewport alone.
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::u8 px[4] = {};
auto gl_y = [](plot::f32 top) { return static_cast<plot::u32>(kH) - 1u - static_cast<plot::u32>(top); };
// Left of the viewport, below it and past its right edge: untouched host grey.
PLOT_CHECK(ctx.read_pixel(10, 10, px));
PLOT_CHECK(px[0] > 100 && px[0] < 160 && px[1] > 100 && px[2] > 100);
PLOT_CHECK(ctx.read_pixel(20, gl_y(300.0f), px));
PLOT_CHECK(px[0] > 100 && px[0] < 160);
PLOT_CHECK(ctx.read_pixel(600, 300, px));
PLOT_CHECK(px[0] > 100 && px[0] < 160);
// Inside the viewport, above the axes: the renderer's own background.
const plot::Rect a = p.layout().axes;
PLOT_CHECK(a.x >= vp.x && a.right() <= vp.right() && a.y >= vp.y && a.bottom() <= vp.bottom());
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(vp.x) + 2u, gl_y(vp.y + 2.0f), px));
PLOT_CHECK(px[0] + px[1] + px[2] < 200);
// And the trace is inside the axes rect in framebuffer coordinates, so the
// viewport origin was subtracted exactly once.
PLOT_CHECK(covered_columns(ctx, a, 16) >= 15);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// A host embeds the plot in its own frame loop, so the two pieces of global state
// the renderer has to change must come back as the host left them: the unpack
// alignment `init()` sets for the glyph atlas (whose single-channel rows are
// 1-byte aligned), and the clear colour the frame paints its background with.
PLOT_TEST(frame_hands_back_the_hosts_clear_colour_and_unpack_alignment) {
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 host's clear colour, set before the first frame. Nothing touches the
// unpack alignment: the context starts at GL's default of 4, and that is what
// the frame has to leave behind -- with the atlas' 1 still in place, a host's
// next padded RGBA upload would read the wrong rows.
plot::gl::glClearColor(0.5f, 0.25f, 0.75f, 1.0f);
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});
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());
plot::f32 clear[4] = {};
plot::gl::glGetFloatv(plot::gl::GL_COLOR_CLEAR_VALUE, clear);
PLOT_CHECK_NEAR(clear[0], 0.5, 1e-6);
PLOT_CHECK_NEAR(clear[1], 0.25, 1e-6);
PLOT_CHECK_NEAR(clear[2], 0.75, 1e-6);
PLOT_CHECK_NEAR(clear[3], 1.0, 1e-6);
plot::gl::GLint alignment = 0;
plot::gl::glGetIntegerv(plot::gl::GL_UNPACK_ALIGNMENT, &alignment);
PLOT_CHECK(alignment == 4);
// Not vacuous: the frame really did clear to its own background rather than the
// host's grey, so the value above is a restoration and not an untouched leftover.
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(2, kH - 3u, px));
PLOT_CHECK(px[0] + px[1] + px[2] < 200);
ctx.destroy();
}
// The renderer draws every label with its own bitmap font, so it has to lay the
// strips out with that font's metrics. A host backend whose metrics are nothing
// like the artwork's would otherwise reserve a strip too narrow for the labels the
// text pipeline then draws -- here, one pixel per string against ~40px labels.
PLOT_TEST(renderer_measures_the_labels_it_draws) {
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;
// The sub-rect viewport the adapters use, so the label strip's left edge is not
// the framebuffer edge: with a narrower strip than the labels need, the renderer
// stages them left of the viewport, where GL's own clip removes all but their
// last few pixels.
const plot::Rect vp{60.0f, 0.0f, 500.0f, 300.0f};
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
// No y title: the title strip sits left of the label strip, and this test reads
// the label strip's own columns for ink.
const plot::u32 ay = p.add_y_axis({"", {-1000.0, 1000.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
OnePxPerStringBackend host_font;
p.set_text_backend(&host_font);
// With the host's metrics the strip is 1px of label: what the frame must replace.
PLOT_CHECK_NEAR(p.layout().y_label_area.w, 1.0f + 2.0f * p.style().grid_label_pad, 1e-6);
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 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());
// Layout and drawing measure with one backend: the renderer's font, at the size
// the text pipeline draws (whole multiples of the glyph cell -- see AxisFont).
const plot::render::TextBackend* back = p.text_backend();
PLOT_CHECK(back != nullptr);
PLOT_CHECK(back != &host_font);
PLOT_CHECK_NEAR(back->height(), r.axis_font_px(), 1e-6);
plot::f32 widest = 0.0f;
for (const plot::Tick& tick : p.y_axis(ay).ticks) {
if (!tick.major || tick.label.empty()) continue;
widest = std::max(widest, back->measure(tick.label.c_str()));
}
PLOT_CHECK(widest > 8.0f); // real labels in the real font, not the host's 1px
const plot::Rect labels = p.layout().y_label_area;
PLOT_CHECK_NEAR(labels.w, widest + 2.0f * p.style().grid_label_pad, 1e-3);
// And the pixels agree with the strip: the drawn labels occupy the width the
// layout reserved and stay inside it. Kept on the host's 1px metrics the strip is
// ~9px wide while the renderer right-aligns its ~40px label against it, so all
// but the label's last few pixels land left of the viewport and are clipped --
// the ink run collapses instead of filling the width that was reserved.
const plot::Color text = r.style().axis_text;
const plot::u32 y = static_cast<plot::u32>(labels.y);
const plot::u32 rows = static_cast<plot::u32>(labels.h);
const plot::u32 strip_x = static_cast<plot::u32>(labels.x);
// Exactly the strip, and no overhang: the axes' own border line runs down its
// right edge through these same rows, and a scanner that reached past the strip
// would read that line as label ink -- it is the same chalk-toned colour family
// as the text (see `text_ink`).
const TextInk ink =
scan_text_ink(ctx, strip_x, strip_x + static_cast<plot::u32>(labels.w), y, rows, text);
PLOT_CHECK(ink.leftmost != kNoColumn); // labels were drawn
PLOT_CHECK(ink.leftmost >= strip_x); // inside their strip
PLOT_CHECK(static_cast<plot::f32>(ink.widest_row_run) >= 0.6f * widest); // full width drawn
PLOT_CHECK(static_cast<plot::f32>(ink.widest_row_run) <= widest + 1.0f); // and no wider
ctx.destroy();
}
// The overlay layer: cursor markers in their own colours, the shaded measurement
// band and its readout, and an annotation pin. These are the pixels the adapters'
// demos are read for, and none of them goes through the trace path.
PLOT_TEST(draws_cursors_measure_band_and_annotations) {
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});
const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame
// Colours no other element of the frame uses, so each assertion below can only
// be satisfied by the element it names.
const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00)); // green
const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF)); // magenta
PLOT_CHECK(p.add_measure(ca, cb, tr) == 0);
plot::Annotation pin;
pin.x = 200.0;
pin.y = 0.5;
pin.text = "A";
pin.color = plot::Color::hex(0xE8E8E8);
pin.axis_x = ax;
pin.axis_y = ay;
p.add_annotation(pin);
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());
const plot::Rect a = p.layout().axes;
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
// Scans one column of the axes rect, in top-down rows like every Rect.
auto column_has = [&](plot::u32 x, bool (*pred)(const plot::u8*)) {
for (plot::u32 row = 0; row < static_cast<plot::u32>(a.h); ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast<plot::u32>(a.y) - row, px));
if (pred(px)) return true;
}
return false;
};
// Scans a square around one framebuffer position, clipped to the framebuffer so
// a square that reaches an edge simply covers fewer pixels.
auto box_has = [&](plot::f32 fx, plot::f32 fy, plot::i32 radius,
bool (*pred)(const plot::u8*)) {
const plot::i32 cx = static_cast<plot::i32>(fx);
const plot::i32 cy = static_cast<plot::i32>(fy);
for (plot::i32 row = cy - radius; row <= cy + radius; ++row) {
if (row < 0 || row >= static_cast<plot::i32>(kH)) continue;
for (plot::i32 column = cx - radius; column <= cx + radius; ++column) {
if (column < 0 || column >= static_cast<plot::i32>(kW)) continue;
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(column),
ctx.height - 1u - static_cast<plot::u32>(row), px));
if (pred(px)) return true;
}
}
return false;
};
const plot::u32 x_a = static_cast<plot::u32>(tx.to_screen_x(100.0));
const plot::u32 x_b = static_cast<plot::u32>(tx.to_screen_x(300.0));
const plot::u32 mid = static_cast<plot::u32>(tx.to_screen_x(200.0));
PLOT_CHECK(column_has(x_a, green_pixel)); // the cursor markers themselves
PLOT_CHECK(column_has(x_b, magenta_pixel));
// Nothing of either colour where neither cursor is.
PLOT_CHECK(!column_has(mid, green_pixel));
PLOT_CHECK(!column_has(mid, magenta_pixel));
// The measurement band tints the columns between the cursors and only those.
PLOT_CHECK(column_has(mid, band_pixel));
PLOT_CHECK(!column_has(static_cast<plot::u32>(tx.to_screen_x(40.0)), band_pixel));
// ... with its numeric readout drawn near the top of it.
PLOT_CHECK(box_has(static_cast<plot::f32>(mid), a.y + 8.0f, 20, text_pixel));
// The annotation's cross pins data (200, 0.5) in its own colour.
PLOT_CHECK(box_has(static_cast<plot::f32>(tx.to_screen_x(200.0)),
static_cast<plot::f32>(ty.to_screen_y(0.5)), 4, marker_pixel));
ctx.destroy();
}
// The grid is on the pixel grid. Its lines are staged in data space like every
// other line and mapped by the vertex shader, so a 1 px stroke's centre used to
// land wherever the view put it: a line at an x that maps to 100.0 spans
// [99.5, 100.5] and lights two columns at half coverage, while one that maps to
// 100.5 spans [100, 101] and lights one fully. A frame's gridlines were a mix of
// both -- a grey smear beside a crisp hairline -- and panning made each shimmer
// between the two. `LineProgram::set_snap()` rounds an axis-aligned segment's ink
// onto whole pixels, so the observable is sharp: **every lit pixel inside the axes
// rect is the grid colour exactly**, with no half-coverage blend anywhere.
//
// No trace and no minor grid, so the only thing drawn inside the rect is the major
// grid and the one colour it is drawn in; the rect is inset so the axis lines on
// its own edges, which are a different colour, stay out of the scan.
/// The minor grid is a *subdivision*, and the style says so three ways: thinner, dashed and
/// blended into the background. Found by reading a frame whose minor grid read as a second set
/// of ticks -- at full opacity and the same width as the labelled grid there was no hierarchy
/// to see.
///
/// The frame holds no trace, so inside `axes` the only elements are the two grid classes and
/// the background. A row off the horizontal lines then classifies every column by inspection:
/// background, an opaque major line (`pixel_is`, the same exactness `grid_hairlines_land_on_whole_pixels`
/// leans on), or the minor's blend -- which at these defaults is the only non-background pixel
/// in the frame that is neither. Width is checked as *coverage* rather than as a pixel count,
/// because at 1x a sub-pixel logical width is exactly a dimmer stroke: the ink is one column
/// either way, so `grid_minor_width` can only show up in how brightly that column is lit. On a
/// HiDPI display the same width is literally narrower.
PLOT_TEST(the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) {
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_CHECK(r.style().grid_minor_visible);
PLOT_CHECK(r.style().grid_minor_dash_off > 0.0f); // the default is a dash, not a solid line
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
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());
const plot::Rect& a = p.layout().axes;
const plot::Color bg = r.style().background;
const plot::Color major = r.style().grid_major;
// Distance from the background, per channel, so "dimmer" means "closer to the background"
// whatever the two colours are.
const auto bg_distance = [&](const plot::u8 px[4]) {
const auto d = [](plot::u8 v, plot::f32 want) {
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
};
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
};
// The row: the first one that holds all three kinds of pixel, so it is neither a horizontal
// grid line (everything major ink) nor a band the layout reserved for something else.
const plot::u32 first_row = static_cast<plot::u32>(a.y) + 3u;
const plot::u32 last_row = static_cast<plot::u32>(a.y + a.h) - 4u;
std::vector<plot::u32> major_cols;
std::vector<plot::u32> minor_cols;
plot::u32 scan_row = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
major_cols.clear();
minor_cols.clear();
plot::u32 background = 0;
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u;
cx + 3u < static_cast<plot::u32>(a.x + a.w); ++cx) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) {
++background;
} else if (pixel_is(px, major)) {
major_cols.push_back(cx);
} else {
minor_cols.push_back(cx);
}
}
if (background > 0u && !major_cols.empty() && !minor_cols.empty()) {
scan_row = row;
break;
}
}
PLOT_CHECK(scan_row != 0u); // a row carrying both grid classes and the background
PLOT_CHECK(major_cols.size() >= 2u);
PLOT_CHECK(minor_cols.size() >= 2u);
// Solid against dashed, down the two columns: a major line is ink at every row, a minor one
// alternates -- which is the whole of "dashed" a pixel test can see. The duty cycle is
// checked as a range rather than as the exact 3/3 the style asks for, because the dash is a
// fragment discard and its ends land where they land.
const auto column_ink = [&](plot::u32 cx, plot::u32& ink, plot::u32& gaps) {
ink = 0;
gaps = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) {
++gaps;
} else {
++ink;
}
}
};
plot::u32 ink = 0;
plot::u32 gaps = 0;
column_ink(major_cols.front(), ink, gaps);
PLOT_CHECK(gaps == 0u); // the labelled grid is continuous
const plot::u32 major_ink = ink;
column_ink(minor_cols.front(), ink, gaps);
PLOT_CHECK(ink > 0u && gaps > 0u); // dashed, not solid
PLOT_CHECK(ink * 5u > major_ink && gaps * 5u > major_ink); // roughly half on, half off
// Dimmer: the minor's ink sits closer to the background than the major's. This is also the
// "thinner" half of the contract at 1x -- see the note above the test.
const auto ink_distance = [&](plot::u32 cx) {
int best = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) continue;
best = std::max(best, bg_distance(px));
}
return best;
};
const int major_bright = ink_distance(major_cols.front());
const int minor_bright = ink_distance(minor_cols.front());
PLOT_CHECK(minor_bright > 0);
PLOT_CHECK(minor_bright < major_bright);
ctx.destroy();
}
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
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));
r.style().grid_minor_visible = false;
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 10.0}, true, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
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());
const plot::Rect& a = p.layout().axes;
const plot::Color grid = r.style().grid_major;
const plot::Color background = r.style().background;
plot::u32 lit = 0;
for (plot::u32 row = 3u; row + 3u < static_cast<plot::u32>(a.h); ++row) {
for (plot::u32 column = 3u; column + 3u < static_cast<plot::u32>(a.w); ++column) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
if (pixel_is(px, background)) continue;
PLOT_CHECK(pixel_is(px, grid)); // full coverage, not a blend of grid and background
++lit;
}
}
PLOT_CHECK(lit > static_cast<plot::u32>(a.h)); // a grid was drawn at all
ctx.destroy();
}
// Finding 2 of the review: `draw_titles()` placed both titles inside
// `layout.axes`, because the layout reserved no room outside it for them, so in
// the dense frame -- whose envelope spans every column's full height -- a title
// sat on the trace and no pixel test could see it. The layout now insets `axes` by
// a strip per direction and the titles are drawn inside those strips, which is
// what makes a title over the data impossible. This draws known titles over that
// same dense envelope and asserts both halves: no title-coloured pixel in a band
// just inside either horizontal edge of `axes`, and the title colour inside both
// strips. The colour is the style's own `axis_text` -- what the text pipeline is
// tinted with -- never a guess, and no other element of the frame can match it
// (the trace is blue-dominant, the grid grey-dark, the axis line mid-grey).
PLOT_TEST(titles_stay_out_of_the_axes_band) {
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({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 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, "titles.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Rect& a = layout.axes;
const plot::Color title = r.style().axis_text;
// Reads a Rect -- whose rows run top-down, like every Rect here -- and reports
// whether any pixel in it holds the title colour by `pred`'s reading of it. The
// two directions of this test need different readings, and for the reason
// `rect_has_exact` below is written for: inside `axes` only the *exact* colour
// means "a title is here", because a third of a chalk tone is under the axis
// stroke's own grey, so an ink scan there would find the axis; in the reserved
// strips, where nothing but the title draws, the ink scan is the one that can
// see a filtered glyph at all (see `text_ink`).
auto area_has_title = [&](const plot::Rect& area, auto pred) {
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
if (pred(px, title)) return true;
}
}
return false;
};
const auto exact = [](const plot::u8* px, plot::Color c) { return pixel_is(px, c); };
const auto ink = [](const plot::u8* px, plot::Color c) { return text_ink(px, c); };
// One text line plus the two pixels the old placement inset itself by: tall
// enough for the whole title line that used to be drawn there.
const plot::f32 band = r.axis_font_px() + 2.0f;
PLOT_CHECK(!area_has_title({a.x, a.y, a.w, band}, exact));
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}, exact));
// ... while the strips the layout reserved hold both titles.
PLOT_CHECK(layout.x_title_area.h > 0.0f);
PLOT_CHECK(layout.y_title_area.w > 0.0f);
PLOT_CHECK(layout.axes.bottom() <= layout.x_title_area.y); // outside the data
PLOT_CHECK(area_has_title(layout.x_title_area, ink));
PLOT_CHECK(area_has_title(layout.y_title_area, ink));
ctx.destroy();
}
// Finding 1 of the review: when the mapping failed, the store re-created its
// buffer as the orphan path's -- and nothing told the vertex array, so it stayed
// bound to a buffer whose attribute bindings `glDeleteBuffers` had already
// cleared, and every later frame drew nothing at all. The epoch now carries the
// buffer's identity, so the array re-points itself before the draw. The refusal is
// forced through the entry point (`glMapBufferRange` returns null while the caps
// and the pointer still advertise the mapped path, which is exactly the state the
// fallback exists for), and the frame after it must still rasterise the trace.
PLOT_TEST(mapped_ring_fallback_still_draws_after_refused_map) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
{
RefusePersistentMap guard; // before init(): the mapped path is still selected
PLOT_CHECK(r.init(&st));
const bool forced = r.using_persistent_upload();
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}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: an unmistakable polyline
// The first upload is the one that grows the ring, so it is the one whose map
// is refused and which ends up on the orphan buffer.
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
// Geometry first: this is the assertion the deleted buffer's attribute
// bindings broke, leaving the draw with no attributes to read at all.
PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15);
if (forced) {
PLOT_CHECK(!r.using_persistent_upload()); // the fallback really ran
}
PLOT_CHECK(!r.last_frame_had_gl_error());
// ... and the frame after it, which is where the deleted buffer's attribute
// bindings used to leave the pipeline with nothing bound at all.
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(covered_columns(ctx, p.layout().axes, 16) >= 15);
PLOT_CHECK(!r.last_frame_had_gl_error());
}
ctx.destroy();
}
// Fix pass 2, finding 1: the reserved strips were clamped only to the viewport, so
// a y title wider than a narrow panel took the rest of it and `axes` collapsed to
// no width -- and `draw_traces()` returns on a zero-width axes, so the frame drew
// no trace at all and reported nothing. The chrome is capped now (the axes keeps
// half the panel, the title strip takes what is left of that allowance, and the
// title is clipped to the strip), so a narrow panel still draws its data. The
// titles here are long on purpose: ~24 glyphs against a 240 px panel is past the
// point where the reserve used to consume the plot.
PLOT_TEST(narrow_panel_with_a_long_y_title_still_draws) {
constexpr plot::u32 kPanelW = 240;
constexpr plot::u32 kPanelH = 200;
plottest::HeadlessContext ctx(kPanelW, kPanelH);
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>(kPanelW), static_cast<plot::f32>(kPanelH)});
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({"millivolts per division", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 150.0}); // a sample every few pixels: one polyline, all columns
r.begin_frame(kPanelW, kPanelH,
{0, 0, static_cast<plot::f32>(kPanelW), static_cast<plot::f32>(kPanelH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::PlotLayout& layout = p.layout();
// The chrome gave way, not the plot: the axes kept its half of the panel...
PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kPanelW));
PLOT_CHECK(layout.axes.h > 0.0f);
// ... and the title is still drawn, inside the strip it was cut to.
PLOT_CHECK(layout.y_title_area.w > 0.0f);
PLOT_CHECK(covered_columns(ctx, layout.axes, 16) >= 15);
const plot::Color title = r.style().axis_text;
auto area_has_title = [&](const plot::Rect& area) {
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
if (pixel_is(px, title)) return true;
}
}
return false;
};
PLOT_CHECK(area_has_title(layout.y_title_area)); // present, though cut short
PLOT_CHECK(!area_has_title(layout.axes)); // and never over the data
ctx.destroy();
}
// The same finding in the x direction: `x_title_height` collapsed `axes.h` on a
// short panel, and the old guard tested only the width, so every trace was
// flattened onto one row instead of being skipped where a caller could see it.
// A 48 px panel is the case where the two strips used to be all of it.
PLOT_TEST(short_panel_with_an_x_title_still_draws) {
constexpr plot::u32 kPanelW = 320;
constexpr plot::u32 kPanelH = 48;
plottest::HeadlessContext ctx(kPanelW, kPanelH);
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>(kPanelW), static_cast<plot::f32>(kPanelH)});
const plot::u32 ax =
p.add_x_axis({"time since the first sample, in seconds", s.x_bounds(), true,
plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 150.0});
r.begin_frame(kPanelW, kPanelH,
{0, 0, static_cast<plot::f32>(kPanelW), static_cast<plot::f32>(kPanelH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::PlotLayout& layout = p.layout();
PLOT_CHECK(layout.axes.h >= 0.5f * static_cast<plot::f32>(kPanelH));
PLOT_CHECK(layout.axes.w > 0.0f);
PLOT_CHECK(covered_columns(ctx, layout.axes, 16) >= 15);
ctx.destroy();
}
// Fix pass 2, finding 2: every titled axis of a direction was staged at the same
// centred position inside the one strip, so a Bottom/Top (or Left/Right) pair drew
// over each other -- the overprint the pre-strip placement did not have. Each
// titled axis now owns a text line, so a pair shows two separated runs of
// title-coloured pixels where it used to show one: two *rows* for an x pair, which
// is what this case reads. A titled y pair's two runs are columns now (the title
// is rotated), so they are read by `two_titled_y_axes_get_two_columns` below.
PLOT_TEST(two_titled_x_axes_get_their_own_rows) {
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({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
p.add_x_axis({"samples", s.x_bounds(), true, plot::AxisPlace::Top, false});
const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p.add_y_axis({"amps", {-1.0, 1.0}, true, plot::AxisPlace::Right, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 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, "pairs.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Color title = r.style().axis_text;
// The rows of the x strip that hold title-coloured pixels, as the runs of rows
// they form: one run per titled axis, because the two runs of a pair are a line
// apart and each covers several pixel rows of glyph ink.
auto row_bands = [&](const plot::Rect& strip) {
plot::u32 bands = 0;
bool previous = false;
plot::u32 rows = 0;
for (plot::u32 row = 0; row < static_cast<plot::u32>(strip.h); ++row) {
bool has = false;
for (plot::u32 column = 0; column < static_cast<plot::u32>(strip.w) && !has; ++column) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(strip.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(strip.y) + row), px));
has = text_ink(px, title);
}
if (has && !previous) ++bands;
if (has) ++rows;
previous = has;
}
PLOT_CHECK(rows >= 4u); // real glyph ink, not one stray pixel
return bands;
};
PLOT_CHECK(layout.x_title_area.h > 0.0f);
PLOT_CHECK(layout.y_title_area.w > 0.0f);
PLOT_CHECK(row_bands(layout.x_title_area) == 2u); // "seconds" above "samples"
ctx.destroy();
}
/// Whether any pixel of `area` is exactly `c` -- the assertion `pixel_is` can make
/// and `text_ink` cannot: a third of the title colour is below the axis stroke's
/// grey, so an ink scan of the axes rect would find the axis itself. For a chalk
/// element whose *absence* from a rectangle is the property, the exact colour is
/// the predicate that means something.
bool rect_has_exact(plottest::HeadlessContext& ctx, const plot::Rect& area, plot::Color c) {
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (pixel_is(px, c)) return true;
}
}
return false;
}
// The change itself. The y title is a rotated run now, so its strip is a column one
// text line wide and as tall as the axes, instead of a strip as wide as the title
// with a horizontal run drawn in it. The layout half of that is asserted in
// test_plot.cpp; this is the pixel half -- the ink really is rotated (taller than
// wide for a title fifteen times wider than tall when horizontal), it stays inside
// the column, and it never reaches the data area.
PLOT_TEST(y_title_ink_is_rotated_and_outside_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::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
const char* const title = "millivolts per division";
p.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
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, "vertical_y_title.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 pad = r.style().grid_label_pad;
// The strip is a column: one text line wide (the renderer handed the plot its own
// scaled font, so the two agree exactly) and as tall as the axes.
PLOT_CHECK_NEAR(layout.y_title_area.w, r.axis_font_px() + 2.0f * pad, 1e-3);
PLOT_CHECK(layout.y_title_area.h > layout.y_title_area.w);
const RectInk drawn = scan_rect_ink(ctx, layout.y_title_area, ink);
PLOT_CHECK(drawn.any());
// Real glyph ink, not one stray anti-aliased pixel: the run covers hundreds.
PLOT_CHECK(drawn.pixels >= 32u);
// Rotated, not merely moved: the run is taller than it is wide. The same string
// drawn on the unrotated basis is ~24 glyphs wide -- seven times the strip it is
// in -- so this is the assertion that fails if the basis is left at (1,0).
PLOT_CHECK(drawn.box.height() > drawn.box.width());
PLOT_CHECK(drawn.box.width() <= static_cast<plot::u32>(layout.y_title_area.w));
// The sign of the basis too, which is the half a rotation and a reflection share:
// the cell is built from the basis and its perpendicular, and with (0,-1) that
// puts the pen -- the run's start -- at the cell's bottom-left and the cell
// growing up and to the *right* of it. A run that advanced the other way round
// the screen would have its cells to the left of the pen, i.e. left of the
// strip's padding, and the ink would start at the strip's edge or off it.
PLOT_CHECK(drawn.box.x0 >= static_cast<plot::u32>(layout.y_title_area.x + pad));
// ... and none of it is inside the axes: the title is drawn in the strip the
// layout reserved, which lies outside the data area.
PLOT_CHECK(layout.y_title_area.right() <= layout.y_label_area.x + 1e-4f);
PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink));
ctx.destroy();
}
// Two titled y axes both draw in the one strip the layout reserves for them, and
// now that the runs are vertical they cannot stack down its height -- they would
// overlap from top to bottom. Each takes a column instead, so a Left/Right pair
// shows two runs side by side in distinct x ranges: one band of inked columns per
// axis with background between them, where the horizontal-run model drew the pair
// as two rows a line apart in a strip as wide as the widest title.
PLOT_TEST(two_titled_y_axes_get_two_columns) {
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::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
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, "y_title_columns.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 pad = r.style().grid_label_pad;
// Room for one column per titled axis, and no more: the strip is two text lines
// wide, not two titles' worth of width.
PLOT_CHECK(p.titled_y_axis_count() == 2u);
PLOT_CHECK_NEAR(layout.y_title_area.w, 2.0f * r.axis_font_px() + 2.0f * pad, 1e-3);
const std::vector<InkBand> columns = ink_column_bands(ctx, layout.y_title_area, ink);
PLOT_CHECK(columns.size() == 2u);
// Distinct x ranges, in the order the axes were added: the Left axis' column is
// left of the Right axis'.
PLOT_CHECK(columns[1].first > columns[0].last);
// Each band is one rotated run -- "volts" in one column, "amps" in the next --
// so each is taller than it is wide on its own.
for (const InkBand& column : columns) {
const RectInk run = scan_rect_ink(
ctx, plot::Rect{static_cast<plot::f32>(column.first), layout.y_title_area.y,
static_cast<plot::f32>(column.width()), layout.y_title_area.h},
ink);
PLOT_CHECK(run.any());
PLOT_CHECK(run.box.height() > run.box.width());
}
ctx.destroy();
}
// A y title longer than its strip is cut where the strip ends, exactly as an
// over-long x title is cut where its row ends. That needs the strip's *height* to
// be the run's budget, which is what threading the axis through the clip buys: a
// budget still taken from the strip's width stops the run after two glyphs, and no
// budget at all lets it leave the strip.
//
// The plot's viewport is inset from the framebuffer so the overspill would land on
// pixels this frame can read: an unclipped run leaves the strip and keeps going
// above the plot, where the framebuffer still has room -- "drawn past the strip" is
// otherwise indistinguishable from "clipped at the strip's end and off the target".
PLOT_TEST(over_long_y_title_is_clipped_to_its_strip) {
constexpr plot::u32 kFbW = 640;
constexpr plot::u32 kFbH = 280;
constexpr plot::f32 kPanelY = 80.0f;
constexpr plot::f32 kPanelH = 160.0f;
plottest::HeadlessContext ctx(kFbW, kFbH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0.0f, kPanelY, static_cast<plot::f32>(kFbW), kPanelH});
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"millivolts per division of the input signal", {-1.0, 1.0}, false,
plot::AxisPlace::Left, false});
r.begin_frame(kFbW, kFbH, {0.0f, kPanelY, static_cast<plot::f32>(kFbW), kPanelH});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "y_title_clipped.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 pad = r.style().grid_label_pad;
const plot::Rect& strip = layout.y_title_area;
// Chrome gave way, not the plot: 43 glyphs of title did not shrink the axes past
// its floor, and the strip is still one column.
PLOT_CHECK(layout.axes.h >= 0.5f * kPanelH);
PLOT_CHECK(layout.axes.w > 0.0f);
PLOT_CHECK_NEAR(strip.w, r.axis_font_px() + 2.0f * pad, 1e-3);
const RectInk drawn = scan_rect_ink(ctx, strip, ink);
PLOT_CHECK(drawn.any());
// The run spent the strip's height: a run cut to the 16 px column would be two
// glyphs tall, and this is the assertion that fails if the axis is threaded but
// the budget is not.
PLOT_CHECK(drawn.box.height() * 4u >= 3u * static_cast<plot::u32>(strip.h));
PLOT_CHECK(drawn.box.height() <= static_cast<plot::u32>(strip.h));
// ... and it stopped inside the strip: nothing in the strip's own columns above
// the plot's viewport or below the axes, which is where an unclipped run would
// have run out of the strip and kept drawing.
PLOT_CHECK(!scan_rect_ink(ctx, plot::Rect{strip.x, 0.0f, strip.w, strip.y}, ink).any());
PLOT_CHECK(!scan_rect_ink(ctx,
plot::Rect{strip.x, strip.bottom(), strip.w,
static_cast<plot::f32>(kFbH) - strip.bottom()},
ink)
.any());
ctx.destroy();
}
// The chrome allowance (`Plot::layout()`) caps the y label strip at half the viewport
// and gives the title strip what is left of that allowance, so a viewport narrow
// enough to spend all of it on the tick labels leaves the title strip narrower than
// one text line -- or, as here, zero wide. Nothing about a *rotated* title is bounded
// by that width: its budget is the strip's height, its pen is the strip's padding and
// `walk_text_clipped()` clips along the run, so before the fit gate the glyph cells
// were staged into a strip that cannot hold them -- over the tick labels, which are
// right-aligned across exactly those columns, and (once the columns step past
// `axes.x`) inside the data area.
//
// The property is "not drawn, not relocated": the same scene drawn with and without
// y titles must produce identical frames. Comparing the whole target catches the
// overprint wherever it lands, and cannot be satisfied by drawing the title somewhere
// else. Three titled axes are what it takes to reach `axes` in a strip this narrow.
PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) {
constexpr plot::u32 kNarrowW = 64;
constexpr plot::u32 kNarrowH = 200;
plottest::HeadlessContext ctx(kNarrowW, kNarrowH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const auto scene = [&](const char* title) {
plot::Plot plot;
plot.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
plot.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
// A milli-scale range, for the *width of its labels*: a symmetric span ticks at
// +/-step and 0, so the labels here are `-200m 0 200m` -- five characters, wide
// enough that the label strip alone spends the whole chrome allowance and the
// title strip is left zero wide, which is the regime asserted below. A `[-1, 1]`
// axis labels `-1 0 1` instead and does not fill the allowance: the titled scene
// then keeps a 9.6 px title column -- still too narrow to stage a glyph, so the
// property holds -- but the column also pushes the label strip 9.6 px right, and
// the frame comparison would be reading that shift rather than title ink.
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
return plot;
};
const auto render = [&](plot::Plot& plot) {
r.begin_frame(kNarrowW, kNarrowH,
{0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
r.draw(plot);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
};
plot::Plot titled = scene("millivolts per division");
render(titled);
const std::vector<plot::u8> with_titles = read_frame(ctx);
const plot::PlotLayout layout = titled.layout(); // a copy: draw() mutates the plot
const plot::Color ink = r.style().axis_text;
// The clamp bit exactly the way this case needs it to: the strip has less room
// than the one column its three titled axes ask for, so no column can be staged.
PLOT_CHECK(titled.titled_y_axis_count() == 3u);
PLOT_CHECK(layout.y_title_area.w < r.axis_font_px() + r.style().grid_label_pad);
// Zero wide, not merely under one column -- the labels spend the allowance whole.
// That is this comparison's own premise as much as the clamp's: a strip with any
// width left in it would move the label strip right in the titled scene alone, and
// the two frames would then differ over the labels rather than over title ink.
PLOT_CHECK(layout.y_title_area.w == 0.0f);
// ... and the plot kept its floor in a viewport this narrow, so nothing here is
// degenerate for some other reason.
PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kNarrowW));
PLOT_CHECK(layout.axes.h > 0.0f);
// No title ink in the data area. This is the direct read of the invariant; the
// frame comparison below is the same statement about the whole target.
PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink));
plot::Plot plain = scene("");
render(plain);
const std::vector<plot::u8> without_titles = read_frame(ctx);
plot::u32 differing = 0u;
plot::u32 first = 0u;
for (plot::u32 i = 0; i < static_cast<plot::u32>(with_titles.size() / 4u); ++i) {
bool same = true;
for (plot::u32 c = 0; c < 4u && same; ++c) {
same = with_titles[4u * i + c] == without_titles[4u * i + c];
}
if (!same) {
if (differing == 0u) first = i;
++differing;
}
}
if (differing != 0u) {
std::printf("[narrow_y_title] %u of %zu pixels differ; first at x=%u y=%u\n",
differing, with_titles.size() / 4u, first % kNarrowW, first / kNarrowW);
}
PLOT_CHECK(differing == 0u);
ctx.destroy();
}
// The same clamp, in a viewport wide enough that the strip holds *some* columns: the
// ones the clamp cut short must be dropped, not drawn beside the ones that fit. Three
// titled y axes ask for three columns, the strip has room for fewer, and the columns
// that survive are the leading ones -- which is what "keep the leading columns"
// means, since the renderer lays them out left to right from the strip's padding.
PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) {
// 170 px is where the clamp leaves room for some of the three columns and not all
// of them: the label strip takes 44 px of the 85 px allowance and a column needs
// 19.7, so the 41 px left over hold two. The assertions below re-derive the count
// from the measured strip rather than assume it, which is what lets the regime be
// "at least one and fewer than three" instead of a pixel figure that drifts with
// the font, the device scale or the tick labels.
constexpr plot::u32 kNarrowW = 170;
constexpr plot::u32 kNarrowH = 200;
plottest::HeadlessContext ctx(kNarrowW, kNarrowH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
// Milli-scale for the label width, as in the zero-wide-strip case above: the strip
// this test wants is the one that holds *some* columns and not all three, and what
// it has left to hold them in is the allowance minus the label strip.
p.add_y_axis({"volts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
p.add_y_axis({"amps", {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
p.add_y_axis({"watts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
r.begin_frame(kNarrowW, kNarrowH,
{0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "y_title_columns_clamped.ppm");
const plot::PlotLayout layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 line = r.axis_font_px();
const plot::f32 pad = r.style().grid_label_pad;
const plot::Rect& strip = layout.y_title_area;
// A column occupies the strip's leading padding plus one line, so this is how many
// of the three fit -- the rule the renderer applies, and the reason the trailing
// ones are the ones with nowhere to go. The regime this case is about needs a
// strip that holds at least one column and fewer than all three.
PLOT_CHECK(p.titled_y_axis_count() == 3u);
const plot::u32 fits = static_cast<plot::u32>(std::max(0.0f, (strip.w - pad) / line));
PLOT_CHECK(fits >= 1u);
PLOT_CHECK(fits < 3u);
PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kNarrowW));
PLOT_CHECK(layout.axes.h > 0.0f);
// One band of inked columns per column that fits, each inside its own column's cell
// (band i starts at the padding plus i lines and is one line wide).
const std::vector<InkBand> columns = ink_column_bands(ctx, strip, ink);
PLOT_CHECK(columns.size() == fits);
for (plot::u32 i = 0; i < static_cast<plot::u32>(columns.size()); ++i) {
PLOT_CHECK(columns[i].first >=
static_cast<plot::u32>(strip.x + pad + static_cast<plot::f32>(i) * line));
PLOT_CHECK(columns[i].last <=
static_cast<plot::u32>(strip.x + pad + static_cast<plot::f32>(i + 1u) * line));
}
// ... and none of it reached the data area.
PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink));
ctx.destroy();
}
// The clamp reaches the x title from the vertical side: `x_title_height` is capped at
// the viewport's allowance minus the tick label strip's height, so a viewport short
// enough leaves the strip too short for a single row. A row staged anyway is not wholly
// clipped -- its cell starts inside the strip, so the top of its glyphs lands in the
// frame, a title cut off mid-stroke -- and neither the run's own clip (which cuts along
// x) nor the layout's clamp can see that. So this is the one titled axis where nothing
// fits, and nothing may be staged.
PLOT_TEST(short_viewport_draws_no_x_title_it_cannot_fit) {
// 80 px tall: the label strip takes 23.69 px of the 40 px allowance, leaving a
// 16.31 px strip against the 19.69 px a row needs -- no room for a row, but 12.31 px
// of a staged row's cell would still be inside the target.
constexpr plot::u32 kShortW = 320;
constexpr plot::u32 kShortH = 80;
plottest::HeadlessContext ctx(kShortW, kShortH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kShortW), static_cast<plot::f32>(kShortH)});
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
r.begin_frame(kShortW, kShortH,
{0, 0, static_cast<plot::f32>(kShortW), static_cast<plot::f32>(kShortH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "x_title_clamped.ppm");
const plot::PlotLayout layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 line = r.axis_font_px();
const plot::f32 pad = r.style().grid_label_pad;
const plot::Rect& strip = layout.x_title_area;
// The clamp bit the way this case needs it to: not one row fits.
PLOT_CHECK(p.titled_x_axis_count() == 1u);
const plot::u32 fits = static_cast<plot::u32>(std::max(0.0f, (strip.h - pad) / line));
PLOT_CHECK(fits == 0u);
// ... and the plot kept its floor in a viewport this short.
PLOT_CHECK(layout.axes.h >= 0.5f * static_cast<plot::f32>(kShortH));
PLOT_CHECK(layout.axes.w > 0.0f);
// Nothing was staged for it: no title ink in the strip at all (a staged row's ink
// would land here, cut off by the viewport's bottom edge), and none in the data
// area either.
PLOT_CHECK(!scan_rect_ink(ctx, strip, ink).any());
PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink));
ctx.destroy();
}
// The same clamp one row short instead of absent: three titled x axes ask for three
// rows and the strip holds one, so the rows it cut short are the trailing ones and must
// be dropped rather than squeezed past its end. Each is partly visible otherwise -- its
// cell starts inside the strip, and the rest of it is cut by the target's bottom edge,
// which is exactly where an x strip ends -- so the strip's row bands are what says how
// many were staged: one per row that fits, each inside its own row's cell.
PLOT_TEST(short_viewport_keeps_only_the_x_title_rows_that_fit) {
// 112 px tall: the label strip takes 23.69 px of the 56 px allowance, leaving a
// 32.31 px strip against the 19.69 px a row needs and the 35.38 px two rows need, so
// one row fits with 12.6 px of the strip left below it -- enough for the row that
// must be dropped to show its glyph tops before the target's bottom edge cuts it.
constexpr plot::u32 kShortW = 320;
constexpr plot::u32 kShortH = 112;
plottest::HeadlessContext ctx(kShortW, kShortH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kShortW), static_cast<plot::f32>(kShortH)});
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_x_axis({"samples", {0.0, 1.0}, false, plot::AxisPlace::Top, false});
p.add_x_axis({"ticks", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
r.begin_frame(kShortW, kShortH,
{0, 0, static_cast<plot::f32>(kShortW), static_cast<plot::f32>(kShortH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "x_title_rows_clamped.ppm");
const plot::PlotLayout layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 line = r.axis_font_px();
const plot::f32 pad = r.style().grid_label_pad;
const plot::Rect& strip = layout.x_title_area;
// A row occupies the strip's top padding plus one line, so this is how many of the
// three fit -- the rule the renderer applies, derived here rather than assumed.
PLOT_CHECK(p.titled_x_axis_count() == 3u);
const plot::u32 fits = static_cast<plot::u32>(std::max(0.0f, (strip.h - pad) / line));
PLOT_CHECK(fits >= 1u);
PLOT_CHECK(fits < 3u);
PLOT_CHECK(layout.axes.h >= 0.5f * static_cast<plot::f32>(kShortH));
PLOT_CHECK(layout.axes.w > 0.0f);
// One band of inked rows per row that fits, each inside its own row's cell (band i
// starts at the padding plus i lines and is one line tall).
const std::vector<InkBand> rows = ink_row_bands(ctx, strip, ink);
PLOT_CHECK(rows.size() == fits);
for (plot::u32 i = 0; i < static_cast<plot::u32>(rows.size()); ++i) {
PLOT_CHECK(rows[i].first >=
static_cast<plot::u32>(strip.y + pad + static_cast<plot::f32>(i) * line));
PLOT_CHECK(rows[i].last <=
static_cast<plot::u32>(strip.y + pad + static_cast<plot::f32>(i + 1u) * line));
}
ctx.destroy();
}
// Finding 4 of the review: `mark_drawn()` dropped a failed fence's status, so the
// slot stayed unsynchronised and the next upload memcpy'd into storage the GPU may
// still have been reading -- exactly the tearing the ring exists to prevent. A
// refused fence now drops the ring for the orphan path and reports it, instead of
// rotating through a slot nothing guards. `glFenceSync` is stubbed to hand back no
// sync object while the caps still advertise the mapped path, so the fence is the
// only thing that fails.
PLOT_TEST(refused_fence_drops_the_mapped_ring) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
{
RefuseFenceSync guard;
PLOT_CHECK(r.init(&st));
const bool forced = r.using_persistent_upload();
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}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 640.0});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
if (forced) {
// The frame whose slot could not be fenced has stopped using the ring: the
// next upload must not write storage the draw may still be reading, and the
// frame reports the failure rather than swallowing it.
PLOT_CHECK(!r.using_persistent_upload());
PLOT_CHECK(r.last_frame_had_gl_error());
}
PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15); // it still drew
// The next frames draw through the orphan path, which needs no fence.
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(covered_columns(ctx, p.layout().axes, 16) >= 15);
PLOT_CHECK(!r.last_frame_had_gl_error());
}
ctx.destroy();
}
/// The ring must absorb a frame that issues more uploads than it starts with
/// slots. The shape it replaced -- three slots and a fence per upload -- wrapped
/// within such a frame: from the fourth upload on, each one waited on a fence
/// recorded a few draws *earlier in the same frame*, i.e. an effective per-frame
/// GPU sync. That is free when the driver retires draws promptly (the GLFW host,
/// 1.07 ms a frame) and ~4 ms of the Qt widget's 7.11 ms under llvmpipe, which is
/// exactly the difference `PLOT_FORCE_GL33_UPLOAD=1` shows -- and it is a cliff
/// that grows with the trace and cursor counts this library exists for.
///
/// The ring now grows to cover the frame's uploads, records one fence after the
/// frame's last draw, and waits once, at the start of the next frame, so a frame
/// never synchronises with work it issued itself. None of that is visible in the
/// pixels -- a syncing ring draws the same image -- so the test pins the three
/// properties directly: a frame writes distinct slots (the offsets it hands the
/// vertex array), the ring grows past its floor to cover them, and a frame
/// performs at most one wait.
PLOT_TEST(ring_grows_to_cover_a_frame_and_waits_once_at_its_start) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::detail::InstanceStore store;
PLOT_CHECK(store.create(plot::render::UploadMode::PersistentMapped, &st));
if (!store.persistent()) {
ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin
return;
}
constexpr plot::u32 kUploadsPerFrame = 6; // more than the ring's three-slot floor
plot::f32 data[4 * kUploadsPerFrame];
for (plot::u32 i = 0; i < 4 * kUploadsPerFrame; ++i) data[i] = static_cast<plot::f32>(i);
auto counter = [](const std::atomic<plot::u64>& c) {
return c.load(std::memory_order_relaxed);
};
plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed);
plot::render::detail::ring_waits().store(0, std::memory_order_relaxed);
plot::render::detail::ring_slots().store(0, std::memory_order_relaxed);
// The first frame has no fence outstanding, so it must not wait at all -- and it
// must have grown the ring past its floor, because six uploads do not fit in
// three slots.
PLOT_CHECK(store.begin_frame());
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
}
PLOT_CHECK(store.mark_drawn());
PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == kUploadsPerFrame);
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 0);
PLOT_CHECK(counter(plot::render::detail::ring_slots()) > store.kInitialSlots);
PLOT_CHECK(counter(plot::render::detail::ring_slots()) >= kUploadsPerFrame);
// The second frame, measured upload by upload. The ring covers it now, so the
// slot each upload lands in -- the offset the vertex array re-points at -- is
// readable, and the invariant is the offsets being distinct: a slot written
// twice in a frame would be one the GPU could still be reading the first copy
// from, because the only fence is the one at the end of the frame.
PLOT_CHECK(store.begin_frame());
plot::usize offsets[kUploadsPerFrame] = {};
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
offsets[i] = store.slot_offset();
}
PLOT_CHECK(store.mark_drawn());
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
for (plot::u32 j = i + 1u; j < kUploadsPerFrame; ++j) {
PLOT_CHECK(offsets[i] != offsets[j]); // no slot reused within the frame
}
}
// ... and this frame waited exactly once: on the fence the first one recorded,
// never on its own work.
PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == 2u * kUploadsPerFrame);
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 1);
// A third frame the same, so the wait is per frame and not per upload: six
// uploads a frame would be five waits a frame under the shape this replaced.
PLOT_CHECK(store.begin_frame());
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
}
PLOT_CHECK(store.mark_drawn());
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 2);
ctx.destroy();
}
/// The same property at the level the demos run at. A frame of this plot stages
/// what the demos stage -- two grid colour groups, the axis lines, tick labels and
/// titles, the trace, two cursors and the measurement band -- nine-ish uploads, or
/// three times the slots the ring used to have, so the old ring wrapped within the
/// frame several times over. The frame must absorb them with at most one wait per
/// store (two stores), and the upload counter must agree with the draw calls the
/// frame reports, so it is counting this frame's staging and nothing else.
///
/// The wait is checked twice over: once from the store's own counter, and once at
/// `glClientWaitSync`, the call every wait is made through. A nine-upload frame
/// under the shape this replaced made six mid-frame wait sites, each costing the
/// non-blocking probe plus the bounded wait behind it.
PLOT_TEST(a_dense_frame_uploads_more_than_the_ring_floor_without_waiting) {
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));
if (!r.using_persistent_upload()) {
ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin
return;
}
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}, true, plot::AxisPlace::Left, false});
const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 640.0});
const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00));
const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF));
PLOT_CHECK(p.add_measure(ca, cb, tr) == 0);
auto counter = [](const std::atomic<plot::u64>& c) {
return c.load(std::memory_order_relaxed);
};
plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed);
plot::render::detail::ring_waits().store(0, std::memory_order_relaxed);
plot::render::detail::ring_slots().store(0, std::memory_order_relaxed);
CountFenceWaits driver_waits; // installed after load(), restored with the test
for (plot::u32 index = 0; index < 3u; ++index) {
const plot::u64 uploads_before = counter(plot::render::detail::ring_uploads());
const plot::u64 waits_before = counter(plot::render::detail::ring_waits());
const plot::u64 calls_before = driver_waits.calls();
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());
const plot::u64 uploads = counter(plot::render::detail::ring_uploads()) - uploads_before;
const plot::u64 waits = counter(plot::render::detail::ring_waits()) - waits_before;
// Every upload lands in a draw this frame reported, so the counter describes
// this frame's staging and no other store's.
PLOT_CHECK(uploads == r.stats().draw_calls);
// The scenario: more uploads than the ring's floor of slots.
PLOT_CHECK(uploads > plot::render::detail::InstanceStore::kInitialSlots);
// ... and not one of them waited: the only wait is the next frame's start,
// one per store.
PLOT_CHECK(waits <= 2);
if (index == 0) PLOT_CHECK(waits == 0);
// At the driver call too: one wait site per store, and a site costs at most
// two calls -- the non-blocking probe, then the bounded wait behind it. Six
// uploads past the old ring's three slots used to cost six sites on their own,
// inside the frame that made them.
const plot::u64 calls = driver_waits.calls() - calls_before;
PLOT_CHECK(calls <= 4u);
if (index == 0) PLOT_CHECK(calls == 0u);
}
// The ring really grew past its floor: what covers a nine-upload frame is not
// what three slots covered.
PLOT_CHECK(counter(plot::render::detail::ring_slots()) >
plot::render::detail::InstanceStore::kInitialSlots);
ctx.destroy();
}
// Rotated text: the staged basis, not the screen axes, is what a run is laid out
// along. The same six glyphs are drawn twice at the same pen and size, once
// unrotated and once with `axis = (0, -1)` -- the direction a bottom-to-top y
// label advances in -- and the ink has to stand up: the rotated box is taller
// than it is wide, and the run marches *up* from the pen instead of to its right.
// A shader that ignored `a_axis` would draw the second frame exactly like the
// first.
PLOT_TEST(rotated_text_advances_along_its_basis) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
const plot::Vec2 pen{300.0f, 180.0f};
const plot::f32 px = 2.0f; // one cell is 16 x 34 screen pixels at this scale
const plot::u32 cell = plot::render::EmbeddedMonoFont::cell_height() * 2u;
const plot::u32 advance_px =
static_cast<plot::u32>(font.glyph(static_cast<plot::u32>('M'))->advance * px);
const InkBox flat = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {1.0f, 0.0f});
PLOT_CHECK(flat.any());
PLOT_CHECK(flat.width() > flat.height());
PLOT_CHECK(flat.x0 >= static_cast<plot::u32>(pen.x));
PLOT_CHECK(flat.y0 >= static_cast<plot::u32>(pen.y));
PLOT_CHECK(flat.height() <= cell);
PLOT_CHECK(flat.width() > 4u * advance_px); // six monospaced glyphs, and then some
const InkBox up = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {0.0f, -1.0f});
PLOT_CHECK(up.any());
PLOT_CHECK(up.height() > up.width()); // the run stands up
PLOT_CHECK(up.height() > flat.height()); // and is as long as the flat one was wide
PLOT_CHECK(up.width() <= cell); // one cell wide, so nothing marched right
PLOT_CHECK(up.x0 >= static_cast<plot::u32>(pen.x));
PLOT_CHECK(up.x1 < static_cast<plot::u32>(pen.x) + cell);
// Ink above the pen: the first cell starts at the pen and every later one is an
// advance further up, so the run's rows run from well above the pen down to the
// pen's own cell -- and the pen's own cell is the lowest row of ink.
PLOT_CHECK(up.y0 + 4u * advance_px < static_cast<plot::u32>(pen.y));
PLOT_CHECK(up.y1 < static_cast<plot::u32>(pen.y) + cell);
ctx.destroy();
}
// The orientation rule, stated rather than hardcoded: a glyph's texel `(col, row)`
// lands at the pen plus `col` steps along the cell's +x axis (`ux` here) and `row`
// steps along its +v axis (the perpendicular). That is the mapping kTextVS
// implements, so with `axis = (0, -1)` the 'L' -- whose ink is a stem down its left
// columns and a foot along its bottom rows -- has to come out as a bar standing up
// from the pen with the foot running right.
//
// The check is two-sided: the texels the basis says hold ink do, and the texels a
// mirrored perpendicular would put that ink in hold none -- so a glyph flipped
// about its own diagonal fails even though it is the same size and shape. The
// coverage is anti-aliased, so "held ink" is a coverage threshold rather than an
// exact white: a texel of the stem is fully covered and a texel of the foot is
// half, and both are ink.
PLOT_TEST(rotated_glyph_orientation_is_not_mirrored) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
const plot::Vec2 pen{300.0f, 200.0f};
const plot::f32 px = 4.0f; // one texel is a 4x4 pixel block
const plot::Vec2 ux{0.0f, -1.0f}; // a 90 degree counter-clockwise label
const plot::Vec2 uy{-ux.y, ux.x}; // (1, 0): the cell's own +v axis
// Where the centre of the glyph's texel (col, row) is drawn. The cell's top-left
// is the pen (the embedded font bakes its bearings into the cell), and the rule
// is the shader's own: the atlas' u runs along the cell's +x axis (`ux` here,
// `a_axis` there) and its v along the perpendicular (`v_axis`), which is what
// `v_uv = a_uv + corner * u_glyph_uv` maps corner by corner.
auto texel_centre = [&](plot::f32 col, plot::f32 row, plot::Vec2 v_axis) {
const plot::f32 du = (col + 0.5f) * px;
const plot::f32 dv = (row + 0.5f) * px;
return plot::Vec2{pen.x + ux.x * du + v_axis.x * dv, pen.y + ux.y * du + v_axis.y * dv};
};
auto inked = [&](plot::Vec2 at) {
plot::u8 pxv[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(at.x),
ctx.height - 1u - static_cast<plot::u32>(at.y), pxv));
return pxv[0] >= 96u; // white tint over black: the pixel *is* the coverage
};
PLOT_CHECK(draw_text_run(ctx, atlas, font, "L", pen, px, ux).any());
// The stem (cell columns 1-2, rows 3-12) and the foot (columns 2-6, rows 11-12)
// put ink at these texels ...
PLOT_CHECK(inked(texel_centre(2.0f, 4.0f, uy))); // stem, fully covered
PLOT_CHECK(inked(texel_centre(2.0f, 11.0f, uy))); // stem meets the foot
PLOT_CHECK(inked(texel_centre(5.0f, 11.0f, uy))); // the foot's far end
// ... and none at these, which are the cell's blank margin: a shader that
// swapped the cell's two axes would put the ink in the wrong half and fail both
// sides.
PLOT_CHECK(!inked(texel_centre(2.0f, 0.0f, uy))); // above the first row of ink
PLOT_CHECK(!inked(texel_centre(6.0f, 4.0f, uy))); // right of the stem at cap height
PLOT_CHECK(!inked(texel_centre(0.0f, 4.0f, uy))); // the cell's empty first column
// The mirrored perpendicular -- the other basis a shader could build -- would
// draw the whole cell on the opposite side of the pen, where this frame has no
// ink at all.
const plot::Vec2 mirrored{-uy.x, -uy.y};
PLOT_CHECK(!inked(texel_centre(2.0f, 4.0f, mirrored)));
PLOT_CHECK(!inked(texel_centre(5.0f, 11.0f, mirrored)));
ctx.destroy();
}
// The atlas cache is keyed on the backend's revision, and every change counts. A
// backend re-baked at another size with the ink moved to the other side of its cell
// must reach the screen: the second frame's ink is where only the new atlas has it,
// and a stale texture -- what a cache keyed on a flag set at init would leave in
// place -- cannot produce it, because its quad is the old size and does not even
// reach that far. The last step drives the revision *backwards* to pin the other
// half of the rule: the cache compares revisions, it does not require them to grow.
PLOT_TEST(atlas_is_reuploaded_when_the_backend_revision_changes) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
const plot::Color white = plot::Color::hex(0xFFFFFF);
const plot::Vec2 pen{120.0f, 120.0f};
const plot::f32 px = 4.0f;
BakeableBackend backend; // 8x8 atlas, ink on the left half
plot::render::TextAtlas atlas;
auto inked = [&](plot::f32 x, plot::f32 y) {
plot::u8 pxv[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(x),
ctx.height - 1u - static_cast<plot::u32>(y), pxv));
return pixel_is(pxv, white);
};
// An 8x8 atlas at scale 4: a 32x32 quad, inked on its left half.
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any());
PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6);
PLOT_CHECK(atlas.revision() == 1u);
PLOT_CHECK(inked(pen.x + 8.0f, pen.y + 16.0f));
PLOT_CHECK(!inked(pen.x + 24.0f, pen.y + 16.0f));
// Re-baked at 16x16 with the ink on the right, revision bumped: a 64x64 quad
// whose ink is beyond the old quad's right edge. A stale 8x8 atlas would leave
// the left half inked and could not reach x + 48 at all.
backend.bake(16u, true);
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any());
PLOT_CHECK_NEAR(atlas.texel_size().x, 16.0, 1e-6);
PLOT_CHECK(atlas.revision() == 2u);
PLOT_CHECK(!inked(pen.x + 8.0f, pen.y + 16.0f));
PLOT_CHECK(inked(pen.x + 48.0f, pen.y + 32.0f));
// Back to an 8x8 atlas, but with a revision *below* the one on the GPU: still a
// change, so the upload happens and the quad is 32x32 again with ink on the
// right -- which is where the 16x16 atlas had none.
backend.bake(8u, true);
backend.set_revision(0u);
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any());
PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6);
PLOT_CHECK(atlas.revision() == 0u);
PLOT_CHECK(!inked(pen.x + 8.0f, pen.y + 16.0f));
PLOT_CHECK(inked(pen.x + 24.0f, pen.y + 16.0f));
ctx.destroy();
}
// The text pipeline filters the atlas; it does not point-sample it. The two halves
// of the old behaviour -- `GL_NEAREST` plus one `floor(v_uv * u_tex_size)` fetch,
// and `AxisFont` drawing the atlas at `requested_px / em_px` -- were each
// reasonable and jointly wrong: a point fetch at a non-integer scale *deletes*
// texels instead of blending them, and the library's own default is a 12 px style
// on the 13 px embedded bake, which dropped one of every 8 columns and one of every
// 17 rows of every glyph. Whole strokes, and which stroke went depended on where the
// unsnapped pen fell.
//
// A checkerboard is the artwork that pins it without depending on a face's shapes.
// At half scale every fragment's footprint spans a 2x2 of it, so a filter must
// return mid grey everywhere, while a point fetch lands on one parity for every
// fragment and returns a solid block -- a 255 or a 0, not a 128. And at scale 1 with
// a whole pen the two taps coincide (`f == 0` exactly), so the second half of the
// case is that filtering cost nothing there: the checkerboard comes back as a
// checkerboard, every pixel either full ink or none.
PLOT_TEST(the_atlas_is_filtered_not_point_sampled) {
plottest::HeadlessContext ctx(64, 64);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
const plot::Vec2 pen{8.0f, 8.0f}; // whole pixels: the best case for a point fetch
BakeableBackend backend;
backend.bake_checkerboard(8u);
plot::render::TextAtlas atlas;
auto red = [&](plot::u32 x, plot::u32 y) {
plot::u8 pxv[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, pxv));
return static_cast<int>(pxv[0]);
};
// Scale 1: an 8x8 quad, and the checkerboard survives as itself.
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 1.0f, {1.0f, 0.0f}).any());
int crisp = 0;
for (plot::u32 row = 0; row < 8u; ++row) {
for (plot::u32 column = 0; column < 8u; ++column) {
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
PLOT_CHECK(v < 8 || v > 247); // one texel or the other, never a blend of both
if (v > 247) ++crisp;
}
}
PLOT_CHECK(crisp == 32); // half the cell, which is what a checkerboard is
// Scale 0.5: a 4x4 quad, and every pixel of it is the average of a 2x2 of the
// checkerboard. A point fetch gives 255 or 0 here, so the band is the assertion.
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 0.5f, {1.0f, 0.0f}).any());
for (plot::u32 row = 0; row < 4u; ++row) {
for (plot::u32 column = 0; column < 4u; ++column) {
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
PLOT_CHECK(v > 96 && v < 160);
}
}
ctx.destroy();
}
// "No text" is a legal backend, not a failure: a null atlas, or one with no
// size, must sync cleanly, leave the atlas owner empty, and be *replaced* on the
// GPU as soon as the backend publishes one -- including by releasing the texture
// a draw would otherwise still be sampling.
PLOT_TEST(a_backend_without_an_atlas_uploads_nothing) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
BakeableBackend backend; // its constructor bakes an 8x8 atlas
plot::render::TextAtlas atlas;
PLOT_CHECK(atlas.sync(backend, &st));
PLOT_CHECK(!atlas.empty());
// The backend now publishes no atlas at all. The uploader has to let go of the
// texture it was holding -- merely recording the new revision would leave a
// draw sampling glyphs the backend no longer stands behind, and dividing by a
// zero texture size doing it.
backend.drop_atlas();
PLOT_CHECK(backend.atlas_pixels() == nullptr);
PLOT_CHECK(atlas.sync(backend, &st)); // legal: nothing to upload
PLOT_CHECK(atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, 0.0, 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, 0.0, 1e-6);
// And the same when the backend keeps claiming a size but publishes nothing.
BakeableBackend zero_sized;
plot::render::TextAtlas zero_atlas;
zero_sized.bake(0u, false);
PLOT_CHECK(zero_sized.atlas_width() == 0u);
PLOT_CHECK(zero_atlas.sync(zero_sized, &st));
PLOT_CHECK(zero_atlas.empty());
// A backend that starts publishing an atlas announces it with a revision bump,
// so the upload happens here rather than being skipped as "nothing changed".
backend.bake(8u, true);
PLOT_CHECK(atlas.sync(backend, &st));
PLOT_CHECK(!atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6);
PLOT_CHECK(atlas.revision() == backend.revision());
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// The fixed-cell check itself, in the build where it is fatal. A backend whose
// glyph rects are ink boxes (see `TextBackend::cell_uv`) would draw every glyph at
// the size the draw was told, reading past its own rows of the atlas -- so the
// staging path asserts `glyph_is_cell()` and this case pins that it does, not
// merely that the predicate can fail.
//
// The assertion's death is the observable, and the only protocol for observing a
// death is a child process: staging this glyph in-process would take the suite
// with it. The child allocates nothing before it dies -- the check comes before
// the first `push_back` -- and its own `assert` line lands on stderr above this
// case's line, which is the evidence for a human reading the log.
PLOT_TEST(staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build) {
#ifndef NDEBUG
BakeableBackend backend;
backend.trim_glyph_rect(0.5f); // half a cell: no longer the declared cell
PLOT_CHECK(!plot::render::glyph_is_cell(*backend.glyph(0u), backend.cell_uv()));
std::fflush(nullptr);
const pid_t child = ::fork();
PLOT_CHECK(child >= 0);
if (child == 0) {
plot::render::TextBatch batch;
batch.begin();
batch.add_text("X", {0.0f, 0.0f}, 1.0f, backend); // dies here, in debug
::_exit(0); // reached only when the check did not fire
}
int status = 0;
PLOT_CHECK(::waitpid(child, &status, 0) == child);
PLOT_CHECK(WIFSIGNALED(status)); // it did not return
PLOT_CHECK(WTERMSIG(status) == SIGABRT); // and it was the assertion
#endif
}
// The loader invariant (see `plot::gl::loader.hpp`): while the entry-point table
// is unresolved no `gl*` call may be made at all, and `TextAtlas` is public, so a
// host may hold one and sync it before `plot::gl::load()` -- Tasks 2 and 8 build
// backends on this class. Every `gl*` call it makes is therefore gated, and a sync
// in that state reports the same `ErrorCode::Unsupported` every fallible
// `plot::gl` handle constructor reports, instead of calling a null pointer.
//
// `available()` itself cannot be driven false in this suite: `load()` is
// process-global and one-way (no API makes the table unresolved again) and it
// falls back to `dlsym` on the system GL library, so there is no state a test can
// restore the process to -- faking it would poison every later case's GL. What is
// reachable is the same guard from the entry point it protects: with the first
// entry point it calls missing, the sync must report and leave the atlas empty.
PLOT_TEST(text_atlas_reports_a_status_when_the_entry_points_are_missing) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
PLOT_CHECK(plot::gl::available());
plot::render::EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
{
RefuseGetIntegerv refused;
PLOT_CHECK(!atlas.sync(font, &st));
PLOT_CHECK(st.code == plot::ErrorCode::Unsupported);
PLOT_CHECK(st.message != nullptr);
PLOT_CHECK(atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, 0.0, 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, 0.0, 1e-6);
}
// With the table back the same atlas uploads, so the refusal was the missing
// entry point and not a poisoned owner, and the retry is what a caller does with
// the Status it got.
PLOT_CHECK(atlas.sync(font, &st));
PLOT_CHECK(!atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast<plot::f32>(font.atlas_width()), 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(font.atlas_height()), 1e-6);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
/// The legend's two cases need a count, not a presence: "the swatch colour appears"
/// is satisfied by the trace that carries the same colour, and "some ink is in the
/// panel" is satisfied by any label the frame happens to put there. Counting the
/// pixels of an area that are exactly `c` (see `pixel_is`) is what turns both into
/// statements about the swatch.
plot::u32 exact_pixels(const std::vector<plot::u8>& frame, plot::u32 fb_w, const plot::Rect& area,
plot::Color c) {
plot::u32 count = 0;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
if (pixel_is(&frame[(plot::usize(y) * fb_w + x) * 4u], c)) ++count;
}
}
return count;
}
/// True when any row of the column at `x` inside `axes` holds a pixel the predicate
/// accepts. The overlay strokes are blended over whatever is under them, so for them a
/// presence test is the honest one, and it is measured in the axes' own rows rather
/// than over the whole framebuffer: a bound is the axes' height by construction.
bool column_has(plottest::HeadlessContext& ctx, plot::u32 x, const plot::Rect& axes,
bool (*pred)(const plot::u8*)) {
for (plot::u32 row = 0; row < static_cast<plot::u32>(axes.h); ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast<plot::u32>(axes.y) - row, px));
if (pred(px)) return true;
}
return false;
}
/// The whole framebuffer as a Rect, for the counts that ask "and nowhere else".
const plot::Rect kFrameRect{0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
/// The pixels of `area` a predicate accepts. `exact_pixels` above answers "the
/// colour only this element can produce"; this one answers the same question for an
/// element that is *blended* -- a translucent region fill, an anti-aliased stroke --
/// where the test has to name the pattern rather than the colour.
plot::u32 predicate_pixels(const std::vector<plot::u8>& frame, plot::u32 fb_w,
const plot::Rect& area, bool (*pred)(const plot::u8*)) {
plot::u32 count = 0;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
if (pred(&frame[(plot::usize(y) * fb_w + x) * 4u])) ++count;
}
}
return count;
}
/// The legend's entries are drawn where `Plot::layout()` measured them: a filled
/// swatch per trace in that trace's own colour, and the name's ink inside the panel
/// and nowhere else in the data area -- and the panel's presence changes nothing
/// outside itself.
///
/// The swatch colour is what makes the count a test. Both traces here are translucent
/// (alpha 0.25), while the swatch is drawn opaque, so the exact colour of a trace is
/// one that only a swatch can produce: the trace blended over the background -- or
/// over a grid line, which is brighter -- never reaches it, and neither do the grid,
/// the axis line or the text. Two colours are used so each entry's swatch has to be
/// its own trace's, not whichever colour the legend drew for everything.
PLOT_TEST(legend_swatch_and_label_are_drawn) {
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);
// The second trace is a series of its own, well clear of the first: two
// translucent strokes stacked over a grid line reach a brightness the `axis_text`
// ink predicate below cannot tell from a label, and the "no name ink in the data
// area" half of this case has to be about the legend's ink only.
plot::data::ArraySeries flat(std::vector<plot::f32>(64u, 0.75f), 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});
const plot::Color green = plot::Color::hex(0x00E070, 0.25f);
const plot::Color pink = plot::Color::hex(0xFF3CC8, 0.25f);
PLOT_CHECK(p.add_trace({&s, green, 1.5f, ax, ay, "signal"}) == 0u);
PLOT_CHECK(p.add_trace({&flat, pink, 1.5f, ax, ay, "noise"}) == 1u);
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame
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, "legend.ppm");
// A copy: the second draw below clears the rows this layout holds.
const plot::PlotLayout layout = p.layout();
const plot::LegendLayout legend = layout.legend;
PLOT_CHECK(legend.rows.size() == 2u);
const plot::Legend cfg = p.legend(); // copy: set_legend() below swaps the member
const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) *
static_cast<plot::u32>(cfg.swatch_h);
const std::vector<plot::u8> frame = read_frame(ctx);
// Each swatch: its own row's swatch box holds the pixels, and every pixel of that
// colour in the whole frame is one of them. The lower bound is the box's area
// minus the fringe a 1 px stroke column cannot promise -- a row and a column at
// each edge -- and the upper bound lets the outermost columns and rows count
// where the fill's edge lands a hair inside them.
const plot::Color opaque[2] = {plot::Color{green.r, green.g, green.b, 1.0f},
plot::Color{pink.r, pink.g, pink.b, 1.0f}};
for (plot::u32 i = 0; i < legend.rows.size(); ++i) {
const plot::Rect& box = legend.rows[i].swatch;
PLOT_CHECK(box.w > 0.0f && box.h > 0.0f);
PLOT_CHECK(box.x >= legend.panel.x && box.right() <= legend.panel.right());
PLOT_CHECK(box.y >= legend.panel.y && box.bottom() <= legend.panel.bottom());
// The row names its own trace, so the colour under the predicate is that
// trace's -- which is what `LegendRow::trace` is for.
PLOT_CHECK_NEAR(p.trace(legend.rows[i].trace).color.r, opaque[i].r, 1e-6);
const plot::u32 inside = exact_pixels(frame, kW, box, opaque[i]);
const plot::u32 anywhere = exact_pixels(frame, kW, kFrameRect, opaque[i]);
const plot::u32 in_panel = exact_pixels(frame, kW, legend.panel, opaque[i]);
// The swatch's colour fills the swatch's box -- within the row and column an
// edge's coverage ramp can reach past it -- and appears nowhere else in the
// frame: not over the trace that carries the same colour at 25%, not in the
// grid, and not outside the panel, which is where this ink's whole budget is.
PLOT_CHECK(inside >= swatch_area - 2u * static_cast<plot::u32>(cfg.swatch_w) -
2u * static_cast<plot::u32>(cfg.swatch_h));
PLOT_CHECK(anywhere <= swatch_area + 2u * static_cast<plot::u32>(cfg.swatch_w) +
2u * static_cast<plot::u32>(cfg.swatch_h));
PLOT_CHECK(anywhere == in_panel);
// The numbers behind those assertions, so a run says what it measured rather than
// only that it passed.
std::printf("[legend] row %u trace %u swatch %.2f,%.2f %.2fx%.2f: %u pixels of its colour, "
"%u in the frame\n",
i, legend.rows[i].trace, box.x, box.y, box.w, box.h, inside, anywhere);
}
// The names: real glyph ink inside the panel, and none in the data area outside it.
// This plot has no cursor, no measurement and no annotation, and every other
// `axis_text` run -- the tick labels, the titles -- is drawn in a strip outside
// `axes`, so the only ink in the data area is the legend's.
const plot::Color ink = r.style().axis_text;
const plot::u32 in_panel = scan_rect_ink(ctx, legend.panel, ink).pixels;
// The data area, inset past the axis lines: they run along the axes' edges and are
// brighter than a third of `axis_text` -- the trap `rect_has_exact` exists for -- so
// an un-inset scan would be counting the axis rather than a label.
const plot::Rect data{layout.axes.x + 2.0f, layout.axes.y + 2.0f, layout.axes.w - 4.0f,
layout.axes.h - 4.0f};
const plot::u32 in_data = scan_rect_ink(ctx, data, ink).pixels;
PLOT_CHECK(in_panel >= 32u); // two names, not one stray anti-aliased pixel
PLOT_CHECK(in_data == in_panel); // and every bit of that ink is the legend's
// The numbers behind those assertions, so a run says what it measured rather than
// only that it passed.
std::printf("[legend] panel %.2f,%.2f %.2fx%.2f, %zu rows; name ink %u in the panel, %u in the "
"data area\n",
legend.panel.x, legend.panel.y, legend.panel.w, legend.panel.h, legend.rows.size(),
in_panel, in_data);
// The panel changes nothing outside itself: the same plot drawn with the legend
// switched off -- same traces, same axes rect, same transforms, since a legend
// reserves none of the plot -- must come out pixel-identical everywhere except
// inside the panel. One pixel of slack on each side, because a fill's stroke
// column lets its fringe reach a pixel past the panel's edge.
plot::Legend off = cfg;
off.visible = false;
p.set_legend(off);
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());
const std::vector<plot::u8> bare = read_frame(ctx);
const plot::Rect changed{legend.panel.x - 1.0f, legend.panel.y - 1.0f, legend.panel.w + 2.0f,
legend.panel.h + 2.0f};
PLOT_CHECK(changed.x >= layout.axes.x && changed.right() <= layout.axes.right());
PLOT_CHECK(changed.bottom() <= layout.axes.bottom()); // so "inside changed" is inside axes
plot::u32 differing = 0;
for (plot::u32 y = 0; y < ctx.height; ++y) {
for (plot::u32 x = 0; x < ctx.width; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (frame[at] == bare[at] && frame[at + 1u] == bare[at + 1u] &&
frame[at + 2u] == bare[at + 2u] && frame[at + 3u] == bare[at + 3u]) {
continue;
}
++differing;
PLOT_CHECK(changed.contains(static_cast<plot::f32>(x) + 0.5f,
static_cast<plot::f32>(y) + 0.5f));
}
}
// The comparison is only evidence if the legend was in the first frame at all.
PLOT_CHECK(differing > 0u);
ctx.destroy();
}
/// The bottom-left corner is the one a legend anchored from the axes' *opposite*
/// corner would miss: the panel has to be down there, inside `axes` and held off the
/// two edges that corner is near, with its swatch in the panel and nowhere else --
/// including not in the strip below the axes, where the tick labels live.
PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
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});
// Translucent, as in the case above: the swatch is drawn opaque, so this colour is
// the swatch's alone.
const plot::Color teal = plot::Color::hex(0x00E070, 0.25f);
p.add_trace({&s, teal, 1.5f, ax, ay, "signal"});
p.set_x_range(ax, {0.0, 640.0});
plot::Legend cfg = p.legend();
cfg.place = plot::LegendPlace::BottomLeft;
p.set_legend(cfg);
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, "legend_bottom_left.ppm");
const plot::PlotLayout layout = p.layout();
const plot::LegendLayout& legend = layout.legend;
PLOT_CHECK(legend.rows.size() == 1u);
const plot::Rect& panel = legend.panel;
PLOT_CHECK_NEAR(panel.x, layout.axes.x + cfg.margin, 1e-4);
PLOT_CHECK_NEAR(panel.bottom(), layout.axes.bottom() - cfg.margin, 1e-4);
PLOT_CHECK(panel.right() <= layout.axes.right());
PLOT_CHECK(panel.bottom() <= layout.axes.bottom());
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::Color opaque{teal.r, teal.g, teal.b, 1.0f};
const plot::u32 in_panel = exact_pixels(frame, kW, panel, opaque);
PLOT_CHECK(in_panel >= static_cast<plot::u32>(cfg.swatch_w - 2.0f) *
static_cast<plot::u32>(cfg.swatch_h - 2.0f));
// Every pixel of the swatch colour is inside the panel: none in the x label strip
// below the axes, none in the y strip, none anywhere else in the frame.
PLOT_CHECK(exact_pixels(frame, kW, kFrameRect, opaque) == in_panel);
const plot::Rect below{0.0f, layout.axes.bottom(), static_cast<plot::f32>(kW),
static_cast<plot::f32>(kH) - layout.axes.bottom()};
PLOT_CHECK(exact_pixels(frame, kW, below, opaque) == 0u);
// ... with the name drawn beside it.
const plot::u32 name_ink = scan_rect_ink(ctx, panel, r.style().axis_text).pixels;
PLOT_CHECK(name_ink >= 16u);
std::printf("[legend] bottom-left panel %.2f,%.2f %.2fx%.2f; swatch %u pixels in the panel, %u "
"in the frame, %u below the axes; name ink %u\n",
panel.x, panel.y, panel.w, panel.h, in_panel,
exact_pixels(frame, kW, kFrameRect, opaque), exact_pixels(frame, kW, below, opaque),
name_ink);
ctx.destroy();
}
// --- one physical size at any DPI --------------------------------------------
namespace {
/// True for a pixel holding the axis line's chalk grey at a usable coverage: the line
/// is `axis_line` (0x8A94A6) over the frame's dark background, so a third of the
/// line's own level per channel is a pixel the stroke lifted off the background --
/// `text_ink`'s coverage test, with a tone test on top that no blue trace and no dark
/// grid line satisfies. The tick labels are the same chalk family, which is why the
/// scans below keep clear of the label strip's own columns.
bool axis_line_pixel(const plot::u8 px[4], plot::Color c) {
const auto lifted = [](plot::u8 got, plot::f32 want) {
return static_cast<plot::f32>(got) >= want * 255.0f * 0.33f;
};
const bool grey = static_cast<int>(px[2]) - static_cast<int>(px[0]) < 40;
return lifted(px[0], c.r) && lifted(px[1], c.g) && lifted(px[2], c.b) && grey;
}
/// The chrome one render reports, in **device** pixels. Both runs of the two-scale
/// tests below fill this from a frame they really drew, so a comparison of the two
/// figures is a comparison of two frames and not of two derivations of one rule.
struct ChromeFigures {
plot::f32 font_cell = 0.0f; ///< the on-screen text cell the frame drew with
plot::f32 y_label_w = 0.0f; ///< the reserved label strip
plot::f32 x_label_h = 0.0f;
plot::f32 y_title_w = 0.0f;
plot::f32 legend_w = 0.0f;
plot::f32 legend_h = 0.0f;
plot::f32 axes_w = 0.0f;
plot::f32 axes_h = 0.0f;
plot::u32 ink_run = 0u; ///< the widest run of label ink in the y label strip
/// Every figure above in logical units, i.e. divided by the scale the frame was
/// drawn at: the numbers that must agree across displays.
ChromeFigures logical(plot::f32 scale) const noexcept;
};
ChromeFigures ChromeFigures::logical(plot::f32 scale) const noexcept {
ChromeFigures l;
l.font_cell = font_cell / scale;
l.y_label_w = y_label_w / scale;
l.x_label_h = x_label_h / scale;
l.y_title_w = y_title_w / scale;
l.legend_w = legend_w / scale;
l.legend_h = legend_h / scale;
l.axes_w = axes_w / scale;
l.axes_h = axes_h / scale;
l.ink_run = static_cast<plot::u32>(static_cast<plot::f32>(ink_run) / scale + 0.5f);
return l;
}
} // namespace
/// The requirement this task exists for, measured rather than asserted by eye: the
/// same logical plot drawn on a 1x display and on a 2x one. One framebuffer holds
/// both passes -- a 1x plot is a 640x360 viewport in it, a 2x plot the whole thing,
/// exactly as an adapter embeds a plot in a widget -- so the two frames differ only
/// in `ui_scale` and in the device size that goes with it.
///
/// The device figures must double. The logical ones -- the same figures divided by
/// the scale -- must be equal, which is what "the same physical size on any display"
/// means: the labels, strips, title column and legend occupy the same share of the
/// panel at both DPI, and each axis label is drawn with twice the pixels of ink.
PLOT_TEST(logical_metrics_are_dpi_independent) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// The axes' border line runs down the label strip's right edge and is the same
// chalk-toned colour family as the text, so it is drawn in the background colour
// here: the only ink inside the strip is then the labels themselves.
r.style().axis_line = r.style().background;
std::vector<plot::f32> ys(256);
for (plot::u64 i = 0; i < ys.size(); ++i) {
ys[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.05));
}
plot::data::ArraySeries s(std::move(ys), 0.0, 1.0);
const auto figures_at = [&](plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
const plot::Rect vp{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom,
false});
const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
r.style().ui_scale = scale;
// The framebuffer stays the big one for both runs: the plot occupies the top-left
// `vp` of it, which is where a sub-rect viewport puts it (and what the adapters
// pass), so the same readback arithmetic reads both.
r.begin_frame(kBigW, kBigH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
// The layout the renderer drew is the one the renderer laid out: `draw()` sets
// the plot's scale before `layout()`, so this is the propagation being tested.
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
PLOT_CHECK_NEAR(p.text_backend()->height(), r.axis_font_px(), 1e-6);
const plot::PlotLayout& lay = p.layout();
ChromeFigures f;
f.font_cell = r.axis_font_px();
f.y_label_w = lay.y_label_area.w;
f.x_label_h = lay.x_label_area.h;
f.y_title_w = lay.y_title_area.w;
f.legend_w = lay.legend.panel.w;
f.legend_h = lay.legend.panel.h;
f.axes_w = lay.axes.w;
f.axes_h = lay.axes.h;
const TextInk ink =
scan_text_ink(ctx, static_cast<plot::u32>(lay.y_label_area.x),
static_cast<plot::u32>(lay.y_label_area.right()),
static_cast<plot::u32>(lay.y_label_area.y),
static_cast<plot::u32>(lay.y_label_area.h), r.style().axis_text);
f.ink_run = ink.widest_row_run;
return f;
};
const ChromeFigures one = figures_at(1.0f);
const ChromeFigures two = figures_at(2.0f);
const plot::f32 k = 2.0f;
// Each run is normalised by *its own* scale -- the logical figures both displays
// must agree on.
const ChromeFigures one_l = one.logical(1.0f);
const ChromeFigures two_l = two.logical(k);
// The device figures double: the same chrome, twice the pixels.
PLOT_CHECK_NEAR(two.font_cell, k * one.font_cell, 1e-3f);
PLOT_CHECK_NEAR(two.y_label_w, k * one.y_label_w, 0.02f);
PLOT_CHECK_NEAR(two.x_label_h, k * one.x_label_h, 0.02f);
PLOT_CHECK_NEAR(two.y_title_w, k * one.y_title_w, 0.02f);
PLOT_CHECK_NEAR(two.legend_w, k * one.legend_w, 0.02f);
PLOT_CHECK_NEAR(two.legend_h, k * one.legend_h, 0.02f);
PLOT_CHECK_NEAR(two.axes_w, k * one.axes_w, 0.02f);
PLOT_CHECK_NEAR(two.axes_h, k * one.axes_h, 0.02f);
// ...and the logical figures agree, which is the requirement itself: a 640x360
// panel and a 1280x720 one show the same logical plot.
PLOT_CHECK_NEAR(two_l.font_cell, one_l.font_cell, 5e-4f);
PLOT_CHECK_NEAR(two_l.y_label_w, one_l.y_label_w, 0.01f);
PLOT_CHECK_NEAR(two_l.x_label_h, one_l.x_label_h, 0.01f);
PLOT_CHECK_NEAR(two_l.y_title_w, one_l.y_title_w, 0.01f);
PLOT_CHECK_NEAR(two_l.legend_w, one_l.legend_w, 0.01f);
PLOT_CHECK_NEAR(two_l.legend_h, one_l.legend_h, 0.01f);
PLOT_CHECK_NEAR(two_l.axes_w, one_l.axes_w, 0.01f);
PLOT_CHECK_NEAR(two_l.axes_h, one_l.axes_h, 0.01f);
// The ink is the pixels' own account: one drawn label occupies twice the device
// extent, and the same extent once normalised -- so the doubling above is drawn
// and not merely reserved.
PLOT_CHECK(one.ink_run > 8u); // labels really were drawn
PLOT_CHECK_NEAR(static_cast<plot::f32>(two.ink_run), k * static_cast<plot::f32>(one.ink_run),
2.0f);
PLOT_CHECK(two_l.ink_run <= one_l.ink_run + 1u);
PLOT_CHECK(two_l.ink_run + 1u >= one_l.ink_run);
std::printf("[ui_scale] 1x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u;"
" 2x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u; logical"
" 1x %.2f/%.2f/%.2f/%.2fx%.2f/%u vs 2x %.2f/%.2f/%.2f/%.2fx%.2f/%u\n",
one.font_cell, one.y_label_w, one.x_label_h, one.y_title_w, one.legend_w,
one.legend_h, one.ink_run, two.font_cell, two.y_label_w, two.x_label_h,
two.y_title_w, two.legend_w, two.legend_h, two.ink_run, one_l.font_cell,
one_l.y_label_w, one_l.x_label_h, one_l.y_title_w, one_l.legend_h, one_l.ink_run,
two_l.font_cell, two_l.y_label_w, two_l.x_label_h, two_l.y_title_w, two_l.legend_h,
two_l.ink_run);
ctx.destroy();
}
/// `dense_threshold_spp` is a **density** -- samples per rasterised pixel -- and not a
/// size, so the display's scale must not move it. This is the failure mode on the
/// over-eager side: scaling the threshold with the panel would call the LOD pyramid
/// less readily at 2x, so the same data would be rasterised by a different path
/// depending on the display.
///
/// The sample count grows with the device width, so both runs present the same
/// samples-per-device-pixel: if the threshold were scaled, the 2x run would fall back
/// to raw samples while the 1x one still took the pyramid. The threshold is set to a
/// value between them so the test can tell the two apart at all -- at the default 1.5
/// the comparison is decided far from the threshold and any scale below 64x would be
/// invisible (`level_for` returns level 0 -- the same raw gather -- until a pixel
/// covers 64 samples).
PLOT_TEST(dense_threshold_stays_a_device_ratio) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
constexpr plot::f32 kThreshold = 1500.0f;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
r.style().dense_threshold_spp = kThreshold;
const auto series_of = [](plot::u64 n) {
std::vector<plot::f32> y(n);
for (plot::u64 i = 0; i < n; ++i) {
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.0001));
}
return y;
};
struct Decision {
plot::u32 lod = 0u;
plot::u32 raw = 0u;
plot::f64 spp = 0.0; ///< samples per *device* pixel the frame was drawn at
plot::u32 level = 0u; ///< the pyramid level that density selects
};
const auto decision_at = [&](plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
plot::data::ArraySeries s(series_of(static_cast<plot::u64>(scale) * 1'000'000u), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
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});
r.style().ui_scale = scale;
r.begin_frame(kBigW, kBigH, {0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
Decision d;
d.lod = r.stats().lod_traces;
d.raw = r.stats().raw_traces;
// What the renderer actually drew at: the samples its gathered window covered
// over the *device* width of the axes it laid out. Recomputed here rather than
// taken on trust, so a spp divided by the logical width instead would show up as
// a doubled density and a different level.
const plot::f32 axes_w = p.layout().axes.w;
PLOT_CHECK(axes_w > 0.0f);
d.spp = static_cast<plot::f64>(r.stats().visible_samples) / static_cast<plot::f64>(axes_w);
d.level = s.pyramid().level_for(s.size(), d.spp);
return d;
};
const Decision one = decision_at(1.0f);
const Decision two = decision_at(2.0f);
// The renderer took the pyramid at both scales...
PLOT_CHECK(one.lod == 1u && one.raw == 0u);
PLOT_CHECK(two.lod == 1u && two.raw == 0u);
// ...and the density that decision rests on is the same in device terms at both
// scales, which is what makes the level below identical rather than merely non-zero.
PLOT_CHECK_NEAR(two.spp, one.spp, 0.5);
PLOT_CHECK(one.spp > static_cast<plot::f64>(kThreshold)); // not vacuous: over the line
PLOT_CHECK(one.level > 0u); // and a level, not the raw path
PLOT_CHECK(two.level == one.level);
std::printf("[dense] 1x spp %.1f -> level %u (lod %u raw %u); 2x spp %.1f -> level %u"
" (lod %u raw %u); threshold %.1f\n",
one.spp, one.level, one.lod, one.raw, two.spp, two.level, two.lod, two.raw,
static_cast<double>(kThreshold));
ctx.destroy();
}
/// A trace's stroke is a chrome size like the axis lines', so the *weight* it is drawn
/// with -- and with it the trace/axis hierarchy a reader sees -- must not depend on the
/// display. `Trace::width` reaches the same `flush_lines()` the axis lines do, so it has
/// to go through the same `Style::device()` conversion; left raw, a 1.5 px trace is 1.5
/// *device* px against a 3 device px axis line at scale 2.
///
/// The measurement is the ink a fixed-size **device** rectangle holds over a flat trace
/// (so the count is the stroke's width times a constant length, and cleaning up the
/// anti-aliasing costs a fraction of a pixel rather than the whole figure), plus the
/// same count over the y axis line. The assertion is the ratio between them: it is 1 at
/// both scales when both strokes are converted, and it halves at scale 2 when the trace
/// alone is not.
PLOT_TEST(trace_stroke_weight_is_logical) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
/// The width both strokes are given, in logical pixels: equal on purpose, so the
/// trace/axis ratio the case pins is 1 rather than some arbitrary figure.
constexpr plot::f32 kWidth = 3.0f;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
r.style().line_width_px = kWidth; // the axis lines and cursor markers
// A flat trace, so every column of the scan crosses it exactly once and the ink
// count is its width times the scan's length. y = 0.5 on a [0,1] axis is not a tick,
// so no grid line runs along it.
std::vector<plot::f32> flat(64, 0.5f);
plot::data::ArraySeries s(std::move(flat), 0.0, 1.0);
/// The device ink of both strokes at one scale: pixels of trace colour in a
/// 200x8 px box over the trace, and axis-line pixels in a 6x200 px box over the y
/// axis line. Both boxes are the same *device* size at every scale, which is what
/// makes the count proportional to the stroke's width alone.
struct StrokeInk {
plot::u32 trace = 0u;
plot::u32 axis = 0u;
};
const auto ink_at = [&](plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 10.0}, 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({&s, plot::Color::hex(0x40C0FF), kWidth, ax, ay});
r.style().ui_scale = scale;
r.begin_frame(kBigW, kBigH, {0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::PlotLayout& lay = p.layout();
// Generic over the predicate: the two strokes are recognised by different rules
// (the trace by blue dominance, the axis line by its chalk grey).
const auto count = [&](plot::u32 x0, plot::u32 y0, plot::u32 bw, plot::u32 bh, auto pred) {
plot::u32 hits = 0;
for (plot::u32 y = y0; y < y0 + bh; ++y) {
for (plot::u32 x = x0; x < x0 + bw; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, kBigH - 1u - y, px));
if (pred(px)) ++hits;
}
}
return hits;
};
StrokeInk ink;
// The trace: a fixed device box inside the axes, centred on the stroke's row.
const plot::u32 ty = static_cast<plot::u32>(p.y_transform(ay).to_screen_y(0.5));
const plot::u32 tx0 = static_cast<plot::u32>(lay.axes.x) + 40u;
ink.trace = count(tx0, ty - 4u, 200u, 8u,
[](const plot::u8* px) { return trace_pixel(px); });
// The axis line: two columns left of it, so the tick labels' own ink (which ends
// `grid_label_pad` short of the line) is outside the scan at both scales.
const plot::Color line = r.style().axis_line;
ink.axis = count(static_cast<plot::u32>(lay.axes.x) - 2u,
static_cast<plot::u32>(lay.axes.y) + 40u, 6u, 200u,
[line](const plot::u8* px) { return axis_line_pixel(px, line); });
return ink;
};
const StrokeInk one = ink_at(1.0f);
const StrokeInk two = ink_at(2.0f);
const plot::f32 trace_ratio =
static_cast<plot::f32>(two.trace) / static_cast<plot::f32>(one.trace);
const plot::f32 axis_ratio =
static_cast<plot::f32>(two.axis) / static_cast<plot::f32>(one.axis);
std::printf("[trace] 1x trace %u axis %u; 2x trace %u axis %u; ratios %.3f / %.3f ->"
" weight ratio %.3f\n",
one.trace, one.axis, two.trace, two.axis, static_cast<double>(trace_ratio),
static_cast<double>(axis_ratio),
static_cast<double>(trace_ratio / axis_ratio));
// Not vacuous: the trace really is in the box (and the axis line in its own).
PLOT_CHECK(one.trace > 100u);
PLOT_CHECK(one.axis > 100u);
// The same logical width at twice the scale is twice the device ink -- for the trace
// exactly as for the axis line it is drawn against. A trace left in device pixels
// fails this: its ink count is the same at both scales.
PLOT_CHECK_NEAR(trace_ratio, 2.0f, 0.35f);
PLOT_CHECK_NEAR(axis_ratio, 2.0f, 0.35f);
PLOT_CHECK_NEAR(trace_ratio / axis_ratio, 1.0f, 0.15f);
ctx.destroy();
}
// ------------------------------------------------------------- grid classes
namespace {
/// The scene the grid-class cases below draw: two axes and **no trace, cursor,
/// legend, measure or annotation**, so the only element that can put ink inside the
/// axes rect is a grid line. The x range is wide relative to the axes (a major about
/// every 100 px, its minors about every 28) so a window can be placed around one line
/// and touch nothing else; the y range's ticks are close enough together that a band
/// of rows fits between two of them.
struct GridFrame {
std::vector<plot::u8> pixels; ///< the whole framebuffer, top-down
plot::u32 fb_w = 0u;
plot::PlotLayout layout;
std::vector<plot::Tick> x_ticks;
std::vector<plot::Tick> y_ticks;
plot::Transform x_transform;
plot::Transform y_transform;
plot::u32 major_lines = 0u; ///< grid segments of each class, as built
plot::u32 minor_lines = 0u;
plot::u32 staged_segments = 0u; ///< everything the frame staged, grid included
};
/// Draws that scene at `scale` with the renderer's current style. `scale` 2 is a 2x
/// framebuffer with a 2x viewport -- the same logical plot on a 2x display -- so the
/// figures of the two scales are figures of two frames that were really drawn.
GridFrame draw_grid_scene(plottest::HeadlessContext& ctx, plot::Renderer& r, plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
const plot::Rect vp{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
r.style().ui_scale = scale;
r.begin_frame(ctx.width, ctx.height, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
GridFrame f;
f.fb_w = ctx.width;
f.layout = p.layout();
f.x_ticks = p.x_axis(ax).ticks;
f.y_ticks = p.y_axis(ay).ticks;
f.x_transform = p.x_transform(ax);
f.y_transform = p.y_transform(ay);
for (const plot::GridLine& l : p.grid().lines()) {
(l.major ? f.major_lines : f.minor_lines) += 1u;
}
f.staged_segments = r.stats().staged_segments;
f.pixels = read_frame(ctx);
return f;
}
/// Half-width and height of a measurement window, in **device** pixels. Fixed counts
/// rather than figures derived from the tick spacing, so the same window measures the
/// same number of rows at 1x and 2x: a height derived from the ticks would double with
/// the scale and then report four times the ink for twice the stroke.
constexpr plot::f32 kWindowHalf = 3.0f;
constexpr plot::f32 kWindowRows = 8.0f;
/// The top row of the `kWindowRows`-tall band of the axes rect furthest from any
/// horizontal grid line: a slice where a window can only catch a *vertical* line's
/// ink. The margin is asserted, not assumed -- a scene whose horizontal lines are too
/// close together fails here instead of quietly measuring one.
plot::f32 clear_band_top(const GridFrame& f) {
std::vector<plot::f64> rows{f.layout.axes.y};
for (const plot::Tick& t : f.y_ticks) {
const plot::f64 y = f.y_transform.to_screen_y(t.value);
if (y > f.layout.axes.y && y < f.layout.axes.bottom()) rows.push_back(y);
}
rows.push_back(f.layout.axes.bottom());
std::sort(rows.begin(), rows.end());
plot::f64 best_gap = 0.0;
plot::f64 best_mid = 0.0;
for (std::size_t i = 0; i + 1u < rows.size(); ++i) {
if (rows[i + 1] - rows[i] > best_gap) {
best_gap = rows[i + 1] - rows[i];
best_mid = 0.5 * (rows[i] + rows[i + 1]);
}
}
PLOT_CHECK(best_gap > kWindowRows + 4.0);
return static_cast<plot::f32>(std::floor(best_mid - 0.5 * kWindowRows));
}
/// The x tick of `want_major`'s class nearest the middle of the axes rect, together
/// with the proof that its column band holds no other line: no tick of either class
/// is within `kWindowHalf + 2` px of the chosen one. Derived rather than assumed, so a
/// scene whose lines crowd each other fails here rather than measuring two strokes.
plot::f64 pick_tick(const GridFrame& f, bool want_major) {
const plot::Rect& axes = f.layout.axes;
const plot::f64 middle = 0.5 * (static_cast<plot::f64>(axes.x) + axes.right());
bool found = false;
plot::f64 best = 0.0;
plot::f64 best_distance = 0.0;
for (const plot::Tick& t : f.x_ticks) {
if (t.major != want_major) continue;
const plot::f64 x = f.x_transform.to_screen_x(t.value);
// Room at the edges: an axis line runs down them, and it is not grid ink.
if (x < axes.x + kWindowHalf + 2.0 || x > axes.right() - kWindowHalf - 2.0) continue;
const plot::f64 distance = std::fabs(x - middle);
if (!found || distance < best_distance) {
found = true;
best = t.value;
best_distance = distance;
}
}
PLOT_CHECK(found);
const plot::f64 chosen = f.x_transform.to_screen_x(best);
for (const plot::Tick& t : f.x_ticks) {
const plot::f64 dx = std::fabs(f.x_transform.to_screen_x(t.value) - chosen);
PLOT_CHECK(dx == 0.0 || dx > kWindowHalf + 2.0);
}
return best;
}
/// A window around one line of each class.
struct GridWindows {
plot::Rect major;
plot::Rect minor;
};
GridWindows grid_windows(const GridFrame& f) {
const plot::f32 top = clear_band_top(f);
const auto band = [&](plot::f64 value) {
const plot::f32 cx = static_cast<plot::f32>(f.x_transform.to_screen_x(value));
return plot::Rect{cx - kWindowHalf, top, 2.0f * kWindowHalf, kWindowRows};
};
return GridWindows{band(pick_tick(f, true)), band(pick_tick(f, false))};
}
/// How much of a pixel is `c` blended over `bg`: the largest channel ratio
/// `(px - bg) / (c - bg)`, clamped to [0, 1]. A stroke's anti-aliased fringe is partly
/// covered and its core fully covered; the *sum* over a window is the ink the stroke
/// laid down, which is proportional to its width. Counting pixels instead would
/// measure how the stroke landed on the pixel grid, and counting exact colours would
/// confuse the two classes: a major pixel at half coverage is the minor colour.
plot::f32 pixel_coverage(const plot::u8 px[4], plot::Color bg, plot::Color c) {
const plot::f32 value[3] = {px[0] / 255.0f, px[1] / 255.0f, px[2] / 255.0f};
const plot::f32 base[3] = {bg.r, bg.g, bg.b};
const plot::f32 ink[3] = {c.r, c.g, c.b};
plot::f32 best = 0.0f;
for (int i = 0; i < 3; ++i) {
const plot::f32 span = ink[i] - base[i];
if (span <= 0.0f) continue;
best = std::max(best, (value[i] - base[i]) / span);
}
return std::clamp(best, 0.0f, 1.0f);
}
/// The ink a window of the frame holds, read with one class' colour: the coverage sum
/// over its pixels. `kWindowRows` rows of one stroke is `width x kWindowRows` of ink,
/// so the figure is in device pixels of stroke.
plot::f32 window_ink(const GridFrame& f, const plot::Rect& window, plot::Color bg,
plot::Color c) {
plot::f32 ink = 0.0f;
for (plot::u32 y = static_cast<plot::u32>(window.y);
y < static_cast<plot::u32>(window.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(window.x);
x < static_cast<plot::u32>(window.right()); ++x) {
ink += pixel_coverage(&f.pixels[(plot::usize(y) * f.fb_w + x) * 4u], bg, c);
}
}
return ink;
}
/// The ink one minor line is expected to leave in a `kWindowRows`-tall window, **from the
/// style**: its width in device pixels, its colour's alpha (the line shader multiplies it in)
/// and the dash's duty cycle. Written out rather than as a literal, because the minor class'
/// defaults are deliberately a fraction of a solid opaque line -- a threshold saying "most of
/// eight rows" stopped describing the class the moment it gained a dash, while it is the style
/// that says how much ink a class is *meant* to leave.
plot::f32 minor_line_ink(const plot::render::Style& style) {
const plot::f32 duty =
style.grid_minor_dash_off > 0.0f
? style.grid_minor_dash_on / (style.grid_minor_dash_on + style.grid_minor_dash_off)
: 1.0f;
return kWindowRows * style.device(style.grid_minor_width) * style.grid_minor.a * duty;
}
} // namespace
/// Spec §11's per-class switch, on the minor class: a class the style switches off is
/// not drawn, and the *other* class is untouched. The second assertion is the one
/// that catches an implementation which hides both, or which rebuilds the grid
/// without the class it was told to hide.
///
/// The measurement is a window around one line of each class rather than a
/// frame-wide colour count: the two grid colours are close enough that a half-covered
/// pixel of the major colour reads as the minor one. Inside its window each class is
/// alone, so the figure is that line's own ink and nothing else's.
PLOT_TEST(disabling_the_minor_grid_removes_its_colour) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Color bg = r.style().background;
const plot::Color major_ink = r.style().grid_major;
const plot::Color minor_ink = r.style().grid_minor;
const GridFrame both = draw_grid_scene(ctx, r, 1.0f);
PLOT_CHECK(both.major_lines > 0u);
PLOT_CHECK(both.minor_lines > 0u);
const GridWindows win = grid_windows(both);
const plot::f32 major_before = window_ink(both, win.major, bg, major_ink);
const plot::f32 minor_before = window_ink(both, win.minor, bg, minor_ink);
// A line, not one stray fringe pixel: an 8-row window over a stroke holds most of the ink
// that stroke is meant to leave. For the major class that is the window's own height; for the
// minor class it is what its width, alpha and dash ask for, so the threshold follows the
// style instead of a literal the class is no longer drawn to.
PLOT_CHECK(major_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
r.style().grid_minor_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
// The windows are the same windows: visibility does not move the tick set.
const GridWindows again = grid_windows(without);
PLOT_CHECK_NEAR(again.major.x, win.major.x, 1e-3);
PLOT_CHECK_NEAR(again.major.y, win.major.y, 1e-3);
PLOT_CHECK_NEAR(again.minor.x, win.minor.x, 1e-3);
const plot::f32 major_after = window_ink(without, win.major, bg, major_ink);
const plot::f32 minor_after = window_ink(without, win.minor, bg, minor_ink);
const plot::f32 other_after = window_ink(without, win.minor, bg, major_ink);
std::printf("[grid] minors off: major window %.3f -> %.3f, minor window %.3f -> %.3f"
" (major colour there %.3f)\n",
static_cast<double>(major_before), static_cast<double>(major_after),
static_cast<double>(minor_before), static_cast<double>(minor_after),
static_cast<double>(other_after));
// Nothing at all in the minor line's window: not the minor colour, and not the
// major one either -- so the line was skipped, not recoloured or drawn faint.
PLOT_CHECK_NEAR(minor_after, 0.0, 1e-4);
PLOT_CHECK_NEAR(other_after, 0.0, 1e-4);
// And the major class is exactly where it was: the same line, the same width, the
// same colour, neither moved nor thinned by the other class' absence.
PLOT_CHECK_NEAR(major_after, major_before, 1e-4);
ctx.destroy();
}
/// The symmetric case: with the major class off, the minor class is untouched and the
/// major line's own window is empty. Together with the case above this is "each class
/// has its own switch", not "the grid has one".
PLOT_TEST(disabling_the_major_grid_leaves_the_minor_grid) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Color bg = r.style().background;
const GridFrame both = draw_grid_scene(ctx, r, 1.0f);
const GridWindows win = grid_windows(both);
const plot::f32 major_before = window_ink(both, win.major, bg, r.style().grid_major);
const plot::f32 minor_before = window_ink(both, win.minor, bg, r.style().grid_minor);
PLOT_CHECK(major_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
r.style().grid_major_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
const plot::f32 major_after = window_ink(without, win.major, bg, r.style().grid_major);
const plot::f32 minor_after = window_ink(without, win.minor, bg, r.style().grid_minor);
const plot::f32 other_after = window_ink(without, win.major, bg, r.style().grid_minor);
std::printf("[grid] majors off: major window %.3f -> %.3f (minor colour there %.3f), minor"
" window %.3f -> %.3f\n",
static_cast<double>(major_before), static_cast<double>(major_after),
static_cast<double>(other_after), static_cast<double>(minor_before),
static_cast<double>(minor_after));
PLOT_CHECK_NEAR(major_after, 0.0, 1e-4);
PLOT_CHECK_NEAR(other_after, 0.0, 1e-4);
PLOT_CHECK_NEAR(minor_after, minor_before, 1e-4);
ctx.destroy();
}
/// "A disabled class costs nothing to draw" is a statement about *staging*, and pixels
/// cannot make it: a class drawn at zero alpha, or staged and then thrown away, leaves
/// the same frame. So this counts the segments the frame staged and requires the count
/// to fall by exactly that class' lines -- and, with both classes off, to be exactly
/// what the rest of the frame stages on its own (here: the two axis lines).
PLOT_TEST(a_disabled_grid_class_stages_nothing) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const GridFrame both = draw_grid_scene(ctx, r, 1.0f);
PLOT_CHECK(both.major_lines > 0u);
PLOT_CHECK(both.minor_lines > 0u);
r.style().grid_minor_visible = false;
const GridFrame no_minor = draw_grid_scene(ctx, r, 1.0f);
r.style().grid_minor_visible = true;
r.style().grid_major_visible = false;
const GridFrame no_major = draw_grid_scene(ctx, r, 1.0f);
r.style().grid_minor_visible = false;
const GridFrame neither = draw_grid_scene(ctx, r, 1.0f);
std::printf("[grid] staged segments: both %u (majors %u, minors %u), no minors %u, no majors %u,"
" neither %u\n",
both.staged_segments, both.major_lines, both.minor_lines, no_minor.staged_segments,
no_major.staged_segments, neither.staged_segments);
// Exactly the class' own lines disappear from staging -- one segment per tick -- and
// the rest of the frame is still staged, so this is a skip and not a frame that
// failed to draw.
PLOT_CHECK(no_minor.staged_segments == both.major_lines + 2u);
PLOT_CHECK(no_major.staged_segments == both.minor_lines + 2u);
PLOT_CHECK(neither.staged_segments == 2u);
PLOT_CHECK(both.staged_segments == both.major_lines + both.minor_lines + 2u);
// The pixels agree with the counters: with both classes off no grid line is in the
// axes rect at all, whatever it is read with.
const GridWindows win = grid_windows(both);
const plot::Color bg = r.style().background;
PLOT_CHECK_NEAR(window_ink(neither, win.major, bg, r.style().grid_major), 0.0, 1e-4);
PLOT_CHECK_NEAR(window_ink(neither, win.minor, bg, r.style().grid_minor), 0.0, 1e-4);
ctx.destroy();
}
/// Widths are per class and logical (spec §11, on §10's rule): a 2:1 logical ratio
/// between the classes draws a 2:1 stroke ratio, and the same logical widths at
/// `ui_scale` 2 lay down twice the *device* ink. One window per class, the same
/// kWindowRows device rows in every run, so the figures compare: a height taken from
/// the ticks would double with the scale and turn "twice the stroke" into four times
/// the measurement.
PLOT_TEST(grid_widths_are_per_class_and_scale_with_dpi) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Color bg = r.style().background;
// 2:1 in the fields, and the same fields across both scales below.
r.style().grid_major_width = 2.0f;
r.style().grid_minor_width = 1.0f;
// Widths are this test's subject, so the minor class' dash and alpha are stated away first:
// they would multiply every count and the ratio below, and their own defaults are the subject
// of `the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`.
r.style().grid_minor_dash_off = 0.0f;
r.style().grid_minor = plot::Color::hex(0x1C2126);
const GridFrame one = draw_grid_scene(ctx, r, 1.0f);
const GridWindows win = grid_windows(one);
const plot::f32 major_1x = window_ink(one, win.major, bg, r.style().grid_major);
const plot::f32 minor_1x = window_ink(one, win.minor, bg, r.style().grid_minor);
const GridFrame two = draw_grid_scene(ctx, r, 2.0f);
const GridWindows big = grid_windows(two);
const plot::f32 major_2x = window_ink(two, big.major, bg, r.style().grid_major);
const plot::f32 minor_2x = window_ink(two, big.minor, bg, r.style().grid_minor);
const plot::f32 ratio_1x = major_1x / minor_1x;
std::printf("[grid] widths 2:1 logical: 1x major %.3f minor %.3f (ratio %.3f), 2x major %.3f"
" minor %.3f (ratio %.3f); device widths %.2f/%.2f at scale %.1f\n",
static_cast<double>(major_1x), static_cast<double>(minor_1x),
static_cast<double>(ratio_1x), static_cast<double>(major_2x),
static_cast<double>(minor_2x), static_cast<double>(major_2x / minor_2x),
static_cast<double>(r.style().device(r.style().grid_major_width)),
static_cast<double>(r.style().device(r.style().grid_minor_width)),
static_cast<double>(r.style().ui_scale));
// Same window height, one stroke each: the ink ratio is the width ratio.
PLOT_CHECK_NEAR(big.major.h, win.major.h, 1e-6);
PLOT_CHECK(major_1x > 1.5f * kWindowRows);
PLOT_CHECK(minor_1x > 0.75f * kWindowRows);
PLOT_CHECK_NEAR(ratio_1x, 2.0, 0.25);
PLOT_CHECK_NEAR(major_2x / minor_2x, 2.0, 0.25);
// Both classes double in device pixels at the 2x scale ...
PLOT_CHECK_NEAR(major_2x, 2.0 * major_1x, 0.25 * major_1x);
PLOT_CHECK_NEAR(minor_2x, 2.0 * minor_1x, 0.25 * minor_1x);
// ... while the logical widths that produced them did not move: the same two fields,
// with `device()` turning them into twice the pixels.
PLOT_CHECK_NEAR(r.style().grid_major_width, 2.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().grid_minor_width, 1.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().ui_scale, 2.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().device(r.style().grid_major_width), 4.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().device(r.style().grid_minor_width), 2.0f, 1e-6f);
ctx.destroy();
}
// --- regions: the fill, the bounds and the emphasized handle (spec §5) ---------
namespace {
/// Green dominance: what the region's translucent `0x33FF00` fill leaves over the dark
/// background, over a grey grid line and over the axis line, and what an opaque
/// `0x00FF00` marker leaves at any coverage. Nothing else in these frames is
/// green-dominant -- the trace is blue (`g - b < 0`), the chrome and the text are
/// grey/chalk (`r == g == b`), the bounds are yellow (`r == g > b`) -- so a count of
/// these pixels is a statement about the fill (or the marker) and about nothing else.
/// Conservative on purpose: the fill's own half-pixel fringe at the top and bottom rows
/// is not counted.
bool green_dominant(const plot::u8 px[4]) {
return px[1] > px[0] + 60 && px[1] > px[2] + 60;
}
} // namespace
/// A region's fill and its two bounds are drawn from the geometry `Plot::layout()`
/// measured -- the same rects the controller hit-tests -- so the rect a test asserts, the
/// rect a frame paints and the column a pointer is grabbed at are one set of numbers.
/// The fill shades the interval between the bounds and nothing outside it; each bound is
/// one vertical stroke of the axes' height in the region's own edge colour, in its own
/// column and nowhere else in the frame.
PLOT_TEST(region_fill_and_edges_are_drawn_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, 640.0}); // one sample per pixel: a flat, cheap frame
plot::Region reg;
reg.x0 = 100.0;
reg.x1 = 300.0;
reg.snap = plot::RegionSnap::Free;
// Colours no other element of the frame produces, so each count below can only be
// satisfied by the element it names.
reg.fill = plot::Color::hex(0x33FF00, 0.35f);
// Magenta, which `magenta_pixel` already names and neither the fill, the trace, the
// chrome nor the accent (0xFFB020) can produce.
reg.edge = plot::Color::hex(0xFF00FF);
const plot::u32 rid = p.add_region(reg);
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, "regions.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
PLOT_CHECK(layout.regions.size() == 1u);
const plot::RegionLayout& rl = layout.regions[0];
PLOT_CHECK(rl.region == rid);
const plot::Rect axes = layout.axes;
// The measured geometry is the drawn geometry: the fill spans the axes' full height
// and is clipped to their columns, so it can never reach over a label strip.
PLOT_CHECK(rl.fill.w > 0.0f);
PLOT_CHECK(rl.fill.x >= axes.x && rl.fill.right() <= axes.right());
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.fill.h), static_cast<plot::f64>(axes.h), 1e-6);
// ... and it is the interval the two bounds mark: its edges are the accepted columns
// of the bound strokes, so the rect that is painted is the interval that is grabbed.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.fill.x),
static_cast<plot::f64>(plot::handle_column(rl.left_handle)), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.fill.right()),
static_cast<plot::f64>(plot::handle_column(rl.right_handle)), 1e-4);
const plot::u32 in_fill = predicate_pixels(frame, kW, rl.fill, green_dominant);
const plot::u32 anywhere = predicate_pixels(frame, kW, kFrameRect, green_dominant);
const plot::u32 area = static_cast<plot::u32>(rl.fill.w) * static_cast<plot::u32>(rl.fill.h);
PLOT_CHECK(in_fill > 0u);
// The fill shades essentially the whole of its rect -- the trace and the grid it is
// drawn over take a few percent -- and tints nothing anywhere else in the frame.
PLOT_CHECK(in_fill >= area - area / 8u);
PLOT_CHECK(anywhere == in_fill);
// The bounds: one stroke per bound, drawn in the bound's own column, of the axes'
// height, and nowhere else in the frame.
const plot::u32 left_col =
static_cast<plot::u32>(plot::handle_column(rl.left_handle));
const plot::u32 right_col =
static_cast<plot::u32>(plot::handle_column(rl.right_handle));
PLOT_CHECK(column_has(ctx, left_col, axes, magenta_pixel));
PLOT_CHECK(column_has(ctx, right_col, axes, magenta_pixel));
PLOT_CHECK(!column_has(ctx, left_col + 25u, axes, magenta_pixel)); // the fill is not an edge
PLOT_CHECK(!column_has(ctx, 0u, axes, magenta_pixel));
// Each band is the bound's own column grown by a pixel either side -- and one row
// taller than the axes, because a stroke drawn to `tab.bottom()` covers the
// half-pixel row a rect's truncating cast ends at.
const plot::Rect left_band{static_cast<plot::f32>(left_col) - 1.0f, axes.y, 3.0f,
axes.h + 1.0f};
const plot::Rect right_band{static_cast<plot::f32>(right_col) - 1.0f, axes.y, 3.0f,
axes.h + 1.0f};
const plot::u32 left_edge = predicate_pixels(frame, kW, left_band, magenta_pixel);
const plot::u32 right_edge = predicate_pixels(frame, kW, right_band, magenta_pixel);
const plot::u32 all_edges = predicate_pixels(frame, kW, kFrameRect, magenta_pixel);
PLOT_CHECK(left_edge >= static_cast<plot::u32>(axes.h) / 2u);
PLOT_CHECK(right_edge >= static_cast<plot::u32>(axes.h) / 2u);
PLOT_CHECK(all_edges == left_edge + right_edge);
ctx.destroy();
}
/// The handles a pointer is on are emphasized, and the emphasis is the plot's to draw:
/// `Plot::hot_handles()` carries what the input layer's one hit test found, and the
/// renderer draws that handle in the style's cursor colour and at the handle width
/// `PlotLayout` sized it with. A grabbed handle keeps its emphasis for the whole
/// gesture, so what is emphasized follows the *grab* rather than the pointer -- which is
/// what the last frame here pins, with the pointer off the plot entirely.
PLOT_TEST(grabbed_handle_is_highlighted) {
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, 640.0});
plot::Region reg;
reg.x0 = 100.0;
reg.x1 = 300.0;
reg.snap = plot::RegionSnap::Free;
reg.edge = plot::Color::hex(0xFF00FF);
const plot::u32 rid = p.add_region(reg);
// A marker in a colour of its own, so the accent count below can only come from a
// highlighted handle and never from the marker as it is drawn when it is idle.
const plot::u32 cid = p.add_cursor(500.0, plot::Color::hex(0x00FF00));
plot::input::Controller c(p);
c.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::Color accent = r.style().cursor;
auto draw_frame = [&] {
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());
return read_frame(ctx);
};
// Frame 1: the controller has seen no pointer, so nothing is hot and no handle is
// emphasized -- while the bounds themselves are still drawn, in their own colour.
const std::vector<plot::u8> bare = draw_frame();
PLOT_CHECK(exact_pixels(bare, kW, kFrameRect, accent) == 0u);
PLOT_CHECK(predicate_pixels(bare, kW, kFrameRect, magenta_pixel) > 0u);
const plot::PlotLayout layout = p.layout();
PLOT_CHECK(layout.regions.size() == 1u);
const plot::Rect tab = layout.regions[0].left_handle;
// The tab grown by a pixel on each side -- and by that row plus one, because a
// stroke drawn to `tab.bottom()` covers the half-pixel row the truncating cast of a
// rect ends at. The window is the handle's, and the handle's alone.
const plot::Rect band{tab.x - 1.0f, tab.y, tab.w + 2.0f, tab.h + 1.0f};
const plot::f32 col = plot::handle_column(tab);
const plot::f32 cy = layout.axes.y + 0.5f * layout.axes.h;
// Frame 2: the pointer is on the bound, so the bound is the handle being emphasized.
c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false});
PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::Left);
PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left);
const std::vector<plot::u8> hovered = draw_frame();
const plot::u32 hot = exact_pixels(hovered, kW, kFrameRect, accent);
PLOT_CHECK(hot > 0u);
// At the handle and nowhere else in the frame.
PLOT_CHECK(exact_pixels(hovered, kW, band, accent) == hot);
// The handle is a handle, not a hairline: the accent covers the tab's own width,
// where the idle bound was drawn 1.5 logical px wide in the region's colour.
PLOT_CHECK(hot >= static_cast<plot::u32>(tab.h) * (static_cast<plot::u32>(tab.w) - 2u));
PLOT_CHECK(hot <= static_cast<plot::u32>(tab.h) * (static_cast<plot::u32>(tab.w) + 2u));
PLOT_CHECK(predicate_pixels(hovered, kW, band, magenta_pixel) == 0u);
// Frame 3: the bound is grabbed and dragged, then the pointer leaves the plot. The
// hover is over -- `region_grab()` says so -- but the gesture is not, so the bound the
// drag holds stays emphasized, at the column the drag has moved it to.
c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
PLOT_CHECK(c.grabbed_region() == rid);
const plot::f32 moved = col + 60.0f;
c.on_pointer({moved, cy, 60.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false});
c.on_pointer({moved, static_cast<plot::f32>(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left,
0u, true, false, false});
PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::None); // the pointer is off the data area
PLOT_CHECK(p.hot_handles().region == rid);
PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left);
const std::vector<plot::u8> dragged = draw_frame();
const plot::PlotLayout moved_layout = p.layout();
const plot::Rect moved_tab = moved_layout.regions[0].left_handle;
PLOT_CHECK_NEAR(static_cast<plot::f64>(plot::handle_column(moved_tab)),
static_cast<plot::f64>(moved), 0.5);
const plot::Rect moved_band{moved_tab.x - 1.0f, moved_tab.y, moved_tab.w + 2.0f,
moved_tab.h + 1.0f};
const plot::u32 held = exact_pixels(dragged, kW, kFrameRect, accent);
PLOT_CHECK(held > 0u);
PLOT_CHECK(exact_pixels(dragged, kW, moved_band, accent) == held);
// Releasing off the plot ends both the gesture and the emphasis.
c.on_pointer({moved, static_cast<plot::f32>(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left,
0u, false, false, true});
PLOT_CHECK(c.grabbed_region() == plot::kInvalid);
PLOT_CHECK(p.hot_handles().region == plot::kInvalid);
PLOT_CHECK(exact_pixels(draw_frame(), kW, kFrameRect, accent) == 0u);
// The same rule for a cursor marker: hovered, it is drawn as a handle in the accent,
// in its own column and nowhere else, and its own colour is gone from that column.
const plot::f32 marker_col = static_cast<plot::f32>(p.x_transform(ax).to_screen_x(p.cursor(cid).x));
c.on_pointer({marker_col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false});
PLOT_CHECK(c.hovered_cursor() == cid);
PLOT_CHECK(p.hot_handles().cursor == cid);
const std::vector<plot::u8> over_marker = draw_frame();
const plot::u32 marker_hot = exact_pixels(over_marker, kW, kFrameRect, accent);
const plot::Rect marker_band{marker_col - 4.0f, layout.axes.y, 9.0f,
layout.axes.h + 1.0f};
PLOT_CHECK(marker_hot > 0u);
PLOT_CHECK(exact_pixels(over_marker, kW, marker_band, accent) == marker_hot);
PLOT_CHECK(predicate_pixels(over_marker, kW, marker_band, green_dominant) == 0u);
ctx.destroy();
}
namespace {
/// Two traces whose colours no other element of these frames produces, each with a
/// predicate that refuses the other's: the top row's is blue (`trace_pixel`), the
/// second row's magenta (`magenta_pixel`). One figure's rows can then be told apart by
/// their pixels, which is what makes "the rows hold different content" a statement
/// about the rows and not about the frame.
const plot::Color kRowBlue = plot::Color::hex(0x40C0FF);
const plot::Color kRowMagenta = plot::Color::hex(0xFF00FF);
/// A sampled wave, one sample per x unit, so a row's trace crosses every column of its
/// axes exactly once: every coverage statement below is then about the row it names.
std::vector<plot::f32> wave(plot::u32 n, plot::f32 phase) {
std::vector<plot::f32> y(n);
for (plot::u32 i = 0; i < n; ++i) {
y[i] = 0.8f * std::sin(phase + 0.05f * static_cast<plot::f32>(i));
}
return y;
}
/// Gives one figure row the axes and the trace these cases draw: an x axis spanning
/// the series and a fixed y axis, so the geometry a row is drawn with is the geometry
/// the case measured.
void add_row_trace(plot::Plot& p, const plot::data::Series& s, plot::Color color) {
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), false, 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, color, 1.5f, ax, ay});
}
} // namespace
/// A figure's rows are drawn into their own rects, so what one row renders never
/// reaches another's: two rows carrying traces of two colours put each colour in its
/// own row's rect and in no other, which is the whole point of stacking plots rather
/// than drawing one.
PLOT_TEST(two_rows_render_distinct_content) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const std::vector<plot::f32> top = wave(640u, 0.0f);
const std::vector<plot::f32> bottom = wave(640u, 1.0f);
plot::data::ArraySeries top_series(top, 0.0, 1.0);
plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0);
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);
add_row_trace(figure.plot(1u), bottom_series, kRowMagenta);
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, "figure_two_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::Rect& row1 = layout.rows[1];
PLOT_CHECK(row0.h > 0.0f && row1.h > 0.0f);
// Each row's own colour is present in its own rect...
PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, row1, magenta_pixel) > 0u);
// ... and each row's colour is absent from the other's, which is what "the rows
// hold distinct content" means: a frame drawn as one plot, or one row drawn over
// the other, fails here whichever way it mixes them.
PLOT_CHECK(predicate_pixels(frame, kW, row1, trace_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, row0, magenta_pixel) == 0u);
// The frame holds exactly the two rows' traces: nothing of either colour was drawn
// outside the rect the row owns.
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) ==
predicate_pixels(frame, kW, row0, trace_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
predicate_pixels(frame, kW, row1, magenta_pixel));
// Each trace is drawn inside its row's *axes*, not merely somewhere in the row: the
// row's y strip is the other half of the rect and holds no data. The top row is
// interior and reserves no x chrome; the bottom row reserves its own label and title
// strips, because the figure's one shared x axis is that row's.
const plot::PlotLayout& layout0 = figure.plot(0u).layout();
const plot::PlotLayout& layout1 = figure.plot(1u).layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout0.axes.h), static_cast<plot::f64>(row0.h), 1e-3);
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout1.axes.bottom() + layout1.x_label_area.h +
layout1.x_title_height),
static_cast<plot::f64>(row1.bottom()), 1e-3);
PLOT_CHECK(predicate_pixels(frame, kW, layout0.axes, trace_pixel) > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout1.axes, magenta_pixel) > 0u);
// The rows are stacked, not overlapped: the second starts below the first.
PLOT_CHECK(row1.y >= row0.bottom() - 1e-3f);
ctx.destroy();
}
/// The figure draws *one* x axis. With labels on the bottom only -- the default --
/// the rows above the bottom reserve no x chrome and their labels appear once, in the
/// **bottom row's own** strip, which is where the figure's one x axis lives; the
/// interior rows' labels are not in the frame at all. Switching the figure to label
/// every row puts them in every row, and the count below -- of x-label ink inside the
/// rows' own strips, so it compares x chrome with x chrome rather than the whole frames,
/// whose row heights differ between the two modes -- says which happened: one row's
/// worth against three.
PLOT_TEST(shared_x_axis_is_drawn_once) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const std::vector<plot::f32> samples = wave(640u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
plot::Figure figure(3u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 row = 0; row < 3u; ++row) add_row_trace(figure.plot(row), series, kRowBlue);
PLOT_CHECK(figure.x_labels_only_on_bottom());
const auto draw_frame = [&] {
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());
return read_frame(ctx);
};
const plot::Color ink = r.style().axis_text;
// --- labels on the bottom only: one set, and it is the figure's -----------------
draw_frame();
dump_if_requested(r, "figure_shared_x.ppm");
const plot::FigureLayout layout = figure.layout();
PLOT_CHECK(layout.rows.size() == 3u);
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
// The strip is the *bottom row's own* label area, inside that row and at the bottom
// of it: the rows tile the figure to its bottom edge, and the shared axis is the
// last band of the last row -- the rect the controller hit-tests and the renderer
// stages the one set of labels in.
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.rows.back().bottom()),
static_cast<plot::f64>(kH), 1e-3);
// The shared strip *is* the bottom row's x label area, to the pixel -- the rect the
// controller hit-tests and the renderer stages the one set of labels in -- and it
// sits inside that row, above the row's own x title strip.
const plot::PlotLayout& bottom_row_layout = figure.plot(2u).layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.x),
static_cast<plot::f64>(bottom_row_layout.x_label_area.x), 1e-3);
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.y),
static_cast<plot::f64>(bottom_row_layout.x_label_area.y), 1e-3);
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.w),
static_cast<plot::f64>(bottom_row_layout.x_label_area.w), 1e-3);
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.h),
static_cast<plot::f64>(bottom_row_layout.x_label_area.h), 1e-3);
PLOT_CHECK(layout.x_axis_strip.bottom() <= layout.rows.back().bottom() + 1e-3f);
const plot::u32 strip_ink = scan_rect_ink(ctx, layout.x_axis_strip, ink).pixels;
PLOT_CHECK(strip_ink > 0u);
// Every row *above* the bottom gave its x label strip away: such a row's axes
// reaches its rect's bottom edge and the row has no band inside itself for a second
// set of labels to be drawn in. The bottom row is the exception -- it keeps its own
// strip, which is where the figure's one x axis is -- so its strip is not zero.
for (plot::u32 row = 0; row + 1u < 3u; ++row) {
const plot::PlotLayout& row_layout = figure.plot(row).layout();
PLOT_CHECK(!plot::x_strip_has_labels(figure.plot(row).x_strip_mode()));
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.x_axis_height), 0.0, 1e-6);
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.x_label_area.h), 0.0, 1e-6);
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.axes.bottom()),
static_cast<plot::f64>(layout.rows[row].bottom()), 1e-3);
}
// ... and the band a second copy of them *would* land in holds none. A row that
// kept no strip of its own would have its x labels staged against the top of the
// zero-height strip it does have, i.e. centred on its `axes`' bottom edge, so the
// band is the figure's strip height at the bottom of the row. Two insets keep it to
// labels and nothing else: four columns past `axes.x` clear the row's y axis line
// (the vertical edge `text_ink`, which is deliberately blind to one chalk tone or
// the other, happily accepts), and two rows above the row's bottom edge clear the
// row's own x axis line.
for (plot::u32 row = 0; row + 1u < 3u; ++row) {
const plot::PlotLayout& row_layout = figure.plot(row).layout();
const plot::Rect band{row_layout.axes.x + 4.0f,
layout.rows[row].bottom() - layout.x_axis_strip.h,
row_layout.axes.w - 8.0f, layout.x_axis_strip.h - 2.0f};
PLOT_CHECK(band.h > 0.0f);
PLOT_CHECK(scan_rect_ink(ctx, band, ink).pixels == 0u);
}
// --- every row labels itself: the same labels, in three places -----------------
figure.set_x_labels_only_on_bottom(false);
draw_frame();
const plot::FigureLayout per_row_layout = figure.layout();
// With every row labelling itself there is no shared axis to speak of: the strip
// reads back the bottom row's own label area again, as one of three rather than as
// the figure's one -- the figure added no band outside its rows either way.
PLOT_CHECK_NEAR(static_cast<plot::f64>(per_row_layout.x_axis_strip.h),
static_cast<plot::f64>(figure.plot(2u).layout().x_label_area.h), 1e-3);
PLOT_CHECK(per_row_layout.rows.size() == 3u);
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& own = figure.plot(row).layout();
PLOT_CHECK(plot::x_strip_has_labels(figure.plot(row).x_strip_mode()));
PLOT_CHECK(own.x_label_area.h > 0.0f);
// A row's own labels are in its own strip, inside its own rect.
PLOT_CHECK(own.x_label_area.y >= per_row_layout.rows[row].y);
PLOT_CHECK(own.x_label_area.bottom() <= per_row_layout.rows[row].bottom() + 1e-3f);
PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u);
}
// The two interior rows' labels are the difference -- they are what the shared axis
// replaced, and in the default frame they are simply not there. Counted inside the
// rows' own x label strips, which is the only like-for-like count here: the two frames
// do not give the rows the same heights, because a shared frame lets its interior rows
// keep the band the per-row frame spends on their x strips (the shared axis costs them
// nothing), so a whole-frame text count compares y chrome against y chrome and decides
// nothing. The bottom row's strip holds one row's worth of x labels in both frames --
// the same rect carrying the same labels -- while the interior rows' strips hold none
// in the shared frame and a row's worth each in the per-row one, so the per-row strips
// together hold three times the shared one's. A frame that drew the interior rows'
// labels but only one set of them, or that spent no strip on the bottom row, cannot
// land here.
plot::u32 per_row_x_ink = 0u;
for (plot::u32 row = 0; row < 3u; ++row) {
per_row_x_ink += scan_rect_ink(ctx, figure.plot(row).layout().x_label_area, ink).pixels;
}
PLOT_CHECK(per_row_x_ink >= 2u * strip_ink);
ctx.destroy();
}
/// Spec §16, end to end: a figure whose rows carry different magnitudes ("671m",
/// "90", "2.51" scale tick labels) draws a vertical feature -- each row's own cursor
/// at the same data x -- in the **same pixel column** in every row. That is what a
/// user sees when the y axes line up, and it is what the rows' `axes` rects being
/// equal is for: the equality below is read from the drawn frame, not from the layout
/// alone, so a renderer that derived a row's geometry of its own would fail here.
PLOT_TEST(figure_rows_line_up_a_vertical_cursor) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const std::vector<plot::f32> samples = wave(101u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
// The cursor's colour, and the only strongly red thing in these frames: the trace is
// `kRowBlue` and every piece of chrome is grey (all three channels equal), so a
// red-dominant pixel inside a row's `axes` can only be that row's cursor. The row
// ranges are the three magnitudes whose tick labels are four, two and three
// characters wide -- the shape that gave each row its own strip before the gutter.
const plot::Color cursor_red = plot::Color::hex(0xFF2020);
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
plot::Figure figure(3u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 row = 0; row < 3u; ++row) {
plot::Plot& p = figure.plot(row);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay =
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
p.add_cursor(50.0, cursor_red);
}
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, "figure_gutter_cursor.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::FigureLayout layout = figure.layout();
PLOT_CHECK(layout.rows.size() == 3u);
// The mechanism, straight from the drawn frame's own layouts: every row's axes starts
// and ends on the same pixel columns, and that is true at the top of the figure too --
// these rows are separate plots vertically and one column horizontally.
const plot::Rect first_axes = figure.plot(0u).layout().axes;
for (plot::u32 row = 1; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first_axes.x), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first_axes.w), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.right()),
static_cast<plot::f64>(first_axes.right()), 1e-4);
}
// Every column of `area` that holds a *fully covered* cursor pixel, left to right.
// The predicate demands the cursor red at full coverage (0xFF2020), which is what
// makes the comparison across rows meaningful: a partially covered column along the
// stroke's edge blends with whatever the row has under it -- its trace at a different
// y -- so its colour is a property of that row's data, not of the geometry this case
// is about. The core of the stroke has no such dependence.
const auto cursor_columns = [&](const plot::Rect& area) {
std::vector<plot::u32> columns;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
if (px[0] >= 200 && px[1] <= 80 && px[2] <= 80) {
columns.push_back(x);
break;
}
}
}
return columns;
};
std::vector<plot::u32> first_columns;
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& row_layout =
static_cast<const plot::Plot&>(figure.plot(row)).layout();
const std::vector<plot::u32> columns = cursor_columns(row_layout.axes);
PLOT_CHECK(!columns.empty()); // the row's cursor really is in the frame
// One connected run: the stroke is one vertical line, not a doubled or smeared one.
PLOT_CHECK(columns.back() - columns.front() + 1u == columns.size());
if (row == 0) {
first_columns = columns;
} else {
// The same pixel column in this row as in the first -- the drawn consequence of
// the shared gutter, which a frame that sized each row's strip from its own
// labels cannot satisfy.
PLOT_CHECK(columns == first_columns);
}
}
// ... and that column is the one the shared transform predicts, so the equality is
// the geometry's and not three cursors that happened to land together. The tolerance
// is the stroke's own pixel: the transform gives the stroke's centre, which lands
// inside the band it inked.
const plot::u32 expected = static_cast<plot::u32>(
static_cast<const plot::Plot&>(figure.plot(0u)).x_transform(0u).to_screen_x(50.0));
PLOT_CHECK(first_columns.front() <= expected + 1u && expected <= first_columns.back() + 1u);
ctx.destroy();
}
/// A resize recomputes the layout: the rows are re-derived from the new viewport --
/// their rects, the shared strip, the axes and the transforms they pin -- rather than
/// rescaled from, or left at, the rects of the frame before. The second frame here is
/// taller than the first, so a stale layout would paint the rows at the old
/// coordinates and a scaled one would put them somewhere between the two; the pixels
/// say which happened.
PLOT_TEST(a_row_resize_recomputes_rather_than_rescaling_a_stale_layout) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const std::vector<plot::f32> top = wave(640u, 0.0f);
const std::vector<plot::f32> bottom = wave(640u, 1.0f);
plot::data::ArraySeries top_series(top, 0.0, 1.0);
plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0);
plot::Figure figure(2u);
add_row_trace(figure.plot(0u), top_series, kRowBlue);
add_row_trace(figure.plot(1u), bottom_series, kRowMagenta);
// First frame: half the height, so the rows are short and the second row of the
// taller frame below is nowhere near where this one puts it.
const plot::f32 short_h = 0.5f * static_cast<plot::f32>(kH);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), short_h});
r.begin_frame(kW, kH, figure.viewport());
r.draw(figure);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::FigureLayout short_layout = figure.layout();
PLOT_CHECK(short_layout.rows.size() == 2u);
// Second frame: the whole framebuffer.
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.begin_frame(kW, kH, figure.viewport());
r.draw(figure);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "figure_resized.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& row1 = layout.rows[1];
// The rows are the *new* figure's: twice the first frame's height, to the pixel,
// and the second row reaches the figure's bottom edge rather than stopping where
// the short frame's shared strip was.
PLOT_CHECK(row1.h > 1.5f * short_layout.rows[1].h);
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1.bottom()), static_cast<plot::f64>(kH), 1e-3);
PLOT_CHECK(row1.y > short_layout.rows[1].y);
// The transforms the frame drew with are the new rects': the second row's own chrome
// stacks inside its rect -- the axes, then the shared-axis label strip, then the
// row's title strip, the three ending on the row's bottom edge -- and its trace is in
// the axes, not at the old coordinates.
const plot::PlotLayout& row1_layout = figure.plot(1u).layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.axes.y), static_cast<plot::f64>(row1.y), 1e-3);
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.y),
static_cast<plot::f64>(row1_layout.x_label_area.y), 1e-3);
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.axes.bottom()),
static_cast<plot::f64>(row1_layout.x_label_area.y), 1e-3);
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.x_label_area.bottom() +
row1_layout.x_title_height),
static_cast<plot::f64>(row1.bottom()), 1e-3);
PLOT_CHECK(predicate_pixels(frame, kW, row1_layout.axes, magenta_pixel) > 0u);
// Nothing of the second row's trace is above it, where the short frame's row 1 was:
// a stale layout would have left the magenta trace up there.
PLOT_CHECK(predicate_pixels(frame, kW, plot::Rect{0.0f, 0.0f, static_cast<plot::f32>(kW), row1.y},
magenta_pixel) == 0u);
// And the top row is still the top row: its own trace is above the second row and
// nowhere below it.
PLOT_CHECK(predicate_pixels(frame, kW, layout.rows[0], trace_pixel) > 0u);
PLOT_CHECK(predicate_pixels(frame, kW,
plot::Rect{0.0f, row1.y, static_cast<plot::f32>(kW),
static_cast<plot::f32>(kH) - row1.y},
trace_pixel) == 0u);
ctx.destroy();
}
/// A `Figure`'s x title is drawn where a figure's x axis is: the bottom row's own
/// title strip, under that row's own labels. An interior row reserves no x chrome at
/// all -- its `axes` reaches its rect's bottom edge, because the figure has one x axis
/// and the bottom row owns it -- so a title on an interior row has nowhere to go and
/// is not drawn. The second half of the frame pair is what makes that a statement:
/// told to label every row, the same figure gives every row its own strip and the
/// interior rows' titles appear, so the ink the default frame withholds is exactly the
/// ink the interior rows' x chrome would have spent.
PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const std::vector<plot::f32> samples = wave(640u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
plot::Figure figure(3u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 row = 0; row < 3u; ++row) {
plot::Plot& p = figure.plot(row);
// Every row gets a title, and a title of its own, so which strip the ink is in says
// which row drew it.
const plot::u32 ax =
p.add_x_axis({row + 1u == 3u ? "bottom time" : "row time",
series.x_bounds(), false, 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({&series, kRowBlue, 1.5f, ax, ay});
}
const auto draw_frame = [&] {
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());
return read_frame(ctx);
};
const plot::Color ink = r.style().axis_text;
const std::vector<plot::u8> bottom_only = draw_frame();
dump_if_requested(r, "figure_x_title.ppm");
const plot::FigureLayout layout = figure.layout();
// The bottom row keeps a real title strip, inside its own rect, and its title is in
// it -- so the title feature still works on a figure.
const plot::PlotLayout& above = figure.plot(2u).layout();
PLOT_CHECK(above.x_title_area.h > 0.0f);
PLOT_CHECK(above.x_title_area.y >= above.x_label_area.bottom() - 1e-3f);
PLOT_CHECK(above.x_title_area.bottom() <= layout.rows[2].bottom() + 1e-3f);
PLOT_CHECK(scan_rect_ink(ctx, above.x_title_area, ink).pixels > 0u);
// The labels are above it and the data above them: the three strips stack, they do
// not overlap.
PLOT_CHECK(above.axes.bottom() <= above.x_label_area.y + 1e-3f);
PLOT_CHECK(above.x_label_area.bottom() <= above.x_title_area.y + 1e-3f);
// An interior row reserves none of that: no label strip, no title strip, and its
// axes takes the whole rect.
for (plot::u32 row = 0; row + 1u < 3u; ++row) {
const plot::PlotLayout& interior = figure.plot(row).layout();
PLOT_CHECK(interior.x_label_area.h == 0.0f);
PLOT_CHECK(interior.x_title_area.h == 0.0f);
PLOT_CHECK_NEAR(static_cast<plot::f64>(interior.axes.bottom()),
static_cast<plot::f64>(layout.rows[row].bottom()), 1e-3);
}
const plot::u32 withheld = frame_text_ink(bottom_only, kW, kH, ink);
// Told to label every row, the same figure puts every row's x chrome back -- label
// strip and title strip, one per row, each inside its own rect -- and the interior
// rows' titles are drawn where the default frame drew nothing.
figure.set_x_labels_only_on_bottom(false);
const std::vector<plot::u8> every_row = draw_frame();
const plot::FigureLayout per_row = figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& own = figure.plot(row).layout();
PLOT_CHECK(own.x_title_area.h > 0.0f);
PLOT_CHECK(own.x_title_area.bottom() <= per_row.rows[row].bottom() + 1e-3f);
PLOT_CHECK(scan_rect_ink(ctx, own.x_title_area, ink).pixels > 0u);
PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u);
}
PLOT_CHECK(frame_text_ink(every_row, kW, kH, ink) > withheld);
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();
}
/// An annotation's **caption** is the one overlay the per-row clip did not reach: the
/// cross is staged as line geometry inside the axes clip, while the caption is staged
/// into the frame's single text batch, which is flushed *after* `clear_clip()`. An
/// annotation anchored outside the visible range therefore kept a caption with no cross,
/// and -- in a `Figure`, whose rows tile the figure -- that caption painted into the
/// neighbouring row, the bleeding the clip exists to prevent. The caption is now skipped
/// with its cross when the anchor is outside the axes; an in-range annotation still draws
/// its caption, so the skip is not a blanket refusal to draw text.
PLOT_TEST(figure_annotation_caption_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));
const std::vector<plot::f32> samples = wave(640u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
plot::Figure figure(2u);
figure.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
add_row_trace(figure.plot(0u), series, kRowBlue);
plot::Plot& bottom = figure.plot(1u);
const plot::u32 ax =
bottom.add_x_axis({"t", series.x_bounds(), false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = bottom.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
bottom.add_trace({&series, kRowMagenta, 1.5f, ax, ay});
// The geometry the out-of-range anchor is chosen against: a y whose pixel row lands
// *inside the row above*, well above this row's own axes.
figure.layout();
const plot::FigureLayout layout = figure.layout();
PLOT_CHECK(layout.rows.size() == 2u);
const plot::Rect& axes0 = figure.plot(0u).layout().axes;
const plot::Rect& axes1 = bottom.layout().axes;
PLOT_CHECK(axes0.h > 0.0f && axes1.h > 0.0f);
const plot::f32 target_y = axes0.y + 0.4f * axes0.h;
plot::Annotation outside;
outside.x = 320.0; // in range for x, so the caption is staged at a real column
outside.y = bottom.y_transform(ay).to_data_y(static_cast<plot::f64>(target_y));
outside.text = "outside";
outside.color = plot::Color::hex(0xE8E8E8);
outside.axis_x = ax;
outside.axis_y = ay;
bottom.add_annotation(outside);
// The control: an annotation actually inside row 1's axes, whose caption must still
// be drawn -- so the assertion below is about the skip, not about text being absent.
plot::Annotation inside;
inside.x = 320.0;
inside.y = 0.0;
inside.text = "inside";
inside.color = plot::Color::hex(0xE8E8E8);
inside.axis_x = ax;
inside.axis_y = ay;
bottom.add_annotation(inside);
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_annotation_caption.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
// Row 0's axes hold no text of their own -- its labels live in its y strip, outside
// `axes` -- so any text ink here would be the neighbour's escaped caption.
PLOT_CHECK(predicate_pixels(frame, kW, axes0, text_pixel) == 0u);
// The in-range caption proves the text pipeline ran and captions are drawn at all.
PLOT_CHECK(predicate_pixels(frame, kW, axes1, text_pixel) > 0u);
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();
}
// ------------------------------------------------- horizontal rulers (spec §14)
namespace {
/// The horizontal ruler's own colour (0x35D0A8) at any coverage: a teal greener than the
/// background and -- unlike the trace's own blue -- with green *above* blue, so no other
/// element of these frames can satisfy it. A predicate rather than an exact match, because
/// a 1.5-device-px stroke is anti-aliased and no pixel is ever guaranteed whole.
bool ruler_pixel(const plot::u8 px[4]) {
return px[1] > px[0] + 50 && px[1] > px[2] + 20 && px[1] > 90;
}
/// The same hue at *any* coverage. A stroke centred exactly on `axes.bottom()` -- where the
/// clip's inward rounding leaves only the inside half -- can survive as a faint sub-pixel
/// sliver when that edge is fractional, which `ruler_pixel`'s brightness floor does not see.
/// The hue gate still excludes the grey chrome, the pale text and the (absent here) blue
/// trace, so a count of it is still a count of the ruler.
bool ruler_ink_any(const plot::u8 px[4]) {
return px[1] > px[0] + 15 && px[2] > px[0] + 10 && px[1] > 35 && px[1] >= px[2] - 8;
}
/// The extent of the ink a predicate accepts anywhere in a read-back frame: its bounding
/// box in top-down framebuffer rows/columns (like every `Rect` here), and its pixel count.
struct InkExtent {
plot::u32 top = kNoColumn;
plot::u32 bottom = 0;
plot::u32 left = kNoColumn;
plot::u32 right = 0;
plot::u32 pixels = 0;
bool any() const noexcept { return top != kNoColumn; }
plot::u32 rows() const noexcept { return any() ? bottom - top + 1u : 0u; }
plot::u32 cols() const noexcept { return any() ? right - left + 1u : 0u; }
};
InkExtent ink_extent(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::u32 fb_h,
bool (*pred)(const plot::u8*)) {
InkExtent e;
for (plot::u32 y = 0; y < fb_h; ++y) {
for (plot::u32 x = 0; x < fb_w; ++x) {
if (!pred(&frame[(plot::usize(y) * fb_w + x) * 4u])) continue;
if (!e.any()) {
e.top = e.bottom = y;
e.left = e.right = x;
}
e.top = std::min(e.top, y);
e.bottom = std::max(e.bottom, y);
e.left = std::min(e.left, x);
e.right = std::max(e.right, x);
++e.pixels;
}
}
return e;
}
} // namespace
/// Spec §14, the paint half: a horizontal ruler is the marker turned a quarter turn, so its
/// ink is one *horizontal* run across `axes` at the row its y maps to -- not a column -- and,
/// Task 1's clip doing its job, nothing of it outside `axes`. The row is asserted against the
/// transform's own `to_screen_y()` rather than "some ink exists", so a ruler drawn at the
/// wrong y, or one still staged as a vertical column, fails here. The label is a second run
/// of text ink just under the ruler, inside the axes -- the value the ruler reads off its own
/// y axis, written the way that axis writes its ticks.
PLOT_TEST(horizontal_ruler_label_reads_its_value) {
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::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"t", {0.0, 300.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
// No trace: the frame is chrome and the ruler alone, so the ruler's colour is the only
// thing near its row and no trace ink can be mistaken for the label beneath it.
const plot::u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
p.layout();
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, "h_ruler_paint.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
const plot::Rect& axes = layout.axes;
const plot::f32 expected_row =
static_cast<plot::f32>(p.y_transform(ay).to_screen_y(p.cursor(ruler).y));
PLOT_CHECK(expected_row >= axes.y && expected_row <= axes.bottom());
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
// One horizontal run: shallow in rows, spanning essentially the axes' own width.
PLOT_CHECK(ink.rows() <= 3u);
PLOT_CHECK(ink.cols() + 2u >= static_cast<plot::u32>(axes.w));
// And at the row the transform says -- a ruler drawn at the wrong y, or a vertical column
// that happens to be inside the rect, cannot satisfy both of these.
PLOT_CHECK(static_cast<plot::f32>(ink.top) <= expected_row + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= expected_row - 1.0f);
// The label: text ink just under the ruler at its left end, inside the axes. The tick
// labels are outside `axes`, in their own strips, so this can only be the ruler's value.
const plot::Rect label{axes.x + 1.0f, expected_row, 60.0f, 16.0f};
PLOT_CHECK(predicate_pixels(frame, kW, label, text_pixel) > 0u);
// The clip: every ruler-coloured pixel of the frame is inside `axes`.
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
predicate_pixels(frame, kW, axes, ruler_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, ruler_pixel) == 0u);
ctx.destroy();
}
/// Spec §14, the edge rule, discovered from the code and pinned here: a *drag* clamps the
/// ruler's y to its own y axis' range (`Controller::move_cursor`), so a pointer that has left
/// the axes cannot park the ruler at an extrapolated value -- but the direct
/// `Plot::set_cursor_y()` stays verbatim, so a caller that means to place one off range still
/// can. Either way Task 1's axes clip and the renderer's far-edge skip keep the ink inside
/// `axes`: a ruler at or past an edge puts nothing over the strips beside it. Both halves are
/// asserted from the pixels, because "drawn over the strip" is a claim about pixels.
PLOT_TEST(horizontal_ruler_stays_inside_the_axes_at_the_edges) {
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::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"t", {0.0, 300.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const plot::u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
plot::input::Controller c(p);
c.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.layout();
// Draw one frame before measuring: the renderer hands the plot its own text backend and
// style on the first draw, and with them the strip widths the layout pins -- so the axes
// the controller hit-tests is the one a drawn frame has, not the empty plot's. Without
// this the press below aims at a rect the first `frame_now()` has already replaced.
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
const plot::Rect& axes = p.layout().axes;
const plot::f32 col = axes.x + 0.5f * axes.w;
const plot::f32 row = static_cast<plot::f32>(p.y_transform(ay).to_screen_y(50.0));
const auto frame_now = [&]() {
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());
return read_frame(ctx);
};
// Presses on the ruler at its own row: no modifier, the gesture the ruler shares with a
// marker. `row` is the y=50 row and does not change with a drag, so it can be reused.
const auto grab = [&]() {
p.set_cursor_y(ruler, 50.0); // back to the row the press is on
p.layout();
c.on_pointer({col, row, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(c.grabbed_cursor() == ruler);
};
const auto drag_to = [&](plot::f32 y) {
c.on_pointer({col, y, 0.0f, y - row, plot::input::Button::Left, 0u, true, false, false});
c.on_pointer({col, y, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
};
// Dragged past the *bottom*: the pointer extrapolates below the axis and the drag clamps
// to the range's minimum rather than storing a value no tick could reach. That value maps
// to the axes' *bottom* row, and the ruler is pinned visible there -- the same outcome as
// a drag past the top, which is the point of the far-edge rule being strict: an at-bound
// coordinate is inside the rect. (The clip's inward rounding keeps the ink on the last row
// inside the edge, so "at the boundary" reads as within a stroke width of it.)
grab();
drag_to(axes.bottom() + 400.0f);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 0.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
const InkExtent ink = ink_extent(frame, kW, kH, ruler_ink_any);
PLOT_CHECK(ink.pixels > 0u); // drawn, not hidden
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= axes.bottom() - 2.0f);
PLOT_CHECK(static_cast<plot::f32>(ink.top) <= axes.bottom());
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_ink_any) ==
predicate_pixels(frame, kW, axes, ruler_ink_any));
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().x_label_area, ruler_ink_any) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_ink_any) == 0u);
}
// Dragged past the *top*: clamped to the range's maximum, and the clamped ruler is still
// drawn -- at the axes' own top row and inside it, rather than over the strip above.
grab();
drag_to(axes.y - 400.0f);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 100.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
PLOT_CHECK(static_cast<plot::f32>(ink.top) < axes.y + 1.5f);
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= axes.y);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
predicate_pixels(frame, kW, axes, ruler_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u);
}
// A degenerate y range (min == max) is widened by +/-0.5 in the transform, so the one
// value it holds maps to the middle of the axes: a ruler there is genuinely in range and
// must be drawn -- the strict far-edge rule cannot hide it. An inverted range is widened
// the same way, so a value strictly between its bounds also maps inside and draws; only
// the two bounds land ~0.5 data units outside the widened span, which is a row outside the
// rect and correctly skipped (the old rule skipped them too, so this is not a regression).
p.set_y_range(ay, {50.0, 50.0});
p.set_cursor_y(ruler, 50.0);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
predicate_pixels(frame, kW, axes, ruler_pixel));
}
p.set_y_range(ay, {100.0, 0.0});
p.set_cursor_y(ruler, 50.0);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
predicate_pixels(frame, kW, axes, ruler_pixel));
}
p.set_y_range(ay, {0.0, 100.0});
// The direct setter is verbatim -- a caller may park a ruler off range -- and the frame
// still holds the ink in: an off-range ruler is skipped rather than drawn over a strip.
p.set_cursor_y(ruler, 500.0);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 500.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
PLOT_CHECK(predicate_pixels(frame, kW, axes, ruler_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u);
}
ctx.destroy();
}
// ------------------------------------------------------ the crosshair (spec §15)
namespace {
/// The crosshair's own ink: **bright and hue-neutral**, which is the property the default colour
/// has on purpose (see `Style::crosshair`) so that it can never be read as a trace of any hue.
/// This predicate is therefore about brightness and chroma rather than about a hue, and it needs no
/// "not this trace's colour" exception: every other element of these frames is either dark (the
/// background, the grid), saturated (the magenta, green and orange traces) or dimmer than the
/// crosshair (the axis text 0xC8D0DC, whose red channel is 200 against the crosshair's 230).
bool crosshair_pixel(const plot::u8 px[4]) {
const int lo = std::min(px[0], std::min(px[1], px[2]));
const int hi = std::max(px[0], std::max(px[1], px[2]));
return px[0] > 215 && hi - lo <= 24;
}
} // namespace
/// The crosshair's colour has to be tellable from a trace, and telling colours apart by hue is the
/// one thing a reader cannot be asked to do at a glance: the library's own default trace colour is a
/// saturated cyan, so a saturated cyan crosshair would differ from it in lightness alone. The
/// default is therefore near-white -- a colour no saturated trace of any hue can be -- and still
/// plainly not the cursor accent. Both halves are asserted, so a future recolouring that makes it
/// confusable with a trace or with a handle fails here rather than in a screenshot.
PLOT_TEST(crosshair_colour_cannot_be_mistaken_for_a_trace) {
const plot::render::Style style;
const plot::Trace default_trace;
// `Color` stores normalised channels; the comparisons below are in 0..255, like the hex literals
// the colours are written with.
const auto chan = [](plot::f32 c) { return static_cast<int>(c * 255.0f + 0.5f); };
const int lo = std::min({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)});
const int hi = std::max({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)});
// Hue-neutral: its channels are within a few levels of each other, so it is a grey/white and not
// a hue at all. A saturated colour -- the default trace's cyan included -- is 150+ levels apart.
PLOT_CHECK(hi - lo <= 24);
PLOT_CHECK(lo > 200); // bright chrome, not a mid-tone that a dimmed trace could reach
// `Color` is a plain channel struct with no comparison of its own, so the two "not this colour"
// assertions spell the channels out.
const auto same = [](plot::Color a, plot::Color b) {
return a.r == b.r && a.g == b.g && a.b == b.b;
};
PLOT_CHECK(!same(style.crosshair, default_trace.color));
PLOT_CHECK(!same(style.crosshair, style.cursor));
// ... and a saturated trace of any hue is out of reach for it by construction: the *most*
// chromatic colour the crosshair could be confused with has to be within the chroma bound above,
// and every trace colour in this library's demos is far outside it.
const plot::Color demo_traces[3] = {plot::Color::hex(0x40C0FF), plot::Color::hex(0xFFB020),
plot::Color::hex(0x35D0A8)};
for (const plot::Color trace : demo_traces) {
PLOT_CHECK(!same(style.crosshair, trace));
}
}
namespace {
/// A third colour predicate for the lateral case below: the orange 0xFFB020 of a trace of its
/// own, which no other element of this frame draws (there is no cursor, no region and no
/// measurement band in it, and the crosshair's own colour is a pale cyan).
bool orange_pixel(const plot::u8 px[4]) {
return px[0] > 200 && px[1] > 150 && px[1] < px[0] && px[2] < 80;
}
} // namespace
/// Spec §15's clip half, and with it a regression guard for Task 1: **everything the crosshair
/// draws stays inside `axes`** -- both lines, every marker and the readout panel -- and so does
/// the data it points at.
///
/// Three traces make the case discriminating, one per way a marker can leave the rect:
///
/// * an **in-range** trace, whose line and marker are inside it -- the complement that says the
/// clip is the axes rect and not something smaller;
/// * a trace **below the axis**: its constant y is five percent under the axis' minimum, so it
/// maps some thirteen pixels beneath the axes' bottom edge -- *into the x label strip*. Nothing
/// in the drawing path tests a sample's y against the visible range, and a crosshair marker is
/// deliberately staged where its trace is drawn rather than skipped, so only Task 1's scissor
/// keeps that ink off the strip;
/// * a trace **beside the axes**: one bound to a *second* x axis whose window starts past the
/// crosshair's x, so the data x under the pointer maps to a column *left* of the axes -- in the
/// y label strip. The marker is placed through the trace's own axis pair (which is what puts it
/// there, and what the row's geometry asserts as the premise), and again only the clip keeps it
/// out.
///
/// A build without the scissor puts marker ink in the x and the y label strip respectively, and
/// this case fails on both. The panel's own placement is asserted as well, because a panel that
/// overhung `axes` would be the same defect by a different route.
PLOT_TEST(crosshair_ink_stays_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));
// A viewport inside the framebuffer with the host's grey painted behind it, so the frame may
// paint inside that rect and nowhere else -- and a marker that escaped `axes` would land either
// in a strip or on the host's own surface, both of which are asserted empty below.
const plot::Rect vp{30.0f, 40.0f, 580.0f, 290.0f};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
// A warm-up frame first, with nothing in the plot: the renderer installs its text backend on the
// first `draw()`, and the strips and the readout panel are measured with it -- so the geometry
// asserted below is a *drawn* frame's, not an empty plot's defaults.
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
// Nothing added from here on can move the strips: an *untitled* second x axis draws its labels
// on the same strip's single line, and a trace reserves nothing at all. (A titled one would
// reserve a title row and move `axes` -- which is why this axis has no label.)
const plot::Rect axes = p.layout().axes;
const plot::Rect y_strip = p.layout().y_label_area;
PLOT_CHECK(y_strip.w > 4.0f);
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
const f64 hover_x = tx.to_data_x(axes.right() - 3.0f);
// The second x axis' window is **derived from that geometry rather than guessed**, so this case
// cannot silently stop testing anything when a font or a padding moves the strip: with a span
// `S` starting at `min`, a data x maps to `axes.x + (x - min) / S * axes.w`, so starting the
// window half a strip's width above the hovered x puts that trace's marker halfway into the y
// label strip.
const f64 span2 = 700.0;
const f64 min2 =
hover_x + (0.5 * static_cast<f64>(y_strip.w) / static_cast<f64>(axes.w)) * span2;
const plot::u32 ax2 =
p.add_x_axis({"", {min2, min2 + span2}, false, plot::AxisPlace::Bottom, false});
// The geometry really is the one asserted below, and the second axis really did not move it.
PLOT_CHECK_NEAR(p.layout().axes.h, axes.h, 1e-4);
// The in-range signal: a wave inside the [0, 1] axis, so its trace and its marker are both
// inside the rect.
std::vector<plot::f32> in_range(640u);
for (plot::u32 i = 0; i < in_range.size(); ++i) {
in_range[i] = 0.5f + 0.4f * std::sin(0.03f * static_cast<plot::f32>(i));
}
plot::data::ArraySeries good(in_range, 0.0, 1.0);
// Five percent below the bottom of the axis: some thirteen pixels, which is the x label strip.
plot::data::ArraySeries below(std::vector<plot::f32>(640u, -0.05f), 0.0, 1.0);
// In range on y, but read through an x axis the hovered x is outside of: its marker lands in the
// y label strip, beside the axes.
plot::data::ArraySeries aside(std::vector<plot::f32>(2000u, 0.25f), 0.0, 1.0);
p.add_trace({&good, kRowMagenta, 1.5f, ax, ay, "good"});
p.add_trace({&below, plot::Color::hex(0x00E070), 1.5f, ax, ay, "below"});
p.add_trace({&aside, plot::Color::hex(0xFFB020), 1.5f, ax2, ay, "aside"});
// The crosshair three pixels inside the bottom-right corner of the data area, so its readout
// panel would leave the rect on two sides if it were not flipped and clamped.
p.set_crosshair(hover_x, ty.to_data_y(axes.bottom() - 3.0f), true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "crosshair_axes.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
const plot::CrosshairLayout& crosshair = layout.crosshair;
PLOT_CHECK(crosshair.active);
PLOT_CHECK(crosshair.rows.size() == 3u);
// The panel exists, and is inside the axes on every side -- the placement rule, asserted
// against the very rect the renderer clipped to.
PLOT_CHECK(crosshair.panel.w > 0.0f);
PLOT_CHECK(crosshair.panel.h > 0.0f);
PLOT_CHECK(crosshair.panel.x >= axes.x);
PLOT_CHECK(crosshair.panel.y >= axes.y);
PLOT_CHECK(crosshair.panel.right() <= axes.right());
PLOT_CHECK(crosshair.panel.bottom() <= axes.bottom());
// The premises, which is what makes every pixel assertion below a statement about the defect and
// not about a marker that never went anywhere: the two off-scale markers are staged where only
// the clip stands between them and a strip -- row 1's below the axes' bottom edge, inside the x
// label strip, row 2's left of the axes' left edge, inside the y label strip.
PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK(crosshair.rows[1].valid);
PLOT_CHECK(crosshair.rows[2].valid);
PLOT_CHECK(crosshair.rows[1].marker.y > axes.bottom());
PLOT_CHECK(crosshair.rows[1].marker.y < layout.x_label_area.bottom());
PLOT_CHECK(crosshair.rows[2].marker.x < axes.x);
PLOT_CHECK(crosshair.rows[2].marker.x >= layout.y_label_area.x);
// Every crosshair-coloured pixel of the frame is inside `axes`, and there are some: not one in a
// label or title strip, not one over the host's surface outside the viewport.
const plot::u32 inside = predicate_pixels(frame, kW, axes, crosshair_pixel);
PLOT_CHECK(inside > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) == inside);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, crosshair_pixel) == 0u);
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);
// The panel's own border is inside the panel, so the readout is drawn where the layout said and
// not merely clipped somewhere else.
PLOT_CHECK(predicate_pixels(frame, kW, crosshair.panel, crosshair_pixel) > 0u);
// The trace below the axis: none of its colour anywhere outside `axes` -- the x label strip and
// the host's surface included -- while its own swatch in the readout proves the colour is one
// this frame really draws, so the absence is the clip's doing and not a colour nothing paints.
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, green_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, green_pixel) ==
predicate_pixels(frame, kW, axes, green_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, crosshair.rows[1].swatch, green_pixel) > 0u);
// The trace beside the axes: the same statement for the y label strip and the title strip beside
// it, where its marker would have been drawn.
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, orange_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, orange_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, orange_pixel) ==
predicate_pixels(frame, kW, axes, orange_pixel));
// The complement: the in-range trace's ink -- its polyline and the marker the crosshair put on it
// -- is inside `axes`, and all of it is, which a clip smaller than the rect could not do.
PLOT_CHECK(predicate_pixels(frame, kW, axes, magenta_pixel) > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
predicate_pixels(frame, kW, axes, magenta_pixel));
ctx.destroy();
}
// ------------------------------------------------- the crosshair's style (spec §17)
namespace {
/// The `mod(v_along, on + off)` pattern is a statement about a *line's length*, so the helpers below
/// read the frame one dimensionally: a boolean row (which columns hold ink) whose maximal `true`
/// runs are the dashes.
bool column_has_ink(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x, plot::i32 y0,
plot::i32 y1, bool (*pred)(const plot::u8[4])) {
if (x < 0 || x >= static_cast<plot::i32>(fb_w)) return false;
for (plot::i32 y = std::max<plot::i32>(0, y0); y < y1; ++y) {
if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) return true;
}
return false;
}
/// How many maximal `true` runs a boolean row holds: one for a solid line, and about `w / (on + off)`
/// for a dash of period `on + off` across a width `w`.
plot::u32 run_count(const std::vector<bool>& on) {
plot::u32 runs = 0;
for (plot::usize i = 0; i < on.size(); ++i) {
if (on[i] && (i == 0u || !on[i - 1u])) ++runs;
}
return runs;
}
/// The boolean row `[x0, x1)` in which each entry is whether that column holds any `pred` pixel in
/// rows `[y0, y1)`, plus a one-column count of the rows it is true in.
std::vector<bool> ink_row(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x0,
plot::i32 x1, plot::i32 y0, plot::i32 y1,
bool (*pred)(const plot::u8[4])) {
std::vector<bool> on;
on.reserve(static_cast<plot::usize>(std::max(0, x1 - x0)));
for (plot::i32 x = x0; x < x1; ++x) {
on.push_back(column_has_ink(frame, fb_w, x, y0, y1, pred));
}
return on;
}
/// The framebuffer column of `[x0, x1)` holding the most pixels `pred` accepts in rows `[y0, y1)`,
/// and that count -- how a case finds the one column a vertical stroke lives in without guessing
/// where a transform put it.
plot::i32 densest_column(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x0,
plot::i32 x1, plot::i32 y0, plot::i32 y1,
bool (*pred)(const plot::u8[4]), plot::u32* out_count) {
plot::i32 best = -1;
plot::u32 best_n = 0;
for (plot::i32 x = x0; x < x1; ++x) {
plot::u32 n = 0;
for (plot::i32 y = std::max<plot::i32>(0, y0); y < y1; ++y) {
if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) ++n;
}
if (n > best_n) {
best_n = n;
best = x;
}
}
if (out_count != nullptr) *out_count = best_n;
return best;
}
/// The grid *majors* (0x2A3038 over the 0x101418 background), which no other element of these
/// frames reaches: the minors are 0x1C2126 (too dark for the bound), the crosshair is near-white,
/// a trace is saturated and the cursor is the orange accent.
bool grid_pixel(const plot::u8 px[4]) {
const plot::u8 lo = std::min(px[0], std::min(px[1], px[2]));
const plot::u8 hi = std::max(px[0], std::max(px[1], px[2]));
return px[0] >= 30 && px[0] <= 90 && hi <= 100 && static_cast<plot::u8>(hi - lo) <= 30;
}
/// The orange cursor accent 0xFFB020, at full coverage.
bool cursor_pixel(const plot::u8 px[4]) {
return px[0] > 200 && px[1] > 120 && px[1] < px[0] && px[2] < 90;
}
plot::u32 crosshair_ink_columns(const std::vector<plot::u8>& frame, plot::u32 fb_w,
const plot::Rect& axes) {
const plot::i32 row0 = static_cast<plot::i32>(axes.y) + static_cast<plot::i32>(axes.h) / 2;
plot::u32 cols = 0;
for (plot::i32 x = static_cast<plot::i32>(axes.x); x < static_cast<plot::i32>(axes.right()); ++x) {
if (column_has_ink(frame, fb_w, x, row0, row0 + 2, crosshair_pixel)) ++cols;
}
return cols;
}
} // namespace
/// Spec §17, the style: the crosshair is drawn **dashed** by default -- a passive hover instrument
/// reads as chrome rather than as data -- and the dash is a fragment-shader effect on `v_along`, so
/// nothing about the geometry changes. This case reads the pattern off the pixels: a dashed stroke
/// is many runs along its length, a solid one is a single run, and a longer gap is a longer period,
/// so **fewer** dashes over the same width. `dash_off == 0` is what makes it solid.
PLOT_TEST(crosshair_dash_is_configurable_and_zero_off_means_solid) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
// A warm-up frame, so the strips have settled and `axes` below is a *drawn* frame's rect.
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const plot::Rect axes = p.layout().axes;
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(0u);
// The horizontal line mid-axes, on a *pixel* centre so a 1 device px stroke lands on exactly one
// framebuffer row: the dash is what this case varies, and a fractional coverage would make the
// row's ink ragged for reasons that are not the dash.
const f32 cxp = std::floor(axes.x) + 0.5f + 60.0f;
const f32 cyp = std::floor(axes.y) + 0.5f + 40.0f;
p.set_crosshair(tx.to_data_x(static_cast<f64>(cxp)), ty.to_data_y(static_cast<f64>(cyp)), true);
const auto frame_with = [&](f32 off) {
// Width 1 device px: the 0.75 default's sub-pixel coverage the `crosshair_pixel` predicate (a
// full-coverage colour) would not count uniformly, and the dash is this case's subject.
p.set_crosshair_style({1.0f, 4.0f, off});
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
return read_frame(ctx);
};
const plot::i32 row = static_cast<plot::i32>(cyp);
const plot::i32 x0 = static_cast<plot::i32>(axes.x) + 1;
const plot::i32 x1 = static_cast<plot::i32>(axes.right()) - 1;
const auto runs = [&](f32 off) {
return run_count(ink_row(frame_with(off), kW, x0, x1, row - 1, row + 2, crosshair_pixel));
};
// 4 on / 4 off over the axes' width: many dashes, not one line.
const plot::u32 dashed = runs(4.0f);
PLOT_CHECK(dashed > 4u);
// Off == 0 is solid: one run, the whole width, no gap anywhere.
PLOT_CHECK(runs(0.0f) == 1u);
// A longer gap is a longer period, so fewer dashes over the same width.
const plot::u32 sparse = runs(12.0f);
PLOT_CHECK(sparse > 1u);
PLOT_CHECK(sparse < dashed);
ctx.destroy();
}
/// Spec §17, the stroke: "a **thinner** stroke (~0.75 logical px rather than a trace's line
/// width)". The claim is asserted twice -- as the numbers the layout resolves (what the renderer
/// hands the shader) and as the ink those numbers produce on the real driver -- so a change that
/// kept the number but stopped drawing at it, or the reverse, fails here.
PLOT_TEST(crosshair_is_thinner_than_a_trace) {
const plot::render::Style style;
const plot::Trace trace;
PLOT_CHECK(style.crosshair_width > 0.0f);
PLOT_CHECK(style.crosshair_width < trace.width); // 0.75 < a trace's 1.5
// The resolved stroke: the style's value through `device()`, doubled at `ui_scale` 2, and the
// crosshair's own override where it has one.
plot::Plot p;
p.set_viewport(plot::Rect{0.0f, 0.0f, 600.0f, 320.0f});
p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
p.set_crosshair_style({-1.0f, 1.0e6f, 0.0f}); // width from the style; solid, for measurement
p.set_crosshair(1.0, true); // vertical-only: one line to measure
p.set_ui_scale(1.0f);
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, style.crosshair_width, 1e-6);
p.set_ui_scale(2.0f);
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.0 * style.crosshair_width, 1e-6);
p.set_crosshair_style({1.25f, 1.0e6f, 0.0f});
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.5, 1e-6);
// ... and drawn: the ink width follows the width and the scale.
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
plot::Plot q;
q.set_viewport(vp);
const plot::u32 qx = q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
r.style().crosshair_width = style.crosshair_width;
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
const plot::Rect axes = q.layout().axes;
q.set_crosshair_style({3.0f, 1.0e6f, 0.0f}); // solid, explicit width
q.set_crosshair(q.x_transform(qx).to_data_x(static_cast<f64>(std::floor(axes.x) + 120.5f)), true);
const auto ink_columns = [&](f32 scale) {
r.style().ui_scale = scale;
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> frame = read_frame(ctx);
return crosshair_ink_columns(frame, kW, q.layout().axes);
};
const plot::u32 wide_1x = ink_columns(1.0f);
const plot::u32 wide_2x = ink_columns(2.0f);
PLOT_CHECK(wide_1x > 0u);
PLOT_CHECK(wide_2x > wide_1x); // `ui_scale` 2 doubles every metric, the stroke included
q.set_crosshair_style({1.0f, 1.0e6f, 0.0f});
const plot::u32 thin_1x = ink_columns(1.0f);
PLOT_CHECK(thin_1x >= 1u);
PLOT_CHECK(thin_1x < wide_1x); // a thinner stroke is thinner ink
ctx.destroy();
}
/// Spec §17's shared-pipeline guard: the dash is a *per-draw* uniform and only the crosshair sets
/// it, so every other line -- grid, traces, axis lines, regions, cursors, the legend border -- draws
/// exactly as before. The crosshair is drawn in the middle of the interior passes (after the grid,
/// the axis lines and the traces; before the regions, the cursors and the legend), so an unreset or
/// leaked dash would show up on an element drawn *after* it -- and this case draws one of each kind
/// around it and reads the pixels.
PLOT_TEST(grid_traces_and_cursors_are_unaffected_by_the_dash_uniform) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
// --- the grid and the cursor, with a dashed crosshair drawn between them ----------------
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const plot::u32 cur = p.add_cursor(37.0, plot::Color::hex(0xFFB020));
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const plot::Rect axes = p.layout().axes;
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(0u);
// A dashed crosshair mid-axes, deliberately *between* ticks in x so its vertical line does not
// land on a grid column and muddy the grid reading below.
p.set_crosshair(tx.to_data_x(static_cast<f64>(std::floor(axes.x) + 130.5f)),
ty.to_data_y(static_cast<f64>(std::floor(axes.y) + 50.5f)), true);
p.set_crosshair_style({1.0f, 4.0f, 4.0f}); // a real dash: what a leak would carry
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> frame = read_frame(ctx);
// The cursor is drawn *after* the crosshair, so it is the leak guard that matters: a dash uniform
// that survived the crosshair's flush would break its full-height stroke into a 4/4 pattern.
plot::u32 cursor_rows = 0;
const plot::i32 cursor_col = densest_column(frame, kW, static_cast<plot::i32>(axes.x),
static_cast<plot::i32>(axes.right()),
static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), cursor_pixel,
&cursor_rows);
PLOT_CHECK(cursor_col >= 0);
PLOT_CHECK(cursor_rows >= static_cast<plot::u32>(axes.h) * 3u / 4u); // a solid, full-height line
PLOT_CHECK(run_count(ink_row(frame, kW, cursor_col, cursor_col + 1, static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), cursor_pixel)) == 1u);
// The grid, drawn *before* the crosshair: its columns are solid but for the one row the crosshair's
// own horizontal line crosses them at (the crosshair overdraws it), so at most one gap per column.
plot::u32 grid_rows = 0;
const plot::i32 grid_col = densest_column(frame, kW, static_cast<plot::i32>(axes.x),
static_cast<plot::i32>(axes.right()),
static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), grid_pixel,
&grid_rows);
PLOT_CHECK(grid_col >= 0);
PLOT_CHECK(grid_rows >= static_cast<plot::u32>(axes.h) * 3u / 4u);
PLOT_CHECK(run_count(ink_row(frame, kW, grid_col, grid_col + 1, static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), grid_pixel)) <= 2u);
(void)cur;
// --- the trace, with the same dashed crosshair over it ----------------------------------
plot::Plot q;
q.set_viewport(vp);
q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
std::vector<plot::f32> ramp(1000u);
for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast<plot::f32>(i) * 0.1f;
plot::data::ArraySeries series(ramp, 0.0, 0.1);
// Unnamed: a named trace would draw a legend panel over the axes' top-right corner, which is
// where this rising trace spends its last columns -- a backplate hiding data for a reason that
// is not the dash.
q.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, ""});
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
const plot::Rect q_axes = q.layout().axes;
q.set_crosshair(q.x_transform(0u).to_data_x(static_cast<f64>(std::floor(q_axes.x) + 130.5f)),
q.y_transform(0u).to_data_y(static_cast<f64>(std::floor(q_axes.y) + 50.5f)),
true);
q.set_crosshair_style({1.0f, 4.0f, 4.0f});
// No readout panel: it is a backplate drawn *over* the data, so it would cover trace columns for
// reasons that have nothing to do with the dash this case is about.
q.set_crosshair_readout(false);
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
// Every column of the axes holds trace ink: the polyline rises across the whole width and a dash
// leaked into the trace pipeline would leave the columns in its gaps empty. Four columns of slack
// is where the crosshair's own thin vertical line overdraws the trace.
const plot::u32 covered = covered_columns(ctx, q_axes, static_cast<plot::u32>(q_axes.w));
PLOT_CHECK(covered >= static_cast<plot::u32>(q_axes.w) - 4u);
ctx.destroy();
}
/// Spec §17's phase requirement: a dash whose phase depended on the axis transform would crawl as
/// the view panned and look like noise. It is anchored to the segment start -- and both crosshair
/// lines are staged from an *axes edge* (the vertical from `y_range.min`, the horizontal from
/// `x_range.min`), so the phase is measured from a fixed edge of the rect. The observable form of
/// "stable" is that the same state renders the same bytes twice: no per-frame counter, no
/// time source, no layout drift in the phase.
PLOT_TEST(dash_phase_is_stable_across_frames) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
std::vector<plot::f32> ramp(1000u);
for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast<plot::f32>(i) * 0.1f;
plot::data::ArraySeries series(ramp, 0.0, 0.1);
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, "ramp"});
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const plot::Rect axes = p.layout().axes;
p.set_crosshair(p.x_transform(ax).to_data_x(static_cast<f64>(std::floor(axes.x) + 130.5f)),
p.y_transform(0u).to_data_y(static_cast<f64>(std::floor(axes.y) + 50.5f)), true);
p.set_crosshair_style({1.0f, 4.0f, 4.0f});
const auto render = [&]() {
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
return read_frame(ctx);
};
const std::vector<plot::u8> first = render();
const std::vector<plot::u8> second = render();
PLOT_CHECK(first.size() == second.size());
PLOT_CHECK(first == second);
ctx.destroy();
}
// ------------------------------------------------- B1: relative-x staging (epoch axes)
/// The B1 defect: gathering and staging an absolute epoch x as f32 quantised it to
/// a grid coarser than a pixel (f32 carries 24 bits -- about 128 s at 1.7e9), so an
/// epoch-window trace collapsed onto a few columns while its zero-origin twin drew
/// correctly. The fix stages `to_space(x_abs) - to_space(origin)` -- O(visible
/// pixels) -- and folds the origin into the offset uniform in f64, so the two
/// windows rasterise to the same geometry.
///
/// Both plots below draw the *same samples* with the same width; the only
/// difference is the series' `x0()` and the axis window's offset. The assertion is
/// the per-column trace-ink pattern: identical column sets, not merely similar
/// counts, which a quantised draw could not satisfy.
namespace {
/// Columns (every x of the axes) that hold at least one trace-coloured pixel.
std::vector<bool> trace_column_mask(plottest::HeadlessContext& ctx, const plot::Rect& a) {
const plot::u32 first_row = static_cast<plot::u32>(a.y);
const plot::u32 rows = static_cast<plot::u32>(a.h);
std::vector<bool> mask(a.w > 0.0f ? static_cast<std::size_t>(a.w) : 1u, false);
for (plot::u32 x = 0; x < static_cast<plot::u32>(a.w); ++x) {
const plot::u32 px_x = static_cast<plot::u32>(a.x) + x;
for (plot::u32 yy = 0; yy < rows; ++yy) {
plot::u8 px[4] = {};
if (ctx.read_pixel(px_x, ctx.height - 1u - (first_row + yy), px) && trace_pixel(px)) {
mask[x] = true;
break;
}
}
}
return mask;
}
} // namespace
PLOT_TEST(epoch_window_and_zero_origin_twin_rasterise_identically) {
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));
// A sparse sine: one raw sample per column, so every stage of the pipeline is
// exercised at pixel resolution -- the f32 defect shows on the x coordinates.
std::vector<plot::f32> ys(640u);
for (plot::u32 i = 0; i < ys.size(); ++i) {
ys[i] = 0.8f * std::sin(0.05f * static_cast<plot::f32>(i));
}
const f64 epoch = 1'700'000'000.0;
plot::data::ArraySeries zero(ys, 0.0, 1.0);
plot::data::ArraySeries shifted(ys, epoch, 1.0);
plot::Plot p0;
p0.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax0 = p0.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay0 = p0.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p0.add_trace({&zero, plot::Color::hex(0x40C0FF), 1.5f, ax0, ay0});
plot::Plot p1;
p1.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax1 =
p1.add_x_axis({"t", {epoch, epoch + 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay1 = p1.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p1.add_trace({&shifted, plot::Color::hex(0x40C0FF), 1.5f, ax1, ay1});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p0);
r.end_frame();
const std::vector<bool> columns_zero = trace_column_mask(ctx, p0.layout().axes);
r.begin_frame(kW, kH, vp);
r.draw(p1);
r.end_frame();
const std::vector<bool> columns_epoch = trace_column_mask(ctx, p1.layout().axes);
PLOT_CHECK(columns_zero == columns_epoch);
// The premise: the twin really drew a trace across the axes (the comparison is
// not vacuously true on two empty frames).
PLOT_CHECK(std::count(columns_zero.begin(), columns_zero.end(), true) >
static_cast<std::size_t>(p0.layout().axes.w) * 9u / 10u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- B6: two text batches
/// The B6 defect: one text batch, flushed after everything, put the legend's
/// names *over* the crosshair's readout panel -- text above the backplate that
/// exists to occlude it. The under batch (legend names included) is now flushed
/// immediately before the panel, so a legend name the panel covers is occluded.
///
/// The scene: a legend in the bottom-left corner with three wide names, and a
/// crosshair parked just inside that corner, whose readout panel flips up and
/// right -- on top of the legend rows. The assertion reads the legend name boxes
/// *minus* every rect the readout legitimately paints (its own label and value
/// runs, and the crosshair's strokes): what is left of a covered name box must
/// hold no text ink with the readout on, and held ink without it.
namespace {
/// Text ink inside `area`, skipping every pixel inside any of `exclude`.
plot::u32 text_ink_outside(const std::vector<plot::u8>& frame, const plot::Rect& area,
const std::vector<plot::Rect>& exclude) {
plot::u32 hit = 0;
const plot::u32 x0 = static_cast<plot::u32>(std::max(0.0f, area.x));
const plot::u32 y0 = static_cast<plot::u32>(std::max(0.0f, area.y));
const plot::u32 x1 = static_cast<plot::u32>(std::min(area.right(), kFrameRect.right()));
const plot::u32 y1 = static_cast<plot::u32>(std::min(area.bottom(), kFrameRect.bottom()));
for (plot::u32 y = y0; y < y1; ++y) {
for (plot::u32 x = x0; x < x1; ++x) {
bool skipped = false;
for (const plot::Rect& e : exclude) {
if (e.contains(static_cast<plot::f32>(x) + 0.5f, static_cast<plot::f32>(y) + 0.5f)) {
skipped = true;
break;
}
}
if (skipped) continue;
if (text_pixel(&frame[(plot::usize(y) * kW + x) * 4u])) ++hit;
}
}
return hit;
}
} // namespace
PLOT_TEST(readout_panel_occludes_the_legend_names_it_covers) {
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::Plot p;
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::Legend legend;
legend.place = plot::LegendPlace::BottomLeft;
p.set_legend(legend);
// Flat traces at distinct heights, wide names: three tall legend rows.
plot::data::ArraySeries a(std::vector<plot::f32>(640u, 0.7f), 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
plot::data::ArraySeries c(std::vector<plot::f32>(640u, 0.3f), 0.0, 1.0);
p.add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "channel one"});
p.add_trace({&b, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "channel two long name"});
p.add_trace({&c, plot::Color::hex(0x50D050), 1.5f, ax, ay, "channel three"});
// Warm-up frame (installs the text backend), then the legend-only frame.
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> legend_only = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
PLOT_CHECK(layout.legend.rows.size() == 3u);
PLOT_CHECK(layout.legend.panel.w > 0.0f);
// The crosshair just inside the bottom-left corner: its panel flips up and
// right, over the legend rows.
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
p.set_crosshair(tx.to_data_x(layout.axes.x + 30.0f),
ty.to_data_y(layout.axes.bottom() - 30.0f), true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> with_readout = read_frame(ctx);
// Fresh layout: the crosshair's own geometry only exists now that it is placed
// (the copy above was taken before `set_crosshair`).
const plot::CrosshairLayout crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.active);
PLOT_CHECK(crosshair.panel.w > 0.0f);
// The exclusions: the readout's own runs and the crosshair's strokes.
std::vector<plot::Rect> exclude;
for (const plot::CrosshairRow& row : crosshair.rows) {
if (row.listed) {
exclude.push_back(row.label_box);
exclude.push_back(row.value_box);
exclude.push_back(row.swatch); // the row's own swatch fill
}
}
exclude.push_back(crosshair.vertical);
exclude.push_back(crosshair.horizontal);
// The premise: the panel really does cover at least one legend name box.
bool covers = false;
for (const plot::LegendRow& row : layout.legend.rows) {
if (row.ellipsis) continue;
if (crosshair.panel.x < row.name.right() && row.name.x < crosshair.panel.right() &&
crosshair.panel.y < row.name.bottom() && row.name.y < crosshair.panel.bottom()) {
covers = true;
}
}
PLOT_CHECK(covers);
// Each legend name box: ink without the readout; in the part the panel covers,
// none of it left (outside the readout's own runs) once the panel is over it.
// The uncovered remainder of a partially covered box keeps its ink -- that is
// the panel doing its job and nothing more.
bool checked = false;
for (const plot::LegendRow& row : layout.legend.rows) {
const bool overlaps = crosshair.panel.x < row.name.right() &&
row.name.x < crosshair.panel.right() &&
crosshair.panel.y < row.name.bottom() &&
row.name.y < crosshair.panel.bottom();
if (row.ellipsis || !overlaps) continue;
// Inset past the panel's own border ring: the border is a hairline of the
// bright crosshair colour (see draw_readout_panel), which the ink predicate
// cannot tell from text -- it is the panel's chrome, not legend ink.
const plot::Rect inner{crosshair.panel.x + 2.0f, crosshair.panel.y + 2.0f,
crosshair.panel.w - 4.0f, crosshair.panel.h - 4.0f};
const plot::Rect covered{
std::max(inner.x, row.name.x), std::max(inner.y, row.name.y),
std::min(inner.right(), row.name.right()) - std::max(inner.x, row.name.x),
std::min(inner.bottom(), row.name.bottom()) - std::max(inner.y, row.name.y)};
PLOT_CHECK(covered.w > 0.0f && covered.h > 0.0f);
PLOT_CHECK(text_ink_outside(legend_only, covered, {}) > 0u);
PLOT_CHECK(text_ink_outside(with_readout, covered, exclude) == 0u);
checked = true;
}
PLOT_CHECK(checked);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- markers
/// Markers are staged as line segments through the trace's own batch: a
/// marker'd trace lights pixels a plain line does not (the shapes extend past
/// the stroke), and the stride is literal -- `marker_every = 3` stages exactly a
/// third of the markers of `marker_every = 1` over the same window.
PLOT_TEST(markers_light_more_than_the_line_and_stride_is_literal) {
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));
// 32 sparse points: the auto stride would be ~1 marker per point anyway, so
// `marker_every = 1` vs `3` differ by construction.
std::vector<plot::f32> ys(32u);
for (plot::u32 i = 0; i < ys.size(); ++i) {
ys[i] = 0.5f + 0.4f * std::sin(0.9f * static_cast<plot::f32>(i));
}
plot::data::ArraySeries s(ys, 0.0, 20.0);
plot::Plot plain_p;
plain_p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = plain_p.add_x_axis({"t", {0.0, 620.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = plain_p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::Plot marked_p = plain_p;
plot::Plot every3_p = plain_p;
marked_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 1u});
every3_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 3u});
plain_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(plain_p);
r.end_frame();
const plot::u32 plain_ink = predicate_pixels(read_frame(ctx), kW, plain_p.layout().axes,
magenta_pixel);
r.begin_frame(kW, kH, vp);
r.draw(marked_p);
r.end_frame();
const plot::u32 marked_ink = predicate_pixels(read_frame(ctx), kW, marked_p.layout().axes,
magenta_pixel);
const plot::u32 marked_count = r.stats().staged_markers;
r.begin_frame(kW, kH, vp);
r.draw(every3_p);
r.end_frame();
const plot::u32 every3_count = r.stats().staged_markers;
// The premise: the line drew something; the markers drew strictly more.
PLOT_CHECK(plain_ink > 0u);
PLOT_CHECK(marked_ink > plain_ink);
// The stride is literal: a third of the markers, ±1 for the window's edge
// sample count.
PLOT_CHECK(marked_count >= 30u);
PLOT_CHECK(every3_count * 3u + 2u >= marked_count);
PLOT_CHECK(every3_count * 3u <= marked_count + 2u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- text colour
/// The readout's VALUE column draws in the row trace's own colour (resolved from
/// the plot at draw time), the labels in the axis text colour: per-instance
/// `a_colour` instead of one text tint per draw.
PLOT_TEST(readout_value_draws_in_the_row_trace_colour) {
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::Plot p;
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
const auto magenta = plot::Color::hex(0xFF5090);
p.add_trace({&s, magenta, 1.5f, ax, ay, "row"});
p.set_crosshair(300.0, 0.5, true);
p.set_crosshair_readout(true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].listed);
PLOT_CHECK(crosshair.rows[0].value_box.w > 0.0f);
// The value box holds pixels the row trace's colour explains (magenta
// dominance at any blend over the dark panel) -- the old single-tint pipeline
// drew every glyph in the axis text grey, so this is the defect's own pixel.
plot::u32 coloured = 0;
const plot::Rect box = crosshair.rows[0].value_box;
for (plot::u32 y = static_cast<plot::u32>(box.y); y < static_cast<plot::u32>(box.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(box.x); x < static_cast<plot::u32>(box.right());
++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (magenta_pixel(px)) ++coloured;
}
}
PLOT_CHECK(coloured > 0u);
// The label box, same frame, is the axis text colour, not the trace's.
plot::u32 label_grey = 0;
const plot::Rect labels = crosshair.rows[0].label_box;
for (plot::u32 y = static_cast<plot::u32>(labels.y); y < static_cast<plot::u32>(labels.bottom());
++y) {
for (plot::u32 x = static_cast<plot::u32>(labels.x); x < static_cast<plot::u32>(labels.right());
++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (text_pixel(px)) ++label_grey;
}
}
PLOT_CHECK(label_grey > 0u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- R6: GL state residue and shutdown
/// `begin_frame()` saves the host's blend function and enable bit; `end_frame()`
/// hands both back -- a host that blends its own way before the frame must not
/// inherit the frame's `SRC_ALPHA / ONE_MINUS_SRC_ALPHA`.
PLOT_TEST(a_frame_hands_the_blend_state_back) {
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::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", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// The host's own blend: function ONE/ZERO, enabled.
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_ONE, plot::gl::GL_ZERO);
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
plot::gl::GLint src = -1;
plot::gl::GLint dst = -1;
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_SRC_RGB, &src);
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_DST_RGB, &dst);
PLOT_CHECK(src == static_cast<plot::gl::GLint>(plot::gl::GL_ONE));
PLOT_CHECK(dst == static_cast<plot::gl::GLint>(plot::gl::GL_ZERO));
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) != plot::gl::GL_FALSE);
// And with blending off, it comes back off.
plot::gl::glDisable(plot::gl::GL_BLEND);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) == plot::gl::GL_FALSE);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
/// `release_gl()` destroys every GL handle while the context is current: after
/// it the renderer is uninitialised, the driver holds no plot objects (a
/// re-init and frame still draw, which is the observable half), and the same
/// renderer object can be brought back.
PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
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::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", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
r.release_gl();
PLOT_CHECK(!r.ready());
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
// Re-init on the same object draws again -- the handles were released, not
// half-destroyed.
PLOT_CHECK(r.init(&st));
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// The other orientation, in a figure: a horizontal ruler is per row (its `y` is on that
// row's y axis, so two rows' rulers are two different values), and every row's has to be
// drawn in its own band. The vertical case above covers the shared-time half; this is the
// half where each row owns its instrument, which is what an app's "two rulers per plot"
// needs -- and which the app found undrawn.
PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const std::vector<plot::f32> samples = wave(101u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
// A strongly red ruler and a strongly green one, so a pixel that is red- or
// green-dominant inside a row's axes can only be that row's ink: the trace is blue and
// the chrome grey (all three channels equal).
const plot::Color red = plot::Color::hex(0xFF2020);
const plot::Color green = plot::Color::hex(0x20FF20);
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
// The harness's no-atlas backend: a ruler always writes its value as text, and this case
// is about the *stroke*, so the text is asked for and staged as nothing. It goes on the
// *renderer*, not on the plots: `draw(Figure&)` replaces each row's backend with the
// renderer's own on the first frame, so a plot-level backend would be swapped out under a
// batch that had already declared that backend's (absent) cell.
OnePxPerStringBackend no_text;
r.set_text_backend(&no_text);
plot::Figure figure(3u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 row = 0; row < 3u; ++row) {
plot::Plot& p = figure.plot(row);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay =
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
// A quarter and three quarters up this row's own range: inside its axes whatever the
// scale is, which is the point of a ruler being per row.
p.add_h_ruler(0.25 * y_scales[row], red);
p.add_h_ruler(0.75 * y_scales[row], green);
}
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, "figure_h_rulers.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
// Rows of the frame that hold a fully covered red or green line, and which row's band
// each falls in. The predicate demands full coverage exactly as the vertical case does.
const auto lines_in = [&](const plot::Rect& area, bool want_red) {
plot::u32 found = 0;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
plot::u32 run = 0;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
const bool ok = want_red ? (px[0] >= 200 && px[1] <= 80 && px[2] <= 80)
: (px[1] >= 200 && px[0] <= 80 && px[2] <= 80);
if (ok) ++run;
}
if (run > (area.w * 3u) / 4u) ++found;
}
return found;
};
const plot::FigureLayout layout = figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK(lines_in(axes, true) >= 1u); // this row's own A ruler
PLOT_CHECK(lines_in(axes, false) >= 1u); // and its B ruler
}
(void)layout;
}