Commit Graph
44 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 37e414bcc5 feat: grade a sparse window's polyline by slope, so phosphor does not switch off on zoom
Intensity grading was gated on the column reduction, which is gated on a
density whose only job is killing the moire beat. Dwell has nothing to do
with that, so narrowing a window past the threshold turned the whole
phosphor model off and a discontinuity went from a third of a flat top's
ink to all of it in one pixel of zoom.

stage_trace_slopes() grades each segment by its own slope instead, with a
measure that agrees with the dense path at the boundary, and reports
itself as FrameStats::slope_traces -- disjoint from column_traces, so a
host asserting graded_traces == column_traces reads the same as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:10 +02:00
Martino FerrariandClaude Opus 4.6 cc908efa36 feat(render): a reduced column can be graded by dwell, not drawn as one flat band
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
A pixel column reduced from a dense envelope is honest about where the signal
went and silent about how long it was there: its min and max are identical
whether its samples fill the span or pile at its two ends, so a ramp and a pulse
are one band. Style::intensity_grading grades it instead -- bright where the
signal sits, faint where it only passes through -- by asking the gather for a
level finer than the pixel grid and reading the distribution from those
sub-buckets. Off by default: a change of look, not a correction of one.

The measure is dwell per row, each segment depositing one unit spread over the
rows it spans; counting +1 per row instead grades a sine's fast middle as
brightly as its rails, which is the pattern inverted. The accumulator is one
column wide (a full w*h density image is 33 MB at 4K) with each span written as
two delta writes, and normalisation is against the column's own peak -- a
frame-wide scale fades the trace out exactly where it moves fastest. Alpha is
quantised into tiers, one LineBatch and one draw call each, because the line
pipeline's colour is a uniform and a per-segment alpha is inexpressible.

The finer ask is floored at level 1 whenever the flat path would have used the
pyramid: level_for() answers 0 -- raw samples -- as soon as a sub-column falls
under one bucket, so an unfloored ask turns a moderately dense frame into an
O(visible) gather (measured at 172 samples a pixel: raw=1154 lod=0 and 6.4 ms a
frame against raw=710 lod=453 and 2.3 ms). Grading is a way of drawing a
reduction and may not undo it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 03:02:36 +02:00
Martino FerrariandClaude Opus 4.6 e81e84645f fix(render): a dense trace is drawn one column at a time, not as a polyline
An LOD envelope staged as a polyline moirés. level_for() sizes a bucket at one to
two pixel columns and Envelope emits two points per bucket, so a column carries
three or four near-vertical hairlines at as many sub-pixel phases; under src-alpha
compositing its brightness is then a non-linear function of those phases, the
pattern repeats at the beat between the bucket pitch and the pixel pitch, and a pan
sweeps the beat so the banding crawls.

draw_traces now reduces such a window onto the pixel grid: one exactly vertical
segment per integer column of the axes, from the lowest to the highest screen y any
gathered point put in it, flushed in framebuffer pixels with the snap on. One phase
for the whole trace, so there is nothing left to beat -- and the geometry becomes
O(axes.w) rather than O(buckets).

Gated on the gathered density rather than on the level (the raw path beats the same
way), and on Envelope alone. A hole stays a hole, disjoint neighbours are joined so
a fast edge keeps it, and a span under the stroke is drawn as the stroke since the
shader caps nothing. It is the one carve-out in u_snap's data/chrome rule: a column
is a pixel column, and the y bounds it reports are untouched.

Measured by the new test as the number of distinct coverage levels in the ink --
4 against the polyline's 126, with 627 segments against 60 054. A count of
partially covered pixels was tried first and separates nothing (577 against 652):
a 1.5 px stroke's fringe always spills into the next column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 02:00:25 +02:00
Martino Ferrari c2c8c596f3 fix(data): the gather origin is the caller's, so a deep zoom draws on the sample
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
`Series::gather()` stages x as f32 relative to `x0()` -- the start of the
*retained window* -- so the value's ULP was a fraction of data the view need
not even hold: 1.9 us over a 20 s ring. A readout quotes `x_at()` in exact
f64, so at a 10 us view across 700 px that put more than a hundred pixels
between where a sample is drawn and the x reported for it.

