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.
159 lines
5.7 KiB
C++
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
|