Commit Graph
26 Commits
Author SHA1 Message Date
Martino Ferrari f363523195 fix(build): keep counter instrumentation out of the installed release library
The linux-release preset inherited PLOT_BUILD_TESTS=ON, so the release build
compiled the pyramid cover/merge and ring upload/wait/slot counters into
libplot_core.a and cmake --install shipped them. Set PLOT_BUILD_TESTS=OFF in the
preset; the suite keeps running in linux-debug and linux-asan, which are
unchanged.

Also pin the two header properties the plan calls Global Constraints and that
nothing guarded: every header under include/plot/ compiles standalone, and
include/plot/plot.hpp includes every public one. tests/CMakeLists.txt globs the
headers at configure time (CONFIGURE_DEPENDS) and generates one TU per header
plus an umbrella TU that constructs Plot, Renderer, data::StreamSeries and
input::Controller; a ctest entry builds the header TUs and runs the umbrella,
and cmake/plot_umbrella_check.cmake fails configure and ctest when a public
header is missing from the umbrella.
2026-09-28 03:25:59 +02:00
Martino Ferrari 0972903d27 feat(build): install/export rules, umbrella header, verified consumer 2026-09-28 03:17:20 +02:00
Martino Ferrari 0445a1861b fix(gtk4): derive the device-pixel factor from GTK's two sizes, and four more review fixes
The factor both the viewport and every forwarded input coordinate use came from
`gtk_widget_get_scale_factor()`, which GTK documents as "the next higher integer
value" under fractional scaling. It is now derived by `DeviceMapping::from_sizes()`
from the widget's logical allocation and the device size `GtkGLArea::resize`
reports -- the viewport that signal's default handler sets with
`glViewport(0, 0, w, h)` -- so the viewport, the renderer and input are one value
by construction. The public `int scale_factor()` became `f32 device_scale()`.

`kSurfacePixelsPerNotch` is now 100.0 and documented as a *calibration* with a
stated assumption, not a unit conversion attributed to axis_value120/Qt.

`::unrealize` releases the renderer unconditionally, after making the context
current: the error it used to check says nothing about whether GL names exist
(creation failure is the path that returns before a renderer is built), and
skipping the release left the deletes to the wrapper's destructor, with no
context.

The drag anchor caches its press point and offsets in logical pixels and scales
the difference by the factor in force now, so a scale change mid-drag cannot
become a phantom pan.

The demo stops its producer in `on_teardown`, before destroying the window whose
renderer the stream feeds.
2026-09-28 02:57:17 +02:00
Martino Ferrari 36cd3421f3 feat(gtk4): GtkGLArea-based plot area and demo 2026-09-28 02:09:31 +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 dc0a977c86 fix(input): refuse a non-finite wheel amount instead of zeroing it
Zeroing an unusable wheel amount turned it into a zero-notch zoom: the factor was
exactly one, the range did not change, but the zoom body still ran through
set_x_range/set_y_range, which clear auto_scale unconditionally. One garbage wheel
therefore disarmed auto-fit on every axis. on_wheel now rejects an unusable amount
before the event can mutate anything, keeping the position fallback.

The tests that should have caught it are strengthened: the wheel case now asserts
auto scale is still armed (it was passing either way), and the invalid-position
case checks a pan measured from the last real position with the drag alive, rather
than a range nothing could have moved.
2026-09-28 01:03:07 +02:00
Martino Ferrari b5b9b42be1 fix(imgui): keep the plot inside the child's clip rect and reject unusable input
The widget drew a region derived from the child's content rect alone, so a child
the hosting window cut off cleared and painted over the host's pixels; it also
scaled ImGui's (-FLT_MAX, -FLT_MAX) no-pointer sentinel and differenced it against
the last real position, turning a drag out of the window into a ~1e35 pan, and it
forgot the host's button levels across a re-bind, handing the next plot a press
that never happened.

The region is now the content rect intersected with the child's clip rect (a
no-op when nothing clips it), an invalid position is no position update, and the
button levels survive a re-bind. The controller sanitises non-finite coordinates
and deltas for any host, and the widget's input translation now has a committed
test on the headless EGL context.
2026-09-28 00:54:25 +02:00
Martino Ferrari fb2c2f5e4d fix(data): draw the partial edge buckets at LOD levels, correct the pyramid's memory and precondition docs
Findings from the review of the sliding-window change:

- A level >= 1 gather emitted only complete, wholly-retained buckets, so the
  geometry stopped at the last bucket boundary inside the range: the newest
  samples of a live window -- up to `span - 1` of them, about a pixel at the
  level level_for() picks -- were not drawn, on the right edge, which is the
  part of a scrolling trace that gets watched, and the oldest retained samples
  fell outside the geometry too (the level-1 gather of a sliding window started
  51 samples in). The two partial edge buckets are now gathered as raw samples
  through copy_raw() and bracketed around the envelope -- the same exactness
  split reduce/sum/sumsq already use -- so the emitted geometry spans the
  requested range exactly. The pyramid still stores no partial bucket, so append
  stays amortised O(1); at 1 MS/s the demo stages 511 extra sub-pixel segments
  and still draws at 1.05 ms/frame, and the screenshot's trace now reaches one
  column further at each end.
