fix(render): environment-aware atlas drift guard, pinned text scale, shipped-atlas coverage
This commit is contained in:
1 parent
5ca3587325
commit
1cb5970757
6 files changed
+257
-31
No files matched your search
+153
-15
@@ -3,12 +3,14 @@
|
||||
// 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>
|
||||
@@ -221,6 +223,83 @@ PLOT_TEST(embedded_atlas_covers_every_expected_codepoint) {
|
||||
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
|
||||
@@ -363,27 +442,86 @@ PLOT_TEST(embedded_atlas_is_uploadable) {
|
||||
static_cast<plot::f32>(EmbeddedMonoFont::cell_width()), 1e-9);
|
||||
}
|
||||
|
||||
// The committed atlas is the generator's output, not a hand edit, and the
|
||||
// generator is deterministic: the same source font, em size and supersample
|
||||
// factor produce it byte for byte. The check runs the committed script against
|
||||
// the committed file, so a table touched by hand, a stale bake after a coverage
|
||||
// change, or a non-deterministic generator all fail here.
|
||||
//
|
||||
// `fontTools` is an authoring-time tool and the test machine is not required to
|
||||
// have it. Its absence is *reported*, not silently passed: the generator exits
|
||||
// with a distinct code for it, and this case says so on stdout.
|
||||
// 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) {
|
||||
const std::string command = std::string("python3 ") + kSourceDir +
|
||||
"/tools/fonts/gen_atlas.py --check --out " + kSourceDir +
|
||||
"/src/render/embedded_mono_font.cpp";
|
||||
// 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);
|
||||
if (code == 3) { // `gen_atlas.py`'s "fontTools is not installed" code
|
||||
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 -- fontTools is not "
|
||||
"installed, so the atlas could not be re-baked\n");
|
||||
"SKIPPED: generator_is_deterministic_and_current -- %s, so the committed "
|
||||
"atlas could not be re-baked\n",
|
||||
missing);
|
||||
return;
|
||||
}
|
||||
PLOT_CHECK(code == 0);
|
||||
|
||||
@@ -426,6 +426,61 @@ class CountFenceWaits {
|
||||
|
||||
} // namespace
|
||||
|
||||
// The renderer draws a run at the size the style asked for, not at a whole
|
||||
// multiple of the backend's cell, and it reports what it drew. `axis_font_px()`
|
||||
// is that report, and the committed atlas is baked at 13 px, so the default
|
||||
// style's 12 px is a 12/13 scale -- exactly the case the old rule snapped to
|
||||
// 16 px. No other case sets a size other than the default, and the ones that
|
||||
// compare a measure compare values scaled by the same factor, so they hold under
|
||||
// a snapping rule too; this is the one that does not.
|
||||
PLOT_TEST(axis_text_is_drawn_at_the_requested_em_not_a_whole_multiple) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
|
||||
// The renderer's own backend, for the raw metrics the ratio is taken against.
|
||||
const plot::render::EmbeddedMonoFont font;
|
||||
const plot::f32 baked = font.em_px();
|
||||
const plot::f32 cell = font.height();
|
||||
|
||||
plot::Renderer r;
|
||||
r.style().axis_font_px = 12.0f; // the default style's em, against a 13 px bake
|
||||
PLOT_CHECK(r.init(&st));
|
||||
PLOT_CHECK_NEAR(r.axis_font_px(), cell * (12.0f / baked), 1e-3);
|
||||
PLOT_CHECK(std::fabs(r.axis_font_px() - 16.0f) > 0.2f); // 2 x the old 8 px glyph
|
||||
PLOT_CHECK(std::fabs(r.axis_font_px() - 17.0f) > 0.2f); // 1 x this cell
|
||||
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false});
|
||||
p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
const plot::Rect viewport{0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||
r.begin_frame(kW, kH, viewport);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
|
||||
// The plot lays out with the backend the renderer installed, so the size it
|
||||
// measured with is the size it drew with.
|
||||
const plot::render::TextBackend* backend = p.text_backend();
|
||||
PLOT_CHECK(backend != nullptr);
|
||||
PLOT_CHECK_NEAR(backend->em_px(), 12.0f, 1e-3);
|
||||
PLOT_CHECK_NEAR(backend->height(), cell * (12.0f / baked), 1e-3);
|
||||
// `em_px()` is in the wrapper's units, like `height()`: a caller drawing at
|
||||
// `requested_px / em_px()` reads 12/12 = 1 and cannot scale a second time.
|
||||
PLOT_CHECK_NEAR(backend->em_px() / backend->height(), baked / cell, 1e-6);
|
||||
|
||||
// An exact multiple still lands exactly, and the frame re-reads the style.
|
||||
r.style().axis_font_px = 26.0f; // 2 x 13
|
||||
r.begin_frame(kW, kH, viewport);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK_NEAR(p.text_backend()->em_px(), 26.0f, 1e-3);
|
||||
PLOT_CHECK_NEAR(p.text_backend()->height(), cell * 2.0f, 1e-3);
|
||||
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
PLOT_TEST(renders_visible_trace_across_viewport) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
|
||||
Reference in new issue
Block a user