From eaae07d8e0cf4a9e67b75c188699f67509de180d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 12:42:22 +0200 Subject: [PATCH] feat(render): independent major and minor grid classes tied to the tick set --- include/plot/plot/grid.hpp | 12 +- include/plot/render/renderer.hpp | 5 +- include/plot/render/style.hpp | 11 + src/plot/grid.cpp | 8 +- src/render/renderer.cpp | 31 ++- tests/test_plot.cpp | 209 +++++++++++++++++ tests/test_render_smoke.cpp | 375 +++++++++++++++++++++++++++++++ 7 files changed, 635 insertions(+), 16 deletions(-) diff --git a/include/plot/plot/grid.hpp b/include/plot/plot/grid.hpp index 6b19bd0..454de0d 100644 --- a/include/plot/plot/grid.hpp +++ b/include/plot/plot/grid.hpp @@ -12,16 +12,26 @@ namespace plot { /// baking screen coordinates in here would break that uniform-only contract. struct GridLine { f64 x0 = 0.0, y0 = 0.0, x1 = 0.0, y1 = 0.0; + /// The class of the tick this segment came from: a major (labelled) tick, or one + /// of the minor subdivisions between them. The renderer picks the class' colour + /// and width from it, so the tick set is the one source for grid positions and + /// `Style` the one place that decides how a class looks and whether it is drawn. bool major = true; }; /// Tick-aligned grid geometry: one segment per tick, spanning the perpendicular /// axis' full range. class Grid { +/// +/// Every tick within the perpendicular range contributes a segment, whatever its +/// class, tagged by it. Whether a class is *drawn* is style rather than geometry: +/// the renderer checks `Style::grid_major_visible` / `grid_minor_visible` per class +/// before it stages a segment, which is the only switch for that behaviour (spec +/// §11). A `Grid` therefore carries both classes' geometry whatever the style says, +/// and a caller that draws it itself applies the same two switches. public: bool vertical = true; ///< emit lines at x ticks bool horizontal = true; ///< emit lines at y ticks - bool show_minor = true; ///< keep minor-tick lines /// Rebuilds the segments from each axis' ticks and range. Ticks outside the /// perpendicular range (or non-finite) are skipped, so a stale tick list can diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 48d6514..de14f0d 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -148,8 +148,9 @@ class Renderer { /// Every metric here is a logical unit (`Style`'s unit rule): the host writes /// `ui_scale` from the display it is on and this converts each metric with /// `device()`. `line_width_px` is the width of the strokes framing the data (axis - /// lines, cursor markers) -- grid lines stay hairlines and every trace carries its - /// own width. + /// lines, cursor markers); the grid carries a width *per class* + /// (`grid_major_width` / `grid_minor_width`) and every trace its own, and a grid + /// class the style switches off stages no geometry at all. render::Style& style() noexcept { return style_; } const render::Style& style() const noexcept { return style_; } diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 974a0e6..0ca2d32 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -55,6 +55,17 @@ struct Style { f32 axis_font_px = 12.0f; ///< logical em the axis text is drawn at. f32 grid_label_pad = 4.0f; ///< logical gap between a tick label and the axes, ///< and the inset inside a title strip. + /// The two grid classes, each with its own width and its own switch. The grid's + /// *positions* come from the tick set alone -- `Grid::build()` tags every segment + /// with the class of the tick it came from -- so these four decide only how a + /// class is drawn. A class switched off stages no geometry at all: the renderer + /// skips it before the first segment is staged, so a dense plot's minor grid + /// costs nothing rather than being drawn transparent (spec §11). The colours + /// above are already per class. + f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line. + f32 grid_minor_width = 1.0f; ///< logical width of a minor (subdivision) line. + bool grid_major_visible = true; ///< stage the major grid at all. + bool grid_minor_visible = true; ///< stage the minor grid at all. f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data ///< (axis lines, cursor markers). f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so diff --git a/src/plot/grid.cpp b/src/plot/grid.cpp index 2193125..9489f9b 100644 --- a/src/plot/grid.cpp +++ b/src/plot/grid.cpp @@ -26,16 +26,20 @@ void Grid::build(const std::vector& x_ticks, Range x_range, // Segments stay in data space: the renderer applies the transform, so a pan or // zoom needs no grid rebuild other than for a changed tick set. + // + // Both classes are built: the tick set is the only source of positions, and + // `Style::grid_*_visible` decides what the renderer stages from here, so a class + // is never filtered out of the tick set (that would be a second, geometry-side + // switch for the same behaviour, and the grid would then disagree with the tick + // list a caller can read). if (vertical) { for (const Tick& tick : x_ticks) { - if (!tick.major && !show_minor) continue; if (!std::isfinite(tick.value) || !inside(tick.value, x_range)) continue; lines_.push_back(GridLine{tick.value, y_range.min, tick.value, y_range.max, tick.major}); } } if (horizontal) { for (const Tick& tick : y_ticks) { - if (!tick.major && !show_minor) continue; if (!std::isfinite(tick.value) || !inside(tick.value, y_range)) continue; lines_.push_back(GridLine{x_range.min, tick.value, x_range.max, tick.value, tick.major}); } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 9c85d30..b7038f5 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -543,21 +543,30 @@ void Renderer::draw_grid(const Plot& plot) { // transforms even when a trace uses another pair. const bool log_x = plot.x_axis(0).log; const bool log_y = plot.y_axis(0).log; - // A grid line is a size a user perceives, so it is one logical pixel wide at - // every DPI rather than a hairline on a 1x panel and a slab on a 2x one. (Spec §11 - // gives the two classes a width each; until then both are this one logical pixel.) - const f32 grid_width = style_.device(1.0f); - // Two colour groups, one pass over the segments each: `LineBatch::clear()` - // keeps its capacity, so the second group re-uses the first one's storage. - for (u32 pass = 0; pass < 2u; ++pass) { - const bool majors = pass == 0u; + // Two classes, one pass over the segment list each: the tick set decides *where* + // the lines are and this decides only how one is drawn -- its class' colour and + // its class' logical width, converted to device pixels here, the one conversion + // point (spec §11: a grid line is a size a user perceives, the same hairline on a + // 1x panel and a 2x one). A class the style switched off stages nothing at all: + // the pass is skipped before the first segment, so a disabled class costs nothing + // to draw rather than being drawn transparent. Majors first, minors over them, as + // the single group order always did. `LineBatch::clear()` keeps its capacity, so + // the second pass re-uses the first one's storage. + const auto draw_class = [&](bool major, bool visible, Color color, f32 width) { + if (!visible) { + return; + } for (const GridLine& line : grid.lines()) { - if (line.major != majors) continue; // grid.build() already dropped non-finite ones + if (line.major != major) continue; // grid.build() already dropped non-finite ones stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y); } - flush_lines(map, majors ? style_.grid_major : style_.grid_minor, grid_width); - } + flush_lines(map, color, width); + }; + draw_class(true, style_.grid_major_visible, style_.grid_major, + style_.device(style_.grid_major_width)); + draw_class(false, style_.grid_minor_visible, style_.grid_minor, + style_.device(style_.grid_minor_width)); } // ------------------------------------------------------------- axis chrome diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index c0d4716..6edd172 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -56,6 +56,41 @@ bool has_horizontal(const plot::Grid& g, f64 y, bool major) { return false; } +/// The same question in *screen* space: the renderer places a line by mapping its +/// `x0`/`y0` through the axis' transform, so this is the coincidence the pixels show. +bool has_vertical_screen(const plot::Grid& g, const plot::Transform& tx, f64 sx, bool major) { + for (const plot::GridLine& l : g.lines()) { + if (l.x0 == l.x1 && l.major == major && tx.to_screen_x(l.x0) == sx) return true; + } + return false; +} + +bool has_horizontal_screen(const plot::Grid& g, const plot::Transform& ty, f64 sy, bool major) { + for (const plot::GridLine& l : g.lines()) { + if (l.y0 == l.y1 && l.major == major && ty.to_screen_y(l.y0) == sy) return true; + } + return false; +} + +/// How many of `g`'s lines carry one class: `vertical` picks the direction, `major` +/// the class. +u32 count_lines(const plot::Grid& g, bool vertical, bool major) { + u32 n = 0; + for (const plot::GridLine& l : g.lines()) { + if (((l.x0 == l.x1) == vertical) && l.major == major) ++n; + } + return n; +} + +/// Whether `ticks` holds `value` -- the "and every tick has a line" half of the +/// coincidence check, asked of the list the grid was built from. +bool tick_at(const std::vector& ticks, f64 value) { + for (const plot::Tick& t : ticks) { + if (t.value == value) return true; + } + return false; +} + } // namespace PLOT_TEST(layout_without_text_backend_uses_default_label_areas) { @@ -574,6 +609,180 @@ PLOT_TEST(grid_lines_are_tick_aligned_data_space_segments) { PLOT_CHECK(minor > 0); // minor grid is generated too } +/// The grid's positions have one source -- the tick set -- and this pins that, in +/// both directions and in both spaces: a line with no tick and a tick with no line +/// are each a defect, and one direction alone is satisfied by a grid that drops +/// ticks or by one that invents them. A labelled major tick and a major grid line +/// therefore coincide *by construction* (spec §11), not by two derivations that +/// happen to agree on the ranges this case tries. +/// +/// Screen space is checked beside data space because the renderer places a line by +/// mapping its endpoints through the axis' transform (`Renderer::draw_grid`), so +/// screen coincidence is what the pixels show. A log axis is among the ranges: that +/// transform is the one that is not the identity. +PLOT_TEST(major_grid_lines_coincide_with_major_ticks) { + /// Both directions, both spaces, one line per tick -- for one laid-out plot. + const auto check_pair = [](const plot::Plot& p, u32 ax, u32 ay, const char* what) { + const plot::Grid& g = p.grid(); + const plot::Transform tx = p.x_transform(ax); + const plot::Transform ty = p.y_transform(ay); + const std::vector& xt = p.x_axis(ax).ticks; + const std::vector& yt = p.y_axis(ay).ticks; + PLOT_CHECK(!xt.empty()); + PLOT_CHECK(!yt.empty()); + + // Exactly one line per tick of the tick set the grid was built from: none + // dropped, none invented, and both classes carried through. + PLOT_CHECK(count_lines(g, true, true) + count_lines(g, true, false) == xt.size()); + PLOT_CHECK(count_lines(g, false, true) + count_lines(g, false, false) == yt.size()); + PLOT_CHECK(count_lines(g, true, false) > 0u); // the range really has minors + PLOT_CHECK(count_lines(g, false, false) > 0u); + + // Every tick -> a line of its own class, and never of the other class. + for (const plot::Tick& t : xt) { + PLOT_CHECK(has_vertical(g, t.value, t.major)); + PLOT_CHECK(!has_vertical(g, t.value, !t.major)); + PLOT_CHECK(has_vertical_screen(g, tx, tx.to_screen_x(t.value), t.major)); + } + for (const plot::Tick& t : yt) { + PLOT_CHECK(has_horizontal(g, t.value, t.major)); + PLOT_CHECK(!has_horizontal(g, t.value, !t.major)); + PLOT_CHECK(has_horizontal_screen(g, ty, ty.to_screen_y(t.value), t.major)); + } + // Every line -> a tick of its own class. + for (const plot::GridLine& l : g.lines()) { + if (l.x0 == l.x1) { + PLOT_CHECK(tick_at(xt, l.x0)); + } else { + PLOT_CHECK(tick_at(yt, l.y0)); + } + } + std::printf("[grid] %-22s x %u ticks (%u major) -> %u vertical lines; y %u ticks (%u major)" + " -> %u horizontal, %s\n", + what, static_cast(xt.size()), count_lines(g, true, true), + count_lines(g, true, true) + count_lines(g, true, false), + static_cast(yt.size()), count_lines(g, false, true), + count_lines(g, false, true) + count_lines(g, false, false), + (tx.log_x || ty.log_y) ? "log" : "linear"); + }; + + // Several ranges and axes widths, so the check is not one nice-number step's + // accident. + struct Scene { + const char* what; + f64 lo, hi; + f32 w, h; + }; + const Scene scenes[] = { + {"[0, 10] 800x400", 0.0, 10.0, 800.0f, 400.0f}, + {"[0, 100] 800x400", 0.0, 100.0, 800.0f, 400.0f}, + {"[-5, 5] 640x360", -5.0, 5.0, 640.0f, 360.0f}, + {"[0, 3.3] 200x120", 0.0, 3.3, 200.0f, 120.0f}, // a narrow axes: few ticks + }; + for (const Scene& s : scenes) { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, s.w, s.h}); + const u32 ax = p.add_x_axis({"t", {s.lo, s.hi}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {s.lo, s.hi}, false, plot::AxisPlace::Left, false}); + (void)p.layout(); + check_pair(p, ax, ay, s.what); + } + + // A log pair: the same data-space coincidence, and the same once mapped through the + // log10 pre-affine -- where an equal data step is *not* an equal screen step, so a + // grid built on any second derivation would part company here first. + { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, true}); + const u32 ay = p.add_y_axis({"v", {1.0, 100.0}, false, plot::AxisPlace::Left, true}); + (void)p.layout(); + PLOT_CHECK(p.x_axis(ax).log); + PLOT_CHECK(p.y_axis(ay).log); + PLOT_CHECK(p.x_transform(ax).log_x); + PLOT_CHECK(p.y_transform(ay).log_y); + check_pair(p, ax, ay, "log [1, 1000]"); + } + + // A tick set with no minors in it -- `generate_ticks(..., include_minors = false)`, + // the labelled set a caller draws numbers for -- makes a grid with no minors: the + // grid owns no subdivision of its own. Both directions still hold, and the minor + // class is empty on *both* sides, which is asserted rather than skipped: an empty + // class on one side only is exactly the drift this case exists to catch. + { + std::vector xt; + std::vector yt; + plot::generate_ticks({0.0, 10.0}, 400.0f, 60.0f, xt, false); + plot::generate_ticks({0.0, 1.0}, 300.0f, 60.0f, yt, false); + PLOT_CHECK(!xt.empty()); + PLOT_CHECK(!yt.empty()); + for (const plot::Tick& t : xt) PLOT_CHECK(t.major); + for (const plot::Tick& t : yt) PLOT_CHECK(t.major); + + plot::Grid g; + g.build(xt, {0.0, 10.0}, yt, {0.0, 1.0}); + PLOT_CHECK(g.size() == xt.size() + yt.size()); + for (const plot::Tick& t : xt) { + PLOT_CHECK(has_vertical(g, t.value, true)); + PLOT_CHECK(!has_vertical(g, t.value, false)); + } + for (const plot::Tick& t : yt) { + PLOT_CHECK(has_horizontal(g, t.value, true)); + PLOT_CHECK(!has_horizontal(g, t.value, false)); + } + PLOT_CHECK(count_lines(g, true, false) == 0u); + PLOT_CHECK(count_lines(g, false, false) == 0u); + std::printf("[grid] majors-only tick set %u x ticks, %u y ticks -> %zu lines, 0 minors\n", + static_cast(xt.size()), static_cast(yt.size()), g.size()); + } + + // Two axis pairs. `Plot::build_grid()` builds the grid from the *primary* pair (x + // axis 0, y axis 0) and `Renderer::draw_grid()` maps it with that pair's transforms + // -- the comment there says so -- so a second pair's ticks get no lines at all. + // Pinned rather than assumed: the coincidence above is with the primary pair's + // ticks, and a value only the second axis ticks at has no line, in data space or on + // screen. (A trace on the second pair is still drawn in the data area; it simply + // has no grid of its own.) + { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax0 = p.add_x_axis({"a", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ax1 = p.add_x_axis({"b", {0.0, 1.0}, false, plot::AxisPlace::Top, false}); + const u32 ay0 = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + const u32 ay1 = p.add_y_axis({"w", {0.0, 1.0}, false, plot::AxisPlace::Right, false}); + (void)p.layout(); + check_pair(p, ax0, ay0, "primary pair only"); + + PLOT_CHECK(p.x_axis_count() == 2u); + PLOT_CHECK(p.y_axis_count() == 2u); + const plot::Grid& g = p.grid(); + const plot::Transform tx1 = p.x_transform(ax1); + const plot::Transform ty1 = p.y_transform(ay1); + u32 x_only_secondary = 0u; + u32 y_only_secondary = 0u; + for (const plot::Tick& t : p.x_axis(ax1).ticks) { + if (tick_at(p.x_axis(ax0).ticks, t.value)) continue; // 0 is on both ranges + ++x_only_secondary; + PLOT_CHECK(!has_vertical(g, t.value, t.major)); + PLOT_CHECK(!has_vertical_screen(g, tx1, tx1.to_screen_x(t.value), t.major)); + } + for (const plot::Tick& t : p.y_axis(ay1).ticks) { + if (tick_at(p.y_axis(ay0).ticks, t.value)) continue; + ++y_only_secondary; + PLOT_CHECK(!has_horizontal(g, t.value, t.major)); + PLOT_CHECK(!has_horizontal_screen(g, ty1, ty1.to_screen_y(t.value), t.major)); + } + // The case is not vacuous: the second pair really does tick where the first does + // not, so "no line there" is a statement about the build and not about the ranges. + PLOT_CHECK(x_only_secondary > 0u); + PLOT_CHECK(y_only_secondary > 0u); + std::printf("[grid] primary pair only %u secondary x ticks with no line, %u secondary y" + " ticks with no line; the grid is the primary pair's %zu lines\n", + static_cast(x_only_secondary), + static_cast(y_only_secondary), g.size()); + } +} + // ------------------------------------------------------------------- legend namespace { diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 7d7b8b8..08c7f4d 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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 pixels; ///< the whole framebuffer, top-down + plot::u32 fb_w = 0u; + plot::PlotLayout layout; + std::vector x_ticks; + std::vector 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(scale * static_cast(kW)); + const plot::u32 h = static_cast(scale * static_cast(kH)); + const plot::Rect vp{0.0f, 0.0f, static_cast(w), static_cast(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 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(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(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(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(window.y); + y < static_cast(window.bottom()); ++y) { + for (plot::u32 x = static_cast(window.x); + x < static_cast(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(major_before), static_cast(major_after), + static_cast(minor_before), static_cast(minor_after), + static_cast(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(major_before), static_cast(major_after), + static_cast(other_after), static_cast(minor_before), + static_cast(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(major_1x), static_cast(minor_1x), + static_cast(ratio_1x), static_cast(major_2x), + static_cast(minor_2x), static_cast(major_2x / minor_2x), + static_cast(r.style().device(r.style().grid_major_width)), + static_cast(r.style().device(r.style().grid_minor_width)), + static_cast(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(); +}