Files
plotter/tests/test_primitives.cpp
T
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00

868 lines
42 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// CPU-side contract of the render layer: the staging batches that feed the
// instanced line and text pipelines, and the embedded font's metrics and atlas.
// Nothing here touches GL, so it runs on any machine that can build the tests.
#include "harness.hpp"
#include <plot/render/detail/text_scale.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/primitives.hpp>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <limits>
#include <string>
#include <sys/wait.h>
#include <vector>
PLOT_TEST(line_batch_emits_two_verts_per_segment) {
plot::render::LineBatch b;
b.begin();
const plot::Vec2 pts[3] = {{0, 0}, {1, 1}, {2, 0}};
b.add_polyline(pts, 3); // 2 segments
PLOT_CHECK(b.vertex_count() == 4);
b.clear();
PLOT_CHECK(b.vertex_count() == 0);
PLOT_CHECK(b.data().capacity() >= 4); // clear() must not free capacity
b.add_segment({0, 0}, {1, 1});
PLOT_CHECK(b.vertex_count() == 2);
}
PLOT_TEST(line_batch_stages_segment_endpoints) {
plot::render::LineBatch b;
b.begin();
b.add_segment({1.0f, 2.0f}, {3.0f, 4.0f});
PLOT_CHECK(b.data().size() == 4); // x0,y0,x1,y1 for the one instance
PLOT_CHECK_NEAR(b.data()[0], 1.0, 0.0);
PLOT_CHECK_NEAR(b.data()[1], 2.0, 0.0);
PLOT_CHECK_NEAR(b.data()[2], 3.0, 0.0);
PLOT_CHECK_NEAR(b.data()[3], 4.0, 0.0);
// A polyline stages one instance per consecutive pair, in order.
const plot::Vec2 pts[3] = {{0.0f, 0.0f}, {1.0f, 0.0f}, {1.0f, 1.0f}};
b.begin();
b.add_polyline(pts, 3);
PLOT_CHECK(b.data().size() == 8);
PLOT_CHECK_NEAR(b.data()[2], 1.0, 0.0);
PLOT_CHECK_NEAR(b.data()[4], 1.0, 0.0);
PLOT_CHECK_NEAR(b.data()[5], 0.0, 0.0);
PLOT_CHECK_NEAR(b.data()[6], 1.0, 0.0);
PLOT_CHECK_NEAR(b.data()[7], 1.0, 0.0);
}
// `add_polyline_xy` takes the layout `Series::gather` appends to, so this pins
// that a gathered window becomes the segments it says it does -- an off-by-one in
// the stride here would draw a diagonal where the data has a corner.
PLOT_TEST(line_batch_stages_interleaved_points) {
plot::render::LineBatch b;
b.begin();
const plot::f32 xy[8] = {0.0f, 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f};
b.add_polyline_xy(xy, 4); // 3 segments
PLOT_CHECK(b.segment_count() == 3);
PLOT_CHECK(b.vertex_count() == 6);
PLOT_CHECK(b.data().size() == 12);
PLOT_CHECK_NEAR(b.data()[0], 0.0, 0.0);
PLOT_CHECK_NEAR(b.data()[1], 1.0, 0.0);
PLOT_CHECK_NEAR(b.data()[2], 2.0, 0.0);
PLOT_CHECK_NEAR(b.data()[3], 3.0, 0.0);
PLOT_CHECK_NEAR(b.data()[10], 6.0, 0.0);
PLOT_CHECK_NEAR(b.data()[11], 7.0, 0.0);
// Fewer than two points, or none: nothing to draw, and nothing to crash on.
b.clear();
b.add_polyline_xy(xy, 1);
b.add_polyline_xy(nullptr, 4);
PLOT_CHECK(b.vertex_count() == 0);
}
PLOT_TEST(line_batch_ignores_degenerate_polylines) {
plot::render::LineBatch b;
b.begin();
const plot::Vec2 one[1] = {{5.0f, 5.0f}};
b.add_polyline(one, 1); // no pair -> no instance
b.add_polyline(nullptr, 0);
PLOT_CHECK(b.vertex_count() == 0);
PLOT_CHECK(b.data().empty());
b.add_polyline(nullptr, 3); // null with a count: still no deref
PLOT_CHECK(b.vertex_count() == 0);
}
PLOT_TEST(line_batch_reuses_capacity_across_frames) {
plot::render::LineBatch b;
const plot::Vec2 pts[5] = {{0, 0}, {1, 1}, {2, 0}, {3, 1}, {4, 0}};
b.begin();
b.add_polyline(pts, 5); // 4 segments -> 16 floats
const std::size_t cap = b.data().capacity();
const f32* store = b.data().data();
PLOT_CHECK(cap >= 16);
b.clear();
PLOT_CHECK(b.vertex_count() == 0);
PLOT_CHECK(b.data().capacity() == cap); // clear() keeps the store
b.begin();
b.add_polyline(pts, 5); // same workload: must not reallocate
PLOT_CHECK(b.data().size() == 16);
PLOT_CHECK(b.data().capacity() == cap);
PLOT_CHECK(b.data().data() == store);
}
namespace {
using plot::render::EmbeddedMonoFont;
/// The coverage byte of one texel of `slot`'s cell: 0 background, 255 full ink.
plot::u8 cell_texel(const EmbeddedMonoFont& font, plot::u32 slot, plot::u32 col, plot::u32 row) {
const plot::Vec2 origin = EmbeddedMonoFont::atlas_origin(slot);
const plot::u32 x = static_cast<plot::u32>(origin.x) + col;
const plot::u32 y = static_cast<plot::u32>(origin.y) + row;
const std::size_t index = static_cast<std::size_t>(y) * font.atlas_width() + x;
return font.atlas_image()[index];
}
/// Total coverage of one cell: zero means the cell rasterised blank.
plot::u32 cell_ink_units(const EmbeddedMonoFont& font, plot::u32 slot) {
plot::u32 total = 0u;
for (plot::u32 y = 0; y < EmbeddedMonoFont::cell_height(); ++y) {
for (plot::u32 x = 0; x < EmbeddedMonoFont::cell_width(); ++x) {
total += cell_texel(font, slot, x, y);
}
}
return total;
}
/// The fallback slot, found by the marker codepoint rather than assumed.
plot::u32 fallback_slot() {
for (plot::u32 slot = 0; slot < EmbeddedMonoFont::glyph_count(); ++slot) {
if (EmbeddedMonoFont::codepoint_of_slot(slot) == EmbeddedMonoFont::kFallbackCodepoint) {
return slot;
}
}
return EmbeddedMonoFont::glyph_count();
}
/// The plot library's own source root, so the generator test can run the committed
/// script against the committed output. Injected by tests/CMakeLists.txt.
constexpr const char* kSourceDir = PLOT_SOURCE_DIR;
} // namespace
// The embedded atlas' metrics: a line taller than its ascent (descenders need the
// room), a non-empty advance, and -- the point of the face -- a fixed advance, so
// a label's width is its length and nothing else. A text backend with variable
// advances is the tile the plot library deliberately does not lay out (see the
// spec's non-goals), so `i` and `W` measure the same and a caller cannot be
// surprised by a label's reserved strip.
PLOT_TEST(embedded_font_metrics_are_sane) {
const EmbeddedMonoFont f;
PLOT_CHECK(f.height() > f.ascent());
PLOT_CHECK(f.ascent() > 0.0f);
PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6);
PLOT_CHECK_NEAR(f.measure(nullptr), 0.0, 1e-6);
// One advance per codepoint, whatever the glyph: this is `i` and `W` alike.
PLOT_CHECK_NEAR(f.measure("i"), f.measure("W"), 1e-6);
PLOT_CHECK_NEAR(f.measure("i"), EmbeddedMonoFont::advance_codepoint('i'), 1e-6);
// Monotone in length, and exactly the sum of the advances it stages.
PLOT_CHECK(f.measure("ab") < f.measure("abc"));
PLOT_CHECK(f.measure("abc") < f.measure("abcd"));
PLOT_CHECK_NEAR(f.measure("12.5 ms"),
(f.glyph('1')->advance + f.glyph('2')->advance + f.glyph('.')->advance +
f.glyph('5')->advance + f.glyph(' ')->advance + f.glyph('m')->advance +
f.glyph('s')->advance),
1e-6);
// The em is the size the table was baked at, and the scale the renderer draws
// with is a ratio of it -- so it must be a positive number and not `height()`.
PLOT_CHECK_NEAR(f.em_px(), static_cast<plot::f32>(EmbeddedMonoFont::kEmPx), 1e-6);
PLOT_CHECK(f.em_px() > 0.0f);
}
// Every codepoint the atlas claims to cover has its own cell, the cell is inside
// the atlas and is the declared cell, and its ink is non-empty -- the generator
// refuses to emit a blank cell for a covered codepoint, and this pins that the
// committed atlas is the generator's output and not a hand edit. U+0020 is the
// one documented blank: a space has no ink by definition and still advances.
PLOT_TEST(embedded_atlas_covers_every_expected_codepoint) {
const EmbeddedMonoFont f;
const plot::Vec2 cell = f.cell_uv();
PLOT_CHECK_NEAR(cell.x, static_cast<plot::f32>(EmbeddedMonoFont::cell_width()) /
static_cast<plot::f32>(f.atlas_width()),
1e-9);
PLOT_CHECK_NEAR(cell.y, static_cast<plot::f32>(EmbeddedMonoFont::cell_height()) /
static_cast<plot::f32>(f.atlas_height()),
1e-9);
PLOT_CHECK(f.atlas_pixels() != nullptr);
std::vector<plot::u32> expected;
for (plot::u32 cp = 0x20u; cp <= 0x7Eu; ++cp) expected.push_back(cp);
for (plot::u32 cp : {0x00B0u, 0x00B5u, 0x03A9u, 0x2013u, 0x2014u}) expected.push_back(cp);
for (plot::u32 cp : expected) {
const plot::u32 slot = EmbeddedMonoFont::glyph_index(cp);
PLOT_CHECK(slot < EmbeddedMonoFont::glyph_count());
PLOT_CHECK(EmbeddedMonoFont::codepoint_of_slot(slot) == cp);
const plot::render::Glyph* g = f.glyph(cp);
PLOT_CHECK(g != nullptr);
// The rect is the atlas cell, and it lies inside the atlas.
PLOT_CHECK(plot::render::glyph_is_cell(*g, cell));
PLOT_CHECK(g->u0 >= 0.0f && g->u1 <= 1.0f && g->v0 >= 0.0f && g->v1 <= 1.0f);
if (cp == 0x20u) {
PLOT_CHECK(cell_ink_units(f, slot) == 0u); // a space is blank ...
PLOT_CHECK(g->advance > 0.0f); // ... and still advances
} else {
PLOT_CHECK(cell_ink_units(f, slot) > 0u); // a covered glyph has ink
}
}
// The fallback box is a real cell with real ink, at a fixed slot, and every
// codepoint outside the coverage set draws it -- so a label the atlas cannot
// spell is visible rather than silently blank.
const plot::u32 fallback = fallback_slot();
PLOT_CHECK(fallback < EmbeddedMonoFont::glyph_count());
PLOT_CHECK(cell_ink_units(f, fallback) > 0u);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x4E00u) == fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0xFFFFu) == fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(plot::render::kInvalidCodepoint) == fallback);
PLOT_CHECK(f.glyph(0x4E00u)->advance > 0.0f);
}
// The *shipped table* -- not the generator, and not a lookup -- is what the
// renderer uploads, so the coverage and quality contract is asserted on it
// directly rather than only on the codepoint lookups above. Slot by slot, the
// atlas holds exactly the documented codepoints in order, every cell is the one
// declared size and lies inside the image, every cell but the space carries ink,
// and the fallback box is a real inked glyph at its fixed final slot.
//
// It is the case the drift guard beside it cannot replace: `--check` compares the
// committed bytes to a bake made with whatever coverage list the generator
// currently holds, so dropping a codepoint from that list keeps it green. These
// assertions are what make a dropped codepoint (say U+00B5) or a blank cell fail
// loudly, on the data that actually ships.
PLOT_TEST(embedded_atlas_matches_its_documented_coverage) {
const EmbeddedMonoFont f;
const plot::Vec2 cell = f.cell_uv();
// The documented coverage set, in the documented slot order: printable ASCII,
// the three codepoints `plot::format_si` emits plus the two label dashes, then
// the fallback box appended last.
std::vector<plot::u32> documented;
for (plot::u32 cp = 0x20u; cp <= 0x7Eu; ++cp) documented.push_back(cp);
for (plot::u32 cp : {0x00B0u, 0x00B5u, 0x03A9u, 0x2013u, 0x2014u}) documented.push_back(cp);
PLOT_CHECK(EmbeddedMonoFont::glyph_count() == documented.size() + 1u);
const plot::u8* atlas = f.atlas_pixels();
const plot::u32 atlas_w = f.atlas_width();
const plot::u32 atlas_h = f.atlas_height();
const std::size_t atlas_texels = static_cast<std::size_t>(atlas_w) * atlas_h;
plot::u32 inked_cells = 0u;
for (plot::u32 slot = 0; slot < EmbeddedMonoFont::glyph_count(); ++slot) {
const plot::u32 expected =
slot < documented.size() ? documented[slot] : EmbeddedMonoFont::kFallbackCodepoint;
const plot::u32 cp = EmbeddedMonoFont::codepoint_of_slot(slot);
PLOT_CHECK(cp == expected); // the table is the documented one, in order
if (cp != EmbeddedMonoFont::kFallbackCodepoint) {
PLOT_CHECK(EmbeddedMonoFont::glyph_index(cp) == slot);
}
// Its rect is the one declared cell, and it lies inside the image ...
const plot::render::Glyph* g = f.glyph(cp);
PLOT_CHECK(g != nullptr);
PLOT_CHECK(plot::render::glyph_is_cell(*g, cell));
PLOT_CHECK(g->u0 >= 0.0f && g->u1 <= 1.0f && g->v0 >= 0.0f && g->v1 <= 1.0f);
// ... and the cell's texels are all inside the image with ink in them, bar
// the one documented blank. The bounds check is what a table claiming more
// bytes than it ships fails on.
const plot::Vec2 origin = EmbeddedMonoFont::atlas_origin(slot);
const plot::u32 ox = static_cast<plot::u32>(origin.x);
const plot::u32 oy = static_cast<plot::u32>(origin.y);
PLOT_CHECK(ox + EmbeddedMonoFont::cell_width() <= atlas_w);
PLOT_CHECK(oy + EmbeddedMonoFont::cell_height() <= atlas_h);
plot::u32 ink = 0u;
for (plot::u32 y = 0; y < EmbeddedMonoFont::cell_height(); ++y) {
for (plot::u32 x = 0; x < EmbeddedMonoFont::cell_width(); ++x) {
const std::size_t index = static_cast<std::size_t>(oy + y) * atlas_w + (ox + x);
PLOT_CHECK(index < atlas_texels);
ink += atlas[index];
}
}
if (cp == 0x20u) {
PLOT_CHECK(ink == 0u); // the space is the one blank cell
} else {
PLOT_CHECK(ink > 0u); // every covered glyph, and the box, has ink
++inked_cells;
}
}
PLOT_CHECK(inked_cells == EmbeddedMonoFont::glyph_count() - 1u);
// The fallback sits at the fixed final slot, as documented: a regeneration that
// inserted or dropped a glyph would move it, and this catches that where the
// lookups alone would not.
PLOT_CHECK(fallback_slot() == EmbeddedMonoFont::glyph_count() - 1u);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x10FFFFu) == fallback_slot());
}
// The atlas carries more than one coverage value: an anti-aliased raster has grey
// edges, and a 1-bit table could only ever be 0 or 255. This is the data-level
// half of the anti-aliasing evidence (the frame-level half is in the render
// smoke test): a table that passed through a threshold would fail it.
PLOT_TEST(embedded_atlas_holds_antialiased_coverage) {
const EmbeddedMonoFont f;
const plot::u8* atlas = f.atlas_pixels();
const std::size_t size = static_cast<std::size_t>(f.atlas_width()) * f.atlas_height();
plot::u32 partial = 0u;
plot::u32 full = 0u;
plot::u32 blank = 0u;
for (std::size_t i = 0; i < size; ++i) {
if (atlas[i] == 0u) ++blank;
else if (atlas[i] == 255u) ++full;
else ++partial;
}
PLOT_CHECK(full > 0u);
PLOT_CHECK(blank > 0u);
// Every glyph's stroke edge contributes: the partial population is a large
// fraction of the inked texels, not a handful of rounding artefacts.
PLOT_CHECK(partial > full / 2u);
}
// The face is monospaced, so every codepoint's advance is the same -- but it is
// the font's own `hmtx` advance carried through the bake, not a constant invented
// here: `advance_codepoint` and the per-glyph `advance` agree, and the whitespace
// glyphs that must never be zero are not.
PLOT_TEST(embedded_font_advance_matches_the_font_units) {
const EmbeddedMonoFont f;
const plot::f32 advance = EmbeddedMonoFont::advance_codepoint('A');
PLOT_CHECK(advance > 0.0f);
PLOT_CHECK(EmbeddedMonoFont::advance_codepoint(' ') > 0.0f);
const char* samples = "iW.1_m ";
for (const char* c = samples; *c != '\0'; ++c) {
PLOT_CHECK_NEAR(f.glyph(static_cast<plot::u32>(*c))->advance, advance, 1e-6);
}
// `advance_codepoint` is a lookup, not a walk: it agrees with `measure` for a
// single decoded codepoint, and disagrees with nothing in the table.
PLOT_CHECK_NEAR(f.measure("W"), EmbeddedMonoFont::advance_codepoint('W'), 1e-6);
for (plot::u32 cp = 0x20u; cp <= 0x7Eu; ++cp) {
PLOT_CHECK_NEAR(f.glyph(cp)->advance, EmbeddedMonoFont::advance_codepoint(cp), 1e-6);
}
}
// Decoding and the fallback, on the codepoints `plot::format_si` and a plotting
// label actually emit: the three SI symbols are real glyphs, everything else is
// the visible fallback box, and a malformed byte is not measured as zero.
PLOT_TEST(embedded_font_decodes_si_units_and_falls_back_visibly) {
const EmbeddedMonoFont f;
const plot::u32 fallback = fallback_slot();
const f32 micro = f.measure("\xC2\xB5s");
const f32 degree = f.measure("\xC2\xB0");
const f32 ohm = f.measure("\xCE\xA9");
PLOT_CHECK(micro > 0.0f);
PLOT_CHECK(degree > 0.0f);
PLOT_CHECK(ohm > 0.0f);
PLOT_CHECK_NEAR(micro, EmbeddedMonoFont::advance_codepoint(0x00B5u) + f.glyph('s')->advance, 1e-6);
// Each maps to its own cell, not to the box.
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B5u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B0u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x03A9u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B5u) != EmbeddedMonoFont::glyph_index(0x00B0u));
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B0u) != EmbeddedMonoFont::glyph_index(0x03A9u));
// The two dashes the coverage adds are real cells too, not the box.
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x2013u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x2014u) != fallback);
// A CJK codepoint and a bare malformed byte both draw the box, and both
// advance: a missing glyph is visible and never a zero-width hole.
PLOT_CHECK_NEAR(f.measure("\xE4\xB8\x80"), EmbeddedMonoFont::advance_codepoint(0x4E00u), 1e-6);
PLOT_CHECK_NEAR(f.measure("\x80"), EmbeddedMonoFont::advance_codepoint(0x4E00u), 1e-6);
}
// The atlas must be left-to-right in absolute terms. The coverage bytes are what
// the GPU samples, so a generator that rasterised with the opposite x sense -- or
// an atlas packed with the cells reversed -- would keep every rect check green
// while drawing mirrored text. 'A' cannot catch that (it is symmetric about its
// vertical axis); 'L', 'b' and '/' can, and they are asserted against absolute
// texel coordinates rather than against the table itself.
PLOT_TEST(embedded_atlas_orientation_runs_left_to_right) {
const EmbeddedMonoFont f;
using plot::render::EmbeddedMonoFont;
auto ink = [&f](char c, plot::u32 col, plot::u32 row) {
const plot::u32 slot = EmbeddedMonoFont::glyph_index(static_cast<plot::u32>(c));
return cell_texel(f, slot, col, row) != 0u;
};
// 'L': the stem runs down the left, and the foot runs right along the bottom --
// so at cap height there is ink on the left and none on the right, and the
// bottom row has ink to the right of where the stem stopped.
PLOT_CHECK(ink('L', 1u, 6u));
PLOT_CHECK(!ink('L', 6u, 6u));
PLOT_CHECK(ink('L', 5u, 11u));
PLOT_CHECK(!ink('L', 5u, 3u));
// 'b': the stem is the left column of the upper rows, the bowl the right.
PLOT_CHECK(ink('b', 1u, 4u));
PLOT_CHECK(!ink('b', 6u, 4u));
PLOT_CHECK(ink('b', 6u, 8u));
// '/': starts at the top right and ends at the bottom left -- the opposite of
// '\'. This is the pair that pins the diagonal's direction.
PLOT_CHECK(ink('/', 5u, 2u));
PLOT_CHECK(!ink('/', 2u, 2u));
PLOT_CHECK(ink('/', 2u, 12u));
PLOT_CHECK(!ink('/', 5u, 12u));
}
// The atlas is a fixed-cell grid of one texel format: a whole number of cells
// across, the cell dividing the height, and every glyph's rect the declared cell
// -- so the pipeline's single per-draw rect size is the right one for every glyph.
PLOT_TEST(embedded_atlas_is_uploadable) {
using plot::render::EmbeddedMonoFont;
PLOT_CHECK(EmbeddedMonoFont::cell_width() > 0u);
PLOT_CHECK(EmbeddedMonoFont::cell_height() > 0u);
PLOT_CHECK(EmbeddedMonoFont::glyph_count() == 95u + 5u + 1u); // ASCII, SI/dashes, box
const EmbeddedMonoFont f;
PLOT_CHECK(f.atlas_width() == EmbeddedMonoFont::cell_width() * EmbeddedMonoFont::glyph_count());
PLOT_CHECK(f.atlas_height() == EmbeddedMonoFont::cell_height());
PLOT_CHECK(f.atlas_pixels() != nullptr);
PLOT_CHECK(f.height() > 0.0f);
PLOT_CHECK(f.ascent() > 0.0f);
PLOT_CHECK(f.ascent() < f.height());
// Every cell, the fallback included, is the declared cell and holds at least
// one non-zero byte (bar the space), and the ASCII codepoints are a direct
// index into the table.
for (plot::u32 slot = 0; slot < EmbeddedMonoFont::glyph_count(); ++slot) {
const plot::u32 cp = EmbeddedMonoFont::codepoint_of_slot(slot);
if (cp != EmbeddedMonoFont::kFallbackCodepoint) {
PLOT_CHECK(EmbeddedMonoFont::glyph_index(cp) == slot);
}
const plot::render::Glyph* g = f.glyph(cp);
PLOT_CHECK(plot::render::glyph_is_cell(*g, f.cell_uv()));
}
for (plot::u32 cp = 0x20u; cp <= 0x7Eu; ++cp) {
PLOT_CHECK(EmbeddedMonoFont::glyph_index(cp) == cp - 0x20u);
}
// A single-row atlas: the cell spans the whole height, so v is 0..1.
PLOT_CHECK_NEAR(f.cell_uv().y, 1.0, 1e-9);
PLOT_CHECK_NEAR(EmbeddedMonoFont::atlas_origin(0u).x, 0.0, 1e-9);
PLOT_CHECK_NEAR(EmbeddedMonoFont::atlas_origin(1u).x,
static_cast<plot::f32>(EmbeddedMonoFont::cell_width()), 1e-9);
}
// The size the renderer draws a run by is a plain ratio, `requested_px / em_px`,
// and not a whole multiple of the backend's cell. This is the rule the whole
// anti-aliasing change rests on: a style asking for 12 px from a 13 px bake is
// drawn at 12/13, where the old 1-bit font had to snap to a multiple of its 8 px
// cell (16 px here). `detail::text_scale` is that rule, so it is asserted without
// a GL context; the frame-level half is in the render smoke test.
PLOT_TEST(text_scale_is_a_ratio_not_a_whole_multiple) {
using plot::render::detail::text_scale;
const plot::f32 em = 13.0f; // what the committed atlas is baked at
// 12/13 = 0.9231, so the 17-px cell is drawn 15.69 px tall -- not the 16 px the
// old rule would have chosen, and not the 17 px of a whole multiple of it.
PLOT_CHECK_NEAR(text_scale(12.0f, em), 12.0f / 13.0f, 1e-6);
PLOT_CHECK(std::fabs(text_scale(12.0f, em) * 17.0f - 16.0f) > 0.2f);
PLOT_CHECK(std::fabs(text_scale(12.0f, em) * 17.0f - 17.0f) > 0.2f);
// A ratio that is not a multiple is not rounded to one ...
PLOT_CHECK_NEAR(text_scale(20.0f, em), 20.0f / 13.0f, 1e-6);
PLOT_CHECK(text_scale(20.0f, em) > 1.5f && text_scale(20.0f, em) < 1.6f);
// ... and an exact multiple is exactly that multiple, so nothing is lost when
// the request does line up with the bake.
PLOT_CHECK_NEAR(text_scale(13.0f, em), 1.0f, 1e-6);
PLOT_CHECK_NEAR(text_scale(26.0f, em), 2.0f, 1e-6);
PLOT_CHECK_NEAR(text_scale(39.0f, em), 3.0f, 1e-6);
}
// A backend that cannot state an em must not take the frame with it: the scale
// falls back to 1:1 rather than dividing by zero or propagating a NaN into every
// vertex, and the same holds for a nonsensical request. No shipped backend can
// reach this -- `EmbeddedMonoFont::em_px()` is a positive constant -- which is
// exactly why the rule is a function with its own case instead of a bare
// division inside `Renderer::AxisFont::reset`.
PLOT_TEST(text_scale_guards_a_backend_without_an_em) {
using plot::render::detail::text_scale;
const plot::f32 nan = std::numeric_limits<plot::f32>::quiet_NaN();
PLOT_CHECK_NEAR(text_scale(12.0f, 0.0f), 1.0f, 1e-6); // no em at all
PLOT_CHECK_NEAR(text_scale(12.0f, -13.0f), 1.0f, 1e-6); // nonsense em
PLOT_CHECK_NEAR(text_scale(12.0f, nan), 1.0f, 1e-6); // NaN em
PLOT_CHECK_NEAR(text_scale(nan, 13.0f), 1.0f, 1e-6); // NaN request
PLOT_CHECK_NEAR(text_scale(0.0f, 13.0f), 1.0f, 1e-6); // zero request
PLOT_CHECK_NEAR(text_scale(-12.0f, 13.0f), 1.0f, 1e-6); // negative request
// The guard does not fire for a healthy pair: the ratio is still exact.
PLOT_CHECK_NEAR(text_scale(26.0f, 13.0f), 2.0f, 1e-6);
PLOT_CHECK_NEAR(text_scale(12.0f, 13.0f), 12.0f / 13.0f, 1e-6);
}
// Three exit codes say something about the *machine* rather than about the
// committed bytes, and none of them can tell a drifted table from a good one:
// `gen_atlas.py`'s EXIT_NO_FONTTOOLS (fontTools not installed, 3) and
// EXIT_BAD_FONT (no source face -- the adwaita-fonts package -- or an
// unparseable one, 2), and the shell's "command not found" for a missing
// `python3` (127). Each is *reported* with what is missing and skipped, rather
// than failed, so an environment difference does not look like atlas drift --
// a machine with fontTools from pip but no distro font package would otherwise
// fail here and send someone hunting for a corruption that is not there. Only a
// real mismatch between the committed atlas and a fresh bake (1) fails. The
// data cases above still assert the shipped table on such a machine, so the
// only thing lost is the drift comparison itself.
PLOT_TEST(generator_is_deterministic_and_current) {
// Both paths are quoted: `kSourceDir` is a build-time value, and a source tree
// whose path contains a space would otherwise split into extra arguments.
const std::string command = "python3 \"" + std::string(kSourceDir) +
"/tools/fonts/gen_atlas.py\" --check --out \"" + kSourceDir +
"/src/render/embedded_mono_font.cpp\"";
const int status = std::system(command.c_str());
PLOT_CHECK(status != -1);
PLOT_CHECK(WIFEXITED(status));
const int code = WEXITSTATUS(status);
const char* missing = code == 3 ? "fontTools is not installed"
: code == 2 ? "the Adwaita Mono face is not installed (adwaita-fonts)"
: code == 127 ? "python3 is not on PATH"
: nullptr;
if (missing != nullptr) {
std::printf(
"SKIPPED: generator_is_deterministic_and_current -- %s, so the committed "
"atlas could not be re-baked\n",
missing);
return;
}
PLOT_CHECK(code == 0);
}
PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
plot::render::EmbeddedMonoFont f;
plot::render::TextBatch b;
b.begin();
const plot::Vec2 end = b.add_text("12.5 ms", {0.0f, 0.0f}, 1.0f, f);
PLOT_CHECK(b.glyph_count() == 7);
PLOT_CHECK(b.data().size() == 42); // x,y,u,v,ax,ay per glyph
PLOT_CHECK_NEAR(end.x, f.measure("12.5 ms"), 1e-6);
PLOT_CHECK_NEAR(end.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[0], 0.0, 1e-6); // first cell at the pen
PLOT_CHECK_NEAR(b.data()[1], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
// The staged cell's UV is the glyph's own atlas rect (see `Glyph`), and the
// basis of an unrotated run is the screen's +x. '.' is the string's third
// glyph, so its instance is the third: floats 12..17.
const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.'));
PLOT_CHECK(dot != nullptr);
PLOT_CHECK_NEAR(b.data()[14], dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[15], dot->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[16], 1.0, 1e-6); // ax
PLOT_CHECK_NEAR(b.data()[17], 0.0, 1e-6); // ay
// Every glyph a batch stages is read through one cell, and the batch publishes
// it for the draw (`u_glyph_uv`).
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, dot->u1 - dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, dot->v1 - dot->v0, 1e-6);
// Non-ASCII runs stage one quad per codepoint, and preserve capacity on reuse.
const std::size_t cap = b.data().capacity();
b.begin();
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.0, 1e-6); // begin() clears the cell too
b.add_text("1.5\xC2\xB5s", {0.0f, 0.0f}, 1.0f, f); // 5 codepoints, 6 bytes
PLOT_CHECK(b.glyph_count() == 5);
PLOT_CHECK(b.data().capacity() == cap);
// A run drawn at twice the backend's size advances twice as far, so the staged
// label still ends where the scaled measurement says it does.
b.begin();
const plot::Vec2 scaled = b.add_text("AW", {10.0f, 4.0f}, 2.0f, f);
PLOT_CHECK(b.glyph_count() == 2);
PLOT_CHECK_NEAR(scaled.x, 10.0f + 2.0f * f.measure("AW"), 1e-6);
PLOT_CHECK_NEAR(scaled.y, 4.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
}
// The pen follows the basis, not the screen axes. A run staged with `axis` has to
// be exactly as long as the plot layer measured it *in that direction* -- that is
// what reserves the room for a rotated y label -- and its cells have to march along
// the same direction, or the ink lands beside the pen instead of up from it.
PLOT_TEST(text_batch_advances_along_its_basis) {
plot::render::EmbeddedMonoFont f;
plot::render::TextBatch b;
const plot::Vec2 pen{100.0f, 40.0f};
const f32 width = f.measure("12.5 ms");
// Unrotated, for the shape of the assertion: the run is `measure()` long and
// does not move in y.
b.begin();
const plot::Vec2 flat = b.add_text("12.5 ms", pen, 1.0f, f);
PLOT_CHECK_NEAR((flat - pen).length(), width, 1e-4);
PLOT_CHECK_NEAR(flat.y, pen.y, 1e-6);
// Up the screen: the same length, measured upward, with a unit basis on every
// staged cell.
b.begin();
const plot::Vec2 up = b.add_text("12.5 ms", pen, 1.0f, f, {0.0f, -1.0f});
PLOT_CHECK_NEAR((up - pen).length(), width, 1e-4);
PLOT_CHECK_NEAR(up.x, pen.x, 1e-6);
PLOT_CHECK_NEAR(up.y, pen.y - width, 1e-4);
for (plot::u32 i = 0; i < b.glyph_count(); ++i) {
PLOT_CHECK_NEAR(b.data()[6u * i + 4u], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6u * i + 5u], -1.0, 1e-6);
}
// The second cell is one advance further up than the pen, which is the same rule
// the unrotated case follows in x.
PLOT_CHECK_NEAR(b.data()[6], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.glyph('1')->advance, 1e-6);
// A diagonal direction is normalised, so a caller can hand over any direction
// and the basis the shader receives is still unit.
b.begin();
const plot::Vec2 diag = b.add_text("12.5 ms", pen, 1.0f, f, {3.0f, 4.0f});
PLOT_CHECK_NEAR((diag - pen).length(), width, 1e-4);
PLOT_CHECK_NEAR(b.data()[4], 0.6, 1e-5);
PLOT_CHECK_NEAR(b.data()[5], 0.8, 1e-5);
}
/// A backend whose atlas is *not* a fixed-cell grid: it declares the cell its
/// pipeline needs and then publishes one glyph trimmed to its ink, which is what
/// a naive TrueType packing produces (`stbtt_GetCodepointBitmap` hands back an ink
/// box, not a cell). The pipeline cannot draw that glyph correctly -- a staged
/// instance carries the rect's origin, and the draw carries one size for the whole
/// run -- so the staging path asserts `glyph_is_cell()` for every glyph it stages.
class TrimmedCellBackend final : public plot::render::TextBackend {
public:
plot::f32 measure(const char*) const override { return 8.0f; }
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 8.0f; }
plot::f32 em_px() const override { return 8.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 0.25f}; }
plot::u32 revision() const override { return 1u; } // baked once, never re-baked
const plot::render::Glyph* glyph(plot::u32 codepoint) const override {
// 'i' is trimmed to half a cell wide, as an ink box would be.
return codepoint == static_cast<plot::u32>('i') ? &trimmed_ : &cell_;
}
const plot::u8* atlas_pixels() const override { return atlas_; }
plot::u32 atlas_width() const override { return 8u; }
plot::u32 atlas_height() const override { return 4u; }
private:
// Two cells of a 2x2 grid of 0.5 x 0.25, one of them trimmed to the ink.
const plot::render::Glyph cell_{0.0f, 0.0f, 0.5f, 0.25f, 0.0f, 0.0f, 4.0f};
const plot::render::Glyph trimmed_{0.5f, 0.0f, 0.6875f, 0.25f, 0.0f, 0.0f, 4.0f};
const plot::u8 atlas_[8 * 4] = {};
};
// The fixed-cell contract, and the check that enforces it. A backend that trims
// each atlas rect to its ink -- the shape a TrueType packing produces, and the
// reason the contract is stated on `TextBackend::cell_uv()` -- publishes glyphs
// the pipeline would draw at the wrong size: the instance carries the rect's
// *origin* only, so a 3/8-cell rect would be read as a half cell, past its own
// rows.
//
// This drives the predicate the staging path asserts, rather than staging the
// offending glyph, because `add_text()` with this backend aborts the test binary
// in a debug build -- which is the point of the assertion.
PLOT_TEST(a_trimmed_glyph_is_not_the_atlas_cell) {
TrimmedCellBackend backend;
const plot::Vec2 cell = backend.cell_uv();
PLOT_CHECK_NEAR(cell.x, 0.5, 1e-6);
PLOT_CHECK_NEAR(cell.y, 0.25, 1e-6);
// The glyph that occupies a whole cell passes ...
PLOT_CHECK(plot::render::glyph_is_cell(*backend.glyph(static_cast<plot::u32>('A')), cell));
// ... and the one trimmed to its ink does not: this is what the staging path
// asserts in a debug build.
PLOT_CHECK(!plot::render::glyph_is_cell(*backend.glyph(static_cast<plot::u32>('i')), cell));
// The check is per axis and per glyph, not a single "is it square" test: a rect
// that matches on one axis and not the other is still not the cell.
plot::render::Glyph wide = *backend.glyph(static_cast<plot::u32>('A'));
wide.u1 = wide.u0 + 0.75f;
PLOT_CHECK(!plot::render::glyph_is_cell(wide, cell));
plot::render::Glyph tall = *backend.glyph(static_cast<plot::u32>('A'));
tall.v1 = tall.v0 + 0.5f;
PLOT_CHECK(!plot::render::glyph_is_cell(tall, cell));
// Rounding a correct cell through the atlas arithmetic twice is not a failure:
// a rect is a subtraction of two rounded fractions, and the tolerance is two
// orders of magnitude above that slop and below one texel.
plot::render::Glyph rounded = *backend.glyph(static_cast<plot::u32>('A'));
rounded.u1 = rounded.u0 + cell.x + 1e-7f;
PLOT_CHECK(plot::render::glyph_is_cell(rounded, cell));
// A real font's grid holds for every glyph it publishes, not just the first.
plot::render::EmbeddedMonoFont font;
for (plot::u32 codepoint = 32u; codepoint < 127u; ++codepoint) {
const plot::render::Glyph* g = font.glyph(codepoint);
PLOT_CHECK(g != nullptr);
PLOT_CHECK(plot::render::glyph_is_cell(*g, font.cell_uv()));
}
PLOT_CHECK_NEAR(font.cell_uv().x,
static_cast<plot::f32>(plot::render::EmbeddedMonoFont::cell_width()) /
static_cast<plot::f32>(font.atlas_width()),
1e-9);
PLOT_CHECK_NEAR(font.cell_uv().y,
static_cast<plot::f32>(plot::render::EmbeddedMonoFont::cell_height()) /
static_cast<plot::f32>(font.atlas_height()),
1e-9);
}
/// A backend whose cell is nothing like the bitmap font's -- half by a quarter of
/// a small atlas -- so anything a batch reports that equals it came from the
/// backend and not from a glyph of some other font.
class HalfCellBackend final : public plot::render::TextBackend {
public:
plot::f32 measure(const char*) const override { return 4.0f; }
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 6.0f; }
plot::f32 em_px() const override { return 8.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 0.25f}; }
plot::u32 revision() const override { return 1u; }
const plot::render::Glyph* glyph(plot::u32) const override { return &cell_; }
const plot::u8* atlas_pixels() const override { return atlas_; }
plot::u32 atlas_width() const override { return 8u; }
plot::u32 atlas_height() const override { return 4u; }
private:
// One cell, at the second slot of a 2x2 grid. Its rect is the declared cell to
// within the atlas tolerance (5e-7) but not bit for bit -- which is what any
// backend's rounded arithmetic looks like, and what makes "the backend's
// number" and "this glyph's rect size" two distinguishable values.
const plot::render::Glyph cell_{0.5f, 0.0f, 1.0000005f, 0.2500005f, 0.0f, 0.0f, 4.0f};
const plot::u8 atlas_[8 * 4] = {};
};
// The draw's one rect size is the *backend's* `cell_uv()`, and it is known before
// a glyph is staged: a batch that took it from whatever glyph it happened to
// stage first could not answer the first of these, and would take another font's
// grid the moment a caller staged two backends into one batch. `add_text()`
// declares it from the backend it is handed, and `set_cell()` is how a caller
// staging glyphs one at a time -- the renderer's clipped walk -- says it.
PLOT_TEST(text_batch_takes_its_cell_from_the_backend) {
HalfCellBackend backend;
plot::render::TextBatch b;
b.begin();
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.0, 1e-6); // nothing declared, nothing staged
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, 0.0, 1e-6);
b.set_cell(backend.cell_uv());
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.5, 1e-6); // declared, still nothing staged
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, 0.25, 1e-6);
// A staged run keeps it -- and keeps the *backend's* number, not the staged
// glyph's: this backend's rects are 5e-7 off its declared cell (inside the
// tolerance, so it is a legal grid), and an implementation that recorded
// whatever glyph it staged first would land off these by exactly that.
b.begin();
const plot::Vec2 end = b.add_text("AB", {0.0f, 0.0f}, 1.0f, backend);
PLOT_CHECK(b.glyph_count() == 2);
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, backend.cell_uv().x, 1e-12);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, backend.cell_uv().y, 1e-12);
PLOT_CHECK(backend.cell_uv().x != backend.glyph(0u)->u1 - backend.glyph(0u)->u0);
PLOT_CHECK(plot::render::glyph_is_cell(*backend.glyph(0u), b.glyph_cell_uv()));
PLOT_CHECK_NEAR(end.x, 2.0f * backend.glyph(static_cast<plot::u32>('A'))->advance, 1e-6);
// The instances carry the cell's *origin* in the atlas; the size rides along
// once, above, which is the whole reason a glyph fits in six floats.
PLOT_CHECK_NEAR(b.data()[2], backend.glyph(0u)->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[3], backend.glyph(0u)->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[4], 1.0, 1e-6); // unrotated basis
// And the real font's grid, staged the same way, reports the font's cell -- the
// two never mix silently, and the batch's value tracks the backend it was given.
plot::render::EmbeddedMonoFont font;
b.begin();
b.add_text("A", {0.0f, 0.0f}, 1.0f, font);
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, font.cell_uv().x, 1e-9);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, font.cell_uv().y, 1e-9);
PLOT_CHECK(b.glyph_cell_uv().x != backend.cell_uv().x);
// begin() forgets the declaration along with the instances, so a frame cannot
// inherit the previous one's cell.
b.begin();
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.0, 1e-6);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, 0.0, 1e-6);
}
/// A backend with a hole in its artwork: it can draw 'A' and 'B' and publishes no
/// glyph at all for anything else, which is what a font whose coverage falls short
/// of a label looks like. `measure()` counts only what it can draw, so "what a
/// string is worth" and "what it stages" stay the same thing -- the pipeline draws
/// nothing for the missing codepoint and moves the pen nowhere, rather than
/// inventing a box or a width.
class HoleInCoverageBackend final : public plot::render::TextBackend {
public:
plot::f32 measure(const char* utf8) const override {
plot::f32 width = 0.0f;
for (const char* p = utf8; p != nullptr && *p != '\0';) {
const plot::render::Glyph* g = glyph(plot::render::next_codepoint(p));
if (g != nullptr) width += g->advance;
}
return width;
}
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 6.0f; }
plot::f32 em_px() const override { return 8.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 1.0f}; }
plot::u32 revision() const override { return 1u; }
const plot::render::Glyph* glyph(plot::u32 codepoint) const override {
if (codepoint == static_cast<plot::u32>('A')) return &a_;
if (codepoint == static_cast<plot::u32>('B')) return &b_;
return nullptr; // no artwork at all: not even a fallback box
}
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; }
private:
const plot::render::Glyph a_{0.0f, 0.0f, 0.5f, 1.0f, 0.0f, 0.0f, 6.0f};
const plot::render::Glyph b_{0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 4.0f};
};
// A backend that publishes no glyph for a codepoint: `add_text` stages nothing for
// it and does not advance the pen, so a run stays exactly as long as the same
// backend measured it and the glyphs it *can* draw keep their places. A backend
// that would rather show something publishes a fallback box as that codepoint's
// glyph (which is what `EmbeddedMonoFont` does); this is the other legal answer, and the
// staging path has to honour it rather than draw whatever was staged last or
// advance by a width nobody published.
PLOT_TEST(add_text_skips_a_codepoint_the_backend_cannot_draw) {
HoleInCoverageBackend backend;
const plot::render::Glyph* a = backend.glyph(static_cast<plot::u32>('A'));
const plot::render::Glyph* b = backend.glyph(static_cast<plot::u32>('B'));
PLOT_CHECK(a != nullptr);
PLOT_CHECK(b != nullptr);
PLOT_CHECK(backend.glyph(static_cast<plot::u32>('?')) == nullptr);
const plot::Vec2 pen{10.0f, 4.0f};
plot::render::TextBatch batch;
batch.begin();
const plot::Vec2 end = batch.add_text("A?B", pen, 1.0f, backend);
// Two glyphs staged for three codepoints, and the pen moved by exactly the two
// advances the backend published.
PLOT_CHECK(batch.glyph_count() == 2);
PLOT_CHECK_NEAR(end.x, pen.x + a->advance + b->advance, 1e-6);
PLOT_CHECK_NEAR(end.y, pen.y, 1e-6);
// Which is the length the backend measured for the same string, so a label's
// reserved space still covers the run drawn into it.
PLOT_CHECK_NEAR((end - pen).length(), backend.measure("A?B"), 1e-6);
PLOT_CHECK_NEAR(backend.measure("A?B"), a->advance + b->advance, 1e-6);
// 'B' starts where 'A' ended -- the skipped codepoint left no gap and drew no
// glyph of its own, which the second instance's atlas rect says outright.
PLOT_CHECK_NEAR(batch.data()[0], pen.x, 1e-6);
PLOT_CHECK_NEAR(batch.data()[2], a->u0, 1e-6);
PLOT_CHECK_NEAR(batch.data()[6], pen.x + a->advance, 1e-6);
PLOT_CHECK_NEAR(batch.data()[8], b->u0, 1e-6);
// A run of nothing but undrawable codepoints stages nothing at all and leaves
// the pen where it was, rather than drawing at the pen.
batch.begin();
const plot::Vec2 same = batch.add_text("??", pen, 1.0f, backend);
PLOT_CHECK(batch.glyph_count() == 0);
PLOT_CHECK(batch.data().empty());
PLOT_CHECK_NEAR(same.x, pen.x, 1e-6);
PLOT_CHECK_NEAR(same.y, pen.y, 1e-6);
PLOT_CHECK_NEAR(backend.measure("??"), 0.0, 1e-6);
// A real font never hits this path (every codepoint maps to its box), so the
// same string through it stages one glyph per codepoint: the two answers to
// "no glyph" are the backend's choice, not the pipeline's.
plot::render::EmbeddedMonoFont font;
batch.begin();
batch.add_text("A?B", pen, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 3);
}