Files
plotter/tests/test_primitives.cpp
T
Martino Ferrari 3ff7b9c978 fix(render): gate TextAtlas::sync on the loader, and cover three review findings
Review of the atlas refactor found one Important and three Minor issues:

- Important: TextAtlas::sync called glGetIntegerv before any available() check,
  so a host that synced before plot::gl::load() -- which is legal for a public
  class Tasks 2 and 8 build backends on -- segfaulted on a null entry point
  instead of receiving the Status the interface promises. It now follows the
  handle-constructor convention (available(), then the entry point itself) and
  reports Unsupported.
- empty() no longer means "a GL name exists": releasing one is a
  glDeleteTextures call, so the no-atlas path forgets the atlas and only resets
  the texture when there is a table to release it with.
- end_frame's comment still claimed the frame hands back the unpack alignment
  init() set, contradicting the code below it and renderer.hpp.
- The orientation case's texel probe mapped the atlas' u along the cell's +v
  axis and vice versa, which is a no-op for its two diagonal probes -- so it
  could not fail on a transposed UV mapping, and its own comment misstated the
  rule. Probes now follow the shader's rule and include an off-diagonal pair.
- The null-glyph path of add_text (and its twin in walk_text_clipped) was
  unreached by any case.

Cases: text_atlas_reports_a_status_when_the_entry_points_are_missing,
add_text_skips_a_codepoint_the_backend_cannot_draw.
2026-09-28 10:22:38 +02:00

576 lines
27 KiB
C++

