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

+11 -1
View File
@@ -12,16 +12,26 @@ namespace plot {
/// baking screen coordinates in here would break that uniform-only contract. /// baking screen coordinates in here would break that uniform-only contract.
struct GridLine { struct GridLine {
f64 x0 = 0.0, y0 = 0.0, x1 = 0.0, y1 = 0.0; 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; bool major = true;
}; };
/// Tick-aligned grid geometry: one segment per tick, spanning the perpendicular /// Tick-aligned grid geometry: one segment per tick, spanning the perpendicular
/// axis' full range. /// axis' full range.
class Grid { 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: public:
bool vertical = true; ///< emit lines at x ticks bool vertical = true; ///< emit lines at x ticks
bool horizontal = true; ///< emit lines at y 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 /// 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 /// perpendicular range (or non-finite) are skipped, so a stale tick list can
+3 -2
View File
@@ -148,8 +148,9 @@ class Renderer {
/// Every metric here is a logical unit (`Style`'s unit rule): the host writes /// 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 /// `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 /// `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 /// lines, cursor markers); the grid carries a width *per class*
/// own width. /// (`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_; } render::Style& style() noexcept { return style_; }
const render::Style& style() const noexcept { return style_; } const render::Style& style() const noexcept { return style_; }
+11
View File
@@ -55,6 +55,17 @@ struct Style {
f32 axis_font_px = 12.0f; ///< logical em the axis text is drawn at. 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, f32 grid_label_pad = 4.0f; ///< logical gap between a tick label and the axes,
///< and the inset inside a title strip. ///< 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 f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers). ///< (axis lines, cursor markers).
f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so
+6 -2
View File
@@ -26,16 +26,20 @@ void Grid::build(const std::vector<Tick>& x_ticks, Range x_range,
// Segments stay in data space: the renderer applies the transform, so a pan or // 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. // 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) { if (vertical) {
for (const Tick& tick : x_ticks) { for (const Tick& tick : x_ticks) {
if (!tick.major && !show_minor) continue;
if (!std::isfinite(tick.value) || !inside(tick.value, x_range)) 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}); lines_.push_back(GridLine{tick.value, y_range.min, tick.value, y_range.max, tick.major});
} }
} }
if (horizontal) { if (horizontal) {
for (const Tick& tick : y_ticks) { for (const Tick& tick : y_ticks) {
if (!tick.major && !show_minor) continue;
if (!std::isfinite(tick.value) || !inside(tick.value, y_range)) 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}); lines_.push_back(GridLine{x_range.min, tick.value, x_range.max, tick.value, tick.major});
} }
+20 -11
View File
@@ -543,21 +543,30 @@ void Renderer::draw_grid(const Plot& plot) {
// transforms even when a trace uses another pair. // transforms even when a trace uses another pair.
const bool log_x = plot.x_axis(0).log; const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_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()` // Two classes, one pass over the segment list each: the tick set decides *where*
// keeps its capacity, so the second group re-uses the first one's storage. // the lines are and this decides only how one is drawn -- its class' colour and
for (u32 pass = 0; pass < 2u; ++pass) { // its class' logical width, converted to device pixels here, the one conversion
const bool majors = pass == 0u; // 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()) { 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); 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 // ------------------------------------------------------------- axis chrome
+209
View File
@@ -56,6 +56,41 @@ bool has_horizontal(const plot::Grid& g, f64 y, bool major) {
return false; 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<plot::Tick>& ticks, f64 value) {
for (const plot::Tick& t : ticks) {
if (t.value == value) return true;
}
return false;
}
} // namespace } // namespace
PLOT_TEST(layout_without_text_backend_uses_default_label_areas) { 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 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<plot::Tick>& xt = p.x_axis(ax).ticks;
const std::vector<plot::Tick>& 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<unsigned>(xt.size()), count_lines(g, true, true),
count_lines(g, true, true) + count_lines(g, true, false),
static_cast<unsigned>(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<plot::Tick> xt;
std::vector<plot::Tick> 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<unsigned>(xt.size()), static_cast<unsigned>(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<unsigned>(x_only_secondary),
static_cast<unsigned>(y_only_secondary), g.size());
}
}
// ------------------------------------------------------------------- legend // ------------------------------------------------------------------- legend
namespace { namespace {
+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); PLOT_CHECK_NEAR(trace_ratio / axis_ratio, 1.0f, 0.15f);
ctx.destroy(); 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();
}