Files
plotter/docs/superpowers/plans/2026-09-28-plot-interaction-and-fonts.md
T

28 KiB
Raw Blame History

plot interaction, fonts, legend and multi-plot — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the y-axis label vertical, replace the 1-bit bitmap font with an anti-aliased embedded Adwaita Mono plus runtime external-font support, make cursors and areas directly draggable, add a signal legend, and add a multi-plot figure with a synchronised x axis.

Architecture: The text pipeline is refactored first so a TextBackend owns its glyph atlas and staged glyphs carry a rotation basis — that single change is what makes both the new font and the vertical label possible. Everything else is additive: a Legend on the plot, a Region primitive plus direct-manipulation drag modes on the controller, and a Figure that stacks plots and shares an x range through a link group. Renderer::draw(Plot&) stays exactly as it is.

Tech stack: C++20, CMake, OpenGL 3.3 core, fontTools (authoring-time only) for atlas generation, vendored stb_truetype.h for the optional runtime-font target.

Spec: docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md (read it before starting any task).

Global Constraints

  • plot_core and every public header under include/plot/ MUST stay free of third-party code. The narrowed rule: only the optional plot_font_ttf target may use the vendored third_party/stb_truetype.h.
  • Public headers MUST be self-contained; the configure-time umbrella and self-containment checks must keep passing (a new header must be added to plot.hpp or configure fails).
  • C++20; our targets stay clean under -Wall -Wextra -Wpedantic -Wconversion -Wshadow, never suppressed per-file.
  • No exceptions across the public API boundary: fallible operations return bool/Status plus an out-param. Do not introduce a Result<T>.
  • Vertex data stays in DATA space; pan/zoom is a uniform update; per-frame cost stays O(visible pixels + produced samples).
  • The renderer clears only its viewport region via scissor and hands back host GL state (extend the documented residue list for anything new).
  • Exact interval queries go through Series::reduce/sum/sumsq, never the pyramid's *_buckets forms.
  • OpenGL 3.3 core baseline; anything above stays behind plot::gl::caps().
  • The 130 existing test cases keep passing. Any signature change is called out in the task report.
  • Every task runs cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure and commits with the message given in its own section.

Task 1: Text atlas interface and rotated glyphs

Files:

  • Modify: include/plot/render/text.hpp, include/plot/render/bitmap_font.hpp, src/render/bitmap_font.cpp, include/plot/render/primitives.hpp, include/plot/render/buffer.hpp, include/plot/render/program.hpp, include/plot/render/shaders.hpp, src/render/shaders.cpp, src/render/renderer.cpp, include/plot/gl/handle.hpp
  • Test: tests/test_primitives.cpp, tests/test_render_smoke.cpp

Interfaces:

  • Consumes: existing BitmapFont, TextBatch, TextProgram, Texture::create_r8/upload_r8, Renderer.

  • Produces: plot::render::Glyph{u0,v0,u1,v1,xoff,yoff,advance}; the widened TextBackend with glyph(u32), atlas_pixels(), atlas_width(), atlas_height(), revision(); TextBatch::add_text(const char* utf8, Vec2 pen, f32 px, TextBackend&, Color, Vec2 axis = {1,0}) and add_glyph(..., Vec2 axis); the text instance as 6 floats {x,y,u,v,ax,ay}.

  • Step 1: Replace the backend interface and the text instance layout

include/plot/render/text.hpp — add Glyph and widen TextBackend exactly as spec §1 shows. Keep measure/height/ascent as they are so PlotLayout is untouched by this step.

include/plot/render/buffer.hpp — TextInstance becomes {f32 x, y, u, v, ax, ay;} with ax=1, ay=0 meaning "unrotated". Update TextVertexArray's attribute layout and stride accordingly, and document the basis in the header: (ax, ay) is the glyph's local +x direction in pixels, so (0,-1) is a run advancing upward (a 90° CCW y label).

src/render/shaders.cpp — kTextVS builds the cell from the basis and its perpendicular instead of hardcoded +x/+y, and maps UVs consistently with the basis. Delete kGlyphTexels; the cell size comes from u_px_range and the glyph's rect comes from the instance UVs. Required UV convention: (u0,v0) is the glyph's top-left texel and (u1,v1) its bottom-right, with v increasing downward in the atlas.

  • Step 2: Make the renderer upload the backend's atlas and re-upload on revision change

