feat(render): independent major and minor grid classes tied to the tick set
This commit is contained in:
1 parent
69495a3799
commit
eaae07d8e0
7 files changed
+635
-16
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user