A grid was the axes rect in device pixels, so dragging a window wide made the image wider with it -- and the per-frame pass over it is O(cells): the fade multiplies every cell, a host's ink()/peak() read every cell, and a frame the fade moved converts and uploads every cell as one byte. At 2x device scale a 1520x850 window's axes rect is 2877 device pixels wide, so four rows of one trace are 4.5 M cells and 4.9 MB uploaded a frame; measured headless on llvmpipe with a 2 s window, 12.9 ms of tick and 12.8 ms of draw a frame, every frame under a time fade -- a cost that follows the window and has no ceiling. The grid is now the axes rect up to kDensityCellCap (2^18 cells, a mebibyte of counts), scaled down uniformly past it so cells stay square and the picture keeps the axes' aspect, and magnified back over the rect with a linear filter: a cell is a count, so a fragment between two of them is between their two ink levels, which a point sample cannot give once a cell is several pixels wide. The same four rows at the cap are 1.05 M cells, 3.9 ms of tick and 5.6 ms of draw and 1.0 MB uploaded, while a grid under the cap is untouched -- a 640x360 viewport's 573x313 axes is still 179 349 cells, one a device pixel. The image's footprint is the axes rect rounded up to whole device pixels on both sides of the pipe, so a grid under the cap keeps the exact one-texel-a-pixel mapping it has always had (sx/sy come out exactly 1) and only a capped grid reads its cell index through a scale. Moving the decay itself to the GPU was considered and left out: the CPU grid is what ink()/peak() read, what the deposit adds to and what the per-sweep dedupe keys on, so a GPU image would have to be reconciled with it per frame -- a larger change than this bound, and not one to half-do. Tests: a_density_grid_is_capped_whatever_the_window reads the bound off a 3840x2160 viewport (261 504 cells of a 3784x2138 axes, aspect kept), a_capped_density_is_magnified_over_the_axes_rect reads the screen side of it (569x460 cells covering all 832 of the rect's columns), and the density cases now assert their capped figures. 399 cases, 0 failed.
7852 lines
379 KiB
C++
7852 lines
379 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
// 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 <plot/gl/api.hpp>
|
|
#include <plot/gl/loader.hpp>
|
|
#include <plot/data/array_series.hpp>
|
|
#include <plot/render/buffer.hpp>
|
|
#include <plot/render/detail/ring_stats.hpp>
|
|
#include <plot/plot/figure.hpp>
|
|
#include <plot/render/renderer.hpp>
|
|
#include <plot/input/controller.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <csignal>
|
|
#include <cstdlib>
|
|
#include <cstdio>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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).
|
|
/// A sine of **one period per pixel column** for the window
|
|
/// `a_dense_trace_can_be_graded_by_dwell` draws (60 000 x-units over a 572 px axis, so
|
|
/// 104.9 samples a column). That is the one density at which a column *has* a dwell
|
|
/// structure to grade: its envelope spans the full amplitude, so the band is tall, and
|
|
/// the sub-buckets inside it are a fraction of a period each, so the ones at the sine's
|
|
/// turning points are short and the ones crossing its middle are long. A period of many
|
|
/// columns leaves every band a thin ribbon with nothing to distinguish, and a period of a
|
|
/// fraction of a column fills every sub-bucket to the full amplitude -- also nothing.
|
|
std::vector<plot::f32> fast_sine_samples() {
|
|
std::vector<plot::f32> y(1'000'000);
|
|
const double w = 2.0 * 3.14159265358979323846 / 104.9;
|
|
for (plot::u64 i = 0; i < y.size(); ++i) {
|
|
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * w));
|
|
}
|
|
return y;
|
|
}
|
|
|
|
std::vector<plot::f32> sine_samples() {
|
|
std::vector<plot::f32> y(1'000'000);
|
|
for (plot::u64 i = 0; i < y.size(); ++i) {
|
|
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(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];
|
|
}
|
|
/// A chalk pixel: `axis_text` (0xC8D0DC) at close to full coverage, which nothing
|
|
/// else in these frames reaches -- the brightest other element is `axis_line`
|
|
/// (0x8A94A6), whose dimmest channel is 138.
|
|
///
|
|
/// The threshold is 160 and not the colour's own 200-220 because the text pipeline
|
|
/// filters the atlas: at the default 12 px style on the 13 px bake the brightest
|
|
/// fragment of a glyph is 0.844 covered, which puts `axis_text`'s green at 179. A
|
|
/// 180 threshold found text only while the sampler was a point fetch, i.e. only
|
|
/// while it was also deleting a column and a row of every glyph.
|
|
bool text_pixel(const plot::u8 px[4]) { return px[0] > 160 && px[1] > 160 && px[2] > 160; }
|
|
/// 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<plot::u8>(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<plot::u32>(a.y);
|
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
|
plot::u32 hit = 0;
|
|
for (plot::u32 c = 0; c < columns; ++c) {
|
|
const plot::u32 x =
|
|
static_cast<plot::u32>(a.x + a.w * (static_cast<plot::f64>(c) + 0.5) /
|
|
static_cast<plot::f64>(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);
|
|
}
|
|
|
|
/// The public-boundary contract (review finding 3d): `dump_frame_ppm()` reports an
|
|
/// allocation failure through its `Status` -- `ErrorCode::OutOfMemory`, as
|
|
/// `TrueTypeFont::from_file()` does -- rather than letting `std::bad_alloc` cross the
|
|
/// boundary, so it is `noexcept` like every other entry point here.
|
|
static_assert(noexcept(std::declval<const plot::Renderer&>().dump_frame_ppm(nullptr)),
|
|
"dump_frame_ppm must not throw across the public boundary");
|
|
|
|
/// 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<int>(got) - static_cast<int>(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<plot::f32>(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<std::size_t>(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<std::size_t>(y) * size + x] = 255u;
|
|
}
|
|
}
|
|
}
|
|
glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast<plot::f32>(size)};
|
|
++revision_;
|
|
}
|
|
|
|
/// Bakes a `size` x `size` atlas of alternating ink and blank texels, and bumps
|
|
/// the revision. It is the artwork that separates a *filtered* sampler from a
|
|
/// point one without depending on any face's shapes: halve the scale and a
|
|
/// filter must average every 2x2 to mid grey, while a point fetch lands on one
|
|
/// parity for every fragment and returns a solid block.
|
|
void bake_checkerboard(plot::u32 size) {
|
|
size_ = size;
|
|
pixels_.assign(static_cast<std::size_t>(size) * size, 0u);
|
|
for (plot::u32 y = 0; y < size; ++y) {
|
|
for (plot::u32 x = 0; x < size; ++x) {
|
|
if (((x + y) & 1u) == 0u) {
|
|
pixels_[static_cast<std::size_t>(y) * size + x] = 255u;
|
|
}
|
|
}
|
|
}
|
|
glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast<plot::f32>(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<plot::f32>(size_); }
|
|
plot::f32 ascent() const override { return static_cast<plot::f32>(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<plot::f32>(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<plot::u8> 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<plot::gl::GLsizei>(ctx.width),
|
|
static_cast<plot::gl::GLsizei>(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<plot::f32>(ctx.width), static_cast<plot::f32>(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);
|
|
atlas.bind();
|
|
vao.bind();
|
|
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4,
|
|
static_cast<plot::gl::GLsizei>(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<plot::u32>(area.right());
|
|
const plot::u32 y1 = static_cast<plot::u32>(area.bottom());
|
|
for (plot::u32 y = static_cast<plot::u32>(area.y); y < y1; ++y) {
|
|
for (plot::u32 x = static_cast<plot::u32>(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<InkBand> ink_row_bands(plottest::HeadlessContext& ctx, const plot::Rect& area,
|
|
plot::Color text) {
|
|
std::vector<InkBand> bands;
|
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
|
bool has = false;
|
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
|
x < static_cast<plot::u32>(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<InkBand> ink_column_bands(plottest::HeadlessContext& ctx, const plot::Rect& area,
|
|
plot::Color text) {
|
|
std::vector<InkBand> bands;
|
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
|
x < static_cast<plot::u32>(area.right()); ++x) {
|
|
bool has = false;
|
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
|
y < static_cast<plot::u32>(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<plot::u8> read_frame(plottest::HeadlessContext& ctx) {
|
|
std::vector<plot::u8> 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<plot::u8>& 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 <plot/render/detail/ring_stats.hpp> 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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();
|
|
}
|
|
|
|
/// A series whose `dx()` understates its spacing.
|
|
///
|
|
/// That is every ring-backed series: x is a streamed timestamp, so `dx()` is only a nominal
|
|
/// grid (an LOD level, a coarse snap) while `x_at()`/`neighbours()` answer from the real time
|
|
/// ring. The samples here sit ten apart and `dx()` claims one, with the plotted window
|
|
/// `[12, 18]` falling strictly *between* two of them -- no sample is inside the window at all,
|
|
/// so the only way to light its margins is the segment that crosses them.
|
|
class LyingGridSeries final : public plot::data::Series {
|
|
public:
|
|
LyingGridSeries() {
|
|
pyramid_.reserve(kN);
|
|
for (u64 i = 0; i < kN; ++i) pyramid_.append(kY);
|
|
}
|
|
|
|
u64 size() const override { return kN; }
|
|
f64 x0() const override { return 0.0; }
|
|
f64 dx() const override { return 1.0; } // the lie: the real spacing is x_at()'s ten
|
|
plot::Range x_bounds() const override { return {0.0, x_at(kN - 1u)}; }
|
|
plot::Range y_bounds() const override { return {kY, kY}; }
|
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
|
f64 x_at(u64 i) const override { return 10.0 * static_cast<f64>(i); }
|
|
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
|
if (x < 0.0 || x > x_at(kN - 1u)) return false;
|
|
i0 = static_cast<u64>(x / 10.0);
|
|
i1 = i0;
|
|
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
|
|
return true;
|
|
}
|
|
void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
|
|
plot::data::GatherMode) const override {
|
|
++gather_calls_;
|
|
for (u64 i = begin; i < end; ++i) {
|
|
out.push_back(static_cast<f32>(x_at(i) - x_origin));
|
|
out.push_back(kY);
|
|
}
|
|
}
|
|
|
|
u64 gather_calls() const noexcept { return gather_calls_; }
|
|
|
|
protected:
|
|
void copy_raw(u64 begin, u64 end, f32* out) const override {
|
|
for (u64 i = begin; i < end; ++i) out[i - begin] = kY;
|
|
}
|
|
|
|
private:
|
|
static constexpr u64 kN = 5u;
|
|
static constexpr f32 kY = 1.0f;
|
|
plot::data::MinMaxPyramid pyramid_;
|
|
mutable u64 gather_calls_ = 0;
|
|
};
|
|
|
|
/// The window falls between two samples: the trace must still be drawn to both margins.
|
|
///
|
|
/// Found by zooming in: the renderer mapped the visible x range onto sample indices with
|
|
/// `(x - x0()) / dx()`, and on a series whose `dx()` is nominal that lands *inside* the visible
|
|
/// range -- here it lands past the end of a five-sample series entirely, so `last <= first` and
|
|
/// the frame held no trace at all. The segment entering the window from the sample before it and
|
|
/// leaving to the one after are what draw the margins, and a series answers where they are
|
|
/// through `neighbours()`, over its own x.
|
|
PLOT_TEST(a_sparse_window_between_samples_still_inks_both_margins) {
|
|
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));
|
|
|
|
LyingGridSeries s;
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
// `auto_scale` off on both: the window has to be the one the test names, or the axis takes
|
|
// the series' own bounds and the window falls outside the frame -- a test of nothing.
|
|
const plot::u32 ax = p.add_x_axis({"t", {12.0, 18.0}, false, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 3.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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
dump_if_requested(r, "margins.ppm");
|
|
|
|
const plot::Rect a = p.layout().axes;
|
|
// The window's own evidence: the samples that bracket it are sampled even though neither is
|
|
// inside it, and the reverted grid walk staged *none* (`(12 - 0) / 1` is past the end of a
|
|
// five-sample series, so `last <= first` and the frame held no trace at all).
|
|
const plot::Range drawn_x = p.x_range(ax);
|
|
const plot::u32 covered = covered_columns(ctx, a, 24);
|
|
std::printf("[margins] window=[%.3f..%.3f] axes=[%.1f %.1f %.1f %.1f] visible=%llu "
|
|
"raw=%llu lod=%llu segments=%llu gathers=%llu trace_visible=%d covered=%u/24\n",
|
|
drawn_x.min, drawn_x.max, a.x, a.y, a.w, a.h,
|
|
static_cast<unsigned long long>(r.stats().visible_samples),
|
|
static_cast<unsigned long long>(r.stats().raw_traces),
|
|
static_cast<unsigned long long>(r.stats().lod_traces),
|
|
static_cast<unsigned long long>(r.stats().staged_segments),
|
|
static_cast<unsigned long long>(s.gather_calls()), int(p.trace(0).visible()),
|
|
covered);
|
|
PLOT_CHECK(r.stats().visible_samples >= 2u);
|
|
PLOT_CHECK(covered == 24u);
|
|
PLOT_CHECK(covered_columns(ctx, a, 24) == 24); // every column, margins included
|
|
|
|
// The margins by name, on the line's own row: the reported symptom was these two being blank
|
|
// while the middle drew. `y = 1` in the window `[-1, 3]` is the middle row of the band.
|
|
const plot::u32 row = static_cast<plot::u32>(a.y + a.h / 2.0f);
|
|
for (const plot::u32 x : {static_cast<plot::u32>(a.x) + 1u,
|
|
static_cast<plot::u32>(a.x + a.w) - 2u}) {
|
|
bool inked = false;
|
|
for (plot::u32 dy = 0; dy < 4u && !inked; ++dy) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (row + dy), px));
|
|
inked = trace_pixel(px);
|
|
}
|
|
PLOT_CHECK(inked);
|
|
}
|
|
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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();
|
|
}
|
|
|
|
/// A window denser than the pixel grid inks **whole columns**, which is what kills the moire.
|
|
///
|
|
/// Reported as "strong moire patterns due to a large number of vertical lines" on a high rate,
|
|
/// a high frequency or a noisy signal. The polyline at this density is a stack of near-vertical
|
|
/// hairlines at *fractional* x -- one to two envelope buckets per column, each a min->max
|
|
/// hairline plus a connector -- so a column's brightness is a non-linear function of those
|
|
/// sub-pixel phases, and the phases repeat at the beat between the bucket pitch and the pixel
|
|
/// pitch. That beat is the pattern, and panning sweeps it, so it crawls.
|
|
///
|
|
/// The observable is the beat's own cause, measured: how many **distinct coverage levels** the
|
|
/// trace's ink takes. One segment per column puts every column's ink at one sub-pixel phase, so
|
|
/// the AA ramp is evaluated at the same few distances everywhere and the ink lands on **4**
|
|
/// levels; the polyline's hairlines sit at arbitrary phases and its ink spreads over **126** of
|
|
/// them. That spread *is* the texture, and it is what beats against the pixel pitch.
|
|
///
|
|
/// A count of partially covered pixels is *not* the reading, which is worth knowing before
|
|
/// adding one: both renderings put about the same amount of ink in the fringe (577 against 652
|
|
/// here, of 1400-odd inked pixels), because a 1.5 px stroke with a 1 px fringe always spills
|
|
/// into its neighbour. The fix is not that the fringe is gone, it is that it is the *same*
|
|
/// fringe in every column.
|
|
///
|
|
/// The window is one period of the sine over 107 samples per pixel: dense enough for the
|
|
/// reduction and slow enough that the band is a *thin ribbon*, which is where the beat shows.
|
|
/// A window whose buckets each span several periods paints a solid block and hides it.
|
|
PLOT_TEST(a_dense_trace_is_drawn_as_whole_pixel_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::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, 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, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
dump_if_requested(r, "columns.ppm");
|
|
|
|
const plot::Rect a = p.layout().axes;
|
|
// Blue over the background minus red is the trace's coverage, linearly: 8 at no coverage
|
|
// and 199 at full (0x40C0FF over 0x101418). Nothing else in this frame is blue-dominant --
|
|
// the grid and the axis line are grey -- so the two bands below are the trace's own ink,
|
|
// split at three quarters covered.
|
|
plot::u32 inked = 0;
|
|
plot::u32 histogram[256] = {};
|
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(a.h); ++row) {
|
|
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
|
|
plot::u8 px[4] = {};
|
|
const plot::u32 x = static_cast<plot::u32>(a.x) + col;
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
|
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
|
if (cover > 40) { // the trace's ink, at a quarter covered or better
|
|
++inked;
|
|
++histogram[cover];
|
|
}
|
|
}
|
|
}
|
|
// How many *distinct* coverages the ink takes is the beat, measured. A column's ink is
|
|
// placed at one sub-pixel phase, so the AA ramp is evaluated at the same few distances in
|
|
// every column and the ink lands on a handful of levels; a polyline's hairlines sit at
|
|
// arbitrary phases, so its ink spreads over the whole range of them. The floor of 4 pixels
|
|
// per level ignores the stragglers at the ribbon's turning points.
|
|
plot::u32 levels = 0;
|
|
for (plot::u32 c = 0; c < 256u; ++c) {
|
|
if (histogram[c] >= 4u) ++levels;
|
|
}
|
|
std::printf("[columns] axes=[%.1f %.1f %.1f %.1f] raw=%u lod=%u columns=%u segments=%u "
|
|
"visible=%llu inked=%u levels=%u\n",
|
|
a.x, a.y, a.w, a.h, r.stats().raw_traces, r.stats().lod_traces,
|
|
r.stats().column_traces, r.stats().staged_segments,
|
|
static_cast<unsigned long long>(r.stats().visible_samples), inked, levels);
|
|
|
|
// The reduction ran, and on the *raw* path: the gate is the gathered density, not the level.
|
|
PLOT_CHECK(r.stats().column_traces == 1);
|
|
PLOT_CHECK(r.stats().visible_samples > 50'000u);
|
|
// One segment per column, so the frame's whole geometry -- grid and axis lines included --
|
|
// is bounded by the axis' width: 627 against the polyline's 60 054.
|
|
PLOT_CHECK(r.stats().staged_segments < static_cast<plot::u32>(a.w) + 64u);
|
|
// The ribbon is there, and its ink sits on a handful of coverage levels (4) rather than on a
|
|
// continuum of them (126).
|
|
PLOT_CHECK(inked > static_cast<plot::u32>(a.w));
|
|
PLOT_CHECK(levels <= 8u);
|
|
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// `Style::intensity_grading`: a reduced column is drawn **graded by dwell** -- bright
|
|
/// where the signal sits, faint where it only passes through -- which is the second half
|
|
/// of the moire answer. The columns alone removed the beat; this is what puts back the
|
|
/// information a flat band throws away, the way a phosphor display does.
|
|
///
|
|
/// The observable is the thing the feature claims and nothing weaker: within one column,
|
|
/// the rows at the **edges** of the band are brighter than the rows in its **middle**.
|
|
/// For a sine of one period per column that is exactly right -- it dwells at its turning
|
|
/// points and crosses its middle at full speed -- and it is a property of the *rendering*
|
|
/// rather than of the waveform, because the same frame drawn ungraded puts the band's
|
|
/// edge and its middle at the same coverage. Both frames are measured here, so what is
|
|
/// asserted is the difference between them.
|
|
///
|
|
/// It also pins what grading may *not* cost. The reduction still runs (`column_traces`),
|
|
/// so the beat is still gone; the geometry is still bounded by the axis' width times a
|
|
/// small factor, because a run is a stretch of equal tier and not a row; and the draw
|
|
/// calls are bounded by the steps, since the line pipeline's colour is a uniform.
|
|
PLOT_TEST(a_dense_trace_can_be_graded_by_dwell) {
|
|
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(fast_sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, 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, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
|
|
// The band's edges against its middle, averaged over columns across the axis. The
|
|
// shares are of the band's own height, so the reading does not depend on where the
|
|
// band happens to sit or on how tall it is: the outer fifth at each end against the
|
|
// middle fifth.
|
|
struct Profile {
|
|
plot::u32 edge = 0; ///< mean peak coverage over the band's two outer fifths
|
|
plot::u32 middle = 0; ///< mean peak coverage over its middle fifth
|
|
plot::u32 columns = 0; ///< columns that held a band at all
|
|
};
|
|
auto profile = [&](const plot::Rect& a) -> Profile {
|
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
|
std::vector<int> cover(rows, 0);
|
|
plot::u64 edge_sum = 0;
|
|
plot::u64 middle_sum = 0;
|
|
Profile out;
|
|
for (plot::u32 col = 8; col + 8 < static_cast<plot::u32>(a.w); col += 16) {
|
|
plot::u32 lo = rows;
|
|
plot::u32 hi = 0;
|
|
for (plot::u32 row = 0; row < rows; ++row) {
|
|
plot::u8 px[4] = {};
|
|
const plot::u32 x = static_cast<plot::u32>(a.x) + col;
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
|
cover[row] = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
|
if (cover[row] > 20) { // the faintest step is 0.3 of 191, so this is under it
|
|
if (row < lo) lo = row;
|
|
hi = row;
|
|
}
|
|
}
|
|
// A band of fewer than 15 rows has no middle fifth to speak of; at this density
|
|
// there is none, but the reading would be meaningless if there were.
|
|
if (lo >= hi || hi - lo < 15u) continue;
|
|
const plot::u32 h = hi - lo + 1u;
|
|
const plot::u32 fifth = h / 5u;
|
|
int edge = 0;
|
|
int middle = 0;
|
|
for (plot::u32 row = lo; row <= hi; ++row) {
|
|
const plot::u32 d = row - lo;
|
|
if (d < fifth || d >= h - fifth) {
|
|
if (cover[row] > edge) edge = cover[row];
|
|
} else if (d >= 2u * fifth && d < 3u * fifth) {
|
|
if (cover[row] > middle) middle = cover[row];
|
|
}
|
|
}
|
|
edge_sum += static_cast<plot::u64>(edge);
|
|
middle_sum += static_cast<plot::u64>(middle);
|
|
++out.columns;
|
|
}
|
|
if (out.columns != 0) {
|
|
out.edge = static_cast<plot::u32>(edge_sum / out.columns);
|
|
out.middle = static_cast<plot::u32>(middle_sum / out.columns);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// Ungraded first: the reference the grading is read against.
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
dump_if_requested(r, "ungraded.ppm");
|
|
const plot::Rect a = p.layout().axes;
|
|
const Profile flat = profile(a);
|
|
const plot::u32 flat_segments = r.stats().staged_segments;
|
|
const plot::u32 flat_draws = r.stats().draw_calls;
|
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
r.style().intensity_grading = true;
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
dump_if_requested(r, "graded.ppm");
|
|
const Profile lit = profile(a);
|
|
|
|
std::printf("[graded] axes=[%.1f %.1f %.1f %.1f] flat edge=%u middle=%u cols=%u seg=%u "
|
|
"draws=%u | graded edge=%u middle=%u cols=%u seg=%u draws=%u columns=%u "
|
|
"graded_traces=%u\n",
|
|
a.x, a.y, a.w, a.h, flat.edge, flat.middle, flat.columns, flat_segments,
|
|
flat_draws, lit.edge, lit.middle, lit.columns, r.stats().staged_segments,
|
|
r.stats().draw_calls, r.stats().column_traces, r.stats().graded_traces);
|
|
|
|
// Both frames measured a band in most of the columns they sampled, or the two
|
|
// profiles are not comparable and everything below is vacuous.
|
|
PLOT_CHECK(flat.columns > 20u && lit.columns == flat.columns);
|
|
// The flat band is flat: its edge and its middle are one ink (191 and 191).
|
|
PLOT_CHECK(flat.middle > 0u && flat.edge * 100u < flat.middle * 115u);
|
|
// The graded one is not, and in the direction a sine dwells in: 191 against 131, a
|
|
// middle at 0.69 of the edge. The figure is a *tier* ratio and not the dwell ratio --
|
|
// the per-row dwell at the rails is several times the crossing's, and
|
|
// `intensity_gamma` (0.45) and `intensity_floor` (0.3) are there to compress exactly
|
|
// that into a range an eye reads as shading rather than as a gap. So the threshold is
|
|
// what the quantised ink does, with room for a step either way.
|
|
PLOT_CHECK(lit.middle > 0u && lit.middle * 100u < lit.edge * 80u);
|
|
// The reduction still ran, so the beat the columns removed is still gone.
|
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
|
|
// A run is a stretch of equal tier, not a row: the geometry stays bounded by the
|
|
// axis' width times a small factor, nothing like `w * h`. Measured 8303 over a 572 px
|
|
// axis, 14.5 runs a column -- the dwell profile's own structure (four tiers from the
|
|
// dim middle out to each rail) plus the quantiser's dither at each boundary. The flat
|
|
// path's 627 is the other end of the scale, and 572 * 156 rows is what this is not.
|
|
PLOT_CHECK(r.stats().staged_segments < 24u * static_cast<plot::u32>(a.w));
|
|
// And the draw calls by the steps: one per tier that staged anything, over the flat
|
|
// frame's single trace draw.
|
|
PLOT_CHECK(r.stats().draw_calls <= flat_draws + r.style().intensity_steps);
|
|
|
|
// And the level floor, which the window above cannot see: at 105 samples a pixel the
|
|
// flat path is already on raw samples, so grading asks for nothing finer. A wider
|
|
// window puts the flat path on the pyramid, and there a sub-column is `spp / 8` -- which
|
|
// falls under one bucket long before the column does, so an unfloored ask answers level
|
|
// 0 and gathers the whole visible window. That is a real cost and it was measured: at
|
|
// 1.3 MS/s over a 2 s window udpscope's own frame report went from `raw=710 lod=453` to
|
|
// `raw=1154 lod=0` and 2.3 -> 6.4 ms a frame. The floor is one bucket level, so the
|
|
// reading is simply that a graded frame stays on the pyramid.
|
|
p.set_x_range(ax, {0.0, 200'000.0});
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
std::printf("[graded] wide: raw=%u lod=%u columns=%u graded=%u seg=%u visible=%llu\n",
|
|
r.stats().raw_traces, r.stats().lod_traces, r.stats().column_traces,
|
|
r.stats().graded_traces, r.stats().staged_segments,
|
|
static_cast<unsigned long long>(r.stats().visible_samples));
|
|
PLOT_CHECK(r.stats().visible_samples > 100'000u);
|
|
PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0);
|
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
|
|
PLOT_CHECK(r.stats().staged_segments < 24u * static_cast<plot::u32>(a.w));
|
|
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
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<plot::u32>(kH) - 1u - static_cast<plot::u32>(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<plot::u32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u32>(labels.y);
|
|
const plot::u32 rows = static_cast<plot::u32>(labels.h);
|
|
const plot::u32 strip_x = static_cast<plot::u32>(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<plot::u32>(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<plot::f32>(ink.widest_row_run) >= 0.6f * widest); // full width drawn
|
|
PLOT_CHECK(static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u32>(a.h); ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast<plot::u32>(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<plot::i32>(fx);
|
|
const plot::i32 cy = static_cast<plot::i32>(fy);
|
|
for (plot::i32 row = cy - radius; row <= cy + radius; ++row) {
|
|
if (row < 0 || row >= static_cast<plot::i32>(kH)) continue;
|
|
for (plot::i32 column = cx - radius; column <= cx + radius; ++column) {
|
|
if (column < 0 || column >= static_cast<plot::i32>(kW)) continue;
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(column),
|
|
ctx.height - 1u - static_cast<plot::u32>(row), px));
|
|
if (pred(px)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const plot::u32 x_a = static_cast<plot::u32>(tx.to_screen_x(100.0));
|
|
const plot::u32 x_b = static_cast<plot::u32>(tx.to_screen_x(300.0));
|
|
const plot::u32 mid = static_cast<plot::u32>(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<plot::u32>(tx.to_screen_x(40.0)), band_pixel));
|
|
// ... with its numeric readout drawn near the top of it.
|
|
PLOT_CHECK(box_has(static_cast<plot::f32>(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<plot::f32>(tx.to_screen_x(200.0)),
|
|
static_cast<plot::f32>(ty.to_screen_y(0.5)), 4, marker_pixel));
|
|
|
|
ctx.destroy();
|
|
}
|
|
|
|
// The grid is on the pixel grid. Its lines are staged in data space like every
|
|
// other line and mapped by the vertex shader, so a 1 px stroke's centre used to
|
|
// land wherever the view put it: a line at an x that maps to 100.0 spans
|
|
// [99.5, 100.5] and lights two columns at half coverage, while one that maps to
|
|
// 100.5 spans [100, 101] and lights one fully. A frame's gridlines were a mix of
|
|
// both -- a grey smear beside a crisp hairline -- and panning made each shimmer
|
|
// between the two. `LineProgram::set_snap()` rounds an axis-aligned segment's ink
|
|
// onto whole pixels, so the observable is sharp: **every lit pixel inside the axes
|
|
// rect is the grid colour exactly**, with no half-coverage blend anywhere.
|
|
//
|
|
// No trace and no minor grid, so the only thing drawn inside the rect is the major
|
|
// grid and the one colour it is drawn in; the rect is inset so the axis lines on
|
|
// its own edges, which are a different colour, stay out of the scan.
|
|
/// The minor grid is a *subdivision*, and the style says so three ways: thinner, dashed and
|
|
/// blended into the background. Found by reading a frame whose minor grid read as a second set
|
|
/// of ticks -- at full opacity and the same width as the labelled grid there was no hierarchy
|
|
/// to see.
|
|
///
|
|
/// The frame holds no trace, so inside `axes` the only elements are the two grid classes and
|
|
/// the background. A row off the horizontal lines then classifies every column by inspection:
|
|
/// background, an opaque major line (`pixel_is`, the same exactness `grid_hairlines_land_on_whole_pixels`
|
|
/// leans on), or the minor's blend -- which at these defaults is the only non-background pixel
|
|
/// in the frame that is neither. Width is checked as *coverage* rather than as a pixel count,
|
|
/// because at 1x a sub-pixel logical width is exactly a dimmer stroke: the ink is one column
|
|
/// either way, so `grid_minor_width` can only show up in how brightly that column is lit. On a
|
|
/// HiDPI display the same width is literally narrower.
|
|
PLOT_TEST(the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) {
|
|
plottest::HeadlessContext ctx(kW, kH);
|
|
plot::Status st;
|
|
PLOT_CHECK(ctx.create(&st));
|
|
PLOT_CHECK(plot::gl::load(nullptr));
|
|
plot::Renderer r;
|
|
PLOT_CHECK(r.init(&st));
|
|
PLOT_CHECK(r.style().grid_minor_visible);
|
|
PLOT_CHECK(r.style().grid_minor_dash_off > 0.0f); // the default is a dash, not a solid line
|
|
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
p.add_x_axis({"seconds", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::Rect& a = p.layout().axes;
|
|
const plot::Color bg = r.style().background;
|
|
const plot::Color major = r.style().grid_major;
|
|
// Distance from the background, per channel, so "dimmer" means "closer to the background"
|
|
// whatever the two colours are.
|
|
const auto bg_distance = [&](const plot::u8 px[4]) {
|
|
const auto d = [](plot::u8 v, plot::f32 want) {
|
|
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
|
|
};
|
|
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
|
|
};
|
|
|
|
// The row: the first one that holds all three kinds of pixel, so it is neither a horizontal
|
|
// grid line (everything major ink) nor a band the layout reserved for something else.
|
|
const plot::u32 first_row = static_cast<plot::u32>(a.y) + 3u;
|
|
const plot::u32 last_row = static_cast<plot::u32>(a.y + a.h) - 4u;
|
|
std::vector<plot::u32> major_cols;
|
|
std::vector<plot::u32> minor_cols;
|
|
plot::u32 scan_row = 0;
|
|
for (plot::u32 row = first_row; row < last_row; ++row) {
|
|
major_cols.clear();
|
|
minor_cols.clear();
|
|
plot::u32 background = 0;
|
|
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u;
|
|
cx + 3u < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
|
|
if (pixel_is(px, bg)) {
|
|
++background;
|
|
} else if (pixel_is(px, major)) {
|
|
major_cols.push_back(cx);
|
|
} else {
|
|
minor_cols.push_back(cx);
|
|
}
|
|
}
|
|
if (background > 0u && !major_cols.empty() && !minor_cols.empty()) {
|
|
scan_row = row;
|
|
break;
|
|
}
|
|
}
|
|
PLOT_CHECK(scan_row != 0u); // a row carrying both grid classes and the background
|
|
PLOT_CHECK(major_cols.size() >= 2u);
|
|
PLOT_CHECK(minor_cols.size() >= 2u);
|
|
|
|
// Solid against dashed, down the two columns: a major line is ink at every row, a minor one
|
|
// alternates -- which is the whole of "dashed" a pixel test can see. The duty cycle is
|
|
// checked as a range rather than as the exact 3/3 the style asks for, because the dash is a
|
|
// fragment discard and its ends land where they land.
|
|
const auto column_ink = [&](plot::u32 cx, plot::u32& ink, plot::u32& gaps) {
|
|
ink = 0;
|
|
gaps = 0;
|
|
for (plot::u32 row = first_row; row < last_row; ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
|
|
if (pixel_is(px, bg)) {
|
|
++gaps;
|
|
} else {
|
|
++ink;
|
|
}
|
|
}
|
|
};
|
|
plot::u32 ink = 0;
|
|
plot::u32 gaps = 0;
|
|
column_ink(major_cols.front(), ink, gaps);
|
|
PLOT_CHECK(gaps == 0u); // the labelled grid is continuous
|
|
const plot::u32 major_ink = ink;
|
|
|
|
column_ink(minor_cols.front(), ink, gaps);
|
|
PLOT_CHECK(ink > 0u && gaps > 0u); // dashed, not solid
|
|
PLOT_CHECK(ink * 5u > major_ink && gaps * 5u > major_ink); // roughly half on, half off
|
|
|
|
// Dimmer: the minor's ink sits closer to the background than the major's. This is also the
|
|
// "thinner" half of the contract at 1x -- see the note above the test.
|
|
const auto ink_distance = [&](plot::u32 cx) {
|
|
int best = 0;
|
|
for (plot::u32 row = first_row; row < last_row; ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
|
|
if (pixel_is(px, bg)) continue;
|
|
best = std::max(best, bg_distance(px));
|
|
}
|
|
return best;
|
|
};
|
|
const int major_bright = ink_distance(major_cols.front());
|
|
const int minor_bright = ink_distance(minor_cols.front());
|
|
PLOT_CHECK(minor_bright > 0);
|
|
PLOT_CHECK(minor_bright < major_bright);
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// A cursor's stroke is its own: a host that places *readout marks* on the data -- a measured
|
|
/// level, a peak, a pulse's edges -- wants them lighter and broken, so they read as annotations
|
|
/// over the data rather than as more chrome around it. Two cursors in one frame, one default and
|
|
/// one given a thin dashed stroke, so the comparison is in the same pixels: the dashed column
|
|
/// alternates ink and gap down its length while the solid one does not, and its ink sits closer
|
|
/// to the background, which at 1x is what a sub-pixel logical width *is*.
|
|
PLOT_TEST(a_cursor_stroke_can_be_thinner_and_dashed) {
|
|
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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
// Green: no other element of this frame is green-dominant (the grid is grey-dark and the axis
|
|
// line is chalk), so the predicate below can only find these two cursors.
|
|
const plot::Color ink = plot::Color::hex(0x00FF00);
|
|
p.add_cursor(3.0, ink);
|
|
const plot::u32 thin = p.add_cursor(7.0, ink);
|
|
p.set_cursor_snap(thin, plot::SnapMode::Free);
|
|
p.set_cursor_stroke(thin, 0.75f, plot::Vec2{3.0f, 3.0f});
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::Rect& a = p.layout().axes;
|
|
const plot::Color bg = r.style().background;
|
|
const auto px_at = [&](plot::u32 cx, plot::u32 cy, plot::u8 px[4]) {
|
|
return ctx.read_pixel(cx, ctx.height - 1u - cy, px);
|
|
};
|
|
const auto bg_distance = [&](const plot::u8 px[4]) {
|
|
const auto d = [](plot::u8 v, plot::f32 want) {
|
|
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
|
|
};
|
|
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
|
|
};
|
|
// The two cursor columns, as the *centre of each run* of green columns: a stroke with an
|
|
// anti-aliased edge covers two or three columns, so taking the first two hits found one cursor
|
|
// twice (238 and 239) and never looked at the other -- which is how this test first reported
|
|
// "neither is dashed" with both columns' ink identical.
|
|
plot::u32 cols[2] = {0u, 0u};
|
|
plot::u32 run_from[2] = {0u, 0u};
|
|
plot::u32 run_to[2] = {0u, 0u};
|
|
int found = 0;
|
|
bool in_run = false;
|
|
plot::u32 run_start = 0;
|
|
const auto close_run = [&](plot::u32 end_column) {
|
|
if (found >= 2) return;
|
|
cols[found] = (run_start + end_column) / 2u;
|
|
run_from[found] = run_start;
|
|
run_to[found] = end_column;
|
|
++found;
|
|
};
|
|
for (plot::u32 cx = static_cast<plot::u32>(a.x); cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
|
bool green = false;
|
|
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
|
|
row < static_cast<plot::u32>(a.y + a.h) - 4u && !green; ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(px_at(cx, row, px));
|
|
green = green_pixel(px);
|
|
}
|
|
if (green && !in_run) {
|
|
in_run = true;
|
|
run_start = cx;
|
|
} else if (!green && in_run) {
|
|
in_run = false;
|
|
close_run(cx - 1u);
|
|
}
|
|
}
|
|
if (in_run) close_run(static_cast<plot::u32>(a.x + a.w) - 1u);
|
|
PLOT_CHECK(found == 2);
|
|
plot::u32 ink_rows[2] = {0u, 0u};
|
|
plot::u32 gap_rows[2] = {0u, 0u};
|
|
// The ink each stroke lays down, summed over its whole run of columns: a cursor is a *data*
|
|
// position and so is deliberately not snapped, so which columns (and how brightly) a stroke
|
|
// covers depends on where its coordinate falls between pixels. Comparing one column's peak made
|
|
// the *solid* stroke look lighter than the thin one, because its ink happened to split across
|
|
// two columns. What is asked here is the thing the style states: less ink per unit length.
|
|
long long total_ink[2] = {0, 0};
|
|
for (int c = 0; c < 2; ++c) {
|
|
for (plot::u32 cx = run_from[c]; cx <= run_to[c]; ++cx) {
|
|
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
|
|
row < static_cast<plot::u32>(a.y + a.h) - 4u; ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(px_at(cx, row, px));
|
|
if (green_pixel(px)) {
|
|
if (cx == cols[c]) ++ink_rows[c];
|
|
total_ink[c] += bg_distance(px);
|
|
} else if (pixel_is(px, bg) && cx == cols[c]) {
|
|
++gap_rows[c];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
std::printf("[cursor-stroke] solid x=%u [%u..%u] ink=%u gaps=%u total=%lld; "
|
|
"thin x=%u [%u..%u] ink=%u gaps=%u total=%lld\n",
|
|
cols[0], run_from[0], run_to[0], ink_rows[0], gap_rows[0], total_ink[0], cols[1],
|
|
run_from[1], run_to[1], ink_rows[1], gap_rows[1], total_ink[1]);
|
|
PLOT_CHECK(gap_rows[0] == 0u); // the default stroke is continuous
|
|
PLOT_CHECK(ink_rows[1] > 0u && gap_rows[1] > 0u); // the custom one is dashed
|
|
PLOT_CHECK(total_ink[1] < total_ink[0]); // and lays down less ink
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// A window sparse enough that its samples *are* the shape gets a marker on each one. Ten samples
|
|
/// across a panel are places a reader counts, and the polyline between them is not the shape any
|
|
/// more; a hundred are a curve and the markers would be noise, which is what the threshold is for.
|
|
/// The test reads the vertical run of trace-coloured pixels per column: a marker is `marker_px`
|
|
/// across, so at a sample's own column the run is taller than the line, and at a midpoint it is
|
|
/// the line alone. The dense case proves the second half -- same series shape, no column taller
|
|
/// than the stroke.
|
|
PLOT_TEST(a_sparse_window_marks_its_samples) {
|
|
plottest::HeadlessContext ctx(kW, kH);
|
|
plot::Status st;
|
|
PLOT_CHECK(ctx.create(&st));
|
|
PLOT_CHECK(plot::gl::load(nullptr));
|
|
plot::Renderer r;
|
|
PLOT_CHECK(r.init(&st));
|
|
PLOT_CHECK(r.style().marker_below_samples >= 50.0f); // the default is the spec's 50
|
|
|
|
const auto run_at = [&](plot::Plot& p, const plot::Rect& a) {
|
|
const plot::u32 first_row = static_cast<plot::u32>(a.y);
|
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
|
std::vector<plot::u32> tall;
|
|
for (plot::u32 cx = static_cast<plot::u32>(a.x);
|
|
cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
|
plot::u32 best = 0;
|
|
plot::u32 run = 0;
|
|
for (plot::u32 yy = 0; yy < rows; ++yy) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (first_row + yy), px));
|
|
if (green_pixel(px)) {
|
|
++run;
|
|
best = std::max(best, run);
|
|
} else {
|
|
run = 0;
|
|
}
|
|
}
|
|
if (best >= 3u) tall.push_back(cx);
|
|
}
|
|
(void)p;
|
|
return tall;
|
|
};
|
|
// The dots *group*: a marker is `marker_px` wide, so it spans several adjacent columns. What
|
|
// identifies one is a run of tall columns, exactly as a cursor's stroke spans two -- the first
|
|
// version of this test counted columns and read three markers per sample.
|
|
const auto groups = [](const std::vector<plot::u32>& cols) {
|
|
std::vector<plot::u32> centres;
|
|
for (std::size_t i = 0; i < cols.size();) {
|
|
std::size_t j = i;
|
|
while (j + 1 < cols.size() && cols[j + 1] == cols[j] + 1u) ++j;
|
|
centres.push_back((cols[i] + cols[j]) / 2u);
|
|
i = j + 1;
|
|
}
|
|
return centres;
|
|
};
|
|
|
|
// Ten samples, a flat line: every sample is a place, and none of them is a bend.
|
|
const auto sparse = [&](plot::u32 samples, const char* what) {
|
|
std::vector<plot::f32> y(samples, 0.25f);
|
|
const plot::data::ArraySeries series(y, 0.0, 10.0 / static_cast<plot::f64>(samples));
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
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", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&series, plot::Color::hex(0x00FF00), 1.5f, ax, ay});
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u32> tall = run_at(p, p.layout().axes);
|
|
const std::vector<plot::u32> centres = groups(tall);
|
|
std::printf("[markers] %s: %zu columns, %zu groups, visible=%llu\n", what, tall.size(),
|
|
centres.size(), static_cast<unsigned long long>(r.stats().visible_samples));
|
|
return centres;
|
|
};
|
|
|
|
const std::vector<plot::u32> ten = sparse(10u, "10 samples");
|
|
PLOT_CHECK(ten.size() == 10u); // one marker per sample
|
|
// ... and at the samples: ten of them across the panel, evenly spaced. This is the assertion
|
|
// that says the dots are *the samples* and not something the style happened to paint.
|
|
const double step = static_cast<double>(ten.back() - ten.front()) / 9.0;
|
|
for (std::size_t i = 1; i < ten.size(); ++i) {
|
|
PLOT_CHECK_NEAR(static_cast<double>(ten[i] - ten[i - 1]), step, 2.0);
|
|
}
|
|
// A hundred samples: the same shape, past the threshold, and no group is a dot.
|
|
const std::vector<plot::u32> hundred = sparse(100u, "100 samples");
|
|
PLOT_CHECK(hundred.size() <= 5u);
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// A ring-backed series whose oldest slots read a *clamped* stamp gets no segment across the panel.
|
|
///
|
|
/// `ChannelBuffer`'s documented bargain: the published window is pinned while both rings slide under
|
|
/// it, so the oldest few slots of a saturated window point at time slots the producer has
|
|
/// overwritten and have to be clamped to the oldest *readable* one -- the alternative, a sentinel,
|
|
/// collapsed the whole index mapping. The effect on the polyline is that the stamps do not increase
|
|
/// at the *front*: the first points carry the newest stamp and everything behind them is older. Drawn
|
|
/// as they come, that is a segment from the newest sample back to the oldest: a line across the whole
|
|
/// panel, which is what "it connects the newest point with the oldest" looks like.
|
|
///
|
|
/// The trace here is flat at +1 except for those two unreadable points, which carry -1. Any ink in the
|
|
/// lower half of the axes is therefore the bad segment, and nothing else in the frame is that colour.
|
|
PLOT_TEST(a_clamped_front_does_not_draw_a_segment_across_the_panel) {
|
|
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));
|
|
|
|
/// Eight samples: indices 0 and 1 are the clamped front (newest stamp, low value), 2..7 walk 0..50
|
|
/// at +1.
|
|
struct ClampedFront final : plot::data::Series {
|
|
ClampedFront() {
|
|
pyramid_.reserve(8u);
|
|
for (u64 i = 0; i < 8u; ++i) pyramid_.append(i < 2u ? -1.0f : 1.0f);
|
|
}
|
|
u64 size() const override { return 8u; }
|
|
f64 x0() const override { return 0.0; }
|
|
f64 dx() const override { return 1.0; }
|
|
plot::Range x_bounds() const override { return {0.0, 60.0}; }
|
|
plot::Range y_bounds() const override { return {-1.0, 1.0}; }
|
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
|
f64 x_at(u64 i) const override { return i < 2u ? 60.0 : 10.0 * static_cast<f64>(i - 2u); }
|
|
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
|
if (x < 0.0 || x > 60.0) return false;
|
|
const u64 k = std::min<u64>(2u + static_cast<u64>(x / 10.0), 7u);
|
|
i0 = k;
|
|
i1 = k;
|
|
if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u;
|
|
return true;
|
|
}
|
|
void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
|
|
plot::data::GatherMode) const override {
|
|
for (u64 i = begin; i < end; ++i) {
|
|
out.push_back(static_cast<f32>(x_at(i) - x_origin));
|
|
out.push_back(i < 2u ? -1.0f : 1.0f);
|
|
}
|
|
}
|
|
|
|
protected:
|
|
void copy_raw(u64 begin, u64 end, f32* out) const override {
|
|
for (u64 i = begin; i < end; ++i) out[i - begin] = i < 2u ? -1.0f : 1.0f;
|
|
}
|
|
|
|
private:
|
|
plot::data::MinMaxPyramid pyramid_;
|
|
} series;
|
|
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {-5.0, 55.0}, false, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&series, plot::Color::hex(0x00FF00), 1.5f, ax, ay});
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::Rect& a = p.layout().axes;
|
|
plot::u32 below = 0;
|
|
plot::u32 on_the_line = 0;
|
|
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u; cx + 3u < static_cast<plot::u32>(a.x + a.w);
|
|
++cx) {
|
|
for (plot::u32 row = static_cast<plot::u32>(a.h / 2u) + 2u;
|
|
row + 2u < static_cast<plot::u32>(a.h); ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
|
if (green_pixel(px)) ++below;
|
|
}
|
|
}
|
|
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u; cx + 3u < static_cast<plot::u32>(a.x + a.w);
|
|
++cx) {
|
|
for (plot::u32 row = static_cast<plot::u32>(a.h / 2u) - 2u;
|
|
row > 1u; --row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
|
if (green_pixel(px)) ++on_the_line;
|
|
}
|
|
}
|
|
std::printf("[clamped-front] ink below the middle: %u, above: %u\n", below, on_the_line);
|
|
PLOT_CHECK(below == 0u); // the two clamped points' segments are nowhere
|
|
PLOT_CHECK(on_the_line > 100u); // and the real trace is still drawn
|
|
ctx.destroy();
|
|
}
|
|
|
|
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
|
|
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));
|
|
r.style().grid_minor_visible = false;
|
|
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
p.add_x_axis({"seconds", {0.0, 10.0}, true, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::Rect& a = p.layout().axes;
|
|
const plot::Color grid = r.style().grid_major;
|
|
const plot::Color background = r.style().background;
|
|
plot::u32 lit = 0;
|
|
for (plot::u32 row = 3u; row + 3u < static_cast<plot::u32>(a.h); ++row) {
|
|
for (plot::u32 column = 3u; column + 3u < static_cast<plot::u32>(a.w); ++column) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + column,
|
|
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
|
if (pixel_is(px, background)) continue;
|
|
PLOT_CHECK(pixel_is(px, grid)); // full coverage, not a blend of grid and background
|
|
++lit;
|
|
}
|
|
}
|
|
PLOT_CHECK(lit > static_cast<plot::u32>(a.h)); // a grid was drawn at all
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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 holds the title colour by `pred`'s reading of it. The
|
|
// two directions of this test need different readings, and for the reason
|
|
// `rect_has_exact` below is written for: inside `axes` only the *exact* colour
|
|
// means "a title is here", because a third of a chalk tone is under the axis
|
|
// stroke's own grey, so an ink scan there would find the axis; in the reserved
|
|
// strips, where nothing but the title draws, the ink scan is the one that can
|
|
// see a filtered glyph at all (see `text_ink`).
|
|
auto area_has_title = [&](const plot::Rect& area, auto pred) {
|
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
|
|
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
|
|
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
|
|
if (pred(px, title)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const auto exact = [](const plot::u8* px, plot::Color c) { return pixel_is(px, c); };
|
|
const auto ink = [](const plot::u8* px, plot::Color c) { return text_ink(px, c); };
|
|
|
|
// 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}, exact));
|
|
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}, exact));
|
|
|
|
// ... 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, ink));
|
|
PLOT_CHECK(area_has_title(layout.y_title_area, ink));
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kPanelW), static_cast<plot::f32>(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<plot::f32>(kPanelW), static_cast<plot::f32>(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<plot::f32>(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<plot::u32>(area.h); ++row) {
|
|
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
|
|
ctx.height - 1u - (static_cast<plot::u32>(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<plot::f32>(kPanelW), static_cast<plot::f32>(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<plot::f32>(kPanelW), static_cast<plot::f32>(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<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u32>(strip.h); ++row) {
|
|
bool has = false;
|
|
for (plot::u32 column = 0; column < static_cast<plot::u32>(strip.w) && !has; ++column) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(strip.x) + column,
|
|
ctx.height - 1u - (static_cast<plot::u32>(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<plot::u32>(area.y);
|
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
|
x < static_cast<plot::u32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u32>(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<plot::u32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<InkBand> 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<plot::f32>(column.first), layout.y_title_area.y,
|
|
static_cast<plot::f32>(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<plot::f32>(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<plot::f32>(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<plot::u32>(strip.h));
|
|
PLOT_CHECK(drawn.box.height() <= static_cast<plot::u32>(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<plot::f32>(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<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
|
plot.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
// A milli-scale range, for the *width of its labels*: a symmetric span ticks at
|
|
// +/-step and 0, so the labels here are `-200m 0 200m` -- five characters, wide
|
|
// enough that the label strip alone spends the whole chrome allowance and the
|
|
// title strip is left zero wide, which is the regime asserted below. A `[-1, 1]`
|
|
// axis labels `-1 0 1` instead and does not fill the allowance: the titled scene
|
|
// then keeps a 9.6 px title column -- still too narrow to stage a glyph, so the
|
|
// property holds -- but the column also pushes the label strip 9.6 px right, and
|
|
// the frame comparison would be reading that shift rather than title ink.
|
|
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
|
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
|
|
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
|
return plot;
|
|
};
|
|
const auto render = [&](plot::Plot& plot) {
|
|
r.begin_frame(kNarrowW, kNarrowH,
|
|
{0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(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<plot::u8> 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);
|
|
// Zero wide, not merely under one column -- the labels spend the allowance whole.
|
|
// That is this comparison's own premise as much as the clamp's: a strip with any
|
|
// width left in it would move the label strip right in the titled scene alone, and
|
|
// the two frames would then differ over the labels rather than over title ink.
|
|
PLOT_CHECK(layout.y_title_area.w == 0.0f);
|
|
// ... 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<plot::f32>(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<plot::u8> without_titles = read_frame(ctx);
|
|
|
|
plot::u32 differing = 0u;
|
|
plot::u32 first = 0u;
|
|
for (plot::u32 i = 0; i < static_cast<plot::u32>(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 some of the three columns and not all
|
|
// of them: the label strip takes 44 px of the 85 px allowance and a column needs
|
|
// 19.7, so the 41 px left over hold two. The assertions below re-derive the count
|
|
// from the measured strip rather than assume it, which is what lets the regime be
|
|
// "at least one and fewer than three" instead of a pixel figure that drifts with
|
|
// the font, the device scale or the tick labels.
|
|
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<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
|
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
// Milli-scale for the label width, as in the zero-wide-strip case above: the strip
|
|
// this test wants is the one that holds *some* columns and not all three, and what
|
|
// it has left to hold them in is the allowance minus the label strip.
|
|
p.add_y_axis({"volts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
|
p.add_y_axis({"amps", {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
|
|
p.add_y_axis({"watts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
|
r.begin_frame(kNarrowW, kNarrowH,
|
|
{0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(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<plot::u32>(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<plot::f32>(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<InkBand> columns = ink_column_bands(ctx, strip, ink);
|
|
PLOT_CHECK(columns.size() == fits);
|
|
for (plot::u32 i = 0; i < static_cast<plot::u32>(columns.size()); ++i) {
|
|
PLOT_CHECK(columns[i].first >=
|
|
static_cast<plot::u32>(strip.x + pad + static_cast<plot::f32>(i) * line));
|
|
PLOT_CHECK(columns[i].last <=
|
|
static_cast<plot::u32>(strip.x + pad + static_cast<plot::f32>(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<plot::f32>(kShortW), static_cast<plot::f32>(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<plot::f32>(kShortW), static_cast<plot::f32>(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<plot::u32>(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<plot::f32>(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<plot::f32>(kShortW), static_cast<plot::f32>(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<plot::f32>(kShortW), static_cast<plot::f32>(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<plot::u32>(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<plot::f32>(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<InkBand> rows = ink_row_bands(ctx, strip, ink);
|
|
PLOT_CHECK(rows.size() == fits);
|
|
for (plot::u32 i = 0; i < static_cast<plot::u32>(rows.size()); ++i) {
|
|
PLOT_CHECK(rows[i].first >=
|
|
static_cast<plot::u32>(strip.y + pad + static_cast<plot::f32>(i) * line));
|
|
PLOT_CHECK(rows[i].last <=
|
|
static_cast<plot::u32>(strip.y + pad + static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(i);
|
|
|
|
auto counter = [](const std::atomic<plot::u64>& 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u64>& 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u32>(font.glyph(static_cast<plot::u32>('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<plot::u32>(pen.x));
|
|
PLOT_CHECK(flat.y0 >= static_cast<plot::u32>(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<plot::u32>(pen.x));
|
|
PLOT_CHECK(up.x1 < static_cast<plot::u32>(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<plot::u32>(pen.y));
|
|
PLOT_CHECK(up.y1 < static_cast<plot::u32>(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<plot::u32>(at.x),
|
|
ctx.height - 1u - static_cast<plot::u32>(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<plot::u32>(x),
|
|
ctx.height - 1u - static_cast<plot::u32>(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();
|
|
}
|
|
|
|
// The text pipeline filters the atlas; it does not point-sample it. The two halves
|
|
// of the old behaviour -- `GL_NEAREST` plus one `floor(v_uv * u_tex_size)` fetch,
|
|
// and `AxisFont` drawing the atlas at `requested_px / em_px` -- were each
|
|
// reasonable and jointly wrong: a point fetch at a non-integer scale *deletes*
|
|
// texels instead of blending them, and the library's own default is a 12 px style
|
|
// on the 13 px embedded bake, which dropped one of every 8 columns and one of every
|
|
// 17 rows of every glyph. Whole strokes, and which stroke went depended on where the
|
|
// unsnapped pen fell.
|
|
//
|
|
// A checkerboard is the artwork that pins it without depending on a face's shapes.
|
|
// At half scale every fragment's footprint spans a 2x2 of it, so a filter must
|
|
// return mid grey everywhere, while a point fetch lands on one parity for every
|
|
// fragment and returns a solid block -- a 255 or a 0, not a 128. And at scale 1 with
|
|
// a whole pen the two taps coincide (`f == 0` exactly), so the second half of the
|
|
// case is that filtering cost nothing there: the checkerboard comes back as a
|
|
// checkerboard, every pixel either full ink or none.
|
|
PLOT_TEST(the_atlas_is_filtered_not_point_sampled) {
|
|
plottest::HeadlessContext ctx(64, 64);
|
|
plot::Status st;
|
|
PLOT_CHECK(ctx.create(&st));
|
|
PLOT_CHECK(plot::gl::load(nullptr));
|
|
|
|
const plot::Vec2 pen{8.0f, 8.0f}; // whole pixels: the best case for a point fetch
|
|
BakeableBackend backend;
|
|
backend.bake_checkerboard(8u);
|
|
plot::render::TextAtlas atlas;
|
|
|
|
auto red = [&](plot::u32 x, plot::u32 y) {
|
|
plot::u8 pxv[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, pxv));
|
|
return static_cast<int>(pxv[0]);
|
|
};
|
|
|
|
// Scale 1: an 8x8 quad, and the checkerboard survives as itself.
|
|
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 1.0f, {1.0f, 0.0f}).any());
|
|
int crisp = 0;
|
|
for (plot::u32 row = 0; row < 8u; ++row) {
|
|
for (plot::u32 column = 0; column < 8u; ++column) {
|
|
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
|
|
PLOT_CHECK(v < 8 || v > 247); // one texel or the other, never a blend of both
|
|
if (v > 247) ++crisp;
|
|
}
|
|
}
|
|
PLOT_CHECK(crisp == 32); // half the cell, which is what a checkerboard is
|
|
|
|
// Scale 0.5: a 4x4 quad, and every pixel of it is the average of a 2x2 of the
|
|
// checkerboard. A point fetch gives 255 or 0 here, so the band is the assertion.
|
|
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 0.5f, {1.0f, 0.0f}).any());
|
|
for (plot::u32 row = 0; row < 4u; ++row) {
|
|
for (plot::u32 column = 0; column < 4u; ++column) {
|
|
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
|
|
PLOT_CHECK(v > 96 && v < 160);
|
|
}
|
|
}
|
|
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<plot::f32>(font.atlas_width()), 1e-6);
|
|
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(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<plot::u8>& frame, plot::u32 fb_w, const plot::Rect& area,
|
|
plot::Color c) {
|
|
plot::u32 count = 0;
|
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
|
x < static_cast<plot::u32>(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<plot::u32>(axes.h); ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast<plot::u32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8>& frame, plot::u32 fb_w,
|
|
const plot::Rect& area, bool (*pred)(const plot::u8*)) {
|
|
plot::u32 count = 0;
|
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
|
x < static_cast<plot::u32>(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<plot::f32>(64u, 0.75f), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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(); // copy: set_legend() below swaps the member
|
|
const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) *
|
|
static_cast<plot::u32>(cfg.swatch_h);
|
|
|
|
const std::vector<plot::u8> 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<plot::u32>(cfg.swatch_w) -
|
|
2u * static_cast<plot::u32>(cfg.swatch_h));
|
|
PLOT_CHECK(anywhere <= swatch_area + 2u * static_cast<plot::u32>(cfg.swatch_w) +
|
|
2u * static_cast<plot::u32>(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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> 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<plot::f32>(x) + 0.5f,
|
|
static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::u32>(cfg.swatch_w - 2.0f) *
|
|
static_cast<plot::u32>(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<plot::f32>(kW),
|
|
static_cast<plot::f32>(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();
|
|
}
|
|
|
|
/// `Plot::set_x_note()`'s caption is *chrome*: it sits in the bottom-left corner of the
|
|
/// axes on the legend's own backplate, and panning or zooming the x range does not move
|
|
/// it by a pixel. That is the whole reason it exists rather than an `Annotation`, whose
|
|
/// anchor is a data coordinate and so crawls under a pan and drifts under a zoom.
|
|
///
|
|
/// The caption's rect is deliberately not readable back -- it reserves no space and
|
|
/// nothing can be picked on it -- so the test measures it the only way a reader can see
|
|
/// it: the pixels a frame *with* the note differs from the same frame without one. The
|
|
/// bounding box of that difference is the caption's footprint, and the assertion is that
|
|
/// two completely different x ranges produce the *same* box.
|
|
PLOT_TEST(the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move) {
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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});
|
|
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
|
|
|
|
// The footprint of the note at one x range: the bounding box of the pixels that the
|
|
// caption, and only the caption, is responsible for.
|
|
const auto footprint = [&](const plot::Range& view, const char* dump) {
|
|
plot::Rect box{0.0f, 0.0f, 0.0f, 0.0f};
|
|
p.set_x_range(ax, view);
|
|
p.set_x_note("t0 = 1.23457e+06 s");
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, dump);
|
|
const std::vector<plot::u8> with = read_frame(ctx);
|
|
p.set_x_note("");
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> without = read_frame(ctx);
|
|
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0;
|
|
for (plot::u32 y = 0; y < kH; ++y) {
|
|
for (plot::u32 x = 0; x < kW; ++x) {
|
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
|
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
|
|
with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) {
|
|
continue;
|
|
}
|
|
++differing;
|
|
lo_x = std::min(lo_x, x);
|
|
lo_y = std::min(lo_y, y);
|
|
hi_x = std::max(hi_x, x);
|
|
hi_y = std::max(hi_y, y);
|
|
}
|
|
}
|
|
// A comparison of two frames is only evidence if the caption was in one of them.
|
|
PLOT_CHECK(differing > 0u);
|
|
box = plot::Rect{static_cast<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
|
|
static_cast<plot::f32>(hi_x - lo_x + 1u),
|
|
static_cast<plot::f32>(hi_y - lo_y + 1u)};
|
|
return box;
|
|
};
|
|
|
|
const plot::Rect wide = footprint({0.0, 640.0}, "x_note.ppm");
|
|
// The corner: inside the axes, held off the left and bottom edges by a pad of a few
|
|
// pixels, and nowhere near the right-hand side or the top.
|
|
const plot::Rect axes = p.layout().axes;
|
|
PLOT_CHECK(wide.x > axes.x && wide.x < axes.x + 16.0f);
|
|
PLOT_CHECK(wide.bottom() <= axes.bottom() && wide.bottom() > axes.bottom() - 16.0f);
|
|
PLOT_CHECK(wide.right() < axes.x + 0.5f * axes.w); // a caption, not a banner
|
|
PLOT_CHECK(wide.h >= 8.0f && wide.h < 0.5f * axes.h);
|
|
// Real glyph ink on the backplate, so the box is a caption and not an empty panel. The
|
|
// frame has to be re-drawn *with* the note first: the measurement above left the
|
|
// note-less frame in the buffer, and `scan_rect_ink` reads the live one.
|
|
p.set_x_range(ax, {0.0, 640.0});
|
|
p.set_x_note("t0 = 1.23457e+06 s");
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
PLOT_CHECK(scan_rect_ink(ctx, wide, r.style().axis_text).pixels >= 16u);
|
|
|
|
// The same caption under a pan to a different decade and a 64x zoom: an annotation
|
|
// anchored in data would have left the frame entirely, and the one here has not moved
|
|
// a pixel. The text is fixed across the three, since this case is about the position.
|
|
const plot::Rect panned = footprint({320.0, 960.0}, "x_note_panned.ppm");
|
|
const plot::Rect zoomed = footprint({300.0, 310.0}, "x_note_zoomed.ppm");
|
|
PLOT_CHECK_NEAR(panned.x, wide.x, 1e-4);
|
|
PLOT_CHECK_NEAR(panned.y, wide.y, 1e-4);
|
|
PLOT_CHECK_NEAR(panned.w, wide.w, 1e-4);
|
|
PLOT_CHECK_NEAR(panned.h, wide.h, 1e-4);
|
|
PLOT_CHECK_NEAR(zoomed.x, wide.x, 1e-4);
|
|
PLOT_CHECK_NEAR(zoomed.y, wide.y, 1e-4);
|
|
PLOT_CHECK_NEAR(zoomed.w, wide.w, 1e-4);
|
|
PLOT_CHECK_NEAR(zoomed.h, wide.h, 1e-4);
|
|
std::printf("[x note] footprint %.0f,%.0f %.0fx%.0f in axes %.0f,%.0f %.0fx%.0f; panned "
|
|
"%.0f,%.0f %.0fx%.0f, zoomed %.0f,%.0f %.0fx%.0f\n",
|
|
wide.x, wide.y, wide.w, wide.h, axes.x, axes.y, axes.w, axes.h, panned.x, panned.y,
|
|
panned.w, panned.h, zoomed.x, zoomed.y, zoomed.w, zoomed.h);
|
|
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<plot::f32>(got) >= want * 255.0f * 0.33f;
|
|
};
|
|
const bool grey = static_cast<int>(px[2]) - static_cast<int>(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<plot::u32>(static_cast<plot::f32>(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<plot::f32> ys(256);
|
|
for (plot::u64 i = 0; i < ys.size(); ++i) {
|
|
ys[i] = static_cast<plot::f32>(std::sin(static_cast<double>(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<plot::u32>(scale * static_cast<plot::f32>(kW));
|
|
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
|
|
const plot::Rect vp{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(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<plot::u32>(lay.y_label_area.x),
|
|
static_cast<plot::u32>(lay.y_label_area.right()),
|
|
static_cast<plot::u32>(lay.y_label_area.y),
|
|
static_cast<plot::u32>(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<plot::f32>(two.ink_run), k * static_cast<plot::f32>(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<plot::f32> y(n);
|
|
for (plot::u64 i = 0; i < n; ++i) {
|
|
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(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<plot::u32>(scale * static_cast<plot::f32>(kW));
|
|
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
|
|
plot::data::ArraySeries s(series_of(static_cast<plot::u64>(scale) * 1'000'000u), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(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<plot::f32>(w), static_cast<plot::f32>(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<plot::f64>(r.stats().visible_samples) / static_cast<plot::f64>(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<plot::f64>(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<double>(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<plot::f32> 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<plot::u32>(scale * static_cast<plot::f32>(kW));
|
|
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(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<plot::f32>(w), static_cast<plot::f32>(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<plot::u32>(p.y_transform(ay).to_screen_y(0.5));
|
|
const plot::u32 tx0 = static_cast<plot::u32>(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<plot::u32>(lay.axes.x) - 2u,
|
|
static_cast<plot::u32>(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<plot::f32>(two.trace) / static_cast<plot::f32>(one.trace);
|
|
const plot::f32 axis_ratio =
|
|
static_cast<plot::f32>(two.axis) / static_cast<plot::f32>(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<double>(trace_ratio),
|
|
static_cast<double>(axis_ratio),
|
|
static_cast<double>(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<plot::u8> pixels; ///< the whole framebuffer, top-down
|
|
plot::u32 fb_w = 0u;
|
|
plot::PlotLayout layout;
|
|
std::vector<plot::Tick> x_ticks;
|
|
std::vector<plot::Tick> 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<plot::u32>(scale * static_cast<plot::f32>(kW));
|
|
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
|
|
const plot::Rect vp{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(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<plot::f64> 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<plot::f32>(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<plot::f64>(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<plot::f32>(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<plot::u32>(window.y);
|
|
y < static_cast<plot::u32>(window.bottom()); ++y) {
|
|
for (plot::u32 x = static_cast<plot::u32>(window.x);
|
|
x < static_cast<plot::u32>(window.right()); ++x) {
|
|
ink += pixel_coverage(&f.pixels[(plot::usize(y) * f.fb_w + x) * 4u], bg, c);
|
|
}
|
|
}
|
|
return ink;
|
|
}
|
|
|
|
/// The ink one minor line is expected to leave in a `kWindowRows`-tall window, **from the
|
|
/// style**: its width in device pixels, its colour's alpha (the line shader multiplies it in)
|
|
/// and the dash's duty cycle. Written out rather than as a literal, because the minor class'
|
|
/// defaults are deliberately a fraction of a solid opaque line -- a threshold saying "most of
|
|
/// eight rows" stopped describing the class the moment it gained a dash, while it is the style
|
|
/// that says how much ink a class is *meant* to leave.
|
|
plot::f32 minor_line_ink(const plot::render::Style& style) {
|
|
const plot::f32 duty =
|
|
style.grid_minor_dash_off > 0.0f
|
|
? style.grid_minor_dash_on / (style.grid_minor_dash_on + style.grid_minor_dash_off)
|
|
: 1.0f;
|
|
return kWindowRows * style.device(style.grid_minor_width) * style.grid_minor.a * duty;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// Spec §11's per-class switch, on the minor class: a class the style switches off is
|
|
/// 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 stroke holds most of the ink
|
|
// that stroke is meant to leave. For the major class that is the window's own height; for the
|
|
// minor class it is what its width, alpha and dash ask for, so the threshold follows the
|
|
// style instead of a literal the class is no longer drawn to.
|
|
PLOT_CHECK(major_before > 0.75f * kWindowRows);
|
|
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
|
|
|
|
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<double>(major_before), static_cast<double>(major_after),
|
|
static_cast<double>(minor_before), static_cast<double>(minor_after),
|
|
static_cast<double>(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 * minor_line_ink(r.style()));
|
|
|
|
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<double>(major_before), static_cast<double>(major_after),
|
|
static_cast<double>(other_after), static_cast<double>(minor_before),
|
|
static_cast<double>(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;
|
|
|
|
// Widths are this test's subject, so the minor class' dash and alpha are stated away first:
|
|
// they would multiply every count and the ratio below, and their own defaults are the subject
|
|
// of `the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`.
|
|
r.style().grid_minor_dash_off = 0.0f;
|
|
r.style().grid_minor = plot::Color::hex(0x1C2126);
|
|
|
|
const GridFrame one = draw_grid_scene(ctx, r, 1.0f);
|
|
const GridWindows win = grid_windows(one);
|
|
const plot::f32 major_1x = window_ink(one, win.major, bg, r.style().grid_major);
|
|
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<double>(major_1x), static_cast<double>(minor_1x),
|
|
static_cast<double>(ratio_1x), static_cast<double>(major_2x),
|
|
static_cast<double>(minor_2x), static_cast<double>(major_2x / minor_2x),
|
|
static_cast<double>(r.style().device(r.style().grid_major_width)),
|
|
static_cast<double>(r.style().device(r.style().grid_minor_width)),
|
|
static_cast<double>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "regions.ppm");
|
|
|
|
const std::vector<plot::u8> 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<plot::f64>(rl.fill.h), static_cast<plot::f64>(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<plot::f64>(rl.fill.x),
|
|
static_cast<plot::f64>(plot::handle_column(rl.left_handle)), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.fill.right()),
|
|
static_cast<plot::f64>(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<plot::u32>(rl.fill.w) * static_cast<plot::u32>(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::u32>(plot::handle_column(rl.left_handle));
|
|
const plot::u32 right_col =
|
|
static_cast<plot::u32>(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<plot::f32>(left_col) - 1.0f, axes.y, 3.0f,
|
|
axes.h + 1.0f};
|
|
const plot::Rect right_band{static_cast<plot::f32>(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<plot::u32>(axes.h) / 2u);
|
|
PLOT_CHECK(right_edge >= static_cast<plot::u32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::Color accent = r.style().cursor;
|
|
|
|
auto draw_frame = [&] {
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::u8> 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<plot::u32>(tab.h) * (static_cast<plot::u32>(tab.w) - 2u));
|
|
PLOT_CHECK(hot <= static_cast<plot::u32>(tab.h) * (static_cast<plot::u32>(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<plot::f32>(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<plot::u8> 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::f64>(plot::handle_column(moved_tab)),
|
|
static_cast<plot::f64>(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<plot::f32>(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<plot::f32>(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<plot::u8> 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<plot::f32> wave(plot::u32 n, plot::f32 phase) {
|
|
std::vector<plot::f32> y(n);
|
|
for (plot::u32 i = 0; i < n; ++i) {
|
|
y[i] = 0.8f * std::sin(phase + 0.05f * static_cast<plot::f32>(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<plot::f32> top = wave(640u, 0.0f);
|
|
const std::vector<plot::f32> 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::f64>(layout0.axes.h), static_cast<plot::f64>(row0.h), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout1.axes.bottom() + layout1.x_label_area.h +
|
|
layout1.x_title_height),
|
|
static_cast<plot::f64>(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<plot::f32> 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f64>(layout.rows.back().bottom()),
|
|
static_cast<plot::f64>(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<plot::f64>(layout.x_axis_strip.x),
|
|
static_cast<plot::f64>(bottom_row_layout.x_label_area.x), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.y),
|
|
static_cast<plot::f64>(bottom_row_layout.x_label_area.y), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.w),
|
|
static_cast<plot::f64>(bottom_row_layout.x_label_area.w), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.h),
|
|
static_cast<plot::f64>(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<plot::f64>(row_layout.x_axis_height), 0.0, 1e-6);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.x_label_area.h), 0.0, 1e-6);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.axes.bottom()),
|
|
static_cast<plot::f64>(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<plot::f64>(per_row_layout.x_axis_strip.h),
|
|
static_cast<plot::f64>(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();
|
|
}
|
|
|
|
/// Spec §16, end to end: a figure whose rows carry different magnitudes ("671m",
|
|
/// "90", "2.51" scale tick labels) draws a vertical feature -- each row's own cursor
|
|
/// at the same data x -- in the **same pixel column** in every row. That is what a
|
|
/// user sees when the y axes line up, and it is what the rows' `axes` rects being
|
|
/// equal is for: the equality below is read from the drawn frame, not from the layout
|
|
/// alone, so a renderer that derived a row's geometry of its own would fail here.
|
|
PLOT_TEST(figure_rows_line_up_a_vertical_cursor) {
|
|
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<plot::f32> samples = wave(101u, 0.0f);
|
|
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
|
|
|
// The cursor's colour, and the only strongly red thing in these frames: the trace is
|
|
// `kRowBlue` and every piece of chrome is grey (all three channels equal), so a
|
|
// red-dominant pixel inside a row's `axes` can only be that row's cursor. The row
|
|
// ranges are the three magnitudes whose tick labels are four, two and three
|
|
// characters wide -- the shape that gave each row its own strip before the gutter.
|
|
const plot::Color cursor_red = plot::Color::hex(0xFF2020);
|
|
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
|
|
|
|
plot::Figure figure(3u);
|
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
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, y_scales[row]}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
|
|
p.add_cursor(50.0, cursor_red);
|
|
}
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(figure);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "figure_gutter_cursor.ppm");
|
|
|
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
|
const plot::FigureLayout layout = figure.layout();
|
|
PLOT_CHECK(layout.rows.size() == 3u);
|
|
// The mechanism, straight from the drawn frame's own layouts: every row's axes starts
|
|
// and ends on the same pixel columns, and that is true at the top of the figure too --
|
|
// these rows are separate plots vertically and one column horizontally.
|
|
const plot::Rect first_axes = figure.plot(0u).layout().axes;
|
|
for (plot::u32 row = 1; row < 3u; ++row) {
|
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first_axes.x), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first_axes.w), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.right()),
|
|
static_cast<plot::f64>(first_axes.right()), 1e-4);
|
|
}
|
|
|
|
// Every column of `area` that holds a *fully covered* cursor pixel, left to right.
|
|
// The predicate demands the cursor red at full coverage (0xFF2020), which is what
|
|
// makes the comparison across rows meaningful: a partially covered column along the
|
|
// stroke's edge blends with whatever the row has under it -- its trace at a different
|
|
// y -- so its colour is a property of that row's data, not of the geometry this case
|
|
// is about. The core of the stroke has no such dependence.
|
|
const auto cursor_columns = [&](const plot::Rect& area) {
|
|
std::vector<plot::u32> columns;
|
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
|
x < static_cast<plot::u32>(area.right()); ++x) {
|
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
|
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
|
|
if (px[0] >= 200 && px[1] <= 80 && px[2] <= 80) {
|
|
columns.push_back(x);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return columns;
|
|
};
|
|
|
|
std::vector<plot::u32> first_columns;
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::PlotLayout& row_layout =
|
|
static_cast<const plot::Plot&>(figure.plot(row)).layout();
|
|
const std::vector<plot::u32> columns = cursor_columns(row_layout.axes);
|
|
PLOT_CHECK(!columns.empty()); // the row's cursor really is in the frame
|
|
// One connected run: the stroke is one vertical line, not a doubled or smeared one.
|
|
PLOT_CHECK(columns.back() - columns.front() + 1u == columns.size());
|
|
if (row == 0) {
|
|
first_columns = columns;
|
|
} else {
|
|
// The same pixel column in this row as in the first -- the drawn consequence of
|
|
// the shared gutter, which a frame that sized each row's strip from its own
|
|
// labels cannot satisfy.
|
|
PLOT_CHECK(columns == first_columns);
|
|
}
|
|
}
|
|
// ... and that column is the one the shared transform predicts, so the equality is
|
|
// the geometry's and not three cursors that happened to land together. The tolerance
|
|
// is the stroke's own pixel: the transform gives the stroke's centre, which lands
|
|
// inside the band it inked.
|
|
const plot::u32 expected = static_cast<plot::u32>(
|
|
static_cast<const plot::Plot&>(figure.plot(0u)).x_transform(0u).to_screen_x(50.0));
|
|
PLOT_CHECK(first_columns.front() <= expected + 1u && expected <= first_columns.back() + 1u);
|
|
|
|
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<plot::f32> top = wave(640u, 0.0f);
|
|
const std::vector<plot::f32> 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<plot::f32>(kH);
|
|
figure.set_viewport({0, 0, static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::f64>(row1.bottom()), static_cast<plot::f64>(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<plot::f64>(row1_layout.axes.y), static_cast<plot::f64>(row1.y), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.y),
|
|
static_cast<plot::f64>(row1_layout.x_label_area.y), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.axes.bottom()),
|
|
static_cast<plot::f64>(row1_layout.x_label_area.y), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.x_label_area.bottom() +
|
|
row1_layout.x_title_height),
|
|
static_cast<plot::f64>(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<plot::f32>(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<plot::f32>(kW),
|
|
static_cast<plot::f32>(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<plot::f32> 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::f64>(interior.axes.bottom()),
|
|
static_cast<plot::f64>(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<plot::u8> 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<plot::f32> off_scale_wave(plot::u32 n) {
|
|
std::vector<plot::f32> 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<plot::f32> in_range_wave(plot::u32 n) {
|
|
std::vector<plot::f32> 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<plot::u8>& 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<plot::f32>(x);
|
|
const plot::f32 cy = static_cast<plot::f32>(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<plot::u32>(axes.x); x < static_cast<plot::u32>(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<plot::f64>(kCoarseSamples));
|
|
plot::data::ArraySeries comb(off_scale_wave(kCombSamples), 0.0,
|
|
kOffScaleSpan / static_cast<plot::f64>(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<plot::u8> 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<plot::f64>(axes.y) - ty.to_screen_y(50.0),
|
|
49.0 * static_cast<plot::f64>(axes.h), 1e-3);
|
|
PLOT_CHECK_NEAR(ty.to_screen_y(-50.0) - static_cast<plot::f64>(axes.bottom()),
|
|
50.0 * static_cast<plot::f64>(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<double>(outside.left),
|
|
static_cast<double>(outside.right), static_cast<double>(outside.above),
|
|
static_cast<double>(outside.below), static_cast<double>(axes.x),
|
|
static_cast<double>(axes.y), static_cast<double>(axes.w),
|
|
static_cast<double>(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<plot::u32>(std::ceil(axes.x)), axes, trace_pixel));
|
|
PLOT_CHECK(
|
|
column_has(ctx, static_cast<plot::u32>(std::floor(axes.right())) - 1u, axes, trace_pixel));
|
|
PLOT_CHECK(axes_row_has(ctx, axes, static_cast<plot::u32>(std::ceil(axes.y)), trace_pixel));
|
|
PLOT_CHECK(axes_row_has(ctx, axes, static_cast<plot::u32>(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<plot::f32> 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<plot::f64>(kCombSamples));
|
|
|
|
plot::Figure figure(2u);
|
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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();
|
|
}
|
|
|
|
/// An annotation's **caption** is the one overlay the per-row clip did not reach: the
|
|
/// cross is staged as line geometry inside the axes clip, while the caption is staged
|
|
/// into the frame's single text batch, which is flushed *after* `clear_clip()`. An
|
|
/// annotation anchored outside the visible range therefore kept a caption with no cross,
|
|
/// and -- in a `Figure`, whose rows tile the figure -- that caption painted into the
|
|
/// neighbouring row, the bleeding the clip exists to prevent. The caption is now skipped
|
|
/// with its cross when the anchor is outside the axes; an in-range annotation still draws
|
|
/// its caption, so the skip is not a blanket refusal to draw text.
|
|
PLOT_TEST(figure_annotation_caption_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));
|
|
|
|
const std::vector<plot::f32> samples = wave(640u, 0.0f);
|
|
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
|
plot::Figure figure(2u);
|
|
figure.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
add_row_trace(figure.plot(0u), series, kRowBlue);
|
|
|
|
plot::Plot& bottom = figure.plot(1u);
|
|
const plot::u32 ax =
|
|
bottom.add_x_axis({"t", series.x_bounds(), false, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = bottom.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
bottom.add_trace({&series, kRowMagenta, 1.5f, ax, ay});
|
|
|
|
// The geometry the out-of-range anchor is chosen against: a y whose pixel row lands
|
|
// *inside the row above*, well above this row's own axes.
|
|
figure.layout();
|
|
const plot::FigureLayout layout = figure.layout();
|
|
PLOT_CHECK(layout.rows.size() == 2u);
|
|
const plot::Rect& axes0 = figure.plot(0u).layout().axes;
|
|
const plot::Rect& axes1 = bottom.layout().axes;
|
|
PLOT_CHECK(axes0.h > 0.0f && axes1.h > 0.0f);
|
|
const plot::f32 target_y = axes0.y + 0.4f * axes0.h;
|
|
|
|
plot::Annotation outside;
|
|
outside.x = 320.0; // in range for x, so the caption is staged at a real column
|
|
outside.y = bottom.y_transform(ay).to_data_y(static_cast<plot::f64>(target_y));
|
|
outside.text = "outside";
|
|
outside.color = plot::Color::hex(0xE8E8E8);
|
|
outside.axis_x = ax;
|
|
outside.axis_y = ay;
|
|
bottom.add_annotation(outside);
|
|
|
|
// The control: an annotation actually inside row 1's axes, whose caption must still
|
|
// be drawn -- so the assertion below is about the skip, not about text being absent.
|
|
plot::Annotation inside;
|
|
inside.x = 320.0;
|
|
inside.y = 0.0;
|
|
inside.text = "inside";
|
|
inside.color = plot::Color::hex(0xE8E8E8);
|
|
inside.axis_x = ax;
|
|
inside.axis_y = ay;
|
|
bottom.add_annotation(inside);
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(figure);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "clip_annotation_caption.ppm");
|
|
|
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
|
|
|
// Row 0's axes hold no text of their own -- its labels live in its y strip, outside
|
|
// `axes` -- so any text ink here would be the neighbour's escaped caption.
|
|
PLOT_CHECK(predicate_pixels(frame, kW, axes0, text_pixel) == 0u);
|
|
// The in-range caption proves the text pipeline ran and captions are drawn at all.
|
|
PLOT_CHECK(predicate_pixels(frame, kW, axes1, text_pixel) > 0u);
|
|
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> 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 same hue at *any* coverage. A stroke centred exactly on `axes.bottom()` -- where the
|
|
/// clip's inward rounding leaves only the inside half -- can survive as a faint sub-pixel
|
|
/// sliver when that edge is fractional, which `ruler_pixel`'s brightness floor does not see.
|
|
/// The hue gate still excludes the grey chrome, the pale text and the (absent here) blue
|
|
/// trace, so a count of it is still a count of the ruler.
|
|
bool ruler_ink_any(const plot::u8 px[4]) {
|
|
return px[1] > px[0] + 15 && px[2] > px[0] + 10 && px[1] > 35 && px[1] >= px[2] - 8;
|
|
}
|
|
|
|
/// 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<plot::u8>& 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> frame = read_frame(ctx);
|
|
const plot::PlotLayout layout = p.layout();
|
|
const plot::Rect& axes = layout.axes;
|
|
const plot::f32 expected_row =
|
|
static_cast<plot::f32>(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<plot::u32>(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<plot::f32>(ink.top) <= expected_row + 1.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(kW), static_cast<plot::f32>(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<plot::f32>(p.y_transform(ay).to_screen_y(50.0));
|
|
|
|
const auto frame_now = [&]() {
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(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. That value maps
|
|
// to the axes' *bottom* row, and the ruler is pinned visible there -- the same outcome as
|
|
// a drag past the top, which is the point of the far-edge rule being strict: an at-bound
|
|
// coordinate is inside the rect. (The clip's inward rounding keeps the ink on the last row
|
|
// inside the edge, so "at the boundary" reads as within a stroke width of it.)
|
|
grab();
|
|
drag_to(axes.bottom() + 400.0f);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 0.0, 1e-9);
|
|
{
|
|
const std::vector<plot::u8> frame = frame_now();
|
|
const InkExtent ink = ink_extent(frame, kW, kH, ruler_ink_any);
|
|
PLOT_CHECK(ink.pixels > 0u); // drawn, not hidden
|
|
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= axes.bottom() - 2.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(ink.top) <= axes.bottom());
|
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_ink_any) ==
|
|
predicate_pixels(frame, kW, axes, ruler_ink_any));
|
|
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().x_label_area, ruler_ink_any) == 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_ink_any) == 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<plot::u8> frame = frame_now();
|
|
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
|
|
PLOT_CHECK(ink.pixels > 0u);
|
|
PLOT_CHECK(static_cast<plot::f32>(ink.top) < axes.y + 1.5f);
|
|
PLOT_CHECK(static_cast<plot::f32>(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);
|
|
}
|
|
|
|
// A degenerate y range (min == max) is widened by +/-0.5 in the transform, so the one
|
|
// value it holds maps to the middle of the axes: a ruler there is genuinely in range and
|
|
// must be drawn -- the strict far-edge rule cannot hide it. An inverted range is widened
|
|
// the same way, so a value strictly between its bounds also maps inside and draws; only
|
|
// the two bounds land ~0.5 data units outside the widened span, which is a row outside the
|
|
// rect and correctly skipped (the old rule skipped them too, so this is not a regression).
|
|
p.set_y_range(ay, {50.0, 50.0});
|
|
p.set_cursor_y(ruler, 50.0);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-9);
|
|
{
|
|
const std::vector<plot::u8> frame = frame_now();
|
|
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
|
|
PLOT_CHECK(ink.pixels > 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
|
|
predicate_pixels(frame, kW, axes, ruler_pixel));
|
|
}
|
|
p.set_y_range(ay, {100.0, 0.0});
|
|
p.set_cursor_y(ruler, 50.0);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-9);
|
|
{
|
|
const std::vector<plot::u8> frame = frame_now();
|
|
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
|
|
PLOT_CHECK(ink.pixels > 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
|
|
predicate_pixels(frame, kW, axes, ruler_pixel));
|
|
}
|
|
p.set_y_range(ay, {0.0, 100.0});
|
|
|
|
// 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<plot::u8> 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();
|
|
}
|
|
|
|
// ------------------------------------------------------ the crosshair (spec §15)
|
|
|
|
namespace {
|
|
|
|
/// The crosshair's own ink: **bright and hue-neutral**, which is the property the default colour
|
|
/// has on purpose (see `Style::crosshair`) so that it can never be read as a trace of any hue.
|
|
/// This predicate is therefore about brightness and chroma rather than about a hue, and it needs no
|
|
/// "not this trace's colour" exception: every other element of these frames is either dark (the
|
|
/// background, the grid), saturated (the magenta, green and orange traces) or dimmer than the
|
|
/// crosshair (the axis text 0xC8D0DC, whose red channel is 200 against the crosshair's 230).
|
|
bool crosshair_pixel(const plot::u8 px[4]) {
|
|
const int lo = std::min(px[0], std::min(px[1], px[2]));
|
|
const int hi = std::max(px[0], std::max(px[1], px[2]));
|
|
return px[0] > 215 && hi - lo <= 24;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// The crosshair's colour has to be tellable from a trace, and telling colours apart by hue is the
|
|
/// one thing a reader cannot be asked to do at a glance: the library's own default trace colour is a
|
|
/// saturated cyan, so a saturated cyan crosshair would differ from it in lightness alone. The
|
|
/// default is therefore near-white -- a colour no saturated trace of any hue can be -- and still
|
|
/// plainly not the cursor accent. Both halves are asserted, so a future recolouring that makes it
|
|
/// confusable with a trace or with a handle fails here rather than in a screenshot.
|
|
PLOT_TEST(crosshair_colour_cannot_be_mistaken_for_a_trace) {
|
|
const plot::render::Style style;
|
|
const plot::Trace default_trace;
|
|
// `Color` stores normalised channels; the comparisons below are in 0..255, like the hex literals
|
|
// the colours are written with.
|
|
const auto chan = [](plot::f32 c) { return static_cast<int>(c * 255.0f + 0.5f); };
|
|
const int lo = std::min({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)});
|
|
const int hi = std::max({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)});
|
|
// Hue-neutral: its channels are within a few levels of each other, so it is a grey/white and not
|
|
// a hue at all. A saturated colour -- the default trace's cyan included -- is 150+ levels apart.
|
|
PLOT_CHECK(hi - lo <= 24);
|
|
PLOT_CHECK(lo > 200); // bright chrome, not a mid-tone that a dimmed trace could reach
|
|
// `Color` is a plain channel struct with no comparison of its own, so the two "not this colour"
|
|
// assertions spell the channels out.
|
|
const auto same = [](plot::Color a, plot::Color b) {
|
|
return a.r == b.r && a.g == b.g && a.b == b.b;
|
|
};
|
|
PLOT_CHECK(!same(style.crosshair, default_trace.color));
|
|
PLOT_CHECK(!same(style.crosshair, style.cursor));
|
|
// ... and a saturated trace of any hue is out of reach for it by construction: the *most*
|
|
// chromatic colour the crosshair could be confused with has to be within the chroma bound above,
|
|
// and every trace colour in this library's demos is far outside it.
|
|
const plot::Color demo_traces[3] = {plot::Color::hex(0x40C0FF), plot::Color::hex(0xFFB020),
|
|
plot::Color::hex(0x35D0A8)};
|
|
for (const plot::Color trace : demo_traces) {
|
|
PLOT_CHECK(!same(style.crosshair, trace));
|
|
}
|
|
}
|
|
namespace {
|
|
|
|
/// A third colour predicate for the lateral case below: the orange 0xFFB020 of a trace of its
|
|
/// own, which no other element of this frame draws (there is no cursor, no region and no
|
|
/// measurement band in it, and the crosshair's own colour is a pale cyan).
|
|
bool orange_pixel(const plot::u8 px[4]) {
|
|
return px[0] > 200 && px[1] > 150 && px[1] < px[0] && px[2] < 80;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// Spec §15's clip half, and with it a regression guard for Task 1: **everything the crosshair
|
|
/// draws stays inside `axes`** -- both lines, every marker and the readout panel -- and so does
|
|
/// the data it points at.
|
|
///
|
|
/// Three traces make the case discriminating, one per way a marker can leave the rect:
|
|
///
|
|
/// * an **in-range** trace, whose line and marker are inside it -- the complement that says the
|
|
/// clip is the axes rect and not something smaller;
|
|
/// * a trace **below the axis**: its constant y is five percent under the axis' minimum, so it
|
|
/// maps some thirteen pixels beneath the axes' bottom edge -- *into the x label strip*. Nothing
|
|
/// in the drawing path tests a sample's y against the visible range, and a crosshair marker is
|
|
/// deliberately staged where its trace is drawn rather than skipped, so only Task 1's scissor
|
|
/// keeps that ink off the strip;
|
|
/// * a trace **beside the axes**: one bound to a *second* x axis whose window starts past the
|
|
/// crosshair's x, so the data x under the pointer maps to a column *left* of the axes -- in the
|
|
/// y label strip. The marker is placed through the trace's own axis pair (which is what puts it
|
|
/// there, and what the row's geometry asserts as the premise), and again only the clip keeps it
|
|
/// out.
|
|
///
|
|
/// A build without the scissor puts marker ink in the x and the y label strip respectively, and
|
|
/// this case fails on both. The panel's own placement is asserted as well, because a panel that
|
|
/// overhung `axes` would be the same defect by a different route.
|
|
PLOT_TEST(crosshair_ink_stays_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));
|
|
|
|
// A viewport inside the framebuffer with the host's grey painted behind it, so the frame may
|
|
// paint inside that rect and nowhere else -- and a marker that escaped `axes` would land either
|
|
// in a strip or on the host's own surface, both of which are asserted empty below.
|
|
const plot::Rect vp{30.0f, 40.0f, 580.0f, 290.0f};
|
|
|
|
plot::Plot p;
|
|
p.set_viewport(vp);
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, false, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
|
|
plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
|
|
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
|
|
// A warm-up frame first, with nothing in the plot: the renderer installs its text backend on the
|
|
// first `draw()`, and the strips and the readout panel are measured with it -- so the geometry
|
|
// asserted below is a *drawn* frame's, not an empty plot's defaults.
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
// Nothing added from here on can move the strips: an *untitled* second x axis draws its labels
|
|
// on the same strip's single line, and a trace reserves nothing at all. (A titled one would
|
|
// reserve a title row and move `axes` -- which is why this axis has no label.)
|
|
const plot::Rect axes = p.layout().axes;
|
|
const plot::Rect y_strip = p.layout().y_label_area;
|
|
PLOT_CHECK(y_strip.w > 4.0f);
|
|
const plot::Transform tx = p.x_transform(ax);
|
|
const plot::Transform ty = p.y_transform(ay);
|
|
const f64 hover_x = tx.to_data_x(axes.right() - 3.0f);
|
|
|
|
// The second x axis' window is **derived from that geometry rather than guessed**, so this case
|
|
// cannot silently stop testing anything when a font or a padding moves the strip: with a span
|
|
// `S` starting at `min`, a data x maps to `axes.x + (x - min) / S * axes.w`, so starting the
|
|
// window half a strip's width above the hovered x puts that trace's marker halfway into the y
|
|
// label strip.
|
|
const f64 span2 = 700.0;
|
|
const f64 min2 =
|
|
hover_x + (0.5 * static_cast<f64>(y_strip.w) / static_cast<f64>(axes.w)) * span2;
|
|
const plot::u32 ax2 =
|
|
p.add_x_axis({"", {min2, min2 + span2}, false, plot::AxisPlace::Bottom, false});
|
|
|
|
// The geometry really is the one asserted below, and the second axis really did not move it.
|
|
PLOT_CHECK_NEAR(p.layout().axes.h, axes.h, 1e-4);
|
|
|
|
// The in-range signal: a wave inside the [0, 1] axis, so its trace and its marker are both
|
|
// inside the rect.
|
|
std::vector<plot::f32> in_range(640u);
|
|
for (plot::u32 i = 0; i < in_range.size(); ++i) {
|
|
in_range[i] = 0.5f + 0.4f * std::sin(0.03f * static_cast<plot::f32>(i));
|
|
}
|
|
plot::data::ArraySeries good(in_range, 0.0, 1.0);
|
|
// Five percent below the bottom of the axis: some thirteen pixels, which is the x label strip.
|
|
plot::data::ArraySeries below(std::vector<plot::f32>(640u, -0.05f), 0.0, 1.0);
|
|
// In range on y, but read through an x axis the hovered x is outside of: its marker lands in the
|
|
// y label strip, beside the axes.
|
|
plot::data::ArraySeries aside(std::vector<plot::f32>(2000u, 0.25f), 0.0, 1.0);
|
|
p.add_trace({&good, kRowMagenta, 1.5f, ax, ay, "good"});
|
|
p.add_trace({&below, plot::Color::hex(0x00E070), 1.5f, ax, ay, "below"});
|
|
p.add_trace({&aside, plot::Color::hex(0xFFB020), 1.5f, ax2, ay, "aside"});
|
|
|
|
// The crosshair three pixels inside the bottom-right corner of the data area, so its readout
|
|
// panel would leave the rect on two sides if it were not flipped and clamped.
|
|
p.set_crosshair(hover_x, ty.to_data_y(axes.bottom() - 3.0f), true);
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "crosshair_axes.ppm");
|
|
|
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
|
const plot::PlotLayout layout = p.layout();
|
|
const plot::CrosshairLayout& crosshair = layout.crosshair;
|
|
PLOT_CHECK(crosshair.active);
|
|
PLOT_CHECK(crosshair.rows.size() == 3u);
|
|
|
|
// The panel exists, and is inside the axes on every side -- the placement rule, asserted
|
|
// against the very rect the renderer clipped to.
|
|
PLOT_CHECK(crosshair.panel.w > 0.0f);
|
|
PLOT_CHECK(crosshair.panel.h > 0.0f);
|
|
PLOT_CHECK(crosshair.panel.x >= axes.x);
|
|
PLOT_CHECK(crosshair.panel.y >= axes.y);
|
|
PLOT_CHECK(crosshair.panel.right() <= axes.right());
|
|
PLOT_CHECK(crosshair.panel.bottom() <= axes.bottom());
|
|
|
|
// The premises, which is what makes every pixel assertion below a statement about the defect and
|
|
// not about a marker that never went anywhere: the two off-scale markers are staged where only
|
|
// the clip stands between them and a strip -- row 1's below the axes' bottom edge, inside the x
|
|
// label strip, row 2's left of the axes' left edge, inside the y label strip.
|
|
PLOT_CHECK(crosshair.rows[0].valid);
|
|
PLOT_CHECK(crosshair.rows[1].valid);
|
|
PLOT_CHECK(crosshair.rows[2].valid);
|
|
PLOT_CHECK(crosshair.rows[1].marker.y > axes.bottom());
|
|
PLOT_CHECK(crosshair.rows[1].marker.y < layout.x_label_area.bottom());
|
|
PLOT_CHECK(crosshair.rows[2].marker.x < axes.x);
|
|
PLOT_CHECK(crosshair.rows[2].marker.x >= layout.y_label_area.x);
|
|
|
|
// Every crosshair-coloured pixel of the frame is inside `axes`, and there are some: not one in a
|
|
// label or title strip, not one over the host's surface outside the viewport.
|
|
const plot::u32 inside = predicate_pixels(frame, kW, axes, crosshair_pixel);
|
|
PLOT_CHECK(inside > 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) == inside);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, crosshair_pixel) == 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, crosshair_pixel) == 0u);
|
|
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);
|
|
// The panel's own border is inside the panel, so the readout is drawn where the layout said and
|
|
// not merely clipped somewhere else.
|
|
PLOT_CHECK(predicate_pixels(frame, kW, crosshair.panel, crosshair_pixel) > 0u);
|
|
|
|
// The trace below the axis: none of its colour anywhere outside `axes` -- the x label strip and
|
|
// the host's surface included -- while its own swatch in the readout proves the colour is one
|
|
// this frame really draws, so the absence is the clip's doing and not a colour nothing paints.
|
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, green_pixel) == 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, green_pixel) ==
|
|
predicate_pixels(frame, kW, axes, green_pixel));
|
|
PLOT_CHECK(predicate_pixels(frame, kW, crosshair.rows[1].swatch, green_pixel) > 0u);
|
|
|
|
// The trace beside the axes: the same statement for the y label strip and the title strip beside
|
|
// it, where its marker would have been drawn.
|
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, orange_pixel) == 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, orange_pixel) == 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, orange_pixel) ==
|
|
predicate_pixels(frame, kW, axes, orange_pixel));
|
|
|
|
// The complement: the in-range trace's ink -- its polyline and the marker the crosshair put on it
|
|
// -- is inside `axes`, and all of it is, which a clip smaller than the rect could not do.
|
|
PLOT_CHECK(predicate_pixels(frame, kW, axes, magenta_pixel) > 0u);
|
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
|
|
predicate_pixels(frame, kW, axes, magenta_pixel));
|
|
|
|
ctx.destroy();
|
|
}
|
|
|
|
// ------------------------------------------------- the crosshair's style (spec §17)
|
|
|
|
namespace {
|
|
|
|
/// The `mod(v_along, on + off)` pattern is a statement about a *line's length*, so the helpers below
|
|
/// read the frame one dimensionally: a boolean row (which columns hold ink) whose maximal `true`
|
|
/// runs are the dashes.
|
|
bool column_has_ink(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x, plot::i32 y0,
|
|
plot::i32 y1, bool (*pred)(const plot::u8[4])) {
|
|
if (x < 0 || x >= static_cast<plot::i32>(fb_w)) return false;
|
|
for (plot::i32 y = std::max<plot::i32>(0, y0); y < y1; ++y) {
|
|
if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// How many maximal `true` runs a boolean row holds: one for a solid line, and about `w / (on + off)`
|
|
/// for a dash of period `on + off` across a width `w`.
|
|
plot::u32 run_count(const std::vector<bool>& on) {
|
|
plot::u32 runs = 0;
|
|
for (plot::usize i = 0; i < on.size(); ++i) {
|
|
if (on[i] && (i == 0u || !on[i - 1u])) ++runs;
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
/// The boolean row `[x0, x1)` in which each entry is whether that column holds any `pred` pixel in
|
|
/// rows `[y0, y1)`, plus a one-column count of the rows it is true in.
|
|
std::vector<bool> ink_row(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x0,
|
|
plot::i32 x1, plot::i32 y0, plot::i32 y1,
|
|
bool (*pred)(const plot::u8[4])) {
|
|
std::vector<bool> on;
|
|
on.reserve(static_cast<plot::usize>(std::max(0, x1 - x0)));
|
|
for (plot::i32 x = x0; x < x1; ++x) {
|
|
on.push_back(column_has_ink(frame, fb_w, x, y0, y1, pred));
|
|
}
|
|
return on;
|
|
}
|
|
|
|
/// The framebuffer column of `[x0, x1)` holding the most pixels `pred` accepts in rows `[y0, y1)`,
|
|
/// and that count -- how a case finds the one column a vertical stroke lives in without guessing
|
|
/// where a transform put it.
|
|
plot::i32 densest_column(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x0,
|
|
plot::i32 x1, plot::i32 y0, plot::i32 y1,
|
|
bool (*pred)(const plot::u8[4]), plot::u32* out_count) {
|
|
plot::i32 best = -1;
|
|
plot::u32 best_n = 0;
|
|
for (plot::i32 x = x0; x < x1; ++x) {
|
|
plot::u32 n = 0;
|
|
for (plot::i32 y = std::max<plot::i32>(0, y0); y < y1; ++y) {
|
|
if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) ++n;
|
|
}
|
|
if (n > best_n) {
|
|
best_n = n;
|
|
best = x;
|
|
}
|
|
}
|
|
if (out_count != nullptr) *out_count = best_n;
|
|
return best;
|
|
}
|
|
|
|
/// The grid *majors* (0x39414C over the 0x101418 background), which no other element of these
|
|
/// frames reaches: the minors are that same colour at 50% alpha over a 0.75-covered dashed
|
|
/// stroke, so their brightest pixel is about 15 levels off the background and stays under this
|
|
/// bound; the crosshair is near-white, a trace is saturated and the cursor is the orange accent.
|
|
bool grid_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 px[0] >= 30 && px[0] <= 90 && hi <= 100 && static_cast<plot::u8>(hi - lo) <= 30;
|
|
}
|
|
|
|
/// The orange cursor accent 0xFFB020, at full coverage.
|
|
bool cursor_pixel(const plot::u8 px[4]) {
|
|
return px[0] > 200 && px[1] > 120 && px[1] < px[0] && px[2] < 90;
|
|
}
|
|
|
|
plot::u32 crosshair_ink_columns(const std::vector<plot::u8>& frame, plot::u32 fb_w,
|
|
const plot::Rect& axes) {
|
|
const plot::i32 row0 = static_cast<plot::i32>(axes.y) + static_cast<plot::i32>(axes.h) / 2;
|
|
plot::u32 cols = 0;
|
|
for (plot::i32 x = static_cast<plot::i32>(axes.x); x < static_cast<plot::i32>(axes.right()); ++x) {
|
|
if (column_has_ink(frame, fb_w, x, row0, row0 + 2, crosshair_pixel)) ++cols;
|
|
}
|
|
return cols;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// Spec §17, the style: the crosshair is drawn **dashed** by default -- a passive hover instrument
|
|
/// reads as chrome rather than as data -- and the dash is a fragment-shader effect on `v_along`, so
|
|
/// nothing about the geometry changes. This case reads the pattern off the pixels: a dashed stroke
|
|
/// is many runs along its length, a solid one is a single run, and a longer gap is a longer period,
|
|
/// so **fewer** dashes over the same width. `dash_off == 0` is what makes it solid.
|
|
PLOT_TEST(crosshair_dash_is_configurable_and_zero_off_means_solid) {
|
|
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::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
|
|
plot::Plot p;
|
|
p.set_viewport(vp);
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
// A warm-up frame, so the strips have settled and `axes` below is a *drawn* frame's rect.
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
const plot::Rect axes = p.layout().axes;
|
|
const plot::Transform tx = p.x_transform(ax);
|
|
const plot::Transform ty = p.y_transform(0u);
|
|
|
|
// The horizontal line mid-axes, on a *pixel* centre so a 1 device px stroke lands on exactly one
|
|
// framebuffer row: the dash is what this case varies, and a fractional coverage would make the
|
|
// row's ink ragged for reasons that are not the dash.
|
|
const f32 cxp = std::floor(axes.x) + 0.5f + 60.0f;
|
|
const f32 cyp = std::floor(axes.y) + 0.5f + 40.0f;
|
|
p.set_crosshair(tx.to_data_x(static_cast<f64>(cxp)), ty.to_data_y(static_cast<f64>(cyp)), true);
|
|
|
|
const auto frame_with = [&](f32 off) {
|
|
// Width 1 device px: the 0.75 default's sub-pixel coverage the `crosshair_pixel` predicate (a
|
|
// full-coverage colour) would not count uniformly, and the dash is this case's subject.
|
|
p.set_crosshair_style({1.0f, 4.0f, off});
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
return read_frame(ctx);
|
|
};
|
|
|
|
const plot::i32 row = static_cast<plot::i32>(cyp);
|
|
const plot::i32 x0 = static_cast<plot::i32>(axes.x) + 1;
|
|
const plot::i32 x1 = static_cast<plot::i32>(axes.right()) - 1;
|
|
const auto runs = [&](f32 off) {
|
|
return run_count(ink_row(frame_with(off), kW, x0, x1, row - 1, row + 2, crosshair_pixel));
|
|
};
|
|
|
|
// 4 on / 4 off over the axes' width: many dashes, not one line.
|
|
const plot::u32 dashed = runs(4.0f);
|
|
PLOT_CHECK(dashed > 4u);
|
|
// Off == 0 is solid: one run, the whole width, no gap anywhere.
|
|
PLOT_CHECK(runs(0.0f) == 1u);
|
|
// A longer gap is a longer period, so fewer dashes over the same width.
|
|
const plot::u32 sparse = runs(12.0f);
|
|
PLOT_CHECK(sparse > 1u);
|
|
PLOT_CHECK(sparse < dashed);
|
|
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// Spec §17, the stroke: "a **thinner** stroke (~0.75 logical px rather than a trace's line
|
|
/// width)". The claim is asserted twice -- as the numbers the layout resolves (what the renderer
|
|
/// hands the shader) and as the ink those numbers produce on the real driver -- so a change that
|
|
/// kept the number but stopped drawing at it, or the reverse, fails here.
|
|
PLOT_TEST(crosshair_is_thinner_than_a_trace) {
|
|
const plot::render::Style style;
|
|
const plot::Trace trace;
|
|
PLOT_CHECK(style.crosshair_width > 0.0f);
|
|
PLOT_CHECK(style.crosshair_width < trace.width); // 0.75 < a trace's 1.5
|
|
|
|
// The resolved stroke: the style's value through `device()`, doubled at `ui_scale` 2, and the
|
|
// crosshair's own override where it has one.
|
|
plot::Plot p;
|
|
p.set_viewport(plot::Rect{0.0f, 0.0f, 600.0f, 320.0f});
|
|
p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
p.set_crosshair_style({-1.0f, 1.0e6f, 0.0f}); // width from the style; solid, for measurement
|
|
p.set_crosshair(1.0, true); // vertical-only: one line to measure
|
|
p.set_ui_scale(1.0f);
|
|
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, style.crosshair_width, 1e-6);
|
|
p.set_ui_scale(2.0f);
|
|
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.0 * style.crosshair_width, 1e-6);
|
|
p.set_crosshair_style({1.25f, 1.0e6f, 0.0f});
|
|
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.5, 1e-6);
|
|
|
|
// ... and drawn: the ink width follows the width and the scale.
|
|
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::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
|
|
plot::Plot q;
|
|
q.set_viewport(vp);
|
|
const plot::u32 qx = q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
r.style().crosshair_width = style.crosshair_width;
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(q);
|
|
r.end_frame();
|
|
const plot::Rect axes = q.layout().axes;
|
|
q.set_crosshair_style({3.0f, 1.0e6f, 0.0f}); // solid, explicit width
|
|
q.set_crosshair(q.x_transform(qx).to_data_x(static_cast<f64>(std::floor(axes.x) + 120.5f)), true);
|
|
|
|
const auto ink_columns = [&](f32 scale) {
|
|
r.style().ui_scale = scale;
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(q);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
|
return crosshair_ink_columns(frame, kW, q.layout().axes);
|
|
};
|
|
|
|
const plot::u32 wide_1x = ink_columns(1.0f);
|
|
const plot::u32 wide_2x = ink_columns(2.0f);
|
|
PLOT_CHECK(wide_1x > 0u);
|
|
PLOT_CHECK(wide_2x > wide_1x); // `ui_scale` 2 doubles every metric, the stroke included
|
|
|
|
q.set_crosshair_style({1.0f, 1.0e6f, 0.0f});
|
|
const plot::u32 thin_1x = ink_columns(1.0f);
|
|
PLOT_CHECK(thin_1x >= 1u);
|
|
PLOT_CHECK(thin_1x < wide_1x); // a thinner stroke is thinner ink
|
|
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// Spec §17's shared-pipeline guard: the dash is a *per-draw* uniform and only the crosshair sets
|
|
/// it, so every other line -- grid, traces, axis lines, regions, cursors, the legend border -- draws
|
|
/// exactly as before. The crosshair is drawn in the middle of the interior passes (after the grid,
|
|
/// the axis lines and the traces; before the regions, the cursors and the legend), so an unreset or
|
|
/// leaked dash would show up on an element drawn *after* it -- and this case draws one of each kind
|
|
/// around it and reads the pixels.
|
|
PLOT_TEST(grid_traces_and_cursors_are_unaffected_by_the_dash_uniform) {
|
|
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::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
|
|
|
|
// --- the grid and the cursor, with a dashed crosshair drawn between them ----------------
|
|
plot::Plot p;
|
|
p.set_viewport(vp);
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
const plot::u32 cur = p.add_cursor(37.0, plot::Color::hex(0xFFB020));
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
const plot::Rect axes = p.layout().axes;
|
|
const plot::Transform tx = p.x_transform(ax);
|
|
const plot::Transform ty = p.y_transform(0u);
|
|
// A dashed crosshair mid-axes, deliberately *between* ticks in x so its vertical line does not
|
|
// land on a grid column and muddy the grid reading below.
|
|
p.set_crosshair(tx.to_data_x(static_cast<f64>(std::floor(axes.x) + 130.5f)),
|
|
ty.to_data_y(static_cast<f64>(std::floor(axes.y) + 50.5f)), true);
|
|
p.set_crosshair_style({1.0f, 4.0f, 4.0f}); // a real dash: what a leak would carry
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
|
|
|
// The cursor is drawn *after* the crosshair, so it is the leak guard that matters: a dash uniform
|
|
// that survived the crosshair's flush would break its full-height stroke into a 4/4 pattern.
|
|
plot::u32 cursor_rows = 0;
|
|
const plot::i32 cursor_col = densest_column(frame, kW, static_cast<plot::i32>(axes.x),
|
|
static_cast<plot::i32>(axes.right()),
|
|
static_cast<plot::i32>(axes.y),
|
|
static_cast<plot::i32>(axes.bottom()), cursor_pixel,
|
|
&cursor_rows);
|
|
PLOT_CHECK(cursor_col >= 0);
|
|
PLOT_CHECK(cursor_rows >= static_cast<plot::u32>(axes.h) * 3u / 4u); // a solid, full-height line
|
|
PLOT_CHECK(run_count(ink_row(frame, kW, cursor_col, cursor_col + 1, static_cast<plot::i32>(axes.y),
|
|
static_cast<plot::i32>(axes.bottom()), cursor_pixel)) == 1u);
|
|
|
|
// The grid, drawn *before* the crosshair: its columns are solid but for the one row the crosshair's
|
|
// own horizontal line crosses them at (the crosshair overdraws it), so at most one gap per column.
|
|
plot::u32 grid_rows = 0;
|
|
const plot::i32 grid_col = densest_column(frame, kW, static_cast<plot::i32>(axes.x),
|
|
static_cast<plot::i32>(axes.right()),
|
|
static_cast<plot::i32>(axes.y),
|
|
static_cast<plot::i32>(axes.bottom()), grid_pixel,
|
|
&grid_rows);
|
|
PLOT_CHECK(grid_col >= 0);
|
|
PLOT_CHECK(grid_rows >= static_cast<plot::u32>(axes.h) * 3u / 4u);
|
|
PLOT_CHECK(run_count(ink_row(frame, kW, grid_col, grid_col + 1, static_cast<plot::i32>(axes.y),
|
|
static_cast<plot::i32>(axes.bottom()), grid_pixel)) <= 2u);
|
|
(void)cur;
|
|
|
|
// --- the trace, with the same dashed crosshair over it ----------------------------------
|
|
plot::Plot q;
|
|
q.set_viewport(vp);
|
|
q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
std::vector<plot::f32> ramp(1000u);
|
|
for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast<plot::f32>(i) * 0.1f;
|
|
plot::data::ArraySeries series(ramp, 0.0, 0.1);
|
|
// Unnamed: a named trace would draw a legend panel over the axes' top-right corner, which is
|
|
// where this rising trace spends its last columns -- a backplate hiding data for a reason that
|
|
// is not the dash.
|
|
q.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, ""});
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(q);
|
|
r.end_frame();
|
|
const plot::Rect q_axes = q.layout().axes;
|
|
q.set_crosshair(q.x_transform(0u).to_data_x(static_cast<f64>(std::floor(q_axes.x) + 130.5f)),
|
|
q.y_transform(0u).to_data_y(static_cast<f64>(std::floor(q_axes.y) + 50.5f)),
|
|
true);
|
|
q.set_crosshair_style({1.0f, 4.0f, 4.0f});
|
|
// No readout panel: it is a backplate drawn *over* the data, so it would cover trace columns for
|
|
// reasons that have nothing to do with the dash this case is about.
|
|
q.set_crosshair_readout(false);
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(q);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
// Every column of the axes holds trace ink: the polyline rises across the whole width and a dash
|
|
// leaked into the trace pipeline would leave the columns in its gaps empty. Four columns of slack
|
|
// is where the crosshair's own thin vertical line overdraws the trace.
|
|
const plot::u32 covered = covered_columns(ctx, q_axes, static_cast<plot::u32>(q_axes.w));
|
|
PLOT_CHECK(covered >= static_cast<plot::u32>(q_axes.w) - 4u);
|
|
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// Spec §17's phase requirement: a dash whose phase depended on the axis transform would crawl as
|
|
/// the view panned and look like noise. It is anchored to the segment start -- and both crosshair
|
|
/// lines are staged from an *axes edge* (the vertical from `y_range.min`, the horizontal from
|
|
/// `x_range.min`), so the phase is measured from a fixed edge of the rect. The observable form of
|
|
/// "stable" is that the same state renders the same bytes twice: no per-frame counter, no
|
|
/// time source, no layout drift in the phase.
|
|
PLOT_TEST(dash_phase_is_stable_across_frames) {
|
|
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::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
|
|
plot::Plot p;
|
|
p.set_viewport(vp);
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
std::vector<plot::f32> ramp(1000u);
|
|
for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast<plot::f32>(i) * 0.1f;
|
|
plot::data::ArraySeries series(ramp, 0.0, 0.1);
|
|
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, "ramp"});
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
const plot::Rect axes = p.layout().axes;
|
|
p.set_crosshair(p.x_transform(ax).to_data_x(static_cast<f64>(std::floor(axes.x) + 130.5f)),
|
|
p.y_transform(0u).to_data_y(static_cast<f64>(std::floor(axes.y) + 50.5f)), true);
|
|
p.set_crosshair_style({1.0f, 4.0f, 4.0f});
|
|
|
|
const auto render = [&]() {
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
return read_frame(ctx);
|
|
};
|
|
|
|
const std::vector<plot::u8> first = render();
|
|
const std::vector<plot::u8> second = render();
|
|
PLOT_CHECK(first.size() == second.size());
|
|
PLOT_CHECK(first == second);
|
|
|
|
ctx.destroy();
|
|
}
|
|
|
|
// ------------------------------------------------- B1: relative-x staging (epoch axes)
|
|
|
|
/// The B1 defect: gathering and staging an absolute epoch x as f32 quantised it to
|
|
/// a grid coarser than a pixel (f32 carries 24 bits -- about 128 s at 1.7e9), so an
|
|
/// epoch-window trace collapsed onto a few columns while its zero-origin twin drew
|
|
/// correctly. The fix stages `to_space(x_abs) - to_space(origin)` -- O(visible
|
|
/// pixels) -- and folds the origin into the offset uniform in f64, so the two
|
|
/// windows rasterise to the same geometry.
|
|
///
|
|
/// Both plots below draw the *same samples* with the same width; the only
|
|
/// difference is the series' `x0()` and the axis window's offset. The assertion is
|
|
/// the per-column trace-ink pattern: identical column sets, not merely similar
|
|
/// counts, which a quantised draw could not satisfy.
|
|
namespace {
|
|
|
|
/// Columns (every x of the axes) that hold at least one trace-coloured pixel.
|
|
std::vector<bool> trace_column_mask(plottest::HeadlessContext& ctx, const plot::Rect& a) {
|
|
const plot::u32 first_row = static_cast<plot::u32>(a.y);
|
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
|
std::vector<bool> mask(a.w > 0.0f ? static_cast<std::size_t>(a.w) : 1u, false);
|
|
for (plot::u32 x = 0; x < static_cast<plot::u32>(a.w); ++x) {
|
|
const plot::u32 px_x = static_cast<plot::u32>(a.x) + x;
|
|
for (plot::u32 yy = 0; yy < rows; ++yy) {
|
|
plot::u8 px[4] = {};
|
|
if (ctx.read_pixel(px_x, ctx.height - 1u - (first_row + yy), px) && trace_pixel(px)) {
|
|
mask[x] = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return mask;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
PLOT_TEST(epoch_window_and_zero_origin_twin_rasterise_identically) {
|
|
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));
|
|
|
|
// A sparse sine: one raw sample per column, so every stage of the pipeline is
|
|
// exercised at pixel resolution -- the f32 defect shows on the x coordinates.
|
|
std::vector<plot::f32> ys(640u);
|
|
for (plot::u32 i = 0; i < ys.size(); ++i) {
|
|
ys[i] = 0.8f * std::sin(0.05f * static_cast<plot::f32>(i));
|
|
}
|
|
const f64 epoch = 1'700'000'000.0;
|
|
|
|
plot::data::ArraySeries zero(ys, 0.0, 1.0);
|
|
plot::data::ArraySeries shifted(ys, epoch, 1.0);
|
|
|
|
plot::Plot p0;
|
|
p0.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax0 = p0.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay0 = p0.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
p0.add_trace({&zero, plot::Color::hex(0x40C0FF), 1.5f, ax0, ay0});
|
|
|
|
plot::Plot p1;
|
|
p1.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax1 =
|
|
p1.add_x_axis({"t", {epoch, epoch + 639.0}, true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay1 = p1.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
p1.add_trace({&shifted, plot::Color::hex(0x40C0FF), 1.5f, ax1, ay1});
|
|
|
|
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p0);
|
|
r.end_frame();
|
|
const std::vector<bool> columns_zero = trace_column_mask(ctx, p0.layout().axes);
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p1);
|
|
r.end_frame();
|
|
const std::vector<bool> columns_epoch = trace_column_mask(ctx, p1.layout().axes);
|
|
|
|
PLOT_CHECK(columns_zero == columns_epoch);
|
|
// The premise: the twin really drew a trace across the axes (the comparison is
|
|
// not vacuously true on two empty frames).
|
|
PLOT_CHECK(std::count(columns_zero.begin(), columns_zero.end(), true) >
|
|
static_cast<std::size_t>(p0.layout().axes.w) * 9u / 10u);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
// ------------------------------------------------- B6: two text batches
|
|
|
|
/// The B6 defect: one text batch, flushed after everything, put the legend's
|
|
/// names *over* the crosshair's readout panel -- text above the backplate that
|
|
/// exists to occlude it. The under batch (legend names included) is now flushed
|
|
/// immediately before the panel, so a legend name the panel covers is occluded.
|
|
///
|
|
/// The scene: a legend in the bottom-left corner with three wide names, and a
|
|
/// crosshair parked just inside that corner, whose readout panel flips up and
|
|
/// right -- on top of the legend rows. The assertion reads the legend name boxes
|
|
/// *minus* every rect the readout legitimately paints (its own label and value
|
|
/// runs, and the crosshair's strokes): what is left of a covered name box must
|
|
/// hold no text ink with the readout on, and held ink without it.
|
|
namespace {
|
|
|
|
/// Text ink inside `area`, skipping every pixel inside any of `exclude`.
|
|
plot::u32 text_ink_outside(const std::vector<plot::u8>& frame, const plot::Rect& area,
|
|
const std::vector<plot::Rect>& exclude) {
|
|
plot::u32 hit = 0;
|
|
const plot::u32 x0 = static_cast<plot::u32>(std::max(0.0f, area.x));
|
|
const plot::u32 y0 = static_cast<plot::u32>(std::max(0.0f, area.y));
|
|
const plot::u32 x1 = static_cast<plot::u32>(std::min(area.right(), kFrameRect.right()));
|
|
const plot::u32 y1 = static_cast<plot::u32>(std::min(area.bottom(), kFrameRect.bottom()));
|
|
for (plot::u32 y = y0; y < y1; ++y) {
|
|
for (plot::u32 x = x0; x < x1; ++x) {
|
|
bool skipped = false;
|
|
for (const plot::Rect& e : exclude) {
|
|
if (e.contains(static_cast<plot::f32>(x) + 0.5f, static_cast<plot::f32>(y) + 0.5f)) {
|
|
skipped = true;
|
|
break;
|
|
}
|
|
}
|
|
if (skipped) continue;
|
|
if (text_pixel(&frame[(plot::usize(y) * kW + x) * 4u])) ++hit;
|
|
}
|
|
}
|
|
return hit;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
PLOT_TEST(readout_panel_occludes_the_legend_names_it_covers) {
|
|
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;
|
|
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
|
p.set_viewport(vp);
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
plot::Legend legend;
|
|
legend.place = plot::LegendPlace::BottomLeft;
|
|
p.set_legend(legend);
|
|
// Flat traces at distinct heights, wide names: three tall legend rows.
|
|
plot::data::ArraySeries a(std::vector<plot::f32>(640u, 0.7f), 0.0, 1.0);
|
|
plot::data::ArraySeries b(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
|
|
plot::data::ArraySeries c(std::vector<plot::f32>(640u, 0.3f), 0.0, 1.0);
|
|
p.add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "channel one"});
|
|
p.add_trace({&b, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "channel two long name"});
|
|
p.add_trace({&c, plot::Color::hex(0x50D050), 1.5f, ax, ay, "channel three"});
|
|
|
|
// Warm-up frame (installs the text backend), then the legend-only frame.
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
const std::vector<plot::u8> legend_only = read_frame(ctx);
|
|
const plot::PlotLayout layout = p.layout();
|
|
PLOT_CHECK(layout.legend.rows.size() == 3u);
|
|
PLOT_CHECK(layout.legend.panel.w > 0.0f);
|
|
|
|
// The crosshair just inside the bottom-left corner: its panel flips up and
|
|
// right, over the legend rows.
|
|
const plot::Transform tx = p.x_transform(ax);
|
|
const plot::Transform ty = p.y_transform(ay);
|
|
p.set_crosshair(tx.to_data_x(layout.axes.x + 30.0f),
|
|
ty.to_data_y(layout.axes.bottom() - 30.0f), true);
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
const std::vector<plot::u8> with_readout = read_frame(ctx);
|
|
|
|
// Fresh layout: the crosshair's own geometry only exists now that it is placed
|
|
// (the copy above was taken before `set_crosshair`).
|
|
const plot::CrosshairLayout crosshair = p.layout().crosshair;
|
|
PLOT_CHECK(crosshair.active);
|
|
PLOT_CHECK(crosshair.panel.w > 0.0f);
|
|
// The exclusions: the readout's own runs and the crosshair's strokes.
|
|
std::vector<plot::Rect> exclude;
|
|
for (const plot::CrosshairRow& row : crosshair.rows) {
|
|
if (row.listed) {
|
|
exclude.push_back(row.label_box);
|
|
exclude.push_back(row.value_box);
|
|
exclude.push_back(row.swatch); // the row's own swatch fill
|
|
}
|
|
}
|
|
exclude.push_back(crosshair.vertical);
|
|
exclude.push_back(crosshair.horizontal);
|
|
|
|
// The premise: the panel really does cover at least one legend name box.
|
|
bool covers = false;
|
|
for (const plot::LegendRow& row : layout.legend.rows) {
|
|
if (row.ellipsis) continue;
|
|
if (crosshair.panel.x < row.name.right() && row.name.x < crosshair.panel.right() &&
|
|
crosshair.panel.y < row.name.bottom() && row.name.y < crosshair.panel.bottom()) {
|
|
covers = true;
|
|
}
|
|
}
|
|
PLOT_CHECK(covers);
|
|
|
|
// Each legend name box: ink without the readout; in the part the panel covers,
|
|
// none of it left (outside the readout's own runs) once the panel is over it.
|
|
// The uncovered remainder of a partially covered box keeps its ink -- that is
|
|
// the panel doing its job and nothing more.
|
|
bool checked = false;
|
|
for (const plot::LegendRow& row : layout.legend.rows) {
|
|
const bool overlaps = crosshair.panel.x < row.name.right() &&
|
|
row.name.x < crosshair.panel.right() &&
|
|
crosshair.panel.y < row.name.bottom() &&
|
|
row.name.y < crosshair.panel.bottom();
|
|
if (row.ellipsis || !overlaps) continue;
|
|
// Inset past the panel's own border ring: the border is a hairline of the
|
|
// bright crosshair colour (see draw_readout_panel), which the ink predicate
|
|
// cannot tell from text -- it is the panel's chrome, not legend ink.
|
|
const plot::Rect inner{crosshair.panel.x + 2.0f, crosshair.panel.y + 2.0f,
|
|
crosshair.panel.w - 4.0f, crosshair.panel.h - 4.0f};
|
|
const plot::Rect covered{
|
|
std::max(inner.x, row.name.x), std::max(inner.y, row.name.y),
|
|
std::min(inner.right(), row.name.right()) - std::max(inner.x, row.name.x),
|
|
std::min(inner.bottom(), row.name.bottom()) - std::max(inner.y, row.name.y)};
|
|
PLOT_CHECK(covered.w > 0.0f && covered.h > 0.0f);
|
|
PLOT_CHECK(text_ink_outside(legend_only, covered, {}) > 0u);
|
|
PLOT_CHECK(text_ink_outside(with_readout, covered, exclude) == 0u);
|
|
checked = true;
|
|
}
|
|
PLOT_CHECK(checked);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
// ------------------------------------------------- markers
|
|
|
|
/// Markers are staged as line segments through the trace's own batch: a
|
|
/// marker'd trace lights pixels a plain line does not (the shapes extend past
|
|
/// the stroke), and the stride is literal -- `marker_every = 3` stages exactly a
|
|
/// third of the markers of `marker_every = 1` over the same window.
|
|
PLOT_TEST(markers_light_more_than_the_line_and_stride_is_literal) {
|
|
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));
|
|
|
|
// 32 sparse points: the auto stride would be ~1 marker per point anyway, so
|
|
// `marker_every = 1` vs `3` differ by construction.
|
|
std::vector<plot::f32> ys(32u);
|
|
for (plot::u32 i = 0; i < ys.size(); ++i) {
|
|
ys[i] = 0.5f + 0.4f * std::sin(0.9f * static_cast<plot::f32>(i));
|
|
}
|
|
plot::data::ArraySeries s(ys, 0.0, 20.0);
|
|
|
|
plot::Plot plain_p;
|
|
plain_p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = plain_p.add_x_axis({"t", {0.0, 620.0}, true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = plain_p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
plot::Plot marked_p = plain_p;
|
|
plot::Plot every3_p = plain_p;
|
|
marked_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
|
|
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 1u});
|
|
every3_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
|
|
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 3u});
|
|
plain_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay});
|
|
|
|
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(plain_p);
|
|
r.end_frame();
|
|
const plot::u32 plain_ink = predicate_pixels(read_frame(ctx), kW, plain_p.layout().axes,
|
|
magenta_pixel);
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(marked_p);
|
|
r.end_frame();
|
|
const plot::u32 marked_ink = predicate_pixels(read_frame(ctx), kW, marked_p.layout().axes,
|
|
magenta_pixel);
|
|
const plot::u32 marked_count = r.stats().staged_markers;
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(every3_p);
|
|
r.end_frame();
|
|
const plot::u32 every3_count = r.stats().staged_markers;
|
|
|
|
// The premise: the line drew something; the markers drew strictly more.
|
|
PLOT_CHECK(plain_ink > 0u);
|
|
PLOT_CHECK(marked_ink > plain_ink);
|
|
// The stride is literal: a third of the markers, ±1 for the window's edge
|
|
// sample count.
|
|
PLOT_CHECK(marked_count >= 30u);
|
|
PLOT_CHECK(every3_count * 3u + 2u >= marked_count);
|
|
PLOT_CHECK(every3_count * 3u <= marked_count + 2u);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
// ------------------------------------------------- text colour
|
|
|
|
/// The readout's VALUE column draws in the row trace's own colour (resolved from
|
|
/// the plot at draw time), the labels in the axis text colour: per-instance
|
|
/// `a_colour` instead of one text tint per draw.
|
|
PLOT_TEST(readout_value_draws_in_the_row_trace_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));
|
|
|
|
plot::Plot p;
|
|
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
|
p.set_viewport(vp);
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
plot::data::ArraySeries s(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
|
|
const auto magenta = plot::Color::hex(0xFF5090);
|
|
p.add_trace({&s, magenta, 1.5f, ax, ay, "row"});
|
|
p.set_crosshair(300.0, 0.5, true);
|
|
p.set_crosshair_readout(true);
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
|
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
|
|
PLOT_CHECK(crosshair.rows.size() == 1u);
|
|
PLOT_CHECK(crosshair.rows[0].listed);
|
|
PLOT_CHECK(crosshair.rows[0].value_box.w > 0.0f);
|
|
|
|
// The value box holds pixels the row trace's colour explains (magenta
|
|
// dominance at any blend over the dark panel) -- the old single-tint pipeline
|
|
// drew every glyph in the axis text grey, so this is the defect's own pixel.
|
|
plot::u32 coloured = 0;
|
|
const plot::Rect box = crosshair.rows[0].value_box;
|
|
for (plot::u32 y = static_cast<plot::u32>(box.y); y < static_cast<plot::u32>(box.bottom()); ++y) {
|
|
for (plot::u32 x = static_cast<plot::u32>(box.x); x < static_cast<plot::u32>(box.right());
|
|
++x) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
|
|
if (magenta_pixel(px)) ++coloured;
|
|
}
|
|
}
|
|
PLOT_CHECK(coloured > 0u);
|
|
// The label box, same frame, is the axis text colour, not the trace's.
|
|
plot::u32 label_grey = 0;
|
|
const plot::Rect labels = crosshair.rows[0].label_box;
|
|
for (plot::u32 y = static_cast<plot::u32>(labels.y); y < static_cast<plot::u32>(labels.bottom());
|
|
++y) {
|
|
for (plot::u32 x = static_cast<plot::u32>(labels.x); x < static_cast<plot::u32>(labels.right());
|
|
++x) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
|
|
if (text_pixel(px)) ++label_grey;
|
|
}
|
|
}
|
|
PLOT_CHECK(label_grey > 0u);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
// ------------------------------------------------- R6: GL state residue and shutdown
|
|
|
|
/// `begin_frame()` saves the host's blend function and enable bit; `end_frame()`
|
|
/// hands both back -- a host that blends its own way before the frame must not
|
|
/// inherit the frame's `SRC_ALPHA / ONE_MINUS_SRC_ALPHA`.
|
|
PLOT_TEST(a_frame_hands_the_blend_state_back) {
|
|
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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
|
|
// The host's own blend: function ONE/ZERO, enabled.
|
|
plot::gl::glEnable(plot::gl::GL_BLEND);
|
|
plot::gl::glBlendFunc(plot::gl::GL_ONE, plot::gl::GL_ZERO);
|
|
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
plot::gl::GLint src = -1;
|
|
plot::gl::GLint dst = -1;
|
|
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_SRC_RGB, &src);
|
|
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_DST_RGB, &dst);
|
|
PLOT_CHECK(src == static_cast<plot::gl::GLint>(plot::gl::GL_ONE));
|
|
PLOT_CHECK(dst == static_cast<plot::gl::GLint>(plot::gl::GL_ZERO));
|
|
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) != plot::gl::GL_FALSE);
|
|
|
|
// And with blending off, it comes back off.
|
|
plot::gl::glDisable(plot::gl::GL_BLEND);
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) == plot::gl::GL_FALSE);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// `release_gl()` destroys every GL handle while the context is current: after
|
|
/// it the renderer is uninitialised, the driver holds no plot objects (a
|
|
/// re-init and frame still draw, which is the observable half), and the same
|
|
/// renderer object can be brought back.
|
|
PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
|
|
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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
r.release_gl();
|
|
PLOT_CHECK(!r.ready());
|
|
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
|
|
|
|
// Re-init on the same object draws again -- the handles were released, not
|
|
// half-destroyed.
|
|
PLOT_CHECK(r.init(&st));
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
// The other orientation, in a figure: a horizontal ruler is per row (its `y` is on that
|
|
// row's y axis, so two rows' rulers are two different values), and every row's has to be
|
|
// drawn in its own band. The vertical case above covers the shared-time half; this is the
|
|
// half where each row owns its instrument, which is what an app's "two rulers per plot"
|
|
// needs -- and which the app found undrawn.
|
|
PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
|
|
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<plot::f32> samples = wave(101u, 0.0f);
|
|
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
|
|
|
// A strongly red ruler and a strongly green one, so a pixel that is red- or
|
|
// green-dominant inside a row's axes can only be that row's ink: the trace is blue and
|
|
// the chrome grey (all three channels equal).
|
|
const plot::Color red = plot::Color::hex(0xFF2020);
|
|
const plot::Color green = plot::Color::hex(0x20FF20);
|
|
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
|
|
|
|
// The harness's no-atlas backend: a ruler always writes its value as text, and this case
|
|
// is about the *stroke*, so the text is asked for and staged as nothing. It goes on the
|
|
// *renderer*, not on the plots: `draw(Figure&)` replaces each row's backend with the
|
|
// renderer's own on the first frame, so a plot-level backend would be swapped out under a
|
|
// batch that had already declared that backend's (absent) cell.
|
|
OnePxPerStringBackend no_text;
|
|
r.set_text_backend(&no_text);
|
|
|
|
plot::Figure figure(3u);
|
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
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, y_scales[row]}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
|
|
// A quarter and three quarters up this row's own range: inside its axes whatever the
|
|
// scale is, which is the point of a ruler being per row.
|
|
p.add_h_ruler(0.25 * y_scales[row], red);
|
|
p.add_h_ruler(0.75 * y_scales[row], green);
|
|
}
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(figure);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "figure_h_rulers.ppm");
|
|
|
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
|
// Rows of the frame that hold a fully covered red or green line, and which row's band
|
|
// each falls in. The predicate demands full coverage exactly as the vertical case does.
|
|
const auto lines_in = [&](const plot::Rect& area, bool want_red) {
|
|
plot::u32 found = 0;
|
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
|
plot::u32 run = 0;
|
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
|
x < static_cast<plot::u32>(area.right()); ++x) {
|
|
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
|
|
const bool ok = want_red ? (px[0] >= 200 && px[1] <= 80 && px[2] <= 80)
|
|
: (px[1] >= 200 && px[0] <= 80 && px[2] <= 80);
|
|
if (ok) ++run;
|
|
}
|
|
if (run > (area.w * 3u) / 4u) ++found;
|
|
}
|
|
return found;
|
|
};
|
|
const plot::FigureLayout layout = figure.layout();
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
|
PLOT_CHECK(lines_in(axes, true) >= 1u); // this row's own A ruler
|
|
PLOT_CHECK(lines_in(axes, false) >= 1u); // and its B ruler
|
|
}
|
|
(void)layout;
|
|
}
|
|
|
|
namespace {
|
|
|
|
/// A true square wave: `period` samples a cycle, half at +1 and half at -1, so every
|
|
/// transition is a single-sample discontinuity. That is the waveform the phosphor model
|
|
/// is hardest on -- a riser has no intermediate samples to deposit ink in, so whatever
|
|
/// the grading does to it is the whole of what a reader sees.
|
|
std::vector<plot::f32> square_samples(plot::u64 period) {
|
|
std::vector<plot::f32> y(1'000'000);
|
|
for (plot::u64 i = 0; i < y.size(); ++i) {
|
|
y[i] = (i % period) < period / 2u ? 1.0f : -1.0f;
|
|
}
|
|
return y;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// A column the signal only *passed* through must still be a column it reached:
|
|
/// `intensity_floor` exists so that "a riser dimmed out of existence is a square wave
|
|
/// drawn as two disconnected lines", and the per-column grading used to break that
|
|
/// promise on exactly the waveform it names.
|
|
///
|
|
/// The cause was the "lit at all" cutoff inside the column reduction. Grading normalises
|
|
/// a column against its own peak and then discards sub-buckets under `0.01 * peak`. A
|
|
/// column holding both a flat level and a riser has a peak set by the level -- the level
|
|
/// deposits its whole unit into one row -- while the riser spreads a unit over 300 rows,
|
|
/// so every riser row lands three orders of magnitude under the cutoff and is dropped
|
|
/// before the floor can be applied to it. The floor never sees the riser; there is no
|
|
/// tier 0 to fall back to.
|
|
///
|
|
/// Measured over this frame's 116 riser columns: the graded path inked 2 916 of the
|
|
/// ungraded path's 35 884 rows (8%), gutting 106 of the 116 and leaving a 311-row dark
|
|
/// run in a 312-row axis. The ten survivors are the phase that puts a riser in a column
|
|
/// of its own, which is what made the defect read as a *flicker* under panning rather
|
|
/// than as a missing edge. The cutoff is now `min(0.01 * peak, 0.5 * w_min)`, so it can
|
|
/// never exclude a deposit some segment actually made, and the reading is 35 572 rows
|
|
/// (99.1%) with 115 of 116 columns intact.
|
|
PLOT_TEST(a_graded_column_keeps_a_riser_it_shares_with_a_flat_level) {
|
|
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));
|
|
|
|
// 2098 samples a cycle over 104.8 samples a column is about 20 columns a cycle, so a
|
|
// riser's column also holds a long stretch of the level it leaves -- the sharing this
|
|
// test is about -- rather than being a column of pure transition.
|
|
plot::data::ArraySeries s(square_samples(2098), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, 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, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
|
|
// Columns whose band spans more than half the axis: on a square wave those are the
|
|
// transitions and nothing else, since a level's own band is a ribbon a few rows deep.
|
|
// "Tall" is the property at stake -- a riser drawn as a stub is as wrong as one not
|
|
// drawn at all -- so it is both how the columns are chosen and what is asserted of
|
|
// them, read from the ungraded frame and then re-read from the graded one.
|
|
const auto tall_columns = [&](const plot::Rect& a) {
|
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
|
std::vector<plot::u32> out;
|
|
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
|
|
plot::u32 lo = rows;
|
|
plot::u32 hi = 0;
|
|
for (plot::u32 row = 0; row < rows; ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
|
|
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
|
// 20 of the 191 a full stroke inks, which is under the faintest tier
|
|
// (`intensity_floor` 0.3) -- so a row this rejects is a row no tier drew.
|
|
if (static_cast<int>(px[2]) - static_cast<int>(px[0]) > 20) {
|
|
if (row < lo) lo = row;
|
|
hi = row;
|
|
}
|
|
}
|
|
if (lo < hi && hi - lo > rows / 2u) out.push_back(col);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// The axes rect is read *after* a draw: `Plot::layout()` sizes its label strips from
|
|
// the tick text at the style's font, so before the first frame it answers with the
|
|
// unmeasured estimate and the window would be off by 11 columns in x.
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
dump_if_requested(r, "riser-ungraded.ppm");
|
|
const plot::Rect a = p.layout().axes;
|
|
const std::vector<plot::u32> flat = tall_columns(a);
|
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
|
|
|
|
r.style().intensity_grading = true;
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
dump_if_requested(r, "riser-graded.ppm");
|
|
const std::vector<plot::u32> lit = tall_columns(a);
|
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
// How many of the ungraded frame's own riser columns survived the grading, which is
|
|
// the reading the ratio below is taken from. Both lists are ascending, so this is one
|
|
// pass.
|
|
plot::u32 kept = 0;
|
|
for (plot::usize i = 0, j = 0; i < flat.size() && j < lit.size();) {
|
|
if (flat[i] == lit[j]) {
|
|
++kept;
|
|
++i;
|
|
++j;
|
|
} else if (flat[i] < lit[j]) {
|
|
++i;
|
|
} else {
|
|
++j;
|
|
}
|
|
}
|
|
std::printf("[riser] axes=[%.1f %.1f %.1f %.1f] flat=%zu graded=%zu kept=%u\n", a.x, a.y, a.w,
|
|
a.h, flat.size(), lit.size(), kept);
|
|
|
|
// Enough columns to make the ratio mean something; 116 were measured.
|
|
PLOT_CHECK(flat.size() > 50u);
|
|
// And the grading kept all but one of them. The one is real and is not the defect: the
|
|
// ungraded path inks *two* full-height columns per transition -- a column's own min/max,
|
|
// the connectivity extension to its neighbour, and the gathered window's slack sample
|
|
// all land in x -- where the reduction inks one, which is also why 116 columns appear
|
|
// for 58 transitions. Before the fix this read 10 of 116.
|
|
PLOT_CHECK(kept * 100u >= static_cast<plot::u32>(flat.size()) * 95u);
|
|
}
|
|
|
|
/// Dwell dimming must not switch off when the window gets narrow. It used to, because
|
|
/// the grading was gated on the *column reduction*, and that reduction is gated on two
|
|
/// gathered points per pixel column -- a density whose only purpose is removing the
|
|
/// moire beat. Zooming past `dense_threshold_spp` therefore turned the whole phosphor
|
|
/// model off in one step, and a discontinuity that had been drawn at a third of the
|
|
/// level's ink was suddenly drawn at all of it: measured 71 of 191 at 3.4 samples a
|
|
/// pixel and 191 of 191 at 1.0, which is what a user reported as an edge that "lights
|
|
/// up" on zoom.
|
|
///
|
|
/// `Renderer::stage_trace_slopes()` grades the polyline by each segment's own slope, so
|
|
/// the model now spans both rasterisation paths. The riser reads 71 either side of the
|
|
/// threshold, and this test is that pair of readings.
|
|
PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_one) {
|
|
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(square_samples(2098), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
|
// -2..2 so the two levels sit a quarter in from each edge rather than on it: a level
|
|
// on the boundary row is half clipped away, and its ink would then read low for a
|
|
// reason that has nothing to do with grading.
|
|
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
r.style().intensity_grading = true;
|
|
|
|
struct Reading {
|
|
int riser = 0; ///< peak ink in the riser's own columns, at mid height
|
|
int flat = 0; ///< peak ink in a level's columns, well clear of the riser
|
|
};
|
|
// Both windows are centred on the transition at sample 1049, so the riser's column is
|
|
// known by construction rather than searched for -- a search would have to decide what
|
|
// a riser looks like, which is the very thing in question.
|
|
const auto measure = [&](double window) {
|
|
p.set_x_range(ax, {1049.0 - window / 2.0, 1049.0 + window / 2.0});
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
const plot::Rect a = p.layout().axes;
|
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
|
const plot::u32 cols = static_cast<plot::u32>(a.w);
|
|
const plot::u32 mid = cols / 2u;
|
|
Reading out;
|
|
for (plot::u32 col = 0; col < cols; ++col) {
|
|
for (plot::u32 row = 0; row < rows; ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
|
|
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
|
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
|
if (col + 6u < mid || col > mid + 6u) {
|
|
// A level's own ink. The six-column guard is for the near-vertical stroke's
|
|
// own width plus the connector either side of it.
|
|
if (cover > out.flat) out.flat = cover;
|
|
} else if (row >= 3u * rows / 8u && row <= 5u * rows / 8u) {
|
|
// The riser's own ink: the middle quarter of the axis, which neither level
|
|
// reaches. Reading the full height instead answers 191 at every zoom, because
|
|
// the riser's column contains a level too.
|
|
if (cover > out.riser) out.riser = cover;
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// 3.4 samples a pixel and 1.0, so one window either side of `dense_threshold_spp`
|
|
// (1.5) over the same waveform at the same place.
|
|
const Reading dense = measure(2000.0);
|
|
const plot::FrameStats dense_stats = r.stats();
|
|
const Reading sparse = measure(600.0);
|
|
const plot::FrameStats sparse_stats = r.stats();
|
|
std::printf("[slope] dense riser=%d flat=%d graded=%u slopes=%u | sparse riser=%d flat=%d "
|
|
"graded=%u slopes=%u\n",
|
|
dense.riser, dense.flat, dense_stats.graded_traces, dense_stats.slope_traces,
|
|
sparse.riser, sparse.flat, sparse_stats.graded_traces, sparse_stats.slope_traces);
|
|
|
|
// The two frames took the two paths, or the comparison is between a frame and itself.
|
|
PLOT_CHECK(dense_stats.graded_traces == 1 && dense_stats.slope_traces == 0);
|
|
PLOT_CHECK(sparse_stats.slope_traces == 1 && sparse_stats.graded_traces == 0);
|
|
// Neither path dims the level: a flat segment dwells in its row, so it is the top tier
|
|
// and full ink. Both read 191.
|
|
PLOT_CHECK(dense.flat > 180 && sparse.flat > 180);
|
|
// And both dim the discontinuity, to about a third -- `intensity_floor` is 0.3 and a
|
|
// riser spanning the axis is the longest span there is, so it sits on the floor. Both
|
|
// read 71. Before the slope grading the sparse frame read 191 here.
|
|
PLOT_CHECK(dense.riser * 100 < dense.flat * 55);
|
|
PLOT_CHECK(sparse.riser * 100 < sparse.flat * 55);
|
|
// The point of the exercise: the same edge at the two zooms is the same ink, so there
|
|
// is no step at the threshold for a reader to see. A tier either way is 23 of 191, and
|
|
// the measured pair is exact.
|
|
PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
}
|
|
|
|
/// An invalid span is the opposite of the x note: the note is chrome that a pan must not move,
|
|
/// and a band marks a *region of the data*, so it must pan and zoom with it. A band that stayed
|
|
/// put under a pan would accuse the wrong samples of being missing, which is worse than drawing
|
|
/// nothing. It also must reserve no layout space -- the label strips were measured before any
|
|
/// band existed, and a hole must not be able to move them.
|
|
///
|
|
/// Measured as a footprint for the x note's reason: the band's rect is deliberately not readable
|
|
/// back, so the only way to see it is the pixels a frame *with* it differs from the same frame
|
|
/// without one, at the same view. Diffing at a fixed view is also what makes the measurement
|
|
/// robust: whatever the trace does under a pan cancels between the two frames, so the bounding
|
|
/// box is the band and nothing else.
|
|
PLOT_TEST(an_invalid_span_is_shaded_and_moves_with_the_data) {
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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});
|
|
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
|
|
|
|
// The bounding box of the pixels the band, and only the band, is responsible for, plus the
|
|
// axes rect the frame was laid out into so the caller can check it did not move.
|
|
const auto footprint = [&](const plot::Range& view, plot::InvalidSpan span,
|
|
plot::Rect& axes_out, const char* dump) {
|
|
p.set_x_range(ax, view);
|
|
const plot::InvalidSpan one[1] = {span};
|
|
p.set_invalid_spans(one);
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, dump);
|
|
const std::vector<plot::u8> with = read_frame(ctx);
|
|
axes_out = p.layout().axes;
|
|
p.set_invalid_spans({});
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> without = read_frame(ctx);
|
|
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0;
|
|
for (plot::u32 y = 0; y < kH; ++y) {
|
|
for (plot::u32 x = 0; x < kW; ++x) {
|
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
|
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
|
|
with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) {
|
|
continue;
|
|
}
|
|
++differing;
|
|
lo_x = std::min(lo_x, x);
|
|
lo_y = std::min(lo_y, y);
|
|
hi_x = std::max(hi_x, x);
|
|
hi_y = std::max(hi_y, y);
|
|
}
|
|
}
|
|
// A comparison of two frames is only evidence if the band was in one of them.
|
|
PLOT_CHECK(differing > 0u);
|
|
return plot::Rect{static_cast<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
|
|
static_cast<plot::f32>(hi_x - lo_x + 1u),
|
|
static_cast<plot::f32>(hi_y - lo_y + 1u)};
|
|
};
|
|
|
|
// A band over the middle third of a 640-wide window.
|
|
plot::Rect axes_a{0.0f, 0.0f, 0.0f, 0.0f};
|
|
const plot::Rect none_box = footprint({0.0, 640.0}, {640.0 / 3.0, 1280.0 / 3.0, false}, axes_a,
|
|
"invalid_span.ppm");
|
|
// Inside the axes on every side -- a hole is not allowed to paint over a label strip -- and
|
|
// the full height of the data area, because the whole column has no data.
|
|
PLOT_CHECK(none_box.x >= axes_a.x && none_box.right() <= axes_a.right() + 1.0f);
|
|
PLOT_CHECK(none_box.y >= axes_a.y && none_box.bottom() <= axes_a.bottom() + 1.0f);
|
|
PLOT_CHECK_NEAR(none_box.h, axes_a.h, 2.0);
|
|
// The middle third: a third of the width, starting a third of the way across. Two pixels of
|
|
// slack for the fill's fractional edges.
|
|
PLOT_CHECK_NEAR(none_box.x - axes_a.x, axes_a.w / 3.0f, 2.0);
|
|
PLOT_CHECK_NEAR(none_box.w, axes_a.w / 3.0f, 2.0);
|
|
|
|
// The same span under a pan of exactly 64 device pixels' worth of data. The band is anchored
|
|
// in the data, so it must move by 64 pixels -- the x note, measured the same way, does not
|
|
// move at all, which is the distinction this pair of cases draws.
|
|
const plot::f64 per_px = 640.0 / plot::f64(axes_a.w);
|
|
const plot::f64 dx = 64.0 * per_px;
|
|
plot::Rect axes_b{0.0f, 0.0f, 0.0f, 0.0f};
|
|
const plot::Rect panned = footprint({dx, 640.0 + dx}, {640.0 / 3.0, 1280.0 / 3.0, false},
|
|
axes_b, "invalid_span_panned.ppm");
|
|
PLOT_CHECK_NEAR(panned.x, none_box.x - 64.0f, 2.0);
|
|
PLOT_CHECK_NEAR(panned.w, none_box.w, 2.0);
|
|
// The layout is identical across both: the band reserved nothing, so the strips the labels
|
|
// were measured into are where they were before any band existed.
|
|
PLOT_CHECK_NEAR(axes_b.x, axes_a.x, 1e-4);
|
|
PLOT_CHECK_NEAR(axes_b.y, axes_a.y, 1e-4);
|
|
PLOT_CHECK_NEAR(axes_b.w, axes_a.w, 1e-4);
|
|
PLOT_CHECK_NEAR(axes_b.h, axes_a.h, 1e-4);
|
|
|
|
std::printf("[invalid span] band %.0f,%.0f %.0fx%.0f panned %.0f,%.0f %.0fx%.0f "
|
|
"(shift %.1f px) in axes %.0f,%.0f %.0fx%.0f\n",
|
|
none_box.x, none_box.y, none_box.w, none_box.h, panned.x, panned.y, panned.w,
|
|
panned.h, none_box.x - panned.x, axes_a.x, axes_a.y, axes_a.w, axes_a.h);
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// A hole narrower than a pixel is still a report. A reserved span of one sample at a window
|
|
/// width of hundreds of thousands maps to a fraction of a column, and a fill rounded to zero
|
|
/// width would draw nothing -- the one case where the feature is most needed is the one where
|
|
/// naive rounding silently drops it. The band therefore has a one-device-pixel floor.
|
|
PLOT_TEST(a_sub_pixel_invalid_span_is_still_shaded) {
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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});
|
|
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
|
|
|
|
// One sample's worth of hole in a 200 000-wide window: well under a hundredth of a column.
|
|
p.set_x_range(ax, {0.0, 200000.0});
|
|
const plot::InvalidSpan one[1] = {{100000.0, 100000.0, false}};
|
|
p.set_invalid_spans(one);
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "invalid_span_subpixel.ppm");
|
|
const std::vector<plot::u8> with = read_frame(ctx);
|
|
p.set_invalid_spans({});
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> without = read_frame(ctx);
|
|
|
|
plot::u32 columns = 0, differing = 0;
|
|
for (plot::u32 x = 0; x < kW; ++x) {
|
|
bool any = false;
|
|
for (plot::u32 y = 0; y < kH; ++y) {
|
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
|
if (with[at] != without[at] || with[at + 1u] != without[at + 1u] ||
|
|
with[at + 2u] != without[at + 2u] || with[at + 3u] != without[at + 3u]) {
|
|
any = true;
|
|
++differing;
|
|
}
|
|
}
|
|
if (any) ++columns;
|
|
}
|
|
// Drawn, and narrow: the floor is one device pixel, not a visible band widened to be seen.
|
|
PLOT_CHECK(differing > 0u);
|
|
PLOT_CHECK(columns >= 1u && columns <= 2u);
|
|
std::printf("[invalid span] sub-pixel hole lit %u column(s), %u pixels\n", columns, differing);
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// The band carries three states and not two: a hole still missing its samples is shaded solidly,
|
|
/// a hole whose late packets have all arrived is shaded faintly, and a hole whose slots have aged
|
|
/// out of the host's buffer is not shaded at all. The faint tier is what keeps the report honest
|
|
/// -- the window *was* drawn without that data -- while telling a reader it is no longer missing.
|
|
///
|
|
/// Measured as ink rather than as a sampled pixel: at a fixed view the trace, the grid and the
|
|
/// labels are byte-identical across all three frames, so every differing byte is the band's own
|
|
/// and a sum of absolute differences measures the tint and nothing else. The alternative -- read
|
|
/// one pixel inside the band -- would be measuring whatever the trace happens to do there.
|
|
PLOT_TEST(a_repaired_invalid_span_is_fainter_than_a_missing_one) {
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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});
|
|
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
|
|
p.set_x_range(ax, {0.0, 640.0});
|
|
|
|
// One band over the middle third, at one fixed view, in each of the three states.
|
|
const auto frame = [&](bool shaded, bool repaired, const char* dump) {
|
|
const plot::InvalidSpan one[1] = {{640.0 / 3.0, 1280.0 / 3.0, repaired}};
|
|
if (shaded) {
|
|
p.set_invalid_spans(one);
|
|
} else {
|
|
p.set_invalid_spans({});
|
|
}
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
if (dump != nullptr) dump_if_requested(r, dump);
|
|
return read_frame(ctx);
|
|
};
|
|
|
|
const std::vector<plot::u8> none = frame(false, false, nullptr);
|
|
const std::vector<plot::u8> missing = frame(true, false, "invalid_span_missing.ppm");
|
|
const std::vector<plot::u8> repaired = frame(true, true, "invalid_span_repaired.ppm");
|
|
|
|
// Total ink a frame laid down over the unshaded one, and the columns it covers.
|
|
const auto ink = [&](const std::vector<plot::u8>& with, plot::u32& lo_x, plot::u32& hi_x) {
|
|
unsigned long long total = 0;
|
|
lo_x = kW;
|
|
hi_x = 0;
|
|
for (plot::u32 y = 0; y < kH; ++y) {
|
|
for (plot::u32 x = 0; x < kW; ++x) {
|
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
|
unsigned d = 0;
|
|
for (plot::usize c = 0; c < 4u; ++c) {
|
|
const unsigned a = with[at + c];
|
|
const unsigned b = none[at + c];
|
|
d += a > b ? a - b : b - a;
|
|
}
|
|
if (d == 0u) continue;
|
|
total += d;
|
|
lo_x = std::min(lo_x, x);
|
|
hi_x = std::max(hi_x, x);
|
|
}
|
|
}
|
|
return total;
|
|
};
|
|
|
|
plot::u32 m_lo = 0, m_hi = 0, r_lo = 0, r_hi = 0;
|
|
const unsigned long long missing_ink = ink(missing, m_lo, m_hi);
|
|
const unsigned long long repaired_ink = ink(repaired, r_lo, r_hi);
|
|
|
|
// A repaired hole is still drawn. A tint that vanished would be the "retire the band on repair"
|
|
// behaviour this feature deliberately does not have: the window was missing that data when it
|
|
// was drawn, and the band is the record of it.
|
|
PLOT_CHECK(repaired_ink > 0ull);
|
|
// And the solid one is unmistakably heavier. The defaults are alpha 0.18 against 0.08, better
|
|
// than two to one, so half again is a wide margin and not a tuned threshold.
|
|
PLOT_CHECK(missing_ink > repaired_ink + repaired_ink / 2ull);
|
|
// Same pixels either way: the state chooses the tint and nothing about the geometry.
|
|
PLOT_CHECK(m_lo == r_lo && m_hi == r_hi);
|
|
std::printf("[invalid span] missing ink %llu over columns %u..%u, repaired ink %llu over "
|
|
"%u..%u\n",
|
|
missing_ink, m_lo, m_hi, repaired_ink, r_lo, r_hi);
|
|
ctx.destroy();
|
|
}
|
|
|
|
PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
|
|
// The frame a host sets has to reach the framebuffer and cover exactly the rect it names. Both halves
|
|
// are read back rather than trusted, and the two renders are compared: what is under the frame is a
|
|
// trace, the grid and the axis chrome, so the assertions are differential -- a predicate guessing
|
|
// those colours would be testing the guess.
|
|
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<plot::f32>(kW), static_cast<plot::f32>(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});
|
|
|
|
// The *floor* of a pixel rather than its luminance: the fill is the axis furniture's light neutral at
|
|
// a low alpha, so over a saturated trace it can lower a channel while it raises the floor, and the
|
|
// floor is what says "something light was laid over this".
|
|
const auto floor_of = [&](plot::u32 x, plot::u32 y) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
|
|
return static_cast<int>(std::min(px[0], std::min(px[1], px[2])));
|
|
};
|
|
// The renderer does not clear -- that is the host's -- and a second draw over the first would double
|
|
// every trace and change every pixel for reasons that have nothing to do with the frame.
|
|
const auto draw = [&](bool framed) {
|
|
if (framed) p.set_selection_rect({200.0f, 100.0f, 200.0f, 120.0f});
|
|
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, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
};
|
|
|
|
draw(false);
|
|
const int border_before = floor_of(200, 160); // the frame's left border, mid-height
|
|
const int inside_before = floor_of(300, 160); // the middle of the framed area
|
|
const int outside_before = floor_of(500, 160); // to the right of it
|
|
draw(true);
|
|
const int border_after = floor_of(200, 160);
|
|
const int inside_after = floor_of(300, 160);
|
|
const int outside_after = floor_of(500, 160);
|
|
|
|
// A frame, not a wash: the border is nearly opaque, the fill is not.
|
|
PLOT_CHECK(border_after > inside_after);
|
|
PLOT_CHECK(border_after > border_before + 60);
|
|
// A fill, not a panel: it lightens what it covers and leaves it readable.
|
|
PLOT_CHECK(inside_after > inside_before + 10);
|
|
// Exactly the rect, and not a pixel beyond it: this is the assertion that the frame matches the area
|
|
// the gesture is choosing rather than merely being somewhere on screen.
|
|
PLOT_CHECK(outside_after == outside_before);
|
|
p.clear_selection_rect();
|
|
}
|
|
|
|
/// The phosphor density **is the trace**, in the trace's own colour: it covers the axes rect in
|
|
/// `Trace::color`, the newest acquisition is deposited into it like every other -- so it fades, and
|
|
/// no solid polyline is drawn over the ink -- and it costs an upload on the frame a grid moved and
|
|
/// nothing on the frame it did not.
|
|
///
|
|
/// Three things are read off the *screen*, because neither the grid's counts nor a tint alone can
|
|
/// make them: that a saturated cell is the trace's own colour and not a monochrome wash; that a
|
|
/// cell a quarter of the way up the scale is dimmer *and the same hue*; and that the acquisition
|
|
/// just deposited fades -- which is only true if it is *inside* the image and not a stroke drawn
|
|
/// on top of its own faint ghost (the "persistent image + last acquisition" report).
|
|
PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_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));
|
|
|
|
plot::data::ArraySeries s(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, 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 trace_color = plot::Color::hex(0x00E070);
|
|
PLOT_CHECK(p.add_trace({&s, trace_color, 1.5f, ax, ay, "flat"}) == 0u);
|
|
p.set_x_range(ax, {0.0, 1.0});
|
|
p.set_y_range(ay, {-1.0, 1.0});
|
|
|
|
// The baseline: no density at all, and no grid to draw one from.
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
PLOT_CHECK(r.stats().density_cells == 0u);
|
|
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
|
|
const std::vector<plot::u8> without = read_frame(ctx);
|
|
|
|
const auto channel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
|
|
return static_cast<int>(f[(plot::usize(y) * kW + x) * 4u + static_cast<plot::usize>(c)]);
|
|
};
|
|
const plot::Transform tx = p.x_transform(ax);
|
|
const plot::Transform ty = p.y_transform(ay);
|
|
const plot::u32 mid_x = static_cast<plot::u32>(tx.to_screen_x(0.5));
|
|
const plot::u32 trace_row = static_cast<plot::u32>(ty.to_screen_y(0.0));
|
|
// A row well away from the sweep, and away from the trace in the no-density frame: the
|
|
// background to read every cell against, rather than a guessed one.
|
|
const plot::u32 blank_row = trace_row + 40u < kH ? trace_row + 40u : trace_row - 40u;
|
|
|
|
// **The newest acquisition is in the image, and it fades.** A time fade from the first deposit:
|
|
// one time constant must dim the same pixel, which a stroke drawn over the density could not do.
|
|
plot::DensityConfig cfg;
|
|
cfg.enabled = true;
|
|
cfg.decay = plot::DensityDecay::TimeConstant;
|
|
cfg.time_constant_s = 1.0f; // the ceiling stays the library's one sweep -- the trace's own
|
|
p.set_density(cfg);
|
|
p.accumulate_density(); // the acquisition on screen, deposited like any other
|
|
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "density.ppm");
|
|
const plot::FrameStats first = r.stats();
|
|
const std::vector<plot::u8> fresh = read_frame(ctx);
|
|
|
|
// The image is drawn -- it changed pixels -- and every pixel it changed is inside the axes:
|
|
// a density reserves no layout space and paints nothing over a label strip.
|
|
const plot::Rect axes = p.layout().axes;
|
|
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0u, hi_y = 0u, differing = 0u;
|
|
for (plot::u32 y = 0u; y < kH; ++y) {
|
|
for (plot::u32 x = 0u; x < kW; ++x) {
|
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
|
if (fresh[at] == without[at] && fresh[at + 1u] == without[at + 1u] &&
|
|
fresh[at + 2u] == without[at + 2u]) {
|
|
continue;
|
|
}
|
|
++differing;
|
|
lo_x = std::min(lo_x, x);
|
|
lo_y = std::min(lo_y, y);
|
|
hi_x = std::max(hi_x, x);
|
|
hi_y = std::max(hi_y, y);
|
|
}
|
|
}
|
|
PLOT_CHECK(differing > 0u);
|
|
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
|
|
|
|
// One trace, so one grid and one upload of one byte a cell -- and the *same* grid the plot
|
|
// reports, so the picture and the numbers are one derivation.
|
|
const plot::DensityGrid& grid = p.density_grid(0u);
|
|
PLOT_CHECK(first.density_cells == static_cast<plot::u32>(grid.cell_count()));
|
|
PLOT_CHECK(first.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
|
|
|
|
// **The colour**: the cell the sweep is in is the trace's own colour at full strength -- one
|
|
// sweep is the ceiling, so a saturated cell *is* the trace -- and not the near-white monochrome
|
|
// the density used to draw in. The trace has no red and plenty of green and blue; a monochrome
|
|
// wash at full ink had all three high and near equal.
|
|
const int bg_g = channel(fresh, mid_x, blank_row, 1);
|
|
const int cell_r = channel(fresh, mid_x, trace_row, 0);
|
|
const int cell_g = channel(fresh, mid_x, trace_row, 1);
|
|
const int cell_b = channel(fresh, mid_x, trace_row, 2);
|
|
PLOT_CHECK(cell_r < 40); // the trace's colour has no red in it
|
|
PLOT_CHECK(cell_g > 150); // ... and its green is the ink
|
|
PLOT_CHECK(cell_g > cell_b && cell_b > cell_r); // the hue is the trace's, not a neutral
|
|
PLOT_CHECK(cell_g > bg_g + 40);
|
|
// A saturated cell **is** the trace's colour: one sweep at the ceiling draws the trace's own
|
|
// rgba, so the pixel matches it exactly rather than merely resembling it.
|
|
const plot::u8* cell_px = fresh.data() + (plot::usize(trace_row) * kW + mid_x) * 4u;
|
|
PLOT_CHECK(pixel_is(cell_px, trace_color));
|
|
|
|
// One time constant of the same pixel: dimmer, and still the trace's hue. This is the assertion
|
|
// that the current acquisition is inside the density -- the fade `exp(-dt/tau)`.
|
|
const double before = grid.ink();
|
|
p.decay_density(1.0); // dt = tau, so the whole image is e^-1 of what it was
|
|
const double after = grid.ink();
|
|
PLOT_CHECK_NEAR(after / before, std::exp(-1.0), 1e-4);
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> faded = read_frame(ctx);
|
|
const int faded_g = channel(faded, mid_x, trace_row, 1);
|
|
const int faded_r = channel(faded, mid_x, trace_row, 0);
|
|
PLOT_CHECK(faded_g < cell_g - 20); // the newest acquisition faded
|
|
PLOT_CHECK(faded_r < 40); // and it faded *as the trace's colour*, not toward a neutral
|
|
PLOT_CHECK(faded_g > bg_g); // ... to more than the background: still on screen
|
|
|
|
// **No solid trace over the image.** Move the series to y = +0.5 and draw *without* depositing:
|
|
// the plot draws no polyline while the density is on, so the +0.5 row is background and the only
|
|
// ink on screen is the sweep already in the image. A trace drawn over its own ghost -- the
|
|
// reported "persistent image + last acquisition" -- would put its colour at +0.5 here.
|
|
s.reset(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> moved = read_frame(ctx);
|
|
const plot::u32 moved_row = static_cast<plot::u32>(ty.to_screen_y(0.5));
|
|
PLOT_CHECK(moved_row != trace_row);
|
|
// At +0.5 there is only the grid: the frame with the density is *identical* to the frame without
|
|
// one there, so nothing was drawn at the moved trace's own row -- no stroke over the image. (The
|
|
// row the sweep is in still holds its ink, so the image itself did not go away.)
|
|
PLOT_CHECK(channel(moved, mid_x, moved_row, 1) == channel(without, mid_x, moved_row, 1));
|
|
PLOT_CHECK(channel(moved, mid_x, moved_row, 0) == channel(without, mid_x, moved_row, 0));
|
|
PLOT_CHECK(channel(moved, mid_x, trace_row, 1) > bg_g + 20); // the image is still there
|
|
s.reset(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
|
|
|
// **The steady state and a quarter of it, in the signal's colour.** Many acquisitions of a
|
|
// per-acquisition fade settle at `1/(1-f)` sweeps, which the one-sweep ceiling clamps to the
|
|
// trace's colour; a decay to a quarter of that ink is dimmer and keeps the hue.
|
|
plot::DensityConfig steady_cfg = cfg;
|
|
steady_cfg.decay = plot::DensityDecay::PerAcquisition;
|
|
steady_cfg.per_acquisition = 0.5f;
|
|
p.set_density(steady_cfg);
|
|
for (int i = 0; i < 24; ++i) p.accumulate_density();
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> steady = read_frame(ctx);
|
|
const int steady_g = channel(steady, mid_x, trace_row, 1);
|
|
const int steady_r = channel(steady, mid_x, trace_row, 0);
|
|
PLOT_CHECK(steady_g > bg_g + 60);
|
|
PLOT_CHECK(steady_r < 40);
|
|
// A quarter of the ink: `dt = tau*ln(4)` is one step of the time fade to a quarter.
|
|
plot::DensityConfig quarter_cfg = steady_cfg;
|
|
quarter_cfg.decay = plot::DensityDecay::TimeConstant;
|
|
quarter_cfg.time_constant_s = 1.0f;
|
|
p.set_density(quarter_cfg);
|
|
const double steady_ink = grid.ink();
|
|
p.decay_density(std::log(4.0));
|
|
PLOT_CHECK_NEAR(grid.ink() / steady_ink, 0.25, 1e-6);
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> quarter = read_frame(ctx);
|
|
const int quarter_g = channel(quarter, mid_x, trace_row, 1);
|
|
const int quarter_r = channel(quarter, mid_x, trace_row, 0);
|
|
PLOT_CHECK(steady_g > quarter_g + 20);
|
|
PLOT_CHECK(quarter_g > bg_g + 10);
|
|
PLOT_CHECK(quarter_r < 40); // still the signal's colour, not a neutral wash
|
|
|
|
// A frame with nothing new to deposit: the grid did not move, so the conversion and the upload
|
|
// are skipped -- the frame costs the one draw call -- and the picture is bit-identical.
|
|
r.begin_frame(kW, kH, kFrameRect);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
PLOT_CHECK(r.stats().density_cells > 0u);
|
|
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
|
|
const std::vector<plot::u8> again = read_frame(ctx);
|
|
PLOT_CHECK(again == quarter);
|
|
|
|
std::printf("[density gl] cells=%u uploaded=%u differing=%u trace row=%u blank row=%u"
|
|
" axes=%.0f,%.0f %.0fx%.0f\n",
|
|
first.density_cells, first.density_upload_bytes, differing, trace_row, blank_row,
|
|
axes.x, axes.y, axes.w, axes.h);
|
|
std::printf("[density gl] green: bg=%d full=%d faded=%d steady=%d quarter=%d"
|
|
" (fresh ink %.0f -> %.0f)\n",
|
|
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// A grid **past `kDensityCellCap`** is the axes rect scaled down, and the frame magnifies the image
|
|
/// back over that rect: this is the screen-side half of the bound --
|
|
/// `a_density_grid_is_capped_whatever_the_window` reads the counts of a capped grid, and the two
|
|
/// agree only if the deposit and the draw use the same footprint, the axes rect in *whole* device
|
|
/// pixels.
|
|
///
|
|
/// What is read here is what a wrong scale moves: the image covers the rect's own columns rather
|
|
/// than the smaller grid's (the assertion a quad drawn at the grid's size fails), it stays inside
|
|
/// the rect, and the sweep is on the row its own transform maps y = 0 to, in the trace's colour.
|
|
PLOT_TEST(a_capped_density_is_magnified_over_the_axes_rect) {
|
|
const plot::u32 w = 900, h = 720;
|
|
plottest::HeadlessContext ctx(w, h);
|
|
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(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
|
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, 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 colour = plot::Color::hex(0x00E070);
|
|
PLOT_CHECK(p.add_trace({&s, colour, 1.5f, ax, ay, "flat"}) == 0u);
|
|
p.set_x_range(ax, {0.0, 1.0});
|
|
p.set_y_range(ay, {-1.0, 1.0});
|
|
|
|
const plot::Rect frame{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
|
|
r.begin_frame(w, h, frame);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const std::vector<plot::u8> without = read_frame(ctx);
|
|
|
|
plot::DensityConfig cfg;
|
|
cfg.enabled = true;
|
|
p.set_density(cfg);
|
|
p.accumulate_density();
|
|
const plot::DensityGrid& grid = p.density_grid(0u);
|
|
const plot::Rect axes = p.layout().axes;
|
|
// The rect is past the cap, so the grid is smaller than it and the frame has to scale the image
|
|
// up -- and the one flat sweep still lights every column, once.
|
|
PLOT_CHECK(grid.cell_count() > 0u);
|
|
PLOT_CHECK(grid.cell_count() <= plot::kDensityCellCap);
|
|
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) < static_cast<plot::u64>(std::ceil(axes.w)));
|
|
PLOT_CHECK(static_cast<plot::u64>(grid.rows()) < static_cast<plot::u64>(std::ceil(axes.h)));
|
|
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
|
|
|
|
r.begin_frame(w, h, frame);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
const plot::FrameStats stats = r.stats();
|
|
PLOT_CHECK(stats.density_cells == static_cast<plot::u32>(grid.cell_count()));
|
|
PLOT_CHECK(stats.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
|
|
const std::vector<plot::u8> capped = read_frame(ctx);
|
|
|
|
const auto differs = [&](plot::u32 x, plot::u32 y) {
|
|
const plot::usize k = (plot::usize(y) * w + x) * 4u;
|
|
return capped[k] != without[k] || capped[k + 1u] != without[k + 1u] ||
|
|
capped[k + 2u] != without[k + 2u];
|
|
};
|
|
const auto at = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
|
|
return static_cast<int>(f[(plot::usize(y) * w + x) * 4u + static_cast<plot::usize>(c)]);
|
|
};
|
|
|
|
// **The image covers the rect's columns, not the grid's.** A quad drawn at the grid's own size --
|
|
// the temptation once a cell is no longer a pixel -- would paint the left `columns` of the rect
|
|
// and leave the rest of it blank, which this counts.
|
|
plot::u32 changed_cols = 0;
|
|
plot::u32 lo_x = w, hi_x = 0, lo_y = h, hi_y = 0;
|
|
for (plot::u32 x = 0; x < w; ++x) {
|
|
bool any = false;
|
|
for (plot::u32 y = 0; y < h; ++y) {
|
|
if (!differs(x, y)) continue;
|
|
any = true;
|
|
lo_x = std::min(lo_x, x);
|
|
hi_x = std::max(hi_x, x);
|
|
lo_y = std::min(lo_y, y);
|
|
hi_y = std::max(hi_y, y);
|
|
}
|
|
if (any) ++changed_cols;
|
|
}
|
|
PLOT_CHECK(changed_cols > 0u);
|
|
PLOT_CHECK(changed_cols + 2u >= static_cast<plot::u32>(axes.w));
|
|
// ... and it stays inside the rect it is an image of.
|
|
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
|
|
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
|
|
|
|
// The sweep is on the row its own transform maps y = 0 to, in the trace's own colour: the cell's
|
|
// one sweep is spread over the block of device pixels it was magnified into, so what is asserted
|
|
// is that the ink is *there* -- the brightest row of the rect is that row -- and that its hue is
|
|
// the trace's rather than the background's.
|
|
const plot::u32 mid_x = static_cast<plot::u32>(p.x_transform(ax).to_screen_x(0.5));
|
|
const plot::u32 trace_row = static_cast<plot::u32>(p.y_transform(ay).to_screen_y(0.0));
|
|
// The background to read the ink against is a row of the *without* frame away from the sweep's
|
|
// own: the trace itself is drawn there (the density is off in that frame), so the trace's own row
|
|
// is the trace's colour and not a background at all.
|
|
const plot::u32 blank_row = trace_row + 40u < h ? trace_row + 40u : trace_row - 40u;
|
|
const int bg_g = at(without, mid_x, blank_row, 1);
|
|
int band_g = 0;
|
|
int band_y = 0;
|
|
for (plot::u32 y = static_cast<plot::u32>(axes.y); y < static_cast<plot::u32>(axes.bottom()); ++y) {
|
|
const int g = at(capped, mid_x, y, 1);
|
|
if (g > band_g) {
|
|
band_g = g;
|
|
band_y = static_cast<int>(y);
|
|
}
|
|
}
|
|
PLOT_CHECK(band_g > bg_g + 40); // the sweep is on screen at all
|
|
PLOT_CHECK(at(capped, mid_x, trace_row, 0) < 40); // ... in the trace's own hue ...
|
|
PLOT_CHECK(at(capped, mid_x, trace_row, 1) > bg_g + 40);
|
|
PLOT_CHECK(std::abs(band_y - static_cast<int>(trace_row)) <= 2);
|
|
|
|
std::printf("[density gl] capped grid=%ux%u cells=%zu of a %.0fx%.0f axes: changed cols=%u"
|
|
" brightest row=%d (transform says %u), green=%d against bg=%d\n",
|
|
grid.columns(), grid.rows(), grid.cell_count(), axes.w, axes.h, changed_cols, band_y,
|
|
trace_row, band_g, bg_g);
|
|
ctx.destroy();
|
|
}
|