Files
plotter/src/render/primitives.cpp
T
Martino Ferrari 38ef459897
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +02:00

159 lines
5.7 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/primitives.hpp>
#include <cassert>
#include <cmath>
namespace plot::render {
namespace {
/// Two declared cells agree when neither size differs by more than the atlas'
/// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell.
bool same_cell(Vec2 a, Vec2 b) noexcept {
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
return near(a.x, b.x) && near(a.y, b.y);
}
/// `axis` as a unit vector: the cell's local +x, which is what the instance and
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
/// means "unrotated" -- a caller's arithmetic slip must not turn a label into NaN
/// geometry, and a basis the shader trusts to be unit must be one.
Vec2 unit_axis(Vec2 axis) noexcept {
const f32 len = axis.length();
if (std::isfinite(len) && len > 0.0f) {
return axis / len;
}
return Vec2{1.0f, 0.0f};
}
} // namespace
// ----------------------------------------------------------------- LineBatch
void LineBatch::begin() noexcept { v_.clear(); }
void LineBatch::clear() noexcept { v_.clear(); }
void LineBatch::add_segment(Vec2 a, Vec2 b) {
v_.push_back(a.x);
v_.push_back(a.y);
v_.push_back(b.x);
v_.push_back(b.y);
}
void LineBatch::add_polyline(const Vec2* pts, u32 n) {
if (pts == nullptr || n < 2u) {
return; // no pair of points, no segment
}
// Grow once for the whole polyline rather than per segment, so a frame that
// stages the same workload as the last one never reallocates.
v_.reserve(v_.size() + 4u * (n - 1u));
for (u32 i = 1; i < n; ++i) {
add_segment(pts[i - 1u], pts[i]);
}
}
void LineBatch::add_polyline_xy(const f32* xy, u32 n) {
if (xy == nullptr || n < 2u) {
return;
}
v_.reserve(v_.size() + 4u * (n - 1u));
for (u32 i = 1; i < n; ++i) {
v_.push_back(xy[2u * (i - 1u)]);
v_.push_back(xy[2u * (i - 1u) + 1u]);
v_.push_back(xy[2u * i]);
v_.push_back(xy[2u * i + 1u]);
}
}
// ----------------------------------------------------------------- TextBatch
void TextBatch::begin() noexcept {
v_.clear();
cell_uv_ = Vec2{};
cell_declared_ = false;
}
void TextBatch::clear() noexcept {
v_.clear();
cell_uv_ = Vec2{};
cell_declared_ = false;
}
void TextBatch::set_cell(Vec2 cell_uv) noexcept {
// One cell per draw (see TextBackend::cell_uv): the draw carries a single
// `u_glyph_uv`, so a second, different cell inside one batch would hand it to
// glyphs staged for another size. A debug build says so; a release build takes
// the last one, as it takes the rest of its state.
assert((!cell_declared_ || same_cell(cell_uv_, cell_uv)) &&
"plot::render::TextBatch: a second, different atlas cell in one batch");
cell_uv_ = cell_uv;
cell_declared_ = true;
}
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color) {
if (!cell_declared_) {
// Nobody declared a cell: a caller staging raw glyphs, where the glyph's own
// rect is the only authority there is. `add_text` and the renderer both
// declare one from the backend, so the draw never depends on this.
cell_uv_ = Vec2{glyph.u1 - glyph.u0, glyph.v1 - glyph.v0};
cell_declared_ = true;
}
// A fixed-cell grid (see TextBackend::cell_uv): an instance carries the rect's
// *origin*, so a glyph whose rect is not this cell would be drawn at the wrong
// size -- and read past its own row of the atlas doing it -- with nothing
// downstream able to tell. Loud in a debug build; a release build draws on with
// the cell it was told, which is what it did before this check existed.
assert(glyph_is_cell(glyph, cell_uv_) &&
"plot::render::TextBatch: staged glyph's rect is not the atlas cell");
// The cell's own axes: the run direction and its perpendicular, which is the
// cell's +v axis -- the one kTextVS builds the quad from. The bearing is in
// font pixels, so it scales with the run and follows the same axes: a glyph
// keeps its place inside a rotated run instead of drifting along screen +y.
const Vec2 uy{-unit_ux.y, unit_ux.x};
const Vec2 corner = pen_px + unit_ux * (glyph.xoff * px) + uy * (glyph.yoff * px);
v_.push_back(corner.x);
v_.push_back(corner.y);
v_.push_back(glyph.u0);
v_.push_back(glyph.v0);
v_.push_back(unit_ux.x);
v_.push_back(unit_ux.y);
v_.push_back(color.r);
v_.push_back(color.g);
v_.push_back(color.b);
v_.push_back(color.a);
}
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis, Color color) {
add_cell(pen_px, glyph, px, unit_axis(axis), color);
}
Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
Vec2 axis, Color color) {
if (utf8 == nullptr) {
return pen_px;
}
// The cell is the backend's, declared once for the whole run: the staged
// instances carry origins only, and the draw carries this size (see
// TextBackend::cell_uv).
set_cell(font.cell_uv());
const Vec2 ux = unit_axis(axis);
for (const char* p = utf8; *p != '\0';) {
// The backend's own codepoints and the backend's own advances -- the same
// numbers `measure()` sums -- scaled by `px` and measured along the basis,
// so a run staged here is exactly as long as the plot layer measured it and
// ends where the space reserved for the label ends.
const Glyph* glyph = font.glyph(next_codepoint(p));
if (glyph == nullptr) {
continue; // no artwork for this codepoint: nothing to draw, nothing to advance
}
add_cell(pen_px, *glyph, px, ux, color);
pen_px = pen_px + ux * (glyph->advance * px);
}
return pen_px;
}
} // namespace plot::render