fix(render): map the visible range through the series' own x

Renderer::draw_traces picked the sample window with (x - x0()) / dx(), and dx() is
documented as a *nominal* spacing -- for a series whose x is a streamed timestamp it
is only the mean of the retained window. The window it computed therefore landed
inside the visible range, and the two end segments, the ones that enter and leave the
frame, were never emitted: the left and right margins of a zoomed-in view came out
empty. A wide enough drift dropped the trace entirely (last <= first).

It asks Series::neighbours() now -- a binary search over the real x, the same call the
crosshair reads through, costing the same. A uniform series answers the same question
through the same call, so it is one path. dx is still read for the samples-per-pixel
density estimate, where a nominal spacing is exactly right.

Test: a_sparse_window_between_samples_still_inks_both_margins -- a series whose dx()
lies, with the window strictly between two samples, so nothing inside the window is
sampled and only the crossing segments can ink it. With the grid walk reverted it
stages nothing at all (visible=0, gathered=0, covered=0/24); with the fix it stages the
three bracketing samples and covers 24/24 columns.
This commit is contained in:
Martino Ferrari committed 2026-10-03 14:20:11 +02:00
1 parent 2dcd1f1864
commit f3fb500068
3 files changed
+143 -17

No files matched your search

+5
View File
@@ -14,6 +14,11 @@ 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 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
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
timestamp would otherwise have its draw window land inside the visible range and lose the two
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
Per-frame rendering cost is O(visible pixels + samples produced since the last frame), Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
+19 -17
View File
@@ -42,15 +42,6 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept {
/// clamp, so it lands off-scale rather than as NaN. /// clamp, so it lands off-scale rather than as NaN.
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; } f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; }
/// Sample index for a (possibly fractional, possibly non-finite) position in a
/// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a
/// range the axis cannot map still yields a valid window.
u64 clamp_index(f64 v, u64 n) noexcept {
if (!(v > 0.0)) return 0;
if (v >= static_cast<f64>(n)) return n;
return static_cast<u64>(v);
}
/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for /// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
/// three significant digits plus a prefix and sign. /// three significant digits plus a prefix and sign.
constexpr usize kNumberChars = 24; constexpr usize kNumberChars = 24;
@@ -994,15 +985,26 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const u64 n = series.size(); const u64 n = series.size();
if (!(dx > 0.0) || n == 0) continue; if (!(dx > 0.0) || n == 0) continue;
// Map the visible x range onto sample indices. One sample of slack on each // The visible x range onto sample indices, through the series' *own* x.
// side covers a partially visible end bucket; the range is clamped to the //
// series, so a zoomed-out view never asks for more than exists. // Not the `(x - x0()) / dx()` grid: `dx()` is documented as a nominal spacing (the LOD
// level, a coarse snap) and for a series whose x is a streamed timestamp it is only the
// mean of the retained window. Walking that grid lands the window inside the visible range
// -- and the first and last *segments*, the ones that enter and leave the frame, are then
// not drawn at all, which is the empty margin at both edges of a zoomed-in view. A series
// with a uniform x answers the same question through the same call, so this is the one
// path. One sample of slack on each side keeps a partially visible end bucket whole, and
// the result is clamped to the series: a zoomed-out view never asks for more than exists,
// and a window that reaches past the data leaves the margin genuinely -- correctly -- empty.
const Range x_range = plot.x_range(trace.axis_x); const Range x_range = plot.x_range(trace.axis_x);
f64 lo = (x_range.min - series.x0()) / dx; // `dx` is still read above for the samples-per-pixel estimate -- a density, where a nominal
f64 hi = (x_range.max - series.x0()) / dx; // spacing is exactly right -- and never for the window itself.
if (hi < lo) std::swap(lo, hi); u64 first = 0;
const u64 first = clamp_index(std::floor(lo) - 1.0, n); u64 last = n;
const u64 last = clamp_index(std::ceil(hi) + 1.0, n); u64 i0 = 0;
u64 i1 = 0;
if (series.neighbours(x_range.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
if (series.neighbours(x_range.max, i0, i1)) last = std::min(n, i1 + 1);
if (last <= first) continue; if (last <= first) continue;
// This is the whole performance contract: the window, never the series. // This is the whole performance contract: the window, never the series.
+119
View File
@@ -695,6 +695,125 @@ PLOT_TEST(renders_visible_trace_across_viewport) {
ctx.destroy(); ctx.destroy();
} }
/// A series whose `dx()` understates its spacing.
///
/// That is every ring-backed series: x is a streamed timestamp, so `dx()` is only a nominal
/// grid (an LOD level, a coarse snap) while `x_at()`/`neighbours()` answer from the real time
/// ring. The samples here sit ten apart and `dx()` claims one, with the plotted window
/// `[12, 18]` falling strictly *between* two of them -- no sample is inside the window at all,
/// so the only way to light its margins is the segment that crosses them.
class LyingGridSeries final : public plot::data::Series {
public:
LyingGridSeries() {
pyramid_.reserve(kN);
for (u64 i = 0; i < kN; ++i) pyramid_.append(kY);
}
u64 size() const override { return kN; }
f64 x0() const override { return 0.0; }
f64 dx() const override { return 1.0; } // the lie: the real spacing is x_at()'s ten
plot::Range x_bounds() const override { return {0.0, x_at(kN - 1u)}; }
plot::Range y_bounds() const override { return {kY, kY}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
f64 x_at(u64 i) const override { return 10.0 * static_cast<f64>(i); }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x < 0.0 || x > x_at(kN - 1u)) return false;
i0 = static_cast<u64>(x / 10.0);
i1 = i0;
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
return true;
}
void gather(u32, u64 begin, u64 end, std::vector<f32>& out,
plot::data::GatherMode) const override {
++gather_calls_;
for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0()));
out.push_back(kY);
}
}
u64 gather_calls() const noexcept { return gather_calls_; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end; ++i) out[i - begin] = kY;
}
private:
static constexpr u64 kN = 5u;
static constexpr f32 kY = 1.0f;
plot::data::MinMaxPyramid pyramid_;
mutable u64 gather_calls_ = 0;
};
/// The window falls between two samples: the trace must still be drawn to both margins.
///
/// Found by zooming in: the renderer mapped the visible x range onto sample indices with
/// `(x - x0()) / dx()`, and on a series whose `dx()` is nominal that lands *inside* the visible
/// range -- here it lands past the end of a five-sample series entirely, so `last <= first` and
/// the frame held no trace at all. The segment entering the window from the sample before it and
/// leaving to the one after are what draw the margins, and a series answers where they are
/// through `neighbours()`, over its own x.
PLOT_TEST(a_sparse_window_between_samples_still_inks_both_margins) {
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));
LyingGridSeries s;
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
// `auto_scale` off on both: the window has to be the one the test names, or the axis takes
// the series' own bounds and the window falls outside the frame -- a test of nothing.
const plot::u32 ax = p.add_x_axis({"t", {12.0, 18.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 3.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
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, "margins.ppm");
const plot::Rect a = p.layout().axes;
// The window's own evidence: the samples that bracket it are sampled even though neither is
// inside it, and the reverted grid walk staged *none* (`(12 - 0) / 1` is past the end of a
// five-sample series, so `last <= first` and the frame held no trace at all).
const plot::Range drawn_x = p.x_range(ax);
const plot::u32 covered = covered_columns(ctx, a, 24);
std::printf("[margins] window=[%.3f..%.3f] axes=[%.1f %.1f %.1f %.1f] visible=%llu "
"raw=%llu lod=%llu segments=%llu gathers=%llu trace_visible=%d covered=%u/24\n",
drawn_x.min, drawn_x.max, a.x, a.y, a.w, a.h,
static_cast<unsigned long long>(r.stats().visible_samples),
static_cast<unsigned long long>(r.stats().raw_traces),
static_cast<unsigned long long>(r.stats().lod_traces),
static_cast<unsigned long long>(r.stats().staged_segments),
static_cast<unsigned long long>(s.gather_calls()), int(p.trace(0).visible()),
covered);
PLOT_CHECK(r.stats().visible_samples >= 2u);
PLOT_CHECK(covered == 24u);
PLOT_CHECK(covered_columns(ctx, a, 24) == 24); // every column, margins included
// The margins by name, on the line's own row: the reported symptom was these two being blank
// while the middle drew. `y = 1` in the window `[-1, 3]` is the middle row of the band.
const plot::u32 row = static_cast<plot::u32>(a.y + a.h / 2.0f);
for (const plot::u32 x : {static_cast<plot::u32>(a.x) + 1u,
static_cast<plot::u32>(a.x + a.w) - 2u}) {
bool inked = false;
for (plot::u32 dy = 0; dy < 4u && !inked; ++dy) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (row + dy), px));
inked = trace_pixel(px);
}
PLOT_CHECK(inked);
}
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
PLOT_TEST(dense_and_sparse_paths_agree_on_extent) { PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
plottest::HeadlessContext ctx(kW, kH); plottest::HeadlessContext ctx(kW, kH);
plot::Status st; plot::Status st;