fix(plot): a figure gives every row the same y gutter so the axes line up

This commit is contained in:
Martino Ferrari committed 2026-09-28 20:11:17 +02:00
1 parent 663b57c679
commit cae672bc99
6 files changed
+348 -5

No files matched your search

+107
View File
@@ -3677,6 +3677,113 @@ PLOT_TEST(shared_x_axis_is_drawn_once) {
ctx.destroy();
}
/// Spec §16, end to end: a figure whose rows carry different magnitudes ("671m",
/// "90", "2.51" scale tick labels) draws a vertical feature -- each row's own cursor
/// at the same data x -- in the **same pixel column** in every row. That is what a
/// user sees when the y axes line up, and it is what the rows' `axes` rects being
/// equal is for: the equality below is read from the drawn frame, not from the layout
/// alone, so a renderer that derived a row's geometry of its own would fail here.
PLOT_TEST(figure_rows_line_up_a_vertical_cursor) {
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));
const std::vector<plot::f32> samples = wave(101u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
// The cursor's colour, and the only strongly red thing in these frames: the trace is
// `kRowBlue` and every piece of chrome is grey (all three channels equal), so a
// red-dominant pixel inside a row's `axes` can only be that row's cursor. The row
// ranges are the three magnitudes whose tick labels are four, two and three
// characters wide -- the shape that gave each row its own strip before the gutter.
const plot::Color cursor_red = plot::Color::hex(0xFF2020);
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
plot::Figure figure(3u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 row = 0; row < 3u; ++row) {
plot::Plot& p = figure.plot(row);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay =
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
p.add_cursor(50.0, cursor_red);
}
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(figure);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "figure_gutter_cursor.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::FigureLayout layout = figure.layout();
PLOT_CHECK(layout.rows.size() == 3u);
// The mechanism, straight from the drawn frame's own layouts: every row's axes starts
// and ends on the same pixel columns, and that is true at the top of the figure too --
// these rows are separate plots vertically and one column horizontally.
const plot::Rect first_axes = figure.plot(0u).layout().axes;
for (plot::u32 row = 1; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first_axes.x), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first_axes.w), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.right()),
static_cast<plot::f64>(first_axes.right()), 1e-4);
}
// Every column of `area` that holds a *fully covered* cursor pixel, left to right.
// The predicate demands the cursor red at full coverage (0xFF2020), which is what
// makes the comparison across rows meaningful: a partially covered column along the
// stroke's edge blends with whatever the row has under it -- its trace at a different
// y -- so its colour is a property of that row's data, not of the geometry this case
// is about. The core of the stroke has no such dependence.
const auto cursor_columns = [&](const plot::Rect& area) {
std::vector<plot::u32> columns;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
if (px[0] >= 200 && px[1] <= 80 && px[2] <= 80) {
columns.push_back(x);
break;
}
}
}
return columns;
};
std::vector<plot::u32> first_columns;
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& row_layout =
static_cast<const plot::Plot&>(figure.plot(row)).layout();
const std::vector<plot::u32> columns = cursor_columns(row_layout.axes);
PLOT_CHECK(!columns.empty()); // the row's cursor really is in the frame
// One connected run: the stroke is one vertical line, not a doubled or smeared one.
PLOT_CHECK(columns.back() - columns.front() + 1u == columns.size());
if (row == 0) {
first_columns = columns;
} else {
// The same pixel column in this row as in the first -- the drawn consequence of
// the shared gutter, which a frame that sized each row's strip from its own
// labels cannot satisfy.
PLOT_CHECK(columns == first_columns);
}
}
// ... and that column is the one the shared transform predicts, so the equality is
// the geometry's and not three cursors that happened to land together. The tolerance
// is the stroke's own pixel: the transform gives the stroke's centre, which lands
// inside the band it inked.
const plot::u32 expected = static_cast<plot::u32>(
static_cast<const plot::Plot&>(figure.plot(0u)).x_transform(0u).to_screen_x(50.0));
PLOT_CHECK(first_columns.front() <= expected + 1u && expected <= first_columns.back() + 1u);
ctx.destroy();
}
/// A resize recomputes the layout: the rows are re-derived from the new viewport --
/// their rects, the shared strip, the axes and the transforms they pin -- rather than
/// rescaled from, or left at, the rects of the frame before. The second frame here is