The origin is now a parameter, placed before `out_xy` so every call site is a
type error rather than a silent change of behaviour. `Renderer::draw_traces`
passes the visible range's minimum, which makes the error a fixed 2^-24 of the
span on screen -- 4e-5 of a pixel over a 700 px axis -- at every zoom.

`stage_xy()` loses its second origin as a consequence: the gather origin and
the draw origin are now always the same value, so the linear case hands the
gathered buffer straight to `add_polyline_xy()` with no per-point copy.

`Plot::crosshair_point()` names the query itself, the one origin that cannot
lose precision for a caller that might.

Guarded by `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`,
which measures the error in pixels both ways: under 0.05 px with the view's
minimum, over 10 px with `x0()`.
2026-10-05 18:43:50 +02:00
Martino FerrariandClaude Opus 4.6 59c2e45af7 feat(plot): an x axis caption drawn as chrome, not as an annotation
A host that re-anchors its x labels on a grid has to name the anchor somewhere, and
an `Annotation` cannot serve: its anchor is a data coordinate, so the caption crawls
across the plot under a pan and drifts under a zoom.

`Plot::set_x_note()` takes the host's own string -- the host is the only one that
knows how much precision the sum of a label and its caption needs -- and
`Renderer::draw_x_note()` places it in the axes' bottom-left corner in pixels, on the
legend's own backplate. It is measured in the renderer rather than in `Plot::layout()`
because it reserves no space and nothing can be picked on it, and a caption explaining
the x labels must not be able to move the layout those labels were measured into.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:21:16 +02:00
Martino Ferrari c960decaea feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice
The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.

`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.

`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
2026-10-05 14:36:47 +02:00
Martino Ferrari 7dbe4e7183 feat(render): mark the samples of a window sparse enough to see them
`Style::marker_below_samples` (50 by default, `0` for never) draws a `marker_px` dot on every
*raw* sample when the visible window holds at most that many, in the trace's own colour. Zoomed
in far enough to see individual samples, the polyline between them stops being the shape a reader
is looking at -- a handful of points across a whole panel ARE the picture -- and a dot makes each
one a place rather than a bend in a line. Level 0 only: above it the geometry is pyramid buckets,
which are not points anyone can find.

Test: a_sparse_window_marks_its_samples -- the same flat series at ten samples and at a hundred.
Ten gives exactly ten dots, evenly spaced (their centres are checked against the panel's own
step, so "they are the samples" is asserted rather than "something was painted"); a hundred is
past the threshold and gives none. The dots *group* into runs of columns (a marker is `marker_px`
wide), which is how the test reads one marker per sample -- counting columns read three.
2026-10-03 15:39:49 +02:00
Martino Ferrari c4c0a49466 style(grid): brighter major grid
grid_major 0x2A3038 -> 0x39414C, still well under axis_line's 0x8A94A6, so the three-way
hierarchy the eye reads stays minor < major < axis. Already fully opaque, so "more
prominent" meant a brighter colour and nothing else.