// CPU-side contract of the render layer: the staging batches that feed the
// instanced line and text pipelines, and the bitmap 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/bitmap_font.hpp>
#include <plot/render/primitives.hpp>
#include <cstddef>
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);
}
PLOT_TEST(text_batch_measures_ascii) {
plot::render::BitmapFont f;
const f32 w = f.measure("12.5 ms");
PLOT_CHECK(w > 0.0f);
PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6);
PLOT_CHECK(f.measure("ab") < f.measure("abc")); // monotonic in length
PLOT_CHECK(f.measure("iiii") < f.measure("WWWW")); // proportional-ish, not fixed-width-blank
}
PLOT_TEST(bitmap_font_advance_follows_ink_extent) {
plot::render::BitmapFont f;
PLOT_CHECK(f.advance('i') < f.advance('W'));
// Whitespace is a blank glyph with a nonzero advance, never zero.
PLOT_CHECK(f.advance(' ') >= 2.0f);
// Advance is ink width + 1px of spacing, floored at 2px: derived here from
// the same bitmaps the atlas uploads, so a second width table cannot drift.
auto ink_width = [&f](char c) {
const plot::u8* rows = f.glyph_bitmap(c);
int lo = 8;
int hi = -1;
for (plot::u32 y = 0; y < f.glyph_rows(c); ++y) {
for (int x = 0; x < 8; ++x) {
if ((rows[y] >> x) & 1u) {
if (x < lo) lo = x;
if (x > hi) hi = x;
}
}
}
return hi < 0 ? 0 : hi - lo + 1;
};
const char* samples = "iW.1_m";
for (const char* c = samples; *c != '\0'; ++c) {
const int ink = ink_width(*c);
const f32 want = ink + 1 < 2 ? 2.0f : static_cast<f32>(ink + 1);
PLOT_CHECK_NEAR(f.advance(*c), want, 1e-6);
}
// measure() is exactly the sum of the glyph advances it stages.
f32 sum = 0.0f;
for (const char* c = "12.5 ms"; *c != '\0'; ++c) sum += f.advance(*c);
PLOT_CHECK_NEAR(f.measure("12.5 ms"), sum, 1e-6);
}
PLOT_TEST(bitmap_font_decodes_si_units_and_falls_back_visibly) {
plot::render::BitmapFont f;
// plot::format_si emits U+00B5 MICRO SIGN, U+00B0 DEGREE SIGN and U+03A9 OHM.
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, f.advance_codepoint(0x00B5u) + f.advance('s'), 1e-6);
PLOT_CHECK_NEAR(degree, f.advance_codepoint(0x00B0u), 1e-6);
PLOT_CHECK_NEAR(ohm, f.advance_codepoint(0x03A9u), 1e-6);
// Those three map to their own renderable glyph, not to the missing-glyph box.
const plot::u32 fallback = f.glyph_index(0x4E00u); // CJK, deliberately absent
PLOT_CHECK(f.glyph_index(0x00B5u) != fallback);
PLOT_CHECK(f.glyph_index(0x00B0u) != fallback);
PLOT_CHECK(f.glyph_index(0x03A9u) != fallback);
PLOT_CHECK(f.glyph_index(0x00B5u) != f.glyph_index(0x00B0u));
PLOT_CHECK(f.glyph_index(0x00B0u) != f.glyph_index(0x03A9u));
// Anything else non-ASCII is a visible box with a nonzero advance, and a
// malformed UTF-8 byte is treated the same way rather than measured as zero.
PLOT_CHECK_NEAR(f.measure("\xE4\xB8\x80"), f.advance_codepoint(0x4E00u), 1e-6);
PLOT_CHECK(f.advance_codepoint(0x4E00u) > 0.0f);
PLOT_CHECK(f.advance(static_cast<char>(0x80)) > 0.0f);
PLOT_CHECK_NEAR(f.advance(static_cast<char>(0x80)), f.advance_codepoint(0x4E00u), 1e-6);
// The fallback box glyph has ink; a blank bitmap would hide the problem.
bool any_ink = false;
const plot::u8* box = plot::render::BitmapFont::glyph_bitmap_of(fallback);
for (plot::u32 y = 0; y < plot::render::BitmapFont::kGlyphPx; ++y) {
any_ink = any_ink || box[y] != 0;
}
PLOT_CHECK(any_ink);
}
// The packed atlas must be left-to-right in absolute terms: `(row >> x) & 1` is
// also how every other expectation here is derived, so a table transcribed with
// the opposite in-row bit order would keep those tests green while drawing
// mirrored text. 'A' cannot catch that (it is
// symmetric about its vertical axis); 'L', 'b' and '/' can.
PLOT_TEST(bitmap_font_bit_order_runs_left_to_right) {
using plot::render::BitmapFont;
const plot::u8* atlas = BitmapFont::atlas_image();
auto ink = [atlas](char c, plot::u32 col, plot::u32 row) {
const plot::u32 slot = static_cast<plot::u32>(c) - BitmapFont::kFirstChar;
const plot::Vec2 origin = BitmapFont::atlas_origin(slot);
const auto x = static_cast<std::size_t>(origin.x) + col;
const auto y = static_cast<std::size_t>(origin.y) + row;
return atlas[y * BitmapFont::kAtlasWidth + x] != 0u;
};
// 'L': stem and top serif on the left, the foot running right along the
// bottom row and stopping one texel short of the right edge.
PLOT_CHECK(ink('L', 0u, 0u));
PLOT_CHECK(!ink('L', 7u, 0u));
PLOT_CHECK(ink('L', 1u, 3u));
PLOT_CHECK(!ink('L', 6u, 3u));
PLOT_CHECK(ink('L', 6u, 6u));
PLOT_CHECK(!ink('L', 7u, 6u));
// 'b': the stem is the leftmost column of the upper rows.
PLOT_CHECK(ink('b', 0u, 0u));
PLOT_CHECK(!ink('b', 7u, 0u));
// '/': starts at the right of the top row and ends at the left of the last
// one — the opposite of '\'.
PLOT_CHECK(ink('/', 6u, 0u));
PLOT_CHECK(!ink('/', 0u, 0u));
PLOT_CHECK(ink('/', 0u, 6u));
PLOT_CHECK(!ink('/', 6u, 6u));
}
PLOT_TEST(bitmap_font_atlas_is_uploadable) {
using plot::render::BitmapFont;
PLOT_CHECK(BitmapFont::kAtlasWidth == BitmapFont::kAtlasCols * BitmapFont::kGlyphPx);
PLOT_CHECK(BitmapFont::kAtlasHeight % BitmapFont::kGlyphPx == 0);
PLOT_CHECK(BitmapFont::kAtlasCols * BitmapFont::kAtlasRows >= BitmapFont::kGlyphCount);
PLOT_CHECK(BitmapFont::kAsciiGlyphs == 95);
plot::render::BitmapFont f;
PLOT_CHECK(f.glyph_rows('A') == 8);
PLOT_CHECK(f.height() > 0.0f);
PLOT_CHECK(f.ascent() > 0.0f);
PLOT_CHECK(f.ascent() <= f.height());
// Each glyph occupies its own atlas cell, and that cell is the bitmap byte
// for byte: 255 where a row bit is set, 0 elsewhere (straight alpha).
const plot::u8* atlas = BitmapFont::atlas_image();
const plot::u32 slots[] = {'A' - 32, 'g' - 32, 'i' - 32, 'W' - 32, 0, '.' - 32,
BitmapFont::kFallbackSlot, BitmapFont::kMicroSlot};
for (const plot::u32 slot : slots) {
const plot::Vec2 o = BitmapFont::atlas_origin(slot);
const plot::u8* rows = BitmapFont::glyph_bitmap_of(slot);
for (plot::u32 y = 0; y < BitmapFont::kGlyphPx; ++y) {
for (plot::u32 x = 0; x < BitmapFont::kGlyphPx; ++x) {
const plot::u8 want = ((rows[y] >> x) & 1u) != 0u ? 0xFFu : 0x00u;
const auto row = static_cast<std::size_t>(o.y) + y;
const auto col = static_cast<std::size_t>(o.x) + x;
PLOT_CHECK(atlas[row * BitmapFont::kAtlasWidth + col] == want);
}
}
}
// The atlas is a whole number of cells and no glyph leaves its cell.
PLOT_CHECK(BitmapFont::kAtlasWidth == 16u * BitmapFont::kGlyphPx);
}
PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
plot::render::BitmapFont 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.advance('1'), 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.advance('A'), 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::BitmapFont 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.advance('1'), 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::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::BitmapFont 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::BitmapFont::kGlyphPx) /
static_cast<plot::f32>(plot::render::BitmapFont::kAtlasWidth),
1e-9);
PLOT_CHECK_NEAR(font.cell_uv().y, static_cast<plot::f32>(plot::render::BitmapFont::kGlyphPx) /
static_cast<plot::f32>(plot::render::BitmapFont::kAtlasHeight),
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::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::BitmapFont 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::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 `BitmapFont` 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::BitmapFont font;
batch.begin();
batch.add_text("A?B", pen, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 3);
}