feat(render): independent major and minor grid classes tied to the tick set

This commit is contained in:
Martino Ferrari committed 2026-09-28 12:42:22 +02:00
1 parent 69495a3799
commit eaae07d8e0
7 files changed
+635 -16

No files matched your search

+375
View File
@@ -2787,3 +2787,378 @@ PLOT_TEST(trace_stroke_weight_is_logical) {
PLOT_CHECK_NEAR(trace_ratio / axis_ratio, 1.0f, 0.15f);
ctx.destroy();
}
// ------------------------------------------------------------- grid classes
namespace {
/// The scene the grid-class cases below draw: two axes and **no trace, cursor,
/// legend, measure or annotation**, so the only element that can put ink inside the
/// axes rect is a grid line. The x range is wide relative to the axes (a major about
/// every 100 px, its minors about every 28) so a window can be placed around one line
/// and touch nothing else; the y range's ticks are close enough together that a band
/// of rows fits between two of them.
struct GridFrame {
std::vector<plot::u8> pixels; ///< the whole framebuffer, top-down
plot::u32 fb_w = 0u;
plot::PlotLayout layout;
std::vector<plot::Tick> x_ticks;
std::vector<plot::Tick> y_ticks;
plot::Transform x_transform;
plot::Transform y_transform;
plot::u32 major_lines = 0u; ///< grid segments of each class, as built
plot::u32 minor_lines = 0u;
plot::u32 staged_segments = 0u; ///< everything the frame staged, grid included
};
/// Draws that scene at `scale` with the renderer's current style. `scale` 2 is a 2x
/// framebuffer with a 2x viewport -- the same logical plot on a 2x display -- so the
/// figures of the two scales are figures of two frames that were really drawn.
GridFrame draw_grid_scene(plottest::HeadlessContext& ctx, plot::Renderer& r, plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
const plot::Rect vp{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
plot::Plot p;
p.set_viewport(vp);
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, 1.0}, false, plot::AxisPlace::Left, false});
r.style().ui_scale = scale;
r.begin_frame(ctx.width, ctx.height, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
GridFrame f;
f.fb_w = ctx.width;
f.layout = p.layout();
f.x_ticks = p.x_axis(ax).ticks;
f.y_ticks = p.y_axis(ay).ticks;
f.x_transform = p.x_transform(ax);
f.y_transform = p.y_transform(ay);
for (const plot::GridLine& l : p.grid().lines()) {
(l.major ? f.major_lines : f.minor_lines) += 1u;
}
f.staged_segments = r.stats().staged_segments;
f.pixels = read_frame(ctx);
return f;
}
/// Half-width and height of a measurement window, in **device** pixels. Fixed counts
/// rather than figures derived from the tick spacing, so the same window measures the
/// same number of rows at 1x and 2x: a height derived from the ticks would double with
/// the scale and then report four times the ink for twice the stroke.
constexpr plot::f32 kWindowHalf = 3.0f;
constexpr plot::f32 kWindowRows = 8.0f;
/// The top row of the `kWindowRows`-tall band of the axes rect furthest from any
/// horizontal grid line: a slice where a window can only catch a *vertical* line's
/// ink. The margin is asserted, not assumed -- a scene whose horizontal lines are too
/// close together fails here instead of quietly measuring one.
plot::f32 clear_band_top(const GridFrame& f) {
std::vector<plot::f64> rows{f.layout.axes.y};
for (const plot::Tick& t : f.y_ticks) {
const plot::f64 y = f.y_transform.to_screen_y(t.value);
if (y > f.layout.axes.y && y < f.layout.axes.bottom()) rows.push_back(y);
}
rows.push_back(f.layout.axes.bottom());
std::sort(rows.begin(), rows.end());
plot::f64 best_gap = 0.0;
plot::f64 best_mid = 0.0;
for (std::size_t i = 0; i + 1u < rows.size(); ++i) {
if (rows[i + 1] - rows[i] > best_gap) {
best_gap = rows[i + 1] - rows[i];
best_mid = 0.5 * (rows[i] + rows[i + 1]);
}
}
PLOT_CHECK(best_gap > kWindowRows + 4.0);
return static_cast<plot::f32>(std::floor(best_mid - 0.5 * kWindowRows));
}
/// The x tick of `want_major`'s class nearest the middle of the axes rect, together
/// with the proof that its column band holds no other line: no tick of either class
/// is within `kWindowHalf + 2` px of the chosen one. Derived rather than assumed, so a
/// scene whose lines crowd each other fails here rather than measuring two strokes.
plot::f64 pick_tick(const GridFrame& f, bool want_major) {
const plot::Rect& axes = f.layout.axes;
const plot::f64 middle = 0.5 * (static_cast<plot::f64>(axes.x) + axes.right());
bool found = false;
plot::f64 best = 0.0;
plot::f64 best_distance = 0.0;
for (const plot::Tick& t : f.x_ticks) {
if (t.major != want_major) continue;
const plot::f64 x = f.x_transform.to_screen_x(t.value);
// Room at the edges: an axis line runs down them, and it is not grid ink.
if (x < axes.x + kWindowHalf + 2.0 || x > axes.right() - kWindowHalf - 2.0) continue;
const plot::f64 distance = std::fabs(x - middle);
if (!found || distance < best_distance) {
found = true;
best = t.value;
best_distance = distance;
}
}
PLOT_CHECK(found);
const plot::f64 chosen = f.x_transform.to_screen_x(best);
for (const plot::Tick& t : f.x_ticks) {
const plot::f64 dx = std::fabs(f.x_transform.to_screen_x(t.value) - chosen);
PLOT_CHECK(dx == 0.0 || dx > kWindowHalf + 2.0);
}
return best;
}
/// A window around one line of each class.
struct GridWindows {
plot::Rect major;
plot::Rect minor;
};
GridWindows grid_windows(const GridFrame& f) {
const plot::f32 top = clear_band_top(f);
const auto band = [&](plot::f64 value) {
const plot::f32 cx = static_cast<plot::f32>(f.x_transform.to_screen_x(value));
return plot::Rect{cx - kWindowHalf, top, 2.0f * kWindowHalf, kWindowRows};
};
return GridWindows{band(pick_tick(f, true)), band(pick_tick(f, false))};
}
/// How much of a pixel is `c` blended over `bg`: the largest channel ratio
/// `(px - bg) / (c - bg)`, clamped to [0, 1]. A stroke's anti-aliased fringe is partly
/// covered and its core fully covered; the *sum* over a window is the ink the stroke
/// laid down, which is proportional to its width. Counting pixels instead would
/// measure how the stroke landed on the pixel grid, and counting exact colours would
/// confuse the two classes: a major pixel at half coverage is the minor colour.
plot::f32 pixel_coverage(const plot::u8 px[4], plot::Color bg, plot::Color c) {
const plot::f32 value[3] = {px[0] / 255.0f, px[1] / 255.0f, px[2] / 255.0f};
const plot::f32 base[3] = {bg.r, bg.g, bg.b};
const plot::f32 ink[3] = {c.r, c.g, c.b};
plot::f32 best = 0.0f;
for (int i = 0; i < 3; ++i) {
const plot::f32 span = ink[i] - base[i];
if (span <= 0.0f) continue;
best = std::max(best, (value[i] - base[i]) / span);
}
return std::clamp(best, 0.0f, 1.0f);
}
/// The ink a window of the frame holds, read with one class' colour: the coverage sum
/// over its pixels. `kWindowRows` rows of one stroke is `width x kWindowRows` of ink,
/// so the figure is in device pixels of stroke.
plot::f32 window_ink(const GridFrame& f, const plot::Rect& window, plot::Color bg,
plot::Color c) {
plot::f32 ink = 0.0f;
for (plot::u32 y = static_cast<plot::u32>(window.y);
y < static_cast<plot::u32>(window.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(window.x);
x < static_cast<plot::u32>(window.right()); ++x) {
ink += pixel_coverage(&f.pixels[(plot::usize(y) * f.fb_w + x) * 4u], bg, c);
}
}
return ink;
}
} // namespace
/// Spec §11's per-class switch, on the minor class: a class the style switches off is
/// not drawn, and the *other* class is untouched. The second assertion is the one
/// that catches an implementation which hides both, or which rebuilds the grid
/// without the class it was told to hide.
///
/// The measurement is a window around one line of each class rather than a
/// frame-wide colour count: the two grid colours are close enough that a half-covered
/// pixel of the major colour reads as the minor one. Inside its window each class is
/// alone, so the figure is that line's own ink and nothing else's.
PLOT_TEST(disabling_the_minor_grid_removes_its_colour) {
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 plot::Color bg = r.style().background;
const plot::Color major_ink = r.style().grid_major;
const plot::Color minor_ink = r.style().grid_minor;
const GridFrame both = draw_grid_scene(ctx, r, 1.0f);
PLOT_CHECK(both.major_lines > 0u);
PLOT_CHECK(both.minor_lines > 0u);
const GridWindows win = grid_windows(both);
const plot::f32 major_before = window_ink(both, win.major, bg, major_ink);
const plot::f32 minor_before = window_ink(both, win.minor, bg, minor_ink);
// A line, not one stray fringe pixel: an 8-row window over a 1 px stroke holds about
// 8 px of ink per class, and this asks for most of it.
PLOT_CHECK(major_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * kWindowRows);
r.style().grid_minor_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
// The windows are the same windows: visibility does not move the tick set.
const GridWindows again = grid_windows(without);
PLOT_CHECK_NEAR(again.major.x, win.major.x, 1e-3);
PLOT_CHECK_NEAR(again.major.y, win.major.y, 1e-3);
PLOT_CHECK_NEAR(again.minor.x, win.minor.x, 1e-3);
const plot::f32 major_after = window_ink(without, win.major, bg, major_ink);
const plot::f32 minor_after = window_ink(without, win.minor, bg, minor_ink);
const plot::f32 other_after = window_ink(without, win.minor, bg, major_ink);
std::printf("[grid] minors off: major window %.3f -> %.3f, minor window %.3f -> %.3f"
" (major colour there %.3f)\n",
static_cast<double>(major_before), static_cast<double>(major_after),
static_cast<double>(minor_before), static_cast<double>(minor_after),
static_cast<double>(other_after));
// Nothing at all in the minor line's window: not the minor colour, and not the
// major one either -- so the line was skipped, not recoloured or drawn faint.
PLOT_CHECK_NEAR(minor_after, 0.0, 1e-4);
PLOT_CHECK_NEAR(other_after, 0.0, 1e-4);
// And the major class is exactly where it was: the same line, the same width, the
// same colour, neither moved nor thinned by the other class' absence.
PLOT_CHECK_NEAR(major_after, major_before, 1e-4);
ctx.destroy();
}
/// The symmetric case: with the major class off, the minor class is untouched and the
/// major line's own window is empty. Together with the case above this is "each class
/// has its own switch", not "the grid has one".
PLOT_TEST(disabling_the_major_grid_leaves_the_minor_grid) {
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 plot::Color bg = r.style().background;
const GridFrame both = draw_grid_scene(ctx, r, 1.0f);
const GridWindows win = grid_windows(both);
const plot::f32 major_before = window_ink(both, win.major, bg, r.style().grid_major);
const plot::f32 minor_before = window_ink(both, win.minor, bg, r.style().grid_minor);
PLOT_CHECK(major_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * kWindowRows);
r.style().grid_major_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
const plot::f32 major_after = window_ink(without, win.major, bg, r.style().grid_major);
const plot::f32 minor_after = window_ink(without, win.minor, bg, r.style().grid_minor);
const plot::f32 other_after = window_ink(without, win.major, bg, r.style().grid_minor);
std::printf("[grid] majors off: major window %.3f -> %.3f (minor colour there %.3f), minor"
" window %.3f -> %.3f\n",
static_cast<double>(major_before), static_cast<double>(major_after),
static_cast<double>(other_after), static_cast<double>(minor_before),
static_cast<double>(minor_after));
PLOT_CHECK_NEAR(major_after, 0.0, 1e-4);
PLOT_CHECK_NEAR(other_after, 0.0, 1e-4);
PLOT_CHECK_NEAR(minor_after, minor_before, 1e-4);
ctx.destroy();
}
/// "A disabled class costs nothing to draw" is a statement about *staging*, and pixels
/// cannot make it: a class drawn at zero alpha, or staged and then thrown away, leaves
/// the same frame. So this counts the segments the frame staged and requires the count
/// to fall by exactly that class' lines -- and, with both classes off, to be exactly
/// what the rest of the frame stages on its own (here: the two axis lines).
PLOT_TEST(a_disabled_grid_class_stages_nothing) {
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 GridFrame both = draw_grid_scene(ctx, r, 1.0f);
PLOT_CHECK(both.major_lines > 0u);
PLOT_CHECK(both.minor_lines > 0u);
r.style().grid_minor_visible = false;
const GridFrame no_minor = draw_grid_scene(ctx, r, 1.0f);
r.style().grid_minor_visible = true;
r.style().grid_major_visible = false;
const GridFrame no_major = draw_grid_scene(ctx, r, 1.0f);
r.style().grid_minor_visible = false;
const GridFrame neither = draw_grid_scene(ctx, r, 1.0f);
std::printf("[grid] staged segments: both %u (majors %u, minors %u), no minors %u, no majors %u,"
" neither %u\n",
both.staged_segments, both.major_lines, both.minor_lines, no_minor.staged_segments,
no_major.staged_segments, neither.staged_segments);
// Exactly the class' own lines disappear from staging -- one segment per tick -- and
// the rest of the frame is still staged, so this is a skip and not a frame that
// failed to draw.
PLOT_CHECK(no_minor.staged_segments == both.major_lines + 2u);
PLOT_CHECK(no_major.staged_segments == both.minor_lines + 2u);
PLOT_CHECK(neither.staged_segments == 2u);
PLOT_CHECK(both.staged_segments == both.major_lines + both.minor_lines + 2u);
// The pixels agree with the counters: with both classes off no grid line is in the
// axes rect at all, whatever it is read with.
const GridWindows win = grid_windows(both);
const plot::Color bg = r.style().background;
PLOT_CHECK_NEAR(window_ink(neither, win.major, bg, r.style().grid_major), 0.0, 1e-4);
PLOT_CHECK_NEAR(window_ink(neither, win.minor, bg, r.style().grid_minor), 0.0, 1e-4);
ctx.destroy();
}
/// Widths are per class and logical (spec §11, on §10's rule): a 2:1 logical ratio
/// between the classes draws a 2:1 stroke ratio, and the same logical widths at
/// `ui_scale` 2 lay down twice the *device* ink. One window per class, the same
/// kWindowRows device rows in every run, so the figures compare: a height taken from
/// the ticks would double with the scale and turn "twice the stroke" into four times
/// the measurement.
PLOT_TEST(grid_widths_are_per_class_and_scale_with_dpi) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Color bg = r.style().background;
// 2:1 in the fields, and the same fields across both scales below.
r.style().grid_major_width = 2.0f;
r.style().grid_minor_width = 1.0f;
const GridFrame one = draw_grid_scene(ctx, r, 1.0f);
const GridWindows win = grid_windows(one);
const plot::f32 major_1x = window_ink(one, win.major, bg, r.style().grid_major);
const plot::f32 minor_1x = window_ink(one, win.minor, bg, r.style().grid_minor);
const GridFrame two = draw_grid_scene(ctx, r, 2.0f);
const GridWindows big = grid_windows(two);
const plot::f32 major_2x = window_ink(two, big.major, bg, r.style().grid_major);
const plot::f32 minor_2x = window_ink(two, big.minor, bg, r.style().grid_minor);
const plot::f32 ratio_1x = major_1x / minor_1x;
std::printf("[grid] widths 2:1 logical: 1x major %.3f minor %.3f (ratio %.3f), 2x major %.3f"
" minor %.3f (ratio %.3f); device widths %.2f/%.2f at scale %.1f\n",
static_cast<double>(major_1x), static_cast<double>(minor_1x),
static_cast<double>(ratio_1x), static_cast<double>(major_2x),
static_cast<double>(minor_2x), static_cast<double>(major_2x / minor_2x),
static_cast<double>(r.style().device(r.style().grid_major_width)),
static_cast<double>(r.style().device(r.style().grid_minor_width)),
static_cast<double>(r.style().ui_scale));
// Same window height, one stroke each: the ink ratio is the width ratio.
PLOT_CHECK_NEAR(big.major.h, win.major.h, 1e-6);
PLOT_CHECK(major_1x > 1.5f * kWindowRows);
PLOT_CHECK(minor_1x > 0.75f * kWindowRows);
PLOT_CHECK_NEAR(ratio_1x, 2.0, 0.25);
PLOT_CHECK_NEAR(major_2x / minor_2x, 2.0, 0.25);
// Both classes double in device pixels at the 2x scale ...
PLOT_CHECK_NEAR(major_2x, 2.0 * major_1x, 0.25 * major_1x);
PLOT_CHECK_NEAR(minor_2x, 2.0 * minor_1x, 0.25 * minor_1x);
// ... while the logical widths that produced them did not move: the same two fields,
// with `device()` turning them into twice the pixels.
PLOT_CHECK_NEAR(r.style().grid_major_width, 2.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().grid_minor_width, 1.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().ui_scale, 2.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().device(r.style().grid_major_width), 4.0f, 1e-6f);
PLOT_CHECK_NEAR(r.style().device(r.style().grid_minor_width), 2.0f, 1e-6f);
ctx.destroy();
}