From daeb31b1ea3232265e8089d2868acb7df7aa6ae8 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 11:11:52 +0200 Subject: [PATCH] feat(plot): vertical y-axis label via a rotated text run --- include/plot/plot/plot.hpp | 20 ++- include/plot/render/renderer.hpp | 25 ++- src/plot/plot.cpp | 21 ++- src/render/renderer.cpp | 99 +++++++---- tests/test_plot.cpp | 81 +++++++-- tests/test_render_smoke.cpp | 287 ++++++++++++++++++++++++++++++- 6 files changed, 453 insertions(+), 80 deletions(-) diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 3789216..10f595b 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -27,7 +27,12 @@ struct PlotLayout { Rect axes; Rect y_label_area; Rect x_label_area; - /// One text line each, just outside the matching label area. Zero-sized (and + /// The y title's strip is a *column*: one text line wide, the axes' height + /// tall, just outside `y_label_area`. The y title is drawn rotated to read + /// bottom-to-top, so what it needs is a line's height rather than the run's own + /// width -- a horizontal run's width is what this strip had to reserve before, + /// and a long title then ate the plot. The x title stays horizontal and gets a + /// row of the same height below `x_label_area`. Both are zero-sized (and /// unoccupied) when no axis of that direction has a title. Rect y_title_area; Rect x_title_area; @@ -39,10 +44,10 @@ struct PlotLayout { /// is reserved. Every titled axis of a direction gets its own text line inside /// its strip -- which is what keeps a Left/Right (or Bottom/Top) pair from /// drawing two titles over each other -- so `x_title_height` is one line per - /// titled x axis; `y_title_width` is the widest y title plus the padding, since - /// y titles are horizontal runs that stack down the axes-tall strip and need - /// width, not another row of it. With no text backend there is no title to - /// measure and both stay 0.0f. + /// titled x axis and `y_title_width` is one line per titled y axis too: a + /// rotated title occupies length along the axes' height, so a second titled y + /// axis takes a *column* beside the first rather than another row of width. With + /// no text backend there is no title to lay out at all and both stay 0.0f. f32 y_title_width = 0.0f; f32 x_title_height = 0.0f; }; @@ -254,8 +259,9 @@ class Plot { /// Widest y tick label plus the label padding on both sides: the left strip the /// text backend asks for. Zero with no backend or no y axis. f32 y_label_width() const; - /// Widest y axis title plus the label padding on both sides: the vertical strip - /// a horizontal y title fits in. Zero with no backend or no titled y axis. + /// One text line of width per titled y axis, plus the label padding on both + /// sides: the column the rotated y title is drawn in, one column per titled + /// axis. Zero with no backend or no titled y axis. f32 y_title_strip_width() const; Rect viewport_{}; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index a9a3ace..f62e4b9 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -261,15 +261,22 @@ class Renderer { /// Lays one run of text out at a framebuffer position and stages it, shifting /// it into viewport space. Returns the pen past the run. Vec2 stage_text(Vec2 pos_px, const char* utf8); - /// Width of the longest prefix of `utf8` that fits `budget_px`, at the size the - /// text pipeline draws with: exactly what `stage_text_clipped()` stages. - f32 clipped_text_width(const char* utf8, f32 budget_px) const; - /// Stages as much of `utf8` as fits `budget_px` at `pos_px` and returns the - /// width it staged. Titles are the runs that need this: a title longer than the - /// strip the layout reserved for it is cut there, rather than being allowed to - /// grow the strip (which would shrink the plot) or to spill over the tick labels - /// and the data (which is the defect the strips exist to prevent). - f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px); + /// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at + /// the size the text pipeline draws with: exactly what `stage_text_clipped()` + /// stages. + f32 clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const; + /// Stages as much of `utf8` as fits `budget_px` along `axis` at `pos_px` and + /// returns the length it staged. Titles are the runs that need this: a title + /// longer than the strip the layout reserved for it is cut there, rather than + /// being allowed to grow the strip (which would shrink the plot) or to spill + /// over the tick labels and the data (which is the defect the strips exist to + /// prevent). + /// + /// `axis` is the run's own direction and a unit vector: (1, 0) for the + /// horizontal x title, which spends the strip's width, and (0,-1) for the y + /// title, which reads bottom-to-top and spends the strip's height. `pos_px` is + /// the pen -- for the rotated run, the bottom-left corner of its first cell. + f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis); void draw_grid(const Plot& plot); void draw_axis_lines(const Plot& plot); diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 4037060..2f950c1 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -336,9 +336,10 @@ const PlotLayout& Plot::layout() { // tick label can change that. Nothing at all is reserved without a text backend // -- there is then no title to measure, which is what keeps the two defaults // above meaningful for the no-text-backend case. The x strip is one text line - // per titled x axis tall (they are stacked in it); the y strip is as wide as the - // widest y title, since a y title is a horizontal run that stacks down the strip - // and so needs width rather than another row of it (see y_title_strip_width). + // per titled x axis tall (they are stacked in it); the y strip is one text line + // per titled y axis *wide*, because the y title is drawn rotated and so spends + // its length along the axes' height and takes a column, not a row, per titled + // axis (see y_title_strip_width). const u32 x_titles = titled_x_axis_count(); const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f; const f32 x_title_h = text_ != nullptr && x_titles != 0u @@ -437,13 +438,15 @@ u32 Plot::titled_y_axis_count() const noexcept { } f32 Plot::y_title_strip_width() const { + // One text line per titled y axis, not the widest title's own width: the title + // is drawn rotated to read bottom-to-top, so it spends the axes' height and + // each titled axis needs a column of its own -- which is what keeps a Left/Right + // pair of titles from being drawn over each other now that they no longer stack + // down a taller strip. if (text_ == nullptr) return 0.0f; - f32 widest = 0.0f; - for (const Axis& a : y_axes_) { - if (a.label.empty()) continue; - widest = std::max(widest, text_->measure(a.label.c_str())); - } - return widest > 0.0f ? widest + 2.0f * style_.grid_label_pad : 0.0f; + const u32 titles = titled_y_axis_count(); + if (titles == 0u) return 0.0f; + return static_cast(titles) * text_->height() + 2.0f * style_.grid_label_pad; } f32 Plot::y_label_width() const { diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index fb02902..53a439a 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -51,19 +51,35 @@ u64 clamp_index(f64 v, u64 n) noexcept { constexpr usize kNumberChars = 24; constexpr usize kReadoutChars = 160; -/// Walks the longest prefix of `utf8` that fits `budget_px`, calling `emit(pen, -/// glyph)` for every glyph it keeps -- `pen` in viewport pixels, `glyph` the -/// backend's own entry for that codepoint -- and returning the width that prefix -/// occupies. Both the measuring and the staging of a clipped run walk this, so the -/// cut lands on the same pixel either way; the codepoints, the advances and the -/// scale are the ones `TextBatch::add_text()` uses, so a clipped run still ends -/// where the plot layer measured its title to end. +/// The run bases the axis titles are drawn with. A title reads left to right in +/// the x strip (`kTextRight`), and bottom-to-top in the y strip (`kTextUp`: a +/// quarter turn anticlockwise on screen, where y grows downward). Both are unit +/// vectors, which is what `walk_text_clipped()` spends its budget along and what +/// `TextBatch` builds the glyph cell from (see `TextInstance`). +constexpr Vec2 kTextRight{1.0f, 0.0f}; +constexpr Vec2 kTextUp{0.0f, -1.0f}; + +/// Walks the longest prefix of `utf8` that fits `budget_px` along `axis`, calling +/// `emit(pen, glyph)` for every glyph it keeps -- `pen` in viewport pixels, +/// `glyph` the backend's own entry for that codepoint -- and returning the length +/// of that prefix in pixels. Both the measuring and the staging of a clipped run +/// walk this, so the cut lands on the same pixel either way; the codepoints, the +/// advances and the scale are the ones `TextBatch::add_text()` uses, so a clipped +/// run still ends where the plot layer measured its title to end. /// /// A codepoint the backend has no glyph for is skipped, exactly as `add_text` /// skips it, so what is measured here is what is drawn. +/// +/// `axis` is the run's own direction in viewport pixels -- a unit vector, the same +/// basis the cells are staged with (see `TextInstance`): the pen advances along it +/// and the budget is spent along it too, so a run is clipped to the strip's extent +/// *in its own direction* rather than to the strip's width. A horizontal x title +/// asks for (1, 0) and spends the strip's width; the y title asks for (0,-1) and +/// spends the strip's height, which is the only reason a rotated run can be cut at +/// the strip's ends at all. template f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::TextBackend& font, - f32 scale, Emit emit) { + f32 scale, Vec2 axis, Emit emit) { if (utf8 == nullptr || !(budget_px > 0.0f)) { return 0.0f; // no room at all: a strip clipped away stages nothing } @@ -74,7 +90,7 @@ f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::T const f32 advance = glyph->advance * scale; if (used + advance > budget_px) break; // the next glyph does not fit: cut here emit(pen, *glyph); - pen.x += advance; + pen = pen + axis * advance; used += advance; } return used; @@ -468,16 +484,16 @@ Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) { return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), default_font_); } -f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const { - return walk_text_clipped(Vec2{}, utf8, budget_px, default_font_, font_backend_.scale(), +f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const { + return walk_text_clipped(Vec2{}, utf8, budget_px, default_font_, font_backend_.scale(), axis, [](Vec2, const render::Glyph&) {}); } -f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px) { +f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) { const f32 scale = font_backend_.scale(); - return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale, - [this, scale](Vec2 pen, const render::Glyph& glyph) { - glyphs_.add_glyph(pen, glyph, scale); + return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale, axis, + [this, scale, axis](Vec2 pen, const render::Glyph& glyph) { + glyphs_.add_glyph(pen, glyph, scale, axis); }); } @@ -563,46 +579,55 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) { // Every title goes in the strip its direction reserved for it, and every titled - // axis gets its own text line inside that strip: one row per axis is what keeps - // a Left/Right (or Bottom/Top) pair of titles from being drawn over each other, - // where the pass before this one put them all on one centred run. Each run is - // also cut to its strip, so a title longer than the strip -- which the layout - // caps, because a title may shrink the plot but never erase it -- stops at the - // strip's padding instead of spilling over the labels or the data. + // axis gets a text line of its own inside that strip -- a row for an x axis, a + // column for a y one -- which is what keeps a Left/Right (or Bottom/Top) pair of + // titles from being drawn over each other, where the pass before that put them + // all on one centred run. Each run is also cut to its strip, so a title longer + // than the strip -- which the layout caps, because a title may shrink the plot + // but never erase it -- stops at the strip's padding instead of spilling over the + // labels or the data. const f32 pad = style_.grid_label_pad; const f32 line_h = font_backend_.height(); - // X titles: the strip's rows top down, one per titled axis. + // X titles: the strip's rows top down, one per titled axis, each read left to + // right on the row it owns. for (u32 i = 0, row = 0; i < plot.x_axis_count(); ++i) { const Axis& axis = plot.x_axis(i); if (axis.label.empty()) continue; const Rect& strip = layout.x_title_area; const f32 budget = strip.w - 2.0f * pad; - const f32 width = clipped_text_width(axis.label.c_str(), budget); + const f32 width = clipped_text_width(axis.label.c_str(), budget, kTextRight); // Centred on the strip: the tick labels have their own rows above it, so the // title needs no alignment of its own. stage_text_clipped({strip.x + 0.5f * (strip.w - width), strip.y + pad + static_cast(row) * line_h}, - axis.label.c_str(), budget); + axis.label.c_str(), budget, kTextRight); ++row; } - // Y titles: the same, stacked down the axes-tall strip and centred on it as a - // group, so the single titled axis an ordinary plot has keeps the row it always - // had. A y title reads horizontally like the x ones, so its width is what the - // strip had to reserve; the extra lines cost height, which the axes already - // provides. - const f32 stack = static_cast(plot.titled_y_axis_count()) * line_h; - for (u32 i = 0, row = 0; i < plot.y_axis_count(); ++i) { + // Y titles: the same strips, cut the same way, but each run is rotated a quarter + // turn so it reads bottom-to-top -- axis (0,-1), the direction the run advances + // in -- which is what lets the strip be one line *wide* instead of as wide as the + // title: a rotation, not a reflection, so the text is not mirrored (the shader + // builds the cell's other axis as the basis' perpendicular). + // + // Each titled axis gets its own column, the mirror of the x rows above, so a + // Left/Right pair of titled y axes is drawn side by side rather than over each + // other. The pen starts at the column's bottom-left: with this basis the first + // cell occupies the pen's own corner and grows up and to the right, so the run + // rises out of the strip's bottom edge and is centred along the height -- and cut + // to it, which is the budget this direction needs. A title longer than the axes + // is therefore clipped at the strip's ends exactly like an over-long x title. + for (u32 i = 0, column = 0; i < plot.y_axis_count(); ++i) { const Axis& axis = plot.y_axis(i); if (axis.label.empty()) continue; const Rect& strip = layout.y_title_area; - const f32 budget = strip.w - 2.0f * pad; - const f32 width = clipped_text_width(axis.label.c_str(), budget); - const f32 top = strip.y + std::max(0.0f, 0.5f * (strip.h - stack)) + - static_cast(row) * line_h; - stage_text_clipped({strip.x + 0.5f * (strip.w - width), top}, axis.label.c_str(), budget); - ++row; + const f32 budget = strip.h - 2.0f * pad; + const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp); + stage_text_clipped({strip.x + pad + static_cast(column) * line_h, + strip.bottom() - 0.5f * (strip.h - length)}, + axis.label.c_str(), budget, kTextUp); + ++column; } } diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index e37b031..e604d37 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -82,10 +82,12 @@ PLOT_TEST(layout_derives_label_areas_from_font_metrics) { const f32 pad = p.style().grid_label_pad; PLOT_CHECK_NEAR(l.y_axis_width, widest_major_label(p.y_axis(ay), font) + 2.0f * pad, 1e-4); PLOT_CHECK_NEAR(l.x_axis_height, font.height() + 2.0f * pad, 1e-4); - // The y axis carries the title "volts": it is drawn horizontally, so the strip - // left of the label strip is as wide as the measured title. There is no x axis, - // so the x direction reserves nothing beyond its label strip. - PLOT_CHECK_NEAR(l.y_title_width, font.measure("volts") + 2.0f * pad, 1e-4); + // The y axis carries the title "volts", drawn rotated to read bottom-to-top, so + // the strip left of the label strip is one text line *wide* -- the column the + // run advances up -- and not as wide as the title is when horizontal. There is + // no x axis, so the x direction reserves nothing beyond its label strip. + PLOT_CHECK_NEAR(l.y_title_width, font.height() + 2.0f * pad, 1e-4); + PLOT_CHECK(l.y_title_width < font.measure("volts") + 2.0f * pad); PLOT_CHECK_NEAR(l.x_title_height, 0.0f, 1e-6); PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4); PLOT_CHECK_NEAR(l.axes.w, 800.0f - (l.y_title_width + l.y_axis_width), 1e-4); @@ -100,8 +102,9 @@ PLOT_TEST(layout_derives_label_areas_from_font_metrics) { PLOT_CHECK_NEAR(l.x_label_area.y, l.axes.bottom(), 1e-4); } -/// Every titled axis of a direction gets its own text line inside its strip; -/// without that, the second titled axis of an edge is drawn on top of the first. +/// Every titled axis of a direction gets its own text line inside its strip; without +/// that, the second titled axis of an edge is drawn on top of the first. An x line is +/// a row of the strip, a y one a column of it, because the y title is drawn rotated. PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) { plot::render::EmbeddedMonoFont font; plot::Plot p; @@ -117,10 +120,13 @@ PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) { PLOT_CHECK(p.titled_y_axis_count() == 2u); // The x titles stack in rows, so the strip grows by a line per titled axis... PLOT_CHECK_NEAR(l.x_title_height, 2.0f * font.height() + 2.0f * pad, 1e-4); - // ... while the y titles stack down the axes-tall strip, so the second one costs - // height the axes already has rather than a second column of width. - PLOT_CHECK_NEAR(l.y_title_width, - std::max(font.measure("volts"), font.measure("amps")) + 2.0f * pad, 1e-4); + // ... and the y titles, rotated, take a *column* each: the second titled y axis + // costs another text line of width rather than a row of the strip's height, and + // the pair still cannot be drawn over each other. The widest title's own width + // plays no part in it. + PLOT_CHECK_NEAR(l.y_title_width, 2.0f * font.height() + 2.0f * pad, 1e-4); + PLOT_CHECK(l.y_title_width < + std::max(font.measure("volts"), font.measure("amps")) + 2.0f * pad); PLOT_CHECK_NEAR(l.axes.h, 400.0f - (l.x_axis_height + l.x_title_height), 1e-4); } @@ -143,6 +149,61 @@ PLOT_TEST(layout_reserves_no_title_strip_without_a_title) { PLOT_CHECK_NEAR(l.axes.h, 400.0f - l.x_axis_height, 1e-4); } +/// The y title is rotated, so its strip is a column: one text line wide where a +/// horizontal run needed the title's own width, and as tall as the axes, which is +/// where the run spends its length. The title here is wide enough to make the two +/// models plainly different -- it measures more than ten of the lines it now costs. +PLOT_TEST(y_title_strip_is_narrow_when_a_y_title_is_set) { + plot::render::EmbeddedMonoFont font; + plot::Plot p; + p.set_text_backend(&font); + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"millivolts per division", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + const plot::PlotLayout& l = p.layout(); + const plot::f32 pad = p.style().grid_label_pad; + + // One text line wide -- a column, not the run's own width... + PLOT_CHECK_NEAR(l.y_title_area.w, l.y_title_width, 1e-6); + PLOT_CHECK_NEAR(l.y_title_width, font.height() + 2.0f * pad, 1e-4); + PLOT_CHECK(l.y_title_width < font.measure("millivolts per division") + 2.0f * pad); + // ... spanning the axes' whole vertical extent, so the run has the room to + // advance in and a title longer than the axes is cut where the axes ends. + PLOT_CHECK_NEAR(l.y_title_area.x, 0.0f, 1e-6); + PLOT_CHECK_NEAR(l.y_title_area.y, l.axes.y, 1e-6); + PLOT_CHECK_NEAR(l.y_title_area.h, l.axes.h, 1e-4); + PLOT_CHECK(l.y_title_area.h > l.y_title_area.w); + // The strips tile the viewport outside `axes`: title, then labels, then data. + PLOT_CHECK_NEAR(l.y_title_area.right(), l.y_label_area.x, 1e-4); + PLOT_CHECK_NEAR(l.y_label_area.right(), l.axes.x, 1e-4); + PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4); + PLOT_CHECK_NEAR(l.axes.w, 800.0f - (l.y_title_width + l.y_axis_width), 1e-4); +} + +/// A Left and a Right titled y axis both draw in the one strip the layout reserves, +/// so the strip has to hold two runs. They no longer stack down its height -- the +/// runs are vertical, they would overlap from top to bottom -- so each gets a +/// column, and the strip is two text lines wide. What the model must not do is go +/// back to being as wide as the widest title: the columns are lines, not titles. +PLOT_TEST(two_titled_y_axes_reserve_a_column_each) { + plot::render::EmbeddedMonoFont font; + plot::Plot p; + p.set_text_backend(&font); + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false}); + const plot::PlotLayout& l = p.layout(); + const plot::f32 pad = p.style().grid_label_pad; + + PLOT_CHECK(p.titled_y_axis_count() == 2u); + PLOT_CHECK_NEAR(l.y_title_width, 2.0f * font.height() + 2.0f * pad, 1e-4); + // Two columns fit side by side inside the strip, and the axes is inset by it once. + PLOT_CHECK(l.y_title_width >= 2.0f * font.height()); + PLOT_CHECK_NEAR(l.y_title_area.w, l.y_title_width, 1e-6); + PLOT_CHECK_NEAR(l.y_title_area.right(), l.y_label_area.x, 1e-4); + PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4); +} + PLOT_TEST(time_axis_tick_labels_use_format_time) { plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 66b0a68..c1a20c3 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -336,6 +336,81 @@ TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1 return ink; } +/// The ink `text`-coloured pixels form inside `area`: its bounding box in Rect +/// pixels (top-down, like every Rect here) and how many pixels it covers. Asking a +/// sub-rect is how a case reads one strip without the labels beside it -- but a +/// scan can only see ink *inside* the rect it is given, so "nothing was drawn +/// outside" is a scan of the region the overflow would have landed in. +struct RectInk { + InkBox box; + plot::u32 pixels = 0u; + bool any() const noexcept { return box.any(); } +}; + +RectInk scan_rect_ink(plottest::HeadlessContext& ctx, const plot::Rect& area, plot::Color text) { + RectInk ink; + const plot::u32 x1 = static_cast(area.right()); + const plot::u32 y1 = static_cast(area.bottom()); + for (plot::u32 y = static_cast(area.y); y < y1; ++y) { + for (plot::u32 x = static_cast(area.x); x < x1; ++x) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px)); + if (!text_ink(px, text)) continue; + // The extremes, not the first and last match: a row-major scan sees a + // rotated run's widest row wherever it happens to be, so the first match's + // column is the box's left edge only for a run that is monotone in x -- which + // an unrotated line of text is, and a quarter-turned one is not. The first + // match also has to seed all four, since the empty box's sentinel is not a + // coordinate. + if (!ink.box.any()) { + ink.box.x0 = ink.box.x1 = x; + ink.box.y0 = ink.box.y1 = y; + } else { + ink.box.x0 = std::min(ink.box.x0, x); + ink.box.x1 = std::max(ink.box.x1, x); + ink.box.y0 = std::min(ink.box.y0, y); + ink.box.y1 = std::max(ink.box.y1, y); + } + ++ink.pixels; + } + } + return ink; +} + +/// One band of inked columns: `[first, last]`, both inclusive, in framebuffer x. +struct InkBand { + plot::u32 first = kNoColumn; + plot::u32 last = 0; + plot::u32 width() const noexcept { return first == kNoColumn ? 0u : last - first + 1u; } +}; + +/// The bands of inked columns `text`-coloured ink forms in `area`, left to right: +/// one band per drawn run that a gap of blank columns separates from its +/// neighbours. Two runs drawn over each other make one band; two separated by +/// background make two, so this is how a case asks how many runs a strip holds and +/// where each of them is. +std::vector ink_column_bands(plottest::HeadlessContext& ctx, const plot::Rect& area, + plot::Color text) { + std::vector bands; + for (plot::u32 x = static_cast(area.x); + x < static_cast(area.right()); ++x) { + bool has = false; + for (plot::u32 y = static_cast(area.y); + y < static_cast(area.bottom()) && !has; ++y) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px)); + has = text_ink(px, text); + } + if (!has) continue; + if (bands.empty() || bands.back().last + 1u != x) { + bands.push_back(InkBand{x, x}); + } else { + bands.back().last = x; + } + } + return bands; +} + /// Holds one GL entry point at a stub for as long as it lives. The entry points /// are the library's own function pointers (`plot/gl/api.hpp`), so a driver /// refusal -- a map or a fence that fails while the caps still advertise support @@ -1056,12 +1131,14 @@ PLOT_TEST(short_panel_with_an_x_title_still_draws) { ctx.destroy(); } -// Fix pass 2, finding 2: every titled y axis was staged at the same centred -// position inside the one strip, so a Left "volts" and a Right "amps" drew over -// each other -- the overprint the pre-strip placement did not have. Each titled -// axis now owns a text line, so a titled pair of either direction shows two -// separated runs of title-coloured pixels where it used to show one. -PLOT_TEST(titled_axis_pairs_get_their_own_rows) { +// Fix pass 2, finding 2: every titled axis of a direction was staged at the same +// centred position inside the one strip, so a Bottom/Top (or Left/Right) pair drew +// over each other -- the overprint the pre-strip placement did not have. Each +// titled axis now owns a text line, so a pair shows two separated runs of +// title-coloured pixels where it used to show one: two *rows* for an x pair, which +// is what this case reads. A titled y pair's two runs are columns now (the title +// is rotated), so they are read by `two_titled_y_axes_get_two_columns` below. +PLOT_TEST(two_titled_x_axes_get_their_own_rows) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); @@ -1086,7 +1163,7 @@ PLOT_TEST(titled_axis_pairs_get_their_own_rows) { const plot::PlotLayout& layout = p.layout(); const plot::Color title = r.style().axis_text; - // The rows of one strip that hold title-coloured pixels, as the runs of rows + // The rows of the x strip that hold title-coloured pixels, as the runs of rows // they form: one run per titled axis, because the two runs of a pair are a line // apart and each covers several pixel rows of glyph ink. auto row_bands = [&](const plot::Rect& strip) { @@ -1112,7 +1189,201 @@ PLOT_TEST(titled_axis_pairs_get_their_own_rows) { PLOT_CHECK(layout.x_title_area.h > 0.0f); PLOT_CHECK(layout.y_title_area.w > 0.0f); PLOT_CHECK(row_bands(layout.x_title_area) == 2u); // "seconds" above "samples" - PLOT_CHECK(row_bands(layout.y_title_area) == 2u); // "volts" above "amps" + ctx.destroy(); +} + +/// Whether any pixel of `area` is exactly `c` -- the assertion `pixel_is` can make +/// and `text_ink` cannot: a third of the title colour is below the axis stroke's +/// grey, so an ink scan of the axes rect would find the axis itself. For a chalk +/// element whose *absence* from a rectangle is the property, the exact colour is +/// the predicate that means something. +bool rect_has_exact(plottest::HeadlessContext& ctx, const plot::Rect& area, plot::Color c) { + for (plot::u32 y = static_cast(area.y); + y < static_cast(area.bottom()); ++y) { + for (plot::u32 x = static_cast(area.x); + x < static_cast(area.right()); ++x) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px)); + if (pixel_is(px, c)) return true; + } + } + return false; +} + +// The change itself. The y title is a rotated run now, so its strip is a column one +// text line wide and as tall as the axes, instead of a strip as wide as the title +// with a horizontal run drawn in it. The layout half of that is asserted in +// test_plot.cpp; this is the pixel half -- the ink really is rotated (taller than +// wide for a title fifteen times wider than tall when horizontal), it stays inside +// the column, and it never reaches the data area. +PLOT_TEST(y_title_ink_is_rotated_and_outside_the_axes) { + 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::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + const char* const title = "millivolts per division"; + p.add_y_axis({title, {-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()); + dump_if_requested(r, "vertical_y_title.ppm"); + + const plot::PlotLayout& layout = p.layout(); + const plot::Color ink = r.style().axis_text; + const plot::f32 pad = r.style().grid_label_pad; + + // The strip is a column: one text line wide (the renderer handed the plot its own + // scaled font, so the two agree exactly) and as tall as the axes. + PLOT_CHECK_NEAR(layout.y_title_area.w, r.axis_font_px() + 2.0f * pad, 1e-3); + PLOT_CHECK(layout.y_title_area.h > layout.y_title_area.w); + + const RectInk drawn = scan_rect_ink(ctx, layout.y_title_area, ink); + PLOT_CHECK(drawn.any()); + // Real glyph ink, not one stray anti-aliased pixel: the run covers hundreds. + PLOT_CHECK(drawn.pixels >= 32u); + // Rotated, not merely moved: the run is taller than it is wide. The same string + // drawn on the unrotated basis is ~24 glyphs wide -- seven times the strip it is + // in -- so this is the assertion that fails if the basis is left at (1,0). + PLOT_CHECK(drawn.box.height() > drawn.box.width()); + PLOT_CHECK(drawn.box.width() <= static_cast(layout.y_title_area.w)); + // The sign of the basis too, which is the half a rotation and a reflection share: + // the cell is built from the basis and its perpendicular, and with (0,-1) that + // puts the pen -- the run's start -- at the cell's bottom-left and the cell + // growing up and to the *right* of it. A run that advanced the other way round + // the screen would have its cells to the left of the pen, i.e. left of the + // strip's padding, and the ink would start at the strip's edge or off it. + PLOT_CHECK(drawn.box.x0 >= static_cast(layout.y_title_area.x + pad)); + + // ... and none of it is inside the axes: the title is drawn in the strip the + // layout reserved, which lies outside the data area. + PLOT_CHECK(layout.y_title_area.right() <= layout.y_label_area.x + 1e-4f); + PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink)); + ctx.destroy(); +} + +// Two titled y axes both draw in the one strip the layout reserves for them, and +// now that the runs are vertical they cannot stack down its height -- they would +// overlap from top to bottom. Each takes a column instead, so a Left/Right pair +// shows two runs side by side in distinct x ranges: one band of inked columns per +// axis with background between them, where the horizontal-run model drew the pair +// as two rows a line apart in a strip as wide as the widest title. +PLOT_TEST(two_titled_y_axes_get_two_columns) { + 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::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, 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()); + dump_if_requested(r, "y_title_columns.ppm"); + + const plot::PlotLayout& layout = p.layout(); + const plot::Color ink = r.style().axis_text; + const plot::f32 pad = r.style().grid_label_pad; + + // Room for one column per titled axis, and no more: the strip is two text lines + // wide, not two titles' worth of width. + PLOT_CHECK(p.titled_y_axis_count() == 2u); + PLOT_CHECK_NEAR(layout.y_title_area.w, 2.0f * r.axis_font_px() + 2.0f * pad, 1e-3); + + const std::vector columns = ink_column_bands(ctx, layout.y_title_area, ink); + PLOT_CHECK(columns.size() == 2u); + // Distinct x ranges, in the order the axes were added: the Left axis' column is + // left of the Right axis'. + PLOT_CHECK(columns[1].first > columns[0].last); + // Each band is one rotated run -- "volts" in one column, "amps" in the next -- + // so each is taller than it is wide on its own. + for (const InkBand& column : columns) { + const RectInk run = scan_rect_ink( + ctx, plot::Rect{static_cast(column.first), layout.y_title_area.y, + static_cast(column.width()), layout.y_title_area.h}, + ink); + PLOT_CHECK(run.any()); + PLOT_CHECK(run.box.height() > run.box.width()); + } + ctx.destroy(); +} + +// A y title longer than its strip is cut where the strip ends, exactly as an +// over-long x title is cut where its row ends. That needs the strip's *height* to +// be the run's budget, which is what threading the axis through the clip buys: a +// budget still taken from the strip's width stops the run after two glyphs, and no +// budget at all lets it leave the strip. +// +// The plot's viewport is inset from the framebuffer so the overspill would land on +// pixels this frame can read: an unclipped run leaves the strip and keeps going +// above the plot, where the framebuffer still has room -- "drawn past the strip" is +// otherwise indistinguishable from "clipped at the strip's end and off the target". +PLOT_TEST(over_long_y_title_is_clipped_to_its_strip) { + constexpr plot::u32 kFbW = 640; + constexpr plot::u32 kFbH = 280; + constexpr plot::f32 kPanelY = 80.0f; + constexpr plot::f32 kPanelH = 160.0f; + plottest::HeadlessContext ctx(kFbW, kFbH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + plot::Plot p; + p.set_viewport({0.0f, kPanelY, static_cast(kFbW), kPanelH}); + p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"millivolts per division of the input signal", {-1.0, 1.0}, false, + plot::AxisPlace::Left, false}); + + r.begin_frame(kFbW, kFbH, {0.0f, kPanelY, static_cast(kFbW), kPanelH}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, "y_title_clipped.ppm"); + + const plot::PlotLayout& layout = p.layout(); + const plot::Color ink = r.style().axis_text; + const plot::f32 pad = r.style().grid_label_pad; + const plot::Rect& strip = layout.y_title_area; + + // Chrome gave way, not the plot: 43 glyphs of title did not shrink the axes past + // its floor, and the strip is still one column. + PLOT_CHECK(layout.axes.h >= 0.5f * kPanelH); + PLOT_CHECK(layout.axes.w > 0.0f); + PLOT_CHECK_NEAR(strip.w, r.axis_font_px() + 2.0f * pad, 1e-3); + + const RectInk drawn = scan_rect_ink(ctx, strip, ink); + PLOT_CHECK(drawn.any()); + // The run spent the strip's height: a run cut to the 16 px column would be two + // glyphs tall, and this is the assertion that fails if the axis is threaded but + // the budget is not. + PLOT_CHECK(drawn.box.height() * 4u >= 3u * static_cast(strip.h)); + PLOT_CHECK(drawn.box.height() <= static_cast(strip.h)); + + // ... and it stopped inside the strip: nothing in the strip's own columns above + // the plot's viewport or below the axes, which is where an unclipped run would + // have run out of the strip and kept drawing. + PLOT_CHECK(!scan_rect_ink(ctx, plot::Rect{strip.x, 0.0f, strip.w, strip.y}, ink).any()); + PLOT_CHECK(!scan_rect_ink(ctx, + plot::Rect{strip.x, strip.bottom(), strip.w, + static_cast(kFbH) - strip.bottom()}, + ink) + .any()); ctx.destroy(); }