Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
895 lines
43 KiB
C++
895 lines
43 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() == 70); // x,y,u,v,ax,ay,r,g,b,a 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()[10], 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 20..29.
|
|
const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.'));
|
|
PLOT_CHECK(dot != nullptr);
|
|
PLOT_CHECK_NEAR(b.data()[22], dot->u0, 1e-6);
|
|
PLOT_CHECK_NEAR(b.data()[23], dot->v0, 1e-6);
|
|
PLOT_CHECK_NEAR(b.data()[24], 1.0, 1e-6); // ax
|
|
PLOT_CHECK_NEAR(b.data()[25], 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()[10], 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()[10u * i + 4u], 0.0, 1e-6);
|
|
PLOT_CHECK_NEAR(b.data()[10u * 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()[10], pen.x, 1e-6);
|
|
PLOT_CHECK_NEAR(b.data()[11], 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);
|
|
}
|
|
|
|
// The tint is per instance (`a_colour`, the last four floats of every glyph),
|
|
// not a uniform: one batch can carry runs of several colours, which is what the
|
|
// crosshair readout needs (values in each row's trace colour, labels in the axis
|
|
// text colour, one draw call). The staged colour is exactly what the caller
|
|
// passed, straight alpha, on every glyph of the run and nothing else.
|
|
PLOT_TEST(text_batch_stages_its_colour_per_instance) {
|
|
plot::render::EmbeddedMonoFont f;
|
|
plot::render::TextBatch b;
|
|
const plot::Color tint{0.25f, 0.5f, 0.75f, 0.9f};
|
|
b.begin();
|
|
b.add_text("AB", {0.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f}, tint);
|
|
PLOT_CHECK(b.glyph_count() == 2);
|
|
for (plot::u32 i = 0; i < 2; ++i) {
|
|
PLOT_CHECK_NEAR(b.data()[10u * i + 6u], tint.r, 1e-6);
|
|
PLOT_CHECK_NEAR(b.data()[10u * i + 7u], tint.g, 1e-6);
|
|
PLOT_CHECK_NEAR(b.data()[10u * i + 8u], tint.b, 1e-6);
|
|
PLOT_CHECK_NEAR(b.data()[10u * i + 9u], tint.a, 1e-6);
|
|
}
|
|
// A second run in another colour keeps its own tint per instance -- the old
|
|
// uniform-tint pipeline could not express this in one batch.
|
|
b.add_text("CD", {20.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f},
|
|
plot::Color{1.0f, 1.0f, 1.0f, 1.0f});
|
|
PLOT_CHECK(b.glyph_count() == 4);
|
|
PLOT_CHECK_NEAR(b.data()[26u], 1.0, 1e-6); // 'C' is white where 'A' was not
|
|
PLOT_CHECK_NEAR(b.data()[6u], 0.25, 1e-6); // ... and 'A' still carries its own
|
|
}
|
|
|
|
/// 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()[10], pen.x + a->advance, 1e-6);
|
|
PLOT_CHECK_NEAR(batch.data()[12], 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);
|
|
}
|