Files
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

253 lines
9.7 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/api.hpp>
#include <plot/math/vec2.hpp>
namespace plot::gl {
/// Move-only ownership of one GL object name (`GLuint`).
///
/// `Derived` supplies `static void destroy(GLuint) noexcept`. Copying is
/// deleted and moving transfers the name, leaving the source empty.
/// Destruction and `reset()` are no-ops when `id() == 0`, so an empty handle is
/// safe to destroy at any point — including after its context is gone. A
/// non-empty handle must not outlive the context that owns the name; call
/// `reset()` first when the context is about to be destroyed.
template <class Derived>
class Handle {
public:
Handle() noexcept = default;
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
~Handle() { reset(); }
/// GL object name, or 0 when the handle is empty.
GLuint id() const noexcept { return id_; }
explicit operator bool() const noexcept { return id_ != 0; }
/// Identity of the object this handle names, as a counter that only ever
/// advances: every `reset()` that deleted a name, every allocating `create()`
/// and every move advances it. A driver is free to hand out a name it has
/// already used, so state that captured the old object -- the attribute
/// pointers of a vertex array above all -- must compare this counter rather
/// than the name (see `render::detail::InstanceStore::epoch()`).
///
/// 64-bit on purpose: `epoch()` sums this with a slot counter, and a long-lived
/// streaming frame loop must not be able to wrap a u32 sum into a stale value.
u64 generation() const noexcept { return generation_; }
/// Deletes the GL object, if any, and empties the handle.
void reset() noexcept {
if (id_ != 0) {
Derived::destroy(id_);
id_ = 0;
++generation_;
}
}
protected:
Handle(Handle&& other) noexcept : id_(other.id_), generation_(other.generation_) {
other.id_ = 0;
++generation_; // the object is this handle's now
++other.generation_; // and no longer the other's
}
Handle& operator=(Handle&& other) noexcept {
if (this != &other) {
reset(); // advances the generation when it deleted a name
id_ = other.id_;
other.id_ = 0;
++generation_;
++other.generation_;
}
return *this;
}
/// Advances the generation for a name the derived class has just allocated and
/// stored: every allocating `create()` calls it, so the counter is a complete
/// record of the handle's identity. A failed allocation needs no call -- it has
/// already gone through `reset()`, which advanced it.
void note_allocated() noexcept { ++generation_; }
GLuint id_ = 0;
private:
u64 generation_ = 0;
};
/// Vertex buffer object. All fallible operations report through `Status`;
/// the void ones assume that `plot::gl::load()` ran and a context is current.
class Buffer final : public Handle<Buffer> {
public:
/// Allocates a buffer name. Fails with `Unsupported` when the entry points
/// are not resolved yet and `GLFailure` when the driver returns name 0.
bool create(Status* st = nullptr) noexcept;
void bind(GLenum target = GL_ARRAY_BUFFER) const noexcept;
/// Replaces the whole store with `bytes` bytes of `data` (`data` may be null
/// to allocate without initialising).
void upload(const void* data, usize bytes, GLenum usage) const noexcept;
/// Overwrites `bytes` bytes starting at `offset`; the store must already be
/// at least `offset + bytes` large.
void upload_sub(const void* data, usize bytes, usize offset) const noexcept;
/// Replaces the store with `bytes` bytes of immutable storage carrying `flags`
/// (`GL_MAP_WRITE_BIT` and friends). GL 4.4 / ARB_buffer_storage: only legal on
/// a context whose caps() report `buffer_storage`, and the entry point may be
/// null even on such a context (it is resolved best-effort, see api.hpp), so a
/// caller checks `glBufferStorage != nullptr` first. The contents start
/// undefined.
void storage(usize bytes, GLbitfield flags) const noexcept;
/// Maps `bytes` bytes from `offset` with `access`, returning the pointer or
/// null. GL 3.0 core, but a `GL_MAP_PERSISTENT_BIT` map needs the same
/// capability as `storage()` above.
void* map_range(usize offset, usize bytes, GLbitfield access) const noexcept;
/// Unmaps the store's data; the pointer from map_range() is invalid after this.
bool unmap() const noexcept;
/// Exposed for the CRTP base. Deleting an unknown name is a GL no-op.
static void destroy(GLuint id) noexcept;
};
/// Vertex array object holding the attribute layout of the bound buffer.
class VertexArray final : public Handle<VertexArray> {
public:
bool create(Status* st = nullptr) noexcept;
void bind() const noexcept;
/// Enables attribute `index` as `components` floats read from the currently
/// bound `GL_ARRAY_BUFFER`, advancing `stride` bytes per vertex and starting
/// `offset` bytes into it. Requires this vertex array to be bound.
void attrib(u32 index, i32 components, usize stride, usize offset) const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// Linked shader program. Constructed only through `from_source()`.
class Program final : public Handle<Program> {
public:
/// Compiles and links a vertex/fragment source pair. On failure the returned
/// program is empty and `st` (when non-null) carries `GLFailure` plus the
/// driver's info log, or `Unsupported` when `load()` was never called.
static Program from_source(const char* vertex_src, const char* fragment_src,
Status* st = nullptr) noexcept;
void use() const noexcept;
/// Sets a float uniform; a name that is not an active uniform of this program
/// is a silent no-op.
void set_uniform(const char* name, f32 value) const noexcept;
/// Sets a two-component float uniform.
void set_uniform(const char* name, Vec2 value) const noexcept;
/// Sets a four-component float uniform, e.g. the colour of a pipeline.
void set_uniform(const char* name, Color value) const noexcept;
/// Sets an integer uniform, e.g. a sampler's texture unit. Deliberately not
/// an overload of the float setter: an untyped literal such as `0` must not
/// turn a float uniform into a `glUniform1i` call.
void set_uniform_int(const char* name, i32 value) const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// 2D texture. Creation only allocates storage; `upload_*` replaces the whole
/// image (`glTexImage2D`), which is what the plot pipelines need for atlases
/// and one-shot uploads.
class Texture final : public Handle<Texture> {
public:
/// Allocates `width` x `height` 8-bit RGBA storage (`GL_RGBA8`), linear
/// filtering, clamped edges.
bool create_rgba(u32 width, u32 height, Status* st = nullptr) noexcept;
/// Allocates `width` x `height` single-channel 8-bit storage (`GL_R8`) for
/// coverage masks and narrow series data.
bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept;
/// Replaces the whole image with `width * height * 4` bytes of RGBA data.
/// Sets `GL_UNPACK_ALIGNMENT` to 4 for the upload and restores the caller's
/// value afterwards, so a host that shares the context never inherits this
/// call's pixel-store state.
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
/// Replaces the whole image with `width * height` bytes of single-channel
/// data (row padding is disabled, so any width is valid). It sets
/// `GL_UNPACK_ALIGNMENT` to 1 to do so and leaves it there -- a caller that
/// shares the context with other uploaders saves and restores it, as
/// `render::TextAtlas` does around every glyph-atlas upload, whenever in a
/// frame that upload happens to run.
void upload_r8(u32 width, u32 height, const void* pixels) const noexcept;
/// Binds to `unit` (e.g. `GL_TEXTURE0`).
void bind(GLenum unit = GL_TEXTURE0) const noexcept;
void set_filter(GLenum min, GLenum mag) const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// Framebuffer object, the render target of the off-screen paths.
class Framebuffer final : public Handle<Framebuffer> {
public:
bool create(Status* st = nullptr) noexcept;
void bind(GLenum target = GL_FRAMEBUFFER) const noexcept;
/// Attaches `texture`'s level 0 to `GL_COLOR_ATTACHMENT0`.
void attach_color(const Texture& texture) const noexcept;
/// True when the attachment set is `GL_FRAMEBUFFER_COMPLETE`.
bool complete() const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// Move-only GL sync object (`GLsync`, GL 3.2) used to guard reuse of buffers
/// that the GPU may still be reading.
class Fence {
public:
Fence() noexcept = default;
Fence(const Fence&) = delete;
Fence& operator=(const Fence&) = delete;
Fence(Fence&& other) noexcept : id_(other.id_) { other.id_ = nullptr; }
Fence& operator=(Fence&& other) noexcept {
if (this != &other) {
reset();
id_ = other.id_;
other.id_ = nullptr;
}
return *this;
}
/// Deleting no fence is a no-op, so an empty fence is always safe.
~Fence() { reset(); }
/// Inserts a fence in the GL command stream.
bool create(Status* st = nullptr) noexcept;
/// Waits up to `timeout_ns` nanoseconds for the fence to be signalled,
/// flushing the command stream first. True when it signalled in time.
bool wait(u64 timeout_ns) const noexcept;
GLsync id() const noexcept { return id_; }
explicit operator bool() const noexcept { return id_ != nullptr; }
void reset() noexcept;
private:
GLsync id_ = nullptr;
};
} // namespace plot::gl