Files
plotter/tests/test_render_smoke.cpp
T

2049 lines
94 KiB
C++

// 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/render/renderer.hpp>
#include <atomic>
#include <cmath>
#include <csignal>
#include <cstdlib>
#include <cstdio>
#include <sys/wait.h>
#include <unistd.h>
#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];
}
bool text_pixel(const plot::u8 px[4]) { return px[0] > 180 && px[1] > 180 && px[2] > 180; }
/// 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);
}
/// 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_;
}
/// 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);
program.set_color(text);
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 columns: `[first, last]`, both inclusive, in framebuffer x.
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 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;
}
/// 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();
}
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();
}
// 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 is the title colour.
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;
};
// 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}));
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}));
// ... 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));
PLOT_CHECK(area_has_title(layout.y_title_area));
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});
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
plot.add_y_axis({title, {-1.0, 1.0}, 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);
// ... 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 exactly one column (the label strip
// takes 51.2 px of the 85 px allowance, leaving 33.8 px against the 19.7 px a
// column needs); the assertions below re-derive that rather than assume it.
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});
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});
p.add_y_axis({"watts", {-1.0, 1.0}, 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();
}
// 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();
}
// "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();
}