The tests that classify grid pixels take their colours from the style, so they followed
the change; the one hardcoded mention (grid_pixel()'s doc comment) is updated, and the
minor's relationship to the major is now stated as an order of magnitude rather than the
fifth the old colours happened to give. 361 cases, 0 failed.
2026-10-03 14:42:06 +02:00
Martino Ferrari 01e410bd74 style(grid): the minor grid is thinner, dashed and blended into the background
Subdivisions used to be drawn exactly like the labelled lines -- same width, same
opacity -- so a plot dense enough to need them read as two competing sets of ticks.
The minor class is now three things the style states explicitly:

- grid_minor_width 1.0 -> 0.75, a sub-pixel logical width. At 1x that is expressed as
  reduced coverage through the anti-aliased ramp; on a HiDPI display the line is
  literally narrower.
- grid_minor_dash_on/_off (new, 3/3): dashed by default, off == 0 draws it solid, the
  same convention as the crosshair's and a trace's dash. The dash is a fragment-shader
  discard, so no geometry is added -- the vertices are the line either way.
- grid_minor carries alpha (0x1C2126 at 50%), and the line shader applies u_color.a,
  so it blends into the background instead of sitting on it.

Together that is about a fifth of the ink a major line leaves along its length, which
is what gives the eye one grid to read instead of two. The major class is untouched:
it stays the structure.

Tests: a new pixel test (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major)
asserts a major column is inked top-to-bottom while a minor one alternates, and that
the minor's ink sits closer to the background than the major's. Three existing tests
counted the minor class as "most of eight rows" of its exact colour; two now take
their floor from the style (minor_line_ink: width x alpha x duty), and the width test
states the dash and alpha away because widths are its subject. 361 cases, 0 failed.
2026-10-03 14:31:57 +02:00
Martino FerrariandClaude Opus 4.6 525759f9a9 fix(render): filter the glyph atlas, snap chrome hairlines to pixels
Text was point-sampled (`GL_NEAREST` plus `floor(v_uv * u_tex_size)`), which
at a non-integer scale *deletes* texels instead of blending them. The library's
own default -- a 12 px style over the 13 px embedded bake -- dropped one of
every 8 columns and one of every 17 rows of every glyph, so whole strokes went
missing and which ones depended on where the unsnapped pen fell. `kTextFS` now
does a 2x2 bilinear fetch clamped to the instance's own cell: the clamp keeps
the no-bleed guarantee the sampler mode protects (the atlas is a tight strip of
cells with no padding), and at scale 1 with a whole pen the weights are exactly
0, so it stays as crisp as the point sample.

Grid and axis lines were staged in data space and mapped in the vertex shader,
so a 1 px line's centre landed on an arbitrary fraction -- x = 100.0 lights two
columns at half coverage, x = 100.5 lights one crisply, and a frame mixed both.
`u_snap` rounds an axis-aligned segment's ink *leading edge* onto a pixel
boundary, which reduces to integer+0.5 for odd widths and to an integer for
even ones with no case analysis. It is on for the chrome that frames a plot and
off for anything drawn at a data position, since the rounding moves a line by
up to half a pixel. Diagonals are untouched.

`text_pixel`'s threshold drops to 160 because filtered glyphs legitimately peak
at 0.844 coverage at 12/13; the 180 threshold found text only while the sampler
was also deleting a column and a row of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-02 00:22:08 +02:00
Martino Ferrari 38ef459897 feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
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.
2026-09-30 17:32:06 +02:00
Martino Ferrari 2fbd1de7d4 fix(render,input): guard the zoom gain, clip annotation captions, no-throw dump
Three findings reachable by ordinary use:

* `Controller::axis_zoom()` divided by `Plot::ui_scale()` directly, though
  `Style::device()` documents a non-positive scale as host state that must be
  tolerated. With `ui_scale == 0` and a negative drag delta, `logical_dy` was
  `-inf`, the factor `+inf`, and the guard `!(factor > 0.0)` did not reject it:
  `zoom_x_axis`/`zoom_y_axis` computed `anchor + (bound - anchor) * inf`, and
  `set_y_range()` stored it verbatim and cleared `auto_scale` -- an axis left
  non-finite until an auto-fit. A negative scale silently inverted the zoom.
  A scale that is not positive and finite now means no zoom at all.

* `draw_annotations()` stages its caption into the frame's single text batch,
  which `flush_text()` draws *after* `clear_clip()`, so a caption staged while
  the axes clip was engaged was rasterised with the viewport clip. An
  annotation anchored outside the visible range kept a caption with no cross,
  and in a `Figure` -- whose rows tile the figure -- that caption painted into
  the neighbouring row. A caption whose anchor is outside `axes` is now skipped
  with its cross, rather than splitting the frame's one text draw call (which
  would cost a draw call per row and mis-clip the labels staged outside `axes`).

* `dump_frame_ppm()` was the one public entry point not `noexcept`, so a
  `std::bad_alloc` crossed the API boundary. Its buffers are now sized before
  anything is on disk and a failure is reported as `ErrorCode::OutOfMemory`,
  exactly as `TrueTypeFont::from_file()` reports it.

