diff --git a/README.md b/README.md index de2219c..1c55eaa 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ run touched a GPU, so read it as an upper bound and a record of the software pat ## Features - grid with independent major/minor line classes (`Style::grid_major_visible`, - `grid_minor_visible`); + `grid_minor_visible`), each with its own colour, width and (for the minors) dash — the + subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the + labelled grid rather than as a second set of ticks; - x and y axes, with a vertical (bottom-to-top) y-axis title; - cursors in both orientations — vertical markers and horizontal rulers; - draggable regions and measurement bands with exact interval statistics; diff --git a/docs/styling.md b/docs/styling.md index a26efcf..db201a6 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -37,7 +37,7 @@ Colours (neither logical nor device — nothing to scale): |---|---|---| | `background` | `0x101418` | frame clear colour and legend/readout panel base | | `grid_major` | `0x2A3038` | major (labelled-tick) grid lines | -| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines | +| `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines | | `axis_line` | `0x8A94A6` | axis lines | | `axis_text` | `0xC8D0DC` | tick labels and axis titles | | `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight | @@ -57,7 +57,9 @@ Logical metrics (scaled by `device()`): | `axis_font_px` | `12.0` | logical em the axis text is drawn at | | `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip | | `grid_major_width` | `1.0` | width of a major grid line | -| `grid_minor_width` | `1.0` | width of a minor grid line | +| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) | +| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels | +| `grid_minor_dash_off` | `3.0` | minor grid dash: gap; `0` draws the minor grid solid | | `grid_major_visible` | `true` | stage the major grid at all | | `grid_minor_visible` | `true` | stage the minor grid at all | | `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) | @@ -91,6 +93,17 @@ transparent. `Grid` therefore carries both classes' geometry whatever the style caller that draws the grid itself applies the same two switches. `Grid::vertical` and `Grid::horizontal` (both `true`) control which directions emit lines at all. +The labelled class is the **structure** and the subdivisions are **texture**, and the minor +grid's three defaults are what makes that read: it is thinner than the major hairline +(`grid_minor_width`, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI +display as a narrower line), its colour carries an **alpha** so it blends into the +background instead of sitting on it, and it is **dashed** (`grid_minor_dash_on`/`_off`) so an +unlabelled line at an unlabelled position is never mistaken for a labelled one. Together +those put a minor line at roughly a fifth of a major line's ink — at full opacity and the +same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader +discard, not extra geometry: the vertices are the line either way. Set +`grid_minor_dash_off = 0` for the old solid subdivision. + ## The crosshair's dash `crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 2321496..ba67c40 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -39,7 +39,13 @@ struct Style { Color background = Color::hex(0x101418); Color grid_major = Color::hex(0x2A3038); - Color grid_minor = Color::hex(0x1C2126); + /// The minor grid's colour, **with an alpha** so it blends into the background rather than + /// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid + /// reads as a second set of ticks competing with the labelled one. The line shader applies + /// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at + /// 0x2A3038 -- stays the structure the eye picks out. Together with `grid_minor_width` and + /// the dash below, the defaults put a minor line at roughly a fifth of a major line's ink. + Color grid_minor = Color::hex(0x1C2126, 0.5f); Color axis_line = Color::hex(0x8A94A6); Color axis_text = Color::hex(0xC8D0DC); Color cursor = Color::hex(0xFFB020); @@ -97,9 +103,23 @@ struct Style { /// 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. + /// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and + /// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp + /// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI + /// display as a line that is literally narrower. The point is hierarchy: the labelled grid is + /// structure, the subdivisions are texture. + f32 grid_minor_width = 0.75f; bool grid_major_visible = true; ///< stage the major grid at all. bool grid_minor_visible = true; ///< stage the minor grid at all. + /// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it + /// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a + /// caller that wants the old solid subdivision states. Dashed is the default because an + /// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and + /// because a broken line carries less weight over data than a continuous one. The dash is a + /// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for + /// being dashed. + f32 grid_minor_dash_on = 3.0f; + f32 grid_minor_dash_off = 3.0f; f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data ///< (axis lines, cursor markers). /// Logical half-width of the anti-aliased fringe the line shader blends at diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 83bed8a..6e1642e 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -798,7 +798,7 @@ void Renderer::draw_grid(const Plot& plot) { // 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) { + const auto draw_class = [&](bool major, bool visible, Color color, f32 width, Vec2 dash) { if (!visible) { return; } @@ -808,12 +808,21 @@ void Renderer::draw_grid(const Plot& plot) { } // Snapped: a grid line is chrome at a round *data* value, and a hairline that // straddles two columns reads as a grey smear and shimmers as the view pans. - flush_lines(map, color, width, Vec2{}, true); + flush_lines(map, color, width, dash, true); }; draw_class(true, style_.grid_major_visible, style_.grid_major, - style_.device(style_.grid_major_width)); + style_.device(style_.grid_major_width), Vec2{}); + // The minor grid's dash rides the same per-draw uniform the crosshair and a dashed trace use, + // and `off == 0` is the solid default. Each line starts its own pattern (the dash distance is + // per-segment), and every segment here is built from the range's minimum, so the dashes of the + // vertical lines line up with each other -- the grid reads as one texture rather than as a + // jumble of unrelated dashes. + const Vec2 minor_dash = style_.grid_minor_dash_off > 0.0f + ? Vec2{style_.device(style_.grid_minor_dash_on), + style_.device(style_.grid_minor_dash_off)} + : Vec2{}; draw_class(false, style_.grid_minor_visible, style_.grid_minor, - style_.device(style_.grid_minor_width)); + style_.device(style_.grid_minor_width), minor_dash); } // ------------------------------------------------------------- axis chrome diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 32bec94..610a38a 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(kW), static_cast(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(kW), static_cast(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(v) - static_cast(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(a.y) + 3u; + const plot::u32 last_row = static_cast(a.y + a.h) - 4u; + std::vector major_cols; + std::vector 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(a.x) + 3u; + cx + 3u < static_cast(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);