28 KiB
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_coreand every public header underinclude/plot/MUST stay free of third-party code. The narrowed rule: only the optionalplot_font_ttftarget may use the vendoredthird_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.hppor 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/Statusplus an out-param. Do not introduce aResult<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*_bucketsforms. - 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-failureand 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 widenedTextBackendwithglyph(u32),atlas_pixels(),atlas_width(),atlas_height(),revision();TextBatch::add_text(const char* utf8, Vec2 pen, f32 px, TextBackend&, Color, Vec2 axis = {1,0})andadd_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 ataxis = (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 (useL) 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 doubleTextBackendwith a different atlas size that bumpsrevision()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
TextBackendfrom Task 1. -
Produces:
plot::render::EmbeddedMonoFont final : public TextBackend, dropping in whereverBitmapFontwas; andtools/fonts/gen_atlas.pyregeneratingsrc/render/embedded_mono_font.cppdeterministically. -
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.
-
--checkmode: 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— rungen_atlas.py --checkfrom the test (skip with a clear message iffontToolsis absent, but do not silently pass), asserting the committed atlas matches its generator. -
The existing
bitmap_font_bit_order_runs_left_to_rightcase 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_areaas a narrow vertical strip andy_title_widthas 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.wis about one line height andy_title_area.his about the axes height;axesis inset by it; the strip lies outsidey_label_area. -
y_title_ink_is_rotated_and_outside_the_axes— pixel test: ink exists iny_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 insideaxes. 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:
TextBackendmetrics 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 computedLegendLayout(panel rect + per-row rects) thatlayout()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 andaxis_textboth appear inside the panel rect and not outside it; and the presence of the panel does not change trace pixel coverage outsideaxes. -
legend_does_not_cover_the_plot_when_placed_bottom_left— assert the panel rect is insideaxesand 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 andDragMode. -
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}andController::region_grab(u32) -> RegionGrabreporting 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::Figureandplot::FigureLayoutper 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.jsonif 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/-styleCMakeLists.txtfor the target - Modify: top-level
CMakeLists.txt(newPLOT_BUILD_TTF_FONToption, default ON when the header is present),include/plot/plot.hpponly if the header is reachable without stb_truetype — see the note below - Test:
tests/test_true_type_font.cpp
Interfaces:
-
Consumes: the widened
TextBackendfrom Task 1. -
Produces:
plot::text::TrueTypeFontwithstatic bool from_file(const char* path, f32 px, TrueTypeFont* out, Status* st = nullptr)andvoid set_coverage(std::span<const u32>), implementingTextBackend. -
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, andpx <= 0each return false with aStatusand leaveoutuntouched. -
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 therevision()re-upload path). -
plot_core_stays_free_of_stb_truetype— assert the builtplot_corearchive 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"