Tests: `ui_scale` 0 and -1 leave the axis range finite and unchanged with
`auto_scale` intact; an annotation anchored outside a bottom figure row's y
range leaves no caption ink in the row above, while an in-range one still
draws; a `static_assert` pins the dump's `noexcept`.
2026-09-28 22:36:34 +02:00
Martino Ferrari 81792dcd05 docs: correct three header comments that had drifted from the code 2026-09-28 22:17:55 +02:00
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00
Martino Ferrari eee7c34dd5 feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable 2026-09-28 20:17:01 +02:00
Martino Ferrari a14f333dbe fix(plot,render,glfw): a hue-neutral crosshair, an em-dash placeholder, one layout per figure event, and a usable readout switch 2026-09-28 19:38:30 +02:00
Martino Ferrari 10d5129fbf refactor(render): share the readout panel and swatch drawing with the legend 2026-09-28 19:25:33 +02:00
Martino Ferrari 8e27e22ebe test(plot): pin the crosshair's lookup, readout, placement, sync and clip 2026-09-28 19:07:22 +02:00
Martino Ferrari fd0c471430 feat(plot): crosshair with an interpolated per-signal hover readout 2026-09-28 18:57:33 +02:00
Martino Ferrari 93ad774281 fix(render): keep a cursor parked on an axis bound inside and visible 2026-09-28 17:49:18 +02:00
Martino Ferrari 63f9655ae1 feat(plot): horizontal rulers alongside the vertical cursors 2026-09-28 17:18:43 +02:00
Martino Ferrari 2d819c4228 fix(render): clip grid, traces, regions and cursors to the plot area 2026-09-28 17:00:02 +02:00
Martino Ferrari d853db62ae fix(text): carry a glyph's negative bearing and harden the bake's extents 2026-09-28 16:32:18 +02:00
Martino Ferrari 14d664a246 feat(text): optional vendored stb_truetype backend for runtime external fonts 2026-09-28 16:07:27 +02:00
Martino Ferrari 770da4c2b2 fix(plot): a figure row's strip shows labels and title independently, and the shared axis is the bottom row's strip 2026-09-28 15:07:39 +02:00
Martino Ferrari 8f1e60a105 feat(plot): Figure with stacked rows and a synchronised x axis 2026-09-28 14:42:17 +02:00
Martino Ferrari e4d48cc6c3 wip(plot): region layout, hot handles and draggable handles (build restored) 2026-09-28 13:28:51 +02:00
Martino Ferrari eaae07d8e0 feat(render): independent major and minor grid classes tied to the tick set 2026-09-28 12:42:22 +02:00
Martino Ferrari 69495a3799 fix(render): convert Trace::width through the UI scale, and fold the controller's tolerance conversion into Style::device() 2026-09-28 12:26:06 +02:00
Martino Ferrari 7500fc90d2 feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size 2026-09-28 12:17:53 +02:00
Martino Ferrari 2d51b978d7 feat(plot): per-trace legend with measured layout and swatches 2026-09-28 11:56:06 +02:00
Martino Ferrari daeb31b1ea feat(plot): vertical y-axis label via a rotated text run 2026-09-28 11:14:36 +02:00
Martino Ferrari 1cb5970757 fix(render): environment-aware atlas drift guard, pinned text scale, shipped-atlas coverage 2026-09-28 10:54:00 +02:00
Martino Ferrari 5ca3587325 feat(render): anti-aliased embedded Adwaita Mono atlas, generated and committed 2026-09-28 10:40:23 +02:00
Martino Ferrari 3ff7b9c978 fix(render): gate TextAtlas::sync on the loader, and cover three review findings
Review of the atlas refactor found one Important and three Minor issues:

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

Cases: text_atlas_reports_a_status_when_the_entry_points_are_missing,
add_text_skips_a_codepoint_the_backend_cannot_draw.
2026-09-28 10:22:38 +02:00
Martino Ferrari ffc060d6ca refactor(render): the atlas is a declared fixed-cell grid, and staging checks it
The text pipeline stages a glyph's rect *origin* per instance and carries the
rect's *size* once per draw, so a backend whose glyphs have differing rect sizes
-- a TrueType packing trimmed to ink, which is Task 8's shape -- would have drawn
every glyph at whichever size it happened to be told, with nothing downstream
able to tell. Make that constraint structural rather than documented:

- TextBackend::cell_uv() declares the one cell size, and the contract (an atlas
  is a fixed-cell grid; a proportional face draws its glyphs *into* a uniform
  cell instead of trimming each rect to its ink, and why the pipeline needs it)
  is stated on the atlas accessors.
- glyph_is_cell() is the check; add_text()/set_cell() declare the cell from the
  backend, and the staging path asserts it for every glyph in a debug build.
  A release build draws on with the cell it was told, as before.
