// 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 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; } /// 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 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 y axis was staged at the same centred // position inside the one strip, so a Left "volts" and a Right "amps" drew over // each other -- the overprint the pre-strip placement did not have. Each titled // axis now owns a text line, so a titled pair of either direction shows two // separated runs of title-coloured pixels where it used to show one. PLOT_TEST(titled_axis_pairs_get_their_own_rows) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::Renderer r; PLOT_CHECK(r.init(&st)); plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); plot::Plot p; p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); p.add_x_axis({"samples", s.x_bounds(), true, plot::AxisPlace::Top, false}); const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); p.add_y_axis({"amps", {-1.0, 1.0}, true, plot::AxisPlace::Right, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); r.draw(p); r.end_frame(); PLOT_CHECK(!r.last_frame_had_gl_error()); dump_if_requested(r, "pairs.ppm"); const plot::PlotLayout& layout = p.layout(); const plot::Color title = r.style().axis_text; // The rows of one strip that hold title-coloured pixels, as the runs of rows // they form: one run per titled axis, because the two runs of a pair are a line // apart and each covers several pixel rows of glyph ink. auto row_bands = [&](const plot::Rect& strip) { plot::u32 bands = 0; bool previous = false; plot::u32 rows = 0; for (plot::u32 row = 0; row < static_cast(strip.h); ++row) { bool has = false; for (plot::u32 column = 0; column < static_cast(strip.w) && !has; ++column) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(static_cast(strip.x) + column, ctx.height - 1u - (static_cast(strip.y) + row), px)); has = 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" PLOT_CHECK(row_bands(layout.y_title_area) == 2u); // "volts" above "amps" ctx.destroy(); } // Finding 4 of the review: `mark_drawn()` dropped a failed fence's status, so the // slot stayed unsynchronised and the next upload memcpy'd into storage the GPU may // still have been reading -- exactly the tearing the ring exists to prevent. A // 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(); }