1535 lines
71 KiB
Markdown
1535 lines
71 KiB
Markdown
# `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 <cstdio>
|
||
#include <cmath>
|
||
#include <chrono>
|
||
#include <algorithm>
|
||
#include <vector>
|
||
#include <string>
|
||
#include <functional>
|
||
|
||
using plot::f32;
|
||
using plot::f64;
|
||
using plot::u32;
|
||
using plot::u64;
|
||
|
||
namespace plottest {
|
||
struct Case { const char* name; std::function<void()> fn; };
|
||
inline std::vector<Case>& cases() { static std::vector<Case> c; return c; }
|
||
struct Reg { Reg(const char* n, std::function<void()> 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 <plot/math/transform.hpp>
|
||
|
||
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 <cstdint>
|
||
#include <cstddef>
|
||
|
||
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 <plot/core/types.hpp>
|
||
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 <plot/core/types.hpp>
|
||
#include <cmath>
|
||
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 plot_tests COMMAND plot_tests)
|
||
```
|
||
Every later task adds its new `test_*.cpp` to the `plot_tests` source list; there is
|
||
exactly one `add_test` entry, because the harness runs every registered case in one
|
||
binary. The top-level `CMakeLists.txt` calls `enable_testing()` when `PLOT_BUILD_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 <plot/gl/loader.hpp>
|
||
#include <plot/gl/caps.hpp>
|
||
|
||
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 <plot/core/types.hpp>
|
||
|
||
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 <plot/core/status.hpp>
|
||
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 <plot/gl/api.hpp>
|
||
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}` and `plot::data::kBucket == 64`.
|
||
**Exactness split (do not get this wrong):** a min/max pyramid stores one aggregate per bucket, so it can only answer exactly for *whole* buckets. Exact range queries therefore live on `Series` — `reduce`, `sum`, `sumsq`, non-virtual, implemented once in `src/data/series.cpp` over the protected `copy_raw` hook: the bucket-aligned interior comes from the pyramid, and at most two partial edge buckets are copied through `copy_raw` and scanned. `MinMaxPyramid` exposes only the bucket-exact `reduce_buckets`/`sum_buckets`/`sumsq_buckets` that back them. Task 5 measurements and auto-scale consume `Series::reduce`/`sum`/`sumsq` and never the `*_buckets` forms directly.
|
||
|
||
- [ ] **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 <plot/data/minmax_pyramid.hpp>
|
||
#include <random>
|
||
|
||
using plot::data::kBucket;
|
||
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_buckets(0, 960); // exactly 15 whole buckets
|
||
PLOT_CHECK_NEAR(r.min, -50.0, 1e-6);
|
||
PLOT_CHECK_NEAR(r.max, 900.0, 1e-6);
|
||
}
|
||
|
||
PLOT_TEST(pyramid_reduce_buckets_matches_bruteforce_on_aligned_ranges) {
|
||
std::mt19937 rng(1234);
|
||
std::uniform_real_distribution<float> d(-100.0f, 100.0f);
|
||
std::vector<float> v(5000);
|
||
MinMaxPyramid p;
|
||
for (auto& x : v) { x = d(rng); p.append(x); }
|
||
for (u64 a = 0; a < 5000; a += kBucket * 3) {
|
||
for (u64 b = a + kBucket; b <= 5000; b += kBucket * 7) {
|
||
const u64 ahi = (b / kBucket) * kBucket; // whole buckets only
|
||
if (ahi <= a) continue;
|
||
float lo = v[a], hi = v[a];
|
||
for (u64 i = a; i < ahi; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
|
||
const Range r = p.reduce_buckets(a, ahi);
|
||
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
|
||
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
|
||
}
|
||
}
|
||
}
|
||
|
||
PLOT_TEST(pyramid_reduce_buckets_excludes_partial_edges) {
|
||
// Bucket 1 covers samples [64,128). reduce_buckets answers in whole buckets
|
||
// only, so the spike must show up in its own bucket and nowhere else.
|
||
MinMaxPyramid p;
|
||
for (int i = 0; i < 128; ++i) p.append(i == 70 ? 999.0f : 1.0f);
|
||
PLOT_CHECK_NEAR(p.reduce_buckets(64, 128).max, 999.0, 1e-6);
|
||
PLOT_CHECK_NEAR(p.reduce_buckets(0, 64).max, 1.0, 1e-6);
|
||
PLOT_CHECK(p.reduce_buckets(64, 64).span() == 0.0); // empty -> degenerate
|
||
}
|
||
|
||
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_on_aligned_ranges) {
|
||
std::mt19937 rng(99);
|
||
std::uniform_real_distribution<float> d(-3.0f, 7.0f);
|
||
std::vector<float> v(3000);
|
||
MinMaxPyramid p;
|
||
for (auto& x : v) { x = d(rng); p.append(x); }
|
||
for (u64 a = 0; a < 3000; a += kBucket * 5) {
|
||
for (u64 b = a + kBucket; b <= 3000; b += kBucket * 9) {
|
||
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_buckets(a, b), s, 1e-3);
|
||
PLOT_CHECK_NEAR(p.sumsq_buckets(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);
|
||
for (int i = 1; i < int(kBucket); ++i) p.append(1.0f); // complete bucket 0
|
||
PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).max, 7.0, 1e-6);
|
||
PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).min, 1.0, 1e-6);
|
||
}
|
||
```
|
||
|
||
`tests/test_ring_buffer.cpp`:
|
||
|
||
```cpp
|
||
PLOT_TEST(ring_wraps_and_keeps_latest) {
|
||
plot::data::RingBuffer<f32> 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<f32> 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
|
||
#include "harness.hpp"
|
||
#include <plot/data/array_series.hpp>
|
||
#include <plot/data/stream_series.hpp>
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <random>
|
||
#include <vector>
|
||
|
||
PLOT_TEST(array_series_bounds_and_gather) {
|
||
std::vector<f32> 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<f32> 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.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);
|
||
}
|
||
|
||
PLOT_TEST(array_series_exact_range_queries_match_bruteforce) {
|
||
// The strong test for the exactness split: arbitrary, deliberately
|
||
// unaligned ranges must match a brute-force scan of the raw samples.
|
||
std::mt19937 rng(7);
|
||
std::uniform_real_distribution<float> d(-5.0f, 5.0f);
|
||
std::vector<f32> y(4000);
|
||
for (auto& v : y) v = d(rng);
|
||
plot::data::ArraySeries s(y, 0.0, 1.0);
|
||
for (u64 a = 0; a < 4000; a += 173) {
|
||
for (u64 b = a + 1; b <= 4000; b += 331) {
|
||
float lo = y[a], hi = y[a];
|
||
double sum = 0.0, sq = 0.0;
|
||
for (u64 i = a; i < b; ++i) {
|
||
lo = std::min(lo, y[i]); hi = std::max(hi, y[i]);
|
||
sum += y[i]; sq += double(y[i]) * double(y[i]);
|
||
}
|
||
const plot::Range r = s.reduce(a, b);
|
||
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
|
||
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
|
||
PLOT_CHECK_NEAR(s.sum(a, b), sum, 1e-2);
|
||
PLOT_CHECK_NEAR(s.sumsq(a, b), sq, 1e-1);
|
||
}
|
||
}
|
||
}
|
||
|
||
PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
|
||
plot::data::StreamSeries s(1u << 10, 0.0, 1.0);
|
||
for (int i = 0; i < 3000; ++i) s.push(f32(i % 97)); // wraps the ring twice
|
||
s.acquire();
|
||
const u64 n = s.size();
|
||
const plot::Range r = s.reduce(0, n); // retained window, wrapped
|
||
PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
|
||
PLOT_CHECK_NEAR(r.min, 0.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 <plot/core/types.hpp>
|
||
#include <span>
|
||
#include <vector>
|
||
|
||
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<const MinMax> level(u32 l) const;
|
||
// Whole-bucket only: [begin, end) is intersected with whole level-0 buckets and
|
||
// partial edges are excluded, never approximated. An empty result has span()==0.
|
||
Range reduce_buckets(u64 begin, u64 end) const;
|
||
f64 sum_buckets(u64 begin, u64 end) const;
|
||
f64 sumsq_buckets(u64 begin, u64 end) const;
|
||
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<std::vector<MinMax>> levels_; // levels_[0] = kBucket granularity
|
||
std::vector<std::vector<f64>> 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). The same walk maintains `sums_`/`sumsqs_` (inclusive prefix sums per level).
|
||
- `reduce_buckets(begin, end)`: `if (end <= begin) return {0,0};` intersect `[begin, end)` with whole level-0 buckets, then cover the result by descending to the coarsest level whose buckets lie entirely inside it, finishing the remainder at level 0. It must NEVER scan raw samples — it stores none — and a partial edge bucket is excluded, not approximated. `sum_buckets`/`sumsq_buckets` use the same decomposition over `sums_`/`sumsqs_`.
|
||
- `Series::reduce`/`sum`/`sumsq` in `src/data/series.cpp` are the exact entry points every other module uses: they take the bucket-aligned interior from `reduce_buckets`/`sum_buckets`/`sumsq_buckets` and scan the at-most-two partial edge buckets obtained via `copy_raw` (a stack buffer of at most 2*kBucket floats, so no allocation on the query path).
|
||
- `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<T>` 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 f64 x0() const` and `virtual f64 dx() const` expose the uniform x grid (measurement and `snap` need them). `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;` is documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level `level`; level 0 emits raw samples". `reduce`/`sum`/`sumsq` are non-virtual, implemented once in `src/data/series.cpp` over the protected pure virtual `copy_raw(u64 begin, u64 end, f32* out) const`, which copies `end - begin` raw y values and handles any wraparound.
|
||
|
||
`ArraySeries`: holds `std::vector<f32> 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`). `copy_raw` is a bounds-checked `std::copy` over `y_`.
|
||
|
||
`StreamSeries`: `RingBuffer<f32>` 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. `copy_raw` must split the copy at the ring's wrap point; `reduce`/`sum`/`sumsq` are documented as operating on the *retained* window, so index 0 is the oldest sample still held.
|
||
|
||
Add `test_ring_buffer.cpp`, `test_minmax_pyramid.cpp` and `test_series.cpp` to the existing `plot_tests` target's source list in `tests/CMakeLists.txt`. Do not add new `add_test` entries — one `add_test(NAME plot_tests ...)` runs every registered case.
|
||
|
||
- [ ] **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/render/primitives.hpp>
|
||
|
||
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 <plot/core/color.hpp>
|
||
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 <plot/core/color.hpp>
|
||
#include <plot/math/vec2.hpp>
|
||
#include <vector>
|
||
|
||
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<f32>& 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<Tick>&)`, `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
|
||
#include "harness.hpp"
|
||
#include <plot/plot/ticks.hpp>
|
||
#include <cmath>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) {
|
||
std::vector<plot::Tick> 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<plot::Tick> 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<plot::Tick> 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
|
||
#include "harness.hpp"
|
||
#include <plot/plot/measure.hpp>
|
||
#include <plot/data/array_series.hpp>
|
||
#include <algorithm>
|
||
#include <chrono>
|
||
#include <cmath>
|
||
#include <vector>
|
||
|
||
PLOT_TEST(measurement_over_interval_matches_direct_computation) {
|
||
std::vector<f32> 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<f32> 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::microseconds>(
|
||
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<f32> 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, bool include_minors = false):
|
||
// if (!(range.span() > 0) || px <= 0 || !isfinite(span) || min > max) { 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)
|
||
// // ROUND UP, not to nearest. Rounding to nearest can exceed target_count and
|
||
// // break the "at most px/min_px + 2 ticks" bound that the tests assert.
|
||
// const f64 step = mag * (norm <= 1.0 ? 1.0 : norm <= 2.0 ? 2.0 : norm <= 5.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, {}});
|
||
// // include_minors defaults to false because minors share this same vector: a
|
||
// // caller that wants only majors must not have to filter, and the "every value
|
||
// // is a multiple of the major step" assertions only hold for the majors-only
|
||
// // form. Grid rendering passes true.
|
||
// // minor ticks: step/5 (step/4 for a step beginning with 2), same loop.
|
||
// // hard guard: 256 entries maximum INCLUDING minors.
|
||
```
|
||
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;
|
||
// Every quantity comes from Series, never from the pyramid's *_buckets forms:
|
||
// const Range r = s.reduce(begin, end);
|
||
// const f64 total = s.sum(begin, end), sq = s.sumsq(begin, end);
|
||
// Each is O(kBucket) worst case: bucket-aligned interior from the pyramid,
|
||
// plus at most two partial edge buckets read through copy_raw().
|
||
// 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;
|
||
}
|
||
```
|
||
Take every quantity from `Series` (Task 3), never from the pyramid directly: `s.reduce(begin, end)` supplies exact `ymin`/`ymax`, and `s.sum(begin, end)` / `s.sumsq(begin, end)` supply the sums. Each is O(kBucket) worst case — the bucket-aligned interior comes from the pyramid, and at most two partial edge buckets (<= 2 x kBucket = 128 samples) are scanned through `copy_raw` — so a measurement never scales with series length, which is exactly what `measurement_uses_pyramid_not_scan` gates. `integral = s.sum(begin, end) * s.dx()`, `mean = sum/n`, `rms = sqrt(sumsq/n)` with `n = end - begin`, and `dx = (end - 1 - begin) * s.dx()` (0 when `end - begin < 2`). Calling the pyramid's `*_buckets` forms here would silently drop the edge samples and return wrong extrema — that is the specific mistake this paragraph exists to prevent.
|
||
|
||
`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<f32> 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<f32> 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<void(plot::Plot&, f64 dt)>;
|
||
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 <plot/plot.hpp>
|
||
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"
|
||
```
|