From 1f5aa11de2274333de779816491e5b8544d94146 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 27 Sep 2026 23:33:06 +0200 Subject: [PATCH] fix(render): floor the axes so chrome cannot erase it, one title row per titled axis Two findings on the pass-1 fix: - The reserved strips were clamped only to the viewport, so a title wider than a narrow panel took the rest of it and the axes collapsed: draw_traces() returned on a zero-width rect (no trace drawn, nothing reported) and a zero-height one flattened every trace onto a single row. The chrome is now capped at half the viewport -- the label strip is served first, the title strip takes what is left of that allowance -- an over-long title is clipped to its strip by a walk that both measures and stages it, and draw_traces() tests the height as well as the width. - Every titled y axis was staged at the same centred position inside one strip, so a Left/Right pair of titles overprinted. Each titled axis now owns a text line: the x strip grows one line per titled x axis (x_title_height), and the y runs stack down the axes-tall strip, which needs no extra width for it. Tests: `narrow_panel_with_a_long_y_title_still_draws` and `short_panel_with_an_x_title_still_draws` pin the floor (the axes keeps half the panel and the trace still covers the columns, with the title clipped into its strip), `titled_axis_pairs_get_their_own_rows` pins one text row per titled axis in both directions, and `layout_stacks_one_title_line_per_titled_axis` pins the strip sizing. All fail against the pass-1 sources. --- include/plot/plot/plot.hpp | 33 +++++-- include/plot/render/renderer.hpp | 9 ++ src/plot/plot.cpp | 64 +++++++++---- src/render/renderer.cpp | 96 ++++++++++++++----- tests/test_plot.cpp | 24 +++++ tests/test_render_smoke.cpp | 155 +++++++++++++++++++++++++++++++ 6 files changed, 330 insertions(+), 51 deletions(-) diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index b72aad6..e7b9966 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -36,10 +36,13 @@ struct PlotLayout { /// Bottom strip height. The default is used when no text backend has been set. f32 x_axis_height = 22.0f; /// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing - /// is reserved. An x title is one text line in a strip as wide as the axes; a y - /// title is a horizontal run in a vertical strip, so the strip is as wide as the - /// widest y title (plus the label padding) -- one text line would cut it off. - /// With no text backend there is no title to measure and both stay 0.0f. + /// 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. f32 y_title_width = 0.0f; f32 x_title_height = 0.0f; }; @@ -94,6 +97,11 @@ class Plot { u32 x_axis_count() const noexcept { return static_cast(x_axes_.size()); } u32 y_axis_count() const noexcept { return static_cast(y_axes_.size()); } + /// Axes of that direction that carry a title, i.e. that `layout()` reserves a + /// text line for and `Renderer::draw_titles()` draws one for. Zero when no + /// backend can measure a title, whatever the labels say. + u32 titled_x_axis_count() const noexcept; + u32 titled_y_axis_count() const noexcept; u32 trace_count() const noexcept { return static_cast(traces_.size()); } u32 cursor_count() const noexcept { return static_cast(cursors_.size()); } u32 measure_count() const noexcept { return static_cast(measures_.size()); } @@ -175,8 +183,17 @@ class Plot { /// `axes` is inset by the label strip of each occupied edge and, when that /// direction has a titled axis, by the strip that title lives in (`y_title_width` /// and `x_title_height` say how much that is): a title can then only be drawn - /// outside the data, whatever the title says. Call this once per frame -- it - /// rebuilds the ticks and the grid -- and pass the result down. + /// outside the data, whatever the title says. + /// + /// The chrome is capped so `axes` keeps half the viewport on each dimension + /// whatever the labels and titles ask for: a plot with a title wider than a + /// narrow panel shrinks, it does not vanish -- an axes rect with no width or no + /// height selects no level, stages no segment and draws no trace, and says + /// nothing about why. The label strip is what makes the tick values readable, so + /// it is served first and the title strip takes what is left of the allowance. + /// + /// Call this once per frame -- it rebuilds the ticks and the grid -- and pass + /// the result down. const PlotLayout& layout(); /// The layout as of the last refresh. The const overload never recomputes, so /// a lookup on a const Plot cannot move the axes out from under the caller. @@ -219,10 +236,6 @@ 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; - /// True when any axis of that direction carries a title, i.e. when layout() - /// must reserve the strip `Renderer::draw_titles()` draws it in. - bool has_x_title() const noexcept; - bool has_y_title() const noexcept; /// 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. f32 y_title_strip_width() const; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 9b24a91..35b7406 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -202,6 +202,15 @@ 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); 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 778cbf6..ce0fa8d 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -11,6 +11,16 @@ namespace { /// changing, so a normal plot converges on the second pass and never reaches this. constexpr u32 kMaxLayoutPasses = 4; +/// Share of the viewport the axes rect keeps however much chrome the label and +/// title strips ask for. A long SI label or a title wider than a narrow panel can +/// ask for the whole viewport, and an axes rect with no width or no height draws +/// nothing at all -- no grid, no tick, no trace -- without reporting anything, so +/// the strips give way instead. The label strip is what makes the tick values +/// readable, so it is served first and the title strip takes what is left; a title +/// longer than its share of the strip is clipped to it rather than allowed to grow +/// it (see `Renderer::draw_titles()`). +constexpr f32 kMinAxesShare = 0.5f; + const Axis& default_axis() noexcept { static const Axis axis{}; return axis; @@ -311,22 +321,33 @@ const PlotLayout& Plot::layout() { x_height = text_->height() + 2.0f * pad; } // The title strips, though, are needed before the loop and never change, unlike - // the label strips: a strip holds exactly one text line, so no tick label can - // make it wider. 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 tall; - // the y strip is one horizontal run wide (see y_title_strip_width). + // the label strips: a strip holds exactly one text line per titled axis and no + // 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). + 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 && has_x_title() ? text_->height() + 2.0f * pad : 0.0f; + const f32 x_title_h = text_ != nullptr && x_titles != 0u + ? static_cast(x_titles) * text_->height() + 2.0f * pad + : 0.0f; for (u32 pass = 0;; ++pass) { - // Clamped to the viewport so a huge label set can never push the axes rect - // outside it (or make its width negative). The title strips take what is left - // after the label strip, so the two can never add up past the viewport. + // Chrome is measured against the viewport, then capped so the axes keeps + // kMinAxesShare of it -- the label strip first, the title strip out of what is + // left of the allowance -- which is what keeps axes.w and axes.h positive + // however long the labels and titles are. `viewport_` is what a huge label set + // is clamped to first, so neither strip can push the rect outside it. y_width = std::clamp(y_width, 0.0f, viewport_.w); x_height = std::clamp(x_height, 0.0f, viewport_.h); - const f32 y_title_width = std::clamp(y_title_w, 0.0f, viewport_.w - y_width); - const f32 x_title_height = std::clamp(x_title_h, 0.0f, viewport_.h - x_height); + const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w; + const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h; + y_width = std::min(y_width, y_budget); + x_height = std::min(x_height, x_budget); + const f32 y_title_width = std::min(y_title_w, y_budget - y_width); + const f32 x_title_height = std::min(x_title_h, x_budget - x_height); layout_.y_axis_width = y_width; layout_.x_axis_height = x_height; layout_.y_title_width = y_title_width; @@ -344,8 +365,11 @@ const PlotLayout& Plot::layout() { refresh_ticks(); if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break; - const f32 need_y = std::clamp(y_label_width(), 0.0f, viewport_.w); - const f32 need_x = std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h); + // The same caps as above, so this compares like with like and the loop still + // reaches its fixed point on the second pass. + const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget); + const f32 need_x = + std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget); if (need_y == y_width && need_x == x_height) break; // strips are a fixed point y_width = need_y; x_height = need_x; @@ -385,18 +409,20 @@ f64 Plot::snapped_x(const Cursor& c) const { return c.x; } -bool Plot::has_x_title() const noexcept { +u32 Plot::titled_x_axis_count() const noexcept { + u32 n = 0; for (const Axis& a : x_axes_) { - if (!a.label.empty()) return true; + if (!a.label.empty()) ++n; } - return false; + return n; } -bool Plot::has_y_title() const noexcept { +u32 Plot::titled_y_axis_count() const noexcept { + u32 n = 0; for (const Axis& a : y_axes_) { - if (!a.label.empty()) return true; + if (!a.label.empty()) ++n; } - return false; + return n; } f32 Plot::y_title_strip_width() const { diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 378ddff..80484bb 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -50,6 +50,32 @@ 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, +/// slot)` for every glyph it keeps -- `pen` in viewport pixels, `slot` in the font +/// atlas -- 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. +template +f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::BitmapFont& font, + f32 px_per_cell, Emit emit) { + if (utf8 == nullptr || !(budget_px > 0.0f)) { + return 0.0f; // no room at all: a strip clipped away stages nothing + } + const f32 scale = px_per_cell / static_cast(render::BitmapFont::kGlyphPx); + f32 used = 0.0f; + for (const char* p = utf8; *p != '\0';) { + const u32 codepoint = render::BitmapFont::next_codepoint(p); + const f32 advance = font.advance_codepoint(codepoint) * scale; + if (used + advance > budget_px) break; // the next glyph does not fit: cut here + emit(pen, render::BitmapFont::glyph_index(codepoint)); + pen.x += advance; + used += advance; + } + return used; +} + } // namespace // --------------------------------------------------------------- AxisFont @@ -326,6 +352,16 @@ Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) { return glyphs_.add_text(to_viewport(pos_px), utf8, font_, font_backend_.cell_px()); } +f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const { + return walk_text_clipped(Vec2{}, utf8, budget_px, font_, font_backend_.cell_px(), + [](Vec2, u32) {}); +} + +f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px) { + return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, font_, font_backend_.cell_px(), + [this](Vec2 pen, u32 slot) { glyphs_.add_glyph(pen, slot); }); +} + // -------------------------------------------------------------------- grid void Renderer::draw_grid(const Plot& plot) { @@ -407,42 +443,58 @@ 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, one text line - // outside the tick labels: below the x labels for an x axis, beside the y labels - // for a y axis. The strips are what makes a title over the data impossible -- - // `layout.axes` is inset by them -- rather than merely unlikely, so nothing here - // may fall back to an inside-the-axes position. + // 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. + const f32 pad = style_.grid_label_pad; const f32 line_h = font_backend_.height(); - for (u32 i = 0; i < plot.x_axis_count(); ++i) { + // X titles: the strip's rows top down, one per titled axis. + for (u32 i = 0, row = 0; i < plot.x_axis_count(); ++i) { const Axis& axis = plot.x_axis(i); if (axis.label.empty()) continue; - // Centred on the strip: the tick labels have their own row above it, so the + 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); + // Centred on the strip: the tick labels have their own rows above it, so the // title needs no alignment of its own. - const f32 width = font_backend_.measure(axis.label.c_str()); - stage_text({layout.x_title_area.x + 0.5f * (layout.x_title_area.w - width), - layout.x_title_area.y + 0.5f * (layout.x_title_area.h - line_h)}, - axis.label.c_str()); + stage_text_clipped({strip.x + 0.5f * (strip.w - width), + strip.y + pad + static_cast(row) * line_h}, + axis.label.c_str(), budget); + ++row; } - for (u32 i = 0; i < plot.y_axis_count(); ++i) { + + // 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) { const Axis& axis = plot.y_axis(i); if (axis.label.empty()) continue; - // A y title is a horizontal run in the vertical strip the layout sized for - // the widest of them, centred on the axes' height: it reads like the x title, - // only beside the plot instead of under it. A run longer than the axes is - // clipped by the viewport, never folded back over the data. - const f32 width = font_backend_.measure(axis.label.c_str()); - stage_text({layout.y_title_area.x + 0.5f * (layout.y_title_area.w - width), - layout.y_title_area.y + 0.5f * (layout.y_title_area.h - line_h)}, - axis.label.c_str()); + 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; } } // ------------------------------------------------------------------- data void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { - if (!(layout.axes.w > 0.0f)) { - return; // a collapsed plot area has no pixel to select a level for + // Both dimensions: a zero-width rect selects no level, and a zero-height one + // would flatten every trace onto a single row -- a frame that looks like a + // rendering choice rather than the collapsed plot it is. + if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) { + return; } for (u32 i = 0; i < plot.trace_count(); ++i) { diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index 10e8d35..b79f117 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -99,6 +99,30 @@ 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. +PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) { + plot::render::BitmapFont font; + plot::Plot p; + p.set_text_backend(&font); + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const f32 pad = p.style().grid_label_pad; + p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + p.add_x_axis({"samples", {0.0, 1.0}, false, plot::AxisPlace::Top, 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}); + const plot::PlotLayout& l = p.layout(); + PLOT_CHECK(p.titled_x_axis_count() == 2u); + 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); + PLOT_CHECK_NEAR(l.axes.h, 400.0f - (l.x_axis_height + l.x_title_height), 1e-4); +} + /// An axis with no title reserves nothing: the strips exist so that a titled axis /// has somewhere outside `axes` to draw in, not as a fixed cost per plot. PLOT_TEST(layout_reserves_no_title_strip_without_a_title) { diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index b3fe4dd..8918e04 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -511,6 +511,161 @@ PLOT_TEST(mapped_ring_fallback_still_draws_after_refused_map) { ctx.destroy(); } +// Fix pass 2, finding 1: the reserved strips were clamped only to the viewport, so +// a y title wider than a narrow panel took the rest of it and `axes` collapsed to +// no width -- and `draw_traces()` returns on a zero-width axes, so the frame drew +// no trace at all and reported nothing. The chrome is capped now (the axes keeps +// half the panel, the title strip takes what is left of that allowance, and the +// title is clipped to the strip), so a narrow panel still draws its data. The +// titles here are long on purpose: ~24 glyphs against a 240 px panel is past the +// point where the reserve used to consume the plot. +PLOT_TEST(narrow_panel_with_a_long_y_title_still_draws) { + constexpr plot::u32 kPanelW = 240; + constexpr plot::u32 kPanelH = 200; + plottest::HeadlessContext ctx(kPanelW, kPanelH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kPanelW), static_cast(kPanelH)}); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = + p.add_y_axis({"millivolts per division", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.set_x_range(ax, {0.0, 150.0}); // a sample every few pixels: one polyline, all columns + + r.begin_frame(kPanelW, kPanelH, + {0, 0, static_cast(kPanelW), static_cast(kPanelH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + + const plot::PlotLayout& layout = p.layout(); + // The chrome gave way, not the plot: the axes kept its half of the panel... + PLOT_CHECK(layout.axes.w >= 0.5f * static_cast(kPanelW)); + PLOT_CHECK(layout.axes.h > 0.0f); + // ... and the title is still drawn, inside the strip it was cut to. + PLOT_CHECK(layout.y_title_area.w > 0.0f); + PLOT_CHECK(covered_columns(ctx, layout.axes, 16) >= 15); + + const plot::Color title = r.style().axis_text; + auto area_has_title = [&](const plot::Rect& area) { + for (plot::u32 row = 0; row < static_cast(area.h); ++row) { + for (plot::u32 column = 0; column < static_cast(area.w); ++column) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(static_cast(area.x) + column, + ctx.height - 1u - (static_cast(area.y) + row), px)); + if (pixel_is(px, title)) return true; + } + } + return false; + }; + PLOT_CHECK(area_has_title(layout.y_title_area)); // present, though cut short + PLOT_CHECK(!area_has_title(layout.axes)); // and never over the data + ctx.destroy(); +} + +// The same finding in the x direction: `x_title_height` collapsed `axes.h` on a +// short panel, and the old guard tested only the width, so every trace was +// flattened onto one row instead of being skipped where a caller could see it. +// A 48 px panel is the case where the two strips used to be all of it. +PLOT_TEST(short_panel_with_an_x_title_still_draws) { + constexpr plot::u32 kPanelW = 320; + constexpr plot::u32 kPanelH = 48; + plottest::HeadlessContext ctx(kPanelW, kPanelH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kPanelW), static_cast(kPanelH)}); + const plot::u32 ax = + p.add_x_axis({"time since the first sample, in seconds", s.x_bounds(), true, + plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.set_x_range(ax, {0.0, 150.0}); + + r.begin_frame(kPanelW, kPanelH, + {0, 0, static_cast(kPanelW), static_cast(kPanelH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + + const plot::PlotLayout& layout = p.layout(); + PLOT_CHECK(layout.axes.h >= 0.5f * static_cast(kPanelH)); + PLOT_CHECK(layout.axes.w > 0.0f); + PLOT_CHECK(covered_columns(ctx, layout.axes, 16) >= 15); + 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) { + 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::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + p.add_x_axis({"samples", s.x_bounds(), true, plot::AxisPlace::Top, false}); + const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); + p.add_y_axis({"amps", {-1.0, 1.0}, true, plot::AxisPlace::Right, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + + 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, "pairs.ppm"); + + 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 + // 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) { + plot::u32 bands = 0; + bool previous = false; + plot::u32 rows = 0; + for (plot::u32 row = 0; row < static_cast(strip.h); ++row) { + bool has = false; + for (plot::u32 column = 0; column < static_cast(strip.w) && !has; ++column) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(static_cast(strip.x) + column, + ctx.height - 1u - (static_cast(strip.y) + row), px)); + has = pixel_is(px, title); + } + if (has && !previous) ++bands; + if (has) ++rows; + previous = has; + } + PLOT_CHECK(rows >= 4u); // real glyph ink, not one stray pixel + return bands; + }; + + 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(); +} + // Finding 4 of the review: `mark_drawn()` dropped a failed fence's status, so the // slot stayed unsynchronised and the next upload memcpy'd into storage the GPU may // still have been reading -- exactly the tearing the ring exists to prevent. A