- Renderer::flush_text() takes u_glyph_uv from the backend, not from the batch,
  so the draw's one rect size can never come from whichever glyph was staged
  first.
2026-09-28 10:09:50 +02:00
Martino Ferrari e074f57ddf refactor(render): TextBackend owns its atlas, and staged glyphs carry a rotation basis 2026-09-28 09:57:28 +02:00
Martino Ferrari a08b184481 fix(plot,render,gl): live streaming measurements, handed-back GL state
Final whole-branch review fixes.

1 (Important). Plot::measurement() cached on (series pointer, sample count)
only, so a saturated StreamSeries -- whose size() stays pinned while x0()
advances every acquire() -- froze its readout on the first saturated frame
until a cursor moved. The cache key now carries the series' x0() (new
MeasureSlot::last_x0), so a sliding window recomputes and a static series
still does not: asserted with a new macro-gated counter hook
(plot/detail/measure_count.hpp, PLOT_COUNT_MEASURE_RECOMPUTES, wired like the
pyramid and ring counters, absent from the release library). New case
saturated_stream_measurement_tracks_the_sliding_window: saturated ring,
stationary cursors, ramp in absolute x -- zero recomputes for repeated reads
with nothing moving, one per acquire while it slides.

2-3, 9. Spec: 4.3 described a GL_LINE_STRIP path that does not exist (there is
one rasteriser -- instanced anti-aliased quads -- and dense_threshold_spp
selects the gather level, not a primitive); 8 named a PLOT_GL_CHECK() macro
that exists nowhere, and stated no exception policy. The table, the field's
comment and 8 now describe the real gather-level threshold, the real
glGetError() drain plus the stores' drop-to-orphan fallbacks, and the real
policy: library failures return Status/bool, allocating entry points propagate
std::bad_alloc.

4. The frame handed back neither GL_UNPACK_ALIGNMENT (init() sets 1 for the
atlas) nor the clear colour (queried once with a new glGetFloatv entry).
end_frame() restores both and renderer.hpp lists them in the residue
contract.

5. draw() kept a host TextBackend for layout while drawing with its own font.
The renderer now installs its font unconditionally -- strips and labels measure
and draw with one backend -- with a debug-build diagnostic if a different one
was set, and Plot::set_text_backend() is documented as the no-renderer path.
New case renderer_measures_the_labels_it_draws asserts layout, measuring and
the drawn label's ink agree.

6-10. draw()'s style overwrite stated at the call site; the load-sensitive
wall-clock bound in test_measure.cpp replaced by a counter assertion (same
interval, same fold count over a 512x length ratio); dump_frame_ppm drops
noexcept and says why it may allocate; error.txt ignored (not this project's,
not generated here) rather than deleted.

Verified: debug and release build clean (-Wall -Wextra -Wpedantic -Wconversion
-Wshadow), 4/4 ctest entries pass, 130 cases pass (was 127), umbrella and
header self-containment checks pass, and no counter symbol in the release
archive.
2026-09-28 03:42:34 +02:00
Martino Ferrari 3c0e7763ab fix(render): one fence per frame for the vertex ring; two qt6 adapter fixes
The persistent-mapped instance ring had three slots and recorded a fence per
upload, so a frame recycled a slot whose fence was recorded a few draws earlier
*in that same frame*: from the fourth of the demos' nine uploads on, each one
waited on the frame's own work. It is free on a driver that retires draws
promptly (the GLFW host, 1.07 ms/frame) and ~4 ms of the Qt widget's 7.11 ms
under llvmpipe, and it grows with the trace and cursor counts this library
exists for.

* The ring grows to cover a frame's upload count, the frame's write cursor is
  the ring index (so a frame writes 0, 1, 2, ... and never a slot twice), one
  fence is recorded after the frame's last draw, and the only wait is at the
  start of the next frame. A wait that times out, or a slot written with no
  fence ever recorded behind it, drops the store to the orphan path -- the
  treatment a refused fence already had -- so nothing is ever written into
  storage the GPU may still be reading.
