Compare commits

...
17 Commits
Author SHA1 Message Date
Martino Ferrari e5b7faadec adapters: route pointer events by cell rect 2026-11-03 17:04:16 +01:00
Martino Ferrari 7d8c453232 figure: rows can hold several plots (cells), cell weights and column gap 2026-11-03 16:47:47 +01:00
Martino Ferrari f79a97be9b fix: a density grid is bounded in cells, not by the window's pixels
A grid was the axes rect in device pixels, so dragging a window wide made the
image wider with it -- and the per-frame pass over it is O(cells): the fade
multiplies every cell, a host's ink()/peak() read every cell, and a frame the
fade moved converts and uploads every cell as one byte. At 2x device scale a
1520x850 window's axes rect is 2877 device pixels wide, so four rows of one
trace are 4.5 M cells and 4.9 MB uploaded a frame; measured headless on
llvmpipe with a 2 s window, 12.9 ms of tick and 12.8 ms of draw a frame, every
frame under a time fade -- a cost that follows the window and has no ceiling.

The grid is now the axes rect up to kDensityCellCap (2^18 cells, a mebibyte of
counts), scaled down uniformly past it so cells stay square and the picture
keeps the axes' aspect, and magnified back over the rect with a linear filter:
a cell is a count, so a fragment between two of them is between their two ink
levels, which a point sample cannot give once a cell is several pixels wide.
The same four rows at the cap are 1.05 M cells, 3.9 ms of tick and 5.6 ms of
draw and 1.0 MB uploaded, while a grid under the cap is untouched -- a 640x360
viewport's 573x313 axes is still 179 349 cells, one a device pixel.

The image's footprint is the axes rect rounded up to whole device pixels on
both sides of the pipe, so a grid under the cap keeps the exact
one-texel-a-pixel mapping it has always had (sx/sy come out exactly 1) and only
a capped grid reads its cell index through a scale. Moving the decay itself to
the GPU was considered and left out: the CPU grid is what ink()/peak() read,
what the deposit adds to and what the per-sweep dedupe keys on, so a GPU image
would have to be reconciled with it per frame -- a larger change than this
bound, and not one to half-do.