- `drop_before`'s doc claimed a no-op "for an empty pyramid": false, and
  backwards -- the restart branch is exactly what an empty pyramid hits, which is
  what StreamSeries::acquire() depends on when the ring saturated before the
  first acquire. The precondition is `absolute_sample <= window_base()`, and the
  restart is documented as what it is.
- The pyramid's memory figure was 3x optimistic: an entry is 24 bytes (MinMax
  plus the exact sum/sumsq doubles), so level 0 costs 0.375n bytes and the whole
  pyramid ~0.75n -- about 19% of 4-byte raw samples, not the 6% a min/max-only
  index would cost. Corrected in the header, spec 4.2 and 4.5, with the trade
  stated (the doubles buy O(log n) sum/mean/RMS/integral from whole buckets),
  and the plan's Task 3 sketch no longer describes per-level prefix sums, a
  level() span or an unaligned-end widening that does not exist.

Tests: `gather_at_a_lod_level_reaches_both_ends_of_the_window` pins that the
first and last emitted points are the oldest and newest retained samples -- with
spike values no bucket envelope can produce -- at level 1, at the level a dense
view picks, and for a static series whose end is unaligned. It fails against the
previous commit (first point at the first whole bucket), and so does the sliding
gather mapping check, which now accounts for the raw edge pairs.
2026-09-28 00:19:59 +02:00
Martino Ferrari 0ac5d47fa6 perf(data): absolute-space pyramid so acquire() is O(produced), not O(window)
Frame cost was O(capacity x levels) once the ring saturated: acquire() saw the
window base move (it always moves once the ring wraps), cleared the pyramid and
re-appended all 1,048,576 retained samples, and append() walked every live level
with no early stop -- 13,647,936 bucket merges and 73.8 ms per frame at a
1M-sample window, scaling with the window, which is what capped the 1 MS/s demo
at 32-58 ms/frame while it was still filling.

Index the pyramid in absolute bucket space instead. Level l bucket k covers the
samples [k * (kBucket << l), (k + 1) * (kBucket << l)), so a bucket's span never
depends on where the window starts and the window can slide over the aggregate
instead of forcing it to be rebuilt. Each level keeps its complete buckets in a
deque plus the absolute base of its first entry; append() folds a sample into the
single in-progress level-0 bucket and, only when that bucket completes, cascades
upward, stopping at the first level that gains no new entry; a new drop_before()
slides the window by discarding leading buckets and remembers where the window
begins. acquire() is now: drop to the new base, then append exactly what was
produced -- 256 merges and 0.034 ms per frame for 8192 new samples, independent
of the window, and memory bounded by the window (32,767 entries after 50M samples
through a 1M ring, the same as after 10M). A producer that laps the consumer by
more than capacity() between two acquires restarts the window rather than
splicing a gap.

Interface: `level(l) -> std::span<const MinMax>` cannot survive a ring and is
replaced by level_base/level_size/bucket in absolute bucket coordinates, plus
window_base/window_end/drop_before. The *_buckets queries stay in window
coordinates (index 0 = oldest retained sample), so Series keeps its window
semantics for callers; its exactness split now aligns in absolute space
(window_base() + the ceil/floor) because the bucket grid is absolute. The sums
move from per-level prefix sums to per-bucket ones -- the only representation a
sliding front can keep exact -- and StreamSeries::y_bounds() becomes
reduce(0, size()), exact in O(log n + kBucket) per frame instead of a scan of the
window. The pyramid owns the published window now, so there is no second copy of
base/end to keep in step, and gather() emits only buckets that are both complete
and wholly retained.

Tests: test-only merge counters (mirroring PLOT_PYRAMID_COUNT_COVERS, absent from
every non-test build) pin an acquire proportional to production and independent
of capacity, an append cost amortised over 16 live levels, and bounded memory;
brute-force checks pin absolute bucket aggregates across slides, sliding-window
reduce/sum/sumsq over unaligned ranges, and the level-1 gather mapping. The demo
now draws 946 samples/px at 1.03 ms/frame (967 fps) where the saturated window
cost 54.96 ms/frame (18 fps).
2026-09-28 00:05:15 +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 e0cdd95a3b fix(input): pan and box zoom through layout().axes, not the viewport
Every pixel-to-data conversion in the controller now maps through the
same rectangle Plot::x_transform/y_transform are built from:

- pan() derives the scale from layout().axes.w/.h, so a drag moves the
  grabbed data point by exactly the drag distance (the viewport model
  stranded it behind the pointer by the label-strip fraction).
- apply_box_zoom() clamps the selection to layout().axes and sets each
  range from the transforms (x_transform().to_data_x(l/r), y_transform()
  .to_data_y(bottom/top)); the selection's bottom edge is the y minimum.
- end_drag() takes the release event and refreshes the selection from its
  coordinates before applying, so a host that reports only press and
  release still frames the box instead of degrading it to a click.
- axis_zoom() uses a per-pixel sensitivity (200 px == one decade) rather
  than reusing the wheel's per-notch constant, so a pixel drag is no
  longer treated as a wheel notch.

