From 37e414bcc5a5b1aceabeb0d44247d0df8a76b7b5 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 8 Oct 2026 16:56:10 +0200 Subject: [PATCH] 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 --- README.md | 9 +- docs/building-and-testing.md | 13 +- docs/data-and-performance.md | 53 +++++++- include/plot/render/renderer.hpp | 30 ++++ include/plot/render/style.hpp | 8 ++ src/render/renderer.cpp | 112 ++++++++++++++- tests/harness.hpp | 3 + tests/test_render_smoke.cpp | 227 +++++++++++++++++++++++++++++++ 8 files changed, 442 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 46417a2..85635b2 100644 --- a/README.md +++ b/README.md @@ -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 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; + 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 (`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`, @@ -184,12 +187,12 @@ documentation. ```sh cmake --preset linux-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 ``` -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 `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 diff --git a/docs/building-and-testing.md b/docs/building-and-testing.md index b2968b4..aa16e7a 100644 --- a/docs/building-and-testing.md +++ b/docs/building-and-testing.md @@ -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 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` -preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line. +`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 `377 cases, 0 failed` summary line. 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. +`PLOT_TEST_FILTER=` 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 `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 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 `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) diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md index fd99f49..65c523a 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -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 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. 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 measured to dwell. - **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 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 Every number the library reports is one the samples actually hold. What differs between diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 1c34ae6..973786c 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -38,6 +38,12 @@ struct FrameStats { /// (`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 u32 staged_segments = 0; ///< line segments staged, all colour groups 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`). 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 /// 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 diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 1056357..ef13dfc 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -202,6 +202,14 @@ struct Style { /// 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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index e7f84ee..f2fca64 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -1096,6 +1096,9 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co 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::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 -- @@ -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; - // What counts as covered at all. The deposits are fractions, so this cannot be an - // integer test: it is a share of the column's own peak, small enough to keep a row a - // single thin segment grazed and large enough not to light a row on rounding alone. - const f32 lit_floor = 0.01f * peak; + // 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; @@ -1175,6 +1193,7 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co } } open = false; + w_min = std::numeric_limits::infinity(); if (!any) { joined = false; // nothing visible here: a hole, like an uncovered column 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 // dwell at each row. const f32 w = 1.0f / static_cast(r1 - r0 + 1u); + if (w < w_min) w_min = w; column_rows_[r0] += w; column_rows_[r1 + 1u] -= w; } @@ -1275,6 +1295,61 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co 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(steps); + + u32 staged = 0; + for (u32 i = 0; i + 1u < points; ++i) { + const f64 y0 = static_cast(xy[2u * i + 1u]); + const f64 y1 = static_cast(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(std::fabs(s1 - s0)); + const f32 dwell = 1.0f / (1.0f + span); + const u32 t = std::min(steps - 1u, static_cast(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(space(static_cast(xy[2u * i]) + x_origin, log_x) - ox), + static_cast(space(y0, log_y))}, + Vec2{static_cast(space(static_cast(xy[2u * i + 2u]) + x_origin, log_x) - ox), + static_cast(space(y1, log_y))}); + } + ++staged; + } + return staged; +} + void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { // 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 @@ -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); ++stats_.column_traces; } else { - stage_xy(gather_.data(), points, x_range.min, log_x, log_y); - flush_lines(map, trace.color, style_.device(trace.width), dash); + // 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(t) / + static_cast(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); + } } } diff --git a/tests/harness.hpp b/tests/harness.hpp index 82f7bb5..c97080c 100644 --- a/tests/harness.hpp +++ b/tests/harness.hpp @@ -3,6 +3,7 @@ #pragma once #include +#include #include #include #include @@ -57,7 +58,9 @@ inline void check_near(double a, double b, const char* file, int line, const cha inline int run_all() { int failed = 0; + const char* only = std::getenv("PLOT_TEST_FILTER"); for (auto& c : cases()) { + if (only != nullptr && std::string(c.name).find(only) == std::string::npos) continue; try { c.fn(); std::printf("[ ok ] %s\n", c.name); diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 963d44f..b66c4a2 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -6985,3 +6985,230 @@ PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) { } (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 square_samples(plot::u64 period) { + std::vector 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(kW), static_cast(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(a.h); + std::vector out; + for (plot::u32 col = 0; col < static_cast(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(a.x) + col, + ctx.height - 1u - (static_cast(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(px[2]) - static_cast(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(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + dump_if_requested(r, "riser-ungraded.ppm"); + const plot::Rect a = p.layout().axes; + const std::vector 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(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + dump_if_requested(r, "riser-graded.ppm"); + const std::vector 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(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(kW), static_cast(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(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + const plot::Rect a = p.layout().axes; + const plot::u32 rows = static_cast(a.h); + const plot::u32 cols = static_cast(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(a.x) + col, + ctx.height - 1u - (static_cast(a.y) + row), px)); + const int cover = static_cast(px[2]) - static_cast(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()); +}