Compare commits

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:10 +02:00
8 changed files with 442 additions and 13 deletions

No files matched your search

+6 -3
View File
@@ -48,7 +48,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is 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 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 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; 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;
- 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`,
@@ -184,12 +187,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 # 377 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 **377 cases**; the `linux-coverage` preset builds **360** 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
+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 **377 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 `377 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 `377 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 **360 of the 377 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)
+52 -1
View File
@@ -251,7 +251,18 @@ Five things carry it, and each was arrived at by being wrong first:
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and 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 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 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. A connector between two disjoint columns 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 is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
measured to dwell. measured to dwell.
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the - **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
@@ -276,6 +287,46 @@ flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=63
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
and the floor is never reached. 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.
## 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
+30
View File
@@ -38,6 +38,12 @@ struct FrameStats {
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of /// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
/// drawing the reduction, not a third path. /// drawing the reduction, not a third path.
u32 graded_traces = 0; 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
@@ -539,6 +545,30 @@ class Renderer {
/// from its own min and max (see `Style::intensity_grading`). /// from its own min and max (see `Style::intensity_grading`).
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace, u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points); 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
+8
View File
@@ -202,6 +202,14 @@ struct Style {
/// bright mark. Counting a row at +1 per segment that touches it instead -- the /// 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 /// obvious thing -- grades a sine's fast middle as brightly as its rails, which
/// is the pattern inverted. /// 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; bool intensity_grading = false;
/// Sub-buckets per pixel column the gather is asked for when grading: the level /// Sub-buckets per pixel column the gather is asked for when grading: the level
+106 -6
View File
@@ -1096,6 +1096,9 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
f32 prev_lo = 0.0f; f32 prev_lo = 0.0f;
f32 prev_hi = 0.0f; f32 prev_hi = 0.0f;
bool joined = false; 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 // 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 -- // starts empty. Two walks of it, which is nothing -- it is one column wide and in L1 --
@@ -1124,10 +1127,25 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
} }
} }
const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f; const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f;
// What counts as covered at all. The deposits are fractions, so this cannot be an // What counts as covered at all: **a row one segment covered is lit**. The deposits
// integer test: it is a share of the column's own peak, small enough to keep a row a // are fractions, so this cannot be an integer test, and it cannot be a share of the
// single thin segment grazed and large enough not to light a row on rounding alone. // column's peak alone either -- that was this function's one real defect. A square
const f32 lit_floor = 0.01f * peak; // 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; f32 cover = 0.0f;
bool in_run = false; bool in_run = false;
u32 run_tier = 0; u32 run_tier = 0;
@@ -1175,6 +1193,7 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
} }
} }
open = false; open = false;
w_min = std::numeric_limits<f32>::infinity();
if (!any) { if (!any) {
joined = false; // nothing visible here: a hole, like an uncovered column joined = false; // nothing visible here: a hole, like an uncovered column
return; return;
@@ -1263,6 +1282,7 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
// Two writes whatever the height: the running sum over the column is then the // Two writes whatever the height: the running sum over the column is then the
// dwell at each row. // dwell at each row.
const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u); const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u);
if (w < w_min) w_min = w;
column_rows_[r0] += w; column_rows_[r0] += w;
column_rows_[r1 + 1u] -= w; column_rows_[r1 + 1u] -= w;
} }
@@ -1275,6 +1295,61 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
return staged; 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) {
// 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
@@ -1498,8 +1573,33 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true); flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
++stats_.column_traces; ++stats_.column_traces;
} else { } else {
stage_xy(gather_.data(), points, x_range.min, log_x, log_y); // The sparse half of the phosphor model. `grade` is not reused as the gate: it
flush_lines(map, trace.color, style_.device(trace.width), dash); // 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);
}
} }
} }
+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);
+227
View File
@@ -6985,3 +6985,230 @@ PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
} }
(void)layout; (void)layout;
} }
namespace {
/// A true square wave: `period` samples a cycle, half at +1 and half at -1, so every
/// transition is a single-sample discontinuity. That is the waveform the phosphor model
/// is hardest on -- a riser has no intermediate samples to deposit ink in, so whatever
/// the grading does to it is the whole of what a reader sees.
std::vector<plot::f32> square_samples(plot::u64 period) {
std::vector<plot::f32> y(1'000'000);
for (plot::u64 i = 0; i < y.size(); ++i) {
y[i] = (i % period) < period / 2u ? 1.0f : -1.0f;
}
return y;
}
} // namespace
/// A column the signal only *passed* through must still be a column it reached:
/// `intensity_floor` exists so that "a riser dimmed out of existence is a square wave
/// drawn as two disconnected lines", and the per-column grading used to break that
/// promise on exactly the waveform it names.
///
/// The cause was the "lit at all" cutoff inside the column reduction. Grading normalises
/// a column against its own peak and then discards sub-buckets under `0.01 * peak`. A
/// column holding both a flat level and a riser has a peak set by the level -- the level
/// deposits its whole unit into one row -- while the riser spreads a unit over 300 rows,
/// so every riser row lands three orders of magnitude under the cutoff and is dropped
/// before the floor can be applied to it. The floor never sees the riser; there is no
/// tier 0 to fall back to.
///
/// Measured over this frame's 116 riser columns: the graded path inked 2 916 of the
/// ungraded path's 35 884 rows (8%), gutting 106 of the 116 and leaving a 311-row dark
/// run in a 312-row axis. The ten survivors are the phase that puts a riser in a column
/// of its own, which is what made the defect read as a *flicker* under panning rather
/// than as a missing edge. The cutoff is now `min(0.01 * peak, 0.5 * w_min)`, so it can
/// never exclude a deposit some segment actually made, and the reading is 35 572 rows
/// (99.1%) with 115 of 116 columns intact.
PLOT_TEST(a_graded_column_keeps_a_riser_it_shares_with_a_flat_level) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// 2098 samples a cycle over 104.8 samples a column is about 20 columns a cycle, so a
// riser's column also holds a long stretch of the level it leaves -- the sharing this
// test is about -- rather than being a column of pure transition.
plot::data::ArraySeries s(square_samples(2098), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// Columns whose band spans more than half the axis: on a square wave those are the
// transitions and nothing else, since a level's own band is a ribbon a few rows deep.
// "Tall" is the property at stake -- a riser drawn as a stub is as wrong as one not
// drawn at all -- so it is both how the columns are chosen and what is asserted of
// them, read from the ungraded frame and then re-read from the graded one.
const auto tall_columns = [&](const plot::Rect& a) {
const plot::u32 rows = static_cast<plot::u32>(a.h);
std::vector<plot::u32> out;
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
plot::u32 lo = rows;
plot::u32 hi = 0;
for (plot::u32 row = 0; row < rows; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
// 20 of the 191 a full stroke inks, which is under the faintest tier
// (`intensity_floor` 0.3) -- so a row this rejects is a row no tier drew.
if (static_cast<int>(px[2]) - static_cast<int>(px[0]) > 20) {
if (row < lo) lo = row;
hi = row;
}
}
if (lo < hi && hi - lo > rows / 2u) out.push_back(col);
}
return out;
};
// The axes rect is read *after* a draw: `Plot::layout()` sizes its label strips from
// the tick text at the style's font, so before the first frame it answers with the
// unmeasured estimate and the window would be off by 11 columns in x.
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "riser-ungraded.ppm");
const plot::Rect a = p.layout().axes;
const std::vector<plot::u32> flat = tall_columns(a);
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
r.style().intensity_grading = true;
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "riser-graded.ppm");
const std::vector<plot::u32> lit = tall_columns(a);
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
PLOT_CHECK(!r.last_frame_had_gl_error());
// How many of the ungraded frame's own riser columns survived the grading, which is
// the reading the ratio below is taken from. Both lists are ascending, so this is one
// pass.
plot::u32 kept = 0;
for (plot::usize i = 0, j = 0; i < flat.size() && j < lit.size();) {
if (flat[i] == lit[j]) {
++kept;
++i;
++j;
} else if (flat[i] < lit[j]) {
++i;
} else {
++j;
}
}
std::printf("[riser] axes=[%.1f %.1f %.1f %.1f] flat=%zu graded=%zu kept=%u\n", a.x, a.y, a.w,
a.h, flat.size(), lit.size(), kept);
// Enough columns to make the ratio mean something; 116 were measured.
PLOT_CHECK(flat.size() > 50u);
// And the grading kept all but one of them. The one is real and is not the defect: the
// ungraded path inks *two* full-height columns per transition -- a column's own min/max,
// the connectivity extension to its neighbour, and the gathered window's slack sample
// all land in x -- where the reduction inks one, which is also why 116 columns appear
// for 58 transitions. Before the fix this read 10 of 116.
PLOT_CHECK(kept * 100u >= static_cast<plot::u32>(flat.size()) * 95u);
}
/// Dwell dimming must not switch off when the window gets narrow. It used to, because
/// the grading was gated on the *column reduction*, and that reduction is gated on two
/// gathered points per pixel column -- a density whose only purpose is removing the
/// moire beat. Zooming past `dense_threshold_spp` therefore turned the whole phosphor
/// model off in one step, and a discontinuity that had been drawn at a third of the
/// level's ink was suddenly drawn at all of it: measured 71 of 191 at 3.4 samples a
/// pixel and 191 of 191 at 1.0, which is what a user reported as an edge that "lights
/// up" on zoom.
///
/// `Renderer::stage_trace_slopes()` grades the polyline by each segment's own slope, so
/// the model now spans both rasterisation paths. The riser reads 71 either side of the
/// threshold, and this test is that pair of readings.
PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_one) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(square_samples(2098), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
// -2..2 so the two levels sit a quarter in from each edge rather than on it: a level
// on the boundary row is half clipped away, and its ink would then read low for a
// reason that has nothing to do with grading.
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.style().intensity_grading = true;
struct Reading {
int riser = 0; ///< peak ink in the riser's own columns, at mid height
int flat = 0; ///< peak ink in a level's columns, well clear of the riser
};
// Both windows are centred on the transition at sample 1049, so the riser's column is
// known by construction rather than searched for -- a search would have to decide what
// a riser looks like, which is the very thing in question.
const auto measure = [&](double window) {
p.set_x_range(ax, {1049.0 - window / 2.0, 1049.0 + window / 2.0});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
const plot::Rect a = p.layout().axes;
const plot::u32 rows = static_cast<plot::u32>(a.h);
const plot::u32 cols = static_cast<plot::u32>(a.w);
const plot::u32 mid = cols / 2u;
Reading out;
for (plot::u32 col = 0; col < cols; ++col) {
for (plot::u32 row = 0; row < rows; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
if (col + 6u < mid || col > mid + 6u) {
// A level's own ink. The six-column guard is for the near-vertical stroke's
// own width plus the connector either side of it.
if (cover > out.flat) out.flat = cover;
} else if (row >= 3u * rows / 8u && row <= 5u * rows / 8u) {
// The riser's own ink: the middle quarter of the axis, which neither level
// reaches. Reading the full height instead answers 191 at every zoom, because
// the riser's column contains a level too.
if (cover > out.riser) out.riser = cover;
}
}
}
return out;
};
// 3.4 samples a pixel and 1.0, so one window either side of `dense_threshold_spp`
// (1.5) over the same waveform at the same place.
const Reading dense = measure(2000.0);
const plot::FrameStats dense_stats = r.stats();
const Reading sparse = measure(600.0);
const plot::FrameStats sparse_stats = r.stats();
std::printf("[slope] dense riser=%d flat=%d graded=%u slopes=%u | sparse riser=%d flat=%d "
"graded=%u slopes=%u\n",
dense.riser, dense.flat, dense_stats.graded_traces, dense_stats.slope_traces,
sparse.riser, sparse.flat, sparse_stats.graded_traces, sparse_stats.slope_traces);
// The two frames took the two paths, or the comparison is between a frame and itself.
PLOT_CHECK(dense_stats.graded_traces == 1 && dense_stats.slope_traces == 0);
PLOT_CHECK(sparse_stats.slope_traces == 1 && sparse_stats.graded_traces == 0);
// Neither path dims the level: a flat segment dwells in its row, so it is the top tier
// and full ink. Both read 191.
PLOT_CHECK(dense.flat > 180 && sparse.flat > 180);
// And both dim the discontinuity, to about a third -- `intensity_floor` is 0.3 and a
// riser spanning the axis is the longest span there is, so it sits on the floor. Both
// read 71. Before the slope grading the sparse frame read 191 here.
PLOT_CHECK(dense.riser * 100 < dense.flat * 55);
PLOT_CHECK(sparse.riser * 100 < sparse.flat * 55);
// The point of the exercise: the same edge at the two zooms is the same ink, so there
// is no step at the threshold for a reader to see. A tier either way is 23 of 191, and
// the measured pair is exact.
PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24);
PLOT_CHECK(!r.last_frame_had_gl_error());
}