style(grid): the minor grid is thinner, dashed and blended into the background

Subdivisions used to be drawn exactly like the labelled lines -- same width, same
opacity -- so a plot dense enough to need them read as two competing sets of ticks.
The minor class is now three things the style states explicitly:

- grid_minor_width 1.0 -> 0.75, a sub-pixel logical width. At 1x that is expressed as
  reduced coverage through the anti-aliased ramp; on a HiDPI display the line is
  literally narrower.
- grid_minor_dash_on/_off (new, 3/3): dashed by default, off == 0 draws it solid, the
  same convention as the crosshair's and a trace's dash. The dash is a fragment-shader
  discard, so no geometry is added -- the vertices are the line either way.
- grid_minor carries alpha (0x1C2126 at 50%), and the line shader applies u_color.a,
  so it blends into the background instead of sitting on it.

Together that is about a fifth of the ink a major line leaves along its length, which
is what gives the eye one grid to read instead of two. The major class is untouched:
it stays the structure.

Tests: a new pixel test (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major)
asserts a major column is inked top-to-bottom while a minor one alternates, and that
the minor's ink sits closer to the background than the major's. Three existing tests
counted the minor class as "most of eight rows" of its exact colour; two now take
their floor from the style (minor_line_ink: width x alpha x duty), and the width test
states the dash and alpha away because widths are its subject. 361 cases, 0 failed.
This commit is contained in:
Martino Ferrari committed 2026-10-03 14:31:57 +02:00
1 parent f3fb500068
commit 01e410bd74
5 files changed
+202 -13

No files matched your search