Tests: a_density_grid_is_capped_whatever_the_window reads the bound off a
3840x2160 viewport (261 504 cells of a 3784x2138 axes, aspect kept),
a_capped_density_is_magnified_over_the_axes_rect reads the screen side of it
(569x460 cells covering all 832 of the rect's columns), and the density cases
now assert their capped figures. 399 cases, 0 failed.
2026-10-16 21:09:07 +02:00
Martino Ferrari 1b8b55d3ee feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset
between latest acquisition and persistent image", "persistent instead of being brighter + opaque is
just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image
+ last acquisition (so for example last acquisition never fade in time mode)". What they add up to is
that this was not a phosphor yet: it was a **density with a solid trace drawn over it**.

Four changes, and the first is the one the others follow from:

- **The current acquisition is deposited like every other, and no solid trace is drawn over the
  image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the
  image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as
  well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last
  acquisition", where the last acquisition never faded in time mode.
- **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized
  `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that
  had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a
  cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4`
  bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which
  is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB.
- **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now
  defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that
  colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour
  its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state --
  the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because
  there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets
  `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just
  not the one a phosphor makes.

The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a
fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated
cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old
monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer
(`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the
trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame
at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in
the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart
(`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should
be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`,
`a_density_fades_by_its_time_constant` `exp(-dt/tau)`).

Suite: **397 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 20:21:38 +02:00
Martino Ferrari 738205293e fix: the density's brightness ceiling is the fade's steady state, and the ramp is linear
A user looked at the display and reported "the colour fading is not applied, it looks white or
full color, no opacity gradient" -- and the *numbers* were right: ink decayed, `step-time` was
1/e, `step-acquisitions` was 1 + 0.5 x 1. It was the mapping from a cell's value to a pixel.

**Neither the shader nor the blend was wrong** (the answer to the question that was asked): the
fragment write is already the weighted one (`vec4(u_color.rgb, u_color.a * ink)`) with the frame's
straight-alpha blend, and a cell at half ink over a grid line rendered 160 where the arithmetic
predicts 160. What was wrong was the **scale**:

- `full_scale` defaulted to **1.0**, so the *newest* acquisition already sat at the ceiling and
  everything above it clamped to the same pixel -- a display of one sweep and a display of five
  were one white. It is now **1/(1 - 0.5) = 2.0**, the steady state of the default per-acquisition
  fade, so one sweep reads half ink and white is reached only where acquisitions agree. A caller
  whose fade settles elsewhere states its own sum (udpscope's is `1/(1 - factor)`, in its commit).
- `gamma` defaulted to **0.45**, the value `Style::intensity_gamma` wants where the measure is a
  *dwell fraction*; on a *count* it spends most of the ramp on the last sweep (a quarter of the
  scale mapped to 0.54). It is now **1.0 -- linear in sweeps**, because half as many sweeps must
  read as half the ink.

The case that would have caught this is a **framebuffer** reading, not the grid's, and
`a_phosphor_density_is_drawn_under_the_traces` now makes it: it fades the grid to a quarter
through the time rule (`dt = tau ln 4`, asserted at 0.25 off the ink) and reads the same pixel
back, so the ramp is asserted where the eye sees it -- **`empty=21 full=83 quarter=54`** (red
channel), against the 199 / 199 the ceiling and a single sweep both read at the old defaults. A
full cell must be measurably above the background and above a quarter cell, and a quarter cell
above the background: three assertions on pixels, none of which the grid's own numbers can make.

Suite: **396 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 17:38:00 +02:00
Martino Ferrari 6a6915e64e feat: a plot can carry a phosphor density, one image for every acquisition
`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind
its traces, into which each acquisition is deposited and which fades by a time constant or by a
per-acquisition factor. It is the library's and not a host's because the library is the side that
knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already
staged, at a second derivation of the same mapping.

It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32`
cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a
display of a thousand acquisitions costs what one costs and there is no depth to get wrong.
Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one
byte per cell uploaded on the frame the grid moved.

- `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is
  mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked
  column by column -- so the deposit is bounded by the axes' columns and not by the samples behind
  them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own
  samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the
  brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved
  here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one
  `u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a
  different question the library already answers (`Style::intensity_grading`).
- `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop
  the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the
  grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are
  one derivation, and a frame whose grid did not move skips the conversion and the upload entirely
  (`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every
  row, which one row's assignment used to overwrite).
- The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`,
  `DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the
  axes' clip: it reserves no layout space and nothing on it can be picked, exactly as
  `Plot::set_invalid_spans()` does. `Style::density` is its tint.
- The two fades are the two questions a persistence display can be asked -- "still visible a second
  later" and "the N-th back is gone" -- so the choice is the caller's.

Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the
per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off
the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver
(`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame
and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over
the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass.
2026-10-16 17:25:39 +02:00
Martino FerrariandClaude Opus 4.6 3ad02e5120 feat: a cleared ring can resume its absolute count at a given origin
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 derived ring that is emptied and refilled from its source's retained
window has to come back numbered as the source is. `clear()` restarts it
at 0, which leaves every absolute index -- `base()`, `produced()`,
`copy_from()` -- short by that origin for a whole window's worth of data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-15 16:30:05 +02:00
Martino Ferrari 9790becb33 chore: ignore an in-tree configure's output
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
`build/` only covers the directory the presets use, so `cmake -S . -B .` left
CMakeFiles/, Makefile, cmake_install.cmake and the generated include/plot/version.hpp
untracked in the repository root -- which shows up as dirty content in the parent
project's status whenever the submodule is checked out.
2026-10-12 16:49:06 +02:00
Martino Ferrari 2f7cdccdde docs: name and link the project this library is built for
The README listed the four host adapters and never said what any of them was for. The GTK4
one exists for udpscope, whose URL is now here, and the library's interaction layer --
cursors, measurement marks, invalid-span shading -- was shaped by what that host needed.
The source and test notes that say "in udpscope's case" have a pointer to it now.
2026-10-12 16:45:23 +02:00
Martino Ferrari be1f31a04f feat: an open box zoom draws the frame the release will apply
The controller has always tracked its selection; nothing drew it, so a Ctrl or
Shift drag framed a range with no feedback until the release.

The preview is the area the release will change, not what the pointer swept:
Ctrl frames the x range only, so a band across the plot is what it will zoom,
where the sliver a straight horizontal drag sweeps is only the gesture. The
plot carries the rect a host hands it -- it learns nothing about pointers --
and the renderer draws a low-alpha fill under a light border over everything
else inside the axes.
2026-10-11 11:07:55 +02:00
Martino Ferrari 1fc97d818f feat: a repaired invalid span is shaded faintly, not solidly 2026-10-08 22:59:07 +02:00
Martino Ferrari ebc67bd318 feat: a span declared invalid is shaded behind the data it is missing from 2026-10-08 20:11:16 +02:00
Martino Ferrari 1ae72d4903 feat: a retained slot can be backfilled without pushing 2026-10-08 19:32:47 +02:00
Martino Ferrari 2ebf2be571 feat: a non-finite aggregate can be amended additively at every level 2026-10-08 19:18:51 +02:00
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
36 changed files with 4512 additions and 146 deletions

No files matched your search

+12
View File
@@ -3,3 +3,15 @@ build/
# Not this project's and not generated by it: a Solarus game-engine message that # Not this project's and not generated by it: a Solarus game-engine message that
# happens to sit in the repo root. # happens to sit in the repo root.
error.txt error.txt
# An *in-tree* configure writes these beside the sources. `build/` above only covers the
# directory the presets use, and `cmake -S . -B .` leaves the whole of CMake's output in the
# repository root -- which then reads as an untracked submodule to whoever has it checked out.
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
Makefile
CTestTestfile.cmake
CPackConfig.cmake
# Configured from `include/plot/version.hpp.in`, so it is output and not a source file.
include/plot/version.hpp
+41 -3
View File
@@ -14,6 +14,12 @@ built on top of it.
| Qt6 | `plot::qt6` | `adapters/qt6/` | | Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` | | GTK4 | `plot::gtk4` | `adapters/gtk4/` |
The GTK4 adapter exists for, and is exercised by, a real consumer: **udpscope**, an oscilloscope-style viewer
for a UDPStreamer source, at <https://git.ferrarinas.it/martino/udpscope>. It embeds the core through
`plot::gtk4`, and the library's interaction layer — cursors, measurement marks, the shading of a span a host
has declared invalid — has been shaped by what that host needed. The notes in `src/`, `docs/` and `tests/`
that say "in udpscope's case" are those places.
The visible x range is mapped onto sample indices through the series' own x The visible x range is mapped onto sample indices through the series' own x
(`Series::neighbours()`, a binary search over the real stamps), never through the (`Series::neighbours()`, a binary search over the real stamps), never through the
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed `(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
@@ -37,6 +43,35 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
edge cannot leave the window's own span over data the scope never had (a window *wider* than the edge cannot leave the window's own span over data the scope never had (a window *wider* than the
data keeps the older placement -- right edge on the newest sample); data keeps the older placement -- right edge on the newest sample);
- a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** --
one snapped vertical segment per integer column, lit from that column's own extremes --
because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per
column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch
is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat
(`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps);
- and such a column can be drawn **graded by dwell** instead of as one flat band
(`Style::intensity_grading`, off by default, `FrameStats::graded_traces`): bright where the
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is
what puts back the information the reduction throws away — a column's min and max are the same
whether its samples fill the span or pile at its two ends — so the gather is asked at a level
finer than the pixel grid and the distribution read from those sub-buckets. Below the density at
which columns are reduced the same switch grades the **polyline** by each segment's slope
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
threshold;
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
often it passed. It fades either by a **time constant** in stream seconds or by a
**per-acquisition factor**, and the newest acquisition is deposited like every other, so it
fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
columns rather than the samples, and a frame whose grids did not move costs one draw call per
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
- sample **markers** when a window is sparse enough that its samples are the shape - sample **markers** when a window is sparse enough that its samples are the shape
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour); (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
- grid with independent major/minor line classes (`Style::grid_major_visible`, - grid with independent major/minor line classes (`Style::grid_major_visible`,
@@ -66,7 +101,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same - input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
controller paths as mouse and wheel (touch parity), with keyboard panning controller paths as mouse and wheel (touch parity), with keyboard panning
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes, (arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only); and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only), the area being
chosen framed as the drag runs -- a low-alpha fill under a light border, widened along the axis
the release will *not* zoom, so Ctrl's frame is the band it is about to apply rather than the
sliver a straight horizontal drag sweeps;
- DPI-consistent sizing: metrics are logical units converted once through - DPI-consistent sizing: metrics are logical units converted once through
`Style::ui_scale`; `Style::ui_scale`;
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend - an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
@@ -172,12 +210,12 @@ documentation.
```sh ```sh
cmake --preset linux-debug cmake --preset linux-debug
cmake --build build/debug cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed ctest --test-dir build/debug --output-on-failure # 396 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check tools/coverage.sh # configure+build+run+capture+floor check
``` ```
The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of The `linux-debug` suite runs **396 cases**; the `linux-coverage` preset builds **379** of
them, because its adapters are off and 17 cases need an adapter target (11 in them, because its adapters are off and 17 cases need an adapter target (11 in
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by `test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and `tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
+25 -21
View File
@@ -455,9 +455,11 @@ void Host::set_figure(Figure* figure) noexcept {
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_` row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
return; return;
} }
const u32 rows = figure_->row_count(); // One controller per **plot** (a row of several cells has one for each), indexed by the
row_controllers_.reserve(rows); // flattened plot index the figure uses.
for (u32 i = 0; i < rows; ++i) { const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i))); row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
} }
// The new figure's rects go into the cache now, not at the next frame: an event // The new figure's rects go into the cache now, not at the next frame: an event
@@ -469,8 +471,10 @@ void Host::set_figure(Figure* figure) noexcept {
void Host::refresh_row_rects() noexcept { void Host::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_` // The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache (R7). // instead of paying this again, which is the whole point of the cache (R7). The rects
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{}; // are the figure's **cells** (one per plot, flattened), which for a figure with one
// cell per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
} }
input::Controller* Host::row_controller(u32 row) noexcept { input::Controller* Host::row_controller(u32 row) noexcept {
@@ -486,8 +490,7 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
// frame stale, which is the documented worst case. // frame stale, which is the documented worst case.
const std::vector<Rect>& rows = row_rects_; const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() const u32 row = row_in(rows, x, y);
const u32 row = row_in(rows, y);
last_row_ = row; last_row_ = row;
input::Controller& controller = *row_controllers_[row]; input::Controller& controller = *row_controllers_[row];
// The row's own viewport, so the geometry this controller hit-tests is the geometry // The row's own viewport, so the geometry this controller hit-tests is the geometry
@@ -506,7 +509,6 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed, input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept { bool released) noexcept {
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
if (figure_ == nullptr || row_controllers_.empty()) return controller_; if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = row_rects_; const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
@@ -523,7 +525,7 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
// drag, and its press must not steal the capture to the row it landed in. // drag, and its press must not steal the capture to the row it landed in.
if (released && button == captured_button_) captured_ = false; if (released && button == captured_button_) captured_ = false;
} else { } else {
row = row_in(rows, y); row = row_in(rows, x, y);
if (pressed) { if (pressed) {
captured_ = true; captured_ = true;
captured_row_ = row; captured_row_ = row;
@@ -541,27 +543,29 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept { u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
if (captured_ && captured_row_ < rows.size()) return captured_row_; if (captured_ && captured_row_ < rows.size()) return captured_row_;
return row_in(rows, last_pointer_y_); return row_in(rows, last_pointer_x_, last_pointer_y_);
} }
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept { u32 Host::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
if (rows.empty()) return 0u; if (rows.empty()) return 0u;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a // The cell the pixel is in, or -- when it is in none of them, which is the row gap or
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the // column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// row whose rect is nearest. Selection by distance rather than by "the first that // figure's own margins -- the cell whose rect is nearest (squared distance from the
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows // pixel to the rect). Selection by distance rather than by "the first that matches" is
// tile the figure at the default gap of 0, so this is unreachable until a host asks // what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
// for one, and a gesture in a visible gap must not be routed to row 0 by default. // visible gap must not be routed to plot 0 by default. For a figure with one cell per
// row every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0u; u32 row = 0u;
f32 best = std::numeric_limits<f32>::infinity(); f32 best = std::numeric_limits<f32>::infinity();
for (u32 i = 0; i < rows.size(); ++i) { for (u32 i = 0; i < rows.size(); ++i) {
const f32 top = rows[i].y; const Rect& r = rows[i];
const f32 bottom = rows[i].y + rows[i].h; const f32 dx = x < r.x ? r.x - x : (x >= r.right() ? x - r.right() : 0.0f);
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f); const f32 dy = y < r.y ? r.y - y : (y >= r.bottom() ? y - r.bottom() : 0.0f);
const f32 distance = dx * dx + dy * dy;
if (distance < best) { if (distance < best) {
best = distance; best = distance;
row = i; row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer if (distance == 0.0f) break; // inside this cell: nothing can be nearer
} }
} }
return row; return row;
+19 -15
View File
@@ -121,18 +121,21 @@ class Host {
/// `plot_` is then untouched by any of this. /// `plot_` is then untouched by any of this.
void set_figure(Figure* figure) noexcept; void set_figure(Figure* figure) noexcept;
Figure* figure() noexcept { return figure_; } Figure* figure() noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or /// One figure plot's controller -- the one a gesture on that plot reaches -- or null
/// null while no figure is set or the row does not exist. A host reads a drag /// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is /// mode or a hover out of it (`controller()` is the single-plot loop's, and is
/// not the one figure gestures go to). /// not the one figure gestures go to).
input::Controller* row_controller(u32 row) noexcept; input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` -- /// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
/// the single-plot loop -- while no figure is set, and the controller of the figure /// the single-plot loop -- while no figure is set, and the controller of the figure
/// row that pixel lands in otherwise. The row rects come from the cache /// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold
/// several cells side by side. The cell rects come from the cache
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture /// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
/// lands on the row it was aimed at and no event lays the figure out; a pixel /// lands on the row it was aimed at and no event lays the figure out; a pixel
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`, /// inside no cell -- the row or column gap a host asked for with
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag /// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell rather than
/// dropped or defaulted, so a drag
/// that leaves the data area still ends where it should. /// that leaves the data area still ends where it should.
/// ///
/// It also hands that row its own viewport, which is what keeps the controller's /// It also hands that row its own viewport, which is what keeps the controller's
@@ -217,11 +220,12 @@ class Host {
void on_scroll(f64 dx, f64 dy) noexcept; void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept; void on_key(int key, int action, int mods) noexcept;
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest /// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event /// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller /// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
/// that has just laid the figure out does not make it lay out again for this. /// -- and taking the rects rather than reading them, so a caller that has just laid the figure
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept; /// out does not make it lay out again for this.
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture: /// The controller a pointer event at (x, y) belongs to, applying the button capture:
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and /// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
@@ -231,10 +235,10 @@ class Host {
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed, input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept; bool released) noexcept;
/// The row the pointer's events currently go to: the captured row while a drag owns /// The plot (flattened index) the pointer's events currently go to: the captured one
/// the button, the row under the pointer otherwise. The run loop uses it to clear /// while a drag owns the button, the one under the pointer otherwise. The run loop uses
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a /// it to clear every *other* plot's hot handles, so one the pointer left cannot keep
/// hover or a grab it last published. /// drawing a hover or a grab it last published.
u32 pointer_row(const std::vector<Rect>& rows) noexcept; u32 pointer_row(const std::vector<Rect>& rows) noexcept;
/// Spreads the hover over a whole `Figure` (spec §15). /// Spreads the hover over a whole `Figure` (spec §15).
@@ -292,7 +296,7 @@ class Host {
u32 captured_row_ = 0u; u32 captured_row_ = 0u;
input::Button captured_button_ = input::Button::Left; input::Button captured_button_ = input::Button::Left;
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the /// The figure's cell rects (one per plot, flattened; the row rects when no row is split) as of the last `refresh_row_rects()` -- the cache the
/// event routing reads instead of laying the figure out per event (R7). Filled by /// event routing reads instead of laying the figure out per event (R7). Filled by
/// `set_figure()` and once per frame by the run loop; empty while no figure is /// `set_figure()` and once per frame by the run loop; empty while no figure is
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the /// set, which is the state `controller_at()`/`pointer_target()` fall back to the
+52 -29
View File
@@ -245,9 +245,11 @@ void PlotArea::set_figure(Figure* figure) noexcept {
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_` row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
return; return;
} }
const u32 rows = figure_->row_count(); // One controller per **plot** (a row of several cells has one for each), indexed by the
row_controllers_.reserve(rows); // flattened plot index the figure uses.
for (u32 i = 0; i < rows; ++i) { const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i))); row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
} }
// The new figure's geometry goes into the cache now, not at the next frame: an event // The new figure's geometry goes into the cache now, not at the next frame: an event
@@ -269,8 +271,10 @@ void PlotArea::lay_figure_out(Rect px) noexcept {
void PlotArea::refresh_row_rects() noexcept { void PlotArea::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_` // The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache. // instead of paying this again, which is the whole point of the cache. The rects are
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{}; // the figure's **cells** (one per plot, flattened), which for a figure with one cell
// per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
} }
input::Controller* PlotArea::row_controller(u32 row) noexcept { input::Controller* PlotArea::row_controller(u32 row) noexcept {
@@ -283,8 +287,7 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of // -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
// pointer motion over a multi-row figure would re-lay every row out per move. // pointer motion over a multi-row figure would re-lay every row out per move.
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_; if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() const u32 row = row_in(row_rects_, x, y);
const u32 row = row_in(row_rects_, y);
input::Controller& controller = *row_controllers_[row]; input::Controller& controller = *row_controllers_[row];
controller.set_viewport(row_rects_[row]); controller.set_viewport(row_rects_[row]);
return controller; return controller;
@@ -292,11 +295,11 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
u32 PlotArea::row_at(double x, double y) const noexcept { u32 PlotArea::row_at(double x, double y) const noexcept {
if (figure_ == nullptr || row_rects_.empty()) return 0; if (figure_ == nullptr || row_rects_.empty()) return 0;
(void)x; const Vec2 p = to_device(x, y);
return row_in(row_rects_, to_device(x, y).y); return row_in(row_rects_, p.x, p.y);
} }
input::Controller& PlotArea::target_controller(f32 y) noexcept { input::Controller& PlotArea::target_controller(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_; if (figure_ == nullptr || row_controllers_.empty()) return controller_;
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_; if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
// The capture: while a press is open the gesture belongs to the row that press // The capture: while a press is open the gesture belongs to the row that press
@@ -304,7 +307,7 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
// the release to the row the pointer is over *now* would leave the first row's drag // the release to the row the pointer is over *now* would leave the first row's drag
// open for ever -- its controller never sees the release that ends it -- and let a // open for ever -- its controller never sees the release that ends it -- and let a
// later gesture resume it from the stale anchor. // later gesture resume it from the stale anchor.
u32 row = press_open_ ? press_row_ : row_in(row_rects_, y); u32 row = press_open_ ? press_row_ : row_in(row_rects_, x, y);
// Re-read against this frame's rects: the widget can be resized mid-drag, and the // Re-read against this frame's rects: the widget can be resized mid-drag, and the
// guard keeps a stale index from reaching past the controller list. // guard keeps a stale index from reaching past the controller list.
if (row >= row_controllers_.size()) row = 0; if (row >= row_controllers_.size()) row = 0;
@@ -314,23 +317,25 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
return controller; return controller;
} }
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 y) noexcept { u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
if (rows.empty()) return 0; if (rows.empty()) return 0;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a // The cell the pixel is in, or -- when it is in none of them, which is the row or
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row // column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// whose rect is nearest. By distance rather than by "the first that matches": the // figure's own margins -- the cell whose rect is nearest (squared distance from the
// rows tile the figure at the default gap of 0, so this is unreachable until a host // pixel to the rect). By distance rather than by "the first that matches": a gesture
// asks for a gap, and a gesture in a visible gap must not default to row 0. // in a visible gap must not default to plot 0. For a figure with one cell per row
// every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0; u32 row = 0;
f32 best = std::numeric_limits<f32>::infinity(); f32 best = std::numeric_limits<f32>::infinity();
for (u32 i = 0; i < rows.size(); ++i) { for (u32 i = 0; i < rows.size(); ++i) {
const f32 top = rows[i].y; const Rect& r = rows[i];
const f32 bottom = rows[i].y + rows[i].h; const f32 dx = x < r.x ? r.x - x : (x >= r.right() ? x - r.right() : 0.0f);
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f); const f32 dy = y < r.y ? r.y - y : (y >= r.bottom() ? y - r.bottom() : 0.0f);
const f32 distance = dx * dx + dy * dy;
if (distance < best) { if (distance < best) {
best = distance; best = distance;
row = i; row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer if (distance == 0.0f) break; // inside this cell: nothing can be nearer
} }
} }
return row; return row;
@@ -341,7 +346,22 @@ u32 PlotArea::pointer_row() const noexcept {
// The captured row while a press owns the gesture, so an in-flight drag keeps its // The captured row while a press owns the gesture, so an in-flight drag keeps its
// highlight even after the pointer has left the row it began in. // highlight even after the pointer has left the row it began in.
if (press_open_ && press_row_ < row_rects_.size()) return press_row_; if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
return row_in(row_rects_, pointer_.y); return row_in(row_rects_, pointer_.x, pointer_.y);
}
void PlotArea::sync_selection_frames() noexcept {
const auto frame_for = [](input::Controller& c) -> Rect {
return c.drag_mode() == input::DragMode::BoxZoom ? c.selection_preview() : Rect{};
};
if (figure_ != nullptr) {
for (u32 i = 0; i < row_controllers_.size(); ++i) {
// An empty rect on the rows that are not choosing: `Rect` has no equality to compare
// with, and a store of the same value is what keeps this path free of anything else.
figure_->plot(i).set_selection_rect(frame_for(*row_controllers_[i]));
}
return;
}
plot_->set_selection_rect(frame_for(controller_));
} }
void PlotArea::sync_figure_crosshair() noexcept { void PlotArea::sync_figure_crosshair() noexcept {
@@ -534,6 +554,9 @@ void PlotArea::render() noexcept {
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the // `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
// ceiling under fractional scaling and would over-scale the chrome by up to 60%. // ceiling under fractional scaling and would over-scale the chrome by up to 60%.
renderer_->style().ui_scale = mapping_.factor; renderer_->style().ui_scale = mapping_.factor;
// After the scale and before the draw: the preview reads the row's layout, and a layout measured
// with the previous frame's scale is not the one being drawn.
sync_selection_frames();
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport); renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
if (figure_ != nullptr) { if (figure_ != nullptr) {
renderer_->draw(*figure_); renderer_->draw(*figure_);
@@ -598,7 +621,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
// The figure row this gesture belongs to for its whole life, claimed from the press's // The figure row this gesture belongs to for its whole life, claimed from the press's
// own position *before* the event is dispatched (see `target_controller()`). Harmless // own position *before* the event is dispatched (see `target_controller()`). Harmless
// and unread while no figure is set. // and unread while no figure is set.
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.y) : 0; press_row_ = figure_ != nullptr ? row_in(row_rects_, p.x, p.y) : 0;
press_open_ = true; press_open_ = true;
press_button_ = button; press_button_ = button;
press_mods_ = mods; press_mods_ = mods;
@@ -616,7 +639,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
e.down = true; e.down = true;
e.pressed = true; e.pressed = true;
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
target_controller(p.y).on_pointer(e); target_controller(p.x, p.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
} }
@@ -625,7 +648,7 @@ void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept {
// Resolved while the press is still open, so the release reaches the row the press // Resolved while the press is still open, so the release reaches the row the press
// claimed rather than the row the pointer ended over -- the controller that owns the // claimed rather than the row the pointer ended over -- the controller that owns the
// drag is the only one that can close it. // drag is the only one that can close it.
input::Controller& target = target_controller(position.y); input::Controller& target = target_controller(position.x, position.y);
press_open_ = false; press_open_ = false;
input::PointerEvent e; input::PointerEvent e;
@@ -730,7 +753,7 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
// The press's row, not the row the pointer has reached: `press_open_` is true here, // The press's row, not the row the pointer has reached: `press_open_` is true here,
// so a drag that crosses a row boundary stays with the controller that began it. // so a drag that crosses a row boundary stays with the controller that began it.
area->target_controller(sample.position.y).on_pointer(e); area->target_controller(sample.position.x, sample.position.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
} }
@@ -777,7 +800,7 @@ void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_); static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
// The folded event carries the focal point, which is the position this pinch belongs // The folded event carries the focal point, which is the position this pinch belongs
// to; while a press is open the capture overrides it, as it does for a drag. // to; while a press is open the capture overrides it, as it does for a drag.
area->target_controller(g.y).on_gesture(g); area->target_controller(g.x, g.y).on_gesture(g);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
} }
@@ -816,7 +839,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
e.dx = static_cast<f32>(scroll_notches(dx, unit)); e.dx = static_cast<f32>(scroll_notches(dx, unit));
e.dy = static_cast<f32>(notches); e.dy = static_cast<f32>(notches);
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll)); e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
area->target_controller(at.y).on_wheel(e); area->target_controller(at.x, at.y).on_wheel(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot zoomed return TRUE; // consumed: the plot zoomed
} }
@@ -840,7 +863,7 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
e.down = false; e.down = false;
e.time_s = PlotArea::pointer_time_s( e.time_s = PlotArea::pointer_time_s(
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion))); gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
area->target_controller(p.y).on_pointer(e); area->target_controller(p.x, p.y).on_pointer(e);
} }
// --- keyboard ------------------------------------------------------------- // --- keyboard -------------------------------------------------------------
+32 -17
View File
@@ -260,15 +260,17 @@ class PlotArea {
Figure* figure() noexcept { return figure_; } Figure* figure() noexcept { return figure_; }
const Figure* figure() const noexcept { return figure_; } const Figure* figure() const noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or null /// One figure plot's controller -- the one a gesture on that plot reaches -- or null
/// while no figure is set or the row does not exist. A host reads a drag mode or a /// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag mode or a
/// hover out of it. /// hover out of it.
input::Controller* row_controller(u32 row) noexcept; input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at device pixel `(x, y)` belongs to: `controller()` /// The controller an event at device pixel `(x, y)` belongs to: `controller()`
/// while no figure is set, and otherwise the controller of the figure row that /// while no figure is set, and otherwise the controller of the figure plot (cell)
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a /// that pixel lands in -- x and y both pick it, as a row may hold several cells side
/// margin) is the *nearest* row rather than dropped, so a drag that leaves the data /// by side. A pixel inside no cell (a `Figure::set_row_gap()`/`set_col_gap()` gap, a
/// margin) is the *nearest* cell rather than dropped, so a drag that leaves the data
/// area still ends where it should. It also hands that row its own viewport, which /// area still ends where it should. It also hands that row its own viewport, which
/// is what keeps the controller's hit-test geometry in step with the layout between /// is what keeps the controller's hit-test geometry in step with the layout between
/// two frames. /// two frames.
@@ -277,10 +279,10 @@ class PlotArea {
/// press capture, so a host may call it freely. /// press capture, so a host may call it freely.
input::Controller& controller_at(f32 x, f32 y) noexcept; input::Controller& controller_at(f32 x, f32 y) noexcept;
/// The figure row a point in the widget's **logical** coordinates lands in -- the /// The flattened plot index a point in the widget's **logical** coordinates lands in -- the
/// coordinates every GTK event controller reports, so a host's own drop target or /// coordinates every GTK event controller reports, so a host's own drop target or
/// context menu can name the row a pointer is over without converting anything. The /// context menu can name the plot a pointer is over without converting anything. The
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set. /// same nearest-cell rule (x and y) as `controller_at()`, and 0 while no figure is set.
u32 row_at(double x, double y) const noexcept; u32 row_at(double x, double y) const noexcept;
/// Re-reads the figure's row rects into the cache the event routing answers from -- /// Re-reads the figure's row rects into the cache the event routing answers from --
@@ -376,20 +378,21 @@ class PlotArea {
/// into device pixels. The one place that mapping exists. /// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept; Vec2 to_device(double x, double y) const noexcept;
/// The controller one event of this area's goes to, given the event's device y. /// The controller one event of this area's goes to, given the event's device (x, y).
/// `controller_` while no figure is set; otherwise the row the gesture *belongs* /// `controller_` while no figure is set; otherwise the row the gesture *belongs*
/// to: the row the open press claimed while a press is open, and the row under `y` /// to: the row the open press claimed while a press is open, and the row under `y`
/// between gestures. That is the capture `controller_at()` deliberately omits, and /// between gestures (x and y pick the cell). That is the capture `controller_at()` deliberately omits, and
/// it is why this -- not `controller_at()` -- is what every handler below calls. /// it is why this -- not `controller_at()` -- is what every handler below calls.
/// ///
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the /// Hands the chosen row its own viewport, so the geometry it hit-tests is the
/// geometry the last frame drew. /// geometry the last frame drew.
input::Controller& target_controller(f32 y) noexcept; input::Controller& target_controller(f32 x, f32 y) noexcept;
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest /// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs
/// one when it is in none (a row gap, a margin outside the figure). Selection by /// to: the cell it is in, or the nearest one when it is in none (a row or column gap,
/// distance rather than "the first that matches" is what makes a pixel in a visible /// a margin outside the figure). Selection by distance rather than "the first that
/// gap belong to the row it is *near* instead of to row 0. /// matches" is what makes a pixel in a visible gap belong to the cell it is *near*
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept; /// instead of to plot 0.
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
/// The row this area's events currently go to: the press's row while one is open, /// The row this area's events currently go to: the press's row while one is open,
/// so an in-flight gesture keeps its highlight, and the row under the last pointer /// so an in-flight gesture keeps its highlight, and the row under the last pointer
/// position otherwise. 0 with no rows. /// position otherwise. 0 with no rows.
@@ -397,6 +400,17 @@ class PlotArea {
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once /// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
/// per frame, from the render callback, after this frame's row rects are cached. /// per frame, from the render callback, after this frame's row rects are cached.
void sync_figure_crosshair() noexcept; void sync_figure_crosshair() noexcept;
/// Hands each row's plot the box-zoom frame its controller is showing, or takes it off
/// the ones whose gesture is over. Run once per frame from the render callback, like
/// the crosshair's spread and for a related reason: a frame has to go away when a drag
/// ends by *any* route -- the release, Escape, a row rebuilt mid-gesture -- and a
/// per-event hook would have to be repeated at every one of them, which is how a frame
/// ends up stuck on screen after a cancelled zoom.
///
/// Reads `selection_preview()`, which needs the row's layout, only for a controller
/// actually in `BoxZoom`: the cost is paid while a range is being chosen and never
/// otherwise.
void sync_selection_frames() noexcept;
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's /// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of /// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
/// `render()`, which must not drift apart. /// `render()`, which must not drift apart.
@@ -446,7 +460,8 @@ class PlotArea {
/// `set_figure()` is called, and the single-plot path reads neither. /// `set_figure()` is called, and the single-plot path reads neither.
Figure* figure_ = nullptr; Figure* figure_ = nullptr;
std::vector<std::unique_ptr<input::Controller>> row_controllers_; std::vector<std::unique_ptr<input::Controller>> row_controllers_;
/// The figure's row rects as of the last `refresh_row_rects()`: the cache the event /// The figure's cell rects (one per plot, flattened; the row rects when no row is split)
/// as of the last `refresh_row_rects()`: the cache the event
/// routing reads instead of laying the figure out per event. Empty while no figure /// routing reads instead of laying the figure out per event. Empty while no figure
/// is set, which is the state the routing falls back to `controller_` on. /// is set, which is the state the routing falls back to `controller_` on.
std::vector<Rect> row_rects_; std::vector<Rect> row_rects_;
+10 -3
View File
@@ -41,11 +41,18 @@ ctest --test-dir build/debug --output-on-failure
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug` `plot_umbrella_check`. The `plot_tests` binary itself runs **396 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line. preset; its harness prints one line per case and a final `396 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases. (`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
`PLOT_TEST_FILTER=<substring>` runs only the cases whose name contains that substring, and
is for working *on* a case rather than for checking the library: most of the render cases
create their own EGL context and read pixels back, so the whole binary is far slower than
the one case being edited and its one line of measured output scrolls past 376 others. The
summary line still counts the whole suite, so a filtered run says `396 cases, 0 failed`
having run one — it is never evidence that the suite passes. Unset, every case runs.
## Install layout ## Install layout
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache `cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
@@ -119,7 +126,7 @@ per-file table and the floor verdict live in [docs/coverage.md](coverage.md), wh
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the `tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
live numbers. live numbers.
**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17 **One thing to know about the coverage preset:** it builds **379 of the 396 cases**. The 17
that are missing are the adapter cases that need an adapter target to exist — 11 in that are missing are the adapter cases that need an adapter target to exist — 11 in
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the `test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases) adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
+271
View File
@@ -166,6 +166,277 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
which path each trace took. Below the threshold the raw samples are drawn instead; both which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline. sides go through the same instanced anti-aliased quad pipeline.
## Dense traces are reduced onto the pixel grid
A gathered window denser than the axis is **not** staged as a polyline. For each integer
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
It exists because a polyline over such a window **moirés**, and three things compound to make
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
and the snap then puts that one phase on the grid.
Four properties of the reduction are load-bearing:
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
would draw a vertical line over x the series says nothing about.
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
trace, which is what the polyline's connector prevented. The later column is extended to
meet the earlier one's nearest bound, so the join is made inside the column that moved.
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
on the distance to the segment's axis and does not cap its ends, so a vertical segment
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
the two bounds are equal, nothing at all.
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
coordinate, so there is no data position to move it off, and the y bounds the segment reports
are untouched by the snap.
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
always spills into its neighbour column, so the two renderings measure 577 and 652, which
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
## A reduced column can be graded by dwell
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
scope reads. It is off by default — a change of look, not a correction of one — and
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
grading is a way of drawing the reduction and not a third path.
What it puts back is the information the reduction throws away: a column's min and max are the
**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse
are one band. The distribution cannot be recovered from the band, which is why the gather is
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
and the dwell read from those sub-buckets.
Five things carry it, and each was arrived at by being wrong first:
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
time.
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
row and the negation one past its last, so it costs two writes whatever its height, and the
running sum over the column is the per-row dwell.
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
the peak.
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
existence — and `intensity_gamma` lifts the faint end. The floor only holds if the riser's rows
**reach** a tier: the "lit at all" cutoff that discards a column's empty rows is
`min(0.01 × peak, 0.5 × w_min)` over the smallest deposit any of that column's own segments
made, and not the first term alone. A column holding a flat level *and* a riser has its peak set
by the level — one unit in one row — while the riser spreads a unit over 300, which is three
orders of magnitude under `0.01 × peak`, so the riser was discarded before the floor could apply
to it. Measured on a 2098-sample square wave at 105 samples a column: **2 916 of the ungraded
path's 35 884 riser rows**, 106 of 116 riser columns gutted entirely, with a 311-row dark run in
a 312-row axis; the ten survivors were the phase that put a riser in a column of its own, which
is why it read as a riser *flickering* under a pan rather than as a missing edge. With the second
term it is 35 572 rows and 115 of 116 columns, for 48 extra segments
(`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`). A connector between two disjoint columns
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
measured to dwell.
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
limit, not the number.
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
and the floor is never reached.
## A sparse polyline is graded by slope
The column reduction stops at `dense_threshold_spp` (1.5 samples a device pixel), below which the
trace is a polyline. Grading used to stop there with it, because it was staged *inside* the
reduction — and that coupling was a defect, not a design. The reduction's gate is a density, and
its only purpose is removing the moiré beat between the bucket pitch and the pixel pitch. Dwell
has nothing to do with that beat, so zooming past the threshold turned the phosphor model off in
one step: the same square-wave edge measured **71 of 191** at 3.4 samples a pixel and **191 of
191** at 1.0, a 2.7× jump across one pixel of zoom, which reads as a discontinuity *lighting up*
the moment you look at it closely. The thing a reader most needs told apart from a real signal
excursion is a transition, and it was being drawn at full ink exactly when it filled the screen.
`Renderer::stage_trace_slopes()` grades the polyline on the same principle, differing only where
it must:
- **The measure is the segment's own slope**, `1 / (1 + |Δy_screen|)`, which is dwell per row for
a single segment. The `1 +` is not a guard against a division: a flat segment — span 0, the
commonest case in a sparse window — must be the *brightest* tier, and this makes the measure
dimensionless and **absolute**. There is no per-column peak to normalise against down here, and
that is an improvement: a sparse frame's tiers mean the same thing from frame to frame, so
panning does not reshade the trace.
- **No gather mode is excluded.** The reduction is `Envelope`-only, because turning a bucket's
min/max into a band answers a different question from the one a `Nearest` or `Mean` host asked.
A segment's slope is a property of the polyline itself, so `grade` is not reused as the gate —
only `intensity_grading` and `intensity_steps > 1` are.
- **The geometry is bit-for-bit the ungraded polyline's**, staged exactly as `stage_xy()` stages
it, with the same `snap = false`; only the batch a segment lands in differs. A segment with one
non-finite end is dropped rather than clamped, which is the conclusion `add_polyline_xy()`
reaches too.
- **`FrameStats::slope_traces` counts it, and it is disjoint from `column_traces`.** A window is
either dense enough to reduce onto the pixel grid or it is drawn as a polyline, never both — so
a host asserting `graded_traces == column_traces` keeps that reading unchanged.
`a_sparse_trace_is_graded_by_slope_like_a_dense_one` is the guard: one window either side of the
threshold over the same transition at the same place. The level reads **191 either way** (a flat
segment dwells in its row, so it is the top tier and full ink) and the riser reads **71 either
way**, against the 191 the sparse frame gave before. Across the whole sweep from 101 down to 0.1
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
left to see.
## A phosphor density is the trace, one grid per trace
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a
time constant or by an acquisition count. It is the library's because the library is the side that
knows a trace's device geometry; a host that built one itself would be re-rasterising traces the
renderer has already staged.
The split from the grading above is what it costs: grading measures *one* frame's dwell and
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
also why it cannot be a per-frame accumulator.
- **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
`kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
of these for **each trace it draws**, so the display's memory is that figure times the traces on
screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
The colour is the reason for the per-trace split: a density that had one hue for the plot could
not say *which* trace put the ink there, and two signals agreeing in a cell would read as one
blended pixel. `Plot::density_grid(trace)` returns a trace's image; a trace with no series (an
unbound slot) holds no grid at all.
The cap is what keeps the resolution from following the window, and the window is not a fixed
thing: a 1520x850-logical window at 2x device scale has a 2877-pixel-wide axes rect, and four
rows of one trace are **4.5 M cells** — 18 MB of counts, every one of them walked per frame. The
pass is `O(cells)` in all three places it happens, so an unbounded image makes the *frame* cost a
function of the window: measured on llvmpipe (four traces, one a row, a 2 s window, a 3040x1700
framebuffer) the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame** and 4.9 MB
uploaded, against **3.9 ms / 5.6 ms** and 1.0 MB at the cap. The renderer magnifies the capped
image back over the rect with a **linear** filter, because a cell is a *count* — a continuous
quantity — and a fragment between two of them is between their two ink levels, which is what a
linear tap returns and a point sample cannot (it would show the cell grid as blocks once a cell
is several pixels wide). A one-cell-tall feature is thereby spread over its neighbours, which is
what any magnification of an image does.
- **A cell's value is a number of sweeps**, and a trace covering it during one
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell
still count twice and the two segments that share a column boundary do not.
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
segment covers, the rows it spans in that column get +1 each. A window of millions of samples
over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace
crossed — the deposit is bounded by the screen, not by the data, which is what makes an
accumulation affordable at the trigger's own rate.
- **The newest acquisition is deposited like every other, and no trace is drawn over the image.**
`Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so
the acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
image + last acquisition" a user reported, where the newest acquisition never faded.
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
`DensityConfig::full_scale` defaults to **one sweep**, so a cell with one acquisition's ink is
drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place
every acquisition settles *exactly* at the colour its trace would have drawn — the settlement is
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
sweep and the image behind it. The case that reads either one off the *screen* rather than the
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
- **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's
count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`)
only tint that byte with the trace's colour. The one derivation is deliberate: a value a host
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
numbers.
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
`kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
grids did not move skips the conversion and the uploads entirely and pays the quads alone
(`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
frame under `TimeConstant`, and what makes them stand still between acquisitions under
`PerAcquisition`.
A `TimeConstant` fade therefore does pay the conversion and the upload every frame, whatever the
bound — a *bounded* bill rather than a free one. Applying the fade on the GPU instead was
considered and is a **larger** change than this bound and was deliberately not bundled with it:
the image would have to live in a ping-pong target the decay multiplies in place, while the
*count* stays on the CPU, because that is what a host reads back through `ink()`/`peak()`, what
the deposit adds whole swaths to, and what the per-sweep dedupe keys on — so the two would have
to be reconciled per frame anyway. The bound is what makes that reconciliation a fixed cost.
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
(**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
the 320x240 leg it ends on is the axes rect *exactly*, one cell a device pixel —
`ink=578 peak=1` for one flat sweep, the ink of one sweep *is* the columns it covers),
`a_density_grid_is_capped_whatever_the_window` reads the bound itself off a 3840x2160 viewport
(**681x384 = 261 504 cells = 1 046 016 bytes of a 3784x2138 axes**: the cells never exceed
`kDensityCellCap`, the grid is within 1 % of the whole budget and its aspect is the axes' — the
uniform scaling), and `a_capped_density_is_magnified_over_the_axes_rect` reads the *screen* side of
it (**569x460 = 261 740 cells of an 832x673 axes**: the image covers all **832** of the rect's
columns rather than the 569 cells it holds — the assertion a quad drawn at the grid's own size
fails — the sweep stays on the row its transform maps it to, and its ink is most of the trace's own
colour, `green=195` against a background of `65`, the rest being the magnification's blend),
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
(578 → 867 → 1012 on a 578-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
ink 578/578**, in two different rows), and
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
background's.
## One sample or a statistic of many ## One sample or a statistic of many
Every number the library reports is one the samples actually hold. What differs between Every number the library reports is one the samples actually hold. What differs between
+10
View File
@@ -192,6 +192,16 @@ without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hove
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a `grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
tooltip, a status line). tooltip, a status line).
The frame an open box zoom is drawing is published the same way and for the same reason
(`Plot::set_selection_rect()`), and the plot still learns nothing about pointers: the host reads the
rect off the controller -- `Controller::selection_preview()` -- on every frame of the drag and takes it
off the plot when the gesture ends. The preview is deliberately not what the pointer swept. Shift
frames both axes and its preview is the swept box; Ctrl frames x only, so its preview is widened to the
full height of the axes, and Alt's to the full width. The swept sliver is the *gesture*; the widened
rect is what the *release* will apply, which is the one worth showing while it is still only a
gesture. Nothing is previewed until the drag has swept every axis the release will act on: an empty
rect widened is a band the width of the plot, which would claim a range that does not exist yet.
## Live follow ## Live follow
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live` An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
+2
View File
@@ -42,6 +42,8 @@ Colours (neither logical nor device — nothing to scale):
| `axis_text` | `0xC8D0DC` | tick labels and axis titles | | `axis_text` | `0xC8D0DC` | tick labels and axis titles |
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight | | `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band | | `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
| `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) |
| `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired |
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) | | `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
The scale: The scale:
+13
View File
@@ -76,6 +76,19 @@ class MinMaxPyramid {
/// whose samples were all non-finite is stored as an empty aggregate /// whose samples were all non-finite is stored as an empty aggregate
/// (count 0, zero sums) that every reader treats as absent. /// (count 0, zero sums) that every reader treats as absent.
void append(f32 v); void append(f32 v);
/// Folds a backfilled sample into every aggregate that covers absolute index
/// `absolute_sample`.
///
/// **Precondition: the sample at that index was appended non-finite.** That is what makes
/// this purely additive -- min/max widen, sum/sumsq accumulate and count rises by one, at
/// every covering level -- so no bucket is ever recomputed and the cost is O(levels)
/// regardless of how wide the repaired span is. Amending a *finite* sample would need the
/// old value removed from sum/sumsq and could not restore min/max at all, which is why
/// this is a one-way NaN-to-value operation and not a general write.
///
/// An index outside `[window_base(), window_end())` is a no-op: the window slides on the
/// consumer thread, so a caller cannot prove the slot is still held before asking.
void amend(u64 absolute_sample, f32 v);
/// Slides the retained window's leading edge to absolute sample index /// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it. /// `absolute_sample`, dropping every aggregate that covered a sample before it.
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty /// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
+30
View File
@@ -64,6 +64,24 @@ class RingBuffer {
for (u64 i = 0; i < n; ++i) push(vs[i]); for (u64 i = 0; i < n; ++i) push(vs[i]);
} }
/// Producer thread only: rewrites the slot at absolute index `absolute`, without pushing.
/// Returns false when that index is not retained -- already overwritten, or not yet
/// produced -- in which case nothing is written.
///
/// This is for *backfill*: a slot pushed as a placeholder and corrected when the data that
/// belongs there arrives late. It is the same race class as `push()` -- one relaxed store
/// into a slot a consumer may be reading, and T is lock-free by the class's static_assert,
/// so a reader sees the old value or the new one and never a torn one. It deliberately does
/// not touch `produced_`: the count fixes which indices a snapshot reads, and an amend
/// changes a value inside that window rather than extending it.
bool amend(u64 absolute, T v) {
const u64 p = produced_.load(std::memory_order_relaxed);
if (absolute >= p) return false;
if (p > cap_ && absolute < p - cap_) return false;
buf_[static_cast<usize>(absolute & mask_)].store(v, std::memory_order_relaxed);
return true;
}
/// Consumer: retained element count, saturating at capacity(). /// Consumer: retained element count, saturating at capacity().
u64 size() const noexcept { u64 size() const noexcept {
const u64 p = produced_.load(std::memory_order_acquire); const u64 p = produced_.load(std::memory_order_acquire);
@@ -109,6 +127,18 @@ class RingBuffer {
/// pushing). /// pushing).
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); } void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
/// Consumer thread only: discards every element and resumes the absolute count
/// at `absolute`, so the next push() lands there (not safe while a producer is
/// pushing).
///
/// This is for a *recompute*: a ring whose contents are derived from another is
/// emptied and refilled from a given absolute origin, and it must come back
/// numbered as its source is. clear() would restart it at 0 and leave every
/// absolute index -- base(), produced(), copy_from() -- off by that origin.
void clear_to(u64 absolute) noexcept {
produced_.store(absolute, std::memory_order_relaxed);
}
private: private:
u64 cap_; u64 cap_;
u64 mask_; u64 mask_;
+12
View File
@@ -229,6 +229,18 @@ class Controller {
DragMode drag_mode() const noexcept { return mode_; } DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom. /// The current box-zoom selection in pixels; meaningful while BoxZoom.
Rect selection_rect() const noexcept { return selection_; } Rect selection_rect() const noexcept { return selection_; }
/// The rectangle a host should *draw* for the open box zoom: the swept area, widened to the full
/// axes along whichever axis the modifier's mask will not zoom. Ctrl frames the x range only, so its
/// preview is the band that spans the plot -- which is what the release will zoom -- where the area
/// a purely horizontal drag sweeps is a sliver a few pixels tall. Shift (and any other combo) frames
/// both, and gets the swept box itself.
///
/// Meaningful while `BoxZoom`, like `selection_rect()`; the zero rect otherwise.
///
/// Defined in the implementation: widening the preview to the axes needs the plot's layout, and
/// `Plot` is only forward-declared here.
Rect selection_preview() const noexcept;
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever /// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area /// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
/// or no cursor is within a few pixels. /// or no cursor is within a few pixels.
+1
View File
@@ -54,6 +54,7 @@
#include <plot/plot/axis.hpp> #include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp> #include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp> #include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/figure.hpp> #include <plot/plot/figure.hpp>
#include <plot/plot/grid.hpp> #include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp> #include <plot/plot/legend.hpp>
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// The most cells one density grid may hold: **262 144**, a mebibyte of `f32` counts.
///
/// A grid is the axes rect in device pixels -- the screen's resolution and not the data's, which is
/// the whole point of accumulating into a grid -- but the screen is not a fixed thing. A window can
/// be dragged to any width, and at 2x device scale the axes rects of a 1520x850-logical window are
/// 2877 device pixels wide each: four rows of one trace are **4.5 M cells**, 18 MB of counts
/// between them, and every one of them is walked per frame. That walk is `O(cells)`: the fade
/// multiplies every cell, a host's `ink()`/`peak()` read every cell, and a frame the fade moved
/// converts and uploads every cell as one byte. So a resolution tied to the window is a per-frame
/// cost tied to the window, with no ceiling -- measured headless on llvmpipe with four rows of one
/// trace, a 2 s window and a 3040x1700 framebuffer: **4.5 M cells, 12.9 ms of tick and 12.8 ms of
/// draw a frame, 4.9 MB uploaded**.
///
/// This is the ceiling, in **cells** rather than in pixels: the axes rect is scaled down, uniformly,
/// until it fits, so a cell stays square and the picture keeps the axes' aspect (see `DensityGrid`).
/// The number is a frame's budget read off that same measurement -- the same four rows at the cap
/// are 1.05 M cells and **3.9 ms of tick and 5.6 ms of draw**, a third and a half of the unbounded
/// grid's pass -- while the image is still 681 columns across on a 3784-pixel axes rect. It is a
/// **cap** and not a resolution: a grid whose axes rect is under it is still exactly that rect in
/// device pixels, and only an axes rect of more than a quarter of a megapixel -- a 4K window's, or
/// one row of a tall one -- is scaled.
inline constexpr usize kDensityCellCap = usize{1} << 18;
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
///
/// The two are answers to two different questions, which is why the choice belongs to the
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
enum class DensityDecay {
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
/// fading it.
TimeConstant,
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
/// last few sweeps is what the display holds -- which is what a caller wants when the
/// interesting comparison is between successive acquisitions and not between seconds.
PerAcquisition,
};
/// A plot's phosphor density: whether the grid is on, how it fades, and how a cell's count is
/// turned into brightness. Plain data, replaced wholesale by `Plot::set_density()`.
///
/// The image is **one grid per trace**, drawn in that trace's own colour (`Trace::color`), because
/// a density that had one hue for the whole plot could not say *which* trace put the ink there --
/// two signals agreeing in a cell would be one blended pixel, and a display of several traces
/// could not be read back to the trace it came from. A cell's brightness is a count of sweeps in
/// *that trace's* colour: `full_scale` (default one sweep, the trace's own brightness) is where it
/// saturates, and a signal crossing the same place every acquisition settles exactly at the colour
/// its trace would have drawn.
///
/// Its reason to exist as a *plot* feature rather than a host's overlay is the same one
/// `Plot::set_invalid_spans()` has: the plot is what knows a trace's device geometry -- the
/// visible window about to be drawn, on the axes rect the layout settled -- so the host would
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
/// the same mapping that could disagree with the first.
struct DensityConfig {
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
/// holding a stale image -- see `Plot::set_density()`.
bool enabled = false;
/// How the grid fades (see `DensityDecay`).
DensityDecay decay = DensityDecay::PerAcquisition;
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
/// the frame rate. `<= 0` freezes the grid.
f32 time_constant_s = 1.0f;
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
/// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the
/// seventh the layer is under 1 %.
f32 per_acquisition = 0.5f;
/// The cell value that reads as **full brightness**, in accumulated sweeps -- and the one field
/// a caller reads *off its own trace* rather than off the fade. A cell at this value is drawn in
/// its trace's own colour at that colour's own alpha; the default of **one sweep** is therefore
/// the ink the newest acquisition leaves, so the acquisition just drawn reads exactly as the
/// trace it is a picture of (a signal crossing the same place every acquisition settles at the
/// trace's brightness, where a ceiling above one sweep would settle *brighter* than the trace and
/// leave a gain offset between the newest sweep and the image behind it). Everything above the
/// ceiling clamps to the same pixel: where several acquisitions agree, the cell is the trace's
/// colour at full strength and no brighter.
///
/// A caller that wants the fade's own ramp on screen instead -- the newest sweep a *fraction* of
/// the steady state, so the eye can rank the sweeps -- sets this to the sum the fade settles at
/// (for `PerAcquisition`'s factor `f` that is `1/(1-f)`) and accepts the gain offset; that was
/// this field's first default, and it is a legitimate choice, not the one a phosphor makes.
f32 full_scale = 1.0f;
/// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by
/// default: linear in sweeps**, because that is what a count means -- a cell covered by half the
/// acquisitions is half the ink. Under 1 lifts the faint end, and that is what
/// `Style::intensity_gamma` is for where the value is a *dwell fraction* rather than a count;
/// here it flattens the top instead (0.45 maps a quarter of the scale to 0.54 and the ceiling to
/// 1.0, which is most of the ramp spent on the last sweep). Over 1 darkens the faint end for a
/// caller that wants only the strong agreement visible.
f32 gamma = 1.0f;
};
/// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
/// its resolution is the screen's and not the data's -- the whole point of accumulating into a grid
/// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
/// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
/// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
/// the number and the frame budget it comes from), and the renderer magnifies the image back over
/// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
///
/// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
/// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
/// window is, at most **262 144 cells, 1 048 576 bytes** a grid -- against the 4.5 MB a trace a
/// 3040x1700 framebuffer's axes used to ask for, and the 26 ms of tick and draw a frame the pass
/// over four of them then cost. A plot holds **one grid per trace** (see `DensityConfig`), so a
/// display's memory is that figure times the traces actually drawn -- a plot's unbound slots hold no
/// grid -- and four traces hold at most 4 MiB between them. The renderer adds one byte per cell as
/// the luminance it uploads (`DensityGrid::cells()` is `f32` because a **count** is what decays and
/// what `ink()`/`peak()` report; the byte is derived per changed frame, not stored here).
///
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
///
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
/// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
/// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
/// while the rect is under the cap, and a block of them when the plot scaled the image down.
/// `revision()` moves on every change, which is what lets the renderer skip the upload -- and the
/// whole conversion -- on a frame the grid did not move.
class DensityGrid {
public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
/// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
/// laid-out plot.
u32 columns() const noexcept { return columns_; }
/// Grid height in cells: the axes rect's height in device pixels, rounded up, scaled down with
/// `columns()`: `columns() * rows()` cells.
u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
}
/// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
/// for the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept {
if (column >= columns_ || row >= rows_) return 0.0f;
return cells_[static_cast<usize>(row) * columns_ + column];
}
/// The **total ink**: every cell's value summed. The reading that says how much has been
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
/// and is 0 for a grid that is off or was just cleared.
f64 ink() const noexcept {
f64 total = 0.0;
for (const f32 v : cells_) total += static_cast<f64>(v);
return total;
}
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
f32 peak() const noexcept {
f32 top = 0.0f;
for (const f32 v : cells_) top = v > top ? v : top;
return top;
}
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
/// The renderer compares it with the revision it last uploaded, so a frame that changed
/// nothing uploads nothing.
u64 revision() const noexcept { return revision_; }
private:
friend class Plot;
std::vector<f32> cells_;
u32 columns_ = 0;
u32 rows_ = 0;
u64 revision_ = 0;
};
} // namespace plot
+73 -17
View File
@@ -27,8 +27,15 @@ struct FigureLayout {
/// strips and axes come from its own `Plot::layout()` against this rect, so a row /// strips and axes come from its own `Plot::layout()` against this rect, so a row
/// with a y title keeps its column and no two rows can disagree about chrome. /// with a y title keeps its column and no two rows can disagree about chrome.
std::vector<Rect> rows; std::vector<Rect> rows;
/// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
/// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row
/// rect here; a row of several tiles its rect left to right with `col_gap` between
/// cells, the cells' widths summing to the row's less the gaps.
std::vector<Rect> cells;
/// The strip the figure's one shared x axis is drawn in: the bottom row's own /// The strip the figure's one shared x axis is drawn in: the bottom row's own
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as /// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the bottom of the bottom row's rect and so as
/// wide as that row's axes rather than the whole figure. Read back from the row /// wide as that row's axes rather than the whole figure. Read back from the row
/// rather than derived here, so a host and the frame cannot disagree about where /// rather than derived here, so a host and the frame cannot disagree about where
/// the axis is. It is nonzero whenever the figure has rows and the bottom row /// the axis is. It is nonzero whenever the figure has rows and the bottom row
@@ -44,6 +51,10 @@ struct FigureLayout {
/// figure edge to edge, which is the default; it is dropped for a figure too /// figure edge to edge, which is the default; it is dropped for a figure too
/// short to hold it, so the rows never lose their height to a gap. /// short to hold it, so the rows never lose their height to a gap.
f32 row_gap = 0.0f; f32 row_gap = 0.0f;
/// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s
/// value, or 0 when any row holding several cells was too narrow to hold it and
/// dropped it, so the cells never lose their width to a gap.
f32 col_gap = 0.0f;
}; };
/// Several plots stacked in one column, sharing one x axis. /// Several plots stacked in one column, sharing one x axis.
@@ -94,13 +105,30 @@ class Figure {
/// which is the *unlinked* group: rows that share nothing, each with the range /// which is the *unlinked* group: rows that share nothing, each with the range
/// its own plot was given. /// its own plot was given.
explicit Figure(u32 rows); explicit Figure(u32 rows);
/// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side
/// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same
/// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so
/// `Figure{3u}` keeps meaning three rows.
explicit Figure(std::span<const u32> cells_per_row);
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); } /// The number of figure rows -- the bands stacked top to bottom -- which equals
/// One row's plot. A row that does not exist yields a default-constructed `Plot` /// `plot_count()` for a figure no row of which is split.
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
/// -- so a stale row can only ever affect itself. /// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
Plot& plot(u32 row) noexcept; /// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
const Plot& plot(u32 row) const noexcept; u32 plot_count() const noexcept { return static_cast<u32>(plots_.size()); }
/// How many cells row `row` holds; 0 for a row that does not exist.
u32 cells_in_row(u32 row) const noexcept;
/// The flattened index of row `row`'s first cell; `plot_count()` for `row ==
/// row_count()` and for anything beyond.
u32 first_plot_of_row(u32 row) const noexcept;
/// The row that holds flattened plot `plot` (the last row for an index past the end).
u32 row_of_plot(u32 plot) const noexcept;
/// One plot, by flattened cell index. A plot that does not exist yields a
/// default-constructed `Plot` rather than reading out of bounds -- the rule every
/// accessor on `Plot` follows -- so a stale index can only ever affect itself.
Plot& plot(u32 index) noexcept;
const Plot& plot(u32 index) const noexcept;
// --- geometry --------------------------------------------------------- // --- geometry ---------------------------------------------------------
@@ -119,6 +147,21 @@ class Figure {
void clear_row_weights() noexcept { row_weights_.clear(); } void clear_row_weights() noexcept { row_weights_.clear(); }
bool has_row_weights() const noexcept { return !row_weights_.empty(); } bool has_row_weights() const noexcept { return !row_weights_.empty(); }
/// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's
/// share of what is left after every cell has its floor width (64 logical pixels,
/// capped at an equal share of a narrow row). Normalised. Refused, changing nothing,
/// when the row does not exist, the count is not the row's cell count, or a weight is
/// not a positive finite number. A row with no weights divides equally.
Status set_cell_weights(u32 row, std::span<const f32> weights);
/// Back to an equal division of row `row`; a row that does not exist is ignored.
void clear_cell_weights(u32 row) noexcept;
bool has_cell_weights(u32 row) const noexcept;
/// The horizontal gap left between two cells of a row, in device pixels (0.0 by
/// default). Negative values are clamped to 0, and a gap too wide for a row to hold is
/// dropped for that layout rather than squeezing the cells to nothing.
void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; }
f32 col_gap() const noexcept { return col_gap_; }
/// The gap left between two rows, in device pixels (0.0 by default, so the rows /// The gap left between two rows, in device pixels (0.0 by default, so the rows
/// tile the figure). Negative values are clamped to 0, and a gap too large for the /// tile the figure). Negative values are clamped to 0, and a gap too large for the
/// figure to hold is dropped for that layout rather than squeezing the rows to /// figure to hold is dropped for that layout rather than squeezing the rows to
@@ -195,17 +238,18 @@ class Figure {
/// is not a number; such a call therefore leaves every row's crosshair as it was. /// is not a number; such a call therefore leaves every row's crosshair as it was.
void set_crosshair(f64 x, bool active); void set_crosshair(f64 x, bool active);
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the /// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
/// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and /// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that* /// the horizontal line is drawn **only in `hovered_row`** -- the **flattened plot index**, not
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal /// the figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. It is the fix for the reported defect -- a figure showing no horizontal
/// line at all -- without inventing a figure-wide y, which would mean a different value in /// line at all -- without inventing a figure-wide y, which would mean a different value in
/// every row: the line appears where the pointer is, and no row ever shows another row's y. /// every row: the line appears where the pointer is, and no row ever shows another row's y.
/// ///
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s /// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
/// two-coordinate placement is; a host reads it from the row's own y transform at the /// two-coordinate placement is; a host reads it from the row's own y transform at the
/// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under /// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same /// the pointer": every plot gets the vertical line and none gets a horizontal one, the same
/// state the single-coordinate overload produces. /// state the single-coordinate overload produces.
/// ///
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)` /// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
@@ -226,9 +270,10 @@ class Figure {
/// How many groups there are, excluding the unlinked group 0. /// How many groups there are, excluding the unlinked group 0.
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); } u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
/// Moves one row into a link group and applies that group's range to it at once, /// Moves one plot into a link group and applies that group's range to it at once; `row`
/// so the row is on the group's window from this call rather than from the next /// is the **flattened plot index** (see `plot_count()`), which is the row index only for a
/// frame. `group` 0 takes the row back out of its group, leaving it with the range /// figure with one cell per row. The plot is on the group's window from this call rather
/// than from the next frame. `group` 0 takes the plot back out of its group, leaving it with the range
/// it holds now. /// it holds now.
/// ///
/// A group with no range yet -- one that no row and no `set_x_link_range()` has /// A group with no range yet -- one that no row and no `set_x_link_range()` has
@@ -242,7 +287,7 @@ class Figure {
/// per-row window. `XFollow::Off` is the group's to set /// per-row window. `XFollow::Off` is the group's to set
/// (`set_x_link_follow()`), not the axis'. /// (`set_x_link_follow()`), not the axis'.
Status link_x(u32 row, u32 group); Status link_x(u32 row, u32 group);
/// The group a row is in, or 0 when it is in none. /// The group the plot with flattened index `row` is in, or 0 when it is in none.
u32 x_link(u32 row) const noexcept; u32 x_link(u32 row) const noexcept;
/// Sets a group's shared range and pushes it to every member. /// Sets a group's shared range and pushes it to every member.
@@ -325,7 +370,18 @@ class Figure {
/// a following group's window, push both to all members. /// a following group's window, push both to all members.
void sync_x_links(); void sync_x_links();
/// Fills the row bookkeeping (`cells_per_row_`, `row_first_`, `cell_weights_`) from a
/// per-row cell count, clamping each to at least one. Does not touch `plots_`, which the
/// constructors size from `total_cells()`.
void build_cells(std::span<const u32> cells);
std::vector<Plot> plots_; std::vector<Plot> plots_;
std::vector<u32> cells_per_row_;
/// Prefix sums of `cells_per_row_`: `rows + 1` entries, `row_first_[r]` the flattened
/// index of row r's first cell and the last entry the plot count.
std::vector<u32> row_first_;
std::vector<std::vector<f32>> cell_weights_;
f32 col_gap_ = 0.0f;
/// What `plot()` yields for a row that does not exist, so a stale row can be used /// What `plot()` yields for a row that does not exist, so a stale row can be used
/// without reading out of bounds. Owned rather than a static, so one figure's /// without reading out of bounds. Owned rather than a static, so one figure's
/// stray row can never write into another's. /// stray row can never write into another's.
+204
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <span>
#include <vector> #include <vector>
#include <plot/core/rect.hpp> #include <plot/core/rect.hpp>
@@ -11,6 +12,7 @@
#include <plot/plot/axis.hpp> #include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp> #include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp> #include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/grid.hpp> #include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp> #include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp> #include <plot/plot/measure.hpp>
@@ -49,6 +51,28 @@ constexpr bool x_strip_has_title(XStrip mode) noexcept {
return mode == XStrip::Both || mode == XStrip::TitleOnly; return mode == XStrip::Both || mode == XStrip::TitleOnly;
} }
/// One span of the primary x axis the host has declared invalid, in **data** coordinates --
/// `Region`'s convention. In udpscope's case a reserved hole: samples the stream's counter says
/// exist and that have not been delivered.
///
/// `repaired` says the host has since filled the span's samples in. Such a span is **still
/// reported**, because the shading is a record of what the window was missing, but it is drawn in
/// the fainter of the two tints -- so a reader can distinguish a gap that is still empty from one
/// that has since been repaired, and from a span that has left the list entirely.
struct InvalidSpan {
f64 x0 = 0.0;
f64 x1 = 0.0;
bool repaired = false;
};
/// One invalid span's measured shading: the rect, clipped to the axes' columns, and the state that
/// chooses its tint. A struct and not a bare `Rect` for `RegionLayout`'s reason -- the renderer
/// draws exactly what the layout measured and derives nothing of its own.
struct InvalidSpanLayout {
Rect fill;
bool repaired = false;
};
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as /// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is /// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
/// what the renderer draws with, already converted from the logical metrics in /// what the renderer draws with, already converted from the logical metrics in
@@ -105,6 +129,15 @@ struct PlotLayout {
/// controller's hit test reads the very same rects, so where a region is drawn and /// controller's hit test reads the very same rects, so where a region is drawn and
/// where a pointer can grab it cannot drift apart. /// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions; std::vector<RegionLayout> regions;
/// Every invalid span's shading, in `Plot::invalid_spans()` order and clipped to the axes'
/// columns. Measured here for the regions' first reason -- the frame draws exactly these
/// numbers -- and reserving nothing, so a hole cannot move the strips the labels were
/// measured into. A span entirely outside the window contributes no rect at all, so this is
/// not index-aligned with `invalid_spans()` and nothing needs it to be: there is no hit test
/// on a band and no label beside it. Each entry carries the span's `repaired` state alongside
/// its rect, so the tint is chosen where the geometry is measured rather than looked up again
/// at draw time.
std::vector<InvalidSpanLayout> invalid_spans;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the /// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
/// readout panel and one row per visible trace with the value of the sample nearest the /// readout panel and one row per visible trace with the value of the sample nearest the
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and /// pointer. Placed here rather than derived by the renderer, exactly as the legend and
@@ -112,6 +145,13 @@ struct PlotLayout {
/// clamped against the rect it was measured for, so nothing of the readout can be drawn /// clamped against the rect it was measured for, so nothing of the readout can be drawn
/// over a label strip or into a figure's neighbouring row. /// over a label strip or into a figure's neighbouring row.
CrosshairLayout crosshair; CrosshairLayout crosshair;
/// The box-zoom frame, when a modified drag has one open: the area the gesture is framing, in
/// viewport pixels, already widened along whichever axis the modifier will *not* zoom (see
/// `input::Controller::selection_preview()`, which is where that is decided). Here rather than
/// derived by the renderer, for the regions' reason -- the frame draws exactly these numbers -- and
/// left as the zero rect whenever no gesture is open, which is what both the fill and the four
/// border segments read as "draw nothing".
Rect selection;
}; };
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the /// What a pointer is on, as of the last pointer event: the cursor marker under it, the
@@ -525,6 +565,104 @@ class Plot {
void set_x_note(std::string text); void set_x_note(std::string text);
const std::string& x_note() const noexcept { return x_note_; } const std::string& x_note() const noexcept { return x_note_; }
/// Spans of the primary x axis the host has declared invalid -- in udpscope's case a reserved
/// hole: samples the stream's counter says exist and that have not been delivered. Each span's
/// `x0`/`x1` are in **data** coordinates, `Region`'s convention, and is shaded behind
/// the traces and over the grid.
///
/// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a
/// label, a cached statistic, a controller hit test -- and a plot can carry dozens of holes at
/// once, which would put a hundred-odd grab targets in the way of every pointer event for
/// marks nobody can usefully drag. This is passive shading and nothing else.
///
/// The span is copied, so the caller's storage need not outlive the call. Spans need not be
/// sorted and may overlap; an empty call clears the shading.
///
/// A span whose `repaired` flag is set is shaded more faintly (`Style::invalid_repaired_fill`
/// against `Style::invalid_fill`). Clearing the shading is the host's decision and nothing
/// here retires a span on its own: a repaired span is drawn until the host stops sending it.
void set_invalid_spans(std::span<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
// --- phosphor density --------------------------------------------------
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything.
///
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
/// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
/// acquisition is deposited like every other and fades with the rest -- a trace left drawn on
/// top of its own density could never fade, which is what a report of "the last acquisition
/// never fades" was. It is deliberately *not* a set of retained traces -- the library has the
/// trace geometry in hand, so a density costs one grid per trace whatever the trigger has done,
/// where retaining the acquisitions would cost a window copy each.
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
/// a block of them past it, which the renderer scales back up with a linear filter -- the image
/// reserves no space either way, so the bound changes its resolution and nothing about the
/// layout.
///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
/// has stopped paying for the display is not left holding a stale image of it, which is the
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
/// sized against yet is a screen it has nothing to say about.
void set_density(const DensityConfig& config);
/// The configuration, as set.
const DensityConfig& density() const noexcept { return density_; }
/// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There
/// is one per trace, in trace order, because each is drawn in that trace's own colour (see
/// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first
/// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off.
const DensityGrid& density_grid(u32 trace) const noexcept {
return trace < density_grids_.size() ? density_grids_[trace] : empty_density_grid();
}
/// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this
/// plot, the newest acquisition included, over the x window its own axis is showing, 1.0 into
/// every cell it covers -- **once per cell per sweep**, however many of that trace's own samples
/// crossed it, so a cell's value is how many traces have covered it since the last decay and not
/// how much the beam moved (see `DensityGrid`; the dwell question is
/// `Style::intensity_grading`'s). Called once per *acquisition*, which is the cadence the grids
/// count in: a caller that deposited every frame would add one sweep sixty times a second, and
/// the per-acquisition fade would have nothing to measure itself against. The renderer draws no
/// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole
/// of what the newest acquisition looks like -- and it fades with the rest.
///
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing.
///
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
/// held, because the image is of the axes rect and a new rect is a new image. Under
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
/// the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
/// has not measured a frame yet cannot wipe the grid by accident.
void decay_density(f64 dt_s);
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
/// under the image is replaced: the screen-relative image of another stream's sweeps says
/// nothing about this one.
void clear_density() noexcept;
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the /// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different /// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same /// series, a longer one, or a sliding window whose sample count stayed the same
@@ -702,6 +840,21 @@ class Plot {
/// calls this when the pointer leaves the axes. /// calls this when the pointer leaves the axes.
void clear_crosshair() noexcept { crosshair_.active = false; } void clear_crosshair() noexcept { crosshair_.active = false; }
/// The box-zoom frame the host is showing, in **viewport pixels**, or the zero rect for none.
///
/// Set by the host from `input::Controller::selection_preview()` while a modified left drag is
/// choosing a range, and taken off when it ends. It is the *host's* job and not the plot's because
/// the plot has no controller and learns nothing about pointer, button or modifier (see
/// `plot::input`): this is the receiving end of that boundary, exactly as `set_hot_handles()` is.
/// Drawn as a frame -- a slightly opaque fill under a light border -- over everything else inside
/// the axes, so the area being framed stays legible against the traces it covers.
void set_selection_rect(Rect rect) noexcept { selection_ = rect; }
/// Takes the frame off, leaving the same state as before any drag: the next one starts from nothing.
void clear_selection_rect() noexcept { selection_ = Rect{}; }
/// The frame as set -- what `layout()` measures into `PlotLayout::selection`, and what a host or a
/// test reads back without paying a layout.
Rect selection_rect() const noexcept { return selection_; }
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and /// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants /// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately /// the pointer's column marked but not a panel over its data sets this once; it is deliberately
@@ -852,6 +1005,30 @@ class Plot {
/// region's pixel geometry is derived -- the controller hit-tests what this /// region's pixel geometry is derived -- the controller hit-tests what this
/// measured instead of mapping the bounds through the transform again. /// measured instead of mapping the bounds through the transform again.
void build_regions(); void build_regions();
/// Measures every invalid span into `layout_.invalid_spans`: its interval clipped to the axes'
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
void build_invalid_spans();
/// Deposits one trace's visible window into its own density grid: the same index window
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
/// visible window or non-positive `dx()` deposits nothing.
void deposit_density_trace(const Trace& trace, DensityGrid& grid);
/// Deposits one already-mapped segment of a trace polyline into `grid`: for every grid column
/// the segment crosses, the rows it covers in that column, +1 each. The columns it also *clips*:
/// a column whose slice of the segment lies entirely outside the axes is skipped, so a trace off
/// the top of the plot deposits nothing rather than a band along the edge, and a segment with
/// both ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
/// non-finite coordinate) fills the single column it is in.
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept;
/// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device
/// pixels, clearing a grid when the size moved (a *different* screen is a different image), and
/// returns whether any grid is usable. The one place the grids are allocated.
bool size_density_grids();
/// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the
/// accessor need not hand back a reference into nothing.
static const DensityGrid& empty_density_grid() noexcept;
/// Exact statistics of `region` over `series`, or an invalid result when the /// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives. /// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series); static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -899,6 +1076,28 @@ class Plot {
std::vector<MeasureSlot> measures_; std::vector<MeasureSlot> measures_;
std::vector<Annotation> annotations_; std::vector<Annotation> annotations_;
std::vector<Region> regions_; std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
/// the axes rect but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
/// traces.
DensityConfig density_{};
std::vector<DensityGrid> density_grids_;
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
/// window, not the series, as `Renderer::gather_` is.
std::vector<f32> density_points_;
/// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: the
/// key of the last (trace, column) to deposit at that row, so one acquisition's own samples
/// cannot count a cell twice (see `deposit_density_segment()`). One `u64` a row -- a few kB at
/// any window -- and not one per cell, which would double a grid's memory for a comparison.
std::vector<u64> density_stamp_;
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
u32 density_trace_ = 0;
/// The (region, trace) statistics cache, filled on demand by `region_stats()` /// The (region, trace) statistics cache, filled on demand by `region_stats()`
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_` /// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
/// does. Entries are created for the pairs a caller actually asks about, never /// does. Entries are created for the pairs a caller actually asks about, never
@@ -913,6 +1112,11 @@ class Plot {
/// The crosshair's state (see Crosshair): at most one per plot, placed by /// The crosshair's state (see Crosshair): at most one per plot, placed by
/// `set_crosshair()` and read back by `crosshair()` and `layout()`. /// `set_crosshair()` and read back by `crosshair()` and `layout()`.
Crosshair crosshair_{}; Crosshair crosshair_{};
/// The host's box-zoom frame (`set_selection_rect()`): viewport pixels, the zero rect when no
/// modified drag is choosing a range. State rather than a `PlotLayout` field alone because a host
/// sets it on every drag motion and reads it back; `layout()` only carries it through.
Rect selection_{};
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends /// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at /// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
/// most two samples a span covers. Sized to hold them from construction -- four floats is /// most two samples a span covers. Sized to hold them from construction -- four floats is
+33
View File
@@ -84,4 +84,37 @@ class TextProgram {
gl::Program program_; gl::Program program_;
}; };
/// Linked phosphor-density program and its uniform surface: one textured quad over a plot's
/// axes rect. It fails exactly the way the two above do, propagating `Unsupported` and
/// `GLFailure`.
///
/// It is the one pipeline with **no vertex data**: the quad's four corners come from
/// `gl_VertexID`, so a draw is one four-vertex strip whatever the grid's size and there is no
/// buffer to stage or upload. The density itself reaches the GPU as a texture -- one texel per
/// cell -- which is the one thing this pipeline has that the others do not.
///
/// `create()` also points the `u_density` sampler at texture unit 0, which is the unit
/// `plot::gl::Texture::bind()` defaults to, and leaves this program bound. The texture stays
/// with the caller, who owns its lifetime.
class DensityProgram {
public:
bool create(Status* st = nullptr) noexcept;
bool valid() const noexcept { return program_.id() != 0; }
void bind() const noexcept { program_.use(); }
/// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept;
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
/// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
/// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
/// `vec4` uniform would need a fourth setter shape for one caller; a corner and a size are what
/// the vertex shader builds the quad from anyway.
void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept;
private:
gl::Program program_;
};
} // namespace plot::render } // namespace plot::render
+156 -3
View File
@@ -29,10 +29,37 @@ namespace plot {
struct FrameStats { struct FrameStats {
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0) u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
/// per column, lit from that column's own extremes. Counts a trace from either
/// path above -- the reduction is about the *density* of the gathered points,
/// not about which level produced them.
u32 column_traces = 0;
/// Traces whose columns were graded by dwell rather than drawn as one flat band
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
/// drawing the reduction, not a third path.
u32 graded_traces = 0;
/// Traces drawn as a polyline whose *segments* were graded by slope -- the sparse
/// half of the same phosphor model (`Renderer::stage_trace_slopes`). **Disjoint from
/// `column_traces`**, and therefore from `graded_traces`: a window is either dense
/// enough to reduce onto the pixel grid or it is drawn as a polyline, never both. A
/// host asserting `graded_traces == column_traces` keeps that reading unchanged.
u32 slope_traces = 0;
u64 visible_samples = 0; ///< samples the gathered windows covered, summed u64 visible_samples = 0; ///< samples the gathered windows covered, summed
u32 staged_segments = 0; ///< line segments staged, all colour groups u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text u32 draw_calls = 0; ///< draw calls issued, line and text
/// The phosphor density's own cells, when one was drawn: the **sum** of every trace's
/// `Plot::density_grid()` resolution, zero for a plot with the density off. It is what the
/// *frame* drew, so a host reading the density's cost off a frame reads it here rather than off
/// the grids, which say what is accumulated and not what was spent -- and it is the number that
/// scales with the trace count, since the image is one grid per trace (see `DensityConfig`).
u32 density_cells = 0;
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
/// since the last frame and 0 when it had not. It is the one field that separates the two
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
/// with the display on and off needs to tell apart.
u32 density_upload_bytes = 0;
}; };
/// Draws a `Plot` into whatever framebuffer is bound. /// Draws a `Plot` into whatever framebuffer is bound.
@@ -78,8 +105,8 @@ class Renderer {
Renderer(const Renderer&) = delete; Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete; Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: both programs, both vertex /// Destroys every GL object this renderer created: all three programs, the vertex arrays and
/// arrays and their instance buffers, and the glyph atlas texture. The /// their instance buffers, the glyph atlas texture and the density's own texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be /// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object. /// called again on the same object.
/// ///
@@ -404,6 +431,13 @@ class Renderer {
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`. /// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}, void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
bool snap = false) noexcept; bool snap = false) noexcept;
/// The same, for a batch other than the renderer's own `lines_`. The graded trace
/// pass stages one batch per alpha step, and a step has to be a draw call of its
/// own because the line pipeline takes its colour from a uniform -- so it needs to
/// flush a batch it chose rather than the member one. Every other caller flushes
/// `lines_` and uses the overload above.
void flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
Vec2 dash = Vec2{}, bool snap = false) noexcept;
/// Draws every staged glyph in `batch` -- each instance carries its own tint /// Draws every staged glyph in `batch` -- each instance carries its own tint
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs /// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
/// once per text batch, after the line geometry it belongs over. /// once per text batch, after the line geometry it belongs over.
@@ -491,6 +525,34 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout); void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot); void draw_grid(const Plot& plot);
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per
/// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace
/// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves
/// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink
/// and the newest acquisition is inside it.
///
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here
/// from the grid's counts -- the scale and the gamma are the plot's and this stage derives
/// neither, so the numbers a host reads back and the picture it sees are one derivation. The
/// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
/// cell is several device pixels across. A frame whose grids did not change skips the conversion
/// *and* the uploads and costs the draw calls, which `FrameStats::density_upload_bytes` says per
/// frame.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and
/// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
/// and not the grid's own cell count, so a capped grid is scaled back over the area it is an
/// image of. The one place the density conversion lives, shared by every trace of
/// `draw_density()`.
void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
/// a hole reads as a property of the plot's background -- the grid shows through it and a trace
/// that does have data in those columns is drawn on top, which is what distinguishes a reserved
/// hole from a trace that has gone quiet.
void draw_invalid_spans(const PlotLayout& layout);
void draw_axis_lines(const Plot& plot); void draw_axis_lines(const Plot& plot);
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the /// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed /// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
@@ -500,6 +562,53 @@ class Renderer {
/// The data helpers take it too; they read the axes rect and the transforms it /// The data helpers take it too; they read the axes rect and the transforms it
/// pins. /// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout); void draw_traces(const Plot& plot, const PlotLayout& layout);
/// One gathered window reduced onto the pixel grid and staged in framebuffer
/// pixels: for each integer column of `layout.axes`, one exactly vertical
/// segment from the lowest to the highest screen y any gathered point put in
/// it. Returns how many columns were staged (0 stages nothing); the caller
/// flushes with `pixel_mapping()` and `snap = true`.
///
/// `gather_` is the input, read with the same origin `draw_traces` asked the
/// series for. See `draw_traces` for when this path is taken and why.
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
/// each at the alpha step its own share of the column's samples earns, so a flat
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
///
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
/// Returns the segments staged across all steps (0 stages nothing).
///
/// `gather_` is the input and must have been asked for at the *finer* level
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
/// from its own min and max (see `Style::intensity_grading`).
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The **sparse** window's polyline, graded by the same measure: each segment is
/// staged into the step its own *slope* earns, so a near-vertical segment between two
/// adjacent samples -- a discontinuity, which the signal crossed without dwelling
/// anywhere in it -- is faint and a near-flat one is bright.
///
/// It exists because grading used to be gated on the column reduction, and that
/// reduction is gated on a *density* (two gathered points per pixel column) whose only
/// purpose is removing the moire beat. Dwell has nothing to do with that density, so
/// zooming in past it switched the whole phosphor model off and drew an edge as
/// brightly as a flat top -- the edge got brighter the further in you zoomed.
///
/// The measure agrees with `stage_trace_density()`'s at the boundary but is **absolute
/// rather than per column**: below two points a column there is no column peak to
/// normalise against, so a segment's dwell is `1 / (1 + rows it spans)` outright. A
/// flat segment earns the top step and one crossing the whole axis earns
/// `Style::intensity_floor`, which is what a riser's column already gets on the dense
/// side.
///
/// Geometry is staged exactly as `stage_xy()` stages it -- in the pre-affine space,
/// relative to `x_origin` -- so only *which batch* a segment lands in differs from the
/// ungraded polyline, and the caller flushes with the trace's own `Mapping` and
/// `snap = false`. Returns the segments staged across all steps (0 stages nothing).
u32 stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
f64 x_origin, bool log_x, bool log_y);
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own /// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole /// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
/// range, in its own colour -- or in the accent at handle width while it is hovered or /// range, in its own colour -- or in the accent at handle width while it is hovered or
@@ -578,6 +687,11 @@ class Renderer {
/// pointer is on: a readout the legend covered would be the feature failing at its one job. /// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout); void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
/// the area being framed is legible over the traces, the regions and the readout alike.
void draw_selection_frame(const PlotLayout& layout);
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two /// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in /// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
/// pixel space, at `size_px` half-extent, so a marker's size is device pixels /// pixel space, at `size_px` half-extent, so a marker's size is device pixels
@@ -598,6 +712,27 @@ class Renderer {
render::TextAtlas atlas_; render::TextAtlas atlas_;
render::LineProgram line_program_; render::LineProgram line_program_;
render::TextProgram text_program_; render::TextProgram text_program_;
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is
/// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled
/// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a
/// row of several traces still costs the one texture. It is re-created when the grid's size moves
/// (a resized plot is a differently sized image, and the plot has already dropped what the old one
/// held).
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
/// nothing: one byte per cell.
std::vector<u8> density_pixels_;
u32 density_w_ = 0;
u32 density_h_ = 0;
/// The grid the texture currently holds, by address, and its revision. Compared per frame and per
/// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause
/// in a persistence display. The address is part of the key because the texture is shared: two
/// traces of one plot have two grids with two revisions, and without the address a skip would
/// draw one trace's image under another's colour.
const DensityGrid* density_uploaded_grid_ = nullptr;
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_; render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_; render::TextVertexArray text_vao_;
@@ -611,7 +746,25 @@ class Renderer {
render::TextBatch glyphs_under_; render::TextBatch glyphs_under_;
render::TextBatch glyphs_over_; render::TextBatch glyphs_over_;
std::vector<f32> gather_; ///< scratch for one gathered window, re-used std::vector<f32> gather_; ///< scratch for one gathered window, re-used
std::vector<f32> staged_; ///< scratch for one staged window, re-used /// One (lowest y, highest y) pair per pixel column of the axes, re-used:
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
/// window.
std::vector<f32> columns_;
/// `stage_trace_density()`'s working set, and deliberately **one column wide**: the
/// gathered points arrive in x order, so a column can be accumulated, read and
/// cleared before the next one starts. A full-axes density image would be
/// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1.
///
/// Each sub-bucket's span is written as a +1 at its first row and a -1 one past
/// its last, so accumulating a span costs two writes whatever its height; the
/// running sum over the column is then how many sub-buckets cover each row, which
/// is the dwell.
std::vector<f32> column_rows_;
/// One batch per alpha step of the grading (`Style::intensity_steps`), re-used
/// across frames: the line pipeline takes its colour from a uniform, so segments
/// at different alphas cannot share a draw call and must be staged apart.
std::vector<render::LineBatch> steps_;
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL /// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
+22
View File
@@ -68,4 +68,26 @@ extern const char* const kLineFS;
extern const char* const kTextVS; extern const char* const kTextVS;
extern const char* const kTextFS; extern const char* const kTextFS;
/// GLSL source of the phosphor-density pipeline: one textured quad over the axes rect.
///
/// The vertex shader builds the quad from `gl_VertexID` -- a four-vertex `GL_TRIANGLE_STRIP`
/// like the other two pipelines, and the only one with **no vertex attributes at all**: the
/// image is one rectangle whose corners are uniform state, so there is no geometry to stage and
/// nothing to upload (the density's own bytes go to a texture, not to a buffer).
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
/// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
/// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
/// and a filtered magnification past it), `u_color` (vec4), `u_density` (sampler2D, unit 0).
///
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
/// CPU (`Plot`), where the count, the decay and the gamma live, so this stage has no scale of its
/// own to get out of step with the numbers a host reads back.
///
/// `Rect` is top-left origin while GL is bottom-left, so the y axis is flipped exactly once,
/// here, as in the line pipeline.
extern const char* const kDensityVS;
extern const char* const kDensityFS;
} // namespace plot::render } // namespace plot::render
+106
View File
@@ -55,6 +55,28 @@ struct Style {
Color axis_text = Color::hex(0xC8D0DC); Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020); Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f); Color measure_fill = Color::hex(0xFFB020, 0.15f);
/// The box-zoom selection's frame, drawn while a modified left drag is choosing a range: a light
/// neutral near full alpha for the border and the same neutral at a low one for the fill. Neutral
/// and not a trace colour, on `invalid_fill`'s argument -- the frame says "this is the area", and a
/// tint a trace could have would read as data. The fill's alpha is chosen against the same floor:
/// low enough that what is under the frame stays readable, because the point is to show what is
/// being selected, not to hide it.
Color selection_fill = Color::hex(0xC8D0DC, 0.12f);
Color selection_edge = Color::hex(0xC8D0DC, 0.85f);
/// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the
/// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill`
/// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and
/// one in a hue a trace could have would read as a signal rather than as a gap. The alpha is
/// low enough that the grid under it stays legible, which is what makes the band read as a
/// region of the plot rather than as a panel laid over it.
Color invalid_fill = Color::hex(0x8A94A6, 0.18f);
/// The same shading for a span whose samples have since arrived and been written in, at well
/// under half the alpha. The gap is still reported -- the window was drawn without that data --
/// but a reader can tell a hole that is still empty from one that has been filled. Fainter by
/// alpha alone and not by hue, so the two read as two depths of one mark rather than as two
/// different kinds of thing; and still above the threshold where the difference from *no* band
/// is visible, which is the floor this alpha is chosen against.
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f);
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are /// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which* /// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
/// trace and the line says *where*. /// trace and the line says *where*.
@@ -177,6 +199,90 @@ struct Style {
///< sides draw through the one instanced ///< sides draw through the one instanced
///< anti-aliased quad path. ///< anti-aliased quad path.
// --- intensity grading: counts and ratios, never sizes -------------------
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
/// each height**, the way a digital-phosphor scope does, instead of as one flat
/// band per column: bright where samples pile up (a flat top, a peak), faint
/// where they only pass through (an edge, a noise excursion).
///
/// Off by default, because it is a change of *look* and not a correction: the
/// flat band is the honest answer to "what did this column reach", and a host
/// that wants that -- or that draws few enough samples never to take the column
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
/// host turns it on.
///
/// It is not derivable from the flat band. A column's min and max are the same
/// whether its samples fill the span or sit piled at its two ends, which is
/// exactly the difference between a ramp and a pulse -- so grading needs the
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
/// and reads the distribution from those sub-buckets.
///
/// The measure is *dwell per row*, and the weighting is the whole of it: each
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
/// is the pattern inverted.
///
/// Below `dense_threshold_spp`, where there are no columns to reduce, this grades
/// the **polyline** by each segment's own slope instead (`FrameStats::slope_traces`
/// against `graded_traces`) -- the same measure for a sparse window, where a
/// segment *is* the sub-column. One switch covers both because the alternative was
/// a defect: gated on the reduction, the model switched off as a window narrowed
/// past the threshold, and an edge drawn at a third of a flat top's ink jumped to
/// all of it in one pixel of zoom.
bool intensity_grading = false;
/// Sub-buckets per pixel column the gather is asked for when grading: the level
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
/// density is read from this many envelopes instead of from one.
///
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
/// points -- and the points stay bounded by the screen, which is the property
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
/// samples apart to separate.
///
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
/// a column is graded from however many it does hold. Without that floor `level_for`
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
/// bucket size is the limit, not this number.
u32 intensity_sub_columns = 8;
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
/// per-instance one), and the segments are bounded by the columns, so the cost
/// of a step is a draw call over a few hundred instances.
///
/// Four is enough to read a waveform's structure and few enough that a step is a
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
/// from "passed through", not to measure.
u32 intensity_steps = 4;
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
///
/// Not 0: a column the signal merely passed through is still a column the signal
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
/// disconnected lines. This is the floor that keeps the trace continuous.
f32 intensity_floor = 0.3f;
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
/// whose dwell is three orders of magnitude greater.
///
/// The dwell is normalised **against the column's own peak**, not against a scale
/// for the whole frame, so the steps spend their range on the structure *inside*
/// each column. One scale for the frame needs the whole `w * h` density image
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
/// worse: a column the signal flew straight through holds little dwell at any
/// height, so it would grade uniformly faint -- a trace that fades out exactly
/// where it moves fastest.
f32 intensity_gamma = 0.45f;
/// `logical` in device pixels: the single conversion point for every logical /// `logical` in device pixels: the single conversion point for every logical
/// metric above, so the arithmetic appears once per consumer instead of a `* /// metric above, so the arithmetic appears once per consumer instead of a `*
/// ui_scale` scattered over the renderer and the layout. /// ui_scale` scattered over the renderer and the layout.
+37
View File
@@ -179,6 +179,43 @@ void MinMaxPyramid::append(f32 v) {
} }
} }
void MinMaxPyramid::amend(u64 absolute_sample, f32 v) {
if (!std::isfinite(v)) return;
if (absolute_sample < base_ || absolute_sample >= count_) return;
const f64 vd = f64(v);
const auto fold = [&](Entry& e) {
// An empty bucket's `mm` is meaningless (see the Entry note), so the first finite
// sample *sets* the extremes instead of widening from a zero that was never a sample.
if (e.count == 0) {
e.mm.min = v;
e.mm.max = v;
} else {
e.mm.min = std::min(e.mm.min, v);
e.mm.max = std::max(e.mm.max, v);
}
e.sum += vd;
e.sumsq += vd * vd;
++e.count;
};
// The in-progress level-0 bucket, when the index lies inside it. It is not an entry yet,
// so the loop below cannot reach it; leaving it out would lose the amendment for a hole
// whose tail is still filling, which is exactly the hole a prompt backfill arrives into.
if (count_ % kBucket != 0 && absolute_sample >= (count_ / kBucket) * kBucket) {
fold(pending_);
}
// Every level independently: min/max/sum/sumsq/count are each associative, so folding the
// value into a parent gives the same aggregate as re-merging its children would.
for (u32 l = 0; l < static_cast<u32>(levels_.size()); ++l) {
const u64 i = absolute_sample / level_span(l);
if (i < levels_[l].base) continue;
if (i - levels_[l].base >= static_cast<u64>(levels_[l].entries.size())) continue;
fold(levels_[l].entries[static_cast<usize>(i - levels_[l].base)]);
}
}
void MinMaxPyramid::drop_before(u64 absolute_sample) { void MinMaxPyramid::drop_before(u64 absolute_sample) {
if (absolute_sample <= base_) return; if (absolute_sample <= base_) return;
if (absolute_sample > count_) { if (absolute_sample > count_) {
+25
View File
@@ -751,6 +751,31 @@ void Controller::pan(const PointerEvent& e) {
} }
} }
Rect Controller::selection_preview() const noexcept {
// Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the
// selection, not them -- so widening without this guard would hand out a full-band rect for a drag
// that finished minutes ago.
if (mode_ != DragMode::BoxZoom) return Rect{};
// And nothing has been framed until the drag has swept every axis the release will apply: at the
// press instant the selection is an empty rect at the pointer, and the *widened* form of that is a
// band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other
// axis is deliberately not required: a drag with no vertical movement at all is a band across the
// plot, which is exactly what Ctrl is for.
if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{};
Rect preview = selection_;
if (box_zoom_x_ && box_zoom_y_) return preview;
const Rect& axes = plot_->layout().axes;
if (!box_zoom_x_) {
preview.x = axes.x;
preview.w = axes.w;
}
if (!box_zoom_y_) {
preview.y = axes.y;
preview.h = axes.h;
}
return preview;
}
void Controller::box_zoom_update(const PointerEvent& e) { void Controller::box_zoom_update(const PointerEvent& e) {
const f32 x = std::min(origin_x_, e.x); const f32 x = std::min(origin_x_, e.x);
const f32 y = std::min(origin_y_, e.y); const f32 y = std::min(origin_y_, e.y);
+148 -24
View File
@@ -23,6 +23,56 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights. /// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f; constexpr f32 kMinRowHeightLogical = 24.0f;
/// The width a cell never drops below, in logical pixels, converted exactly as the row
/// height floor is. It yields to the row -- capped at an equal share of the width there
/// is -- so a row too narrow for its cells still tiles instead of overflowing.
constexpr f32 kMinCellWidthLogical = 64.0f;
/// The number of plots a per-row cell count asks for, each row holding at least one.
u32 total_cells(std::span<const u32> cells) noexcept {
u32 total = 0;
for (const u32 c : cells) total += std::max<u32>(c, 1u);
return total;
}
/// Splits the row rect `r` into `n` cells in `out[0..n)` and returns the gap actually used.
/// The cells tile `r` exactly: the gap is dropped when `r` cannot hold it, every cell first
/// gets the floor width and then its weight's share of what is left, and the last cell
/// closes the row on its right edge, absorbing the rounding of the ones before it.
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap,
f32 ui_scale, Rect* out) {
if (n == 1u) {
out[0] = r;
return col_gap;
}
f32 gap = col_gap;
f32 usable = r.w - gap * static_cast<f32>(n - 1u);
if (usable <= 0.0f) {
gap = 0.0f;
usable = r.w;
}
usable = std::max(0.0f, usable);
const f32 floor_device = ui_scale > 0.0f ? kMinCellWidthLogical * ui_scale
: kMinCellWidthLogical;
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(n));
const f32 extra = usable - floor_px * static_cast<f32>(n);
f32 total = 0.0f;
const bool weighted = weights.size() == n;
if (weighted) {
for (const f32 w : weights) total += w;
}
f32 x = r.x;
f32 consumed = 0.0f;
for (u32 i = 0; i < n; ++i) {
const f32 share = weighted && total > 0.0f ? weights[i] / total : 1.0f / static_cast<f32>(n);
const f32 end = i + 1u == n ? usable : consumed + floor_px + extra * share;
out[i] = Rect{x, r.y, end - consumed, r.h};
consumed = end;
x = out[i].right() + gap;
}
return gap;
}
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one /// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is /// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset /// logarithmic, the value itself otherwise. A following group's span and offset
@@ -41,18 +91,72 @@ f64 from_link_space(f64 v, bool log) noexcept {
} // namespace } // namespace
Figure::Figure(u32 rows) : plots_(rows) {} Figure::Figure(u32 rows) : plots_(rows) {
const std::vector<u32> ones(rows, 1u);
Plot& Figure::plot(u32 row) noexcept { build_cells(std::span<const u32>(ones));
return row < plots_.size() ? plots_[row] : fallback_;
} }
const Plot& Figure::plot(u32 row) const noexcept { Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
return row < plots_.size() ? plots_[row] : fallback_; build_cells(cells_per_row);
}
void Figure::build_cells(std::span<const u32> cells) {
cells_per_row_.assign(cells.begin(), cells.end());
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
row_first_.assign(cells_per_row_.size() + 1u, 0u);
for (std::size_t r = 0; r < cells_per_row_.size(); ++r) {
row_first_[r + 1u] = row_first_[r] + cells_per_row_[r];
}
cell_weights_.assign(cells_per_row_.size(), {});
}
u32 Figure::cells_in_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? cells_per_row_[row] : 0u;
}
u32 Figure::first_plot_of_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? row_first_[row] : plot_count();
}
u32 Figure::row_of_plot(u32 plot) const noexcept {
u32 r = 0;
while (r + 1u < cells_per_row_.size() && row_first_[r + 1u] <= plot) ++r;
return r;
}
Plot& Figure::plot(u32 index) noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
const Plot& Figure::plot(u32 index) const noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
Status Figure::set_cell_weights(u32 row, std::span<const f32> weights) {
if (row >= cells_per_row_.size() || weights.size() != cells_per_row_[row]) {
return Status{ErrorCode::InvalidArgument, "cell weights must name every cell of an existing row"};
}
for (const f32 w : weights) {
if (!std::isfinite(w) || !(w > 0.0f)) {
return Status{ErrorCode::InvalidArgument,
"cell weights must be positive finite numbers: a weight is a share of "
"the row, and a cell with none of it has no rect"};
}
}
cell_weights_[row].assign(weights.begin(), weights.end());
return Status{};
}
void Figure::clear_cell_weights(u32 row) noexcept {
if (row < cell_weights_.size()) cell_weights_[row].clear();
}
bool Figure::has_cell_weights(u32 row) const noexcept {
return row < cell_weights_.size() && !cell_weights_[row].empty();
} }
Status Figure::set_row_weights(std::span<const f32> weights) { Status Figure::set_row_weights(std::span<const f32> weights) {
if (weights.size() != plots_.size()) { if (weights.size() != cells_per_row_.size()) {
return Status{ErrorCode::InvalidArgument, "row weights must name every row"}; return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
} }
f32 total = 0.0f; f32 total = 0.0f;
@@ -369,6 +473,8 @@ void Figure::sync_x_links() {
const FigureLayout& Figure::layout() { const FigureLayout& Figure::layout() {
const u32 rows = row_count(); const u32 rows = row_count();
layout_.rows.assign(rows, Rect{}); layout_.rows.assign(rows, Rect{});
layout_.cells.clear();
layout_.col_gap = 0.0f;
layout_.row_gap = row_gap_; layout_.row_gap = row_gap_;
layout_.x_axis_strip = Rect{}; layout_.x_axis_strip = Rect{};
if (rows == 0u) { if (rows == 0u) {
@@ -441,20 +547,35 @@ const FigureLayout& Figure::layout() {
// short to hold the one asked for. // short to hold the one asked for.
layout_.row_gap = gap; layout_.row_gap = gap;
// Each row rect is split into its cells; a one-cell row's cell is the rect itself, so an
// unsplit figure lays out exactly as it always did.
layout_.cells.assign(plots_.size(), Rect{});
layout_.col_gap = col_gap_;
for (u32 row = 0; row < rows; ++row) {
const u32 n = cells_per_row_[row];
const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_,
floor_scale, &layout_.cells[row_first_[row]]);
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
}
const u32 bottom_row = rows - 1u; const u32 bottom_row = rows - 1u;
for (u32 row = 0; row < rows; ++row) { for (u32 row = 0; row < rows; ++row) {
Plot& p = plots_[row]; // One x axis for the whole figure, and it is the bottom row's: every plot keeps its
p.set_viewport(layout_.rows[row]);
// One x axis for the whole figure, and it is the bottom row's: every row keeps its
// x chrome -- labels, title and the strip the controller's axis zoom is found in -- // x chrome -- labels, title and the strip the controller's axis zoom is found in --
// and every other row gives all of it to that one, so the labels and the title are // and every plot above the bottom row gives all of it to that row, so the labels and
// not drawn once per row. A figure told to label every row keeps them everywhere, // the title are not drawn once per row. A figure told to label every row keeps them
// and then there is no shared axis to speak of: each row has its own. // everywhere, and then there is no shared axis to speak of: each plot has its own. A
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both); // split bottom row labels each of its cells, each against its own x axis.
// Before either pass below, and the same value for every row: nothing is measured on const XStrip strip = labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both;
// the right, so unlike the left gutter this needs no measurement pass -- the rows end for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
// on a common column because they are all told the one width. Plot& p = plots_[i];
p.set_right_gutter(right_gutter_); p.set_viewport(layout_.cells[i]);
p.set_x_strip_mode(strip);
// Before either pass below, and the same value for every plot: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the plots
// end on a common column because they are all told the one width.
p.set_right_gutter(right_gutter_);
}
} }
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and // After the viewports, so a row's ticks and grid -- built from its axes rect -- and
// its transforms describe this frame's rects; and before the rows lay themselves // its transforms describe this frame's rects; and before the rows lay themselves
@@ -486,18 +607,21 @@ const FigureLayout& Figure::layout() {
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the // `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
// measurement, so a reserved floor cannot widen what a row reports and then be widened // measurement, so a reserved floor cannot widen what a row reports and then be widened
// by its own reservation on the next frame. // by its own reservation on the next frame.
//
// Over the flattened plots: every cell of every row takes part, so the y axes of a split
// row line up with each other and with the rows above and below.
f32 gutter = min_y_gutter_; f32 gutter = min_y_gutter_;
for (u32 row = 0; row < rows; ++row) { for (Plot& p : plots_) {
plots_[row].set_min_y_gutter(0.0f); p.set_min_y_gutter(0.0f);
const PlotLayout& row_layout = plots_[row].layout(); const PlotLayout& row_layout = p.layout();
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width); gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
} }
// A one-row figure imposes exactly what that row measured, so it lays out as the plot // A one-row figure imposes exactly what that row measured, so it lays out as the plot
// would on its own; a figure whose rows all need the same strip imposes that, which is // would on its own; a figure whose rows all need the same strip imposes that, which is
// also what each would have done alone. // also what each would have done alone.
for (u32 row = 0; row < rows; ++row) { for (Plot& p : plots_) {
plots_[row].set_min_y_gutter(gutter); p.set_min_y_gutter(gutter);
plots_[row].layout(); p.layout();
} }
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that // The figure's one x axis is the bottom row's, so the strip it is drawn in is that
+345
View File
@@ -344,6 +344,311 @@ void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
void Plot::set_x_note(std::string text) { x_note_ = std::move(text); } void Plot::set_x_note(std::string text) { x_note_ = std::move(text); }
void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
invalid_spans_.assign(spans.begin(), spans.end());
}
// --- phosphor density ---------------------------------------------------------
void Plot::set_density(const DensityConfig& config) {
// Disabling is a caller asking to stop paying for the display, and a trigger that has stopped
// firing never reaches another accumulation -- so the image goes now rather than at an event
// that may not come. The same rule, for the same reason, as the app's `set_persist_depth()`.
if (density_.enabled && !config.enabled) {
for (DensityGrid& grid : density_grids_) {
grid.cells_.clear();
grid.cells_.shrink_to_fit();
grid.columns_ = 0u;
grid.rows_ = 0u;
++grid.revision_;
}
density_grids_.clear();
density_grids_.shrink_to_fit();
}
// The two fields the renderer turns a cell's count into a luminance with: a change to either is
// a change to every cell's *byte*, and a grid's own revision does not see it. A host that states
// its configuration every frame (udpscope does) must not force a re-upload per frame, so this
// bumps only when one of the two actually moved -- and then a re-upload is owed.
const bool rescale = density_.enabled && config.enabled &&
(density_.full_scale != config.full_scale || density_.gamma != config.gamma);
density_ = config;
if (rescale) {
for (DensityGrid& grid : density_grids_) ++grid.revision_;
}
}
void Plot::decay_density(f64 dt_s) {
if (!density_.enabled || density_.decay != DensityDecay::TimeConstant) return;
// A non-positive frame and a non-positive time constant are both "do not fade": the first is a
// caller that has not measured a frame yet, and the second is a caller asking for a grid that
// is drawn and never fades.
if (!(dt_s > 0.0) || !(density_.time_constant_s > 0.0f)) return;
const f32 factor =
static_cast<f32>(std::exp(-dt_s / static_cast<f64>(density_.time_constant_s)));
if (!(factor < 1.0f)) return;
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
for (f32& cell : grid.cells_) cell *= factor;
++grid.revision_;
}
}
void Plot::clear_density() noexcept {
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
std::fill(grid.cells_.begin(), grid.cells_.end(), 0.0f);
++grid.revision_;
}
}
bool Plot::size_density_grids() {
const Rect& axes = layout_.axes;
if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false;
u32 columns = static_cast<u32>(std::ceil(axes.w));
u32 rows = static_cast<u32>(std::ceil(axes.h));
if (columns == 0u || rows == 0u) return false;
// **The grid is the axes rect in device pixels, up to `kDensityCellCap`.** Beyond it the rect is
// scaled down *uniformly*, so the cells stay square in device pixels and the picture keeps the
// axes' aspect: a cap on one axis alone would stretch a cell, and a density smeared in one
// direction only reads as a bug in the data rather than as a coarse image. The cells are then no
// longer one device pixel across, which is why the renderer magnifies the image with a linear
// filter instead of a point sample -- see `Renderer::draw_density_grid()`.
//
// The floor of the product is what makes the cap hold: `floor(w*s) * floor(h*s) <= w*s * h*s
// <= cap` for the `s` chosen, and the two `max(1, ...)` keep a very thin axes rect from
// rounding to nothing.
const u64 pixels = static_cast<u64>(columns) * static_cast<u64>(rows);
if (pixels > static_cast<u64>(kDensityCellCap)) {
const f64 scale = std::sqrt(static_cast<f64>(kDensityCellCap) / static_cast<f64>(pixels));
columns = std::max(1u, static_cast<u32>(static_cast<f64>(columns) * scale));
rows = std::max(1u, static_cast<u32>(static_cast<f64>(rows) * scale));
}
// **One grid per trace**, because a density is drawn in the colour of the trace that laid it
// down (see `DensityConfig`). The vector tracks the trace list, so a trace added after the first
// accumulation gets its own empty grid here while the grids already sized keep what they held.
if (density_grids_.size() != traces_.size()) density_grids_.resize(traces_.size());
// The per-sweep dedupe's stamps are one per grid **row** and shared by every trace's deposit:
// the key a cell is deduped against carries the trace's number (see
// `deposit_density_segment()`), so a stamp left by one trace's sweep never matches another's, and
// a zeroed stamp -- every key has a non-zero trace number in its high half -- matches nothing.
if (density_stamp_.size() != rows) density_stamp_.assign(rows, 0u);
bool usable = false;
for (usize i = 0u; i < traces_.size(); ++i) {
DensityGrid& grid = density_grids_[i];
if (!traces_[i].visible()) {
// A trace with nothing to draw gets no image: the grid is per *trace*, and a slot with no
// series is not one. Dropping it here is also what keeps the display's memory proportional
// to the traces actually on screen rather than to a plot's fixed trace slots -- a row of four
// signals out of eight slots holds four grids, not eight.
if (!grid.cells_.empty()) {
grid.cells_.clear();
grid.cells_.shrink_to_fit();
grid.columns_ = 0u;
grid.rows_ = 0u;
++grid.revision_;
}
continue;
}
if (columns != grid.columns_ || rows != grid.rows_) {
// A grid of another size is another image, and there is nothing honest to carry over from
// one to the other: the cells are device pixels, so a resize moves every one of them. Drop
// what it held rather than resample it -- the display restarts, which is what a scope does
// when its screen changes.
grid.cells_.assign(static_cast<usize>(columns) * rows, 0.0f);
grid.columns_ = columns;
grid.rows_ = rows;
++grid.revision_;
}
usable = usable || !grid.cells_.empty();
}
return usable;
}
void Plot::accumulate_density() {
if (!density_.enabled) return;
if (!size_density_grids()) return;
// The fade before the deposit, never after: under `PerAcquisition` the acquisition just drawn
// is the brightest one and each older one is the factor down, which is what "the N-th back is
// gone" means. TimeConstant fades in `decay_density()` instead, because its fade is the clock's
// and not the acquisition's.
if (density_.decay == DensityDecay::PerAcquisition) {
const f32 factor = std::clamp(density_.per_acquisition, 0.0f, 1.0f);
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
for (f32& cell : grid.cells_) cell *= factor;
++grid.revision_;
}
}
// **Every visible trace of the acquisition on screen, the newest included.** The current
// acquisition is deposited here like any other, so it is inside the image and fades with it --
// there is no second, solid trace drawn over the density (see `Renderer::draw_traces()`), which
// is what a report of "the last acquisition never fades" turned out to be. One grid each, so two
// traces that agree read as two colours at full strength where they overlap and not as one
// blended one.
const usize n = std::min(density_grids_.size(), traces_.size());
for (usize i = 0u; i < n; ++i) deposit_density_trace(traces_[i], density_grids_[i]);
}
void Plot::deposit_density_trace(const Trace& trace, DensityGrid& grid) {
if (!trace.visible()) return;
const data::Series& series = *trace.series;
const u64 n = series.size();
if (!(series.dx() > 0.0) || n == 0u) return;
// The same index window `Renderer::draw_traces()` maps, by the same calls: `neighbours()` on
// the series' own stamps -- never the `(x - x0()) / dx()` grid, which is a nominal spacing and
// would lose the segments entering and leaving the frame -- with one sample of slack on each
// side and clamped to the series.
const Range window = x_range(trace.axis_x);
u64 first = 0u;
u64 last = n;
u64 i0 = 0u;
u64 i1 = 0u;
if (series.neighbours(window.min, i0, i1)) first = i0 > 0u ? i0 - 1u : 0u;
if (series.neighbours(window.max, i0, i1)) last = std::min(n, i1 + 1u);
if (last <= first) return;
// The same level gate the renderer uses, and for the same reason: a window denser than the
// pixel grid is a *reduction*, and the density is a reduction of it too -- one pass per cell
// the column's own envelope crosses. The levels agree because the ask is the same ask.
const f64 samples_per_pixel =
static_cast<f64>(last - first) / static_cast<f64>(layout_.axes.w);
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
? series.pyramid().level_for(n, samples_per_pixel)
: 0u;
density_points_.clear();
series.gather(level, first, last, window.min, density_points_, trace.mode);
if (density_points_.size() < 2u) return;
// A fresh stamp for this trace's part of this acquisition: the key a cell is deduped against is
// `(this, column)`, so every trace of a plot deposits once per cell it covers even where they
// overlap, and a cell one trace crosses twice in one sweep deposits once.
++density_trace_;
const Transform tx = x_transform(trace.axis_x);
const Transform ty = y_transform(trace.axis_y);
f32 px = 0.0f;
f32 py = 0.0f;
u32 run = 0u; // points in the current unbroken run
// A run's *first* point is not deposited on its own: the segment that leaves it already puts
// ink in that cell, and depositing both would count the pen's arrival twice -- a single pass
// would read as two in the cell every trace starts in, which is exactly the cell a peak
// reading is taken from. A run of one point -- a lone visible sample, or one isolated by a
// gap -- is deposited as a degenerate segment instead, because a place a trace *was* is ink
// however short its stay.
const auto flush_run = [&]() {
if (run == 1u) deposit_density_segment(px, py, px, py, grid);
};
for (usize k = 0; k + 1u < density_points_.size(); k += 2u) {
// x is gathered relative to the visible range's minimum (see `Series::gather()`), so the
// absolute x is reconstructed in f64 before it is mapped -- the same reconstruction, and the
// same reason, as the staging paths.
const f64 x_abs = window.min + static_cast<f64>(density_points_[k]);
const f64 y_abs = static_cast<f64>(density_points_[k + 1u]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) {
flush_run(); // a NaN is a gap: it joins nothing, so the trace is deposited in pieces
run = 0u;
continue;
}
const f32 sx = static_cast<f32>(tx.to_screen_x(x_abs));
const f32 sy = static_cast<f32>(ty.to_screen_y(y_abs));
if (!std::isfinite(sx) || !std::isfinite(sy)) {
flush_run();
run = 0u;
continue;
}
if (run > 0u) deposit_density_segment(px, py, sx, sy, grid);
px = sx;
py = sy;
++run;
}
flush_run();
++grid.revision_;
}
void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept {
const u32 columns = grid.columns_;
const u32 rows = grid.rows_;
if (columns == 0u || rows == 0u) return;
if (density_stamp_.size() < rows) return; // sized with the grid; nothing deposits before that
if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) return;
const Rect& axes = layout_.axes;
// **The image's footprint is the axes rect rounded up to whole device pixels** -- the rect
// `size_density_grids()` counted the cells on -- so `sx`/`sy` are exactly **1** while that rect is
// under `kDensityCellCap`: a cell *is* a device pixel and this is the arithmetic the density has
// always used. Past the cap they are a fraction of one, and the cell index is read through the
// scale rather than straight off the pixel. (The rounding is why the footprint, not the
// fractional rect, is the reference: a cell is defined on whole pixels, and a grid of `ceil(w)` of
// them covers the rect the axes was laid out with.)
const f32 sx = axes.w > 0.0f ? static_cast<f32>(columns) / std::ceil(axes.w) : 1.0f;
const f32 sy = axes.h > 0.0f ? static_cast<f32>(rows) / std::ceil(axes.h) : 1.0f;
const f32 lo_x = std::min(x0, x1);
const f32 hi_x = std::max(x0, x1);
i32 c0 = static_cast<i32>(std::floor((lo_x - axes.x) * sx));
i32 c1 = static_cast<i32>(std::floor((hi_x - axes.x) * sx));
// Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps
// the part that is inside, and one entirely outside deposits nothing at all. The clamp is what
// keeps ink off the edge column when a trace leaves the frame sideways.
c0 = std::max(c0, 0);
c1 = std::min(c1, static_cast<i32>(columns) - 1);
if (c1 < c0) return;
const f32 span = hi_x - lo_x;
const f32 denom = x1 - x0;
const i32 last_row = static_cast<i32>(rows) - 1;
// The segment's y span inside one column's own slice of x: the two ends of the slice taken
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in
// x), so the deposit is a continuous trace and not a dotted one.
const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
const f32 ca = axes.x + static_cast<f32>(column) / sx;
f32 ya = y0;
f32 yb = y1;
if (span > 0.0f && denom != 0.0f) {
const f32 a = std::max(ca, lo_x);
const f32 b = std::min(ca + 1.0f / sx, hi_x);
ya = y0 + (a - x0) / denom * (y1 - y0);
yb = y0 + (b - x0) / denom * (y1 - y0);
}
out_lo = std::min(ya, yb);
out_hi = std::max(ya, yb);
};
for (i32 c = c0; c <= c1; ++c) {
f32 y_lo = 0.0f;
f32 y_hi = 0.0f;
column_y_span(c, y_lo, y_hi);
// A column whose slice of the segment lies wholly outside the axes deposits nothing: without
// this, a trace above the top of the plot would paint a band along the top row, which is the
// one thing a density must not do -- it would say the trace was there.
if (!(y_hi >= axes.y) || !(y_lo <= axes.bottom())) continue;
const f32 top = std::max(y_lo, axes.y);
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
// rather than one past the end.
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y));
i32 r0 = std::clamp(static_cast<i32>(std::floor((top - axes.y) * sy)), 0, last_row);
i32 r1 = std::clamp(static_cast<i32>(std::floor((bottom - axes.y) * sy)), 0, last_row);
// **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds
// of times by one acquisition's own samples, and counting each crossing would put a single
// sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
// and a cell's value would mean "how much the beam moved here" instead of "how many
// acquisitions were here" -- which is what a persistence display is. The stamp is per
// *(trace, column)*, so two sweeps that agree on a cell still count twice, and the two segments
// that share a column's boundary do not.
const u64 key = (static_cast<u64>(density_trace_) << 32) | static_cast<u64>(static_cast<u32>(c));
f32* column_cells = grid.cells_.data() + static_cast<usize>(c);
for (i32 r = r0; r <= r1; ++r) {
const usize row = static_cast<usize>(r);
if (density_stamp_[row] == key) continue; // this sweep has already put its ink here
density_stamp_[row] = key;
column_cells[row * static_cast<usize>(columns)] += 1.0f;
}
}
}
const DensityGrid& Plot::empty_density_grid() noexcept {
static const DensityGrid empty{};
return empty;
}
f64 Plot::x_axis_origin(u32 axis) const { f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0; return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
} }
@@ -1091,11 +1396,16 @@ const PlotLayout& Plot::layout() {
// measured from the *placed* live windows, which is what makes a region's data // measured from the *placed* live windows, which is what makes a region's data
// interval map to the pixel columns the frame will actually use. // interval map to the pixel columns the frame will actually use.
build_regions(); build_regions();
build_invalid_spans();
build_legend(); build_legend();
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and // The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
// its rows are the sample values nearest a data x -- so it wants the transforms the // its rows are the sample values nearest a data x -- so it wants the transforms the
// settled layout pins, exactly as the legend and the regions do. // settled layout pins, exactly as the legend and the regions do.
build_crosshair(); build_crosshair();
// The box-zoom frame is not measured, only carried: the host already has it in viewport pixels from
// the controller that produced it, and re-deriving it here would be a second rounding of the same
// rectangle -- and a second place for it to disagree with what the gesture is doing.
layout_.selection = selection_;
return layout_; return layout_;
} }
@@ -1358,6 +1668,41 @@ void Plot::build_regions() {
} }
} }
void Plot::build_invalid_spans() {
layout_.invalid_spans.clear();
if (invalid_spans_.empty()) return;
const Rect& axes = layout_.axes;
// The primary x axis' transform, taken here for `build_regions()`' reason: after the live
// windows have been placed and the strips have settled, so these are the columns the frame
// draws with and a band is anchored to the data by construction rather than by a second
// mapping that would have to be kept in step with this one.
const Transform tx = x_transform(0u);
// A hole can be narrower than a column -- one missing sample in a window of hundreds of
// thousands -- and a fill rounded to zero width would draw nothing, losing exactly the case
// the band is most needed for. One device pixel is the floor.
const f32 min_w = std::max(1.0f, style_.device(1.0f));
for (const InvalidSpan& span : invalid_spans_) {
const f64 a = std::min(span.x0, span.x1);
const f64 b = std::max(span.x0, span.x1);
if (!std::isfinite(a) || !std::isfinite(b)) continue;
const f32 lo = static_cast<f32>(tx.to_screen_x(a));
const f32 hi = static_cast<f32>(tx.to_screen_x(b));
// Scrolled out of the window: nothing to shade. Tested before the floor is applied, so a
// span off the left edge is dropped rather than pinned to it as a one-pixel band that
// accuses the samples at the window's start.
if (hi < axes.x || lo > axes.right()) continue;
const f32 first = std::clamp(lo, axes.x, axes.right());
const f32 last = std::clamp(hi, axes.x, axes.right());
const f32 w = std::min(std::max(last - first, min_w), axes.w);
// Widening a band at the right edge would run it over the axis line, so the floor is taken
// out of the left instead: the span's own edge is kept wherever there is room for it.
const f32 x = std::clamp(first, axes.x, axes.right() - w);
layout_.invalid_spans.push_back(InvalidSpanLayout{Rect{x, axes.y, w, axes.h}, span.repaired});
}
}
// --- crosshair (spec §15) -------------------------------------------------- // --- crosshair (spec §15) --------------------------------------------------
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
+23
View File
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels); program_.set_uniform("u_tex_size", texels);
} }
bool DensityProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kDensityVS, kDensityFS, st);
if (program_.id() == 0) {
return false;
}
// The grid texture is bound to texture unit 0 by plot::gl::Texture::bind(), so the sampler
// has to point there. Set once, here, not per frame -- the same shape as the text program's.
program_.use();
program_.set_uniform_int("u_density", 0);
return true;
}
void DensityProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
void DensityProgram::set_rect(Vec2 origin, Vec2 size) const noexcept {
program_.set_uniform("u_origin", origin);
program_.set_uniform("u_size", size);
}
void DensityProgram::set_color(Color color) const noexcept {
program_.set_uniform("u_color", color);
}
} // namespace plot::render } // namespace plot::render
+667 -14
View File
@@ -193,14 +193,23 @@ Renderer::~Renderer() {
void Renderer::release_gl() noexcept { void Renderer::release_gl() noexcept {
ready_ = false; ready_ = false;
// Assigning fresh objects releases every GL name through the handles' // Assigning fresh objects releases every GL name through the handles'
// destructors: both programs, both vertex arrays and their instance buffers, // destructors: all three programs, the vertex arrays and their instance
// and the atlas texture. Safe to call more than once; every assignment is a // buffers, the glyph atlas texture and the density's own texture. Safe to call
// no-op while the renderer is uninitialised. // more than once; every assignment is a no-op while the renderer is
// uninitialised.
atlas_ = render::TextAtlas{}; atlas_ = render::TextAtlas{};
line_program_ = render::LineProgram{}; line_program_ = render::LineProgram{};
text_program_ = render::TextProgram{}; text_program_ = render::TextProgram{};
density_program_ = render::DensityProgram{};
line_vao_ = render::LineVertexArray{}; line_vao_ = render::LineVertexArray{};
text_vao_ = render::TextVertexArray{}; text_vao_ = render::TextVertexArray{};
density_vao_ = gl::VertexArray{};
density_texture_ = gl::Texture{};
density_pixels_.clear();
density_w_ = 0;
density_h_ = 0;
density_uploaded_grid_ = nullptr;
density_uploaded_revision_ = 0;
lines_.clear(); lines_.clear();
glyphs_under_.clear(); glyphs_under_.clear();
glyphs_over_.clear(); glyphs_over_.clear();
@@ -252,10 +261,10 @@ bool Renderer::init(Status* st) noexcept {
return false; return false;
} }
if (!line_program_.create(st) || !text_program_.create(st)) { if (!line_program_.create(st) || !text_program_.create(st) || !density_program_.create(st)) {
return false; return false;
} }
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) { if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode) || !density_vao_.create(st)) {
return false; return false;
} }
ready_ = true; ready_ = true;
@@ -462,7 +471,7 @@ void Renderer::draw(Figure& figure) {
// Every row is prepared before the figure lays itself out, because a row's strips // Every row is prepared before the figure lays itself out, because a row's strips
// are measured from the metrics this installs: a row measuring with one backend and // are measured from the metrics this installs: a row measuring with one backend and
// drawing with another is the defect the propagation point exists to prevent. // drawing with another is the defect the propagation point exists to prevent.
const u32 rows = figure.row_count(); const u32 rows = figure.plot_count();
for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row)); for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row));
// The figure's one layout for the frame. It hands every row its rect and its // The figure's one layout for the frame. It hands every row its rect and its
@@ -516,6 +525,13 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// against the line passes would not change a pixel.) // against the line passes would not change a pixel.)
set_clip(layout.axes); set_clip(layout.axes);
draw_grid(plot); draw_grid(plot);
// The holes over the grid and under the data: a band is chrome of the *background*, not an
// overlay, so everything a frame draws inside the axes is drawn on top of it.
draw_invalid_spans(layout);
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
// where the traces did -- and, while it is on, *instead* of them (`draw_traces()` returns) -- and
// under them for the same reason they are under the data.
draw_density(plot, layout);
clear_clip(); clear_clip();
draw_axis_lines(plot); draw_axis_lines(plot);
set_clip(layout.axes); set_clip(layout.axes);
@@ -542,6 +558,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// panel is drawn over the legend and every other overlay rather than under them, and its // panel is drawn over the legend and every other overlay rather than under them, and its
// text joins the frame's over flush with the tick labels and titles. // text joins the frame's over flush with the tick labels and titles.
draw_crosshair_readout(plot, layout); draw_crosshair_readout(plot, layout);
// The selection frame last of all: it is the gesture still in progress, and nothing inside the axes
// should be able to sit on top of the area being chosen.
draw_selection_frame(layout);
clear_clip(); clear_clip();
draw_tick_labels(plot, layout); draw_tick_labels(plot, layout);
draw_titles(plot, layout); draw_titles(plot, layout);
@@ -616,9 +635,14 @@ void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash, void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash,
bool snap) noexcept { bool snap) noexcept {
const u32 segments = lines_.segment_count(); flush_lines(lines_, map, color, width, dash, snap);
}
void Renderer::flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
Vec2 dash, bool snap) noexcept {
const u32 segments = batch.segment_count();
if (segments != 0 && ready_) { if (segments != 0 && ready_) {
upload_vertices(lines_.data()); upload_vertices(batch.data());
line_program_.bind(); line_program_.bind();
line_program_.set_scale(map.scale); line_program_.set_scale(map.scale);
line_program_.set_offset(map.offset); line_program_.set_offset(map.offset);
@@ -643,7 +667,7 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
stats_.staged_segments += segments; stats_.staged_segments += segments;
++stats_.draw_calls; ++stats_.draw_calls;
} }
lines_.clear(); // keeps the capacity, so the next group re-uses the store batch.clear(); // keeps the capacity, so the next group re-uses the store
} }
void Renderer::flush_text(render::TextBatch& batch) noexcept { void Renderer::flush_text(render::TextBatch& batch) noexcept {
@@ -972,7 +996,388 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// ------------------------------------------------------------------- data // ------------------------------------------------------------------- data
u32 Renderer::stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points) {
const Transform xt = plot.x_transform(trace.axis_x);
const Transform yt = plot.y_transform(trace.axis_y);
const f32 left = layout.axes.x;
// One column per whole pixel of the axes, plus the partial one at the right-hand
// edge: `axes.w` is fractional, and the samples that land in that last fraction
// are inside the frame.
const u32 count = static_cast<u32>(layout.axes.w) + 1u;
constexpr f32 kInf = std::numeric_limits<f32>::infinity();
columns_.resize(2u * static_cast<usize>(count));
for (u32 c = 0; c < count; ++c) {
columns_[2u * c] = kInf; // the lowest screen y seen in this column
columns_[2u * c + 1u] = -kInf; // and the highest; `lo <= hi` is "present"
}
for (u32 k = 0; k < points; ++k) {
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
const f64 sx = xt.to_screen_x(x_abs);
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
// A NaN sample, and a non-positive one on a log axis, have no position: both
// arrive here as a non-finite screen coordinate.
if (!std::isfinite(sx) || !std::isfinite(sy)) continue;
const f64 column = std::floor(sx - static_cast<f64>(left));
// Clamped, not dropped. The gathered window carries one sample of slack on each
// side, and that sample's ink belongs to the edge column -- it is what the
// polyline drew there, under the same clip. Nothing further out can arrive: the
// window is the visible range's.
const u32 c = !(column > 0.0) ? 0u
: column >= static_cast<f64>(count - 1u) ? count - 1u
: static_cast<u32>(column);
const f32 y = static_cast<f32>(sy);
if (y < columns_[2u * c]) columns_[2u * c] = y;
if (y > columns_[2u * c + 1u]) columns_[2u * c + 1u] = y;
}
const f32 width = style_.device(trace.width);
u32 staged = 0;
f32 prev_lo = 0.0f;
f32 prev_hi = 0.0f;
bool joined = false; // the column before this one is present, so there is a join to make
for (u32 c = 0; c < count; ++c) {
f32 lo = columns_[2u * c];
f32 hi = columns_[2u * c + 1u];
if (!(lo <= hi)) {
// No sample landed here. A hole in the data stays a hole: bridging it would
// draw a vertical line over x the series says nothing about, where the
// polyline drew a diagonal across the gap at most one pixel wide.
joined = false;
continue;
}
// Connectivity. Two columns' extremes need not overlap -- a fast edge rises
// through a whole column -- and two disjoint vertical segments read as a broken
// trace, which is exactly what the polyline's connecting segment prevented. The
// later column is extended to meet the earlier one's nearest bound, so the join
// is made inside the column that moved and no ink appears left of it.
if (joined) {
if (hi < prev_lo) {
hi = prev_lo;
} else if (lo > prev_hi) {
lo = prev_hi;
}
}
prev_lo = lo;
prev_hi = hi;
joined = true;
// A span shorter than the stroke is drawn *as* the stroke: the fragment shader
// ramps on the distance to the segment's axis and does not cap its ends, so a
// vertical segment shorter than its own width rasterises a sliver of itself --
// and a column of a flat trace, where `lo == hi`, nothing at all.
if (hi - lo < width) {
const f32 mid = 0.5f * (lo + hi);
lo = mid - 0.5f * width;
hi = mid + 0.5f * width;
}
// The column's centre, in framebuffer pixels. Every segment therefore shares one
// sub-pixel phase, which is the whole point (see `draw_traces`), and the flush's
// snap puts that one phase on the grid.
const f32 x = left + static_cast<f32>(c) + 0.5f;
lines_.add_segment({x, lo}, {x, hi});
++staged;
}
return staged;
}
u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points) {
if (points < 2u) return 0u;
// At least two steps, or there is nothing to grade and the faintest step is also
// the brightest -- a one-step grading is the flat band drawn dimmer, which is not
// what any host asking for this wants.
const u32 steps = std::max(2u, style_.intensity_steps);
// Exactly `steps`, never merely at least: the flush loop reads `steps_.size()` as the
// step count and divides by it, so a batch left over from a larger setting would be
// flushed at the wrong alpha -- or, being uncleared, at all.
steps_.resize(steps);
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
const Transform xt = plot.x_transform(trace.axis_x);
const Transform yt = plot.y_transform(trace.axis_y);
const f32 left = layout.axes.x;
const f32 top = layout.axes.y;
const u32 columns = static_cast<u32>(layout.axes.w) + 1u;
// One accumulator row per whole pixel of the axes, plus the partial one at the
// bottom edge, plus one past the end for a span's closing delta.
const u32 rows = static_cast<u32>(layout.axes.h) + 1u;
column_rows_.assign(static_cast<usize>(rows) + 1u, 0.0f);
const f32 width = style_.device(trace.width);
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
const f32 bottom = top + layout.axes.h;
u32 staged = 0;
u32 column = 0; // the column being accumulated
bool open = false; // it has at least one segment
u32 prev_column = 0; // the last column that emitted anything, for the join below
f32 prev_lo = 0.0f;
f32 prev_hi = 0.0f;
bool joined = false;
// The smallest deposit any segment made into the column being accumulated, which is
// what bounds the "lit at all" test below. Reset per column by `emit_column()`.
f32 w_min = std::numeric_limits<f32>::infinity();
// Reads one column's accumulator out as graded runs and clears it, so the next column
// starts empty. Two walks of it, which is nothing -- it is one column wide and in L1 --
// and the first is what makes the second's grading *relative to this column*:
//
// the brightest row of a column is always the top tier. That is deliberate and it is
// the difference between grading and dimming. The alternative, one scale for the whole
// frame, needs a pass over every column before any of them can be drawn (an
// `axes.w * axes.h` image, 33 MB at 4K) and makes a column the signal flew through
// *uniformly* faint -- a trace that fades out where it moves fastest, which is where a
// reader is most often looking. Per column, the dwell structure *inside* each one is
// what the tiers spend their range on: the rails of a sine are bright and its fast
// middle is faint, in every column.
//
// The runs are maximal stretches of equal tier, so this is O(rows) segments per column
// at worst and a handful in practice: a column of a clean waveform is a bright ridge
// and a faint tail.
const auto emit_column = [&]() {
const f32 x = left + static_cast<f32>(column) + 0.5f;
f32 peak = 0.0f;
{
f32 cover = 0.0f;
for (u32 r = 0; r < rows; ++r) {
cover += column_rows_[r];
if (cover > peak) peak = cover;
}
}
const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f;
// What counts as covered at all: **a row one segment covered is lit**. The deposits
// are fractions, so this cannot be an integer test, and it cannot be a share of the
// column's peak alone either -- that was this function's one real defect. A square
// wave's riser is a single segment spread over the whole axis, so it deposits
// `1/rows` per row, while a flat level in the same column is one deposit of 1.0 per
// sub-bucket piled on one row: at 105 samples a column the peak is ~50 and the share
// floor is ~0.5, eighty times the riser's own 0.006. Every row of the edge was
// unlit, and the connector could not bridge it either -- the two levels are both lit,
// so the column's band already spans the gap and neither gap test fires. Measured on
// a true square at 104.8 spp over a 312-row axis: 106 of 116 riser columns drawn as
// two disconnected marks with up to 311 dark rows between them, the other 10 kept by
// the phase that put the riser in a column of its own -- which is why it read as an
// edge *flickering* rather than as one missing. `intensity_floor` guards a lit row's
// alpha and was documented as preventing exactly this; it never got the chance.
//
// So the share term is kept only for what it is good for -- suppressing residue from
// the running-sum reconstruction -- and is capped by half the smallest deposit the
// column actually received, which no real segment's contribution can fall under.
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
f32 cover = 0.0f;
bool in_run = false;
u32 run_tier = 0;
u32 run_start = 0;
f32 band_lo = 0.0f;
f32 band_hi = 0.0f;
bool any = false;
// One past the last row too: it closes a run that reaches the bottom edge.
for (u32 r = 0; r <= rows; ++r) {
cover += column_rows_[r];
column_rows_[r] = 0.0f;
const bool lit = r < rows && cover > lit_floor;
u32 tier = 0;
if (lit) {
const f32 share = std::min(1.0f, cover * inv);
const u32 t = static_cast<u32>(std::pow(share, gamma) * static_cast<f32>(steps));
tier = std::min(steps - 1u, t);
}
if (in_run && (!lit || tier != run_tier)) {
f32 lo = top + static_cast<f32>(run_start);
f32 hi = top + static_cast<f32>(r);
// A run shorter than the stroke is drawn *as* the stroke, for the reason
// `stage_trace_columns()` gives: the fragment shader ramps on the distance to
// the segment's axis and does not cap its ends.
if (hi - lo < width) {
const f32 mid = 0.5f * (lo + hi);
lo = mid - 0.5f * width;
hi = mid + 0.5f * width;
}
steps_[run_tier].add_segment({x, lo}, {x, hi});
++staged;
in_run = false;
}
if (lit && !in_run) {
run_start = r;
run_tier = tier;
in_run = true;
}
if (lit) {
if (!any) {
band_lo = top + static_cast<f32>(r);
any = true;
}
band_hi = top + static_cast<f32>(r) + 1.0f;
}
}
open = false;
w_min = std::numeric_limits<f32>::infinity();
if (!any) {
joined = false; // nothing visible here: a hole, like an uncovered column
return;
}
// Connectivity, the same rule as the flat path: two columns' bands need not
// overlap -- a fast edge rises through a whole column -- and two disjoint
// verticals read as a broken trace. The gap is bridged by a segment of its own at
// the **faintest** tier rather than by extending a run, because a connector is
// inferred geometry and not a place the signal was measured to dwell: grading it
// bright would claim the opposite of what it is.
if (joined && column == prev_column + 1u) {
if (band_hi < prev_lo) {
steps_[0].add_segment({x, band_hi}, {x, prev_lo});
++staged;
} else if (band_lo > prev_hi) {
steps_[0].add_segment({x, prev_hi}, {x, band_lo});
++staged;
}
}
prev_lo = band_lo;
prev_hi = band_hi;
prev_column = column;
joined = true;
};
f64 px = 0.0;
f64 py = 0.0;
bool have_prev = false;
for (u32 k = 0; k < points; ++k) {
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
const f64 sx = xt.to_screen_x(x_abs);
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
// A NaN sample, and a non-positive one on a log axis, have no position -- and it
// breaks the chain as well as skipping the point: a segment from the sample
// before it to the one after would cross x the series says nothing about.
if (!std::isfinite(sx) || !std::isfinite(sy)) {
have_prev = false;
continue;
}
if (have_prev) {
// The *segment*, not the point, is what carries dwell: it is a stretch of the
// waveform, and where it went slowly it covers few rows and where it went fast it
// covers many. This is where a height-based heuristic cannot reach -- a column's
// min and max cannot tell a ramp from a pulse, and a column's segments can (see
// `Style::intensity_grading`).
//
// It is charged to the column its earlier end falls in. The gather is asked at
// `intensity_sub_columns` buckets per column, so a segment is a fraction of a
// column wide and splitting one across a boundary would buy a fraction of one
// sub-bucket's dwell.
const f64 c = std::floor(px - static_cast<f64>(left));
// Clamped, not dropped, for the reason `stage_trace_columns()` gives: the
// gathered window carries one sample of slack on each side and that sample's
// ink belongs to the edge column.
const u32 ci = !(c > 0.0) ? 0u
: c >= static_cast<f64>(columns - 1u) ? columns - 1u
: static_cast<u32>(c);
if (open && ci != column) emit_column(); // x is increasing, so ci > column
if (!open) {
column = ci;
open = true;
}
const f64 lo = std::min(py, sy);
const f64 hi = std::max(py, sy);
// Wholly outside the axes is wholly clipped, and a clamp would move its ink to
// the edge and invent a ridge there. A segment that *crosses* the edge is
// clamped instead, which is the coverage the clip leaves -- so a column's shares
// are shares of what is visible in it, which is what is being graded.
if (hi >= static_cast<f64>(top) && lo <= static_cast<f64>(bottom)) {
const f64 ra = std::floor(lo - static_cast<f64>(top));
const f64 rb = std::floor(hi - static_cast<f64>(top));
const u32 r0 = !(ra > 0.0) ? 0u
: ra >= static_cast<f64>(rows - 1u) ? rows - 1u
: static_cast<u32>(ra);
const u32 r1 = !(rb > 0.0) ? 0u
: rb >= static_cast<f64>(rows - 1u) ? rows - 1u
: static_cast<u32>(rb);
// A segment deposits one unit of dwell, **spread over the rows it covers** --
// which is the whole measurement, and counting the rows instead was wrong in a
// way worth recording: a sub-bucket holds about as many samples as any other, so
// a segment crossing a hundred rows is those samples *passing through* each of
// them, while one covering two rows is the same samples *sitting* in them. Giving
// each crossed row a full unit makes a sine's fast middle as bright as its rails,
// which is the pattern grading exists to show.
//
// Two writes whatever the height: the running sum over the column is then the
// dwell at each row.
const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u);
if (w < w_min) w_min = w;
column_rows_[r0] += w;
column_rows_[r1 + 1u] -= w;
}
}
px = sx;
py = sy;
have_prev = true;
}
if (open) emit_column();
return staged;
}
u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
f64 x_origin, bool log_x, bool log_y) {
if (points < 2u) return 0u;
// At least two, for the reason `stage_trace_density()` gives: a one-step grading is
// the whole trace drawn dimmer, which is not what a host asking for this wants.
const u32 steps = std::max(2u, style_.intensity_steps);
steps_.resize(steps);
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
const Transform yt = plot.y_transform(trace.axis_y);
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
const f32 fsteps = static_cast<f32>(steps);
u32 staged = 0;
for (u32 i = 0; i + 1u < points; ++i) {
const f64 y0 = static_cast<f64>(xy[2u * i + 1u]);
const f64 y1 = static_cast<f64>(xy[2u * i + 3u]);
const f64 s0 = yt.to_screen_y(y0);
const f64 s1 = yt.to_screen_y(y1);
// A NaN sample, and a non-positive one on a log axis, have no position, and the
// *segment* is what is being staged -- so one unplaceable end drops it entirely
// rather than being clamped to somewhere the series says nothing about. The
// ungraded polyline reaches the same conclusion through `add_polyline_xy()`, which
// a non-finite vertex excludes from both of its segments.
if (!std::isfinite(s0) || !std::isfinite(s1)) continue;
// Dwell, the same quantity `stage_trace_density()` deposits and for the same
// reason: a segment is a stretch of the waveform, and the rows it spans are the
// rows the signal crossed *on its way* rather than sat in. `1 + span` so a flat
// segment -- span 0, the commonest case in a sparse window -- is 1 and not a
// division by zero, which also makes the measure dimensionless and absolute: no
// per-column peak exists to normalise against down here.
const f32 span = static_cast<f32>(std::fabs(s1 - s0));
const f32 dwell = 1.0f / (1.0f + span);
const u32 t = std::min(steps - 1u, static_cast<u32>(std::pow(dwell, gamma) * fsteps));
// Staged exactly as `stage_xy()` does, so the geometry is bit-for-bit the ungraded
// polyline's and only the batch differs. The linear case needs no mapping at all --
// the gather already emitted x relative to this origin.
if (!log_x && !log_y) {
steps_[t].add_segment(Vec2{xy[2u * i], xy[2u * i + 1u]},
Vec2{xy[2u * i + 2u], xy[2u * i + 3u]});
} else {
const f64 ox = space(x_origin, log_x);
steps_[t].add_segment(
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i]) + x_origin, log_x) - ox),
static_cast<f32>(space(y0, log_y))},
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i + 2u]) + x_origin, log_x) - ox),
static_cast<f32>(space(y1, log_y))});
}
++staged;
}
return staged;
}
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// **No solid trace while the density is on.** The trace ink *is* the density image (see
// `Plot::set_density()` and `draw_density()`): every visible trace was deposited, the newest
// acquisition included, so drawing the polyline as well would put a solid, unfading stroke over
// its own faint image -- the "persistent image + last acquisition" a report described, where the
// newest acquisition never faded because it was never only the image. The traces still exist --
// the legend, the crosshair readout and the measurements read them -- they are simply not drawn
// as lines.
if (plot.density().enabled) return;
// Both dimensions: a zero-width rect selects no level, and a zero-height one // Both dimensions: a zero-width rect selects no level, and a zero-height one
// would flatten every trace onto a single row -- a frame that looks like a // would flatten every trace onto a single row -- a frame that looks like a
// rendering choice rather than the collapsed plot it is. // rendering choice rather than the collapsed plot it is.
@@ -1013,9 +1418,30 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// This is the whole performance contract: the window, never the series. // This is the whole performance contract: the window, never the series.
const u64 visible = last - first; const u64 visible = last - first;
const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w); const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w);
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp) // Grading reads a column's *distribution*, which one envelope per column cannot
? series.pyramid().level_for(n, samples_per_pixel) // carry (`Style::intensity_grading`), so the gather is asked for the level a
: 0u; // fraction of a column implies. The *gate* is still the pixel grid's density: a
// window sparse enough to be drawn as a polyline is drawn as one, graded or not.
const bool grade = style_.intensity_grading && trace.mode == data::GatherMode::Envelope &&
style_.intensity_sub_columns > 1u && style_.intensity_steps > 1u;
const u32 flat_level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
? series.pyramid().level_for(n, samples_per_pixel)
: 0u;
// The finer level, floored at the pyramid's coarsest bucket level. Grading asks for a
// fraction of a column and `level_for` answers 0 -- raw samples -- as soon as that
// fraction falls under one bucket, so an unfloored ask turns every moderately dense
// frame into an O(visible) gather: measured at 1.3 MS/s over a 2 s window, 172 samples
// a pixel, where the flat path sits on the pyramid and grading dropped the whole figure
// to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 -> 6.4 ms a frame.
// The floor degrades the *look* instead: fewer sub-columns than asked for, down to
// whatever the coarsest buckets give, which at 64 samples a bucket is still two or three
// per column. Grading is a way of drawing a reduction and may not undo it.
const u32 level =
grade && flat_level > 0u
? std::max(1u, series.pyramid().level_for(
n, samples_per_pixel /
static_cast<f64>(style_.intensity_sub_columns)))
: flat_level;
gather_.clear(); gather_.clear();
// The visible range's minimum is the gather origin: the emitted f32 is then O(the span on // The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
@@ -1111,8 +1537,97 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the // trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
// markers below are the whole trace. // markers below are the whole trace.
if (trace.width > 0.0f) { if (trace.width > 0.0f) {
stage_xy(gather_.data(), points, x_range.min, log_x, log_y); // A window denser than two gathered points per pixel column is drawn as *columns*
flush_lines(map, trace.color, style_.device(trace.width), dash); // rather than as a polyline: one exactly vertical, pixel-snapped segment per column,
// spanning what that column's own points reach.
//
// The polyline's geometry at this density is a stack of near-vertical hairlines at
// *fractional* x -- an envelope bucket spans (spp/2, spp] samples, so there are one to
// two buckets per column, each emitting its own min->max hairline plus a connector to
// the next, about three and a half per column. Each is composited with its own
// sub-pixel coverage, so a column's brightness is a non-linear function of those
// phases; the phases repeat at the beat between the bucket pitch and the pixel pitch,
// which is a **moire** across the trace, and panning sweeps the beat so the pattern
// crawls. It is strongest exactly where a scope is used hardest: a high sample rate, a
// high signal frequency, or a noisy signal, all of which make the ribbon thin enough
// for its per-column coverage to be visible as texture.
//
// Reducing to one segment per column removes the cause rather than the symptom: there
// is one phase on the whole trace (and the snap puts it on the grid), so there is
// nothing left to beat. It is also O(axes.w) geometry instead of O(buckets), and it
// loses nothing a reader could have seen -- a column cannot show more than the extremes
// of what it covers, which is what the band is.
//
// Two conditions, and each refuses a case this would damage.
// - `Envelope` only. `Nearest` and `Mean` are one-point-per-bucket modes chosen so the
// trace reads as *samples* or as a smoothed line; a min/max band would answer a
// different question from the one the host asked.
// - Two points per column. Below that the gather is sparser than the grid, and the
// polyline's segments are the shape: `Series::gather()` also falls back to raw samples
// when no complete bucket fits the range, and that window's trace is drawn by the two
// segments that enter and leave the frame (see
// `a_sparse_window_between_samples_still_inks_both_margins`). The real envelope path
// emits two to four points per column by construction, so the test holds exactly when
// it ran.
const bool columns = trace.mode == data::GatherMode::Envelope &&
static_cast<f64>(points) >= 2.0 * static_cast<f64>(layout.axes.w);
// `snap = true` on geometry that is not chrome, which is the one carve-out in that
// uniform's rule ("a line that reports a data position may not be moved off it"): a
// column *is* a pixel column, and its x is already a screen coordinate derived from
// one -- there is no data position to move it off. The y bounds, which are what the
// segment reports, are untouched by the snap. It holds for both staging paths.
const u32 staged =
!columns ? 0u
: grade ? stage_trace_density(plot, layout, trace, x_range.min, points)
: stage_trace_columns(plot, layout, trace, x_range.min, points);
if (staged != 0u && grade) {
// One draw call per alpha step, faintest first: the line pipeline's colour is a
// uniform, so a per-segment alpha is not expressible and a step is a batch. They
// are flushed in order so the bright ridge composites over the faint tail rather
// than under it -- the runs within a column do not overlap, but their AA fringes
// do, and that is the order a phosphor display puts them in.
for (u32 t = 0; t < steps_.size(); ++t) {
if (steps_[t].segment_count() == 0u) continue;
Color color = trace.color;
color.a *= style_.intensity_floor +
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
static_cast<f32>(steps_.size() - 1u);
flush_lines(steps_[t], pixel_mapping(), color, style_.device(trace.width), dash, true);
}
++stats_.column_traces;
++stats_.graded_traces;
} else if (staged != 0u) {
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
++stats_.column_traces;
} else {
// The sparse half of the phosphor model. `grade` is not reused as the gate: it
// requires `Envelope`, because the *column reduction* turns a bucket's min/max
// into a band and that answers a different question from the one a `Nearest` or
// `Mean` host asked. A segment's slope is a property of the polyline itself, so
// no gather mode is excluded here.
const u32 sparse =
style_.intensity_grading && style_.intensity_steps > 1u
? stage_trace_slopes(plot, trace, gather_.data(), points, x_range.min, log_x,
log_y)
: 0u;
if (sparse != 0u) {
// Faintest first, and `snap = false` -- the same order as the graded columns
// for the same compositing reason, but these segments sit at *data* positions
// and the snap uniform's rule applies to them in full.
for (u32 t = 0; t < steps_.size(); ++t) {
if (steps_[t].segment_count() == 0u) continue;
Color color = trace.color;
color.a *= style_.intensity_floor +
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
static_cast<f32>(steps_.size() - 1u);
flush_lines(steps_[t], map, color, style_.device(trace.width), dash, false);
}
++stats_.slope_traces;
} else {
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash);
}
}
} }
// Markers, staged as line segments through this same batch in *pixel* space // Markers, staged as line segments through this same batch in *pixel* space
@@ -1240,6 +1755,123 @@ void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, con
} }
} }
void Renderer::draw_invalid_spans(const PlotLayout& layout) {
for (const InvalidSpanLayout& band : layout.invalid_spans) {
fill_pixels(band.fill, band.repaired ? style_.invalid_repaired_fill : style_.invalid_fill);
}
}
void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
const DensityConfig& config = plot.density();
if (!config.enabled) {
// A plot that has stopped drawing a density is not holding a stale one: forget what the shared
// texture holds, so a re-enable -- which drops the grids and rebuilds them, possibly at the same
// addresses and with fresh revisions -- re-uploads rather than trusting a cache entry that
// happens to match.
density_uploaded_grid_ = nullptr;
return;
}
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) return;
if (!density_program_.valid()) return; // a pipeline that never linked draws nothing
// One quad per trace, in that trace's own colour: the plot holds one grid per trace (see
// `DensityConfig`), so where two traces agree the viewer sees both colours, and a saturated cell
// is the colour of the trace whose ink filled it and not a blend. A trace with no visible series
// was deposited into nothing (its grid is empty), so it is skipped.
for (u32 i = 0u; i < plot.trace_count(); ++i) {
const Trace& trace = plot.trace(i);
if (!trace.visible()) continue;
draw_density_grid(config, plot.density_grid(i), trace.color, layout);
}
}
void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout) {
const u32 columns = grid.columns();
const u32 rows = grid.rows();
if (columns == 0u || rows == 0u) return;
// The image as the shader reads it: one byte per cell, `full_scale` and `gamma` already
// applied. The conversion lives here rather than in the fragment shader so that a cell's
// *count* has exactly one reading -- the plot's, which is also what a host reads back through
// `DensityGrid::ink()`/`peak()`, and which decays once per frame on the CPU anyway.
//
// **One texture, re-filled per trace.** The cache is keyed by the grid's own address as well as
// its revision, so a plot drawn twice with nothing changed (the steady state of a display that
// has stopped being swept) skips the conversion *and* the upload -- while a row of several traces
// re-fills the one texture once per trace, which is what a figure of several rows already cost
// before the density was per trace.
const bool resized = columns != density_w_ || rows != density_h_;
if (resized) {
if (!density_texture_.create_r8(columns, rows, nullptr)) {
gl_error_ = true;
return;
}
// **Linear, and it used to be a point sample.** The filter is the thing that makes a cell
// bigger than a pixel acceptable: a cell is a *count*, a continuous quantity, so a fragment
// between two of them is between their two ink levels -- which is exactly what a linear tap
// returns, and what a `GL_NEAREST` fetch cannot (it would show the grid's own steps as blocks
// once the image is scaled up, `kDensityCellCap` cells spread across a wider axes rect). The
// nearest tap was right only while a cell *was* a device pixel and every fragment landed in
// its own; minification interpolates for the same reason, so a plot whose rect is under the
// cap (cell and pixel one and the same, up to the rounding of `ceil`) reads as it always did.
density_texture_.set_filter(gl::GL_LINEAR, gl::GL_LINEAR);
density_w_ = columns;
density_h_ = rows;
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
}
if (resized || density_uploaded_grid_ != &grid || grid.revision() != density_uploaded_revision_) {
const f32 full = config.full_scale > 0.0f ? config.full_scale : 1.0f;
const f32 gamma = config.gamma > 0.0f ? config.gamma : 1.0f;
// The gamma through a 256-entry table rather than a `pow` per cell: the output *is* eight
// bits, so quantising the count first and lifting the quantised value costs nothing a
// per-cell `pow` would have bought, and it keeps the per-frame pass a loop of multiplies.
u8 lut[256];
for (u32 i = 0u; i < 256u; ++i) {
const f32 v = static_cast<f32>(i) / 255.0f;
lut[i] = static_cast<u8>(std::clamp(std::pow(v, gamma), 0.0f, 1.0f) * 255.0f + 0.5f);
}
density_pixels_.resize(grid.cell_count());
const f32* cells = grid.cells().data();
for (usize i = 0; i < density_pixels_.size(); ++i) {
const f32 v = cells[i] / full;
const u32 quantised =
static_cast<u32>(std::clamp(v, 0.0f, 1.0f) * 255.0f + 0.5f);
density_pixels_[i] = lut[std::min(quantised, 255u)];
}
// `upload_r8` sets GL_UNPACK_ALIGNMENT to 1 and leaves it there (see gl::Texture), so the
// host's own value is saved and put back -- the same care `TextAtlas::sync()` takes around
// its own upload, since the two share a context and a frame.
gl::GLint alignment = 4;
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &alignment);
density_texture_.upload_r8(columns, rows, density_pixels_.data());
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, alignment);
density_uploaded_grid_ = &grid;
density_uploaded_revision_ = grid.revision();
// += and not =: a figure draws a density per trace per row, and what this field reports is the
// frame's upload, not the last one's.
stats_.density_upload_bytes += columns * rows;
}
// The quad covers the image's own **footprint**: the axes rect rounded up to whole device pixels,
// the rect `Plot::size_density_grids()` counted the cells on. While that rect is under
// `kDensityCellCap` it is exactly `columns` by `rows` pixels -- one texel a pixel, the mapping
// this has always had -- and past the cap the grid is smaller and the image is magnified back
// over it. The uv still runs 0..1 over the quad, so a fragment between two cells is a linear
// blend of them: the filter the texture was created with.
density_program_.bind();
density_program_.set_viewport({viewport_.w, viewport_.h});
density_program_.set_rect({layout.axes.x, layout.axes.y},
{std::ceil(layout.axes.w), std::ceil(layout.axes.h)});
// The tint is the trace's own colour: its alpha is the trace's, so a saturated cell reads as the
// trace at its own brightness, and the shader multiplies it by the cell's luminance.
density_program_.set_color(tint);
density_texture_.bind();
density_vao_.bind();
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
stats_.density_cells += columns * rows;
++stats_.draw_calls;
}
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
const HotHandles hot = plot.hot_handles(); const HotHandles hot = plot.hot_handles();
const f32 edge_w = style_.device(style_.line_width_px); const f32 edge_w = style_.device(style_.line_width_px);
@@ -1463,6 +2095,27 @@ void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
} }
} }
void Renderer::draw_selection_frame(const PlotLayout& layout) {
const Rect& frame = layout.selection;
// Two guards, not one: a drag swept flat in one direction has a real extent on one axis and none on
// the other, and a border on that is still the gesture's own answer -- the fill is what a zero
// extent has nothing of.
if (!(frame.w > 0.0f) && !(frame.h > 0.0f)) return;
if (frame.w > 0.0f && frame.h > 0.0f) fill_pixels(frame, style_.selection_fill);
// The border frames the pixels the fill covers and not the measured rect's own fractional edges:
// the readout panel's rounding and the readout panel's reason, since a hairline straddling a
// fractional edge is a frame bright on two sides and dim on the others.
const f32 left = std::ceil(frame.x - 0.5f) + 0.5f;
const f32 right = std::ceil(frame.right() - 0.5f) - 0.5f;
const f32 top = std::ceil(frame.y - 0.5f) + 0.5f;
const f32 bottom = std::ceil(frame.bottom() - 0.5f) - 0.5f;
lines_.add_segment({left, top}, {right, top});
lines_.add_segment({left, bottom}, {right, bottom});
lines_.add_segment({left, top}, {left, bottom});
lines_.add_segment({right, top}, {right, bottom});
flush_lines(pixel_mapping(), style_.selection_edge, style_.device(1.0f));
}
void Renderer::draw_readout_panel(const Rect& panel, Color border) { void Renderer::draw_readout_panel(const Rect& panel, Color border) {
if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return; if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return;
// The frame's own background at a partial alpha, which dims whatever it covers instead of erasing // The frame's own background at a partial alpha, which dims whatever it covers instead of erasing
+44
View File
@@ -235,4 +235,48 @@ void main() {
} }
)"; )";
const char* const kDensityVS = R"(#version 330 core
// No vertex attributes at all: the image is one axis-aligned rectangle whose corners are
// uniform state, so the quad comes from gl_VertexID alone and there is no vertex buffer to
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
uniform vec2 u_size; // the quad's size in pixels -- the axes rect, which the image fills
out vec2 v_uv;
void main() {
// A four-vertex triangle strip, like the line and text pipelines: the corner index is the
// vertex id's two bits, so 0,1,2,3 are (0,0), (1,0), (0,1), (1,1).
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
vec2 px = u_origin + corner * u_size;
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
// uv runs 0..1 across the whole image, so a fragment lands wherever the axes rect puts it
// between the grid's cells: one texel a cell while the rect is under the cap (a cell a device
// pixel, the sampling all but a point sample) and a linear magnification of the image past it.
v_uv = corner;
}
)";
const char* const kDensityFS = R"(#version 330 core
in vec2 v_uv; // cell coordinate in [0,1] across the quad
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_LINEAR
uniform vec4 u_color;
out vec4 o_color;
void main() {
// The cell's count was already turned into a luminance on the CPU, where the count, the
// decay and the gamma live -- so this stage has no scale of its own that a host reading the
// grid's numbers back could disagree with. All that is left is the tint: the density is drawn
// once per trace, in that trace's own colour (the caller's `u_color`), so a saturated cell is
// the trace at its own brightness and a faint one is the same hue at a lower alpha.
float ink = texture(u_density, v_uv).r;
o_color = vec4(u_color.rgb, u_color.a * ink);
}
)";
} // namespace plot::render } // namespace plot::render
+3
View File
@@ -3,6 +3,7 @@
#pragma once #pragma once
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <cmath> #include <cmath>
#include <chrono> #include <chrono>
#include <algorithm> #include <algorithm>
@@ -57,7 +58,9 @@ inline void check_near(double a, double b, const char* file, int line, const cha
inline int run_all() { inline int run_all() {
int failed = 0; int failed = 0;
const char* only = std::getenv("PLOT_TEST_FILTER");
for (auto& c : cases()) { for (auto& c : cases()) {
if (only != nullptr && std::string(c.name).find(only) == std::string::npos) continue;
try { try {
c.fn(); c.fn();
std::printf("[ ok ] %s\n", c.name); std::printf("[ ok ] %s\n", c.name);
+51
View File
@@ -2403,3 +2403,54 @@ PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12); PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
PLOT_CHECK(c.grabbed_cursor() == normal); PLOT_CHECK(c.grabbed_cursor() == normal);
} }
PLOT_TEST(a_ctrl_box_zoom_previews_the_band_it_will_zoom) {
// What a host draws for an open box zoom is not always the area the pointer swept. Ctrl frames the
// x range only, so what its release will change is a *band* across the plot -- and a pointer dragged
// straight across sweeps a sliver a couple of pixels tall, which would be the frame showing the
// gesture instead of its effect. Shift frames both axes and gets the swept box itself; Alt is the
// other single-axis mask, and gets the other frame.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
const plot::Rect axes = p.layout().axes;
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_pointer({100, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
// Nothing framed until something was swept: the press alone is an empty rect at the pointer, and a
// full-width band derived from it would claim a range that does not exist yet.
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
c.on_pointer({300, 202, 200, 2, plot::input::Button::Left, kCtrl, true, false, false});
const plot::Rect ctrl = c.selection_preview();
PLOT_CHECK_NEAR(ctrl.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.w, 200.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.y, axes.y, 1e-6);
PLOT_CHECK_NEAR(ctrl.h, axes.h, 1e-6);
// The gesture's own geometry is untouched: the widening happens in the preview, so the range the
// release computes is still the one the pointer described.
PLOT_CHECK_NEAR(c.selection_rect().h, 2.0, 1e-6);
c.on_pointer({300, 202, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({400, 150, 300, 100, plot::input::Button::Left, kShift, true, false, false});
const plot::Rect both = c.selection_preview();
PLOT_CHECK_NEAR(both.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(both.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(both.w, 300.0, 1e-6);
PLOT_CHECK_NEAR(both.h, 100.0, 1e-6);
c.on_pointer({400, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kAlt, true, true, false});
c.on_pointer({110, 150, 10, 100, plot::input::Button::Left, kAlt, true, false, false});
const plot::Rect alt = c.selection_preview();
PLOT_CHECK_NEAR(alt.x, axes.x, 1e-6);
PLOT_CHECK_NEAR(alt.w, axes.w, 1e-6);
PLOT_CHECK_NEAR(alt.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(alt.h, 100.0, 1e-6);
c.on_pointer({110, 150, 0, 0, plot::input::Button::Left, kAlt, false, false, true});
// Closed: a frame on a finished gesture would claim the old selection is still open.
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
}
+134
View File
@@ -16,6 +16,7 @@
#include <plot/render/embedded_mono_font.hpp> #include <plot/render/embedded_mono_font.hpp>
#include <cmath> #include <cmath>
#include <span>
#include <vector> #include <vector>
namespace { namespace {
@@ -1141,3 +1142,136 @@ PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) {
PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f); PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f);
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f); PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
} }
// ------------------------------------------------------------------ cell grid
namespace {
/// A figure whose rows hold `cells[r]` plots each (the flattened plots in row-major
/// order), with an x and a y axis on every plot unless `axes` is off.
plot::Figure split_figure(const std::vector<plot::u32>& cells, bool axes = true) {
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a vexing parse
figure.set_viewport(whole_figure());
if (axes) {
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
}
return figure;
}
} // namespace
PLOT_TEST(figure_cells_all_ones_equals_rows) {
plot::Figure split = split_figure({1, 1, 1});
plot::Figure plain = figure_with_axes(3u);
const plot::FigureLayout& sl = split.layout();
const plot::FigureLayout& pl = plain.layout();
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
PLOT_CHECK(sl.cells.size() == 3u);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(sl.cells[i].y, pl.rows[i].y, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].h, pl.rows[i].h, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].w, pl.rows[i].w, 0.01);
}
}
PLOT_TEST(figure_cells_tile_exactly) {
plot::Figure figure = split_figure({1, 2, 1});
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
PLOT_CHECK(figure.cells_in_row(1u) == 2u);
PLOT_CHECK(figure.first_plot_of_row(1u) == 1u && figure.first_plot_of_row(3u) == 4u);
PLOT_CHECK(figure.row_of_plot(2u) == 1u);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
}
PLOT_TEST(figure_cell_weights_and_floor) {
plot::Figure figure = split_figure({2});
figure.set_col_gap(0.0f);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
PLOT_CHECK(figure.has_cell_weights(0u));
{
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR((c[0].w - 64.0), (c[1].w - 64.0) * 3.0, 0.01);
}
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
PLOT_CHECK(figure.set_cell_weights(0u, tiny));
PLOT_CHECK(figure.layout().cells[1].w >= 64.0f - 0.001f);
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
const std::vector<plot::f32> zero{1.0f, 0.0f};
PLOT_CHECK(!figure.set_cell_weights(0u, three));
PLOT_CHECK(!figure.set_cell_weights(0u, zero));
PLOT_CHECK(!figure.set_cell_weights(5u, w));
figure.clear_cell_weights(0u);
PLOT_CHECK(!figure.has_cell_weights(0u));
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR(c[0].w, c[1].w, 0.01);
}
PLOT_TEST(figure_cells_narrow_degrades) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 20.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
}
PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
plot::Figure figure = split_figure({2, 1});
const plot::u32 group = figure.add_x_link();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.link_x(i, group));
figure.plot(1u).set_x_range(0u, {3.0, 9.0});
figure.layout();
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).min, 3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).max, 9.0, 1e-9);
}
figure.set_crosshair(4.0, true);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK(figure.plot(i).crosshair().active);
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
}
figure.clear_crosshair();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
}
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
plot::Figure figure = split_figure({1, 2});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.plot(0u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h > 0.0f);
PLOT_CHECK(figure.plot(2u).layout().x_label_area.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 0.001);
}
PLOT_TEST(figure_cells_common_y_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
const plot::FigureLayout& fl = figure.layout();
const double g0 = figure.plot(0u).layout().axes.x - fl.cells[0].x;
const double g1 = figure.plot(1u).layout().axes.x - fl.cells[1].x;
PLOT_CHECK(g0 > 0.0);
PLOT_CHECK_NEAR(g0, g1, 0.01);
}
+31
View File
@@ -14,6 +14,9 @@
#include <glfw/glfw_host.hpp> #include <glfw/glfw_host.hpp>
#include <plot/plot/figure.hpp> #include <plot/plot/figure.hpp>
#include <span>
#include <vector>
namespace { namespace {
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so /// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
@@ -315,3 +318,31 @@ PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
PLOT_CHECK(host.row_controller(0u) == nullptr); PLOT_CHECK(host.row_controller(0u) == nullptr);
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller()); PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
} }
PLOT_TEST(glfw_host_routes_by_cell) {
// A figure of two rows, the first of one cell and the second of two side by side: the
// pointer's x as well as its y picks the plot, and the controllers are indexed by the
// flattened plot.
std::vector<plot::u32> cells{1, 2};
plot::Figure figure{std::span<const plot::u32>(cells)};
figure.set_viewport(plot::Rect{0, 0, 300, 600});
figure.set_col_gap(6.0f);
for (plot::u32 i = 0; i < 3; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
plot::glfw::Host host;
host.set_figure(&figure);
const std::vector<plot::Rect> c = figure.layout().cells;
PLOT_CHECK(c.size() == 3u);
for (plot::u32 i = 0; i < 3; ++i) {
PLOT_CHECK(host.row_controller(i) != nullptr);
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
PLOT_CHECK(&host.controller_at(static_cast<plot::f32>(cx), static_cast<plot::f32>(cy)) ==
host.row_controller(i));
}
PLOT_CHECK(host.row_controller(3u) == nullptr);
// in the column gap: the nearest cell wins
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0f, c[1].y + 5.0f) == host.row_controller(1));
PLOT_CHECK(&host.controller_at(c[2].x - 1.0f, c[2].y + 5.0f) == host.row_controller(2));
}
+67
View File
@@ -6,6 +6,7 @@
#include <plot/data/detail/merge_count.hpp> #include <plot/data/detail/merge_count.hpp>
#include <plot/data/minmax_pyramid.hpp> #include <plot/data/minmax_pyramid.hpp>
#include <algorithm> #include <algorithm>
#include <limits>
#include <random> #include <random>
using plot::data::kBucket; using plot::data::kBucket;
@@ -342,3 +343,69 @@ PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
p.append(1.0f); p.append(1.0f);
PLOT_CHECK(p.level_count() >= 1); PLOT_CHECK(p.level_count() >= 1);
} }
PLOT_TEST(amend_folds_a_backfilled_sample_into_every_covering_level) {
// A hole is appended as NaN -- which the pyramid already stores as an empty aggregate --
// and amended later when the packet that owned it finally arrives. The amendment has to
// reach every level that covers the index, because the renderer reads whichever level the
// zoom picked: repairing level 0 alone would make a trace whole when zoomed in and still
// broken when zoomed out.
MinMaxPyramid p;
p.reserve(8 * kBucket);
for (u64 i = 0; i < 8 * kBucket; ++i) {
// One NaN, at an index comfortably inside the first bucket of every level.
p.append(i == 5 ? std::numeric_limits<f32>::quiet_NaN() : 1.0f);
}
// Before: every covering bucket aggregates kBucket-1 ones and knows nothing of index 5.
PLOT_CHECK(p.bucket_count(0, 0) == kBucket - 1);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 1.0);
p.amend(5, 7.0f);
// Level 0 gained the sample.
PLOT_CHECK(p.bucket_count(0, 0) == kBucket);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 7.0);
PLOT_CHECK_NEAR(p.bucket_sum(0, 0), f64(kBucket - 1) + 7.0);
PLOT_CHECK_NEAR(p.bucket_sumsq(0, 0), f64(kBucket - 1) + 49.0);
// And so did every coarser level that covers it.
for (u32 l = 1; l < p.level_count(); ++l) {
if (p.level_size(l) == 0) continue;
const u64 span = u64{kBucket} << l;
const u64 b = 5 / span;
if (b < p.level_base(l) || b - p.level_base(l) >= p.level_size(l)) continue;
PLOT_CHECK_NEAR(p.bucket(l, b).max, 7.0);
PLOT_CHECK(p.bucket_count(l, b) == span);
}
}
PLOT_TEST(amend_makes_an_all_nan_bucket_present_again) {
// A bucket whose every sample was non-finite is stored with count 0, which every reader
// treats as absent. One amendment has to make it present -- min and max become the value
// rather than widening from a meaningless zero, which is the case a plain fold gets wrong.
MinMaxPyramid p;
p.reserve(4 * kBucket);
const f32 nan = std::numeric_limits<f32>::quiet_NaN();
for (u64 i = 0; i < 2 * kBucket; ++i) p.append(i < kBucket ? nan : 1.0f);
PLOT_CHECK(p.bucket_count(0, 0) == 0);
p.amend(3, -2.0f);
PLOT_CHECK(p.bucket_count(0, 0) == 1);
PLOT_CHECK_NEAR(p.bucket(0, 0).min, -2.0);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, -2.0);
}
PLOT_TEST(amend_outside_the_window_is_a_no_op) {
// An index the window has already dropped, or one not yet appended, has nowhere to go.
// Silently ignoring it is what lets the caller amend without first proving the ring still
// holds the slot -- a race it cannot win, because the window slides on the other thread.
MinMaxPyramid p;
p.reserve(4 * kBucket);
for (u64 i = 0; i < 4 * kBucket; ++i) p.append(1.0f);
p.drop_before(2 * kBucket);
const u64 before = p.bucket_count(0, 2);
p.amend(5, 99.0f); // dropped
p.amend(100 * kBucket, 99.0f); // never appended
PLOT_CHECK(p.bucket_count(0, 2) == before);
PLOT_CHECK_NEAR(p.bucket(0, 2).max, 1.0);
}
+422
View File
@@ -2789,3 +2789,425 @@ PLOT_TEST(an_annotation_can_be_moved_by_id) {
p.set_annotation(42u, other); p.set_annotation(42u, other);
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12); PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
} }
PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
// The box-zoom frame is the host's to set -- the plot has no controller and learns nothing about
// pointers -- so what it owes is to carry it: out through `layout()` unchanged, because it is
// already in viewport pixels and a second derivation would be a second rounding of the same
// rectangle, and gone when the host takes it off, because a frame left behind claims the selection
// is still open.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
PLOT_CHECK(p.layout().selection.w == 0.0f && p.layout().selection.h == 0.0f); // nothing swept
p.set_selection_rect({10.0f, 20.0f, 30.0f, 40.0f});
PLOT_CHECK_NEAR(p.selection_rect().x, 10.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.y, 20.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.w, 30.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 40.0, 1e-6);
p.clear_selection_rect();
PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6);
}
// --- phosphor density -------------------------------------------------------
namespace {
/// A two-sample flat series: one segment across the whole window at y = 0, which is the
/// simplest geometry a density can be read against -- one cell per column and nothing else.
std::vector<f32> flat_pair() { return std::vector<f32>{0.0f, 0.0f}; }
/// A plot with one flat trace over a 640x480 viewport, laid out. The axes rect it returns is
/// the one `accumulate_density()` sizes the grid to, so a test reads both together.
struct DensityFixture {
plot::data::ArraySeries series;
plot::Plot plot;
plot::u32 ax = 0;
plot::u32 ay = 0;
explicit DensityFixture(std::vector<f32> y = flat_pair(), plot::f64 dx = 1.0)
: series(std::move(y), 0.0, dx) {
plot.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
ax = plot.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "flat"}) == 0u);
plot.set_x_range(ax, {0.0, 1.0});
plot.set_y_range(ay, {-1.0, 1.0});
plot.layout();
}
};
} // namespace
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect **up to
/// `kDensityCellCap`**, and nothing at all until the density is switched on. The figures here are
/// the ones the API's memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has
/// done and whatever the window is.
PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
DensityFixture f;
const plot::Rect axes = f.plot.layout().axes;
// The 640x480 viewport this fixture asks for is past the cap, so its grid is the axes rect
// *scaled down* -- the rule the library states, written out here so the assertion is arithmetic
// rather than a recorded number. A grid under the cap is the rect itself, which the 320x240 leg
// at the end reads.
const auto capped = [](plot::u32 w, plot::u32 h) {
std::pair<plot::u32, plot::u32> out{w, h};
const plot::u64 pixels = static_cast<plot::u64>(w) * static_cast<plot::u64>(h);
if (pixels > static_cast<plot::u64>(plot::kDensityCellCap)) {
const plot::f64 s = std::sqrt(static_cast<plot::f64>(plot::kDensityCellCap) /
static_cast<plot::f64>(pixels));
out.first = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(w) * s));
out.second = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(h) * s));
}
return out;
};
const auto [columns, rows] =
capped(static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
// The row a device y lands in is read through the grid's cells-per-*whole*-pixel, which is what
// the deposit uses (see `deposit_density_segment()`): 1 while the grid is the rect.
const plot::f32 sy = static_cast<plot::f32>(rows) / std::ceil(axes.h);
PLOT_CHECK(static_cast<plot::u64>(columns) * rows <= plot::kDensityCellCap);
// Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that
// deposits nothing at all.
PLOT_CHECK(!f.plot.density().enabled);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density().enabled);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid(0u);
PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
// `4 bytes * cells`, and never more than the cap: the whole point of the bound.
const plot::usize bytes = grid.cell_count() * sizeof(plot::f32);
PLOT_CHECK(bytes <= static_cast<plot::usize>(plot::kDensityCellCap) * sizeof(plot::f32));
std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n",
grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
static_cast<double>(grid.peak()));
// One flat trace over the whole window lights every column exactly once: one segment, no
// column reached twice, so the ink *is* the width and the peak is one pass.
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(columns), 1.0);
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
// ... and all of it in one row, the one y = 0 maps to.
const plot::Transform ty = f.plot.y_transform(f.ay);
const int row = static_cast<int>(std::floor((ty.to_screen_y(0.0) - axes.y) * sy));
plot::u32 lit = 0;
for (plot::u32 r = 0; r < grid.rows(); ++r) {
for (plot::u32 c = 0; c < grid.columns(); ++c) {
if (grid.at(c, r) <= 0.0f) continue;
++lit;
PLOT_CHECK(static_cast<int>(r) == row);
}
}
PLOT_CHECK(lit == columns);
// The accumulation is a *screen* image: a plot that has moved to another axes rect gets a
// grid of that size and nothing carried over from the old one -- and that rect is under the
// cap, so the grid is it *exactly*: one cell a device pixel, no scaling at all.
f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
f.plot.layout();
f.plot.accumulate_density();
const plot::DensityGrid& small = f.plot.density_grid(0u);
const plot::Rect small_axes = f.plot.layout().axes;
PLOT_CHECK(small.columns() == static_cast<plot::u32>(std::ceil(small_axes.w)));
PLOT_CHECK(small.rows() == static_cast<plot::u32>(std::ceil(small_axes.h)));
PLOT_CHECK(small.cell_count() <= plot::kDensityCellCap);
PLOT_CHECK(small.columns() < columns);
PLOT_CHECK_NEAR(small.ink(), static_cast<double>(small.columns()), 1.0);
}
/// The cap is what keeps the resolution from following the window: a grid is never more than
/// `kDensityCellCap` cells, whatever the axes rect is, so the memory a display holds and the frame
/// pass over it are numbers and not functions of how wide the window was dragged. The scaling is
/// **uniform**, so a cell stays square and the picture keeps the axes' aspect -- the reading that
/// separates this from a cap on one axis, which would stretch every cell by the other one's ratio.
PLOT_TEST(a_density_grid_is_capped_whatever_the_window) {
DensityFixture f;
f.plot.set_viewport({0.0f, 0.0f, 3840.0f, 2160.0f});
f.plot.layout();
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid(0u);
const plot::Rect axes = f.plot.layout().axes;
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) * grid.rows() <= plot::kDensityCellCap);
PLOT_CHECK(grid.cell_count() * sizeof(plot::f32) <= 1'048'576u);
// ... and it is as large as the cap allows: flooring a uniform scale costs at most the two
// columns and rows the truncation drops, so the grid is within a hair of the whole budget.
PLOT_CHECK(static_cast<plot::f64>(grid.cell_count()) >
0.99 * static_cast<plot::f64>(plot::kDensityCellCap));
// Uniform: the grid's aspect is the axes rect's, to the rounding of the two floors.
PLOT_CHECK_NEAR(static_cast<plot::f64>(grid.columns()) / grid.rows(),
static_cast<plot::f64>(axes.w) / axes.h, 0.01);
// A cap on the *cells* also returns a full sweep's worth of ink: one flat pass over the window
// is one per column however fine or coarse the grid is.
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
std::printf("[density] capped grid=%ux%u cells=%zu bytes=%zu of a %ux%u axes\n", grid.columns(),
grid.rows(), grid.cell_count(), grid.cell_count() * sizeof(plot::f32),
static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
}
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
/// deposited, so the sweep on screen is the brightest and the one behind it is the factor
/// down: the schedule a phosphor's afterglow has, and the reading that says which way round
/// the halving goes.
PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::PerAcquisition;
cfg.per_acquisition = 0.5f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
// The grid is sized by the first accumulation, so the width is read after it -- and the ink
// of one pass *is* the width: one segment across the window, one cell per column.
const double width = static_cast<double>(f.plot.density_grid(0u).columns());
const double one = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(one, width, 1.0);
f.plot.accumulate_density();
const double two = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.5f, 1e-3);
f.plot.accumulate_density();
const double three = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.75f, 1e-3);
// The steady state of a stream that keeps sweeping: 1 + 1/2 + 1/4 + ... = 2 passes.
for (int i = 0; i < 40; ++i) f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 2.0f, 1e-3);
// A factor of 0 is "only the newest acquisition is on screen": the grid is the last pass.
cfg.per_acquisition = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), width, 1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.0f, 1e-3);
std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two,
three, 2.0);
}
/// The time constant is the other question: the image fades while you watch, by the frame's own
/// `dt` against a constant stated in seconds, and it is the *caller* who advances it -- there is
/// no clock in the library, which is what makes the reading reproducible.
PLOT_TEST(a_density_fades_by_its_time_constant) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::TimeConstant;
cfg.time_constant_s = 2.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double full = f.plot.density_grid(0u).ink();
// A frame that has not been measured yet (dt <= 0) does not wipe the grid, and neither does
// a frame under `PerAcquisition`, which fades in `accumulate_density()` instead.
f.plot.decay_density(0.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
f.plot.decay_density(-1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
// Quarter of a time constant: exp(-0.25).
f.plot.decay_density(0.5);
const double quarter = full * std::exp(-0.25);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), quarter, quarter * 1e-4);
// Ten more constants and the display is gone to within a rounding: 4.5e-5 of the ink.
for (int i = 0; i < 10; ++i) f.plot.decay_density(2.0);
PLOT_CHECK(f.plot.density_grid(0u).ink() < quarter * 1e-3);
PLOT_CHECK(f.plot.density_grid(0u).peak() < 1e-3f);
// A non-positive constant freezes the grid rather than fading it.
cfg.time_constant_s = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double frozen = f.plot.density_grid(0u).ink();
f.plot.decay_density(100.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), frozen, 1e-6);
// And `PerAcquisition` fades in `accumulate_density()` alone: a frame that only passed
// changes nothing.
cfg.decay = plot::DensityDecay::PerAcquisition;
f.plot.set_density(cfg);
const double before = f.plot.density_grid(0u).ink();
f.plot.decay_density(1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), before, 1e-6);
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
frozen);
}
/// Switching off drops the image at once -- not at the next acquisition, which a trigger that
/// has stopped firing never sends -- and turning it back on starts from an empty grid.
PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
f.plot.clear_density();
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
// The clear keeps the allocation -- the grid is still the screen's -- so the next
// accumulation does not have to reallocate it.
PLOT_CHECK(f.plot.density_grid(0u).cell_count() > 0u);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
cfg.enabled = false;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
// ... and an accumulation while it is off deposits nothing (it is not a hidden build-up).
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
}
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
/// leaves no ink rather than a band along the edge it left by, and a NaN sample is a gap that
/// joins nothing.
PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
// y = 5 against a [-1, 1] axis: the segment's whole column span is above the axes.
DensityFixture off(std::vector<f32>{5.0f, 5.0f});
plot::DensityConfig cfg;
cfg.enabled = true;
off.plot.set_density(cfg);
off.plot.accumulate_density();
PLOT_CHECK_NEAR(off.plot.density_grid(0u).ink(), 0.0, 1e-9);
// ... and the same series inside the axis deposits one cell per column, in two rows: the
// first half at y = -1 and the second at y = +1, joined by a diagonal.
DensityFixture split(std::vector<f32>{-1.0f, 1.0f});
split.plot.set_density(cfg);
split.plot.accumulate_density();
const plot::DensityGrid& grid = split.plot.density_grid(0u);
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay);
const plot::Rect axes = split.plot.layout().axes;
// The row a device y lands in is the grid's own mapping: the axis rect scaled by the grid's
// cells-per-pixel (1 while the rect is under `kDensityCellCap`, less past it).
const plot::f32 sy =
axes.h > 0.0f ? static_cast<plot::f32>(grid.rows()) / std::ceil(axes.h) : 1.0f;
// The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto*
// the bottom edge -- lands in the last row rather than one past the end.
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor((ty.to_screen_y(y) - axes.y) * sy)), 0,
static_cast<int>(grid.rows()) - 1));
};
PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f);
PLOT_CHECK(grid.at(grid.columns() - 1u, row_of(1.0)) > 0.0f);
// Inverted on screen: y = +1 is the *lower* row index, which is the one y flip of the mapping.
PLOT_CHECK(row_of(1.0) < row_of(-1.0));
// A NaN in the middle is a gap: it joins nothing, so the trace is deposited as two runs and
// the columns where the gap is stay empty -- where the same series with the sample filled in
// lights every one of them. Five samples at x = 0, 0.25, 0.5, 0.75, 1.0.
const auto column_ink = [](const plot::DensityGrid& g, plot::u32 column) {
double sum = 0.0;
for (plot::u32 r = 0; r < g.rows(); ++r) sum += static_cast<double>(g.at(column, r));
return sum;
};
DensityFixture whole(std::vector<f32>{0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, 0.25);
whole.plot.set_density(cfg);
whole.plot.accumulate_density();
DensityFixture gap(
std::vector<f32>{0.0f, 0.0f, std::numeric_limits<f32>::quiet_NaN(), 0.0f, 0.0f}, 0.25);
gap.plot.set_density(cfg);
gap.plot.accumulate_density();
const plot::DensityGrid& whole_grid = whole.plot.density_grid(0u);
const plot::DensityGrid& gap_grid = gap.plot.density_grid(0u);
const plot::Transform tx = gap.plot.x_transform(gap.ax);
const plot::Rect gap_axes = gap.plot.layout().axes;
const plot::f32 gsx = gap_axes.w > 0.0f
? static_cast<plot::f32>(gap_grid.columns()) / std::ceil(gap_axes.w)
: 1.0f;
const plot::u32 column = static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor((tx.to_screen_x(0.5) - gap_axes.x) * gsx)), 0,
static_cast<int>(gap_grid.columns()) - 1));
PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit
PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it
PLOT_CHECK(gap_grid.ink() < whole_grid.ink());
std::printf("[density] off-range ink=%.0f split ink=%.0f gap ink=%.0f whole ink=%.0f\n",
off.plot.density_grid(0u).ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
}
/// **One grid per trace.** A plot of two signals holds two images, each deposited from its own
/// trace and drawn in its own colour (the colour is the renderer's, and here the *count* is what is
/// read): the two traces at two levels leave ink in two different rows, so one grid holding one
/// trace's ink and not the other's is the reading that says the images are not shared.
PLOT_TEST(a_density_holds_one_grid_per_trace) {
plot::data::ArraySeries lo(std::vector<plot::f32>{-0.5f, -0.5f}, 0.0, 1.0);
plot::data::ArraySeries hi(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(p.add_trace({&lo, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay, "lo"}) == 0u);
PLOT_CHECK(p.add_trace({&hi, plot::Color::hex(0xFF8A4F), 1.5f, ax, ay, "hi"}) == 1u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
p.layout();
plot::DensityConfig cfg;
cfg.enabled = true;
p.set_density(cfg);
p.accumulate_density();
PLOT_CHECK(p.trace_count() == 2u);
const plot::DensityGrid& g0 = p.density_grid(0u);
const plot::DensityGrid& g1 = p.density_grid(1u);
// Both are sized to the one axes rect, and each holds exactly its own trace's one pass: the ink
// is the width in each grid, so neither picked up the other trace's ink (two traces would be two
// widths in a shared grid).
PLOT_CHECK(g0.cell_count() > 0u);
PLOT_CHECK(g1.cell_count() == g0.cell_count());
PLOT_CHECK_NEAR(g0.ink(), static_cast<double>(g0.columns()), 1.0);
PLOT_CHECK_NEAR(g1.ink(), static_cast<double>(g1.columns()), 1.0);
const plot::Transform ty = p.y_transform(ay);
const plot::Rect axes = p.layout().axes;
const plot::f32 sy =
axes.h > 0.0f ? static_cast<plot::f32>(g0.rows()) / std::ceil(axes.h) : 1.0f;
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::floor((ty.to_screen_y(y) - axes.y) * sy));
};
const plot::u32 lo_row = row_of(-0.5);
const plot::u32 hi_row = row_of(0.5);
PLOT_CHECK(lo_row != hi_row);
PLOT_CHECK(g0.at(0u, lo_row) > 0.0f);
PLOT_CHECK(g1.at(0u, hi_row) > 0.0f);
PLOT_CHECK(g0.at(0u, hi_row) == 0.0f); // no trace but `lo` is in the lower grid
PLOT_CHECK(g1.at(0u, lo_row) == 0.0f);
// An out-of-range trace reads an empty image rather than anything borrowed.
PLOT_CHECK(p.density_grid(2u).cell_count() == 0u);
PLOT_CHECK(p.density_grid(9u).ink() == 0.0);
std::printf("[density] per-trace grids: cells=%zu each ink=%.0f/%.0f rows lo=%u hi=%u\n",
g0.cell_count(), g0.ink(), g1.ink(), lo_row, hi_row);
}
File diff suppressed because it is too large. Load diff
+61
View File
@@ -21,6 +21,43 @@ PLOT_TEST(ring_clear_resets) {
PLOT_CHECK_NEAR(r[0], 42.0); PLOT_CHECK_NEAR(r[0], 42.0);
} }
PLOT_TEST(ring_clear_to_resumes_the_absolute_count) {
// A recompute: a derived ring is emptied and refilled from the absolute origin its source
// retains, so it has to come back numbered as the source is. `clear()` restarts at 0, which
// leaves a refilled ring reporting a produced() a whole window behind an identically fed one.
plot::data::RingBuffer<f32> src(8);
plot::data::RingBuffer<f32> derived(8);
for (int i = 0; i < 20; ++i) {
src.push(f32(i));
derived.push(f32(i) * 2.0f);
}
PLOT_CHECK(src.base() == 12);
derived.clear_to(src.base());
for (u64 i = 0; i < src.size(); ++i) derived.push(src[i] * 3.0f);
// Numbered as the source: same produced(), same base(), and the retained windows pair up.
PLOT_CHECK(derived.produced() == src.produced());
PLOT_CHECK(derived.base() == src.base());
PLOT_CHECK(derived.size() == src.size());
PLOT_CHECK_NEAR(derived[0], 36.0); // 12 * 3
PLOT_CHECK_NEAR(derived[7], 57.0); // 19 * 3
// And the absolute reader agrees with the windowed one, which is what a snapshot reads.
f32 at = 0.0f;
derived.copy_from(19, 1, &at);
PLOT_CHECK_NEAR(at, 57.0);
}
PLOT_TEST(ring_clear_to_zero_is_clear) {
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 5; ++i) r.push(f32(i));
r.clear_to(0);
PLOT_CHECK(r.size() == 0);
PLOT_CHECK(r.produced() == 0);
r.push(42.0f);
PLOT_CHECK_NEAR(r[0], 42.0);
}
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) { PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
// The capacity is rounded up so slot addressing stays a mask, and capacity() // The capacity is rounded up so slot addressing stays a mask, and capacity()
// reports the rounded value -- the one callers must size for (R4). // reports the rounded value -- the one callers must size for (R4).
@@ -35,3 +72,27 @@ PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
PLOT_CHECK_NEAR(r[127], 999.0); // newest PLOT_CHECK_NEAR(r[127], 999.0); // newest
} }
PLOT_TEST(ring_amend_rewrites_a_retained_slot) {
// Backfill: a slot pushed as a placeholder is rewritten once the data that belongs there
// arrives. The index is absolute, because that is the only coordinate a late packet can
// compute -- a retained-window index would have moved by the time it got here.
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 6; ++i) r.push(f32(i));
PLOT_CHECK(r.amend(3, 42.0f));
PLOT_CHECK_NEAR(r[3], 42.0);
PLOT_CHECK(r.produced() == 6); // an amend is not a push
}
PLOT_TEST(ring_amend_refuses_an_index_the_ring_no_longer_holds) {
// The window slides on the producer, so a late amend may arrive after its slot was
// overwritten. Writing it anyway would corrupt whatever sample now owns that slot, so the
// bounds test is the whole safety of the operation and the caller reads the result.
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 20; ++i) r.push(f32(i));
PLOT_CHECK(r.base() == 12);
PLOT_CHECK(!r.amend(3, 42.0f)); // aged out
PLOT_CHECK(!r.amend(20, 42.0f)); // never produced
PLOT_CHECK_NEAR(r[0], 12.0); // and nothing moved
PLOT_CHECK_NEAR(r[7], 19.0);
}