commit e4051bc6b56fb8284dd4b8631d02a897046acc26 Author: Martino Ferrari Date: Sun Sep 27 20:50:40 2026 +0200 docs: design spec and implementation plan for the plot library diff --git a/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md b/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md new file mode 100644 index 0000000..aebd0d6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md @@ -0,0 +1,1447 @@ +# `plot` library scaffold — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a zero-dependency-core C++20 OpenGL plotting library that renders real-time, high-sample-rate time series into a caller-owned framebuffer, with GLFW, Dear ImGui, Qt6 and GTK4 adapters. + +**Architecture:** Strict downward layering (`core/math → gl → data → render → plot → input`) with adapters on top. Vertex data stays in data space and pan/zoom is a shader uniform, so nothing is re-uploaded on interaction. An incremental min/max LOD pyramid gives O(pixels) rasterisation and O(log n) measurement queries on the same index. + +**Tech Stack:** C++20, CMake ≥3.24, Ninja, OpenGL 3.3 core (runtime 4.3+ fast path), EGL for headless tests, GLFW/ImGui/Qt6/GTK4 only for adapters. + +**Spec:** `docs/superpowers/specs/2026-09-27-plot-library-design.md` (read it before starting any task). + +## Global Constraints + +- Core target `plot_core` MUST NOT link or include any third-party library: no GLM, no glad, no GLEW, no `GL/glcorearb.h`, no `third_party/` directory. +- Public headers live under `include/plot/` and MUST be self-contained (each compiles standalone). +- OpenGL baseline is **3.3 core**. Anything above 3.3 MUST be behind a runtime capability check in `plot::gl::caps()`. +- Every GL vertex/instance buffer holds **data-space** coordinates. Pan/zoom MUST NOT re-upload vertex data. +- No exceptions cross the public API boundary; fallible operations return `bool`/`Status`. +- C++20. Warnings: `-Wall -Wextra -Wpedantic -Wconversion -Wshadow` on plot targets; warnings MUST be clean, never suppressed per-file. +- Namespace `plot`, sub-namespaces `plot::gl`, `plot::data`, `plot::input`, `plot::math`. +- Commit after every task with the message given in the task. Do not run project-wide test suites inside a task; run only the commands listed. +- `tests/` must pass with `ctest --output-on-failure` from the build directory at the end of every task from Task 1 onward. + +--- + +## File Structure + +``` +plotter/ +├── CMakeLists.txt plot_core, options, install/export +├── CMakePresets.json linux-debug, linux-release, linux-asan +├── .clang-format +├── cmake/plot_options.cmake version floor, warnings, option defaults +├── include/plot/ +│ ├── plot.hpp umbrella include +│ ├── core/{types,color,rect,span,status}.hpp +│ ├── math/{vec2,mat3,transform}.hpp +│ ├── gl/{api,loader,handle,caps}.hpp +│ ├── render/{style,text,bitmap_font,program,buffer,vertex_array,primitives,renderer}.hpp +│ ├── data/{ring_buffer,minmax_pyramid,series,array_series,stream_series}.hpp +│ ├── plot/{ticks,axis,grid,trace,cursor,measure,annotation,plot}.hpp +│ └── input/{events,controller}.hpp +├── src/ one .cpp per non-inline component +├── adapters/{glfw,imgui,qt6,gtk4}/ each: CMakeLists.txt + header + impl +├── examples/{glfw_demo,imgui_demo,qt_demo,gtk_demo}.cpp +└── tests/ harness.hpp, egl_headless.{hpp,cpp}, test_*.cpp +``` + +--- + +### Task 1: Repository skeleton, core types, math + +**Files:** +- Create: `CMakeLists.txt`, `CMakePresets.json`, `.clang-format`, `cmake/plot_options.cmake`, `.gitignore` +- Create: `include/plot/core/{types,color,rect,status,span}.hpp` +- Create: `include/plot/math/{vec2,mat3,transform}.hpp` +- Create: `src/math/transform.cpp`, `src/core/status.cpp` +- Create: `include/plot/plot.hpp` +- Create: `tests/CMakeLists.txt`, `tests/harness.hpp`, `tests/test_transform.cpp` + +**Interfaces:** +- Consumes: nothing. +- Produces: `plot::f32/f64/u32/u64/kInvalid`, `plot::Range`, `plot::Rect`, `plot::Color`, `plot::Status`, `plot::ErrorCode`, `plot::Transform` with `to_screen_x/to_data_x/to_screen_y/to_data_y`, and the `PLOT_CHECK(cond)` test macro, `PLOT_TEST(name)` test registration. + +- [ ] **Step 1: Write the failing test** + +`tests/harness.hpp` — a zero-dependency CTest harness (no gtest): + +```cpp +#pragma once +#include +#include +#include +#include +#include +#include +#include + +using plot::f32; +using plot::f64; +using plot::u32; +using plot::u64; + +namespace plottest { +struct Case { const char* name; std::function fn; }; +inline std::vector& cases() { static std::vector c; return c; } +struct Reg { Reg(const char* n, std::function f) { cases().push_back({n, std::move(f)}); } }; +struct Failure { std::string msg; }; +inline int run_all() { + int failed = 0; + for (auto& c : cases()) { + try { c.fn(); std::printf("[ ok ] %s\n", c.name); } + catch (const Failure& f) { std::printf("[FAIL] %s: %s\n", c.name, f.msg.c_str()); ++failed; } + } + std::printf("%zu cases, %d failed\n", cases().size(), failed); + return failed == 0 ? 0 : 1; +} +} // namespace plottest + +#define PLOT_TEST(name) \ + static void name(); \ + static ::plottest::Reg reg_##name(#name, name); \ + static void name() + +#define PLOT_CHECK(cond) \ + do { if (!(cond)) throw ::plottest::Failure{std::string(__FILE__) + ":" + \ + std::to_string(__LINE__) + " CHECK(" #cond ")"}; } while (0) + +#define PLOT_CHECK_NEAR(a, b, eps) \ + do { const double _a = (a), _b = (b); \ + if (!(std::fabs(_a - _b) <= (eps))) throw ::plottest::Failure{ \ + std::string(__FILE__) + ":" + std::to_string(__LINE__) + \ + " CHECK_NEAR(" #a ", " #b ") got " + std::to_string(_a) + \ + " vs " + std::to_string(_b)}; } while (0) +``` + +`tests/main.cpp`: + +```cpp +#include "harness.hpp" +int main() { return plottest::run_all(); } +``` + +`tests/test_transform.cpp`: + +```cpp +#include "harness.hpp" +#include + +using plot::Transform; + +PLOT_TEST(linear_round_trip) { + Transform t{2.0, 10.0, 3.0, -1.0, false, false}; + PLOT_CHECK_NEAR(t.to_screen_x(0.0), 10.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_x(5.0), 20.0, 1e-12); + PLOT_CHECK_NEAR(t.to_data_x(20.0), 5.0, 1e-12); + PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(7.5)), 7.5, 1e-12); +} + +PLOT_TEST(log_x_round_trip) { + Transform t{100.0, 0.0, 1.0, 0.0, true, false}; + PLOT_CHECK_NEAR(t.to_screen_x(1.0), 0.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_x(10.0), 100.0, 1e-12); + PLOT_CHECK_NEAR(t.to_data_x(100.0), 10.0, 1e-9); +} + +PLOT_TEST(zoom_anchor_is_invariant) { + // Anchoring a zoom at a data coordinate must leave that coordinate's screen + // position unchanged. + Transform t{1.0, 0.0, 1.0, 0.0, false, false}; + const double anchor_data = 3.25; + const double before = t.to_screen_x(anchor_data); + t.sx *= 4.0; + t.tx = before - anchor_data * t.sx; + PLOT_CHECK_NEAR(t.to_screen_x(anchor_data), before, 1e-12); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --preset linux-debug && cmake --build build/debug --target plot_tests` +Expected: FAIL — `plot/math/transform.hpp` does not exist. + +- [ ] **Step 3: Write the implementation** + +`include/plot/core/types.hpp`: + +```cpp +#pragma once +#include +#include + +namespace plot { +using f32 = float; +using f64 = double; +using i32 = std::int32_t; +using u32 = std::uint32_t; +using u64 = std::uint64_t; +using usize = std::size_t; +constexpr u64 kInvalid = ~u64{0}; + +struct Range { + f64 min = 0.0; + f64 max = 1.0; + constexpr f64 span() const noexcept { return max - min; } + constexpr f64 clamp(f64 v) const noexcept { return v < min ? min : (v > max ? max : v); } +}; +} // namespace plot +``` + +`include/plot/math/vec2.hpp`: + +```cpp +#pragma once +#include +namespace plot { +struct Vec2 { + f32 x = 0.0f, y = 0.0f; +}; +} // namespace plot +``` +(Implement `Vec2{x,y}` with `+ - * /`, `dot`, `length`.) + +`include/plot/math/transform.hpp`: + +```cpp +#pragma once +#include +#include +namespace plot { +/// Affine data→screen mapping: screen = data * s + t, applied after log10 when enabled. +struct Transform { + f64 sx = 1.0, tx = 0.0; + f64 sy = 1.0, ty = 0.0; + bool log_x = false, log_y = false; + + f64 to_screen_x(f64 x) const noexcept { + const f64 v = log_x ? log10_positive(x) : x; + return v * sx + tx; + } + f64 to_screen_y(f64 y) const noexcept { + const f64 v = log_y ? log10_positive(y) : y; + return v * sy + ty; + } + f64 to_data_x(f64 sx_screen) const noexcept { + const f64 v = (sx_screen - tx) / sx; + return log_x ? std::pow(10.0, v) : v; + } + f64 to_data_y(f64 sy_screen) const noexcept { + const f64 v = (sy_screen - ty) / sy; + return log_y ? std::pow(10.0, v) : v; + } + /// Set scale/offset so that [lo,hi] data maps onto [pix_lo,pix_hi] screen. + static Transform from_range(Range x, Range y, f64 px0, f64 px1, f64 py0, f64 py1, bool lx, bool ly); + static f64 log10_positive(f64 v) noexcept; // clamps to >= 1e-300 before log10 +}; +} // namespace plot +``` + +Implement `from_range` and `log10_positive` in `src/math/transform.cpp`. `from_range` for a log axis maps `log10(lo)→log10(hi)`, and for a degenerate span (min == max) expands by ±0.5 so the transform is always invertible. + +`include/plot/core/{color,rect,status,span}.hpp` per spec §6: `Color{r,g,b,a}` with `static Color hex(u32 rgb, f32 a = 1.0f)`; `Rect{x,y,w,h}` with `contains`, `right()`, `bottom()`; `ErrorCode` + `Status` with `explicit operator bool() const { return code == ErrorCode::Ok; }`; `span.hpp` re-exports `std::span` as `plot::Span`. + +`tests/CMakeLists.txt`: + +```cmake +add_executable(plot_tests main.cpp test_transform.cpp) +target_link_libraries(plot_tests PRIVATE plot::core) +add_test(NAME transform COMMAND plot_tests) +``` + +Top-level `CMakeLists.txt`: `cmake_minimum_required(VERSION 3.24)`, `project(plot VERSION 0.1.0 LANGUAGES CXX)`, include `cmake/plot_options.cmake`, declare the six `PLOT_BUILD_*` options from the plan header, build `plot_core` from `src/**/*.cpp` (explicit list), `add_library(plot::core ALIAS plot_core)`, `target_include_directories(plot_core PUBLIC include)`, `target_compile_features(plot_core PUBLIC cxx_std_20)`, `target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})`, then `add_subdirectory(tests)` when `PLOT_BUILD_TESTS`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` +Expected: `linear_round_trip`, `log_x_round_trip`, `zoom_anchor_is_invariant` all `[ ok ]`, `3 cases, 0 failed`, ctest reports 100% tests passed. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: project skeleton, core types and math with transform tests" +``` + +--- + +### Task 2: GL loader, RAII handles, capability probe, headless EGL harness + +**Files:** +- Create: `include/plot/gl/{api,loader,handle,caps}.hpp`, `src/gl/loader.cpp`, `src/gl/handle.cpp` +- Create: `tests/egl_headless.hpp`, `tests/egl_headless.cpp`, `tests/test_gl_context.cpp` +- Modify: `CMakeLists.txt` (add `src/gl/*.cpp`, EGL dependency for tests), `tests/CMakeLists.txt` + +**Interfaces:** +- Consumes: `plot::Status`, `plot::ErrorCode`. +- Produces: `plot::gl::load(ProcLoader)`, `plot::gl::caps()`, `plot::gl::Buffer/VertexArray/Program/Texture/Framebuffer/Fence`, and test helper `plottest::HeadlessContext` with `bool create(Status*)`, `void destroy()`, `u32 width/height`, `bool read_pixel(u32 x, u32 y, u8 rgba[4])`. + +- [ ] **Step 1: Write the failing test** + +`tests/test_gl_context.cpp`: + +```cpp +#include "harness.hpp" +#include "egl_headless.hpp" +#include +#include + +PLOT_TEST(headless_context_provides_gl33) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); // fails loudly if surfaceless EGL is unavailable + PLOT_CHECK(plot::gl::load(nullptr)); + const plot::gl::Caps& c = plot::gl::caps(); + PLOT_CHECK(c.major > 3 || (c.major == 3 && c.minor >= 3)); + PLOT_CHECK(c.renderer != nullptr && c.renderer[0] != '\0'); + ctx.destroy(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build/debug --target plot_tests && ./build/debug/tests/plot_tests` +Expected: FAIL to compile — `egl_headless.hpp` not found. + +- [ ] **Step 3: Write the implementation** + +`include/plot/gl/api.hpp` — own typedefs and the function table, no Khronos headers: + +```cpp +#pragma once +#include + +namespace plot::gl { + +using GLenum = unsigned int; using GLuint = unsigned int; +using GLint = int; using GLsizei = int; +using GLboolean = unsigned char; +using GLbitfield = unsigned int; using GLintptr = std::intptr_t; +using GLsizeiptr = std::ptrdiff_t; using GLchar = char; + +// Enums actually used. Values are fixed by the OpenGL specification. +enum : GLenum { + GL_NO_ERROR = 0, GL_TRIANGLES = 0x0004, GL_UNSIGNED_BYTE = 0x1401, + GL_FLOAT = 0x1406, GL_TRIANGLE_STRIP = 0x0005, GL_LINE_STRIP = 0x0003, + GL_ARRAY_BUFFER = 0x8892, GL_STATIC_DRAW = 0x88E4, GL_DYNAMIC_DRAW = 0x88E8, + GL_STREAM_DRAW = 0x88E0, GL_FRAGMENT_SHADER = 0x8B30, GL_VERTEX_SHADER = 0x8B31, + GL_COMPILE_STATUS = 0x8B81, GL_LINK_STATUS = 0x8B82, GL_RGBA8 = 0x8058, + GL_RGBA = 0x1908, GL_TEXTURE_2D = 0x0DE1, GL_FRAMEBUFFER = 0x8D40, + GL_COLOR_ATTACHMENT0 = 0x8CE0, GL_FRAMEBUFFER_COMPLETE = 0x8CD5, + GL_BLEND = 0x0BE2, GL_SRC_ALPHA = 0x0302, GL_ONE_MINUS_SRC_ALPHA = 0x0303, + GL_TEXTURE0 = 0x84C0, GL_LINEAR = 0x2601, GL_CLAMP_TO_EDGE = 0x812F, + GL_MAP_WRITE_BIT = 0x0002, GL_MAP_PERSISTENT_BIT = 0x0040, + GL_MAP_COHERENT_BIT = 0x0080, GL_SYNC_GPU_COMMANDS_COMPLETE = 0x9117, + /* … remaining enums added as used … */ +}; + +// X-macro list: (return type, name, parameter list) +#define PLOT_GL_FUNCS(X) \ + X(const GLubyte*, glGetString, (GLenum)) \ + X(GLenum, glGetError, (void)) \ + X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \ + X(void, glClearColor, (float, float, float, float)) \ + X(void, glClear, (GLbitfield)) \ + X(void, glEnable, (GLenum)) \ + X(void, glBlendFunc, (GLenum, GLenum)) \ + X(GLuint, glCreateShader, (GLenum)) \ + X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*))\ + X(void, glCompileShader, (GLuint)) \ + X(void, glGetShaderiv, (GLuint, GLenum, GLint*)) \ + X(void, glGetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \ + X(GLuint, glCreateProgram, (void)) \ + X(void, glAttachShader, (GLuint, GLuint)) \ + X(void, glLinkProgram, (GLuint)) \ + X(void, glGetProgramiv, (GLuint, GLenum, GLint*)) \ + X(void, glGetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \ + X(void, glUseProgram, (GLuint)) \ + X(GLint, glGetUniformLocation, (GLuint, const GLchar*)) \ + X(void, glUniform1f, (GLint, float)) \ + X(void, glUniform2f, (GLint, float, float)) \ + X(void, glUniform1i, (GLint, GLint)) \ + X(void, glUniform2fv, (GLint, GLsizei, const float*)) \ + X(void, glUniform1fv, (GLint, GLsizei, const float*)) \ + X(void, glGenBuffers, (GLsizei, GLuint*)) \ + X(void, glDeleteBuffers, (GLsizei, const GLuint*)) \ + X(void, glBindBuffer, (GLenum, GLuint)) \ + X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \ + X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \ + X(void, glGenVertexArrays, (GLsizei, GLuint*)) \ + X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \ + X(void, glBindVertexArray, (GLuint)) \ + X(void, glEnableVertexAttribArray, (GLuint)) \ + X(void, glVertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*))\ + X(void, glDrawArrays, (GLenum, GLint, GLsizei)) \ + X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \ + X(void, glGenTextures, (GLsizei, GLuint*)) \ + X(void, glDeleteTextures, (GLsizei, const GLuint*)) \ + X(void, glBindTexture, (GLenum, GLuint)) \ + X(void, glTexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*))\ + X(void, glTexParameteri, (GLenum, GLenum, GLint)) \ + X(void, glGenFramebuffers, (GLsizei, GLuint*)) \ + X(void, glDeleteFramebuffers, (GLsizei, const GLuint*)) \ + X(void, glBindFramebuffer, (GLenum, GLuint)) \ + X(void, glFramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \ + X(GLenum, glCheckFramebufferStatus, (GLenum)) \ + X(void, glReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*))\ + X(void, glGenRenderbuffers, (GLsizei, GLuint*)) \ + X(void, glDeleteRenderbuffers, (GLsizei, const GLuint*)) \ + X(void, glBindRenderbuffer, (GLenum, GLuint)) \ + X(void, glRenderbufferStorage, (GLenum, GLenum, GLsizei, GLsizei)) \ + X(void, glFramebufferRenderbuffer, (GLenum, GLenum, GLenum, GLuint)) + +#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args; +PLOT_GL_FUNCS(PLOT_GL_DECL) +#undef PLOT_GL_DECL + +} // namespace plot::gl +``` + +`include/plot/gl/loader.hpp`: + +```cpp +#pragma once +#include +namespace plot::gl { +using ProcLoader = void* (*)(const char*); +/// Resolves every PLOT_GL_FUNCS entry. loader == nullptr selects the platform +/// default: glXGetProcAddressARB / eglGetProcAddress / wglGetProcAddress, then +/// dlsym / GetProcAddress on the system GL library. Returns false if any +/// required entry point is missing. +bool load(ProcLoader loader = nullptr); +} // namespace plot::gl +``` + +`src/gl/loader.cpp` resolves each name through the supplied loader, falling back to `dlsym(RTLD_DEFAULT, name)` and then `dlopen("libGL.so.1")` + `dlsym`. Report the first missing symbol via `PLOT_ASSERT` in debug and return `false`. + +`include/plot/gl/caps.hpp`: + +```cpp +#pragma once +#include +namespace plot::gl { +struct Caps { + int major = 0, minor = 0; + const char* renderer = ""; + const char* vendor = ""; + bool compute = false; // GL 4.3+ + bool buffer_storage = false; // GL 4.4+ / ARB_buffer_storage + bool persistent_map = false; // 4.4+ or ARB_buffer_storage + bool core_profile = false; +}; +const Caps& caps(); // valid after load(); queries the current context once +} // namespace plot::gl +``` + +`include/plot/gl/handle.hpp` + `src/gl/handle.cpp`: move-only RAII wrappers. `Buffer` with `void upload(const void*, usize bytes, GLenum usage)`, `void upload_sub(const void*, usize bytes, usize offset)`; `VertexArray` with `void attrib(u32 index, i32 components, usize stride, usize offset)` using a `GL_FLOAT` type; `Program` with `static Program from_source(const char* vs, const char* fs, Status*)` and `void set_uniform(const char* name, f32)` / `(const char*, Vec2)`; `Texture`, `Framebuffer` (`void attach_color(Texture&)`, `bool complete()`), `Fence`. All destructors are no-ops when `id() == 0`, so a `Renderer` destroyed after context loss is safe. + +`tests/egl_headless.cpp` — surfaceless EGL, no window: + +```cpp +// 1. eglGetPlatformDisplayEXT(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr) +// with fallback to eglGetDisplay(EGL_DEFAULT_DISPLAY). +// 2. eglInitialize, eglBindAPI(EGL_OPENGL_API). +// 3. choose a config with EGL_SURFACE_TYPE = EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE = EGL_OPENGL_BIT, +// EGL_RED/GREEN/BLUE/ALPHA_SIZE >= 8, EGL_DEPTH_SIZE = 0. +// 4. eglCreateContext(cfg, EGL_NO_CONTEXT, {EGL_CONTEXT_MAJOR_VERSION, 3, +// EGL_CONTEXT_MINOR_VERSION, 3, EGL_CONTEXT_OPENGL_PROFILE_MASK, +// EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR, EGL_NONE}). +// 5. eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) // requires EGL_KHR_surfaceless_context +// 6. create a color texture + framebuffer of (w,h), bind it. +// read_pixel() binds the FBO, glReadPixels(x, y, 1, 1, ...), returns true on success. +``` + +Link tests against `EGL` via `find_package(EGL REQUIRED)` or `pkg-config --libs egl`; if either is missing, `PLOT_BUILD_TESTS` MUST fail configuration with a clear message rather than silently skipping the GL tests. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` +Expected: 4 tests pass. `headless_context_provides_gl33` prints `[ ok ]`. If EGL surfaceless fails here, stop and switch `egl_headless.cpp` to the GLX hidden-window fallback before proceeding — Tasks 7–11 depend on this. + +Quick manual confirmation of the GPU path: + +```bash +./build/debug/tests/plot_tests # includes gl_context +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(gl): dependency-free GL loader, RAII handles, caps probe, headless EGL harness" +``` + +--- + +### Task 3: Data layer — ring buffer, min/max pyramid, series + +**Files:** +- Create: `include/plot/data/{ring_buffer,minmax_pyramid,series,array_series,stream_series}.hpp` +- Create: `src/data/{minmax_pyramid,array_series,stream_series}.cpp` +- Create: `tests/test_ring_buffer.cpp`, `tests/test_minmax_pyramid.cpp`, `tests/test_series.cpp` +- Modify: `CMakeLists.txt`, `tests/CMakeLists.txt` + +**Interfaces:** +- Consumes: `plot::Range`, `plot::f32/f64/u64`, `plot::kInvalid`. +- Produces: `plot::data::RingBuffer`, `plot::data::MinMaxPyramid`, `plot::data::Series`, `plot::data::ArraySeries`, `plot::data::StreamSeries` exactly as declared in spec §6, plus `plot::data::MinMax{min,max}`, `plot::data::kBucket == 64`, and `MinMaxPyramid::sum_range`/`sumsq_range` (exact interval sums, O(kBucket) worst case — consumed by Task 5 measurements and by auto-scale). + +- [ ] **Step 1: Write the failing tests** + +`tests/test_minmax_pyramid.cpp` — the pyramid is the performance core, so it gets brute-force cross-checks: + +```cpp +#include "harness.hpp" +#include +#include + +using plot::data::MinMaxPyramid; +using plot::Range; + +PLOT_TEST(pyramid_bucket_extremes_preserved) { + MinMaxPyramid p; + p.reserve(1000); + for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f)); + PLOT_CHECK(p.size() == 1000); + const Range r = p.reduce(0, 1000); + PLOT_CHECK_NEAR(r.min, -50.0, 1e-6); + PLOT_CHECK_NEAR(r.max, 900.0, 1e-6); +} + +PLOT_TEST(pyramid_reduce_matches_bruteforce) { + std::mt19937 rng(1234); + std::uniform_real_distribution d(-100.0f, 100.0f); + std::vector v(5000); + MinMaxPyramid p; + for (auto& x : v) { x = d(rng); p.append(x); } + for (u64 a = 0; a < 5000; a += 137) { + for (u64 b = a + 1; b <= 5000; b += 511) { + float lo = v[a], hi = v[a]; + for (u64 i = a; i < b; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); } + const Range r = p.reduce(a, b); + PLOT_CHECK_NEAR(r.min, lo, 1e-4); + PLOT_CHECK_NEAR(r.max, hi, 1e-4); + } + } +} + +PLOT_TEST(pyramid_level_selection) { + MinMaxPyramid p; + p.reserve(1u << 20); + for (u64 i = 0; i < (1u << 20); ++i) p.append(0.0f); + // <= kBucket samples per pixel must use level 0 (raw path handles it anyway). + PLOT_CHECK(p.level_for(1u << 20, 1.0) == 0); + // 512 samples per pixel with kBucket == 64 → bucket size 512 → level 3. + PLOT_CHECK(p.level_for(1u << 20, 512.0) == 3); + // Level selection must never exceed the deepest level that has data. + PLOT_CHECK(p.level_for(1000, 1e9) <= p.level_count() - 1); +} + +PLOT_TEST(pyramid_snap_returns_nearest_sample) { + MinMaxPyramid p; + for (int i = 0; i < 100; ++i) p.append(float(i)); + PLOT_CHECK(p.snap(10.4, 0.0, 1.0) == 10); + PLOT_CHECK(p.snap(10.6, 0.0, 1.0) == 11); + PLOT_CHECK(p.snap(-5.0, 0.0, 1.0) == 0); // clamped + PLOT_CHECK(p.snap(1e9, 0.0, 1.0) == 99); // clamped +} + +PLOT_TEST(pyramid_sums_match_bruteforce) { + std::mt19937 rng(99); + std::uniform_real_distribution d(-3.0f, 7.0f); + std::vector v(3000); + MinMaxPyramid p; + for (auto& x : v) { x = d(rng); p.append(x); } + for (u64 a = 0; a < 3000; a += 311) { + for (u64 b = a + 1; b <= 3000; b += 419) { + double s = 0.0, sq = 0.0; + for (u64 i = a; i < b; ++i) { s += v[i]; sq += double(v[i]) * double(v[i]); } + PLOT_CHECK_NEAR(p.sum_range(a, b), s, 1e-3); + PLOT_CHECK_NEAR(p.sumsq_range(a, b), sq, 1e-2); + } + } +} + +PLOT_TEST(pyramid_clear_and_rebuild) { + MinMaxPyramid p; + for (int i = 0; i < 500; ++i) p.append(1.0f); + p.clear(); + PLOT_CHECK(p.size() == 0); + PLOT_CHECK(p.level_count() <= 1); + p.append(7.0f); + PLOT_CHECK(p.size() == 1); + PLOT_CHECK_NEAR(p.reduce(0, 1).max, 7.0, 1e-6); +} +``` + +`tests/test_ring_buffer.cpp`: + +```cpp +PLOT_TEST(ring_wraps_and_keeps_latest) { + plot::data::RingBuffer r(8); // capacity rounded up to a power of two + for (int i = 0; i < 20; ++i) r.push(f32(i)); + PLOT_CHECK(r.size() == 8); + PLOT_CHECK_NEAR(r[0], 12.0); // oldest retained + PLOT_CHECK_NEAR(r[7], 19.0); // newest +} +PLOT_TEST(ring_clear_resets) { + plot::data::RingBuffer r(8); + for (int i = 0; i < 5; ++i) r.push(f32(i)); + r.clear(); + PLOT_CHECK(r.size() == 0); + r.push(42.0f); + PLOT_CHECK(r.size() == 1); + PLOT_CHECK_NEAR(r[0], 42.0); +} +``` + +`tests/test_series.cpp`: + +```cpp +PLOT_TEST(array_series_bounds_and_gather) { + std::vector y{1, 5, -2, 3}; + plot::data::ArraySeries s(std::move(y), /*x0=*/10.0, /*dx=*/0.5); + PLOT_CHECK(s.size() == 4); + PLOT_CHECK_NEAR(s.x_bounds().min, 10.0, 1e-9); + PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9); + PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6); + std::vector out; + s.gather(0, 0, 4, out); + PLOT_CHECK(out.size() == 8); // interleaved x,y + PLOT_CHECK_NEAR(out[0], 10.0, 1e-6); + PLOT_CHECK_NEAR(out[1], 1.0, 1e-6); + PLOT_CHECK_NEAR(out[6], 11.5, 1e-6); +} + +PLOT_TEST(stream_series_acquire_publishes_snapshot) { + plot::data::StreamSeries s(1 << 12, /*x0=*/0.0, /*dx=*/1e-6); + for (int i = 0; i < 100; ++i) s.push(f32(i)); + PLOT_CHECK(s.produced() == 100); + s.acquire(); + PLOT_CHECK(s.size() == 100); + // Pyramid must track the published samples. + PLOT_CHECK_NEAR(s.pyramid().reduce(0, 100).max, 99.0, 1e-4); + for (int i = 100; i < 250; ++i) s.push(f32(i)); + s.acquire(); + PLOT_CHECK(s.size() == 250); +} + +PLOT_TEST(stream_series_drops_oldest_when_full) { + plot::data::StreamSeries s(256, 0.0, 1.0); + for (int i = 0; i < 1000; ++i) s.push(f32(i)); + s.acquire(); + PLOT_CHECK(s.size() <= 256); + PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cmake --build build/debug --target plot_tests` +Expected: FAIL to compile — `plot/data/minmax_pyramid.hpp` not found. + +- [ ] **Step 3: Write the implementation** + +`include/plot/data/minmax_pyramid.hpp`: + +```cpp +#pragma once +#include +#include +#include + +namespace plot::data { +struct MinMax { f32 min = 0.0f; f32 max = 0.0f; }; +inline constexpr u32 kBucket = 64; + +class MinMaxPyramid { + public: + void reserve(u64 n); + void append(f32 v); + void clear(); + u64 size() const noexcept { return size_; } + u32 level_count() const noexcept { return u32(levels_.size()); } + std::span level(u32 l) const; + Range reduce(u64 begin, u64 end) const; // half-open [begin, end) + f64 sum_range(u64 begin, u64 end) const; // exact sum over [begin, end) + f64 sumsq_range(u64 begin, u64 end) const; // exact sum of squares + u32 level_for(u64 n, f64 samples_per_pixel) const; + u64 snap(f64 x, f64 x0, f64 dx) const; + + private: + u64 size_ = 0; + std::vector> levels_; // levels_[0] = kBucket granularity + std::vector> sums_, sumsqs_; // inclusive prefix sums per level +}; +} // namespace plot::data +``` + +Implementation rules for `src/data/minmax_pyramid.cpp`: +- `append(v)`: write/merge into the last bucket of `levels_[0]`, then for each level `l`, if level `l` gained a complete pair, merge the last two entries of level `l` into a new entry of level `l+1`; otherwise stop early. Amortised O(1). +- `reduce(begin, end)`: `if (end <= begin) return {0,0};` walk the range: handle the partial head bucket and partial tail bucket by consulting `levels_[0]`, and cover the aligned middle by descending to the coarsest level whose buckets are fully inside the range. Never scan raw samples — the pyramid stores no raw samples. +- `level_for(n, spp)`: `if (spp <= f64(kBucket)) return 0;` else `u32 l = u32(std::floor(std::log2(spp / f64(kBucket))));` clamp to `level_count() - 1`, and additionally clamp so that `level(l).size() >= 2`. +- `snap(x, x0, dx)`: `if (dx <= 0.0 || size_ == 0) return 0;` compute `i = llround((x - x0) / dx)` and clamp to `[0, size_ - 1]`. + +`include/plot/data/ring_buffer.hpp`: `RingBuffer` with power-of-two capacity, atomic producer index, `push(T)` overwriting the oldest entry when full, `size()`, `operator[](u64)` indexing oldest→newest, `clear()`. + +`include/plot/data/series.hpp`: the abstract `Series` from spec §6, `virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const = 0;` documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level `level`; level 0 emits raw samples". + +`ArraySeries`: holds `std::vector y_`, `f64 x0_`, `f64 dx_`, a `MinMaxPyramid`, and cached `Range y_bounds_`/`Range x_bounds_`. Constructor and `reset()` fill the pyramid and bounds; `commit()` rebuilds them (for when the caller mutated the vector out of band — document that the vector is owned and mutated only via `reset`). + +`StreamSeries`: `RingBuffer` of rounded-up capacity, atomic `produced_`, `f64 x0_`, `f64 dx_`; `push` is the only producer entry point and MUST NOT touch the pyramid; `acquire()` reads `produced_` once, grows the pyramid by the newly produced samples, and updates bounds. `x_bounds()` derives from the oldest retained index and `dx_`, so a wrap shifts the window forward. + +Register the new tests in `tests/CMakeLists.txt` with `add_test(NAME ring_buffer COMMAND plot_tests)` etc. (the harness runs all cases in one binary; the multiple `add_test` names just label subsets in ctest output). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` +Expected: `pyramid_reduce_matches_bruteforce` (the 1300-odd range comparisons) passes, along with all series and ring tests. 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(data): SPSC ring buffer, incremental min/max LOD pyramid, series types" +``` + +--- + +### Task 4: Render layer — programs, buffers, batching, bitmap text + +**Files:** +- Create: `include/plot/render/{style,text,bitmap_font,program,buffer,vertex_array,primitives}.hpp` +- Create: `src/render/{program,buffer,vertex_array,bitmap_font,primitives}.cpp` +- Create: `src/render/shaders/*.glsl` (embedded at build time via a generated header, or as raw string literals in `.cpp` — pick the raw-string approach to avoid a build step) +- Create: `tests/test_primitives.cpp` + +**Interfaces:** +- Consumes: `plot::gl::{Buffer,VertexArray,Program}`, `plot::gl::caps()`, `plot::Color`, `plot::Rect`, `plot::Transform`. +- Produces: `plot::render::Style`, `plot::render::TextBackend` (abstract), `plot::render::BitmapFont`, `plot::render::LineBatch`, `plot::render::TextBatch`, each with `begin()/flush()` semantics, and the GLSL sources as `constexpr const char* kLineVS/kLineFS/kTextVS/kTextFS`. + +- [ ] **Step 1: Write the failing test** + +`tests/test_primitives.cpp` (CPU-side geometry, no GL needed): + +```cpp +#include "harness.hpp" +#include + +PLOT_TEST(line_batch_emits_two_verts_per_segment) { + plot::render::LineBatch b; + b.begin(); + const plot::Vec2 pts[3] = {{0, 0}, {1, 1}, {2, 0}}; + b.add_polyline(pts, 3); // 2 segments + PLOT_CHECK(b.vertex_count() == 4); + b.clear(); + PLOT_CHECK(b.vertex_count() == 0); + PLOT_CHECK(b.data().capacity() >= 4); // clear() must not free capacity + b.add_segment({0, 0}, {1, 1}); + PLOT_CHECK(b.vertex_count() == 2); +} + +PLOT_TEST(text_batch_measures_ascii) { + plot::render::BitmapFont f; + const f32 w = f.measure("12.5 ms"); + PLOT_CHECK(w > 0.0f); + PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6); + PLOT_CHECK(f.measure("ab") < f.measure("abc")); // monotonic in length + PLOT_CHECK(f.measure("iiii") < f.measure("WWWW")); // proportional-ish, not fixed-width-blank +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build/debug --target plot_tests` +Expected: FAIL to compile — `plot/render/primitives.hpp` not found. + +- [ ] **Step 3: Write the implementation** + +`include/plot/render/style.hpp`: + +```cpp +#pragma once +#include +namespace plot::render { +struct Style { + Color background = Color::hex(0x101418); + Color grid_major = Color::hex(0x2A3038); + Color grid_minor = Color::hex(0x1C2126); + Color axis_line = Color::hex(0x8A94A6); + Color axis_text = Color::hex(0xC8D0DC); + Color cursor = Color::hex(0xFFB020); + Color measure_fill = Color::hex(0xFFB020, 0.15f); + f32 axis_font_px = 12.0f; + f32 grid_label_pad = 4.0f; + f32 line_width_px = 1.5f; + f32 dense_threshold_spp = 1.5f; // spec §4.3 + f32 tick_min_px = 60.0f; // minimum spacing between major ticks +}; +} // namespace plot::render +``` + +`include/plot/render/text.hpp`: `class TextBackend { public: virtual ~TextBackend(); virtual f32 measure(const char*) const = 0; virtual f32 height() const = 0; virtual f32 ascent() const = 0; }`. + +`include/plot/render/bitmap_font.hpp`: `class BitmapFont final : public TextBackend` — owns a 95-glyph (ASCII 32..126) 8×16 1-bit atlas as a `static constexpr u8 kAtlas[95 * 16]` table in the `.cpp`, plus `f32 measure`, `f32 advance(char)`, and `u32 glyph_rows(char)` returning the 16 rows. `measure("") == 0`. + +`include/plot/render/primitives.hpp`: + +```cpp +#pragma once +#include +#include +#include + +namespace plot::render { +/// CPU-side staging for one instanced-AA line draw call. +class LineBatch { + public: + void begin(); + void clear(); + void add_segment(Vec2 a, Vec2 b); // one instance + void add_polyline(const Vec2* pts, u32 n); // n-1 instances + u32 vertex_count() const noexcept; // 2 vertices per staged segment + const std::vector& data() const noexcept; // interleaved x0,y0,x1,y1 +}; +} // namespace plot::render +``` + +`src/render/primitives.cpp` implements the staging buffers with `reserve`/`resize` reuse — `clear()` MUST NOT free capacity, and `begin()` MUST NOT reallocate when the frame's workload matches the previous frame. + +GLSL sources live as raw string literals in `src/render/program.cpp`: + +- `kLineVS`: inputs `vec2 a_p0, a_p1` (per-instance) and a unit-quad corner via `gl_VertexID`; uniforms `vec2 u_scale, u_offset` (data→pixel), `vec2 u_viewport`, `float u_width`; expands the segment in pixel space by `u_width/2` along the segment normal plus 1px of AA fringe, and outputs `v_dist` (distance to the segment axis in pixels) and `v_along`. +- `kLineFS`: `discard` when `length(v_dist) > u_width * 0.5 + 1.0`; alpha = `smoothstep` over the last pixel of the half-width; outputs `u_color * alpha`. +- `kTextVS/kTextFS`: instanced quads from the glyph atlas; a single-channel `GL_R8` texture sampled with `GL_NEAREST`, tinted by `u_color`, with `u_px_range`/`u_tex_size` uniforms. + +Both shaders declare `#version 330 core`. The 4.3+ fast path in Task 7 changes only the buffer upload strategy, never the shader source, so these stay valid on both paths. + +`include/plot/render/program.hpp` / `buffer.hpp` / `vertex_array.hpp` are thin owning wrappers over `plot::gl::{Program,Buffer,VertexArray}` that know the attribute layouts for the line and text pipelines (offsetof-based, no magic numbers in callers). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` +Expected: `line_batch_emits_two_verts_per_segment`, `text_batch_measures_ascii` `[ ok ]`, 0 failed. + +Then confirm the shaders compile on the real driver (this catches GLSL syntax errors that no CPU test can). Append this permanent test to `tests/test_gl_context.cpp`: + +```cpp +PLOT_TEST(shaders_compile_and_link) { + plottest::HeadlessContext ctx; plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Status s0, s1; + plot::gl::Program line = plot::gl::Program::from_source(kLineVS, kLineFS, &s0); + plot::gl::Program text = plot::gl::Program::from_source(kTextVS, kTextFS, &s1); + PLOT_CHECK(bool(s0)); PLOT_CHECK(bool(s1)); + PLOT_CHECK(line.id() != 0); PLOT_CHECK(text.id() != 0); + ctx.destroy(); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(render): style, GLSL line/text pipelines, staging batches, bitmap font" +``` + +--- + +### Task 5: Plot layer — ticks, axes, grid, trace, cursor, measurement + +**Files:** +- Create: `include/plot/plot/{ticks,axis,grid,trace,cursor,measure,annotation,plot}.hpp` +- Create: `src/plot/{ticks,axis,grid,plot,measure}.cpp` +- Create: `tests/test_ticks.cpp`, `tests/test_measure.cpp` + +**Interfaces:** +- Consumes: `plot::data::{Series,MinMaxPyramid}`, `plot::Range`, `plot::Transform`, `plot::render::{Style,TextBackend}`. +- Produces: `plot::Tick{ f64 value; bool major; std::string label; }`, `plot::generate_ticks(Range, f32 px, f32 min_px, std::vector&)`, `plot::format_si(f64, char* buf, usize n)`, `plot::format_time(f64 seconds, char*, usize)`, `plot::AxisPlace`, `plot::Axis`, `plot::Grid`, `plot::Trace`, `plot::Cursor`, `plot::Measurement{ dx, dy, slope, rms, mean, ymin, ymax, integral, valid }`, `plot::Measure::evaluate(const data::Series&, u64 begin, u64 end) -> Measurement`, and `plot::Plot` with `layout()`, `trace_count()`, `measurement(u32)`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_ticks.cpp`: + +```cpp +PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) { + std::vector t; + plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, t); + PLOT_CHECK(!t.empty()); + for (auto& tick : t) PLOT_CHECK(std::fabs(tick.value * 5.0 - std::round(tick.value * 5.0)) < 1e-9); + // spacing of major ticks >= min_px + PLOT_CHECK(t.size() <= 500.0 / 60.0 + 2.0); +} +PLOT_TEST(ticks_are_monotonic_and_cover_range) { + std::vector t; + plot::generate_ticks({-3.7, 91.2}, 800.0f, 60.0f, t); + for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value); + PLOT_CHECK(t.front().value <= 0.0); + PLOT_CHECK(t.back().value >= 90.0); +} +PLOT_TEST(si_formatting) { + char b[32]; + plot::format_si(0.0000015, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s" || std::string(b).find("1.5") == 0); + plot::format_si(2.5e9, b, sizeof b); PLOT_CHECK(std::string(b).find("2.5") == 0); + plot::format_si(1234.0, b, sizeof b); PLOT_CHECK(std::string(b).find("1.23") == 0); + plot::format_si(0.0, b, sizeof b); PLOT_CHECK(std::string(b) == "0"); +} +PLOT_TEST(degenerate_ranges_do_not_hang) { + std::vector t; + plot::generate_ticks({5.0, 5.0}, 500.0f, 60.0f, t); // must not loop forever + PLOT_CHECK(t.size() <= 64); + plot::generate_ticks({0.0, 0.0}, 0.0f, 60.0f, t); + plot::generate_ticks({1e300, 1e300}, 500.0f, 60.0f, t); +} +``` + +`tests/test_measure.cpp`: + +```cpp +PLOT_TEST(measurement_over_interval_matches_direct_computation) { + std::vector y; for (int i = 0; i < 1000; ++i) y.push_back(f32(std::sin(i * 0.01))); + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + const auto m = plot::Measure::evaluate(s, 100, 800); + double sum = 0, sumsq = 0, integral = 0; float lo = 1e30f, hi = -1e30f; + for (u64 i = 100; i < 800; ++i) { + const double v = std::sin(i * 0.01); + sum += v; sumsq += v * v; integral += v; // dx == 1 + lo = std::min(lo, float(v)); hi = std::max(hi, float(v)); + } + const double n = 700; + PLOT_CHECK_NEAR(m.mean, sum / n, 1e-5); + PLOT_CHECK_NEAR(m.rms, std::sqrt(sumsq / n), 1e-5); + PLOT_CHECK_NEAR(m.integral, integral, 1e-3); + PLOT_CHECK_NEAR(m.ymin, lo, 1e-6); + PLOT_CHECK_NEAR(m.ymax, hi, 1e-6); + PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9); +} +PLOT_TEST(measurement_uses_pyramid_not_scan) { + // Guard against a future regression that linear-scans the series: a huge + // series must evaluate a 1-sample-wide interval in well under a millisecond. + std::vector y(1u << 22, 1.0f); // 4M samples + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + const auto t0 = std::chrono::steady_clock::now(); + for (int i = 0; i < 1000; ++i) (void)plot::Measure::evaluate(s, 1'000'000, 1'000'001); + const auto us = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + PLOT_CHECK(us < 50'000); // 1000 evals in < 50 ms +} +PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) { + std::vector y{3.0f}; + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid); + PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cmake --build build/debug --target plot_tests` +Expected: FAIL to compile — `plot/plot/ticks.hpp` not found. + +- [ ] **Step 3: Write the implementation** + +`src/plot/ticks.cpp` — the nice-number algorithm: + +```cpp +// generate_ticks(range, px, min_px, out): +// if (!(range.span() > 0) || px <= 0) { out.clear(); return; } +// const f64 target_count = std::max(1.0, f64(px) / f64(min_px)); +// const f64 raw_step = range.span() / target_count; +// const f64 mag = std::pow(10.0, std::floor(std::log10(raw_step))); +// const f64 norm = raw_step / mag; // in [1, 10) +// const f64 step = mag * (norm <= 1.5 ? 1.0 : norm <= 3.0 ? 2.0 : norm <= 7.0 ? 5.0 : 10.0); +// f64 first = std::ceil(range.min / step) * step; +// for (f64 v = first; v <= range.max + step * 1e-9; v += step) out.push_back({v, true, {}}); +// // minor ticks: step/5 (or step/4 when step starts with 2, step/5 when with 5), same loop. +// // hard guard: if (out.size() > 256) out.resize(256); +``` +Labels are assigned by `format_si` unless the axis is a time axis, in which case `format_time` is used. Ticks whose label would be non-finite or absurdly long (> 12 chars) fall back to `%.3g`. + +`src/plot/measure.cpp`: + +```cpp +Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) { + Measurement r{}; + if (end <= begin || end > s.size()) return r; + // Sums (mean, rms, integral): O(1) interior from level-0 prefix sums plus a + // scan of at most two partial edge buckets (<= 2*kBucket samples). + // Extrema: pyramid().reduce(begin, end), O(log n), never scans. + // dx = (end - 1 - begin) * dx_of_series; + // slope = (y[end-1] - y[begin]) / dx (0 when dx == 0); + // dy = y[end-1] - y[begin]; + // valid = true. + return r; +} +``` +`reduce()` supplies `ymin`/`ymax` in O(log n) and never scans the series. mean/rms/integral use `MinMaxPyramid::sum_range`/`sumsq_range` (Task 3): O(1) over bucket-aligned interiors from the level-0 prefix sums, plus a direct scan of at most two partial edge buckets (<= 2 x kBucket = 128 samples). A measurement therefore never scales with series length, which is exactly what `measurement_uses_pyramid_not_scan` gates. `integral = sum_range(begin,end) * dx`, `mean = sum_range/n`, `rms = sqrt(sumsq_range/n)`, where `n = end - begin`. + +`include/plot/plot/plot.hpp` — the public façade, exactly the API in spec §6 plus: + +```cpp +struct PlotLayout { + Rect axes; // plot area (excludes tick labels) + Rect y_label_area, x_label_area; + f32 y_axis_width = 56.0f; + f32 x_axis_height = 22.0f; +}; +``` +`Plot` owns vectors of axes/traces/cursors/measures, computes `PlotLayout` from `viewport()` and the text backend metrics, and recomputes measurement results for every measure whose cursors moved (dirty flag, not every frame). + +`Grid` produces tick-aligned line segments in data space; `Trace` binds a `Series` to a color/width/axis pair; `Cursor` carries `f64 x`, `SnapMode { Free, NearestSample }`, color and an optional label; `Annotation` is a pinned point with text. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` +Expected: all tick and measure tests `[ ok ]`, including `measurement_uses_pyramid_not_scan`. 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries" +``` + +--- + +### Task 6: Input layer — events and controller + +**Files:** +- Create: `include/plot/input/{events,controller}.hpp`, `src/input/controller.cpp` +- Create: `tests/test_controller.cpp` + +**Interfaces:** +- Consumes: `plot::{Plot,Rect,Range,Transform}`. +- Produces: `plot::input::{Button,Mod,PointerEvent,WheelEvent,KeyEvent,DragMode,Controller}` exactly as in spec §6. + +- [ ] **Step 1: Write the failing test** + +```cpp +PLOT_TEST(wheel_zoom_keeps_data_under_cursor_fixed) { + plot::Plot p; p.set_viewport({0, 0, 800, 400}); + const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false}); + plot::input::Controller c(p); + c.set_viewport({0, 0, 800, 400}); + const f32 cursor_x = 200.0f; + const f64 before = p.x_transform(ax).to_data_x(cursor_x); + plot::input::WheelEvent w{cursor_x, 100.0f, 0.0f, 1.0f, 0}; + c.on_wheel(w); + const f64 after = p.x_transform(ax).to_data_x(cursor_x); + PLOT_CHECK_NEAR(before, after, 1e-6); + PLOT_CHECK(plot::Range{p.x_range(ax)}.span() < 100.0); // zoomed in +} + +PLOT_TEST(left_drag_pans_without_changing_span) { + plot::Plot p; p.set_viewport({0, 0, 800, 400}); + const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); + plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400}); + const double span_before = p.x_range(ax).span(); + c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false}); + PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan); + c.on_pointer({180, 130, 80, 30, plot::input::Button::Left, 0, true, false, false}); + c.on_pointer({180, 130, 0, 0, plot::input::Button::Left, 0, false, false, true}); + PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); + PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before, 1e-9); // span unchanged + PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-6); // 80/800 * 100 data units + PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down raises y range +} + +PLOT_TEST(shift_drag_box_zooms_to_selection) { + plot::Plot p; p.set_viewport({0, 0, 800, 400}); + const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false}); + plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400}); + const u32 e_shift = 1u << 0; // Mod::Shift + c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, e_shift, true, true, false}); + PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom); + c.on_pointer({300, 150, 200, 100, plot::input::Button::Left, e_shift, true, false, false}); + PLOT_CHECK_NEAR(c.selection_rect().x, 100.0, 1e-6); + PLOT_CHECK_NEAR(c.selection_rect().w, 200.0, 1e-6); + c.on_pointer({300, 150, 0, 0, plot::input::Button::Left, e_shift, false, false, true}); + PLOT_CHECK_NEAR(p.x_range(ax).min, 100.0, 1e-6); + PLOT_CHECK_NEAR(p.x_range(ax).max, 300.0, 1e-6); +} + +PLOT_TEST(auto_scale_disabled_by_user_interaction) { + // A wheel zoom or pan MUST clear auto_scale on the affected axis so the range + // the user chose is not snapped back by the next autoscale. + plot::Plot p; p.set_viewport({0, 0, 800, 400}); + const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false}); + plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400}); + c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0}); + PLOT_CHECK(!p.x_axis(ax).auto_scale); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build/debug --target plot_tests` +Expected: FAIL to compile — `plot/input/controller.hpp` not found. + +- [ ] **Step 3: Write the implementation** + +`src/input/controller.cpp` gesture table: + +| gesture | mode | effect | +|---|---|---| +| left drag | `Pan` | translate both ranges by `-dx_px / plot_width * x_span` (and y likewise, inverted) | +| shift + left drag | `BoxZoom` | record `selection_rect()`; on release set both ranges from the rect (ignore sub-4px rects — treat as a click) | +| ctrl + left drag near a cursor | `MoveCursor` | move that cursor, re-snapping and re-evaluating its measures | +| wheel at (x,y) | — | zoom both axes about the pointer's data coordinate by `exp(-dy * k)`; hold shift to zoom x only, ctrl to zoom y only | +| middle drag on the x-axis strip | `AxisZoomX` | x-only zoom | +| middle drag on the y-axis strip | `AxisZoomY` | y-only zoom | +| right drag | `Pan` | same as left, for hosts that reserve left-drag | +| `key` +/− | — | zoom about the plot centre | +| `key` F | — | `auto_fit_x()` + `auto_fit_y` on all y axes | + +Every gesture that changes a range MUST set `auto_scale = false` on the affected axis. Zoom must be implemented by recomputing `Transform` from the anchored data coordinate (the same arithmetic the Task 1 `zoom_anchor_is_invariant` test pins), never by scaling the range around its midpoint. + +Add `Plot::x_transform(u32)/y_transform(u32)`, `x_range(u32)/y_range(u32)`, `x_axis(u32)` accessors as needed by the tests and by the renderer. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` +Expected: all four controller tests `[ ok ]`. 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(input): host-agnostic events and pan/zoom/cursor controller" +``` + +--- + +### Task 7: Renderer and headless render smoke test + +**Files:** +- Create: `include/plot/render/renderer.hpp`, `src/render/renderer.cpp` +- Create: `tests/test_render_smoke.cpp` +- Modify: `CMakeLists.txt`, `tests/CMakeLists.txt` + +**Interfaces:** +- Consumes: everything below it. +- Produces: `plot::Renderer` with `bool init(Status*)`, `void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px)`, `void draw(Plot&)`, `void end_frame()`, `Style& style()`, `bool last_frame_had_gl_error() const`, and `void dump_frame_ppm(const char* path)` (used by the test when `PLOT_DUMP_FRAMES` is set). Consumes `Plot::layout()`, `Plot::x_transform(...)`, `Plot::x_range(...)`, `Plot::add_trace(...)` from Tasks 5 and 6. + +- [ ] **Step 1: Write the failing test** + +```cpp +PLOT_TEST(renders_visible_trace_across_viewport) { + plottest::HeadlessContext ctx; plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + const u32 W = 640, H = 360; + std::vector y(1'000'000); + for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001)); + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, f32(W), f32(H)}); + const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); + const u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + + r.begin_frame(W, H, {0, 0, f32(W), f32(H)}); + r.draw(p); + r.end_frame(); + + // 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; + u32 hit_columns = 0; + for (u32 c = 0; c < 16; ++c) { + const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 16.0); + bool hit = false; + for (u32 yy = u32(a.y); yy < u32(a.y + a.h) && !hit; ++yy) { + u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px)); + if (px[2] > 120 && px[3] > 0 && px[0] < 160) hit = true; // blue-ish + } + if (hit) ++hit_columns; + } + PLOT_CHECK(hit_columns >= 15); // >= 95% of columns covered + + u8 corner[4]; PLOT_CHECK(ctx.read_pixel(2, 2, corner)); + PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200); // dark background + + PLOT_CHECK(!r.last_frame_had_gl_error()); + ctx.destroy(); +} + +PLOT_TEST(dense_and_sparse_paths_agree_on_extent) { + plottest::HeadlessContext ctx; plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; PLOT_CHECK(r.init(&st)); + + const u32 W = 640, H = 360; + std::vector y(1'000'000); + for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001)); + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, f32(W), f32(H)}); + const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const 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_columns = [&](plot::Range xr) -> u32 { + p.set_x_range(ax, xr); + r.begin_frame(W, H, {0, 0, f32(W), f32(H)}); + r.draw(p); + r.end_frame(); + const plot::Rect a = p.layout().axes; + u32 cols = 0; + for (u32 c = 0; c < 32; ++c) { + const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 32.0); + for (u32 yy = u32(a.y); yy < u32(a.y + a.h); ++yy) { + u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px)); + if (px[2] > 120 && px[0] < 160) { ++cols; break; } + } + } + return cols; + }; + + // Dense: 1M samples over 1M x-units -> ~1562 samples/pixel -> LOD buckets. + const u32 coarse = covered_columns({0.0, 1'000'000.0}); + // Sparse: 640 samples over 640 x-units -> 1 sample/pixel -> instanced quads. + const u32 fine = covered_columns({0.0, 640.0}); + PLOT_CHECK(coarse >= 30); // >= 95% of 32 columns + PLOT_CHECK(fine >= 30); + PLOT_CHECK(!r.last_frame_had_gl_error()); + ctx.destroy(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build/debug --target plot_tests` +Expected: FAIL to compile — `plot/render/renderer.hpp` not found. + +- [ ] **Step 3: Write the implementation** + +`src/render/renderer.cpp` frame sequence: + +``` +begin_frame(fb_w, fb_h, viewport_px): + glViewport(fb_w, fb_h, viewport_px); // origin flipped: Rect is top-left, GL is bottom-left + clear the viewport to style.background + reset all batches + +draw(plot): + for each axis: compute ticks; stage grid segments; stage tick labels + for each visible trace: + spp = x_span_samples / plot_width_px + if (spp < style.dense_threshold_spp) stage raw polyline (level 0, instanced path) + else level = pyramid.level_for(n, spp); + stage bucket min/max vertical strokes + flush grids, then line batches, then text last (text is texture-bound, draw once) + +end_frame(): + unbind everything; record glGetError into last_frame_had_gl_error() +``` + +Upload strategy selected once in `init()`: +- `caps().buffer_storage && caps().persistent_map` → allocate each VBO with `glBufferStorage(MAP_WRITE|MAP_PERSISTENT|MAP_COHERENT)`, keep a persistent pointer, and triple-buffer with `glFenceSync`/`glClientWaitSync(0)` before reusing a slot. +- otherwise → `glBufferData(size, nullptr, GL_STREAM_DRAW)` orphan-and-refill each frame. + +Both paths go through the same `upload_vertices(span)` helper so no caller sees the difference. + +Coordinate note to get right the first time: `Rect` is top-left-origin in pixels, GL is bottom-left. Convert once in `begin_frame` and store the flipped viewport in the renders; never flip per-primitive. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` +Expected: `renders_visible_trace_across_viewport` `[ ok ]` with `hit_columns >= 15`, `dense_and_sparse_paths_agree_on_extent` `[ ok ]`. 0 failed. + +Save one rendered frame as a PPM to `build/debug/smoke.ppm` from the test when `PLOT_DUMP_FRAMES` is set in the environment, so the render can be eyeballed without a window. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(render): Renderer with LOD selection, 3.3 and 4.3+ upload paths, headless smoke test" +``` + +--- + +### Task 8: GLFW adapter and 1M-sample demo + +**Files:** +- Create: `adapters/glfw/{CMakeLists.txt,glfw_host.hpp,glfw_host.cpp}` +- Create: `examples/glfw_demo.cpp` +- Modify: `CMakeLists.txt` (`add_subdirectory(adapters/glfw)` when `PLOT_BUILD_GLFW`), `examples/CMakeLists.txt` + +**Interfaces:** +- Consumes: `plot::Renderer`, `plot::Plot`, `plot::input::Controller`. +- Produces: `plot::glfw::Host` — owns the window/context/loop. Types: + ```cpp + struct RunConfig { int width = 1280; int height = 720; const char* title = "plot"; bool vsync = true; }; + using FrameFn = std::function; + bool run(const RunConfig&, const FrameFn&, plot::Status* = nullptr); + ``` + Translates GLFW callbacks (`glfwSetCursorPosCallback`, `glfwSetMouseButtonCallback`, `glfwSetScrollCallback`, `glfwSetKeyCallback`, `glfwSetFramebufferSizeCallback`, `glfwGetFramebufferSize`, `glfwGetWindowContentScale`) into `plot::input` events and keeps `Plot::viewport()` in framebuffer pixels. + +- [ ] **Step 1: Write the demo** + +`examples/glfw_demo.cpp`: creates a `StreamSeries(1u << 20, 0.0, 1e-6)` fed by a background thread at 1 MS/s (`std::this_thread::sleep_for` paced, sine + noise + a periodic spike so decimation correctness is visible), two axes with SI labels on the time axis, a grid, one trace, and two cursors with a measurement readout. An ImGui-free on-screen HUD is out of scope — the measurement is drawn as plot annotations. + +The time axis label must be `format_time` based and the title must show live samples/second and frame time. + +- [ ] **Step 2: Build and run it** + +Run: `cmake --preset linux-release && cmake --build build/release --target plot_glfw_demo && ./build/release/examples/plot_glfw_demo` +Expected: a window with a scrolling sine trace, grid, labelled axes, two draggable cursors and a Δ/RMS readout. Pan, wheel-zoom, shift-box-zoom and cursor drag all work. Screenshot it. + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "feat(glfw): adapter and 1 MS/s streaming demo" +``` + +--- + +### Task 9: Dear ImGui adapter and demo + +**Files:** +- Create: `adapters/imgui/{CMakeLists.txt,imgui_plot.hpp,imgui_plot.cpp}` +- Create: `examples/imgui_demo.cpp` +- Modify: `CMakeLists.txt`, `cmake/plot_options.cmake` (FetchContent fallback for ImGui when `find_package` fails) + +**Interfaces:** +- Consumes: `plot::Renderer`, `plot::Plot`, `plot::input::Controller`, ImGui. +- Produces: `plot::imgui::PlotWidget` with `void draw(const char* id, Plot&, Vec2 size, Renderer&)` — allocates a child region, maps ImGui's cursor screen position to a framebuffer rect via `ImGui::GetWindowDrawList()` clipping and `io.DisplayFramebufferScale`, and forwards `io.MousePos/MouseWheel/MouseDown` as `plot::input` events while `ImGui::IsWindowHovered()`. + +- [ ] **Step 1: Write the demo** + +`examples/imgui_demo.cpp`: GLFW + ImGui `docking`-free single window, a slider controlling sample rate, a button that pushes a spike into the `StreamSeries`, and a checkbox toggling the LOD statistics overlay. + +- [ ] **Step 2: Build and run it** + +Run: `cmake --build build/release --target plot_imgui_demo && ./build/release/examples/plot_imgui_demo` +Expected: ImGui window showing the live plot; dragging inside the child region pans only when hovering it. Screenshot it. + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "feat(imgui): embeddable plot widget and demo" +``` + +--- + +### Task 10: Qt6 adapter and demo + +**Files:** +- Create: `adapters/qt6/{CMakeLists.txt,qt_plot_widget.hpp,qt_plot_widget.cpp}` +- Create: `examples/qt_demo.cpp` +- Modify: `CMakeLists.txt`, `examples/CMakeLists.txt` (Qt targets need `CMAKE_AUTOMOC`) + +**Interfaces:** +- Consumes: `QOpenGLWidget`, `QOpenGLFunctions`, `plot::Renderer`, `plot::Plot`, `plot::input::Controller`. +- Produces: `plot::qt::PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions` with: + - `initializeGL()` → `initializeOpenGLFunctions(); plot::gl::load(); renderer_.init();` + - `paintGL()` → `begin_frame(width() * devicePixelRatioF(), height() * devicePixelRatioF(), rect); renderer_.draw(plot_); renderer_.end_frame();` + - `resizeGL(int,int)` → update viewport rect + - `mousePressEvent/mouseMoveEvent/mouseReleaseEvent/wheelEvent/keyPressEvent` → construct `plot::input` events in **logical** pixels and pass them to the controller. The controller works in the same pixel space as `Plot::viewport()`, so the DPR conversion must be applied consistently in `set_viewport`/event translation — pick physical pixels for both, or logical for both, and document it in the header. + - Qt6 requires `QSurfaceFormat` with `setVersion(3,3)`, `setProfile(CoreProfile)`, `setSwapBehavior(DoubleBuffer)` set **before** the widget is created; set it in `main()` for the demo and document that an embedding application must have done the same. + +- [ ] **Step 1: Write the demo** + +`examples/qt_demo.cpp`: `QApplication`, a `QMainWindow` with a toolbar (Fit, Autoscale, Add cursor), the `PlotWidget` as central widget, fed by a `QTimer` at 60 Hz pushing samples from a producer thread. + +- [ ] **Step 2: Build and run it** + +Run: `cmake --build build/release --target plot_qt_demo && ./build/release/examples/plot_qt_demo` +Expected: a Qt window with the live plot; resizing reflows the grid and labels; DPR scaling on this display renders crisp, not stretched. Screenshot it. + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "feat(qt6): QOpenGLWidget-based plot widget and demo" +``` + +--- + +### Task 11: GTK4 adapter and demo + +**Files:** +- Create: `adapters/gtk4/{CMakeLists.txt,gtk_plot_area.hpp,gtk_plot_area.cpp}` +- Create: `examples/gtk_demo.cpp` +- Modify: `CMakeLists.txt`, `examples/CMakeLists.txt` + +**Interfaces:** +- Consumes: `GtkGLArea`, `plot::Renderer`, `plot::Plot`, `plot::input::Controller`. +- Produces: `plot::gtk4::PlotArea` — a `GtkWidget` subclass (C++ wrapper around a `GtkGLArea` created in a factory function, since GTK4 forbids deriving new GObject types in C++ without G_DEFINE_TYPE) with: + - `"realize"` → `gtk_gl_area_set_required_version(area, 3, 3)` and `gtk_gl_area_make_current`, then `plot::gl::load()` + `renderer_.init()` + - `"render"` → `begin_frame(fb_w, fb_h, rect)` … `end_frame()`, scaling by `gtk_widget_get_scale_factor()` + - `GtkGestureDrag` + `GtkEventControllerScroll` + `GtkEventControllerKey` → `plot::input` events + - `"resize"` → update the viewport rect + - Guard against `GtkGLArea` with no error set (`gtk_gl_area_get_error`) and report `ErrorCode::GLFailure` instead of crashing. + +- [ ] **Step 1: Write the demo** + +`examples/gtk_demo.cpp`: `gtk_application_new` + `GtkApplicationWindow` containing the plot area and a header bar with a "Fit" button; a `g_timeout_add` pump feeding the series. + +- [ ] **Step 2: Build and run it** + +Run: `cmake --build build/release --target plot_gtk_demo && ./build/release/examples/plot_gtk_demo` +Expected: a GTK4 window with the live plot; scroll-to-zoom and drag-to-pan work; window resize reflows. Screenshot it. + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "feat(gtk4): GtkGLArea-based plot area and demo" +``` + +--- + +### Task 12: Install/export, umbrella header, full verification + +**Files:** +- Create: `cmake/plotConfig.cmake.in` +- Modify: `CMakeLists.txt` (install rules, `plot::core` export set, `PLOT_INSTALL` option), `include/plot/plot.hpp` (umbrella include of every public header) + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: `find_package(plot)` → `plot::core` (+ `plot::glfw`, `plot::imgui`, `plot::qt6`, `plot::gtk4` when their deps resolve), working from an installed prefix. + +- [ ] **Step 1: Add install and export rules** + +`install(TARGETS plot_core EXPORT plotTargets ...)` with `INCLUDES DESTINATION include`; `install(DIRECTORY include/plot DESTINATION include)`; `install(EXPORT plotTargets NAMESPACE plot:: FILE plotTargets.cmake DESTINATION lib/cmake/plot)`; `configure_package_config_file` + `write_basic_package_version_file` for `plotConfig.cmake`/`plotConfigVersion.cmake`. Adapters export only if their dependency was found, and MUST NOT be exported with a hard dependency on a package that was only found via FetchContent — in that case skip the export and say so in the configure log. + +- [ ] **Step 2: Verify the install is usable** + +```bash +cmake --install build/release --prefix /tmp/plot-install +mkdir -p /tmp/plot-consumer +cat > /tmp/plot-consumer/CMakeLists.txt <<'EOF' +cmake_minimum_required(VERSION 3.24) +project(consumer LANGUAGES CXX) +find_package(plot 0.1 REQUIRED) +add_executable(consumer main.cpp) +target_link_libraries(consumer PRIVATE plot::core) +EOF +cat > /tmp/plot-consumer/main.cpp <<'EOF' +#include +int main() { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + return int(p.trace_count()); +} +EOF +cmake -S /tmp/plot-consumer -B /tmp/plot-consumer/build -DCMAKE_PREFIX_PATH=/tmp/plot-install +cmake --build /tmp/plot-consumer/build +/tmp/plot-consumer/build/consumer; echo "exit=$?" +``` +Expected: configure finds `plot 0.1.0`, the consumer compiles and links `plot::core` with no `-I` or `-L` flags of its own. + +- [ ] **Step 3: Full verification pass** + +```bash +cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure +cmake --preset linux-asan && cmake --build build/asan && ctest --test-dir build/asan --output-on-failure +cmake --preset linux-release && cmake --build build/release +``` +Expected: warnings clean with `-Wall -Wextra -Wpedantic -Wconversion -Wshadow`; all ctest cases pass on both debug and asan; release builds all four demos. + +Then launch all four demos and screenshot each: `plot_glfw_demo`, `plot_imgui_demo`, `plot_qt_demo`, `plot_gtk_demo`. + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "feat(build): install/export rules, umbrella header, verified consumer" +``` diff --git a/docs/superpowers/specs/2026-09-27-plot-library-design.md b/docs/superpowers/specs/2026-09-27-plot-library-design.md new file mode 100644 index 0000000..0abbe23 --- /dev/null +++ b/docs/superpowers/specs/2026-09-27-plot-library-design.md @@ -0,0 +1,322 @@ +# `plot` — embeddable OpenGL time-series plotting library + +**Date:** 2026-09-27 +**Status:** approved (design reviewed section-by-section with the requester) + +## Goal + +A C++20 library that renders real-time, high-sample-rate time-series plots with +grid, axes, cursors and measurements, and that can be embedded into an existing +Qt6, GTK4, Dear ImGui, or raw GLFW application without owning the window, the GL +context, or the event loop. + +## Non-goals (this cut) + +- 3D surfaces, scatter/bar/pie charts, polar plots. +- Data I/O (CSV/HDF5 readers), file formats, network sources. +- Font rendering beyond the built-in bitmap atlas. +- Any host API not listed above. + +These are explicitly out of scope so the first cut is a coherent vertical slice. +The layering (§3) leaves room for them without redesign. + +## Constraints (fixed by the requester) + +| Constraint | Value | +|---|---| +| Core dependencies | **zero.** No GLM, glad, GLEW, or bundled Khronos headers. | +| GL baseline | **3.3 core**, with a runtime-selected 4.3+ fast path. | +| Reference host | GLFW, plus ImGui and Qt6 and GTK4 adapters in the same cut. | +| First deliverable | buildable **end-to-end** skeleton: no stub bodies on the demo path. | +| Language | C++20, GCC/Clang, Linux first (no portability blockers introduced). | + +## 1. Environment (verified on the development machine) + +GCC 16.2.1, CMake 4.4.3, Ninja 1.13.2. Qt6 6.11.2 with `Qt6OpenGLWidgets`, +GTK4 4.22.5 with `gtkglarea.h`, GLFW 3.5.1, EGL 1.5 + `libEGL_mesa` (surfaceless +headless contexts available), `glm` and `GL/glew.h` installed but **unused**. +Dear ImGui is absent and is provided via `FetchContent` (network verified). + +## 2. The embeddability contract + +The library never creates a window, never creates or shares a GL context, and +never runs an event loop. Its render entry point is: + +```cpp +plot::Renderer r; // constructed with the caller's context current +r.init(); +r.begin_frame(fb_w, fb_h, viewport_rect_in_pixels); // in the caller's FBO +r.draw(plot); +r.end_frame(); +``` + +Input arrives as plain value types (`PointerEvent`, `WheelEvent`, `KeyEvent`) that +each adapter translates from its host toolkit. Nothing in `plot::core` names a +toolkit, so a consumer links only the adapter they need. + +## 3. Layering + +``` +adapters/{glfw,imgui,qt6,gtk4} own the surface; translate host events + ↓ +plot/ Plot · Axis · Trace · Grid · Cursor · Measure · Annotation + ↓ +render/ Renderer · Style · TextBackend · primitive batches + ↓ +data/ Series · ArraySeries · StreamSeries · MinMaxPyramid + ↓ +gl/ runtime loader · RAII handles · capability probe + ↓ +core/ + math/ types · Color · Rect · vec2 · mat3 · Transform +``` + +Each layer depends only downward. `adapters/*` and `examples/*` are optional +CMake targets guarded by `PLOT_BUILD_*` options and by dependency discovery. + +## 4. Performance architecture + +These four decisions are what make 1M+ samples/frame viable. Each replaces an +approach that fails at high sample rates. + +### 4.1 Data-space vertices, uniform-only pan and zoom + +Vertex buffers hold raw *data* coordinates. Screen mapping is +`x' = x * sx + tx`, `y' = y * sy + ty` evaluated in the vertex shader from a +`Transform` uniform. Panning and zooming therefore touch one uniform and zero +vertex data. Only new samples or an LOD-level change cause an upload. + +Log axes are handled inside the same transform (`log10` before scaling) so no +separate code path or pre-transformed buffer is needed. + +### 4.2 Min/max LOD pyramid + +Every series owns a mip chain: level 0 buckets `kBucket = 64` consecutive samples +and stores one `(min, max)` pair per bucket; each subsequent level halves the +bucket count. Memory cost is `n/8` bytes at level 0 (2 floats per 64 samples) times the geometric sum `1 + ½ + ¼ + …`, giving `n/4` bytes total — ≈6% of the 4n bytes of raw samples, not 2×. + +- Built incrementally: `append()` updates only the last bucket and the O(log n) + chain above it — amortised O(1) per sample. +- The renderer picks the level where samples-per-pixel ≈ 2, so a 100M-sample + series rasterises ≈ `2 · viewport_width` vertices instead of 100M. +- Buckets preserve extremes, so spikes are never dropped by decimation. + +### 4.3 Hybrid line rasterisation + +Chosen per trace, per frame, from observed samples-per-pixel: + +| samples/px | path | +|---|---| +| `< 1.5` | `GL_LINE_STRIP`, 1px, no AA — dense data hides aliasing; ~10× cheaper | +| `>= 1.5` | instanced anti-aliased quads: one instance per segment, 4 verts expanded in the vertex shader, fragment shader does distance-to-segment SDF for smooth edges and joins | + +A geometry-shader expansion path was rejected: poor performance and it is the +one stage macOS cannot be relied on for. + +### 4.4 Interval reduction reuses the pyramid + +Cursor measurements (`Δx`, `Δy`, slope, RMS, mean, min/max, integral over a +dragged region) walk the same min/max chain and return in `O(log n)`; the series +is never scanned linearly. `pyramid().reduce(a, b)` is the single implementation +used both by measurement and by auto-scale. + +### 4.5 Decimation approach — decision + +| | Approach | Verdict | +|---|---|---| +| **A** | **CPU min/max pyramid, incremental** | **chosen** — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈6% memory. | +| B | GPU compute decimation every frame | rejected — re-reads the whole series each frame (real bandwidth at 1M samples/frame), no 3.3 fallback, untestable headlessly. | +| C | no decimation | rejected — dies above ~500k samples. | + +The 4.3+ fast path therefore applies to *transfer*, not to decimation: a +persistent-mapped, `glFenceSync`-guarded triple-buffered ring replaces the +staging-upload path. Both paths share identical semantics and are selected by +the capability probe. + +## 5. Zero-dependency mechanisms + +- **Math** — hand-written `vec2`, `mat3`, `Transform`: ~250 lines, no templates + in public headers, no consumer-visible dependency. +- **GL loader** — a hand-maintained X-macro list `PLOT_GL_FUNCS(X)` covering only + the ≈60 entry points actually used, with our own typedefs and enums. No + `glcorearb.h` bundling and no code-generation step. Resolution order: + context-provided `getProcAddress` (glX / EGL / wgl) → `dlsym`/`GetProcAddress` + on the system GL library. +- **Text** — a `TextBackend` interface plus a built-in 8×16 bitmap-glyph atlas. + Axis labelling needs no third-party code; a FreeType or stb_truetype backend + can be added behind the same interface without touching callers. + +There is no `third_party/` directory. Adapters use `find_package` first and +`FetchContent` only as a fallback (ImGui; GLFW when absent). + +## 6. Public API sketch + +Signatures here are the contract the implementation plan and adapters rely on. + +```cpp +namespace plot { + +using f32 = float; using f64 = double; +using u32 = std::uint32_t; using u64 = std::uint64_t; +constexpr u64 kInvalid = ~u64{0}; + +struct Range { f64 min, max; }; +struct Rect { f32 x, y, w, h; }; // pixels, origin top-left +struct Color { f32 r, g, b, a; static Color hex(u32 rgb, f32 a = 1.0f); }; + +enum class ErrorCode { Ok, InvalidArgument, OutOfMemory, GLFailure, Unsupported, NotImplemented }; +struct Status { ErrorCode code = ErrorCode::Ok; const char* message = ""; explicit operator bool() const; }; + +// math/transform.hpp +struct Transform { + f64 sx, tx, sy, ty; bool log_x = false, log_y = false; + f64 to_screen_x(f64) const; f64 to_data_x(f64) const; + f64 to_screen_y(f64) const; f64 to_data_y(f64) const; +}; + +namespace gl { + using ProcLoader = void* (*)(const char*); + bool load(ProcLoader); // nullptr → platform default + struct Caps { int major, minor; bool compute, persistent_map, buffer_storage; }; + const Caps& caps(); + class Buffer; class VertexArray; class Program; class Texture; class Framebuffer; class Fence; +} + +namespace data { + struct MinMax { f32 min, max; }; + constexpr u32 kBucket = 64; + + class MinMaxPyramid { + public: + void reserve(u64 n); void append(f32 v); void clear(); + u64 size() const; + Range reduce(u64 begin, u64 end) const; // O(log n) + u32 level_for(u64 n, f64 samples_per_pixel) const; + std::span level(u32 l) const; + u64 snap(f64 x, f64 x0, f64 dx) const; // nearest sample index + }; + + class Series { // abstract + public: + virtual ~Series() = default; + virtual u64 size() const = 0; + virtual Range x_bounds() const = 0; + virtual Range y_bounds() const = 0; + virtual const MinMaxPyramid& pyramid() const = 0; + virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const = 0; + }; + + class ArraySeries final : public Series { // own storage, uniform dx + public: + ArraySeries(std::vector y, f64 x0, f64 dx); + void reset(std::vector y, f64 x0, f64 dx); + void commit(); // rebuild the pyramid + }; + + class StreamSeries final : public Series { // SPSC, producer + consumer + public: + explicit StreamSeries(u64 capacity, f64 x0, f64 dx); + void push(f32 y); // producer thread only + void push(const f32* ys, u64 n); // producer thread only + u64 produced() const; // atomic, monotonic + void acquire(); // consumer: snapshot + extend pyramid + }; +} + +enum class AxisPlace { Bottom, Top, Left, Right }; + +struct AxisDesc { std::string label; Range range; bool auto_scale = true; AxisPlace place; bool log = false; }; +struct TraceDesc { const data::Series* series; Color color; f32 width = 1.5f; u32 axis_x = 0, axis_y = 0; }; + +class Plot { + public: + u32 add_x_axis(AxisDesc); u32 add_y_axis(AxisDesc); + u32 add_trace(TraceDesc); + u32 add_cursor(f64 x, Color); + u32 add_measure(u32 cursor_a, u32 cursor_b); + void set_viewport(Rect px); + void set_x_range(u32 axis, Range); void set_y_range(u32 axis, Range); + void auto_fit_x(); void auto_fit_y(u32 axis); + struct Measurement { f64 dx, dy, slope, rms, mean, ymin, ymax, integral; bool valid; }; + const Measurement& measurement(u32 id) const; // recomputed on cursor move +}; + +class Renderer { + public: + bool init(Status* = nullptr); // context must be current + void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px); + void draw(Plot&); + void end_frame(); + Style& style(); +}; + +namespace input { + enum class Button { Left, Middle, Right }; + enum class Mod { None = 0, Shift = 1, Ctrl = 2, Alt = 4, Super = 8 }; + struct PointerEvent { f32 x, y, dx, dy; Button button; u32 mods; bool down, pressed, released; }; + struct WheelEvent { f32 x, y, dx, dy; u32 mods; }; + struct KeyEvent { int key; u32 mods; bool down; }; + + enum class DragMode { None, Pan, BoxZoom, MoveCursor, AxisZoomX, AxisZoomY }; + class Controller { + public: + explicit Controller(Plot&); + void set_viewport(Rect); + void on_pointer(const PointerEvent&); + void on_wheel(const WheelEvent&); + void on_key(const KeyEvent&); + DragMode drag_mode() const; + Rect selection_rect() const; // valid while BoxZoom + u64 hovered_cursor() const; // kInvalid when none + }; +} +} // namespace plot +``` + +## 7. Threading model + +- All GL calls happen on the thread that has the context current; `Renderer` is + not thread-safe and does not need to be. +- Producers write to `data::StreamSeries` from an acquisition thread through a + lock-free SPSC ring. No mutex on the sample path. +- A frame begins with `StreamSeries::acquire()`, which publishes a consistent + `[0, produced)` snapshot and extends the pyramid by exactly the newly produced + samples. Readers use the snapshot; the producer never blocks. +- `Renderer`, `Plot`, and `Controller` are confined to the render thread. + +## 8. Error handling + +- No exceptions cross the public boundary. Fallible operations return `Status` + or `bool` plus an out-parameter `Status*`. +- GL failures are surfaced as `ErrorCode::GLFailure` from `Renderer::init` and + recorded on the first occurrence per frame; `PLOT_GL_CHECK()` is a no-op in + release builds. +- Unsupported-but-optional capabilities (compute, persistent mapping) degrade + silently by selecting the 3.3 path; unsupported-and-required ones return + `ErrorCode::Unsupported` from `init`. + +## 9. Verification + +| Layer | How it is verified | +|---|---| +| core/math, data, plot/ticks, input | CTest unit tests, no GL required: ring-buffer wrap/overwrite/tearing, pyramid build and `reduce()` vs. brute force, level selection, tick generation, transform round-trip, zoom-anchor invariance, pan invariance. | +| render, plot | **headless EGL surfaceless context + FBO**, rasterise a known sine, `glReadPixels` and assert ink present in expected columns and background elsewhere; assert the trace covers ≥95% of viewport width at 1M samples. | +| adapters | Launch each demo (`glfw`, `imgui`, `qt`, `gtk`) on the development display, screenshot, confirm grid + axes + trace + cursor + measurement readout render. | + +Headless-EGL risk: if Mesa surfaceless context creation fails on this machine the +fallback is GLX on a hidden window; this is validated in the first GL task, before +three of the four adapters depend on it. + +## 10. Milestones + +1. Repo skeleton, CMake, core+math with tests. +2. GL loader, handles, capability probe; headless EGL harness proven. +3. Data layer: ring buffer, pyramid, series. +4. Render layer: programs, buffers, batching, bitmap text. +5. Plot layer: axes, ticks, grid, trace, cursor, measurement. +6. Input layer: events, controller. +7. Renderer + headless render smoke test (pixel assertions). +8. GLFW adapter + 1M-sample demo. +9. ImGui adapter + demo. +10. Qt6 adapter + demo. +11. GTK4 adapter + demo. +12. Install/export, full verification pass, screenshots.