+149 -4
View File
@@ -1150,6 +1150,129 @@ PLOT_TEST(draws_cursors_measure_band_and_annotations) {
// No trace and no minor grid, so the only thing drawn inside the rect is the major
// grid and the one colour it is drawn in; the rect is inset so the axis lines on
// its own edges, which are a different colour, stay out of the scan.
/// The minor grid is a *subdivision*, and the style says so three ways: thinner, dashed and
/// blended into the background. Found by reading a frame whose minor grid read as a second set
/// of ticks -- at full opacity and the same width as the labelled grid there was no hierarchy
/// to see.
///
/// The frame holds no trace, so inside `axes` the only elements are the two grid classes and
/// the background. A row off the horizontal lines then classifies every column by inspection:
/// background, an opaque major line (`pixel_is`, the same exactness `grid_hairlines_land_on_whole_pixels`
/// leans on), or the minor's blend -- which at these defaults is the only non-background pixel
/// in the frame that is neither. Width is checked as *coverage* rather than as a pixel count,
/// because at 1x a sub-pixel logical width is exactly a dimmer stroke: the ink is one column
/// either way, so `grid_minor_width` can only show up in how brightly that column is lit. On a
/// HiDPI display the same width is literally narrower.
PLOT_TEST(the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) {
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_CHECK(r.style().grid_minor_visible);
PLOT_CHECK(r.style().grid_minor_dash_off > 0.0f); // the default is a dash, not a solid line
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::Rect& a = p.layout().axes;
const plot::Color bg = r.style().background;
const plot::Color major = r.style().grid_major;
// Distance from the background, per channel, so "dimmer" means "closer to the background"
// whatever the two colours are.
const auto bg_distance = [&](const plot::u8 px[4]) {
const auto d = [](plot::u8 v, plot::f32 want) {
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
};
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
};
// The row: the first one that holds all three kinds of pixel, so it is neither a horizontal
// grid line (everything major ink) nor a band the layout reserved for something else.
const plot::u32 first_row = static_cast<plot::u32>(a.y) + 3u;
const plot::u32 last_row = static_cast<plot::u32>(a.y + a.h) - 4u;
std::vector<plot::u32> major_cols;
std::vector<plot::u32> minor_cols;
plot::u32 scan_row = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
major_cols.clear();
minor_cols.clear();
plot::u32 background = 0;
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u;
cx + 3u < static_cast<plot::u32>(a.x + a.w); ++cx) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) {
++background;
} else if (pixel_is(px, major)) {
major_cols.push_back(cx);
} else {
minor_cols.push_back(cx);
}
}
if (background > 0u && !major_cols.empty() && !minor_cols.empty()) {
scan_row = row;
break;
}
}
PLOT_CHECK(scan_row != 0u); // a row carrying both grid classes and the background
PLOT_CHECK(major_cols.size() >= 2u);
PLOT_CHECK(minor_cols.size() >= 2u);
// Solid against dashed, down the two columns: a major line is ink at every row, a minor one
// alternates -- which is the whole of "dashed" a pixel test can see. The duty cycle is
// checked as a range rather than as the exact 3/3 the style asks for, because the dash is a
// fragment discard and its ends land where they land.
const auto column_ink = [&](plot::u32 cx, plot::u32& ink, plot::u32& gaps) {
ink = 0;
gaps = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) {
++gaps;
} else {
++ink;
}
}
};
plot::u32 ink = 0;
plot::u32 gaps = 0;
column_ink(major_cols.front(), ink, gaps);
PLOT_CHECK(gaps == 0u); // the labelled grid is continuous
const plot::u32 major_ink = ink;
column_ink(minor_cols.front(), ink, gaps);
PLOT_CHECK(ink > 0u && gaps > 0u); // dashed, not solid
PLOT_CHECK(ink * 5u > major_ink && gaps * 5u > major_ink); // roughly half on, half off
// Dimmer: the minor's ink sits closer to the background than the major's. This is also the
// "thinner" half of the contract at 1x -- see the note above the test.
const auto ink_distance = [&](plot::u32 cx) {
int best = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) continue;
best = std::max(best, bg_distance(px));
}
return best;
};
const int major_bright = ink_distance(major_cols.front());
const int minor_bright = ink_distance(minor_cols.front());
PLOT_CHECK(minor_bright > 0);
PLOT_CHECK(minor_bright < major_bright);
ctx.destroy();
}
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
@@ -3296,6 +3419,20 @@ plot::f32 window_ink(const GridFrame& f, const plot::Rect& window, plot::Color b
return ink;
}
/// The ink one minor line is expected to leave in a `kWindowRows`-tall window, **from the
/// style**: its width in device pixels, its colour's alpha (the line shader multiplies it in)
/// and the dash's duty cycle. Written out rather than as a literal, because the minor class'
/// defaults are deliberately a fraction of a solid opaque line -- a threshold saying "most of
/// eight rows" stopped describing the class the moment it gained a dash, while it is the style
/// that says how much ink a class is *meant* to leave.
plot::f32 minor_line_ink(const plot::render::Style& style) {
const plot::f32 duty =
style.grid_minor_dash_off > 0.0f
? style.grid_minor_dash_on / (style.grid_minor_dash_on + style.grid_minor_dash_off)
: 1.0f;
return kWindowRows * style.device(style.grid_minor_width) * style.grid_minor.a * duty;
}
} // namespace
/// Spec §11's per-class switch, on the minor class: a class the style switches off is
@@ -3325,10 +3462,12 @@ PLOT_TEST(disabling_the_minor_grid_removes_its_colour) {
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.
// A line, not one stray fringe pixel: an 8-row window over a stroke holds most of the ink
// that stroke is meant to leave. For the major class that is the window's own height; for the
// minor class it is what its width, alpha and dash ask for, so the threshold follows the
// style instead of a literal the class is no longer drawn to.
PLOT_CHECK(major_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
r.style().grid_minor_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
@@ -3375,7 +3514,7 @@ PLOT_TEST(disabling_the_major_grid_leaves_the_minor_grid) {
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);
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
r.style().grid_major_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
@@ -3464,6 +3603,12 @@ PLOT_TEST(grid_widths_are_per_class_and_scale_with_dpi) {
r.style().grid_major_width = 2.0f;
r.style().grid_minor_width = 1.0f;
// Widths are this test's subject, so the minor class' dash and alpha are stated away first:
// they would multiply every count and the ratio below, and their own defaults are the subject
// of `the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`.
r.style().grid_minor_dash_off = 0.0f;
r.style().grid_minor = plot::Color::hex(0x1C2126);
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);