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:
1 parent
2dcd1f1864
commit
f3fb500068
3 files changed
+143
-17
No files matched your search
@@ -695,6 +695,125 @@ PLOT_TEST(renders_visible_trace_across_viewport) {
|
||||
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) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
|
||||
Reference in new issue
Block a user