* New test-only counters (PLOT_RING_STATS, the PLOT_PYRAMID_COUNT_* pattern:
  free functions, no member, so no consumer's layout changes) pin a frame's
  upload and wait counts. The new tests assert the frame's slot offsets are
  distinct, that the ring grew past its floor to cover the frame, and that a
  frame waits at most once per store -- read from the counters and from
  glClientWaitSync itself.
* qt6: print the unresolved entry-point names before the load-failure branch,
  which used to return first and leave the one failure this widget can have
  reporting nothing but a generic message.
* qt6: the cached pointer anchor is now in logical pixels and a move's delta is
  the logical difference scaled by the DPR in effect, so a scale change between
  two move events (the window dragged onto another monitor mid-drag) is no
  longer a cross-scale jump applied as a pan. The anchor moved into a
  toolkit-free unit (adapters/qt6/qt_pointer.hpp) with its own test.

GLFW demo headless: 1.07 -> 0.73 ms/frame. Qt widget under xvfb/llvmpipe:
7.1 -> 3.8 ms at dpr 1.0, 6.7 -> 3.6 ms at 1.25, with both upload paths still
rendering and the demo's scripted pan/box-zoom checks passing at both scales.
2026-09-28 01:38:09 +02:00
Martino Ferrari 1f5aa11de2 fix(render): floor the axes so chrome cannot erase it, one title row per titled axis
Two findings on the pass-1 fix:

- The reserved strips were clamped only to the viewport, so a title wider than a
  narrow panel took the rest of it and the axes collapsed: draw_traces() returned
  on a zero-width rect (no trace drawn, nothing reported) and a zero-height one
  flattened every trace onto a single row. The chrome is now capped at half the
  viewport -- the label strip is served first, the title strip takes what is left
  of that allowance -- an over-long title is clipped to its strip by a walk that
  both measures and stages it, and draw_traces() tests the height as well as the
  width.
- Every titled y axis was staged at the same centred position inside one strip, so
  a Left/Right pair of titles overprinted. Each titled axis now owns a text line:
  the x strip grows one line per titled x axis (x_title_height), and the y runs
  stack down the axes-tall strip, which needs no extra width for it.

Tests: `narrow_panel_with_a_long_y_title_still_draws` and
`short_panel_with_an_x_title_still_draws` pin the floor (the axes keeps half the
panel and the trace still covers the columns, with the title clipped into its
strip), `titled_axis_pairs_get_their_own_rows` pins one text row per titled axis
in both directions, and `layout_stacks_one_title_line_per_titled_axis` pins the
strip sizing. All fail against the pass-1 sources.
2026-09-27 23:33:06 +02:00
Martino Ferrari 8f45108559 fix(render): bind identity into the epoch, titles into reserved strips, one layout per frame
Four review findings on the Task 7 renderer:

- InstanceStore::epoch() is derived from gl::Buffer::generation() -- advanced by
  every allocation, deletion and move -- plus the slot the instances were written
  to, so the orphan fallback of grow_persistent() can no longer leave the vertex
  array's attribute pointers bound to a deleted buffer: the epoch reports the new
  identity and upload() re-points them before the draw.
- PlotLayout reserves y_title_area/x_title_area outside axes (sized from the text
  metrics) and draw_titles() draws in them, so a title can no longer land on the
  data, whatever the envelope does.
- draw() computes plot.layout() once and hands it to the helpers, instead of six
  times per frame, each of which rebuilt the ticks and the grid.
- A refused fence is no longer dropped: mark_drawn() falls back to the orphan path
  and reports the failure rather than rotating through an unsynchronised slot.

New smoke cases force both refusals through the GL entry points (glMapBufferRange,
glFenceSync) and assert the pixels plus the live transfer mode; the title case
asserts the title colour is absent from the bands inside axes and present in both
strips. All three fail against the pre-fix sources.
2026-09-27 23:19:31 +02:00
Martino Ferrari 3555ab63d7 feat(render): Renderer with LOD selection, 3.3 and 4.3+ upload paths, headless smoke test 2026-09-27 23:03:08 +02:00
Martino Ferrari b4e88fe0c1 fix(render): give degenerate segments area, pin font bit order, clarify vertex_count 2026-09-27 22:10:53 +02:00
Martino Ferrari 7cbc1c9955 feat(render): style, GLSL line/text pipelines, staging batches, bitmap font 2026-09-27 22:03:56 +02:00