feat: project skeleton, core types and math with transform tests

This commit is contained in:
Martino Ferrari committed 2026-09-27 20:54:19 +02:00
1 parent e4051bc6b5
commit 8a7e9e97f8
21 files changed
+515 -2

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
BasedOnStyle: LLVM
Language: Cpp
Standard: c++20
IndentWidth: 2
ColumnLimit: 100
+2
View File
@@ -0,0 +1,2 @@
.superpowers/
build/
+21
View File
@@ -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()
+38
View File
@@ -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"
}
]
}
+33
View File
@@ -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(<target>) 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()
@@ -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<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.
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**
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <plot/core/types.hpp>
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<f32>((rgb >> 16) & 0xFFu) * kInv255,
static_cast<f32>((rgb >> 8) & 0xFFu) * kInv255,
static_cast<f32>(rgb & 0xFFu) * kInv255, alpha};
}
};
} // namespace plot
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <plot/core/types.hpp>
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
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <cstddef>
#include <span>
namespace plot {
/// `std::span` re-exported so public headers and callers share one spelling.
template <class T, std::size_t Extent = std::dynamic_extent>
using Span = std::span<T, Extent>;
} // namespace plot
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <plot/core/types.hpp>
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
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <cstddef>
#include <cstdint>
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
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <plot/core/types.hpp>
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;
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <cmath>
#include <plot/core/types.hpp>
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;
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <cmath>
#include <plot/core/types.hpp>
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;
}
+14
View File
@@ -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 <plot/core/color.hpp>
#include <plot/core/rect.hpp>
#include <plot/core/span.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/math/mat3.hpp>
#include <plot/math/transform.hpp>
#include <plot/math/vec2.hpp>
+23
View File
@@ -0,0 +1,23 @@
#include <plot/core/status.hpp>
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
+44
View File
@@ -0,0 +1,44 @@
#include <plot/math/transform.hpp>
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
+8
View File
@@ -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)
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <cstdio>
#include <cmath>
#include <chrono>
#include <algorithm>
#include <functional>
#include <string>
#include <utility>
#include <vector>
#include <plot/core/types.hpp>
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)
+3
View File
@@ -0,0 +1,3 @@
#include "harness.hpp"
int main() { return plottest::run_all(); }
+31
View File
@@ -0,0 +1,31 @@
#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);
}