src/render/renderer.cpp — replace the hardcoded 128×56 upload with: upload backend.atlas_pixels() at atlas_width() × atlas_height() (GL_R8, GL_UNPACK_ALIGNMENT 1 while uploading, restored after), remember the revision() used, and re-upload whenever it changes. Handle a null atlas, a zero-sized atlas, and a null glyph() (draw the fallback box using a glyph the backend does provide, or skip if it provides none).

  • Step 3: Tests

Commit in tests/test_render_smoke.cpp:

  • rotated_text_advances_along_its_basis — a run of 6 glyphs at axis = (0,-1) occupies a box taller than it is wide, and the ink is above the pen (negative y), not to its right. Assert with the pixel scanner, and fail if the basis is ignored.
  • rotated_glyph_orientation_is_not_mirrored — an asymmetric glyph (use L) rotated 90° CCW has its ink in the correct corner; compute the expected corner from the basis rather than hardcoding a pixel, so the test states its own rule.
  • atlas_is_reuploaded_when_the_backend_revision_changes — a test double TextBackend with a different atlas size that bumps revision() mid-run must render the new atlas; assert the rendered result changes and that a stale atlas would not produce it.

In tests/test_primitives.cpp: add_text advances the pen by the backend's advances along the basis, and measure and the staged run agree in length for the same string.

  • Step 4: Verify and commit

Run cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure; all cases pass, warning-clean, and each new test fails against the pre-change code (report the failure text).

git add -A && git commit -m "refactor(render): TextBackend owns its atlas, and staged glyphs carry a rotation basis"

Task 2: Embedded Adwaita Mono atlas, generated at authoring time

