diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..2014ca6 --- /dev/null +++ b/.clang-format @@ -0,0 +1,5 @@ +BasedOnStyle: LLVM +Language: Cpp +Standard: c++20 +IndentWidth: 2 +ColumnLimit: 100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd0be13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.superpowers/ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0788673 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.24) +project(plot VERSION 0.1.0 LANGUAGES CXX) + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +include(plot_options) + +add_library(plot_core + src/core/status.cpp + src/math/transform.cpp +) +add_library(plot::core ALIAS plot_core) + +target_include_directories(plot_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_compile_features(plot_core PUBLIC cxx_std_20) +target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS}) +plot_apply_warnings(plot_core) + +if(PLOT_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..605785c --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,38 @@ +{ + "version": 5, + "cmakeMinimumRequired": { + "major": 3, + "minor": 24, + "patch": 0 + }, + "configurePresets": [ + { + "name": "linux-debug", + "displayName": "Linux Debug", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/debug", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "linux-release", + "displayName": "Linux Release", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/release", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { + "name": "linux-debug", + "configurePreset": "linux-debug" + }, + { + "name": "linux-release", + "configurePreset": "linux-release" + } + ] +} diff --git a/cmake/plot_options.cmake b/cmake/plot_options.cmake new file mode 100644 index 0000000..93ef95e --- /dev/null +++ b/cmake/plot_options.cmake @@ -0,0 +1,33 @@ +# Shared build options and warning policy for the plot project. +# +# Included by the top-level CMakeLists.txt; every subdirectory target is +# expected to run plot_apply_warnings() so the core stays warning-clean +# under -Wall -Wextra -Wpedantic -Wconversion -Wshadow. + +if(CMAKE_VERSION VERSION_LESS 3.24) + message(FATAL_ERROR "plot requires CMake 3.24 or newer (found ${CMAKE_VERSION})") +endif() + +# Adapters and examples pull in third-party SDKs, so they default to OFF; the +# core library and its tests build on a bare toolchain. +option(PLOT_BUILD_TESTS "Build the plot test suite" ON) +option(PLOT_BUILD_EXAMPLES "Build the example programs" OFF) +option(PLOT_BUILD_GLFW "Build the GLFW adapter and demo" OFF) +option(PLOT_BUILD_IMGUI "Build the Dear ImGui adapter and demo" OFF) +option(PLOT_BUILD_QT6 "Build the Qt6 adapter and demo" OFF) +option(PLOT_BUILD_GTK4 "Build the GTK4 adapter and demo" OFF) + +# Apply the project warning set to a first-party target. Warnings are never +# suppressed per file. +function(plot_apply_warnings target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wshadow) + endif() +endfunction() diff --git a/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md b/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md index aebd0d6..abfd853 100644 --- a/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md +++ b/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md @@ -247,8 +247,11 @@ Implement `from_range` and `log10_positive` in `src/math/transform.cpp`. `from_r ```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) +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`. @@ -688,7 +691,7 @@ Implementation rules for `src/data/minmax_pyramid.cpp`: `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). +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** diff --git a/include/plot/core/color.hpp b/include/plot/core/color.hpp new file mode 100644 index 0000000..c25911c --- /dev/null +++ b/include/plot/core/color.hpp @@ -0,0 +1,22 @@ +#pragma once +#include + +namespace plot { + +/// RGBA colour with components in [0,1]. +struct Color { + f32 r = 0.0f; + f32 g = 0.0f; + f32 b = 0.0f; + f32 a = 1.0f; + + /// Build from a packed 0xRRGGBB literal, with an optional alpha override. + static constexpr Color hex(u32 rgb, f32 alpha = 1.0f) noexcept { + constexpr f32 kInv255 = 1.0f / 255.0f; + return Color{static_cast((rgb >> 16) & 0xFFu) * kInv255, + static_cast((rgb >> 8) & 0xFFu) * kInv255, + static_cast(rgb & 0xFFu) * kInv255, alpha}; + } +}; + +} // namespace plot diff --git a/include/plot/core/rect.hpp b/include/plot/core/rect.hpp new file mode 100644 index 0000000..1fe2d84 --- /dev/null +++ b/include/plot/core/rect.hpp @@ -0,0 +1,22 @@ +#pragma once +#include + +namespace plot { + +/// Axis-aligned rectangle in pixels, origin at the top-left. +struct Rect { + f32 x = 0.0f; + f32 y = 0.0f; + f32 w = 0.0f; + f32 h = 0.0f; + + constexpr f32 right() const noexcept { return x + w; } + constexpr f32 bottom() const noexcept { return y + h; } + + /// Half-open containment: the right/bottom edges are outside the rect. + constexpr bool contains(f32 px, f32 py) const noexcept { + return px >= x && px < right() && py >= y && py < bottom(); + } +}; + +} // namespace plot diff --git a/include/plot/core/span.hpp b/include/plot/core/span.hpp new file mode 100644 index 0000000..fec04c1 --- /dev/null +++ b/include/plot/core/span.hpp @@ -0,0 +1,11 @@ +#pragma once +#include +#include + +namespace plot { + +/// `std::span` re-exported so public headers and callers share one spelling. +template +using Span = std::span; + +} // namespace plot diff --git a/include/plot/core/status.hpp b/include/plot/core/status.hpp new file mode 100644 index 0000000..043ffa8 --- /dev/null +++ b/include/plot/core/status.hpp @@ -0,0 +1,26 @@ +#pragma once +#include + +namespace plot { + +enum class ErrorCode { + Ok, + InvalidArgument, + OutOfMemory, + GLFailure, + Unsupported, + NotImplemented, +}; + +/// Never-null human-readable name of an error code. +const char* to_string(ErrorCode code) noexcept; + +/// Result of a fallible operation. Falsy unless `code == ErrorCode::Ok`. +struct Status { + ErrorCode code = ErrorCode::Ok; + const char* message = ""; + + constexpr explicit operator bool() const noexcept { return code == ErrorCode::Ok; } +}; + +} // namespace plot diff --git a/include/plot/core/types.hpp b/include/plot/core/types.hpp new file mode 100644 index 0000000..321011c --- /dev/null +++ b/include/plot/core/types.hpp @@ -0,0 +1,26 @@ +#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; + +/// Sentinel for "no index", e.g. an unmatched cursor id. +constexpr u64 kInvalid = ~u64{0}; + +/// Closed numeric interval on one axis. +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 diff --git a/include/plot/math/mat3.hpp b/include/plot/math/mat3.hpp new file mode 100644 index 0000000..73256cd --- /dev/null +++ b/include/plot/math/mat3.hpp @@ -0,0 +1,31 @@ +#pragma once +#include + +namespace plot::math { + +/// Column-major 3x3 matrix of `f32`, the layout OpenGL expects for `mat3` +/// uniforms. +struct Mat3 { + f32 m[9] = {1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f}; + + static constexpr Mat3 identity() noexcept { return Mat3{}; } + + /// Column-major product `(*this) * rhs`. + constexpr Mat3 operator*(const Mat3& rhs) const noexcept { + Mat3 out{0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; + for (usize col = 0; col < 3; ++col) { + for (usize row = 0; row < 3; ++row) { + f32 acc = 0.0f; + for (usize k = 0; k < 3; ++k) acc += m[k * 3 + row] * rhs.m[col * 3 + k]; + out.m[col * 3 + row] = acc; + } + } + return out; + } +}; + +} // namespace plot::math + +namespace plot { +using math::Mat3; +} diff --git a/include/plot/math/transform.hpp b/include/plot/math/transform.hpp new file mode 100644 index 0000000..1d540cb --- /dev/null +++ b/include/plot/math/transform.hpp @@ -0,0 +1,48 @@ +#pragma once +#include +#include + +namespace plot::math { + +/// Affine data->screen mapping: `screen = data * s + t`, applied after `log10` +/// when the corresponding log flag is set. +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 screen_x) const noexcept { + const f64 v = (screen_x - tx) / sx; + return log_x ? std::pow(10.0, v) : v; + } + + f64 to_data_y(f64 screen_y) const noexcept { + const f64 v = (screen_y - ty) / sy; + return log_y ? std::pow(10.0, v) : v; + } + + /// Set scale/offset so `x` and `y` map onto `[px0,px1]` and `[py0,py1]`. + /// A degenerate range is widened by +/-0.5 in the (possibly logarithmic) + /// coordinate so the resulting mapping always stays invertible. + static Transform from_range(Range x, Range y, f64 px0, f64 px1, f64 py0, f64 py1, bool lx, + bool ly) noexcept; + + /// `log10` of `v`, with `v` clamped to at least 1e-300 first. + static f64 log10_positive(f64 v) noexcept; +}; + +} // namespace plot::math + +namespace plot { +using math::Transform; +} diff --git a/include/plot/math/vec2.hpp b/include/plot/math/vec2.hpp new file mode 100644 index 0000000..e144dc1 --- /dev/null +++ b/include/plot/math/vec2.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include + +namespace plot::math { + +/// 2D vector of `f32`, used for pixel offsets and normalised device coords. +struct Vec2 { + f32 x = 0.0f; + f32 y = 0.0f; + + constexpr Vec2 operator+(const Vec2& o) const noexcept { return {x + o.x, y + o.y}; } + constexpr Vec2 operator-(const Vec2& o) const noexcept { return {x - o.x, y - o.y}; } + constexpr Vec2 operator*(f32 s) const noexcept { return {x * s, y * s}; } + constexpr Vec2 operator/(f32 s) const noexcept { return {x / s, y / s}; } + + constexpr f32 dot(const Vec2& o) const noexcept { return x * o.x + y * o.y; } + + f32 length() const noexcept { return std::sqrt(x * x + y * y); } +}; + +constexpr Vec2 operator*(f32 s, const Vec2& v) noexcept { return v * s; } + +} // namespace plot::math + +namespace plot { +using math::Vec2; +} diff --git a/include/plot/plot.hpp b/include/plot/plot.hpp new file mode 100644 index 0000000..9d0dba4 --- /dev/null +++ b/include/plot/plot.hpp @@ -0,0 +1,14 @@ +#pragma once + +/// Umbrella header: pulls in every public component of the plot library. +/// Later layers (gl, data, render, plot, input) are added here as they land. + +#include +#include +#include +#include +#include + +#include +#include +#include diff --git a/src/core/status.cpp b/src/core/status.cpp new file mode 100644 index 0000000..d70432c --- /dev/null +++ b/src/core/status.cpp @@ -0,0 +1,23 @@ +#include + +namespace plot { + +const char* to_string(ErrorCode code) noexcept { + switch (code) { + case ErrorCode::Ok: + return "Ok"; + case ErrorCode::InvalidArgument: + return "InvalidArgument"; + case ErrorCode::OutOfMemory: + return "OutOfMemory"; + case ErrorCode::GLFailure: + return "GLFailure"; + case ErrorCode::Unsupported: + return "Unsupported"; + case ErrorCode::NotImplemented: + return "NotImplemented"; + } + return "Unknown"; +} + +} // namespace plot diff --git a/src/math/transform.cpp b/src/math/transform.cpp new file mode 100644 index 0000000..e98bff1 --- /dev/null +++ b/src/math/transform.cpp @@ -0,0 +1,44 @@ +#include + +namespace plot::math { + +namespace { + +/// Resolve the endpoints of one axis in transform space, widening a degenerate +/// range by +/-0.5 so the mapping stays invertible. +void axis_ends(Range r, bool log, f64& lo, f64& hi) noexcept { + lo = log ? Transform::log10_positive(r.min) : r.min; + hi = log ? Transform::log10_positive(r.max) : r.max; + if (!(hi > lo)) { + lo -= 0.5; + hi += 0.5; + } +} + +} // namespace + +f64 Transform::log10_positive(f64 v) noexcept { + constexpr f64 kMin = 1e-300; + return std::log10(v < kMin ? kMin : v); +} + +Transform Transform::from_range(Range x, Range y, f64 px0, f64 px1, f64 py0, f64 py1, bool lx, + bool ly) noexcept { + Transform t; + t.log_x = lx; + t.log_y = ly; + + f64 x_lo = 0.0, x_hi = 0.0; + axis_ends(x, lx, x_lo, x_hi); + t.sx = (px1 - px0) / (x_hi - x_lo); + t.tx = px0 - x_lo * t.sx; + + f64 y_lo = 0.0, y_hi = 0.0; + axis_ends(y, ly, y_lo, y_hi); + t.sy = (py1 - py0) / (y_hi - y_lo); + t.ty = py0 - y_lo * t.sy; + + return t; +} + +} // namespace plot::math diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..3715056 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,8 @@ +add_executable(plot_tests + main.cpp + test_transform.cpp +) +target_link_libraries(plot_tests PRIVATE plot::core) +plot_apply_warnings(plot_tests) + +add_test(NAME plot_tests COMMAND plot_tests) diff --git a/tests/harness.hpp b/tests/harness.hpp new file mode 100644 index 0000000..5a87ba0 --- /dev/null +++ b/tests/harness.hpp @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#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) diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 0000000..d9f0b7b --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,3 @@ +#include "harness.hpp" + +int main() { return plottest::run_all(); } diff --git a/tests/test_transform.cpp b/tests/test_transform.cpp new file mode 100644 index 0000000..1180a34 --- /dev/null +++ b/tests/test_transform.cpp @@ -0,0 +1,31 @@ +#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); +}