feat: a span declared invalid is shaded behind the data it is missing from

This commit is contained in:
Martino Ferrari committed 2026-10-08 20:11:16 +02:00
1 parent 1ae72d4903
commit ebc67bd318
6 files changed
+250

No files matched your search

+162
View File
@@ -7212,3 +7212,165 @@ PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_one) {
PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24);
PLOT_CHECK(!r.last_frame_had_gl_error());
}
/// An invalid span is the opposite of the x note: the note is chrome that a pan must not move,
/// and a band marks a *region of the data*, so it must pan and zoom with it. A band that stayed
/// put under a pan would accuse the wrong samples of being missing, which is worse than drawing
/// nothing. It also must reserve no layout space -- the label strips were measured before any
/// band existed, and a hole must not be able to move them.
///
/// Measured as a footprint for the x note's reason: the band's rect is deliberately not readable
/// back, so the only way to see it is the pixels a frame *with* it differs from the same frame
/// without one, at the same view. Diffing at a fixed view is also what makes the measurement
/// robust: whatever the trace does under a pan cancels between the two frames, so the bounding
/// box is the band and nothing else.
PLOT_TEST(an_invalid_span_is_shaded_and_moves_with_the_data) {
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(sine_samples(), 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", s.x_bounds(), true, 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({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
// The bounding box of the pixels the band, and only the band, is responsible for, plus the
// axes rect the frame was laid out into so the caller can check it did not move.
const auto footprint = [&](const plot::Range& view, std::pair<plot::f64, plot::f64> span,
plot::Rect& axes_out, const char* dump) {
p.set_x_range(ax, view);
const std::pair<plot::f64, plot::f64> one[1] = {span};
p.set_invalid_spans(one);
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, dump);
const std::vector<plot::u8> with = read_frame(ctx);
axes_out = p.layout().axes;
p.set_invalid_spans({});
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> without = read_frame(ctx);
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0;
for (plot::u32 y = 0; y < kH; ++y) {
for (plot::u32 x = 0; x < kW; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) {
continue;
}
++differing;
lo_x = std::min(lo_x, x);
lo_y = std::min(lo_y, y);
hi_x = std::max(hi_x, x);
hi_y = std::max(hi_y, y);
}
}
// A comparison of two frames is only evidence if the band was in one of them.
PLOT_CHECK(differing > 0u);
return plot::Rect{static_cast<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
static_cast<plot::f32>(hi_x - lo_x + 1u),
static_cast<plot::f32>(hi_y - lo_y + 1u)};
};
// A band over the middle third of a 640-wide window.
plot::Rect axes_a{0.0f, 0.0f, 0.0f, 0.0f};
const plot::Rect none_box = footprint({0.0, 640.0}, {640.0 / 3.0, 1280.0 / 3.0}, axes_a,
"invalid_span.ppm");
// Inside the axes on every side -- a hole is not allowed to paint over a label strip -- and
// the full height of the data area, because the whole column has no data.
PLOT_CHECK(none_box.x >= axes_a.x && none_box.right() <= axes_a.right() + 1.0f);
PLOT_CHECK(none_box.y >= axes_a.y && none_box.bottom() <= axes_a.bottom() + 1.0f);
PLOT_CHECK_NEAR(none_box.h, axes_a.h, 2.0);
// The middle third: a third of the width, starting a third of the way across. Two pixels of
// slack for the fill's fractional edges.
PLOT_CHECK_NEAR(none_box.x - axes_a.x, axes_a.w / 3.0f, 2.0);
PLOT_CHECK_NEAR(none_box.w, axes_a.w / 3.0f, 2.0);
// The same span under a pan of exactly 64 device pixels' worth of data. The band is anchored
// in the data, so it must move by 64 pixels -- the x note, measured the same way, does not
// move at all, which is the distinction this pair of cases draws.
const plot::f64 per_px = 640.0 / plot::f64(axes_a.w);
const plot::f64 dx = 64.0 * per_px;
plot::Rect axes_b{0.0f, 0.0f, 0.0f, 0.0f};
const plot::Rect panned = footprint({dx, 640.0 + dx}, {640.0 / 3.0, 1280.0 / 3.0}, axes_b,
"invalid_span_panned.ppm");
PLOT_CHECK_NEAR(panned.x, none_box.x - 64.0f, 2.0);
PLOT_CHECK_NEAR(panned.w, none_box.w, 2.0);
// The layout is identical across both: the band reserved nothing, so the strips the labels
// were measured into are where they were before any band existed.
PLOT_CHECK_NEAR(axes_b.x, axes_a.x, 1e-4);
PLOT_CHECK_NEAR(axes_b.y, axes_a.y, 1e-4);
PLOT_CHECK_NEAR(axes_b.w, axes_a.w, 1e-4);
PLOT_CHECK_NEAR(axes_b.h, axes_a.h, 1e-4);
std::printf("[invalid span] band %.0f,%.0f %.0fx%.0f panned %.0f,%.0f %.0fx%.0f "
"(shift %.1f px) in axes %.0f,%.0f %.0fx%.0f\n",
none_box.x, none_box.y, none_box.w, none_box.h, panned.x, panned.y, panned.w,
panned.h, none_box.x - panned.x, axes_a.x, axes_a.y, axes_a.w, axes_a.h);
ctx.destroy();
}
/// A hole narrower than a pixel is still a report. A reserved span of one sample at a window
/// width of hundreds of thousands maps to a fraction of a column, and a fill rounded to zero
/// width would draw nothing -- the one case where the feature is most needed is the one where
/// naive rounding silently drops it. The band therefore has a one-device-pixel floor.
PLOT_TEST(a_sub_pixel_invalid_span_is_still_shaded) {
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(sine_samples(), 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", s.x_bounds(), true, 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({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
// One sample's worth of hole in a 200 000-wide window: well under a hundredth of a column.
p.set_x_range(ax, {0.0, 200000.0});
const std::pair<plot::f64, plot::f64> one[1] = {{100000.0, 100000.0}};
p.set_invalid_spans(one);
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "invalid_span_subpixel.ppm");
const std::vector<plot::u8> with = read_frame(ctx);
p.set_invalid_spans({});
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> without = read_frame(ctx);
plot::u32 columns = 0, differing = 0;
for (plot::u32 x = 0; x < kW; ++x) {
bool any = false;
for (plot::u32 y = 0; y < kH; ++y) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (with[at] != without[at] || with[at + 1u] != without[at + 1u] ||
with[at + 2u] != without[at + 2u] || with[at + 3u] != without[at + 3u]) {
any = true;
++differing;
}
}
if (any) ++columns;
}
// Drawn, and narrow: the floor is one device pixel, not a visible band widened to be seen.
PLOT_CHECK(differing > 0u);
PLOT_CHECK(columns >= 1u && columns <= 2u);
std::printf("[invalid span] sub-pixel hole lit %u column(s), %u pixels\n", columns, differing);
ctx.destroy();
}