Tests now assert the grab-and-drag invariant through the transform (the
old pixel-fraction assertions certified the viewport defect), add a
press+release-only box zoom, and pin the axis-zoom magnitude.
2026-09-27 22:41:42 +02:00
Martino Ferrari 5ab854104a feat(input): host-agnostic events and pan/zoom/cursor controller 2026-09-27 22:34:16 +02:00
Martino Ferrari 606e40a9f1 fix(plot): single-rounding clock labels, log-axis fits that refuse non-positive data 2026-09-27 22:28:56 +02:00
Martino Ferrari 93fc06f050 feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries 2026-09-27 22:21:22 +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
Martino Ferrari 9a086b7d69 test(data): make the push/read race test deterministic
The completion check was scheduling-dependent and the test could lose its TSan

overlap window. The producer now signals done with a release store, the consumer

reads in a do/while keyed on that flag so at least one read is guaranteed by

construction, and a started/reading handshake makes the producer's remaining

pushes provably overlap the reader's loop. Bounded to 4096 pushes on the 256-slot

ring, and the assertions moved after join() so a failure reports instead of

terminating. With the pre-fix ring restored, four TSan runs still flag push vs

copy_from, so the case keeps its teeth.
2026-09-27 21:51:26 +02:00
Martino Ferrari 5f1a4991e2 fix(data): remove MinMaxPyramid::reduce, repoint its two test callers
The pyramid's public query surface is the bucket-grained trio
(reduce_buckets/sum_buckets/sumsq_buckets); reduce() was the one
non-*_buckets query and silently over-covered non-aligned ranges, which
is exactly the misuse the exactness split exists to prevent. Exact range
queries stay on Series.

- delete the declaration, its doc paragraph and the definition; no
  helper became dead (fold_range/cover_buckets/ceil_to_bucket are all
  still used by the *_buckets queries)
- pyramid_clear_and_rebuild: complete bucket 0 and assert its extrema via
  reduce_buckets(0, kBucket) -- a pyramid has no exact sub-bucket query
- stream_series_acquire_publishes_snapshot: s.pyramid().reduce(0, 100)
  -> s.reduce(0, 100), keeping the == 99.0 assertion
2026-09-27 21:45:03 +02:00
Martino Ferrari d9909f2115 fix(data): level-per-emission pyramid cover, relaxed-atomic ring slots
cover_buckets chose one level per pass, so an odd first whole bucket (half of all

measurement ranges) never reached a level above 0 and folded every remaining

bucket at level 0 -- 15624 entries for a 1e6-sample pyramid. Pick the largest

aligned, in-range, materialised level at each cursor instead; the new cost test

counts folded entries through a PLOT_PYRAMID_COUNT_COVERS-only hook (absent from

normal builds) and fails on the old body.

Ring slots are now relaxed std::atomic: overwrite-on-full means the producer

reuses the slot of a snapshot's oldest sample while the consumer reads it, which

was a data race, not just a stale value. copy_from loads explicitly instead of

std::copy; push stays lock-free. A new producer/consumer case is the one TSan

flags without this.

Also extends the wrapped-window test with x0/bounds/pyramid/sum/sumsq/gather

checks, and corrects the StreamSeries threading note to the sample-count bound.
2026-09-27 21:42:53 +02:00
Martino Ferrari 138fa478c3 feat(data): SPSC ring buffer, incremental min/max LOD pyramid, series types 2026-09-27 21:29:59 +02:00
Martino Ferrari 6866457155 fix(gl): gate fallible constructors on a complete loader table
- loader: add available(), true only after load() has resolved every
  required entry point (false at the start of load()). Program::from_source
  and the other fallible constructors return ErrorCode::Unsupported while it
  is false, so a failed load() with a partially populated table can no longer
  fault on an unresolved entry point.
- caps: GL_CONTEXT_PROFILE_MASK only exists from 3.2, so query it only on
  3.2+ contexts. A 3.0/3.1 query latches GL_INVALID_ENUM in the context, which
  the next unrelated glGetError() (e.g. read_pixel) then reports as failure.
- tests: gate the dlsym/libGL.so.1 fallback assertions on libGL being
  resolvable, so the GL suite also passes on a GLES-only host.
2026-09-27 21:15:57 +02:00
Martino Ferrari 258caa38a7 feat(gl): dependency-free GL loader, RAII handles, caps probe, headless EGL harness 2026-09-27 21:07:36 +02:00
Martino Ferrari ed71719df7 fix(math): keep from_range invertible for collapsed pixel spans
A zero pixel span previously produced sx/sy == 0, so to_data_* returned
NaN or +/-inf. axis_scale now substitutes a unit pixel span (1.0) when
|p1 - p0| <= 1e-12, keeping the mapping finite and invertible while
leaving non-collapsed spans byte-identical.

Add committed coverage for from_range (linear, flipped pixel axis, log x,
degenerate data range, collapsed pixel span) and log10_positive clamping.
2026-09-27 20:58:37 +02:00
Martino Ferrari 8a7e9e97f8 feat: project skeleton, core types and math with transform tests 2026-09-27 20:54:19 +02:00