// The integration test: the whole stack on the real driver. A 1M-sample series, // the plot layer's layout/ticks/grid, the renderer's visible-range LOD selection // and both vertex-transfer strategies, all asserted by reading pixels back from // the headless EGL framebuffer rather than by inspecting anything the renderer // says about itself. #include "harness.hpp" #include "egl_headless.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr plot::u32 kW = 640; constexpr plot::u32 kH = 360; /// The series every case draws: a slow sine whose period is 62 832 samples, so a /// 1M-wide window holds ~16 of them (the dense envelope spans the full height in /// every column) while a 640-wide one is a nearly flat line (the sparse path's /// polyline crosses every column exactly once). std::vector sine_samples() { std::vector y(1'000'000); for (plot::u64 i = 0; i < y.size(); ++i) { y[i] = static_cast(std::sin(static_cast(i) * 0.0001)); } return y; } /// Columns of the axes rect (sampled at `columns` evenly spaced positions) that /// hold at least one trace-coloured pixel (0x40C0FF over the 0x101418 /// background). /// /// The predicate is blue *dominance*, not merely "blue-ish": the axis line is /// 0x8A94A6, which any threshold on blue alone would accept, and the axis line /// crosses every column of the rect -- a loose predicate would make every /// coverage assertion here vacuous. Blending the trace over the background keeps /// blue - red at `alpha * 191`, so even the AA fringe of a 1.5px stroke clears /// the bound. bool trace_pixel(const plot::u8 px[4]) { return px[2] > 120 && px[2] - px[0] > 100; } /// Predicates for the overlay layer, each chosen so that no other element of the /// frame can satisfy it (see the colours the test picks). A marker drawn on a /// pixel boundary blends at about half coverage, so they are dominance tests with /// room for that rather than exact colour matches. bool green_pixel(const plot::u8 px[4]) { return px[1] > px[0] + 60 && px[1] > px[2] + 60; } bool magenta_pixel(const plot::u8 px[4]) { return px[0] > px[1] + 60 && px[2] > px[1] + 60; } /// The measure band: 0xFFB020 at 15% over the background, so a warm tint. bool band_pixel(const plot::u8 px[4]) { return px[0] > 35 && px[0] > px[1] && px[1] > px[2]; } bool text_pixel(const plot::u8 px[4]) { return px[0] > 180 && px[1] > 180 && px[2] > 180; } /// A grey marker: the annotation colour at any coverage, which no data or chrome /// colour here matches (they are either dark or strongly coloured). bool marker_pixel(const plot::u8 px[4]) { const plot::u8 lo = std::min(px[0], std::min(px[1], px[2])); const plot::u8 hi = std::max(px[0], std::max(px[1], px[2])); return lo > 90 && static_cast(hi - lo) < 30; } plot::u32 covered_columns(plottest::HeadlessContext& ctx, const plot::Rect& a, plot::u32 columns) { // `a` is a Rect, so its rows are top-down; read_pixel() speaks GL's bottom-left // convention. Flipping here is the same single conversion the renderer does for // its viewport, and scanning the rows without it would test the 24 rows above // the axes instead of the axes itself. const plot::u32 first_row = static_cast(a.y); const plot::u32 rows = static_cast(a.h); plot::u32 hit = 0; for (plot::u32 c = 0; c < columns; ++c) { const plot::u32 x = static_cast(a.x + a.w * (static_cast(c) + 0.5) / static_cast(columns)); bool found = false; for (plot::u32 yy = 0; yy < rows && !found; ++yy) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (first_row + yy), px)); found = trace_pixel(px); } if (found) ++hit; } return hit; } /// Honours PLOT_DUMP_FRAMES: a non-empty value is the PPM path, an empty one /// dumps next to the test binary. Nothing is written unless it is set. void dump_if_requested(const plot::Renderer& r, const char* default_name) { const char* path = std::getenv("PLOT_DUMP_FRAMES"); if (path == nullptr) return; plot::Status st; const bool ok = r.dump_frame_ppm(path[0] != '\0' ? path : default_name, &st); std::printf("[dump] %s: %s\n", path[0] != '\0' ? path : default_name, ok ? "written" : st.message); PLOT_CHECK(ok); } /// True when `px` is (within a couple of levels) the straight-alpha colour `c`, /// which is what a fully covered text pixel is. The colour comes from the style, /// so no predicate here has to guess what the renderer draws with. bool pixel_is(const plot::u8 px[4], plot::Color c) { const auto near = [](plot::u8 got, plot::f32 want) { const int diff = static_cast(got) - static_cast(want * 255.0f + 0.5f); return diff >= -3 && diff <= 3; }; return near(px[0], c.r) && near(px[1], c.g) && near(px[2], c.b); } /// True when `px` holds `c` at some coverage -- the anti-aliased case `pixel_is` /// cannot see. The embedded atlas is greyscale and the renderer draws it at /// `Style::axis_font_px` from a 13 px bake, so at the default size a glyph stroke /// is barely a pixel wide and *no* pixel is ever fully covered: demanding the /// exact colour finds no text at all. /// /// A partially covered pixel is `c` scaled toward the frame's dark chrome, so this /// asks for every channel to be at least a third of `c`'s: bright enough to be /// glyph ink, dark enough to exclude a background, and deliberately blind to which /// of two chalk-toned chrome colours a pixel is -- which is why every caller scans /// a region only one element draws in. bool text_ink(const plot::u8 px[4], plot::Color c) { const auto lifted = [](plot::u8 got, plot::f32 want) { return static_cast(got) >= want * 255.0f * 0.35f; }; return lifted(px[0], c.r) && lifted(px[1], c.g) && lifted(px[2], c.b); } /// No column held ink. constexpr plot::u32 kNoColumn = 0xFFFFFFFFu; /// A host's `TextBackend` that measures one pixel per string, when the labels the /// renderer draws are tens of pixels wide: installed on a plot, it asks for a label /// strip far too narrow for anything the text pipeline draws. It publishes no /// atlas at all -- a backend that draws nothing is still a legal backend to /// measure with, and the plot layer only ever asks it for metrics. class OnePxPerStringBackend final : public plot::render::TextBackend { public: plot::f32 measure(const char*) const override { return 1.0f; } plot::f32 height() const override { return 1.0f; } plot::f32 ascent() const override { return 1.0f; } plot::f32 em_px() const override { return 1.0f; } const plot::render::Glyph* glyph(plot::u32) const override { return nullptr; } plot::Vec2 cell_uv() const override { return plot::Vec2{}; } // no atlas, no cell const plot::u8* atlas_pixels() const override { return nullptr; } plot::u32 atlas_width() const override { return 0u; } plot::u32 atlas_height() const override { return 0u; } plot::u32 revision() const override { return 0u; } }; /// A backend the test bakes by hand: one glyph whose cell is the whole atlas, and /// a revision it bumps on every bake. Baking two atlases of *different sizes* with /// the ink on opposite sides of the cell is what makes a stale atlas visible -- the /// frame after a re-bake must read the new bytes, and a quad that is still the old /// size cannot even reach the new ink. class BakeableBackend final : public plot::render::TextBackend { public: BakeableBackend() noexcept { bake(8u, false); } /// Bakes a `size` x `size` atlas with ink on the right half when `ink_right`, /// on the left half otherwise, and bumps the revision. The glyph's cell is the /// whole atlas, so the drawn quad is `size * px` pixels across. void bake(plot::u32 size, bool ink_right) { size_ = size; pixels_.assign(static_cast(size) * size, 0u); for (plot::u32 y = 0; y < size; ++y) { for (plot::u32 x = 0; x < size; ++x) { if ((x >= size / 2u) == ink_right) { pixels_[static_cast(y) * size + x] = 255u; } } } glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast(size)}; ++revision_; } /// Trims the published glyph's rect to an ink box, which is what breaks the /// fixed-cell grid (see `TextBackend::cell_uv`): the rect is no longer the cell /// `cell_uv()` declares, so the staging path's check fails. Used by the case /// that pins that check. void trim_glyph_rect(plot::f32 width) { glyph_.u1 = glyph_.u0 + width; } /// Publishes no atlas at all (the "no text" a backend is allowed to be), and /// bumps the revision -- which is how a backend that *starts* publishing one /// announces it. void drop_atlas() { pixels_.clear(); size_ = 0u; ++revision_; } /// Moves the revision without touching the artwork, so a test can drive it /// backwards -- any change is a change, and "smaller" is still a change. void set_revision(plot::u32 revision) noexcept { revision_ = revision; } plot::f32 measure(const char*) const override { return 8.0f; } plot::f32 height() const override { return static_cast(size_); } plot::f32 ascent() const override { return static_cast(size_); } /// Its cell is the whole atlas, so its em is the bake size: one unit is one pixel. plot::f32 em_px() const override { return static_cast(size_); } const plot::render::Glyph* glyph(plot::u32) const override { return &glyph_; } plot::Vec2 cell_uv() const override { return pixels_.empty() ? plot::Vec2{} : plot::Vec2{1.0f, 1.0f}; } const plot::u8* atlas_pixels() const override { return pixels_.empty() ? nullptr : pixels_.data(); } plot::u32 atlas_width() const override { return size_; } plot::u32 atlas_height() const override { return size_; } plot::u32 revision() const override { return revision_; } private: std::vector pixels_; plot::render::Glyph glyph_{}; plot::u32 size_ = 8u; plot::u32 revision_ = 0u; }; /// The ink one drawn text run left in the framebuffer: its bounding box in Rect /// pixels (top-down, like every other Rect here). `any()` is false when nothing /// was drawn. struct InkBox { plot::u32 x0 = kNoColumn; plot::u32 y0 = 0; plot::u32 x1 = 0; plot::u32 y1 = 0; bool any() const noexcept { return x0 != kNoColumn; } plot::u32 width() const noexcept { return any() ? x1 - x0 + 1u : 0u; } plot::u32 height() const noexcept { return any() ? y1 - y0 + 1u : 0u; } }; /// Scans the whole framebuffer for `text`-coloured ink, anti-aliased fringe and /// all (see `text_ink`). These cases draw nothing but the run, so everything lit /// in the target's colour is the run. InkBox scan_ink_box(plottest::HeadlessContext& ctx, plot::Color text) { InkBox box; for (plot::u32 y = 0; y < ctx.height; ++y) { for (plot::u32 x = 0; x < ctx.width; ++x) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px)); if (!text_ink(px, text)) continue; if (!box.any()) { box.x0 = x; box.y0 = y; } box.x1 = x; box.y1 = y; } } return box; } /// Draws one staged text run from `backend`'s atlas into the whole context in /// `text`, and returns the ink's bounding box. The pipeline is driven directly -- /// atlas, batch, program, vertex array -- so a case can rotate a run, or swap the /// backend underneath a live atlas, without the renderer in between. `atlas` is /// the caller's, so a case can bake again, sync again and draw again. InkBox draw_text_run(plottest::HeadlessContext& ctx, plot::render::TextAtlas& atlas, const plot::render::TextBackend& backend, const char* utf8, plot::Vec2 pen, plot::f32 px, plot::Vec2 axis) { plot::Status st; PLOT_CHECK(atlas.sync(backend, &st)); PLOT_CHECK(!atlas.empty()); plot::render::TextProgram program; PLOT_CHECK(program.create(&st)); plot::render::TextVertexArray vao; PLOT_CHECK(vao.create(&st)); plot::render::TextBatch batch; batch.begin(); batch.add_text(utf8, pen, px, backend, axis); PLOT_CHECK(batch.glyph_count() != 0u); vao.upload(batch.data().data(), batch.glyph_count()); const plot::Color text = plot::Color::hex(0xFFFFFF); plot::gl::glViewport(0, 0, static_cast(ctx.width), static_cast(ctx.height)); plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); plot::gl::glEnable(plot::gl::GL_BLEND); plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA); program.bind(); program.set_viewport({static_cast(ctx.width), static_cast(ctx.height)}); // The same two numbers the renderer's flush computes: the backend's cell, and // that cell's size on screen at this scale. const plot::Vec2 cell = backend.cell_uv(); const plot::Vec2 texels = atlas.texel_size(); program.set_glyph_uv(cell); program.set_px_range({cell.x * texels.x * px, cell.y * texels.y * px}); program.set_texture_size(texels); program.set_color(text); atlas.bind(); vao.bind(); plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, static_cast(vao.instance_count())); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); return scan_ink_box(ctx, text); } /// What a text strip's pixels say: the leftmost column holding ink, and the widest /// run of ink columns any single row holds -- one drawn label's extent, since a /// label has a row band to itself. struct TextInk { plot::u32 leftmost = kNoColumn; plot::u32 widest_row_run = 0; }; /// Scans the top-down pixels of [x0, x1) x [y, y + rows) for `text`-coloured ink, /// anti-aliased fringe and all (see `text_ink`). TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1, plot::u32 y, plot::u32 rows, plot::Color text) { TextInk ink; for (plot::u32 r = 0; r < rows; ++r) { plot::u32 first = kNoColumn; plot::u32 last = 0; for (plot::u32 x = x0; x < x1; ++x) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (y + r), px)); if (!text_ink(px, text)) continue; if (first == kNoColumn) first = x; last = x; if (ink.leftmost == kNoColumn) ink.leftmost = x; } if (first != kNoColumn && last - first + 1u > ink.widest_row_run) { ink.widest_row_run = last - first + 1u; } } 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 lines: `[first, last]`, both inclusive, in the coordinate the /// band runs across -- framebuffer x for `ink_column_bands()`, framebuffer y for /// `ink_row_bands()`. 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 lines `text`-coloured ink forms in `area`, top to bottom: one /// band per drawn text line that a gap of blank lines separates from its neighbours, /// which is how a case counts the rows a strip holds (an x title's rows, a y strip's /// stacked labels). Two lines drawn over each other make one band. std::vector ink_row_bands(plottest::HeadlessContext& ctx, const plot::Rect& area, plot::Color text) { std::vector bands; for (plot::u32 y = static_cast(area.y); y < static_cast(area.bottom()); ++y) { bool has = false; for (plot::u32 x = static_cast(area.x); x < static_cast(area.right()) && !has; ++x) { 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 != y) { bands.push_back(InkBand{y, y}); } else { bands.back().last = y; } } return bands; } /// 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; } /// Every pixel of the framebuffer, four bytes each, top row first -- the same order /// the Rect-based helpers above read in. Comparing two frames pixel for pixel is how /// a case asserts that a change had *no* effect anywhere in the target: nothing was /// drawn, rather than drawn somewhere else. std::vector read_frame(plottest::HeadlessContext& ctx) { std::vector frame(plot::usize(ctx.width) * ctx.height * 4u); for (plot::u32 y = 0; y < ctx.height; ++y) { for (plot::u32 x = 0; x < ctx.width; ++x) { PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, &frame[(plot::usize(y) * ctx.width + x) * 4u])); } } return frame; } /// The pixels of an already-read frame that hold `text`-coloured ink, anti-aliased /// fringe and all (see `text_ink`). `predicate_pixels` takes a predicate and not a /// colour, so a count that has to name *one* colour reads the frame this way -- and /// reading the frame once is what makes counting it twice cheap enough to compare two /// whole frames. plot::u32 frame_text_ink(const std::vector& frame, plot::u32 fb_w, plot::u32 fb_h, plot::Color text) { plot::u32 count = 0; for (plot::u32 y = 0; y < fb_h; ++y) { for (plot::u32 x = 0; x < fb_w; ++x) { if (text_ink(&frame[(plot::usize(y) * fb_w + x) * 4u], text)) ++count; } } return count; } /// 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 /// -- is testable without a broken driver. The pointer is saved on construction /// and put back in the destructor, which also runs while a failed `PLOT_CHECK` /// unwinds, so a case can never leak its stub into the next one. class RefusePersistentMap { public: RefusePersistentMap() noexcept : saved_(plot::gl::glMapBufferRange) { plot::gl::glMapBufferRange = &refuse; } ~RefusePersistentMap() { plot::gl::glMapBufferRange = saved_; } RefusePersistentMap(const RefusePersistentMap&) = delete; RefusePersistentMap& operator=(const RefusePersistentMap&) = delete; private: static void* refuse(plot::gl::GLenum, plot::gl::GLintptr, plot::gl::GLsizeiptr, plot::gl::GLbitfield) noexcept { return nullptr; } decltype(plot::gl::glMapBufferRange) saved_; }; /// Holds `plot::gl::glGetIntegerv` at null for as long as it lives: the entry /// point `TextAtlas::sync` reaches first, so a sync with it missing has to report /// a `Status` rather than call through a null pointer. Saved on construction, /// restored on destruction, exactly like the two stubs above -- so a failed check /// cannot leak it into the next case. class RefuseGetIntegerv { public: RefuseGetIntegerv() noexcept : saved_(plot::gl::glGetIntegerv) { plot::gl::glGetIntegerv = nullptr; } ~RefuseGetIntegerv() { plot::gl::glGetIntegerv = saved_; } RefuseGetIntegerv(const RefuseGetIntegerv&) = delete; RefuseGetIntegerv& operator=(const RefuseGetIntegerv&) = delete; private: decltype(plot::gl::glGetIntegerv) saved_; }; /// Same, for `glFenceSync`: the sync object the ring's slot guard needs is /// refused while the caps and the entry point still say the mapped path is on. class RefuseFenceSync { public: RefuseFenceSync() noexcept : saved_(plot::gl::glFenceSync) { plot::gl::glFenceSync = &refuse; } ~RefuseFenceSync() { plot::gl::glFenceSync = saved_; } RefuseFenceSync(const RefuseFenceSync&) = delete; RefuseFenceSync& operator=(const RefuseFenceSync&) = delete; private: static plot::gl::GLsync refuse(plot::gl::GLenum, plot::gl::GLbitfield) noexcept { return nullptr; } decltype(plot::gl::glFenceSync) saved_; }; /// Counts the calls the ring's fence waits make through `glClientWaitSync` -- the /// one entry point every wait goes through, whatever the store's own bookkeeping /// says. The test-only counters in report how /// often a store decides to wait; this reports what reached the driver, so the two /// cannot agree by construction. u64 g_fence_wait_calls = 0; decltype(plot::gl::glClientWaitSync) g_fence_wait_saved = nullptr; plot::gl::GLenum count_fence_wait(plot::gl::GLsync sync, plot::gl::GLbitfield flags, plot::gl::GLuint64 timeout) { ++g_fence_wait_calls; return g_fence_wait_saved(sync, flags, timeout); } class CountFenceWaits { public: CountFenceWaits() noexcept : saved_(plot::gl::glClientWaitSync) { g_fence_wait_saved = saved_; plot::gl::glClientWaitSync = &count_fence_wait; } ~CountFenceWaits() { plot::gl::glClientWaitSync = saved_; } CountFenceWaits(const CountFenceWaits&) = delete; CountFenceWaits& operator=(const CountFenceWaits&) = delete; /// Calls made since the counter was installed. u64 calls() const noexcept { return g_fence_wait_calls; } private: decltype(plot::gl::glClientWaitSync) saved_; }; } // namespace // The renderer draws a run at the size the style asked for, not at a whole // multiple of the backend's cell, and it reports what it drew. `axis_font_px()` // is that report, and the committed atlas is baked at 13 px, so the default // style's 12 px is a 12/13 scale -- exactly the case the old rule snapped to // 16 px. No other case sets a size other than the default, and the ones that // compare a measure compare values scaled by the same factor, so they hold under // a snapping rule too; this is the one that does not. PLOT_TEST(axis_text_is_drawn_at_the_requested_em_not_a_whole_multiple) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); // The renderer's own backend, for the raw metrics the ratio is taken against. const plot::render::EmbeddedMonoFont font; const plot::f32 baked = font.em_px(); const plot::f32 cell = font.height(); plot::Renderer r; r.style().axis_font_px = 12.0f; // the default style's em, against a 13 px bake PLOT_CHECK(r.init(&st)); PLOT_CHECK_NEAR(r.axis_font_px(), cell * (12.0f / baked), 1e-3); PLOT_CHECK(std::fabs(r.axis_font_px() - 16.0f) > 0.2f); // 2 x the old 8 px glyph PLOT_CHECK(std::fabs(r.axis_font_px() - 17.0f) > 0.2f); // 1 x this cell plot::Plot p; p.set_viewport({0.0f, 0.0f, static_cast(kW), static_cast(kH)}); p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false}); p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); const plot::Rect viewport{0.0f, 0.0f, static_cast(kW), static_cast(kH)}; r.begin_frame(kW, kH, viewport); r.draw(p); r.end_frame(); // The plot lays out with the backend the renderer installed, so the size it // measured with is the size it drew with. const plot::render::TextBackend* backend = p.text_backend(); PLOT_CHECK(backend != nullptr); PLOT_CHECK_NEAR(backend->em_px(), 12.0f, 1e-3); PLOT_CHECK_NEAR(backend->height(), cell * (12.0f / baked), 1e-3); // `em_px()` is in the wrapper's units, like `height()`: a caller drawing at // `requested_px / em_px()` reads 12/12 = 1 and cannot scale a second time. PLOT_CHECK_NEAR(backend->em_px() / backend->height(), baked / cell, 1e-6); // An exact multiple still lands exactly, and the frame re-reads the style. r.style().axis_font_px = 26.0f; // 2 x 13 r.begin_frame(kW, kH, viewport); r.draw(p); r.end_frame(); PLOT_CHECK_NEAR(p.text_backend()->em_px(), 26.0f, 1e-3); PLOT_CHECK_NEAR(p.text_backend()->height(), cell * 2.0f, 1e-3); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } PLOT_TEST(renders_visible_trace_across_viewport) { 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({"t", 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}); r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(p); r.end_frame(); dump_if_requested(r, "smoke.ppm"); // Trace colour must appear in every one of 16 sampled columns across the plot // area; background must dominate the corners. const plot::Rect a = p.layout().axes; PLOT_CHECK(covered_columns(ctx, a, 16) >= 15); // >= 95% of columns covered plot::u8 corner[4] = {}; PLOT_CHECK(ctx.read_pixel(2, 2, corner)); PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200); // dark background // The 1M-sample window is ~1700 samples per pixel, so it must have gone // through a pyramid level rather than through raw samples. PLOT_CHECK(r.stats().lod_traces == 1); PLOT_CHECK(r.stats().raw_traces == 0); PLOT_CHECK(r.stats().staged_segments < 10000); // O(pixels), not O(samples) PLOT_CHECK(!r.last_frame_had_gl_error()); ctx.destroy(); } PLOT_TEST(dense_and_sparse_paths_agree_on_extent) { 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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); auto covered = [&](plot::Range xr) -> plot::u32 { p.set_x_range(ax, xr); 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()); return covered_columns(ctx, p.layout().axes, 32); }; // Sparse first: 640 samples over 640 x-units -> ~1 sample/pixel -> raw polyline. const plot::u32 fine = covered({0.0, 640.0}); PLOT_CHECK(fine >= 30); // >= 95% of 32 columns PLOT_CHECK(r.stats().raw_traces == 1 && r.stats().lod_traces == 0); const plot::u32 fine_segments = r.stats().staged_segments; const plot::u64 fine_samples = r.stats().visible_samples; // Then dense: 1M samples over 1M x-units -> ~1700 samples/pixel -> LOD buckets. // It stages several times what the sparse frame did, so on the persistent path // this frame also grows the mapped ring -- re-creating the store, dropping the // fence of the draw that may still be reading it and moving the attribute // offsets -- rather than only ever allocating on a renderer's first frame. const plot::u32 coarse = covered({0.0, 1'000'000.0}); PLOT_CHECK(coarse >= 30); PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0); const plot::u32 coarse_segments = r.stats().staged_segments; const plot::u64 coarse_samples = r.stats().visible_samples; // The two windows really did take different paths: the dense one saw orders of // magnitude more samples while staging far fewer segments than it saw samples, // which is the whole point of the LOD pyramid. The sparse one staged the window // itself. PLOT_CHECK(coarse_samples > 100 * fine_samples); PLOT_CHECK(coarse_segments < 10000); PLOT_CHECK(coarse_segments * 100 < coarse_samples); PLOT_CHECK(fine_segments + 1 >= fine_samples); // one segment per sample pair ctx.destroy(); } // Resolution #1 of the brief: the renderer draws into whatever framebuffer is // bound and clears only its viewport, so a host that embeds the plot in a // sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given // in the plot's top-left pixel space, and the renderer shifts them by the // viewport origin exactly once. PLOT_TEST(clears_only_the_viewport_region) { 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; const plot::Rect vp{80.0f, 40.0f, 400.0f, 240.0f}; p.set_viewport(vp); 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({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.set_x_range(ax, {0.0, 300.0}); // ~1 sample / pixel: every column is crossed // The host paints its whole surface first; the frame must leave all of it that // is outside the viewport alone. plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); r.begin_frame(kW, kH, vp); r.draw(p); r.end_frame(); plot::u8 px[4] = {}; auto gl_y = [](plot::f32 top) { return static_cast(kH) - 1u - static_cast(top); }; // Left of the viewport, below it and past its right edge: untouched host grey. PLOT_CHECK(ctx.read_pixel(10, 10, px)); PLOT_CHECK(px[0] > 100 && px[0] < 160 && px[1] > 100 && px[2] > 100); PLOT_CHECK(ctx.read_pixel(20, gl_y(300.0f), px)); PLOT_CHECK(px[0] > 100 && px[0] < 160); PLOT_CHECK(ctx.read_pixel(600, 300, px)); PLOT_CHECK(px[0] > 100 && px[0] < 160); // Inside the viewport, above the axes: the renderer's own background. const plot::Rect a = p.layout().axes; PLOT_CHECK(a.x >= vp.x && a.right() <= vp.right() && a.y >= vp.y && a.bottom() <= vp.bottom()); PLOT_CHECK(ctx.read_pixel(static_cast(vp.x) + 2u, gl_y(vp.y + 2.0f), px)); PLOT_CHECK(px[0] + px[1] + px[2] < 200); // And the trace is inside the axes rect in framebuffer coordinates, so the // viewport origin was subtracted exactly once. PLOT_CHECK(covered_columns(ctx, a, 16) >= 15); PLOT_CHECK(!r.last_frame_had_gl_error()); ctx.destroy(); } // A host embeds the plot in its own frame loop, so the two pieces of global state // the renderer has to change must come back as the host left them: the unpack // alignment `init()` sets for the glyph atlas (whose single-channel rows are // 1-byte aligned), and the clear colour the frame paints its background with. PLOT_TEST(frame_hands_back_the_hosts_clear_colour_and_unpack_alignment) { 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)); // The host's clear colour, set before the first frame. Nothing touches the // unpack alignment: the context starts at GL's default of 4, and that is what // the frame has to leave behind -- with the atlas' 1 still in place, a host's // next padded RGBA upload would read the wrong rows. plot::gl::glClearColor(0.5f, 0.25f, 0.75f, 1.0f); 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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, 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()); plot::f32 clear[4] = {}; plot::gl::glGetFloatv(plot::gl::GL_COLOR_CLEAR_VALUE, clear); PLOT_CHECK_NEAR(clear[0], 0.5, 1e-6); PLOT_CHECK_NEAR(clear[1], 0.25, 1e-6); PLOT_CHECK_NEAR(clear[2], 0.75, 1e-6); PLOT_CHECK_NEAR(clear[3], 1.0, 1e-6); plot::gl::GLint alignment = 0; plot::gl::glGetIntegerv(plot::gl::GL_UNPACK_ALIGNMENT, &alignment); PLOT_CHECK(alignment == 4); // Not vacuous: the frame really did clear to its own background rather than the // host's grey, so the value above is a restoration and not an untouched leftover. plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(2, kH - 3u, px)); PLOT_CHECK(px[0] + px[1] + px[2] < 200); ctx.destroy(); } // The renderer draws every label with its own bitmap font, so it has to lay the // strips out with that font's metrics. A host backend whose metrics are nothing // like the artwork's would otherwise reserve a strip too narrow for the labels the // text pipeline then draws -- here, one pixel per string against ~40px labels. PLOT_TEST(renderer_measures_the_labels_it_draws) { 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; // The sub-rect viewport the adapters use, so the label strip's left edge is not // the framebuffer edge: with a narrower strip than the labels need, the renderer // stages them left of the viewport, where GL's own clip removes all but their // last few pixels. const plot::Rect vp{60.0f, 0.0f, 500.0f, 300.0f}; p.set_viewport(vp); const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); // No y title: the title strip sits left of the label strip, and this test reads // the label strip's own columns for ink. const plot::u32 ay = p.add_y_axis({"", {-1000.0, 1000.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); OnePxPerStringBackend host_font; p.set_text_backend(&host_font); // With the host's metrics the strip is 1px of label: what the frame must replace. PLOT_CHECK_NEAR(p.layout().y_label_area.w, 1.0f + 2.0f * p.style().grid_label_pad, 1e-6); plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); r.begin_frame(kW, kH, vp); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); // Layout and drawing measure with one backend: the renderer's font, at the size // the text pipeline draws (whole multiples of the glyph cell -- see AxisFont). const plot::render::TextBackend* back = p.text_backend(); PLOT_CHECK(back != nullptr); PLOT_CHECK(back != &host_font); PLOT_CHECK_NEAR(back->height(), r.axis_font_px(), 1e-6); plot::f32 widest = 0.0f; for (const plot::Tick& tick : p.y_axis(ay).ticks) { if (!tick.major || tick.label.empty()) continue; widest = std::max(widest, back->measure(tick.label.c_str())); } PLOT_CHECK(widest > 8.0f); // real labels in the real font, not the host's 1px const plot::Rect labels = p.layout().y_label_area; PLOT_CHECK_NEAR(labels.w, widest + 2.0f * p.style().grid_label_pad, 1e-3); // And the pixels agree with the strip: the drawn labels occupy the width the // layout reserved and stay inside it. Kept on the host's 1px metrics the strip is // ~9px wide while the renderer right-aligns its ~40px label against it, so all // but the label's last few pixels land left of the viewport and are clipped -- // the ink run collapses instead of filling the width that was reserved. const plot::Color text = r.style().axis_text; const plot::u32 y = static_cast(labels.y); const plot::u32 rows = static_cast(labels.h); const plot::u32 strip_x = static_cast(labels.x); // Exactly the strip, and no overhang: the axes' own border line runs down its // right edge through these same rows, and a scanner that reached past the strip // would read that line as label ink -- it is the same chalk-toned colour family // as the text (see `text_ink`). const TextInk ink = scan_text_ink(ctx, strip_x, strip_x + static_cast(labels.w), y, rows, text); PLOT_CHECK(ink.leftmost != kNoColumn); // labels were drawn PLOT_CHECK(ink.leftmost >= strip_x); // inside their strip PLOT_CHECK(static_cast(ink.widest_row_run) >= 0.6f * widest); // full width drawn PLOT_CHECK(static_cast(ink.widest_row_run) <= widest + 1.0f); // and no wider ctx.destroy(); } // The overlay layer: cursor markers in their own colours, the shaded measurement // band and its readout, and an annotation pin. These are the pixels the adapters' // demos are read for, and none of them goes through the trace path. PLOT_TEST(draws_cursors_measure_band_and_annotations) { 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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame // Colours no other element of the frame uses, so each assertion below can only // be satisfied by the element it names. const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00)); // green const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF)); // magenta PLOT_CHECK(p.add_measure(ca, cb, tr) == 0); plot::Annotation pin; pin.x = 200.0; pin.y = 0.5; pin.text = "A"; pin.color = plot::Color::hex(0xE8E8E8); pin.axis_x = ax; pin.axis_y = ay; p.add_annotation(pin); 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::Transform tx = p.x_transform(ax); const plot::Transform ty = p.y_transform(ay); // Scans one column of the axes rect, in top-down rows like every Rect. auto column_has = [&](plot::u32 x, bool (*pred)(const plot::u8*)) { for (plot::u32 row = 0; row < static_cast(a.h); ++row) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast(a.y) - row, px)); if (pred(px)) return true; } return false; }; // Scans a square around one framebuffer position, clipped to the framebuffer so // a square that reaches an edge simply covers fewer pixels. auto box_has = [&](plot::f32 fx, plot::f32 fy, plot::i32 radius, bool (*pred)(const plot::u8*)) { const plot::i32 cx = static_cast(fx); const plot::i32 cy = static_cast(fy); for (plot::i32 row = cy - radius; row <= cy + radius; ++row) { if (row < 0 || row >= static_cast(kH)) continue; for (plot::i32 column = cx - radius; column <= cx + radius; ++column) { if (column < 0 || column >= static_cast(kW)) continue; plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(static_cast(column), ctx.height - 1u - static_cast(row), px)); if (pred(px)) return true; } } return false; }; const plot::u32 x_a = static_cast(tx.to_screen_x(100.0)); const plot::u32 x_b = static_cast(tx.to_screen_x(300.0)); const plot::u32 mid = static_cast(tx.to_screen_x(200.0)); PLOT_CHECK(column_has(x_a, green_pixel)); // the cursor markers themselves PLOT_CHECK(column_has(x_b, magenta_pixel)); // Nothing of either colour where neither cursor is. PLOT_CHECK(!column_has(mid, green_pixel)); PLOT_CHECK(!column_has(mid, magenta_pixel)); // The measurement band tints the columns between the cursors and only those. PLOT_CHECK(column_has(mid, band_pixel)); PLOT_CHECK(!column_has(static_cast(tx.to_screen_x(40.0)), band_pixel)); // ... with its numeric readout drawn near the top of it. PLOT_CHECK(box_has(static_cast(mid), a.y + 8.0f, 20, text_pixel)); // The annotation's cross pins data (200, 0.5) in its own colour. PLOT_CHECK(box_has(static_cast(tx.to_screen_x(200.0)), static_cast(ty.to_screen_y(0.5)), 4, marker_pixel)); ctx.destroy(); } // Finding 2 of the review: `draw_titles()` placed both titles inside // `layout.axes`, because the layout reserved no room outside it for them, so in // the dense frame -- whose envelope spans every column's full height -- a title // sat on the trace and no pixel test could see it. The layout now insets `axes` by // a strip per direction and the titles are drawn inside those strips, which is // what makes a title over the data impossible. This draws known titles over that // same dense envelope and asserts both halves: no title-coloured pixel in a band // just inside either horizontal edge of `axes`, and the title colour inside both // strips. The colour is the style's own `axis_text` -- what the text pipeline is // tinted with -- never a guess, and no other element of the frame can match it // (the trace is blue-dominant, the grid grey-dark, the axis line mid-grey). PLOT_TEST(titles_stay_out_of_the_axes_band) { 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}); const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, 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, "titles.ppm"); const plot::PlotLayout& layout = p.layout(); const plot::Rect& a = layout.axes; const plot::Color title = r.style().axis_text; // Reads a Rect -- whose rows run top-down, like every Rect here -- and reports // whether any pixel in it is the title colour. 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; }; // One text line plus the two pixels the old placement inset itself by: tall // enough for the whole title line that used to be drawn there. const plot::f32 band = r.axis_font_px() + 2.0f; PLOT_CHECK(!area_has_title({a.x, a.y, a.w, band})); PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band})); // ... while the strips the layout reserved hold both titles. PLOT_CHECK(layout.x_title_area.h > 0.0f); PLOT_CHECK(layout.y_title_area.w > 0.0f); PLOT_CHECK(layout.axes.bottom() <= layout.x_title_area.y); // outside the data PLOT_CHECK(area_has_title(layout.x_title_area)); PLOT_CHECK(area_has_title(layout.y_title_area)); ctx.destroy(); } // Finding 1 of the review: when the mapping failed, the store re-created its // buffer as the orphan path's -- and nothing told the vertex array, so it stayed // bound to a buffer whose attribute bindings `glDeleteBuffers` had already // cleared, and every later frame drew nothing at all. The epoch now carries the // buffer's identity, so the array re-points itself before the draw. The refusal is // forced through the entry point (`glMapBufferRange` returns null while the caps // and the pointer still advertise the mapped path, which is exactly the state the // fallback exists for), and the frame after it must still rasterise the trace. PLOT_TEST(mapped_ring_fallback_still_draws_after_refused_map) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::Renderer r; { RefusePersistentMap guard; // before init(): the mapped path is still selected PLOT_CHECK(r.init(&st)); const bool forced = r.using_persistent_upload(); 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({"t", 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, 640.0}); // one sample per pixel: an unmistakable polyline // The first upload is the one that grows the ring, so it is the one whose map // is refused and which ends up on the orphan buffer. r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(p); r.end_frame(); // Geometry first: this is the assertion the deleted buffer's attribute // bindings broke, leaving the draw with no attributes to read at all. PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15); if (forced) { PLOT_CHECK(!r.using_persistent_upload()); // the fallback really ran } PLOT_CHECK(!r.last_frame_had_gl_error()); // ... and the frame after it, which is where the deleted buffer's attribute // bindings used to leave the pipeline with nothing bound at all. r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(p); r.end_frame(); PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15); PLOT_CHECK(!r.last_frame_had_gl_error()); } 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 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)); 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 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) { 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 = text_ink(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" 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(); } // The chrome allowance (`Plot::layout()`) caps the y label strip at half the viewport // and gives the title strip what is left of that allowance, so a viewport narrow // enough to spend all of it on the tick labels leaves the title strip narrower than // one text line -- or, as here, zero wide. Nothing about a *rotated* title is bounded // by that width: its budget is the strip's height, its pen is the strip's padding and // `walk_text_clipped()` clips along the run, so before the fit gate the glyph cells // were staged into a strip that cannot hold them -- over the tick labels, which are // right-aligned across exactly those columns, and (once the columns step past // `axes.x`) inside the data area. // // The property is "not drawn, not relocated": the same scene drawn with and without // y titles must produce identical frames. Comparing the whole target catches the // overprint wherever it lands, and cannot be satisfied by drawing the title somewhere // else. Three titled axes are what it takes to reach `axes` in a strip this narrow. PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) { constexpr plot::u32 kNarrowW = 64; constexpr plot::u32 kNarrowH = 200; plottest::HeadlessContext ctx(kNarrowW, kNarrowH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::Renderer r; PLOT_CHECK(r.init(&st)); const auto scene = [&](const char* title) { plot::Plot plot; plot.set_viewport({0, 0, static_cast(kNarrowW), static_cast(kNarrowH)}); plot.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Right, false}); plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); return plot; }; const auto render = [&](plot::Plot& plot) { r.begin_frame(kNarrowW, kNarrowH, {0, 0, static_cast(kNarrowW), static_cast(kNarrowH)}); r.draw(plot); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); }; plot::Plot titled = scene("millivolts per division"); render(titled); const std::vector with_titles = read_frame(ctx); const plot::PlotLayout layout = titled.layout(); // a copy: draw() mutates the plot const plot::Color ink = r.style().axis_text; // The clamp bit exactly the way this case needs it to: the strip has less room // than the one column its three titled axes ask for, so no column can be staged. PLOT_CHECK(titled.titled_y_axis_count() == 3u); PLOT_CHECK(layout.y_title_area.w < r.axis_font_px() + r.style().grid_label_pad); // ... and the plot kept its floor in a viewport this narrow, so nothing here is // degenerate for some other reason. PLOT_CHECK(layout.axes.w >= 0.5f * static_cast(kNarrowW)); PLOT_CHECK(layout.axes.h > 0.0f); // No title ink in the data area. This is the direct read of the invariant; the // frame comparison below is the same statement about the whole target. PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink)); plot::Plot plain = scene(""); render(plain); const std::vector without_titles = read_frame(ctx); plot::u32 differing = 0u; plot::u32 first = 0u; for (plot::u32 i = 0; i < static_cast(with_titles.size() / 4u); ++i) { bool same = true; for (plot::u32 c = 0; c < 4u && same; ++c) { same = with_titles[4u * i + c] == without_titles[4u * i + c]; } if (!same) { if (differing == 0u) first = i; ++differing; } } if (differing != 0u) { std::printf("[narrow_y_title] %u of %zu pixels differ; first at x=%u y=%u\n", differing, with_titles.size() / 4u, first % kNarrowW, first / kNarrowW); } PLOT_CHECK(differing == 0u); ctx.destroy(); } // The same clamp, in a viewport wide enough that the strip holds *some* columns: the // ones the clamp cut short must be dropped, not drawn beside the ones that fit. Three // titled y axes ask for three columns, the strip has room for fewer, and the columns // that survive are the leading ones -- which is what "keep the leading columns" // means, since the renderer lays them out left to right from the strip's padding. PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) { // 170 px is where the clamp leaves room for exactly one column (the label strip // takes 51.2 px of the 85 px allowance, leaving 33.8 px against the 19.7 px a // column needs); the assertions below re-derive that rather than assume it. constexpr plot::u32 kNarrowW = 170; constexpr plot::u32 kNarrowH = 200; plottest::HeadlessContext ctx(kNarrowW, kNarrowH); 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(kNarrowW), static_cast(kNarrowH)}); p.add_x_axis({"", {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}); p.add_y_axis({"watts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); r.begin_frame(kNarrowW, kNarrowH, {0, 0, static_cast(kNarrowW), static_cast(kNarrowH)}); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "y_title_columns_clamped.ppm"); const plot::PlotLayout layout = p.layout(); const plot::Color ink = r.style().axis_text; const plot::f32 line = r.axis_font_px(); const plot::f32 pad = r.style().grid_label_pad; const plot::Rect& strip = layout.y_title_area; // A column occupies the strip's leading padding plus one line, so this is how many // of the three fit -- the rule the renderer applies, and the reason the trailing // ones are the ones with nowhere to go. The regime this case is about needs a // strip that holds at least one column and fewer than all three. PLOT_CHECK(p.titled_y_axis_count() == 3u); const plot::u32 fits = static_cast(std::max(0.0f, (strip.w - pad) / line)); PLOT_CHECK(fits >= 1u); PLOT_CHECK(fits < 3u); PLOT_CHECK(layout.axes.w >= 0.5f * static_cast(kNarrowW)); PLOT_CHECK(layout.axes.h > 0.0f); // One band of inked columns per column that fits, each inside its own column's cell // (band i starts at the padding plus i lines and is one line wide). const std::vector columns = ink_column_bands(ctx, strip, ink); PLOT_CHECK(columns.size() == fits); for (plot::u32 i = 0; i < static_cast(columns.size()); ++i) { PLOT_CHECK(columns[i].first >= static_cast(strip.x + pad + static_cast(i) * line)); PLOT_CHECK(columns[i].last <= static_cast(strip.x + pad + static_cast(i + 1u) * line)); } // ... and none of it reached the data area. PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink)); ctx.destroy(); } // The clamp reaches the x title from the vertical side: `x_title_height` is capped at // the viewport's allowance minus the tick label strip's height, so a viewport short // enough leaves the strip too short for a single row. A row staged anyway is not wholly // clipped -- its cell starts inside the strip, so the top of its glyphs lands in the // frame, a title cut off mid-stroke -- and neither the run's own clip (which cuts along // x) nor the layout's clamp can see that. So this is the one titled axis where nothing // fits, and nothing may be staged. PLOT_TEST(short_viewport_draws_no_x_title_it_cannot_fit) { // 80 px tall: the label strip takes 23.69 px of the 40 px allowance, leaving a // 16.31 px strip against the 19.69 px a row needs -- no room for a row, but 12.31 px // of a staged row's cell would still be inside the target. constexpr plot::u32 kShortW = 320; constexpr plot::u32 kShortH = 80; plottest::HeadlessContext ctx(kShortW, kShortH); 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(kShortW), static_cast(kShortH)}); p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); r.begin_frame(kShortW, kShortH, {0, 0, static_cast(kShortW), static_cast(kShortH)}); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "x_title_clamped.ppm"); const plot::PlotLayout layout = p.layout(); const plot::Color ink = r.style().axis_text; const plot::f32 line = r.axis_font_px(); const plot::f32 pad = r.style().grid_label_pad; const plot::Rect& strip = layout.x_title_area; // The clamp bit the way this case needs it to: not one row fits. PLOT_CHECK(p.titled_x_axis_count() == 1u); const plot::u32 fits = static_cast(std::max(0.0f, (strip.h - pad) / line)); PLOT_CHECK(fits == 0u); // ... and the plot kept its floor in a viewport this short. PLOT_CHECK(layout.axes.h >= 0.5f * static_cast(kShortH)); PLOT_CHECK(layout.axes.w > 0.0f); // Nothing was staged for it: no title ink in the strip at all (a staged row's ink // would land here, cut off by the viewport's bottom edge), and none in the data // area either. PLOT_CHECK(!scan_rect_ink(ctx, strip, ink).any()); PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink)); ctx.destroy(); } // The same clamp one row short instead of absent: three titled x axes ask for three // rows and the strip holds one, so the rows it cut short are the trailing ones and must // be dropped rather than squeezed past its end. Each is partly visible otherwise -- its // cell starts inside the strip, and the rest of it is cut by the target's bottom edge, // which is exactly where an x strip ends -- so the strip's row bands are what says how // many were staged: one per row that fits, each inside its own row's cell. PLOT_TEST(short_viewport_keeps_only_the_x_title_rows_that_fit) { // 112 px tall: the label strip takes 23.69 px of the 56 px allowance, leaving a // 32.31 px strip against the 19.69 px a row needs and the 35.38 px two rows need, so // one row fits with 12.6 px of the strip left below it -- enough for the row that // must be dropped to show its glyph tops before the target's bottom edge cuts it. constexpr plot::u32 kShortW = 320; constexpr plot::u32 kShortH = 112; plottest::HeadlessContext ctx(kShortW, kShortH); 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(kShortW), static_cast(kShortH)}); 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_x_axis({"ticks", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); r.begin_frame(kShortW, kShortH, {0, 0, static_cast(kShortW), static_cast(kShortH)}); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "x_title_rows_clamped.ppm"); const plot::PlotLayout layout = p.layout(); const plot::Color ink = r.style().axis_text; const plot::f32 line = r.axis_font_px(); const plot::f32 pad = r.style().grid_label_pad; const plot::Rect& strip = layout.x_title_area; // A row occupies the strip's top padding plus one line, so this is how many of the // three fit -- the rule the renderer applies, derived here rather than assumed. PLOT_CHECK(p.titled_x_axis_count() == 3u); const plot::u32 fits = static_cast(std::max(0.0f, (strip.h - pad) / line)); PLOT_CHECK(fits >= 1u); PLOT_CHECK(fits < 3u); PLOT_CHECK(layout.axes.h >= 0.5f * static_cast(kShortH)); PLOT_CHECK(layout.axes.w > 0.0f); // One band of inked rows per row that fits, each inside its own row's cell (band i // starts at the padding plus i lines and is one line tall). const std::vector rows = ink_row_bands(ctx, strip, ink); PLOT_CHECK(rows.size() == fits); for (plot::u32 i = 0; i < static_cast(rows.size()); ++i) { PLOT_CHECK(rows[i].first >= static_cast(strip.y + pad + static_cast(i) * line)); PLOT_CHECK(rows[i].last <= static_cast(strip.y + pad + static_cast(i + 1u) * line)); } 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 // refused fence now drops the ring for the orphan path and reports it, instead of // rotating through a slot nothing guards. `glFenceSync` is stubbed to hand back no // sync object while the caps still advertise the mapped path, so the fence is the // only thing that fails. PLOT_TEST(refused_fence_drops_the_mapped_ring) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::Renderer r; { RefuseFenceSync guard; PLOT_CHECK(r.init(&st)); const bool forced = r.using_persistent_upload(); 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({"t", 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, 640.0}); r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(p); r.end_frame(); if (forced) { // The frame whose slot could not be fenced has stopped using the ring: the // next upload must not write storage the draw may still be reading, and the // frame reports the failure rather than swallowing it. PLOT_CHECK(!r.using_persistent_upload()); PLOT_CHECK(r.last_frame_had_gl_error()); } PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15); // it still drew // The next frames draw through the orphan path, which needs no fence. r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(p); r.end_frame(); PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15); PLOT_CHECK(!r.last_frame_had_gl_error()); } ctx.destroy(); } /// The ring must absorb a frame that issues more uploads than it starts with /// slots. The shape it replaced -- three slots and a fence per upload -- wrapped /// within such a frame: from the fourth upload on, each one waited on a fence /// recorded a few draws *earlier in the same frame*, i.e. an effective per-frame /// GPU sync. That is free when the driver retires draws promptly (the GLFW host, /// 1.07 ms a frame) and ~4 ms of the Qt widget's 7.11 ms under llvmpipe, which is /// exactly the difference `PLOT_FORCE_GL33_UPLOAD=1` shows -- and it is a cliff /// that grows with the trace and cursor counts this library exists for. /// /// The ring now grows to cover the frame's uploads, records one fence after the /// frame's last draw, and waits once, at the start of the next frame, so a frame /// never synchronises with work it issued itself. None of that is visible in the /// pixels -- a syncing ring draws the same image -- so the test pins the three /// properties directly: a frame writes distinct slots (the offsets it hands the /// vertex array), the ring grows past its floor to cover them, and a frame /// performs at most one wait. PLOT_TEST(ring_grows_to_cover_a_frame_and_waits_once_at_its_start) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::render::detail::InstanceStore store; PLOT_CHECK(store.create(plot::render::UploadMode::PersistentMapped, &st)); if (!store.persistent()) { ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin return; } constexpr plot::u32 kUploadsPerFrame = 6; // more than the ring's three-slot floor plot::f32 data[4 * kUploadsPerFrame]; for (plot::u32 i = 0; i < 4 * kUploadsPerFrame; ++i) data[i] = static_cast(i); auto counter = [](const std::atomic& c) { return c.load(std::memory_order_relaxed); }; plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed); plot::render::detail::ring_waits().store(0, std::memory_order_relaxed); plot::render::detail::ring_slots().store(0, std::memory_order_relaxed); // The first frame has no fence outstanding, so it must not wait at all -- and it // must have grown the ring past its floor, because six uploads do not fit in // three slots. PLOT_CHECK(store.begin_frame()); for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) { store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance)); } PLOT_CHECK(store.mark_drawn()); PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == kUploadsPerFrame); PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 0); PLOT_CHECK(counter(plot::render::detail::ring_slots()) > store.kInitialSlots); PLOT_CHECK(counter(plot::render::detail::ring_slots()) >= kUploadsPerFrame); // The second frame, measured upload by upload. The ring covers it now, so the // slot each upload lands in -- the offset the vertex array re-points at -- is // readable, and the invariant is the offsets being distinct: a slot written // twice in a frame would be one the GPU could still be reading the first copy // from, because the only fence is the one at the end of the frame. PLOT_CHECK(store.begin_frame()); plot::usize offsets[kUploadsPerFrame] = {}; for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) { store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance)); offsets[i] = store.slot_offset(); } PLOT_CHECK(store.mark_drawn()); for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) { for (plot::u32 j = i + 1u; j < kUploadsPerFrame; ++j) { PLOT_CHECK(offsets[i] != offsets[j]); // no slot reused within the frame } } // ... and this frame waited exactly once: on the fence the first one recorded, // never on its own work. PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == 2u * kUploadsPerFrame); PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 1); // A third frame the same, so the wait is per frame and not per upload: six // uploads a frame would be five waits a frame under the shape this replaced. PLOT_CHECK(store.begin_frame()); for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) { store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance)); } PLOT_CHECK(store.mark_drawn()); PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 2); ctx.destroy(); } /// The same property at the level the demos run at. A frame of this plot stages /// what the demos stage -- two grid colour groups, the axis lines, tick labels and /// titles, the trace, two cursors and the measurement band -- nine-ish uploads, or /// three times the slots the ring used to have, so the old ring wrapped within the /// frame several times over. The frame must absorb them with at most one wait per /// store (two stores), and the upload counter must agree with the draw calls the /// frame reports, so it is counting this frame's staging and nothing else. /// /// The wait is checked twice over: once from the store's own counter, and once at /// `glClientWaitSync`, the call every wait is made through. A nine-upload frame /// under the shape this replaced made six mid-frame wait sites, each costing the /// non-blocking probe plus the bounded wait behind it. PLOT_TEST(a_dense_frame_uploads_more_than_the_ring_floor_without_waiting) { 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)); if (!r.using_persistent_upload()) { ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin return; } 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({"t", 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}); const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.set_x_range(ax, {0.0, 640.0}); const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00)); const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF)); PLOT_CHECK(p.add_measure(ca, cb, tr) == 0); auto counter = [](const std::atomic& c) { return c.load(std::memory_order_relaxed); }; plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed); plot::render::detail::ring_waits().store(0, std::memory_order_relaxed); plot::render::detail::ring_slots().store(0, std::memory_order_relaxed); CountFenceWaits driver_waits; // installed after load(), restored with the test for (plot::u32 index = 0; index < 3u; ++index) { const plot::u64 uploads_before = counter(plot::render::detail::ring_uploads()); const plot::u64 waits_before = counter(plot::render::detail::ring_waits()); const plot::u64 calls_before = driver_waits.calls(); 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::u64 uploads = counter(plot::render::detail::ring_uploads()) - uploads_before; const plot::u64 waits = counter(plot::render::detail::ring_waits()) - waits_before; // Every upload lands in a draw this frame reported, so the counter describes // this frame's staging and no other store's. PLOT_CHECK(uploads == r.stats().draw_calls); // The scenario: more uploads than the ring's floor of slots. PLOT_CHECK(uploads > plot::render::detail::InstanceStore::kInitialSlots); // ... and not one of them waited: the only wait is the next frame's start, // one per store. PLOT_CHECK(waits <= 2); if (index == 0) PLOT_CHECK(waits == 0); // At the driver call too: one wait site per store, and a site costs at most // two calls -- the non-blocking probe, then the bounded wait behind it. Six // uploads past the old ring's three slots used to cost six sites on their own, // inside the frame that made them. const plot::u64 calls = driver_waits.calls() - calls_before; PLOT_CHECK(calls <= 4u); if (index == 0) PLOT_CHECK(calls == 0u); } // The ring really grew past its floor: what covers a nine-upload frame is not // what three slots covered. PLOT_CHECK(counter(plot::render::detail::ring_slots()) > plot::render::detail::InstanceStore::kInitialSlots); ctx.destroy(); } // Rotated text: the staged basis, not the screen axes, is what a run is laid out // along. The same six glyphs are drawn twice at the same pen and size, once // unrotated and once with `axis = (0, -1)` -- the direction a bottom-to-top y // label advances in -- and the ink has to stand up: the rotated box is taller // than it is wide, and the run marches *up* from the pen instead of to its right. // A shader that ignored `a_axis` would draw the second frame exactly like the // first. PLOT_TEST(rotated_text_advances_along_its_basis) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::render::EmbeddedMonoFont font; plot::render::TextAtlas atlas; const plot::Vec2 pen{300.0f, 180.0f}; const plot::f32 px = 2.0f; // one cell is 16 x 34 screen pixels at this scale const plot::u32 cell = plot::render::EmbeddedMonoFont::cell_height() * 2u; const plot::u32 advance_px = static_cast(font.glyph(static_cast('M'))->advance * px); const InkBox flat = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {1.0f, 0.0f}); PLOT_CHECK(flat.any()); PLOT_CHECK(flat.width() > flat.height()); PLOT_CHECK(flat.x0 >= static_cast(pen.x)); PLOT_CHECK(flat.y0 >= static_cast(pen.y)); PLOT_CHECK(flat.height() <= cell); PLOT_CHECK(flat.width() > 4u * advance_px); // six monospaced glyphs, and then some const InkBox up = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {0.0f, -1.0f}); PLOT_CHECK(up.any()); PLOT_CHECK(up.height() > up.width()); // the run stands up PLOT_CHECK(up.height() > flat.height()); // and is as long as the flat one was wide PLOT_CHECK(up.width() <= cell); // one cell wide, so nothing marched right PLOT_CHECK(up.x0 >= static_cast(pen.x)); PLOT_CHECK(up.x1 < static_cast(pen.x) + cell); // Ink above the pen: the first cell starts at the pen and every later one is an // advance further up, so the run's rows run from well above the pen down to the // pen's own cell -- and the pen's own cell is the lowest row of ink. PLOT_CHECK(up.y0 + 4u * advance_px < static_cast(pen.y)); PLOT_CHECK(up.y1 < static_cast(pen.y) + cell); ctx.destroy(); } // The orientation rule, stated rather than hardcoded: a glyph's texel `(col, row)` // lands at the pen plus `col` steps along the cell's +x axis (`ux` here) and `row` // steps along its +v axis (the perpendicular). That is the mapping kTextVS // implements, so with `axis = (0, -1)` the 'L' -- whose ink is a stem down its left // columns and a foot along its bottom rows -- has to come out as a bar standing up // from the pen with the foot running right. // // The check is two-sided: the texels the basis says hold ink do, and the texels a // mirrored perpendicular would put that ink in hold none -- so a glyph flipped // about its own diagonal fails even though it is the same size and shape. The // coverage is anti-aliased, so "held ink" is a coverage threshold rather than an // exact white: a texel of the stem is fully covered and a texel of the foot is // half, and both are ink. PLOT_TEST(rotated_glyph_orientation_is_not_mirrored) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::render::EmbeddedMonoFont font; plot::render::TextAtlas atlas; const plot::Vec2 pen{300.0f, 200.0f}; const plot::f32 px = 4.0f; // one texel is a 4x4 pixel block const plot::Vec2 ux{0.0f, -1.0f}; // a 90 degree counter-clockwise label const plot::Vec2 uy{-ux.y, ux.x}; // (1, 0): the cell's own +v axis // Where the centre of the glyph's texel (col, row) is drawn. The cell's top-left // is the pen (the embedded font bakes its bearings into the cell), and the rule // is the shader's own: the atlas' u runs along the cell's +x axis (`ux` here, // `a_axis` there) and its v along the perpendicular (`v_axis`), which is what // `v_uv = a_uv + corner * u_glyph_uv` maps corner by corner. auto texel_centre = [&](plot::f32 col, plot::f32 row, plot::Vec2 v_axis) { const plot::f32 du = (col + 0.5f) * px; const plot::f32 dv = (row + 0.5f) * px; return plot::Vec2{pen.x + ux.x * du + v_axis.x * dv, pen.y + ux.y * du + v_axis.y * dv}; }; auto inked = [&](plot::Vec2 at) { plot::u8 pxv[4] = {}; PLOT_CHECK(ctx.read_pixel(static_cast(at.x), ctx.height - 1u - static_cast(at.y), pxv)); return pxv[0] >= 96u; // white tint over black: the pixel *is* the coverage }; PLOT_CHECK(draw_text_run(ctx, atlas, font, "L", pen, px, ux).any()); // The stem (cell columns 1-2, rows 3-12) and the foot (columns 2-6, rows 11-12) // put ink at these texels ... PLOT_CHECK(inked(texel_centre(2.0f, 4.0f, uy))); // stem, fully covered PLOT_CHECK(inked(texel_centre(2.0f, 11.0f, uy))); // stem meets the foot PLOT_CHECK(inked(texel_centre(5.0f, 11.0f, uy))); // the foot's far end // ... and none at these, which are the cell's blank margin: a shader that // swapped the cell's two axes would put the ink in the wrong half and fail both // sides. PLOT_CHECK(!inked(texel_centre(2.0f, 0.0f, uy))); // above the first row of ink PLOT_CHECK(!inked(texel_centre(6.0f, 4.0f, uy))); // right of the stem at cap height PLOT_CHECK(!inked(texel_centre(0.0f, 4.0f, uy))); // the cell's empty first column // The mirrored perpendicular -- the other basis a shader could build -- would // draw the whole cell on the opposite side of the pen, where this frame has no // ink at all. const plot::Vec2 mirrored{-uy.x, -uy.y}; PLOT_CHECK(!inked(texel_centre(2.0f, 4.0f, mirrored))); PLOT_CHECK(!inked(texel_centre(5.0f, 11.0f, mirrored))); ctx.destroy(); } // The atlas cache is keyed on the backend's revision, and every change counts. A // backend re-baked at another size with the ink moved to the other side of its cell // must reach the screen: the second frame's ink is where only the new atlas has it, // and a stale texture -- what a cache keyed on a flag set at init would leave in // place -- cannot produce it, because its quad is the old size and does not even // reach that far. The last step drives the revision *backwards* to pin the other // half of the rule: the cache compares revisions, it does not require them to grow. PLOT_TEST(atlas_is_reuploaded_when_the_backend_revision_changes) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); const plot::Color white = plot::Color::hex(0xFFFFFF); const plot::Vec2 pen{120.0f, 120.0f}; const plot::f32 px = 4.0f; BakeableBackend backend; // 8x8 atlas, ink on the left half plot::render::TextAtlas atlas; auto inked = [&](plot::f32 x, plot::f32 y) { plot::u8 pxv[4] = {}; PLOT_CHECK(ctx.read_pixel(static_cast(x), ctx.height - 1u - static_cast(y), pxv)); return pixel_is(pxv, white); }; // An 8x8 atlas at scale 4: a 32x32 quad, inked on its left half. PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any()); PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6); PLOT_CHECK(atlas.revision() == 1u); PLOT_CHECK(inked(pen.x + 8.0f, pen.y + 16.0f)); PLOT_CHECK(!inked(pen.x + 24.0f, pen.y + 16.0f)); // Re-baked at 16x16 with the ink on the right, revision bumped: a 64x64 quad // whose ink is beyond the old quad's right edge. A stale 8x8 atlas would leave // the left half inked and could not reach x + 48 at all. backend.bake(16u, true); PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any()); PLOT_CHECK_NEAR(atlas.texel_size().x, 16.0, 1e-6); PLOT_CHECK(atlas.revision() == 2u); PLOT_CHECK(!inked(pen.x + 8.0f, pen.y + 16.0f)); PLOT_CHECK(inked(pen.x + 48.0f, pen.y + 32.0f)); // Back to an 8x8 atlas, but with a revision *below* the one on the GPU: still a // change, so the upload happens and the quad is 32x32 again with ink on the // right -- which is where the 16x16 atlas had none. backend.bake(8u, true); backend.set_revision(0u); PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any()); PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6); PLOT_CHECK(atlas.revision() == 0u); PLOT_CHECK(!inked(pen.x + 8.0f, pen.y + 16.0f)); PLOT_CHECK(inked(pen.x + 24.0f, pen.y + 16.0f)); ctx.destroy(); } // "No text" is a legal backend, not a failure: a null atlas, or one with no // size, must sync cleanly, leave the atlas owner empty, and be *replaced* on the // GPU as soon as the backend publishes one -- including by releasing the texture // a draw would otherwise still be sampling. PLOT_TEST(a_backend_without_an_atlas_uploads_nothing) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); BakeableBackend backend; // its constructor bakes an 8x8 atlas plot::render::TextAtlas atlas; PLOT_CHECK(atlas.sync(backend, &st)); PLOT_CHECK(!atlas.empty()); // The backend now publishes no atlas at all. The uploader has to let go of the // texture it was holding -- merely recording the new revision would leave a // draw sampling glyphs the backend no longer stands behind, and dividing by a // zero texture size doing it. backend.drop_atlas(); PLOT_CHECK(backend.atlas_pixels() == nullptr); PLOT_CHECK(atlas.sync(backend, &st)); // legal: nothing to upload PLOT_CHECK(atlas.empty()); PLOT_CHECK_NEAR(atlas.texel_size().x, 0.0, 1e-6); PLOT_CHECK_NEAR(atlas.texel_size().y, 0.0, 1e-6); // And the same when the backend keeps claiming a size but publishes nothing. BakeableBackend zero_sized; plot::render::TextAtlas zero_atlas; zero_sized.bake(0u, false); PLOT_CHECK(zero_sized.atlas_width() == 0u); PLOT_CHECK(zero_atlas.sync(zero_sized, &st)); PLOT_CHECK(zero_atlas.empty()); // A backend that starts publishing an atlas announces it with a revision bump, // so the upload happens here rather than being skipped as "nothing changed". backend.bake(8u, true); PLOT_CHECK(atlas.sync(backend, &st)); PLOT_CHECK(!atlas.empty()); PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6); PLOT_CHECK(atlas.revision() == backend.revision()); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } // The fixed-cell check itself, in the build where it is fatal. A backend whose // glyph rects are ink boxes (see `TextBackend::cell_uv`) would draw every glyph at // the size the draw was told, reading past its own rows of the atlas -- so the // staging path asserts `glyph_is_cell()` and this case pins that it does, not // merely that the predicate can fail. // // The assertion's death is the observable, and the only protocol for observing a // death is a child process: staging this glyph in-process would take the suite // with it. The child allocates nothing before it dies -- the check comes before // the first `push_back` -- and its own `assert` line lands on stderr above this // case's line, which is the evidence for a human reading the log. PLOT_TEST(staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build) { #ifndef NDEBUG BakeableBackend backend; backend.trim_glyph_rect(0.5f); // half a cell: no longer the declared cell PLOT_CHECK(!plot::render::glyph_is_cell(*backend.glyph(0u), backend.cell_uv())); std::fflush(nullptr); const pid_t child = ::fork(); PLOT_CHECK(child >= 0); if (child == 0) { plot::render::TextBatch batch; batch.begin(); batch.add_text("X", {0.0f, 0.0f}, 1.0f, backend); // dies here, in debug ::_exit(0); // reached only when the check did not fire } int status = 0; PLOT_CHECK(::waitpid(child, &status, 0) == child); PLOT_CHECK(WIFSIGNALED(status)); // it did not return PLOT_CHECK(WTERMSIG(status) == SIGABRT); // and it was the assertion #endif } // The loader invariant (see `plot::gl::loader.hpp`): while the entry-point table // is unresolved no `gl*` call may be made at all, and `TextAtlas` is public, so a // host may hold one and sync it before `plot::gl::load()` -- Tasks 2 and 8 build // backends on this class. Every `gl*` call it makes is therefore gated, and a sync // in that state reports the same `ErrorCode::Unsupported` every fallible // `plot::gl` handle constructor reports, instead of calling a null pointer. // // `available()` itself cannot be driven false in this suite: `load()` is // process-global and one-way (no API makes the table unresolved again) and it // falls back to `dlsym` on the system GL library, so there is no state a test can // restore the process to -- faking it would poison every later case's GL. What is // reachable is the same guard from the entry point it protects: with the first // entry point it calls missing, the sync must report and leave the atlas empty. PLOT_TEST(text_atlas_reports_a_status_when_the_entry_points_are_missing) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); PLOT_CHECK(plot::gl::available()); plot::render::EmbeddedMonoFont font; plot::render::TextAtlas atlas; { RefuseGetIntegerv refused; PLOT_CHECK(!atlas.sync(font, &st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(st.message != nullptr); PLOT_CHECK(atlas.empty()); PLOT_CHECK_NEAR(atlas.texel_size().x, 0.0, 1e-6); PLOT_CHECK_NEAR(atlas.texel_size().y, 0.0, 1e-6); } // With the table back the same atlas uploads, so the refusal was the missing // entry point and not a poisoned owner, and the retry is what a caller does with // the Status it got. PLOT_CHECK(atlas.sync(font, &st)); PLOT_CHECK(!atlas.empty()); PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast(font.atlas_width()), 1e-6); PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast(font.atlas_height()), 1e-6); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } /// The legend's two cases need a count, not a presence: "the swatch colour appears" /// is satisfied by the trace that carries the same colour, and "some ink is in the /// panel" is satisfied by any label the frame happens to put there. Counting the /// pixels of an area that are exactly `c` (see `pixel_is`) is what turns both into /// statements about the swatch. plot::u32 exact_pixels(const std::vector& frame, plot::u32 fb_w, const plot::Rect& area, plot::Color c) { plot::u32 count = 0; 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) { if (pixel_is(&frame[(plot::usize(y) * fb_w + x) * 4u], c)) ++count; } } return count; } /// True when any row of the column at `x` inside `axes` holds a pixel the predicate /// accepts. The overlay strokes are blended over whatever is under them, so for them a /// presence test is the honest one, and it is measured in the axes' own rows rather /// than over the whole framebuffer: a bound is the axes' height by construction. bool column_has(plottest::HeadlessContext& ctx, plot::u32 x, const plot::Rect& axes, bool (*pred)(const plot::u8*)) { for (plot::u32 row = 0; row < static_cast(axes.h); ++row) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast(axes.y) - row, px)); if (pred(px)) return true; } return false; } /// The whole framebuffer as a Rect, for the counts that ask "and nowhere else". const plot::Rect kFrameRect{0.0f, 0.0f, static_cast(kW), static_cast(kH)}; /// The pixels of `area` a predicate accepts. `exact_pixels` above answers "the /// colour only this element can produce"; this one answers the same question for an /// element that is *blended* -- a translucent region fill, an anti-aliased stroke -- /// where the test has to name the pattern rather than the colour. plot::u32 predicate_pixels(const std::vector& frame, plot::u32 fb_w, const plot::Rect& area, bool (*pred)(const plot::u8*)) { plot::u32 count = 0; 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) { if (pred(&frame[(plot::usize(y) * fb_w + x) * 4u])) ++count; } } return count; } /// The legend's entries are drawn where `Plot::layout()` measured them: a filled /// swatch per trace in that trace's own colour, and the name's ink inside the panel /// and nowhere else in the data area -- and the panel's presence changes nothing /// outside itself. /// /// The swatch colour is what makes the count a test. Both traces here are translucent /// (alpha 0.25), while the swatch is drawn opaque, so the exact colour of a trace is /// one that only a swatch can produce: the trace blended over the background -- or /// over a grid line, which is brighter -- never reaches it, and neither do the grid, /// the axis line or the text. Two colours are used so each entry's swatch has to be /// its own trace's, not whichever colour the legend drew for everything. PLOT_TEST(legend_swatch_and_label_are_drawn) { 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); // The second trace is a series of its own, well clear of the first: two // translucent strokes stacked over a grid line reach a brightness the `axis_text` // ink predicate below cannot tell from a label, and the "no name ink in the data // area" half of this case has to be about the legend's ink only. plot::data::ArraySeries flat(std::vector(64u, 0.75f), 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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); const plot::Color green = plot::Color::hex(0x00E070, 0.25f); const plot::Color pink = plot::Color::hex(0xFF3CC8, 0.25f); PLOT_CHECK(p.add_trace({&s, green, 1.5f, ax, ay, "signal"}) == 0u); PLOT_CHECK(p.add_trace({&flat, pink, 1.5f, ax, ay, "noise"}) == 1u); p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame 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, "legend.ppm"); // A copy: the second draw below clears the rows this layout holds. const plot::PlotLayout layout = p.layout(); const plot::LegendLayout legend = layout.legend; PLOT_CHECK(legend.rows.size() == 2u); const plot::Legend& cfg = p.legend(); const plot::u32 swatch_area = static_cast(cfg.swatch_w) * static_cast(cfg.swatch_h); const std::vector frame = read_frame(ctx); // Each swatch: its own row's swatch box holds the pixels, and every pixel of that // colour in the whole frame is one of them. The lower bound is the box's area // minus the fringe a 1 px stroke column cannot promise -- a row and a column at // each edge -- and the upper bound lets the outermost columns and rows count // where the fill's edge lands a hair inside them. const plot::Color opaque[2] = {plot::Color{green.r, green.g, green.b, 1.0f}, plot::Color{pink.r, pink.g, pink.b, 1.0f}}; for (plot::u32 i = 0; i < legend.rows.size(); ++i) { const plot::Rect& box = legend.rows[i].swatch; PLOT_CHECK(box.w > 0.0f && box.h > 0.0f); PLOT_CHECK(box.x >= legend.panel.x && box.right() <= legend.panel.right()); PLOT_CHECK(box.y >= legend.panel.y && box.bottom() <= legend.panel.bottom()); // The row names its own trace, so the colour under the predicate is that // trace's -- which is what `LegendRow::trace` is for. PLOT_CHECK_NEAR(p.trace(legend.rows[i].trace).color.r, opaque[i].r, 1e-6); const plot::u32 inside = exact_pixels(frame, kW, box, opaque[i]); const plot::u32 anywhere = exact_pixels(frame, kW, kFrameRect, opaque[i]); const plot::u32 in_panel = exact_pixels(frame, kW, legend.panel, opaque[i]); // The swatch's colour fills the swatch's box -- within the row and column an // edge's coverage ramp can reach past it -- and appears nowhere else in the // frame: not over the trace that carries the same colour at 25%, not in the // grid, and not outside the panel, which is where this ink's whole budget is. PLOT_CHECK(inside >= swatch_area - 2u * static_cast(cfg.swatch_w) - 2u * static_cast(cfg.swatch_h)); PLOT_CHECK(anywhere <= swatch_area + 2u * static_cast(cfg.swatch_w) + 2u * static_cast(cfg.swatch_h)); PLOT_CHECK(anywhere == in_panel); // The numbers behind those assertions, so a run says what it measured rather than // only that it passed. std::printf("[legend] row %u trace %u swatch %.2f,%.2f %.2fx%.2f: %u pixels of its colour, " "%u in the frame\n", i, legend.rows[i].trace, box.x, box.y, box.w, box.h, inside, anywhere); } // The names: real glyph ink inside the panel, and none in the data area outside it. // This plot has no cursor, no measurement and no annotation, and every other // `axis_text` run -- the tick labels, the titles -- is drawn in a strip outside // `axes`, so the only ink in the data area is the legend's. const plot::Color ink = r.style().axis_text; const plot::u32 in_panel = scan_rect_ink(ctx, legend.panel, ink).pixels; // The data area, inset past the axis lines: they run along the axes' edges and are // brighter than a third of `axis_text` -- the trap `rect_has_exact` exists for -- so // an un-inset scan would be counting the axis rather than a label. const plot::Rect data{layout.axes.x + 2.0f, layout.axes.y + 2.0f, layout.axes.w - 4.0f, layout.axes.h - 4.0f}; const plot::u32 in_data = scan_rect_ink(ctx, data, ink).pixels; PLOT_CHECK(in_panel >= 32u); // two names, not one stray anti-aliased pixel PLOT_CHECK(in_data == in_panel); // and every bit of that ink is the legend's // The numbers behind those assertions, so a run says what it measured rather than // only that it passed. std::printf("[legend] panel %.2f,%.2f %.2fx%.2f, %zu rows; name ink %u in the panel, %u in the " "data area\n", legend.panel.x, legend.panel.y, legend.panel.w, legend.panel.h, legend.rows.size(), in_panel, in_data); // The panel changes nothing outside itself: the same plot drawn with the legend // switched off -- same traces, same axes rect, same transforms, since a legend // reserves none of the plot -- must come out pixel-identical everywhere except // inside the panel. One pixel of slack on each side, because a fill's stroke // column lets its fringe reach a pixel past the panel's edge. plot::Legend off = cfg; off.visible = false; p.set_legend(off); 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 std::vector bare = read_frame(ctx); const plot::Rect changed{legend.panel.x - 1.0f, legend.panel.y - 1.0f, legend.panel.w + 2.0f, legend.panel.h + 2.0f}; PLOT_CHECK(changed.x >= layout.axes.x && changed.right() <= layout.axes.right()); PLOT_CHECK(changed.bottom() <= layout.axes.bottom()); // so "inside changed" is inside axes plot::u32 differing = 0; for (plot::u32 y = 0; y < ctx.height; ++y) { for (plot::u32 x = 0; x < ctx.width; ++x) { const plot::usize at = (plot::usize(y) * kW + x) * 4u; if (frame[at] == bare[at] && frame[at + 1u] == bare[at + 1u] && frame[at + 2u] == bare[at + 2u] && frame[at + 3u] == bare[at + 3u]) { continue; } ++differing; PLOT_CHECK(changed.contains(static_cast(x) + 0.5f, static_cast(y) + 0.5f)); } } // The comparison is only evidence if the legend was in the first frame at all. PLOT_CHECK(differing > 0u); ctx.destroy(); } /// The bottom-left corner is the one a legend anchored from the axes' *opposite* /// corner would miss: the panel has to be down there, inside `axes` and held off the /// two edges that corner is near, with its swatch in the panel and nowhere else -- /// including not in the strip below the axes, where the tick labels live. PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) { 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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); // Translucent, as in the case above: the swatch is drawn opaque, so this colour is // the swatch's alone. const plot::Color teal = plot::Color::hex(0x00E070, 0.25f); p.add_trace({&s, teal, 1.5f, ax, ay, "signal"}); p.set_x_range(ax, {0.0, 640.0}); plot::Legend cfg = p.legend(); cfg.place = plot::LegendPlace::BottomLeft; p.set_legend(cfg); 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, "legend_bottom_left.ppm"); const plot::PlotLayout layout = p.layout(); const plot::LegendLayout& legend = layout.legend; PLOT_CHECK(legend.rows.size() == 1u); const plot::Rect& panel = legend.panel; PLOT_CHECK_NEAR(panel.x, layout.axes.x + cfg.margin, 1e-4); PLOT_CHECK_NEAR(panel.bottom(), layout.axes.bottom() - cfg.margin, 1e-4); PLOT_CHECK(panel.right() <= layout.axes.right()); PLOT_CHECK(panel.bottom() <= layout.axes.bottom()); const std::vector frame = read_frame(ctx); const plot::Color opaque{teal.r, teal.g, teal.b, 1.0f}; const plot::u32 in_panel = exact_pixels(frame, kW, panel, opaque); PLOT_CHECK(in_panel >= static_cast(cfg.swatch_w - 2.0f) * static_cast(cfg.swatch_h - 2.0f)); // Every pixel of the swatch colour is inside the panel: none in the x label strip // below the axes, none in the y strip, none anywhere else in the frame. PLOT_CHECK(exact_pixels(frame, kW, kFrameRect, opaque) == in_panel); const plot::Rect below{0.0f, layout.axes.bottom(), static_cast(kW), static_cast(kH) - layout.axes.bottom()}; PLOT_CHECK(exact_pixels(frame, kW, below, opaque) == 0u); // ... with the name drawn beside it. const plot::u32 name_ink = scan_rect_ink(ctx, panel, r.style().axis_text).pixels; PLOT_CHECK(name_ink >= 16u); std::printf("[legend] bottom-left panel %.2f,%.2f %.2fx%.2f; swatch %u pixels in the panel, %u " "in the frame, %u below the axes; name ink %u\n", panel.x, panel.y, panel.w, panel.h, in_panel, exact_pixels(frame, kW, kFrameRect, opaque), exact_pixels(frame, kW, below, opaque), name_ink); ctx.destroy(); } // --- one physical size at any DPI -------------------------------------------- namespace { /// True for a pixel holding the axis line's chalk grey at a usable coverage: the line /// is `axis_line` (0x8A94A6) over the frame's dark background, so a third of the /// line's own level per channel is a pixel the stroke lifted off the background -- /// `text_ink`'s coverage test, with a tone test on top that no blue trace and no dark /// grid line satisfies. The tick labels are the same chalk family, which is why the /// scans below keep clear of the label strip's own columns. bool axis_line_pixel(const plot::u8 px[4], plot::Color c) { const auto lifted = [](plot::u8 got, plot::f32 want) { return static_cast(got) >= want * 255.0f * 0.33f; }; const bool grey = static_cast(px[2]) - static_cast(px[0]) < 40; return lifted(px[0], c.r) && lifted(px[1], c.g) && lifted(px[2], c.b) && grey; } /// The chrome one render reports, in **device** pixels. Both runs of the two-scale /// tests below fill this from a frame they really drew, so a comparison of the two /// figures is a comparison of two frames and not of two derivations of one rule. struct ChromeFigures { plot::f32 font_cell = 0.0f; ///< the on-screen text cell the frame drew with plot::f32 y_label_w = 0.0f; ///< the reserved label strip plot::f32 x_label_h = 0.0f; plot::f32 y_title_w = 0.0f; plot::f32 legend_w = 0.0f; plot::f32 legend_h = 0.0f; plot::f32 axes_w = 0.0f; plot::f32 axes_h = 0.0f; plot::u32 ink_run = 0u; ///< the widest run of label ink in the y label strip /// Every figure above in logical units, i.e. divided by the scale the frame was /// drawn at: the numbers that must agree across displays. ChromeFigures logical(plot::f32 scale) const noexcept; }; ChromeFigures ChromeFigures::logical(plot::f32 scale) const noexcept { ChromeFigures l; l.font_cell = font_cell / scale; l.y_label_w = y_label_w / scale; l.x_label_h = x_label_h / scale; l.y_title_w = y_title_w / scale; l.legend_w = legend_w / scale; l.legend_h = legend_h / scale; l.axes_w = axes_w / scale; l.axes_h = axes_h / scale; l.ink_run = static_cast(static_cast(ink_run) / scale + 0.5f); return l; } } // namespace /// The requirement this task exists for, measured rather than asserted by eye: the /// same logical plot drawn on a 1x display and on a 2x one. One framebuffer holds /// both passes -- a 1x plot is a 640x360 viewport in it, a 2x plot the whole thing, /// exactly as an adapter embeds a plot in a widget -- so the two frames differ only /// in `ui_scale` and in the device size that goes with it. /// /// The device figures must double. The logical ones -- the same figures divided by /// the scale -- must be equal, which is what "the same physical size on any display" /// means: the labels, strips, title column and legend occupy the same share of the /// panel at both DPI, and each axis label is drawn with twice the pixels of ink. PLOT_TEST(logical_metrics_are_dpi_independent) { 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)); // The axes' border line runs down the label strip's right edge and is the same // chalk-toned colour family as the text, so it is drawn in the background colour // here: the only ink inside the strip is then the labels themselves. r.style().axis_line = r.style().background; std::vector ys(256); for (plot::u64 i = 0; i < ys.size(); ++i) { ys[i] = static_cast(std::sin(static_cast(i) * 0.05)); } plot::data::ArraySeries s(std::move(ys), 0.0, 1.0); const auto figures_at = [&](plot::f32 scale) { const plot::u32 w = static_cast(scale * static_cast(kW)); const plot::u32 h = static_cast(scale * static_cast(kH)); const plot::Rect vp{0.0f, 0.0f, static_cast(w), static_cast(h)}; plot::Plot p; p.set_viewport(vp); const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"}); r.style().ui_scale = scale; // The framebuffer stays the big one for both runs: the plot occupies the top-left // `vp` of it, which is where a sub-rect viewport puts it (and what the adapters // pass), so the same readback arithmetic reads both. r.begin_frame(kBigW, kBigH, vp); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); // The layout the renderer drew is the one the renderer laid out: `draw()` sets // the plot's scale before `layout()`, so this is the propagation being tested. PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6); PLOT_CHECK_NEAR(p.text_backend()->height(), r.axis_font_px(), 1e-6); const plot::PlotLayout& lay = p.layout(); ChromeFigures f; f.font_cell = r.axis_font_px(); f.y_label_w = lay.y_label_area.w; f.x_label_h = lay.x_label_area.h; f.y_title_w = lay.y_title_area.w; f.legend_w = lay.legend.panel.w; f.legend_h = lay.legend.panel.h; f.axes_w = lay.axes.w; f.axes_h = lay.axes.h; const TextInk ink = scan_text_ink(ctx, static_cast(lay.y_label_area.x), static_cast(lay.y_label_area.right()), static_cast(lay.y_label_area.y), static_cast(lay.y_label_area.h), r.style().axis_text); f.ink_run = ink.widest_row_run; return f; }; const ChromeFigures one = figures_at(1.0f); const ChromeFigures two = figures_at(2.0f); const plot::f32 k = 2.0f; // Each run is normalised by *its own* scale -- the logical figures both displays // must agree on. const ChromeFigures one_l = one.logical(1.0f); const ChromeFigures two_l = two.logical(k); // The device figures double: the same chrome, twice the pixels. PLOT_CHECK_NEAR(two.font_cell, k * one.font_cell, 1e-3f); PLOT_CHECK_NEAR(two.y_label_w, k * one.y_label_w, 0.02f); PLOT_CHECK_NEAR(two.x_label_h, k * one.x_label_h, 0.02f); PLOT_CHECK_NEAR(two.y_title_w, k * one.y_title_w, 0.02f); PLOT_CHECK_NEAR(two.legend_w, k * one.legend_w, 0.02f); PLOT_CHECK_NEAR(two.legend_h, k * one.legend_h, 0.02f); PLOT_CHECK_NEAR(two.axes_w, k * one.axes_w, 0.02f); PLOT_CHECK_NEAR(two.axes_h, k * one.axes_h, 0.02f); // ...and the logical figures agree, which is the requirement itself: a 640x360 // panel and a 1280x720 one show the same logical plot. PLOT_CHECK_NEAR(two_l.font_cell, one_l.font_cell, 5e-4f); PLOT_CHECK_NEAR(two_l.y_label_w, one_l.y_label_w, 0.01f); PLOT_CHECK_NEAR(two_l.x_label_h, one_l.x_label_h, 0.01f); PLOT_CHECK_NEAR(two_l.y_title_w, one_l.y_title_w, 0.01f); PLOT_CHECK_NEAR(two_l.legend_w, one_l.legend_w, 0.01f); PLOT_CHECK_NEAR(two_l.legend_h, one_l.legend_h, 0.01f); PLOT_CHECK_NEAR(two_l.axes_w, one_l.axes_w, 0.01f); PLOT_CHECK_NEAR(two_l.axes_h, one_l.axes_h, 0.01f); // The ink is the pixels' own account: one drawn label occupies twice the device // extent, and the same extent once normalised -- so the doubling above is drawn // and not merely reserved. PLOT_CHECK(one.ink_run > 8u); // labels really were drawn PLOT_CHECK_NEAR(static_cast(two.ink_run), k * static_cast(one.ink_run), 2.0f); PLOT_CHECK(two_l.ink_run <= one_l.ink_run + 1u); PLOT_CHECK(two_l.ink_run + 1u >= one_l.ink_run); std::printf("[ui_scale] 1x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u;" " 2x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u; logical" " 1x %.2f/%.2f/%.2f/%.2fx%.2f/%u vs 2x %.2f/%.2f/%.2f/%.2fx%.2f/%u\n", one.font_cell, one.y_label_w, one.x_label_h, one.y_title_w, one.legend_w, one.legend_h, one.ink_run, two.font_cell, two.y_label_w, two.x_label_h, two.y_title_w, two.legend_w, two.legend_h, two.ink_run, one_l.font_cell, one_l.y_label_w, one_l.x_label_h, one_l.y_title_w, one_l.legend_h, one_l.ink_run, two_l.font_cell, two_l.y_label_w, two_l.x_label_h, two_l.y_title_w, two_l.legend_h, two_l.ink_run); ctx.destroy(); } /// `dense_threshold_spp` is a **density** -- samples per rasterised pixel -- and not a /// size, so the display's scale must not move it. This is the failure mode on the /// over-eager side: scaling the threshold with the panel would call the LOD pyramid /// less readily at 2x, so the same data would be rasterised by a different path /// depending on the display. /// /// The sample count grows with the device width, so both runs present the same /// samples-per-device-pixel: if the threshold were scaled, the 2x run would fall back /// to raw samples while the 1x one still took the pyramid. The threshold is set to a /// value between them so the test can tell the two apart at all -- at the default 1.5 /// the comparison is decided far from the threshold and any scale below 64x would be /// invisible (`level_for` returns level 0 -- the same raw gather -- until a pixel /// covers 64 samples). PLOT_TEST(dense_threshold_stays_a_device_ratio) { constexpr plot::u32 kBigW = 2u * kW; constexpr plot::u32 kBigH = 2u * kH; constexpr plot::f32 kThreshold = 1500.0f; 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)); r.style().dense_threshold_spp = kThreshold; const auto series_of = [](plot::u64 n) { std::vector y(n); for (plot::u64 i = 0; i < n; ++i) { y[i] = static_cast(std::sin(static_cast(i) * 0.0001)); } return y; }; struct Decision { plot::u32 lod = 0u; plot::u32 raw = 0u; plot::f64 spp = 0.0; ///< samples per *device* pixel the frame was drawn at plot::u32 level = 0u; ///< the pyramid level that density selects }; const auto decision_at = [&](plot::f32 scale) { const plot::u32 w = static_cast(scale * static_cast(kW)); const plot::u32 h = static_cast(scale * static_cast(kH)); plot::data::ArraySeries s(series_of(static_cast(scale) * 1'000'000u), 0.0, 1.0); plot::Plot p; p.set_viewport({0.0f, 0.0f, static_cast(w), static_cast(h)}); 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({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); r.style().ui_scale = scale; r.begin_frame(kBigW, kBigH, {0.0f, 0.0f, static_cast(w), static_cast(h)}); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); Decision d; d.lod = r.stats().lod_traces; d.raw = r.stats().raw_traces; // What the renderer actually drew at: the samples its gathered window covered // over the *device* width of the axes it laid out. Recomputed here rather than // taken on trust, so a spp divided by the logical width instead would show up as // a doubled density and a different level. const plot::f32 axes_w = p.layout().axes.w; PLOT_CHECK(axes_w > 0.0f); d.spp = static_cast(r.stats().visible_samples) / static_cast(axes_w); d.level = s.pyramid().level_for(s.size(), d.spp); return d; }; const Decision one = decision_at(1.0f); const Decision two = decision_at(2.0f); // The renderer took the pyramid at both scales... PLOT_CHECK(one.lod == 1u && one.raw == 0u); PLOT_CHECK(two.lod == 1u && two.raw == 0u); // ...and the density that decision rests on is the same in device terms at both // scales, which is what makes the level below identical rather than merely non-zero. PLOT_CHECK_NEAR(two.spp, one.spp, 0.5); PLOT_CHECK(one.spp > static_cast(kThreshold)); // not vacuous: over the line PLOT_CHECK(one.level > 0u); // and a level, not the raw path PLOT_CHECK(two.level == one.level); std::printf("[dense] 1x spp %.1f -> level %u (lod %u raw %u); 2x spp %.1f -> level %u" " (lod %u raw %u); threshold %.1f\n", one.spp, one.level, one.lod, one.raw, two.spp, two.level, two.lod, two.raw, static_cast(kThreshold)); ctx.destroy(); } /// A trace's stroke is a chrome size like the axis lines', so the *weight* it is drawn /// with -- and with it the trace/axis hierarchy a reader sees -- must not depend on the /// display. `Trace::width` reaches the same `flush_lines()` the axis lines do, so it has /// to go through the same `Style::device()` conversion; left raw, a 1.5 px trace is 1.5 /// *device* px against a 3 device px axis line at scale 2. /// /// The measurement is the ink a fixed-size **device** rectangle holds over a flat trace /// (so the count is the stroke's width times a constant length, and cleaning up the /// anti-aliasing costs a fraction of a pixel rather than the whole figure), plus the /// same count over the y axis line. The assertion is the ratio between them: it is 1 at /// both scales when both strokes are converted, and it halves at scale 2 when the trace /// alone is not. PLOT_TEST(trace_stroke_weight_is_logical) { constexpr plot::u32 kBigW = 2u * kW; constexpr plot::u32 kBigH = 2u * kH; /// The width both strokes are given, in logical pixels: equal on purpose, so the /// trace/axis ratio the case pins is 1 rather than some arbitrary figure. constexpr plot::f32 kWidth = 3.0f; 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)); r.style().line_width_px = kWidth; // the axis lines and cursor markers // A flat trace, so every column of the scan crosses it exactly once and the ink // count is its width times the scan's length. y = 0.5 on a [0,1] axis is not a tick, // so no grid line runs along it. std::vector flat(64, 0.5f); plot::data::ArraySeries s(std::move(flat), 0.0, 1.0); /// The device ink of both strokes at one scale: pixels of trace colour in a /// 200x8 px box over the trace, and axis-line pixels in a 6x200 px box over the y /// axis line. Both boxes are the same *device* size at every scale, which is what /// makes the count proportional to the stroke's width alone. struct StrokeInk { plot::u32 trace = 0u; plot::u32 axis = 0u; }; const auto ink_at = [&](plot::f32 scale) { const plot::u32 w = static_cast(scale * static_cast(kW)); const plot::u32 h = static_cast(scale * static_cast(kH)); plot::Plot p; p.set_viewport({0.0f, 0.0f, static_cast(w), static_cast(h)}); const plot::u32 ax = p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), kWidth, ax, ay}); r.style().ui_scale = scale; r.begin_frame(kBigW, kBigH, {0.0f, 0.0f, static_cast(w), static_cast(h)}); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); const plot::PlotLayout& lay = p.layout(); // Generic over the predicate: the two strokes are recognised by different rules // (the trace by blue dominance, the axis line by its chalk grey). const auto count = [&](plot::u32 x0, plot::u32 y0, plot::u32 bw, plot::u32 bh, auto pred) { plot::u32 hits = 0; for (plot::u32 y = y0; y < y0 + bh; ++y) { for (plot::u32 x = x0; x < x0 + bw; ++x) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, kBigH - 1u - y, px)); if (pred(px)) ++hits; } } return hits; }; StrokeInk ink; // The trace: a fixed device box inside the axes, centred on the stroke's row. const plot::u32 ty = static_cast(p.y_transform(ay).to_screen_y(0.5)); const plot::u32 tx0 = static_cast(lay.axes.x) + 40u; ink.trace = count(tx0, ty - 4u, 200u, 8u, [](const plot::u8* px) { return trace_pixel(px); }); // The axis line: two columns left of it, so the tick labels' own ink (which ends // `grid_label_pad` short of the line) is outside the scan at both scales. const plot::Color line = r.style().axis_line; ink.axis = count(static_cast(lay.axes.x) - 2u, static_cast(lay.axes.y) + 40u, 6u, 200u, [line](const plot::u8* px) { return axis_line_pixel(px, line); }); return ink; }; const StrokeInk one = ink_at(1.0f); const StrokeInk two = ink_at(2.0f); const plot::f32 trace_ratio = static_cast(two.trace) / static_cast(one.trace); const plot::f32 axis_ratio = static_cast(two.axis) / static_cast(one.axis); std::printf("[trace] 1x trace %u axis %u; 2x trace %u axis %u; ratios %.3f / %.3f ->" " weight ratio %.3f\n", one.trace, one.axis, two.trace, two.axis, static_cast(trace_ratio), static_cast(axis_ratio), static_cast(trace_ratio / axis_ratio)); // Not vacuous: the trace really is in the box (and the axis line in its own). PLOT_CHECK(one.trace > 100u); PLOT_CHECK(one.axis > 100u); // The same logical width at twice the scale is twice the device ink -- for the trace // exactly as for the axis line it is drawn against. A trace left in device pixels // fails this: its ink count is the same at both scales. PLOT_CHECK_NEAR(trace_ratio, 2.0f, 0.35f); PLOT_CHECK_NEAR(axis_ratio, 2.0f, 0.35f); 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 pixels; ///< the whole framebuffer, top-down plot::u32 fb_w = 0u; plot::PlotLayout layout; std::vector x_ticks; std::vector 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(scale * static_cast(kW)); const plot::u32 h = static_cast(scale * static_cast(kH)); const plot::Rect vp{0.0f, 0.0f, static_cast(w), static_cast(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 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(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(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(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(window.y); y < static_cast(window.bottom()); ++y) { for (plot::u32 x = static_cast(window.x); x < static_cast(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(major_before), static_cast(major_after), static_cast(minor_before), static_cast(minor_after), static_cast(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(major_before), static_cast(major_after), static_cast(other_after), static_cast(minor_before), static_cast(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(major_1x), static_cast(minor_1x), static_cast(ratio_1x), static_cast(major_2x), static_cast(minor_2x), static_cast(major_2x / minor_2x), static_cast(r.style().device(r.style().grid_major_width)), static_cast(r.style().device(r.style().grid_minor_width)), static_cast(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(); } // --- regions: the fill, the bounds and the emphasized handle (spec §5) --------- namespace { /// Green dominance: what the region's translucent `0x33FF00` fill leaves over the dark /// background, over a grey grid line and over the axis line, and what an opaque /// `0x00FF00` marker leaves at any coverage. Nothing else in these frames is /// green-dominant -- the trace is blue (`g - b < 0`), the chrome and the text are /// grey/chalk (`r == g == b`), the bounds are yellow (`r == g > b`) -- so a count of /// these pixels is a statement about the fill (or the marker) and about nothing else. /// Conservative on purpose: the fill's own half-pixel fringe at the top and bottom rows /// is not counted. bool green_dominant(const plot::u8 px[4]) { return px[1] > px[0] + 60 && px[1] > px[2] + 60; } } // namespace /// A region's fill and its two bounds are drawn from the geometry `Plot::layout()` /// measured -- the same rects the controller hit-tests -- so the rect a test asserts, the /// rect a frame paints and the column a pointer is grabbed at are one set of numbers. /// The fill shades the interval between the bounds and nothing outside it; each bound is /// one vertical stroke of the axes' height in the region's own edge colour, in its own /// column and nowhere else in the frame. PLOT_TEST(region_fill_and_edges_are_drawn_inside_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::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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame plot::Region reg; reg.x0 = 100.0; reg.x1 = 300.0; reg.snap = plot::RegionSnap::Free; // Colours no other element of the frame produces, so each count below can only be // satisfied by the element it names. reg.fill = plot::Color::hex(0x33FF00, 0.35f); // Magenta, which `magenta_pixel` already names and neither the fill, the trace, the // chrome nor the accent (0xFFB020) can produce. reg.edge = plot::Color::hex(0xFF00FF); const plot::u32 rid = p.add_region(reg); 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, "regions.ppm"); const std::vector frame = read_frame(ctx); const plot::PlotLayout layout = p.layout(); PLOT_CHECK(layout.regions.size() == 1u); const plot::RegionLayout& rl = layout.regions[0]; PLOT_CHECK(rl.region == rid); const plot::Rect axes = layout.axes; // The measured geometry is the drawn geometry: the fill spans the axes' full height // and is clipped to their columns, so it can never reach over a label strip. PLOT_CHECK(rl.fill.w > 0.0f); PLOT_CHECK(rl.fill.x >= axes.x && rl.fill.right() <= axes.right()); PLOT_CHECK_NEAR(static_cast(rl.fill.h), static_cast(axes.h), 1e-6); // ... and it is the interval the two bounds mark: its edges are the accepted columns // of the bound strokes, so the rect that is painted is the interval that is grabbed. PLOT_CHECK_NEAR(static_cast(rl.fill.x), static_cast(plot::handle_column(rl.left_handle)), 1e-4); PLOT_CHECK_NEAR(static_cast(rl.fill.right()), static_cast(plot::handle_column(rl.right_handle)), 1e-4); const plot::u32 in_fill = predicate_pixels(frame, kW, rl.fill, green_dominant); const plot::u32 anywhere = predicate_pixels(frame, kW, kFrameRect, green_dominant); const plot::u32 area = static_cast(rl.fill.w) * static_cast(rl.fill.h); PLOT_CHECK(in_fill > 0u); // The fill shades essentially the whole of its rect -- the trace and the grid it is // drawn over take a few percent -- and tints nothing anywhere else in the frame. PLOT_CHECK(in_fill >= area - area / 8u); PLOT_CHECK(anywhere == in_fill); // The bounds: one stroke per bound, drawn in the bound's own column, of the axes' // height, and nowhere else in the frame. const plot::u32 left_col = static_cast(plot::handle_column(rl.left_handle)); const plot::u32 right_col = static_cast(plot::handle_column(rl.right_handle)); PLOT_CHECK(column_has(ctx, left_col, axes, magenta_pixel)); PLOT_CHECK(column_has(ctx, right_col, axes, magenta_pixel)); PLOT_CHECK(!column_has(ctx, left_col + 25u, axes, magenta_pixel)); // the fill is not an edge PLOT_CHECK(!column_has(ctx, 0u, axes, magenta_pixel)); // Each band is the bound's own column grown by a pixel either side -- and one row // taller than the axes, because a stroke drawn to `tab.bottom()` covers the // half-pixel row a rect's truncating cast ends at. const plot::Rect left_band{static_cast(left_col) - 1.0f, axes.y, 3.0f, axes.h + 1.0f}; const plot::Rect right_band{static_cast(right_col) - 1.0f, axes.y, 3.0f, axes.h + 1.0f}; const plot::u32 left_edge = predicate_pixels(frame, kW, left_band, magenta_pixel); const plot::u32 right_edge = predicate_pixels(frame, kW, right_band, magenta_pixel); const plot::u32 all_edges = predicate_pixels(frame, kW, kFrameRect, magenta_pixel); PLOT_CHECK(left_edge >= static_cast(axes.h) / 2u); PLOT_CHECK(right_edge >= static_cast(axes.h) / 2u); PLOT_CHECK(all_edges == left_edge + right_edge); ctx.destroy(); } /// The handles a pointer is on are emphasized, and the emphasis is the plot's to draw: /// `Plot::hot_handles()` carries what the input layer's one hit test found, and the /// renderer draws that handle in the style's cursor colour and at the handle width /// `PlotLayout` sized it with. A grabbed handle keeps its emphasis for the whole /// gesture, so what is emphasized follows the *grab* rather than the pointer -- which is /// what the last frame here pins, with the pointer off the plot entirely. PLOT_TEST(grabbed_handle_is_highlighted) { 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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.set_x_range(ax, {0.0, 640.0}); plot::Region reg; reg.x0 = 100.0; reg.x1 = 300.0; reg.snap = plot::RegionSnap::Free; reg.edge = plot::Color::hex(0xFF00FF); const plot::u32 rid = p.add_region(reg); // A marker in a colour of its own, so the accent count below can only come from a // highlighted handle and never from the marker as it is drawn when it is idle. const plot::u32 cid = p.add_cursor(500.0, plot::Color::hex(0x00FF00)); plot::input::Controller c(p); c.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); const plot::Color accent = r.style().cursor; auto draw_frame = [&] { 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()); return read_frame(ctx); }; // Frame 1: the controller has seen no pointer, so nothing is hot and no handle is // emphasized -- while the bounds themselves are still drawn, in their own colour. const std::vector bare = draw_frame(); PLOT_CHECK(exact_pixels(bare, kW, kFrameRect, accent) == 0u); PLOT_CHECK(predicate_pixels(bare, kW, kFrameRect, magenta_pixel) > 0u); const plot::PlotLayout layout = p.layout(); PLOT_CHECK(layout.regions.size() == 1u); const plot::Rect tab = layout.regions[0].left_handle; // The tab grown by a pixel on each side -- and by that row plus one, because a // stroke drawn to `tab.bottom()` covers the half-pixel row the truncating cast of a // rect ends at. The window is the handle's, and the handle's alone. const plot::Rect band{tab.x - 1.0f, tab.y, tab.w + 2.0f, tab.h + 1.0f}; const plot::f32 col = plot::handle_column(tab); const plot::f32 cy = layout.axes.y + 0.5f * layout.axes.h; // Frame 2: the pointer is on the bound, so the bound is the handle being emphasized. c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false}); PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::Left); PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left); const std::vector hovered = draw_frame(); const plot::u32 hot = exact_pixels(hovered, kW, kFrameRect, accent); PLOT_CHECK(hot > 0u); // At the handle and nowhere else in the frame. PLOT_CHECK(exact_pixels(hovered, kW, band, accent) == hot); // The handle is a handle, not a hairline: the accent covers the tab's own width, // where the idle bound was drawn 1.5 logical px wide in the region's colour. PLOT_CHECK(hot >= static_cast(tab.h) * (static_cast(tab.w) - 2u)); PLOT_CHECK(hot <= static_cast(tab.h) * (static_cast(tab.w) + 2u)); PLOT_CHECK(predicate_pixels(hovered, kW, band, magenta_pixel) == 0u); // Frame 3: the bound is grabbed and dragged, then the pointer leaves the plot. The // hover is over -- `region_grab()` says so -- but the gesture is not, so the bound the // drag holds stays emphasized, at the column the drag has moved it to. c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::ResizeRegionLeft); PLOT_CHECK(c.grabbed_region() == rid); const plot::f32 moved = col + 60.0f; c.on_pointer({moved, cy, 60.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false}); c.on_pointer({moved, static_cast(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false}); PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::None); // the pointer is off the data area PLOT_CHECK(p.hot_handles().region == rid); PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left); const std::vector dragged = draw_frame(); const plot::PlotLayout moved_layout = p.layout(); const plot::Rect moved_tab = moved_layout.regions[0].left_handle; PLOT_CHECK_NEAR(static_cast(plot::handle_column(moved_tab)), static_cast(moved), 0.5); const plot::Rect moved_band{moved_tab.x - 1.0f, moved_tab.y, moved_tab.w + 2.0f, moved_tab.h + 1.0f}; const plot::u32 held = exact_pixels(dragged, kW, kFrameRect, accent); PLOT_CHECK(held > 0u); PLOT_CHECK(exact_pixels(dragged, kW, moved_band, accent) == held); // Releasing off the plot ends both the gesture and the emphasis. c.on_pointer({moved, static_cast(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true}); PLOT_CHECK(c.grabbed_region() == plot::kInvalid); PLOT_CHECK(p.hot_handles().region == plot::kInvalid); PLOT_CHECK(exact_pixels(draw_frame(), kW, kFrameRect, accent) == 0u); // The same rule for a cursor marker: hovered, it is drawn as a handle in the accent, // in its own column and nowhere else, and its own colour is gone from that column. const plot::f32 marker_col = static_cast(p.x_transform(ax).to_screen_x(p.cursor(cid).x)); c.on_pointer({marker_col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false}); PLOT_CHECK(c.hovered_cursor() == cid); PLOT_CHECK(p.hot_handles().cursor == cid); const std::vector over_marker = draw_frame(); const plot::u32 marker_hot = exact_pixels(over_marker, kW, kFrameRect, accent); const plot::Rect marker_band{marker_col - 4.0f, layout.axes.y, 9.0f, layout.axes.h + 1.0f}; PLOT_CHECK(marker_hot > 0u); PLOT_CHECK(exact_pixels(over_marker, kW, marker_band, accent) == marker_hot); PLOT_CHECK(predicate_pixels(over_marker, kW, marker_band, green_dominant) == 0u); ctx.destroy(); } namespace { /// Two traces whose colours no other element of these frames produces, each with a /// predicate that refuses the other's: the top row's is blue (`trace_pixel`), the /// second row's magenta (`magenta_pixel`). One figure's rows can then be told apart by /// their pixels, which is what makes "the rows hold different content" a statement /// about the rows and not about the frame. const plot::Color kRowBlue = plot::Color::hex(0x40C0FF); const plot::Color kRowMagenta = plot::Color::hex(0xFF00FF); /// A sampled wave, one sample per x unit, so a row's trace crosses every column of its /// axes exactly once: every coverage statement below is then about the row it names. std::vector wave(plot::u32 n, plot::f32 phase) { std::vector y(n); for (plot::u32 i = 0; i < n; ++i) { y[i] = 0.8f * std::sin(phase + 0.05f * static_cast(i)); } return y; } /// Gives one figure row the axes and the trace these cases draw: an x axis spanning /// the series and a fixed y axis, so the geometry a row is drawn with is the geometry /// the case measured. void add_row_trace(plot::Plot& p, const plot::data::Series& s, plot::Color color) { const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), false, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, color, 1.5f, ax, ay}); } } // namespace /// A figure's rows are drawn into their own rects, so what one row renders never /// reaches another's: two rows carrying traces of two colours put each colour in its /// own row's rect and in no other, which is the whole point of stacking plots rather /// than drawing one. PLOT_TEST(two_rows_render_distinct_content) { 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 std::vector top = wave(640u, 0.0f); const std::vector bottom = wave(640u, 1.0f); plot::data::ArraySeries top_series(top, 0.0, 1.0); plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0); plot::Figure figure(2u); figure.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); add_row_trace(figure.plot(0u), top_series, kRowBlue); add_row_trace(figure.plot(1u), bottom_series, kRowMagenta); r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(figure); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "figure_two_rows.ppm"); const std::vector frame = read_frame(ctx); const plot::FigureLayout layout = figure.layout(); PLOT_CHECK(layout.rows.size() == 2u); const plot::Rect& row0 = layout.rows[0]; const plot::Rect& row1 = layout.rows[1]; PLOT_CHECK(row0.h > 0.0f && row1.h > 0.0f); // Each row's own colour is present in its own rect... PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, row1, magenta_pixel) > 0u); // ... and each row's colour is absent from the other's, which is what "the rows // hold distinct content" means: a frame drawn as one plot, or one row drawn over // the other, fails here whichever way it mixes them. PLOT_CHECK(predicate_pixels(frame, kW, row1, trace_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, row0, magenta_pixel) == 0u); // The frame holds exactly the two rows' traces: nothing of either colour was drawn // outside the rect the row owns. PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) == predicate_pixels(frame, kW, row0, trace_pixel)); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) == predicate_pixels(frame, kW, row1, magenta_pixel)); // Each trace is drawn inside its row's *axes*, not merely somewhere in the row: the // row's y strip is the other half of the rect and holds no data. The top row is // interior and reserves no x chrome; the bottom row reserves its own label and title // strips, because the figure's one shared x axis is that row's. const plot::PlotLayout& layout0 = figure.plot(0u).layout(); const plot::PlotLayout& layout1 = figure.plot(1u).layout(); PLOT_CHECK_NEAR(static_cast(layout0.axes.h), static_cast(row0.h), 1e-3); PLOT_CHECK_NEAR(static_cast(layout1.axes.bottom() + layout1.x_label_area.h + layout1.x_title_height), static_cast(row1.bottom()), 1e-3); PLOT_CHECK(predicate_pixels(frame, kW, layout0.axes, trace_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, layout1.axes, magenta_pixel) > 0u); // The rows are stacked, not overlapped: the second starts below the first. PLOT_CHECK(row1.y >= row0.bottom() - 1e-3f); ctx.destroy(); } /// The figure draws *one* x axis. With labels on the bottom only -- the default -- /// the rows above the bottom reserve no x chrome and their labels appear once, in the /// **bottom row's own** strip, which is where the figure's one x axis lives; the /// interior rows' labels are not in the frame at all. Switching the figure to label /// every row puts them in every row, and the count below -- of x-label ink inside the /// rows' own strips, so it compares x chrome with x chrome rather than the whole frames, /// whose row heights differ between the two modes -- says which happened: one row's /// worth against three. PLOT_TEST(shared_x_axis_is_drawn_once) { 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 std::vector samples = wave(640u, 0.0f); plot::data::ArraySeries series(samples, 0.0, 1.0); plot::Figure figure(3u); figure.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); for (plot::u32 row = 0; row < 3u; ++row) add_row_trace(figure.plot(row), series, kRowBlue); PLOT_CHECK(figure.x_labels_only_on_bottom()); const auto draw_frame = [&] { r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(figure); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); return read_frame(ctx); }; const plot::Color ink = r.style().axis_text; // --- labels on the bottom only: one set, and it is the figure's ----------------- draw_frame(); dump_if_requested(r, "figure_shared_x.ppm"); const plot::FigureLayout layout = figure.layout(); PLOT_CHECK(layout.rows.size() == 3u); PLOT_CHECK(layout.x_axis_strip.h > 0.0f); // The strip is the *bottom row's own* label area, inside that row and at the bottom // of it: the rows tile the figure to its bottom edge, and the shared axis is the // last band of the last row -- the rect the controller hit-tests and the renderer // stages the one set of labels in. PLOT_CHECK_NEAR(static_cast(layout.rows.back().bottom()), static_cast(kH), 1e-3); // The shared strip *is* the bottom row's x label area, to the pixel -- the rect the // controller hit-tests and the renderer stages the one set of labels in -- and it // sits inside that row, above the row's own x title strip. const plot::PlotLayout& bottom_row_layout = figure.plot(2u).layout(); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.x), static_cast(bottom_row_layout.x_label_area.x), 1e-3); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.y), static_cast(bottom_row_layout.x_label_area.y), 1e-3); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.w), static_cast(bottom_row_layout.x_label_area.w), 1e-3); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.h), static_cast(bottom_row_layout.x_label_area.h), 1e-3); PLOT_CHECK(layout.x_axis_strip.bottom() <= layout.rows.back().bottom() + 1e-3f); const plot::u32 strip_ink = scan_rect_ink(ctx, layout.x_axis_strip, ink).pixels; PLOT_CHECK(strip_ink > 0u); // Every row *above* the bottom gave its x label strip away: such a row's axes // reaches its rect's bottom edge and the row has no band inside itself for a second // set of labels to be drawn in. The bottom row is the exception -- it keeps its own // strip, which is where the figure's one x axis is -- so its strip is not zero. for (plot::u32 row = 0; row + 1u < 3u; ++row) { const plot::PlotLayout& row_layout = figure.plot(row).layout(); PLOT_CHECK(!plot::x_strip_has_labels(figure.plot(row).x_strip_mode())); PLOT_CHECK_NEAR(static_cast(row_layout.x_axis_height), 0.0, 1e-6); PLOT_CHECK_NEAR(static_cast(row_layout.x_label_area.h), 0.0, 1e-6); PLOT_CHECK_NEAR(static_cast(row_layout.axes.bottom()), static_cast(layout.rows[row].bottom()), 1e-3); } // ... and the band a second copy of them *would* land in holds none. A row that // kept no strip of its own would have its x labels staged against the top of the // zero-height strip it does have, i.e. centred on its `axes`' bottom edge, so the // band is the figure's strip height at the bottom of the row. Two insets keep it to // labels and nothing else: four columns past `axes.x` clear the row's y axis line // (the vertical edge `text_ink`, which is deliberately blind to one chalk tone or // the other, happily accepts), and two rows above the row's bottom edge clear the // row's own x axis line. for (plot::u32 row = 0; row + 1u < 3u; ++row) { const plot::PlotLayout& row_layout = figure.plot(row).layout(); const plot::Rect band{row_layout.axes.x + 4.0f, layout.rows[row].bottom() - layout.x_axis_strip.h, row_layout.axes.w - 8.0f, layout.x_axis_strip.h - 2.0f}; PLOT_CHECK(band.h > 0.0f); PLOT_CHECK(scan_rect_ink(ctx, band, ink).pixels == 0u); } // --- every row labels itself: the same labels, in three places ----------------- figure.set_x_labels_only_on_bottom(false); draw_frame(); const plot::FigureLayout per_row_layout = figure.layout(); // With every row labelling itself there is no shared axis to speak of: the strip // reads back the bottom row's own label area again, as one of three rather than as // the figure's one -- the figure added no band outside its rows either way. PLOT_CHECK_NEAR(static_cast(per_row_layout.x_axis_strip.h), static_cast(figure.plot(2u).layout().x_label_area.h), 1e-3); PLOT_CHECK(per_row_layout.rows.size() == 3u); for (plot::u32 row = 0; row < 3u; ++row) { const plot::PlotLayout& own = figure.plot(row).layout(); PLOT_CHECK(plot::x_strip_has_labels(figure.plot(row).x_strip_mode())); PLOT_CHECK(own.x_label_area.h > 0.0f); // A row's own labels are in its own strip, inside its own rect. PLOT_CHECK(own.x_label_area.y >= per_row_layout.rows[row].y); PLOT_CHECK(own.x_label_area.bottom() <= per_row_layout.rows[row].bottom() + 1e-3f); PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u); } // The two interior rows' labels are the difference -- they are what the shared axis // replaced, and in the default frame they are simply not there. Counted inside the // rows' own x label strips, which is the only like-for-like count here: the two frames // do not give the rows the same heights, because a shared frame lets its interior rows // keep the band the per-row frame spends on their x strips (the shared axis costs them // nothing), so a whole-frame text count compares y chrome against y chrome and decides // nothing. The bottom row's strip holds one row's worth of x labels in both frames -- // the same rect carrying the same labels -- while the interior rows' strips hold none // in the shared frame and a row's worth each in the per-row one, so the per-row strips // together hold three times the shared one's. A frame that drew the interior rows' // labels but only one set of them, or that spent no strip on the bottom row, cannot // land here. plot::u32 per_row_x_ink = 0u; for (plot::u32 row = 0; row < 3u; ++row) { per_row_x_ink += scan_rect_ink(ctx, figure.plot(row).layout().x_label_area, ink).pixels; } PLOT_CHECK(per_row_x_ink >= 2u * strip_ink); ctx.destroy(); } /// A resize recomputes the layout: the rows are re-derived from the new viewport -- /// their rects, the shared strip, the axes and the transforms they pin -- rather than /// rescaled from, or left at, the rects of the frame before. The second frame here is /// taller than the first, so a stale layout would paint the rows at the old /// coordinates and a scaled one would put them somewhere between the two; the pixels /// say which happened. PLOT_TEST(a_row_resize_recomputes_rather_than_rescaling_a_stale_layout) { 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 std::vector top = wave(640u, 0.0f); const std::vector bottom = wave(640u, 1.0f); plot::data::ArraySeries top_series(top, 0.0, 1.0); plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0); plot::Figure figure(2u); add_row_trace(figure.plot(0u), top_series, kRowBlue); add_row_trace(figure.plot(1u), bottom_series, kRowMagenta); // First frame: half the height, so the rows are short and the second row of the // taller frame below is nowhere near where this one puts it. const plot::f32 short_h = 0.5f * static_cast(kH); figure.set_viewport({0, 0, static_cast(kW), short_h}); r.begin_frame(kW, kH, figure.viewport()); r.draw(figure); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); const plot::FigureLayout short_layout = figure.layout(); PLOT_CHECK(short_layout.rows.size() == 2u); // Second frame: the whole framebuffer. figure.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); r.begin_frame(kW, kH, figure.viewport()); r.draw(figure); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "figure_resized.ppm"); const std::vector frame = read_frame(ctx); const plot::FigureLayout layout = figure.layout(); PLOT_CHECK(layout.rows.size() == 2u); const plot::Rect& row1 = layout.rows[1]; // The rows are the *new* figure's: twice the first frame's height, to the pixel, // and the second row reaches the figure's bottom edge rather than stopping where // the short frame's shared strip was. PLOT_CHECK(row1.h > 1.5f * short_layout.rows[1].h); PLOT_CHECK_NEAR(static_cast(row1.bottom()), static_cast(kH), 1e-3); PLOT_CHECK(row1.y > short_layout.rows[1].y); // The transforms the frame drew with are the new rects': the second row's own chrome // stacks inside its rect -- the axes, then the shared-axis label strip, then the // row's title strip, the three ending on the row's bottom edge -- and its trace is in // the axes, not at the old coordinates. const plot::PlotLayout& row1_layout = figure.plot(1u).layout(); PLOT_CHECK_NEAR(static_cast(row1_layout.axes.y), static_cast(row1.y), 1e-3); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.y), static_cast(row1_layout.x_label_area.y), 1e-3); PLOT_CHECK_NEAR(static_cast(row1_layout.axes.bottom()), static_cast(row1_layout.x_label_area.y), 1e-3); PLOT_CHECK_NEAR(static_cast(row1_layout.x_label_area.bottom() + row1_layout.x_title_height), static_cast(row1.bottom()), 1e-3); PLOT_CHECK(predicate_pixels(frame, kW, row1_layout.axes, magenta_pixel) > 0u); // Nothing of the second row's trace is above it, where the short frame's row 1 was: // a stale layout would have left the magenta trace up there. PLOT_CHECK(predicate_pixels(frame, kW, plot::Rect{0.0f, 0.0f, static_cast(kW), row1.y}, magenta_pixel) == 0u); // And the top row is still the top row: its own trace is above the second row and // nowhere below it. PLOT_CHECK(predicate_pixels(frame, kW, layout.rows[0], trace_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, plot::Rect{0.0f, row1.y, static_cast(kW), static_cast(kH) - row1.y}, trace_pixel) == 0u); ctx.destroy(); } /// A `Figure`'s x title is drawn where a figure's x axis is: the bottom row's own /// title strip, under that row's own labels. An interior row reserves no x chrome at /// all -- its `axes` reaches its rect's bottom edge, because the figure has one x axis /// and the bottom row owns it -- so a title on an interior row has nowhere to go and /// is not drawn. The second half of the frame pair is what makes that a statement: /// told to label every row, the same figure gives every row its own strip and the /// interior rows' titles appear, so the ink the default frame withholds is exactly the /// ink the interior rows' x chrome would have spent. PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) { 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 std::vector samples = wave(640u, 0.0f); plot::data::ArraySeries series(samples, 0.0, 1.0); plot::Figure figure(3u); figure.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); for (plot::u32 row = 0; row < 3u; ++row) { plot::Plot& p = figure.plot(row); // Every row gets a title, and a title of its own, so which strip the ink is in says // which row drew it. const plot::u32 ax = p.add_x_axis({row + 1u == 3u ? "bottom time" : "row time", series.x_bounds(), false, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&series, kRowBlue, 1.5f, ax, ay}); } const auto draw_frame = [&] { r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(figure); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); return read_frame(ctx); }; const plot::Color ink = r.style().axis_text; const std::vector bottom_only = draw_frame(); dump_if_requested(r, "figure_x_title.ppm"); const plot::FigureLayout layout = figure.layout(); // The bottom row keeps a real title strip, inside its own rect, and its title is in // it -- so the title feature still works on a figure. const plot::PlotLayout& above = figure.plot(2u).layout(); PLOT_CHECK(above.x_title_area.h > 0.0f); PLOT_CHECK(above.x_title_area.y >= above.x_label_area.bottom() - 1e-3f); PLOT_CHECK(above.x_title_area.bottom() <= layout.rows[2].bottom() + 1e-3f); PLOT_CHECK(scan_rect_ink(ctx, above.x_title_area, ink).pixels > 0u); // The labels are above it and the data above them: the three strips stack, they do // not overlap. PLOT_CHECK(above.axes.bottom() <= above.x_label_area.y + 1e-3f); PLOT_CHECK(above.x_label_area.bottom() <= above.x_title_area.y + 1e-3f); // An interior row reserves none of that: no label strip, no title strip, and its // axes takes the whole rect. for (plot::u32 row = 0; row + 1u < 3u; ++row) { const plot::PlotLayout& interior = figure.plot(row).layout(); PLOT_CHECK(interior.x_label_area.h == 0.0f); PLOT_CHECK(interior.x_title_area.h == 0.0f); PLOT_CHECK_NEAR(static_cast(interior.axes.bottom()), static_cast(layout.rows[row].bottom()), 1e-3); } const plot::u32 withheld = frame_text_ink(bottom_only, kW, kH, ink); // Told to label every row, the same figure puts every row's x chrome back -- label // strip and title strip, one per row, each inside its own rect -- and the interior // rows' titles are drawn where the default frame drew nothing. figure.set_x_labels_only_on_bottom(false); const std::vector every_row = draw_frame(); const plot::FigureLayout per_row = figure.layout(); for (plot::u32 row = 0; row < 3u; ++row) { const plot::PlotLayout& own = figure.plot(row).layout(); PLOT_CHECK(own.x_title_area.h > 0.0f); PLOT_CHECK(own.x_title_area.bottom() <= per_row.rows[row].bottom() + 1e-3f); PLOT_CHECK(scan_rect_ink(ctx, own.x_title_area, ink).pixels > 0u); PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u); } PLOT_CHECK(frame_text_ink(every_row, kW, kH, ink) > withheld); ctx.destroy(); } // ---------------------------------------------------- the axes clip (spec §13) // The reported defect this section is about: a trace is staged in *data* space and // mapped through the axes transform, so a sample outside the visible range maps // outside `axes` and was drawn there -- over the label strips, over the host's own // surface outside the viewport, and in a `Figure` straight into the neighbouring row. // The frame's only scissor was the one around its clear, which bounds that overflow at // the framebuffer and nowhere else. The fix is a clip to `layout().axes`, intersected // with the viewport, around every pass that draws inside the axes; the cases below // assert it from the pixels, and prove it is neither missing nor too small. namespace { /// `n` samples alternating between +50 and -50. On the [0, 1] y axis these cases fix, /// every sample maps fifty axes' heights off the rect: the +50 one 49 `axes.h` above its /// top edge, the -50 one 50 below its bottom edge -- thousands of pixels, i.e. past the /// target -- so the polyline leaves the axes at its first segment whatever window is /// framed. std::vector off_scale_wave(plot::u32 n) { std::vector y(n); for (plot::u32 i = 0; i < n; ++i) y[i] = (i % 2u == 0u) ? 50.0f : -50.0f; return y; } /// `n` samples alternating between 0.25 and 0.75: inside the [0, 1] axis these cases fix, so /// this trace's overflow is the *window's* alone. The renderer gathers one sample of slack /// on each side of the visible window -- which is what covers a partially visible end /// bucket, and why nothing is clamped in CPU -- so a coarse series hangs a whole segment, /// some fifty pixels wide, over the axes' left edge and across both y strips. std::vector in_range_wave(plot::u32 n) { std::vector y(n); for (plot::u32 i = 0; i < n; ++i) y[i] = (i % 2u == 0u) ? 0.25f : 0.75f; return y; } /// The x window these cases frame and the two resolutions they sample it at: forty samples /// over it (a segment some 120 px wide, so the gathered window's one sample of slack hangs a /// whole segment over the end of the axes) and six thousand (a near-vertical segment per /// pixel of the axes, i.e. a comb that crosses every column of it). constexpr plot::f64 kOffScaleSpan = 100.0; constexpr plot::f64 kOffScaleMin = 10.4; constexpr plot::f64 kOffScaleMax = 20.6; constexpr plot::u32 kCoarseSamples = 40u; constexpr plot::u32 kCombSamples = 6000u; /// The trace-coloured ink a frame holds *outside* `axes`: how many pixels, and how far /// past each edge the furthest of them reached. The assertions in this section are the /// clip's; this is the defect *measured*, which is where its size is quoted from. struct OutsideInk { plot::u32 pixels = 0u; plot::f32 left = 0.0f; plot::f32 right = 0.0f; plot::f32 above = 0.0f; plot::f32 below = 0.0f; }; OutsideInk outside_ink(const std::vector& frame, plot::u32 fb_w, plot::u32 fb_h, const plot::Rect& axes) { OutsideInk out; for (plot::u32 y = 0; y < fb_h; ++y) { for (plot::u32 x = 0; x < fb_w; ++x) { if (!trace_pixel(&frame[(plot::usize(y) * fb_w + x) * 4u])) continue; const plot::f32 cx = static_cast(x); const plot::f32 cy = static_cast(y); // Entirely inside the rect: not an overflow. A pixel straddling an edge is // counted, because that is a pixel the ink's own extent reached outside. if (cx >= axes.x && cx + 1.0f <= axes.right() && cy >= axes.y && cy + 1.0f <= axes.bottom()) { continue; } ++out.pixels; out.left = std::max(out.left, axes.x - cx); out.right = std::max(out.right, (cx + 1.0f) - axes.right()); out.above = std::max(out.above, axes.y - cy); out.below = std::max(out.below, (cy + 1.0f) - axes.bottom()); } } return out; } /// Any pixel of the axes' row `y` (framebuffer rows, top-left origin, like every `Rect` /// here) that a predicate accepts: the row-wise twin of `column_has` above, so a case can /// ask whether the clip reaches the rect's own top and bottom rows. bool axes_row_has(plottest::HeadlessContext& ctx, const plot::Rect& axes, plot::u32 y, bool (*pred)(const plot::u8*)) { for (plot::u32 x = static_cast(axes.x); x < static_cast(axes.right()); ++x) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px)); if (pred(px)) return true; } return false; } /// The cursor colour (0xFFB020) at any coverage: warm, with green strictly between red /// and blue. Nothing else in these frames produces it -- the trace is blue, the region /// magenta or green, the chrome and the text grey, and the measure band's own tint is /// 0xFFB020 at 15% over the background, too dark for the red bound below -- so a count of /// it is a count of cursor strokes. bool accent_pixel(const plot::u8 px[4]) { return px[0] > 90 && px[1] < px[0] && px[1] > px[2]; } } // namespace /// Spec §13, the defect reported from use: "you can see signals outside the plot area". /// /// The three failure modes are asserted separately, so a clip that fixes one of them and /// not the others cannot pass: no trace ink in the y label strip (the coarse trace's /// window slack hangs a whole segment over the left edge of the axes), none in the x /// label strip (the segments cross the bottom edge on their way to the -50 samples), and /// none anywhere outside `axes` at all -- in a strip, in the band between the axes and the /// frame's edge, or over the host's own surface outside the viewport. The complement is /// asserted too: the ink still reaches all four edges of the axes, because "nothing /// outside" is also satisfied by a clip narrower than the rect. PLOT_TEST(off_scale_trace_is_clipped_to_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)); // Two traces, and both directions of the defect: the coarse one is in range, so the ink // it puts over the y strips is the window slack's alone; the comb is fifty axes' heights // off scale, one near-vertical segment per pixel, so it crosses the x strips below the // axes and the row above them. plot::data::ArraySeries coarse(in_range_wave(kCoarseSamples), 0.0, kOffScaleSpan / static_cast(kCoarseSamples)); plot::data::ArraySeries comb(off_scale_wave(kCombSamples), 0.0, kOffScaleSpan / static_cast(kCombSamples)); plot::Plot p; // A viewport inside the framebuffer, as an embedding host's is, with the host's grey // painted behind it: the frame may paint inside the rect and nowhere else at all. const plot::Rect vp{40.0f, 24.0f, 560.0f, 300.0f}; p.set_viewport(vp); const plot::u32 ax = p.add_x_axis({"t", {0.0, kOffScaleSpan}, false, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&coarse, kRowBlue, 1.5f, ax, ay}); p.add_trace({&comb, kRowBlue, 1.5f, ax, ay}); p.set_x_range(ax, {kOffScaleMin, kOffScaleMax}); plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); r.begin_frame(kW, kH, vp); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "clip_axes.ppm"); const std::vector frame = read_frame(ctx); const plot::PlotLayout layout = p.layout(); const plot::Rect& axes = layout.axes; // The premise, from the transform rather than from the pixels: what a frame shows of // these samples is bounded by the clip and by nothing else. const plot::Transform ty = p.y_transform(ay); PLOT_CHECK_NEAR(static_cast(axes.y) - ty.to_screen_y(50.0), 49.0 * static_cast(axes.h), 1e-3); PLOT_CHECK_NEAR(ty.to_screen_y(-50.0) - static_cast(axes.bottom()), 50.0 * static_cast(axes.h), 1e-3); // The overflow, measured: the ink the three checks below are about. Everything it names // is what the clip has to remove, and its magnitude -- how far past each edge the // furthest pixel landed -- is the defect's size. const OutsideInk outside = outside_ink(frame, kW, kH, axes); // What is left of the defect when it comes back is a strip of ink nobody would see in a // screenshot, so the failure says where it landed: the size and direction print only // when there is an overflow to print, immediately before the check that fails on it. if (outside.pixels != 0u) { std::printf("[clip] %u trace pixels outside the axes -- left %.1f right %.1f above %.1f" " below %.1f px (axes %.1f,%.1f %.1fx%.1f); y label strip %u, x label strip" " %u\n", outside.pixels, static_cast(outside.left), static_cast(outside.right), static_cast(outside.above), static_cast(outside.below), static_cast(axes.x), static_cast(axes.y), static_cast(axes.w), static_cast(axes.h), predicate_pixels(frame, kW, layout.y_label_area, trace_pixel), predicate_pixels(frame, kW, layout.x_label_area, trace_pixel)); std::fflush(stdout); } // (1) The y label strip and the y title strip beyond it: the coarse trace's first // segment lies one whole x unit -- some fifty pixels -- left of the axes' left edge. PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, trace_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, trace_pixel) == 0u); // (2) The x label strip and the x title strip below it. PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, trace_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, trace_pixel) == 0u); // (3) Nothing outside the axes anywhere in the framebuffer -- including over the host's // surface outside the viewport, which the viewport-scissored clear left bare for the // overflow to land on. PLOT_CHECK(outside.pixels == 0u); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) == predicate_pixels(frame, kW, axes, trace_pixel)); plot::u8 host[4] = {}; PLOT_CHECK(ctx.read_pixel(4u, kH - 4u, host)); PLOT_CHECK(host[0] > 100 && host[0] < 160 && host[1] > 100 && host[2] > 100); // And the complement, so the clip cannot be smaller than the rect either: the comb // crosses every column of the axes, reaches its first and last columns, and its // segments span its first and last rows. A clip inset by even a strip passes every // check above and fails here. PLOT_CHECK(covered_columns(ctx, axes, 24) == 24u); PLOT_CHECK(column_has(ctx, static_cast(std::ceil(axes.x)), axes, trace_pixel)); PLOT_CHECK( column_has(ctx, static_cast(std::floor(axes.right())) - 1u, axes, trace_pixel)); PLOT_CHECK(axes_row_has(ctx, axes, static_cast(std::ceil(axes.y)), trace_pixel)); PLOT_CHECK(axes_row_has(ctx, axes, static_cast(std::floor(axes.bottom())) - 1u, trace_pixel)); ctx.destroy(); } /// The same defect across a `Figure`'s rows. Each row is a `Plot` laid out in its own /// rect but drawn into one framebuffer, so the bottom row's off-scale samples were drawn /// straight into the row above -- over that row's data and its chrome -- and clearing the /// framebuffer once per frame did nothing to stop it. The clip is per row, so the ink /// stops at the axes the row owns. PLOT_TEST(figure_row_overshoot_stays_out_of_the_neighbouring_row) { 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)); // The top row carries an ordinary in-range wave, so the row the overflow would land in // has content of its own to be told apart from it; the bottom row carries the comb, // off scale by fifty axes' heights. const std::vector top = wave(640u, 0.0f); plot::data::ArraySeries top_series(top, 0.0, 1.0); plot::data::ArraySeries bottom_series(off_scale_wave(kCombSamples), 0.0, kOffScaleSpan / static_cast(kCombSamples)); plot::Figure figure(2u); figure.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); add_row_trace(figure.plot(0u), top_series, kRowBlue); { plot::Plot& p = figure.plot(1u); const plot::u32 ax = p.add_x_axis({"t", {0.0, kOffScaleSpan}, false, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&bottom_series, kRowMagenta, 1.5f, ax, ay}); p.set_x_range(ax, {kOffScaleMin, kOffScaleMax}); } r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(figure); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "clip_figure_rows.ppm"); const std::vector frame = read_frame(ctx); const plot::FigureLayout layout = figure.layout(); PLOT_CHECK(layout.rows.size() == 2u); const plot::Rect& row0 = layout.rows[0]; const plot::PlotLayout& bottom = figure.plot(1u).layout(); const plot::Rect& axes1 = bottom.axes; // Not vacuous in either direction: both rows drew their own trace. PLOT_CHECK(predicate_pixels(frame, kW, axes1, magenta_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) > 0u); // The bottom row's off-scale ink used to be drawn here -- over the top row's data, its // strips and its chrome -- which is the neighbouring row it does not own. PLOT_CHECK(predicate_pixels(frame, kW, row0, magenta_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, bottom.y_label_area, magenta_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, bottom.x_label_area, magenta_pixel) == 0u); // All of its ink is inside its own axes and none of it is anywhere else in the frame. PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) == predicate_pixels(frame, kW, axes1, magenta_pixel)); // The top row is untouched by the clip as well: its own trace is still in its rect. PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) == predicate_pixels(frame, kW, kFrameRect, trace_pixel)); ctx.destroy(); } /// The fix's neighbours: the overlay geometry that already declined to draw over the /// strips did so by its own column rules -- a region's bounds outside the window are /// skipped by `draw_region_edge`, a cursor scrolled out of it by `draw_cursors`, and a /// region's fill is clamped to the axes' columns by `Plot::build_regions()`. The clip /// makes that structural rather than a rule every pass has to remember, and this case /// pins both halves: the off-window overlay puts no pixel in a strip, and the geometry /// that *is* on screen is not clipped away with it. PLOT_TEST(out_of_window_region_and_cursor_stay_inside_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::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({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.set_x_range(ax, {0.0, 300.0}); // ~1 sample/pixel: every column is crossed // A region entirely right of the window: nothing of it may be drawn. plot::Region reg; reg.x0 = 500.0; reg.x1 = 800.0; reg.snap = plot::RegionSnap::Free; reg.fill = plot::Color::hex(0x33FF00, 0.35f); reg.edge = plot::Color::hex(0xFF00FF); const plot::u32 rid = p.add_region(reg); // One cursor inside the window and one past its right edge, with a measurement between // them: the band is bounded by the window's edge rather than by the off-window cursor. const plot::u32 in_cursor = p.add_cursor(150.0, plot::Color::hex(0xFFB020)); const plot::u32 out_cursor = p.add_cursor(400.0, plot::Color::hex(0x35D0A8)); const plot::u32 measure = p.add_measure(in_cursor, out_cursor, 0u); 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, "clip_neighbours.ppm"); const std::vector frame = read_frame(ctx); const plot::PlotLayout layout = p.layout(); const plot::Rect& axes = layout.axes; PLOT_CHECK(layout.regions.size() == 1u); PLOT_CHECK(layout.regions[0].region == rid); // The layout agrees with the clip: an off-window region has no fill to shade and no // column inside the axes for either bound. PLOT_CHECK(layout.regions[0].fill.w == 0.0f); // Nothing of the off-window region was drawn, in a strip or anywhere else. PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, green_dominant) == 0u); // The off-window cursor drew nothing; the in-window one drew its stroke, and all of // that ink is inside the axes. PLOT_CHECK(predicate_pixels(frame, kW, axes, accent_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, accent_pixel) == predicate_pixels(frame, kW, axes, accent_pixel)); PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, accent_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, accent_pixel) == 0u); // The measurement band between an on-window and an off-window cursor is drawn, and it // stops at the axes: it is the one pass whose geometry is built from two cursors, so a // clamp that forgot one of them would put the tint over the strip. PLOT_CHECK(predicate_pixels(frame, kW, axes, band_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, band_pixel) == predicate_pixels(frame, kW, axes, band_pixel)); PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, band_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, band_pixel) == 0u); // And the trace is still whole: the clip is the axes rect, so the data still fills it. PLOT_CHECK(covered_columns(ctx, axes, 16) >= 15u); PLOT_CHECK(p.measurement(measure).valid); ctx.destroy(); } /// The clip's other half: what it must *not* do. A trace off scale is held inside `axes` /// in this frame as in the cases above, while the legend -- panel, swatch and name, all /// measured inside the rect -- survives, and the tick labels and axis titles, which live /// outside it by design, are still drawn: the clip is released before those passes. All /// three are asserted from the pixels, so a clip that was never released (which loses the /// strips) or one released too early (which loses the legend) fails here rather than in a /// screenshot nobody looks at. PLOT_TEST(legend_and_strip_labels_survive_the_axes_clip) { 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)}); // Titled axes, so both title strips exist and hold something to keep. 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({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); // A translucent trace whose swatch is drawn opaque: the exact colour of the swatch is // then one only a swatch can produce, which is what makes the count below a statement // about the legend rather than about the trace that carries the same colour. p.add_trace({&s, plot::Color::hex(0x40C0FF, 0.25f), 1.5f, ax, ay, "signal"}); p.set_x_range(ax, {0.0, 300.0}); // The trace that has to stay inside the axes -- the same off-scale values as the cases // above, sampled across this one's whole window -- in a second blue no other element of // the frame produces, and unnamed, so it adds no legend entry. plot::data::ArraySeries off_scale(off_scale_wave(3600u), 0.0, 300.0 / 3600.0); p.add_trace({&off_scale, plot::Color::hex(0x35A0FF), 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, "clip_legend.ppm"); const std::vector frame = read_frame(ctx); const plot::PlotLayout layout = p.layout(); const plot::Color ink = r.style().axis_text; PLOT_CHECK(layout.legend.rows.size() == 1u); const plot::LegendRow& row = layout.legend.rows[0]; PLOT_CHECK(row.swatch.w > 0.0f && row.swatch.h > 0.0f); // The legend is inside the axes, as its layout promises, and its swatch is still drawn // -- opaquely, and nowhere outside the rect the clip holds it in. PLOT_CHECK(row.swatch.x >= layout.axes.x && row.swatch.right() <= layout.axes.right()); PLOT_CHECK(row.swatch.y >= layout.axes.y && row.swatch.bottom() <= layout.axes.bottom()); const plot::u32 swatch = exact_pixels(frame, kW, row.swatch, p.trace(0u).color); PLOT_CHECK(swatch > 0u); PLOT_CHECK(exact_pixels(frame, kW, kFrameRect, p.trace(0u).color) == exact_pixels(frame, kW, layout.axes, p.trace(0u).color)); // And the off-scale trace is held in: its ink is inside the axes and nowhere else in the // frame, the same statement the first case makes with the same clip and a different // frame around it. PLOT_CHECK(predicate_pixels(frame, kW, layout.axes, trace_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) == predicate_pixels(frame, kW, layout.axes, trace_pixel)); // The strips outside the axes are drawn, in all four: the clip was released before the // tick-label and axis-title passes, which is what keeps them from being erased. PLOT_CHECK(scan_rect_ink(ctx, layout.y_label_area, ink).pixels > 0u); PLOT_CHECK(scan_rect_ink(ctx, layout.x_label_area, ink).pixels > 0u); PLOT_CHECK(scan_rect_ink(ctx, layout.y_title_area, ink).pixels > 0u); PLOT_CHECK(scan_rect_ink(ctx, layout.x_title_area, ink).pixels > 0u); ctx.destroy(); } /// The clip is the frame's, not the host's. `renderer.hpp` documents the GL state a frame /// leaves behind, and the scissor is now one of the pieces it moves: the frame ends with /// the scissor test off -- not left enabled on the axes rect of its last interior pass, /// which would clip whatever the embedding host drew next, and not on the viewport rect /// either, which would do the same to a host drawing outside its plot's region. Observed /// rather than documented: a clear the host issues after `end_frame()` has to reach the /// whole framebuffer. PLOT_TEST(a_frame_leaves_no_clip_for_the_host_to_inherit) { 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; const plot::Rect vp{80.0f, 40.0f, 400.0f, 240.0f}; p.set_viewport(vp); 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({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.set_x_range(ax, {0.0, 300.0}); r.begin_frame(kW, kH, vp); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); // The host paints its own surface next, as an embedding host does. With the frame's // clip left engaged -- at the axes rect, or at the viewport's -- this clear would cover // only that rect, and the corners below would still hold the frame's background. plot::gl::glClearColor(0.75f, 0.25f, 0.5f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); const auto host_clear = [&](plot::u32 x, plot::u32 y) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(x, y, px)); return px[0] > 180 && px[1] < 90 && px[2] > 100 && px[2] < 160; }; PLOT_CHECK(host_clear(2u, 2u)); // a corner, outside the viewport PLOT_CHECK(host_clear(kW - 3u, kH - 3u)); // the opposite corner PLOT_CHECK(host_clear(2u, kH - 3u)); // under the viewport, in its columns ctx.destroy(); } // ------------------------------------------------- horizontal rulers (spec §14) namespace { /// The horizontal ruler's own colour (0x35D0A8) at any coverage: a teal greener than the /// background and -- unlike the trace's own blue -- with green *above* blue, so no other /// element of these frames can satisfy it. A predicate rather than an exact match, because /// a 1.5-device-px stroke is anti-aliased and no pixel is ever guaranteed whole. bool ruler_pixel(const plot::u8 px[4]) { return px[1] > px[0] + 50 && px[1] > px[2] + 20 && px[1] > 90; } /// The extent of the ink a predicate accepts anywhere in a read-back frame: its bounding /// box in top-down framebuffer rows/columns (like every `Rect` here), and its pixel count. struct InkExtent { plot::u32 top = kNoColumn; plot::u32 bottom = 0; plot::u32 left = kNoColumn; plot::u32 right = 0; plot::u32 pixels = 0; bool any() const noexcept { return top != kNoColumn; } plot::u32 rows() const noexcept { return any() ? bottom - top + 1u : 0u; } plot::u32 cols() const noexcept { return any() ? right - left + 1u : 0u; } }; InkExtent ink_extent(const std::vector& frame, plot::u32 fb_w, plot::u32 fb_h, bool (*pred)(const plot::u8*)) { InkExtent e; for (plot::u32 y = 0; y < fb_h; ++y) { for (plot::u32 x = 0; x < fb_w; ++x) { if (!pred(&frame[(plot::usize(y) * fb_w + x) * 4u])) continue; if (!e.any()) { e.top = e.bottom = y; e.left = e.right = x; } e.top = std::min(e.top, y); e.bottom = std::max(e.bottom, y); e.left = std::min(e.left, x); e.right = std::max(e.right, x); ++e.pixels; } } return e; } } // namespace /// Spec §14, the paint half: a horizontal ruler is the marker turned a quarter turn, so its /// ink is one *horizontal* run across `axes` at the row its y maps to -- not a column -- and, /// Task 1's clip doing its job, nothing of it outside `axes`. The row is asserted against the /// transform's own `to_screen_y()` rather than "some ink exists", so a ruler drawn at the /// wrong y, or one still staged as a vertical column, fails here. The label is a second run /// of text ink just under the ruler, inside the axes -- the value the ruler reads off its own /// y axis, written the way that axis writes its ticks. PLOT_TEST(horizontal_ruler_label_reads_its_value) { 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({"t", {0.0, 300.0}, true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); // No trace: the frame is chrome and the ruler alone, so the ruler's colour is the only // thing near its row and no trace ink can be mistaken for the label beneath it. const plot::u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8)); p.layout(); 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, "h_ruler_paint.ppm"); const std::vector frame = read_frame(ctx); const plot::PlotLayout layout = p.layout(); const plot::Rect& axes = layout.axes; const plot::f32 expected_row = static_cast(p.y_transform(ay).to_screen_y(p.cursor(ruler).y)); PLOT_CHECK(expected_row >= axes.y && expected_row <= axes.bottom()); const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel); PLOT_CHECK(ink.pixels > 0u); // One horizontal run: shallow in rows, spanning essentially the axes' own width. PLOT_CHECK(ink.rows() <= 3u); PLOT_CHECK(ink.cols() + 2u >= static_cast(axes.w)); // And at the row the transform says -- a ruler drawn at the wrong y, or a vertical column // that happens to be inside the rect, cannot satisfy both of these. PLOT_CHECK(static_cast(ink.top) <= expected_row + 1.0f); PLOT_CHECK(static_cast(ink.bottom) >= expected_row - 1.0f); // The label: text ink just under the ruler at its left end, inside the axes. The tick // labels are outside `axes`, in their own strips, so this can only be the ruler's value. const plot::Rect label{axes.x + 1.0f, expected_row, 60.0f, 16.0f}; PLOT_CHECK(predicate_pixels(frame, kW, label, text_pixel) > 0u); // The clip: every ruler-coloured pixel of the frame is inside `axes`. PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) == predicate_pixels(frame, kW, axes, ruler_pixel)); PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, ruler_pixel) == 0u); ctx.destroy(); } /// Spec §14, the edge rule, discovered from the code and pinned here: a *drag* clamps the /// ruler's y to its own y axis' range (`Controller::move_cursor`), so a pointer that has left /// the axes cannot park the ruler at an extrapolated value -- but the direct /// `Plot::set_cursor_y()` stays verbatim, so a caller that means to place one off range still /// can. Either way Task 1's axes clip and the renderer's far-edge skip keep the ink inside /// `axes`: a ruler at or past an edge puts nothing over the strips beside it. Both halves are /// asserted from the pixels, because "drawn over the strip" is a claim about pixels. PLOT_TEST(horizontal_ruler_stays_inside_the_axes_at_the_edges) { 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({"t", {0.0, 300.0}, true, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); const plot::u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8)); plot::input::Controller c(p); c.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); p.layout(); // Draw one frame before measuring: the renderer hands the plot its own text backend and // style on the first draw, and with them the strip widths the layout pins -- so the axes // the controller hit-tests is the one a drawn frame has, not the empty plot's. Without // this the press below aims at a rect the first `frame_now()` has already replaced. r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(p); r.end_frame(); const plot::Rect& axes = p.layout().axes; const plot::f32 col = axes.x + 0.5f * axes.w; const plot::f32 row = static_cast(p.y_transform(ay).to_screen_y(50.0)); const auto frame_now = [&]() { 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()); return read_frame(ctx); }; // Presses on the ruler at its own row: no modifier, the gesture the ruler shares with a // marker. `row` is the y=50 row and does not change with a drag, so it can be reused. const auto grab = [&]() { p.set_cursor_y(ruler, 50.0); // back to the row the press is on p.layout(); c.on_pointer({col, row, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor); PLOT_CHECK(c.grabbed_cursor() == ruler); }; const auto drag_to = [&](plot::f32 y) { c.on_pointer({col, y, 0.0f, y - row, plot::input::Button::Left, 0u, true, false, false}); c.on_pointer({col, y, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); }; // Dragged past the *bottom*: the pointer extrapolates below the axis and the drag clamps // to the range's minimum rather than storing a value no tick could reach. grab(); drag_to(axes.bottom() + 400.0f); PLOT_CHECK_NEAR(p.cursor(ruler).y, 0.0, 1e-9); { const std::vector frame = frame_now(); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) == predicate_pixels(frame, kW, axes, ruler_pixel)); PLOT_CHECK(predicate_pixels(frame, kW, p.layout().x_label_area, ruler_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u); } // Dragged past the *top*: clamped to the range's maximum, and the clamped ruler is still // drawn -- at the axes' own top row and inside it, rather than over the strip above. grab(); drag_to(axes.y - 400.0f); PLOT_CHECK_NEAR(p.cursor(ruler).y, 100.0, 1e-9); { const std::vector frame = frame_now(); const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel); PLOT_CHECK(ink.pixels > 0u); PLOT_CHECK(static_cast(ink.top) < axes.y + 1.5f); PLOT_CHECK(static_cast(ink.bottom) >= axes.y); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) == predicate_pixels(frame, kW, axes, ruler_pixel)); PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u); } // The direct setter is verbatim -- a caller may park a ruler off range -- and the frame // still holds the ink in: an off-range ruler is skipped rather than drawn over a strip. p.set_cursor_y(ruler, 500.0); PLOT_CHECK_NEAR(p.cursor(ruler).y, 500.0, 1e-9); { const std::vector frame = frame_now(); PLOT_CHECK(predicate_pixels(frame, kW, axes, ruler_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u); } ctx.destroy(); }