Files:

  • Create: tools/fonts/gen_atlas.py, licences/OFL-1.1.txt, include/plot/render/embedded_mono_font.hpp, src/render/embedded_mono_font.cpp
  • Delete: include/plot/render/bitmap_font.hpp, src/render/bitmap_font.cpp
  • Modify: every caller of BitmapFont (src/render/renderer.cpp, adapters/*, examples/*, tests/*)

Interfaces:

  • Consumes: the widened TextBackend from Task 1.

  • Produces: plot::render::EmbeddedMonoFont final : public TextBackend, dropping in wherever BitmapFont was; and tools/fonts/gen_atlas.py regenerating src/render/embedded_mono_font.cpp deterministically.

  • Step 1: Write the generator

tools/fonts/gen_atlas.py takes the source TTF path, an em size, and an output path; rasterises the coverage set with fontTools (fontTools.pens, grayscale supersampling with a simple coverage count — do not shell out to a rasteriser); packs glyphs into a single-row atlas in codepoint order using a uniform cell (cell width = the font's maximum advance, cell height = its ascent-to-descent extent; each glyph is drawn into its cell with its bearing preserved, never trimmed to ink, because the pipeline has one rect size per draw — see spec non-goals); emits a C++ translation unit containing the pixel bytes, the per-glyph rect/bearing/advance table, and a header comment with provenance (source font file name, its version string, em size, supersample factor, generator invocation, coverage list, and the licence file path).

Hard requirements:

  • Assert every requested codepoint produced non-empty ink, and exit non-zero if not. A silent blank glyph must be impossible.

  • Be deterministic: same input and em size must produce a byte-identical file (sort inputs, no timestamps, no dict-iteration order dependence).

  • Default coverage: ASCII 32..126, U+00B0, U+00B5, U+03A9, U+2013, U+2014, plus a fallback box glyph stored at a fixed index.

  • --check mode: regenerate into a temp file and fail if it differs from the committed one, so drift is caught in CI.

  • Step 2: Generate and commit the atlas

Run it against /usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf at 13 px em with 4× supersampling. Commit the generated .cpp, licences/OFL-1.1.txt (copied verbatim from /usr/share/licenses/adwaita-fonts/LICENSE), and the generator. Rename BitmapFont to EmbeddedMonoFont in the same commit and migrate every caller — no alias, no re-export; grep -rn "BitmapFont" must return nothing when you are done.

  • Step 3: Tests

  • embedded_atlas_covers_every_expected_codepoint — every codepoint in the coverage set has a glyph whose rect is inside the atlas and whose ink is non-empty; the fallback has non-empty ink.

  • embedded_font_metrics_are_sane — height() > ascent() > 0; measure("i") < measure("W"); measure("") == 0; advance is monotone in string length.

  • generator_is_deterministic_and_current — run gen_atlas.py --check from the test (skip with a clear message if fontTools is absent, but do not silently pass), asserting the committed atlas matches its generator.

  • The existing bitmap_font_bit_order_runs_left_to_right case is replaced by an equivalent absolute-orientation assertion on the new atlas.

  • Step 4: Verify the visual result

Because the whole point is that the old font looked terrible, produce evidence rather than an assertion: render a frame headlessly and report a crop of the axis-label region as ASCII ink coverage, plus the mean stroke weight and the ratio of antialiased (mid-grey) to fully-covered pixels. AA text must show a substantial mid-grey population — a 1-bit font cannot, so this number distinguishes the change.

  • Step 5: Commit
git add -A && git commit -m "feat(render): anti-aliased embedded Adwaita Mono atlas, generated and committed"

Task 3: Vertical y-axis label

Files:

  • Modify: include/plot/plot/plot.hpp, src/plot/plot.cpp, src/render/renderer.cpp
  • Test: tests/test_plot.cpp, tests/test_render_smoke.cpp

Interfaces:

  • Consumes: Task 1's rotation basis; PlotLayout.

  • Produces: PlotLayout::y_title_area as a narrow vertical strip and y_title_width as that strip's width (one line height); the y title drawn bottom-to-top.

  • Step 1: Change the layout

Plot::layout() — y_title_area becomes one text line wide (height() + padding) placed outside y_label_area, spanning the axes' vertical extent; y_title_width is that width. The spec's rule replaces "one row per titled y axis" with "one column per titled y axis", so a Left/Right titled pair still gets its own column and cannot collide. An axis with no title reserves nothing, as before.

  • Step 2: Rotate the title

draw_titles() — stage the x title as today (axis (1,0)), and the y title with axis = (0,-1) starting from the strip's bottom-left, so it reads bottom-to-top. Keep the existing rule that a title is never drawn inside axes.

  • Step 3: Tests

  • y_title_strip_is_narrow_when_a_y_title_is_set — with a y title, y_title_area.w is about one line height and y_title_area.h is about the axes height; axes is inset by it; the strip lies outside y_label_area.

  • y_title_ink_is_rotated_and_outside_the_axes — pixel test: ink exists in y_title_area; its bounding box is taller than it is wide for a title that is wider than tall when horizontal; and no title-coloured ink appears inside axes. Must fail if the axis basis is left at (1,0).

  • two_titled_y_axes_get_two_columns — a Left and a Right titled axis occupy distinct x ranges in the strip.

  • Step 4: Verify and commit

Also run the GLFW demo headlessly and report the y-label region from its own frame so the change is visible, not just asserted.

git add -A && git commit -m "feat(plot): vertical y-axis label via a rotated text run"

Task 4: Signal legend

Files:

  • Create: include/plot/plot/legend.hpp
  • Modify: include/plot/plot/trace.hpp, include/plot/plot/plot.hpp, src/plot/plot.cpp, src/render/renderer.cpp, include/plot/plot.hpp (umbrella)
  • Test: tests/test_plot.cpp, tests/test_render_smoke.cpp

Interfaces:

  • Consumes: TextBackend metrics via the plot's backend, Trace, PlotLayout.

  • Produces: Trace::name; plot::LegendPlace; plot::Legend{visible, place, frame, pad, swatch_w, swatch_h, row_gap, margin}; Plot::legend(), Plot::set_legend(const Legend&), and a computed LegendLayout (panel rect + per-row rects) that layout() exposes so tests and the renderer agree.

  • Step 1: Trace name and legend config

Add std::string name; to Trace (empty means no entry), and the Legend struct per spec §4 with the stated defaults. Add the legend accessors to Plot and include the new header from the umbrella.

  • Step 2: Legend layout

Compute it in Plot::layout() from the plot's TextBackend: entry rows are max(swatch_h, line_height) tall with row_gap between; the panel is pad + swatch_w + gap + widest_name + pad by pad + rows + pad, anchored per place and inset by margin, clamped inside axes. If the panel does not fit, drop entries from the end and add a … row; if nothing fits, produce an empty layout. No backend ⇒ no legend (nothing to measure), and a plot whose traces are all unnamed produces an empty layout and draws nothing.

  • Step 3: Draw it

In draw() after the grid and traces, before text is flushed: a filled panel in style.background at partial alpha when frame is set, a 1 px border, a filled swatch per entry in that trace's colour, and the name in axis_text. A trace bound to a null series or marked invisible contributes no entry. Clamp so the panel cannot cross axes.

  • Step 4: Tests

  • Layout: legend_panel_wraps_the_entries, legend_is_clamped_inside_the_axes (place each of the four corners), legend_drops_overflowing_entries, unnamed_traces_produce_no_legend, no_text_backend_produces_no_legend.

  • Pixel: legend_swatch_and_label_are_drawn — the swatch colour and axis_text both appear inside the panel rect and not outside it; and the presence of the panel does not change trace pixel coverage outside axes.

  • legend_does_not_cover_the_plot_when_placed_bottom_left — assert the panel rect is inside axes and that no swatch pixel lands outside it.

  • Step 5: Commit

git add -A && git commit -m "feat(plot): per-trace legend with measured layout and swatches"

Task 5: Direct manipulation — grabbable cursors, regions and band edges

Files:

  • Create: include/plot/plot/region.hpp
  • Modify: include/plot/plot/plot.hpp, src/plot/plot.cpp, include/plot/input/controller.hpp, src/input/controller.cpp, src/render/renderer.cpp, include/plot/plot.hpp (umbrella)
  • Test: tests/test_controller.cpp, tests/test_plot.cpp, tests/test_render_smoke.cpp

Interfaces:

  • Consumes: Plot::layout(), the axis transforms, the existing cursor hit radius and DragMode.

  • Produces: plot::Region, plot::RegionSnap, plot::RegionStats; Plot::add_region/region/set_region/region_stats/remove_region; DragMode::MoveRegion, DragMode::ResizeRegionLeft, DragMode::ResizeRegionRight; plot::input::RegionGrab{None, Left, Right, Body} and Controller::region_grab(u32) -> RegionGrab reporting the hot zone.

  • Step 1: The Region primitive

Add the type and the plot accessors per spec §5. Bounds are ordered on assignment (set_region stores min/max, so x0 > x1 cannot be stored). region_stats(region, trace) is exact via Series::reduce/sum/sumsq over the region's sample interval, and caches on (region, trace, series identity, sample count, x0()) exactly as Plot::measurement does after its final-review fix — the sliding-window term included, or a streaming region shows frozen statistics, which is the bug that fix existed to prevent.

  • Step 2: Cursors grabbable without a modifier

Controller::begin_drag — hit-test cursor markers first, before pan, using the same radius hovered_cursor() already uses; a hit enters MoveCursor with no modifier required. Ctrl+drag keeps working (it must not regress into a different mode). Add Controller::grabbed_cursor() and expose the hovered/grabbed cursor to the renderer so the marker can be drawn highlighted.

  • Step 3: Region drag zones

Resolve the hot zone nearest-first: an edge within the radius beats the body; a region narrower than 3 px cannot be grabbed by its edges (so it stays movable once collapsed). MoveRegion translates both bounds preserving width; the resize modes move one bound and re-order via set_region. Snapping follows RegionSnap and applies on release. Pressing where no handle is hot preserves today's pan/box-zoom behaviour exactly.

  • Step 4: Draggable measurement-band edges

Hit-test the cursor-bound band's two edges in the same pass, and map a grab to the cursor that owns that edge, so an existing measurement band can be resized by dragging either end while Plot::measurement updates as it does today. No new plumbing: it is the cursor drag path with a different hit target.

  • Step 5: Draw regions and highlights

Filled Region::fill over the x interval between the axes bounds, a Region::edge line at each bound, an optional label, the grabbed/hovered handle highlighted. Extend renderer.hpp's documented GL-state residue list if anything new is left behind.

  • Step 6: Tests

Controller: cursor_is_grabbed_without_a_modifier; pressing_away_from_a_handle_still_pans; region_edge_wins_over_region_body; region_body_move_preserves_width; region_bounds_stay_ordered_when_dragged_past_each_other; collapsed_region_cannot_be_grabbed_by_an_edge; band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement. Plot: region_stats_match_bruteforce_over_an_unaligned_interval; region_stats_track_a_sliding_streaming_window (the saturated-ring case). Pixel: region_fill_and_edges_are_drawn_inside_the_axes; grabbed_handle_is_highlighted.

  • Step 7: Commit
git add -A && git commit -m "feat(input): draggable cursors, regions and measurement-band edges"

Task 6: Figure — stacked plots with a synchronised x axis

Files:

  • Create: include/plot/plot/figure.hpp, src/plot/figure.cpp
  • Modify: include/plot/render/renderer.hpp, src/render/renderer.cpp, include/plot/plot.hpp (umbrella)
  • Test: tests/test_figure.cpp, tests/test_render_smoke.cpp

Interfaces:

  • Consumes: Plot, PlotLayout, Renderer::draw(Plot&), the controller's range setters.

  • Produces: plot::Figure and plot::FigureLayout per spec §6; Renderer::draw(Figure&); Figure::plot(u32) row access, add_x_link(), link_x(u32 row, u32 group), set_x_link_range(u32, Range), set_row_weights(std::span<const f32>), set_x_labels_only_on_bottom(bool).

  • Step 1: Layout

FigureLayout holds std::vector<Rect> rows, a shared bottom x-axis strip, and the inter-row gap. Row heights come from equal division or set_row_weights (normalised; a zero/negative weight set is rejected with Status rather than producing a degenerate rect). Each row's PlotLayout is derived by setting that plot's viewport to its row rect — so a row's own y-axis strips still come from its own Plot::layout(). Give every row a floor height so chrome cannot erase a plot (the same invariant the title strips needed).

  • Step 2: The x link

A link group holds one Range. link_x moves a plot into a group and immediately applies that group's range to it. set_x_link_range sets the group and pushes to every member. A range change arriving from the controller on row i is applied to the group and pushed to all members before the next frame. Propagation must be idempotent and must terminate: a member receiving a propagated range must not re-propagate it — enforce this with an explicit flag or by comparing ranges before assigning, and test it.

  • Step 3: Render

Renderer::draw(Figure&) — for each row: plot.set_viewport(row rect), then the existing single-plot draw path; then draw one shared x axis under the bottom row. Interior rows suppress their x label strip when set_x_labels_only_on_bottom(true) (the default), so labels are not duplicated. draw(Plot&) is untouched.

  • Step 4: Tests

  • Layout: row_rects_tile_the_figure_without_overlap, row_weights_change_relative_heights_and_sum_to_the_viewport, degenerate_row_weights_are_rejected, every_row_keeps_a_positive_height.

  • Link: link_x_applies_the_group_range_immediately, setting_a_range_on_any_member_propagates_to_all, propagation_is_idempotent_and_terminates (drive a long alternating sequence and assert it converges in one pass), unlinked_plots_keep_independent_ranges.

  • Pixel: two_rows_render_distinct_content (different trace colours land in different row rects), shared_x_axis_is_drawn_once (count label ink in the bottom strip only, and none in the interior rows' strips when labels are bottom-only), a_row_resize_does_not_rescale_a_stale_layout.

  • Step 5: Commit

git add -A && git commit -m "feat(plot): Figure with stacked rows and a synchronised x axis"

Task 7: Multi-plot example, legends everywhere, full verification

Files:

  • Create: examples/multiplot_demo.cpp
  • Modify: examples/{glfw,imgui,qt,gtk}_demo.cpp, examples/CMakeLists.txt, CMakePresets.json if needed
  • Modify: README-class docs only if one exists (it does not — do not create one)

Interfaces:

  • Consumes: everything above.

  • Step 1: The multi-plot example

examples/multiplot_demo.cpp: a 3-row Figure on the GLFW host. Row 1 carries three named signals (a sine, a square, and a noisy ramp) on a shared y axis with a legend; row 2 carries two signals at a different scale; row 3 carries a single sparse signal. All three rows share one x link, so panning or zooming anywhere moves all three together. Every signal is named so the legend is populated. Add two cursors and one region with a stats readout, and drive it at a high sample rate through a StreamSeries so the sync is exercised while data slides. Reuse the project's PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT headless hooks.

  • Step 2: Legends and interaction in the existing demos

Add names to the traces and enable the legend in all four existing demos, and add one region to at least one of them so the new interaction is reachable in the GLFW and Qt demos (the two with the richest input paths). Do not restructure those demos otherwise.

  • Step 3: Full verification
cmake --preset linux-debug   && cmake --build build/debug   && ctest --test-dir build/debug --output-on-failure
cmake --preset linux-asan    && cmake --build build/asan    && ctest --test-dir build/asan  --output-on-failure
cmake --preset linux-release && cmake --build build/release

Run all five demos headlessly with their own screenshot paths and confirm, from each PPM: trace pixels present, axis text present, legend swatch colours present where a legend is on, and the region fill present. Then report each demo's measured frame time against the pre-change baselines (GLFW ~0.7-1.3 ms, Qt ~3.6 ms, GTK ~1.1-5.3 ms at 810x564, ImGui ~0.5-0.76 ms) so any regression is visible.

  • Step 4: Commit
git add -A && git commit -m "feat(examples): multi-plot synced demo, legends in every demo, verified headlessly"

Task 8: Optional runtime TrueType font target

Independent of Tasks 3-7 (it needs only Task 1's interface), so it can be done last. It exists because the request was for a font that is both embedded and user-customisable from external files.

Files:

  • Create: third_party/stb_truetype.h, include/plot/text/true_type_font.hpp, src/text/true_type_font.cpp, adapters/-style CMakeLists.txt for the target
  • Modify: top-level CMakeLists.txt (new PLOT_BUILD_TTF_FONT option, default ON when the header is present), include/plot/plot.hpp only if the header is reachable without stb_truetype — see the note below
  • Test: tests/test_true_type_font.cpp

Interfaces:

  • Consumes: the widened TextBackend from Task 1.

  • Produces: plot::text::TrueTypeFont with static bool from_file(const char* path, f32 px, TrueTypeFont* out, Status* st = nullptr) and void set_coverage(std::span<const u32>), implementing TextBackend.

  • Step 1: Vendor and isolate

Download stb_truetype.h (public domain) into third_party/ with its version and the source URL recorded in a comment at the top of our own wrapper, and in third_party/README-equivalent text inside the wrapper header — do not create a new markdown file. Define STB_TRUETYPE_IMPLEMENTATION in exactly one translation unit inside the new target. Confirm with nm/ldd that plot_core and the public headers are untouched: plot_core must link and include nothing new.

  • Step 2: The backend

Implement TrueTypeFont over stb_truetype: bake the coverage set at px into a single-row R8 atlas of uniform cells (cell width stbtt_ScaleForPixelHeight applied to the font's maximum advance, cell height its ascent-to-descent extent; each codepoint's trimmed bitmap from stbtt_GetCodepointBitmap is blitted into its cell at the offset stbtt_GetCodepointBitmapBox reports, so the bearing is preserved and the atlas rect stays uniform), build the glyph table from stbtt_GetCodepointHMetrics, and implement the full TextBackend, including revision() (monotone; bumped by set_coverage and by re-baking). Declare the cell size the backend will report; do not let it vary per glyph. Reject a missing file, an unparsable font, a non-positive px, and a bake that produced no ink, filling Status.

Important: do not add this header to include/plot/plot.hpp. The umbrella must stay compilable with no third-party header on the include path, and the configure-time coverage check will reject a header it cannot compile standalone. Keep plot/text/true_type_font.hpp self-contained (it may include plot/render/text.hpp and the standard library, and must forward-declare nothing from stb_truetype), and document that it is built only by the optional target.

  • Step 3: Tests

  • ttf_backend_bakes_the_requested_coverage — load the Adwaita Mono TTF from the system path if present, else skip with a printed reason and a non-zero-skipped marker; assert every requested codepoint has non-empty ink and a plausible advance.

  • ttf_failure_paths_report_status — a nonexistent path, a directory path, a text file, and px <= 0 each return false with a Status and leave out untouched.

  • ttf_backend_renders_through_the_renderer — bake at a size clearly different from the embedded font, render a frame with it as the plot's text backend, and assert the ink differs from the embedded font's for the same string (proving the renderer genuinely uses it, including the revision() re-upload path).

  • plot_core_stays_free_of_stb_truetype — assert the built plot_core archive contains no stb_truetype symbols and that no public header mentions it.

  • Step 4: Verify and commit

Build with PLOT_BUILD_TTF_FONT=OFF as well and confirm everything still configures and passes, so the optional target is genuinely optional.

git add -A && git commit -m "feat(text): optional vendored stb_truetype backend for runtime external fonts"