feat: embeddable OpenGL time-series plotting library (plot)

This commit is contained in:
Martino Ferrari committed 2026-09-28 08:20:05 +02:00
commit d110bfd459
110 files changed
+17856 -72

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
BasedOnStyle: LLVM
Language: Cpp
Standard: c++20
IndentWidth: 2
ColumnLimit: 100
+5
View File
@@ -0,0 +1,5 @@
.superpowers/
build/
# Not this project's and not generated by it: a Solarus game-engine message that
# happens to sit in the repo root.
error.txt
+140
View File
@@ -0,0 +1,140 @@
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
src/data/minmax_pyramid.cpp
src/data/series.cpp
src/data/array_series.cpp
src/data/stream_series.cpp
src/gl/loader.cpp
src/gl/handle.cpp
src/render/shaders.cpp
src/render/bitmap_font.cpp
src/render/primitives.cpp
src/render/buffer.cpp
src/render/vertex_array.cpp
src/render/program.cpp
src/render/renderer.cpp
src/plot/ticks.cpp
src/plot/axis.cpp
src/plot/grid.cpp
src/plot/measure.cpp
src/plot/plot.cpp
src/input/controller.cpp
)
add_library(plot::core ALIAS plot_core)
# What the target is called in an installed tree. Without this the export set is
# namespaced by the target's own name (plot::plot_core), while the build tree --
# and every consumer, and this project's own comments -- says plot::core.
set_target_properties(plot_core PROPERTIES EXPORT_NAME core)
target_include_directories(plot_core PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
target_compile_features(plot_core PUBLIC cxx_std_20)
target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
plot_apply_warnings(plot_core)
# Adapters own the surface and translate their host's events; examples link the
# adapter they demo. Both are opt-in, so the core library and its tests still build
# on a bare toolchain.
#
# They are added before the test suite on purpose: a test that drives an adapter
# needs that adapter's targets to exist, and with both switches on the suite can
# then exercise them (see tests/CMakeLists.txt).
if(PLOT_BUILD_GLFW)
add_subdirectory(adapters/glfw)
endif()
if(PLOT_BUILD_IMGUI)
add_subdirectory(adapters/imgui)
endif()
if(PLOT_BUILD_QT6)
add_subdirectory(adapters/qt6)
endif()
if(PLOT_BUILD_GTK4)
add_subdirectory(adapters/gtk4)
endif()
if(PLOT_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
if(PLOT_BUILD_TESTS)
# Test-only: counts the pyramid's covering-decomposition entries and its bucket
# merges so a test can pin the query's O(log n) cost and the append path's
# amortised O(1) cost, neither of which any answer reveals. Off in every
# non-test build (see include/plot/data/detail/{cover,merge}_count.hpp).
#
# PLOT_RING_STATS is the same for the persistent-mapped instance ring: the
# properties a frame must hold there -- no slot written twice within a frame,
# and one fence wait at most, at the start of the frame -- are invisible in the
# pixels, so a test reads counters instead (see
# include/plot/render/detail/ring_stats.hpp).
#
# PLOT_COUNT_MEASURE_RECOMPUTES is the same for the measurement cache: whether
# `Plot::measurement()` recomputed or returned a cached result is invisible in
# the value it returns, so a test that has to tell a live cache from one frozen
# on a stale window counts re-evaluations (see
# include/plot/plot/detail/measure_count.hpp).
target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS
PLOT_PYRAMID_COUNT_MERGES
PLOT_RING_STATS
PLOT_COUNT_MEASURE_RECOMPUTES)
enable_testing()
add_subdirectory(tests)
endif()
# Install and export. The core always goes in; each adapter joins the set only if
# its toolkit can be re-found on a consumer's machine, which the adapter decided
# when it was added (plot_record_adapter_export() -- the decisions and the reasons
# are in the configure log above). plotConfig.cmake then re-finds exactly those
# toolkits before including the targets file the adapters are described in, so
# find_package(plot) + target_link_libraries(consumer PRIVATE plot::core) works
# from an installed prefix with no -I or -L of the consumer's own.
if(PLOT_INSTALL)
# The adapters that could be promised to a consumer, in the order they were
# added, and the find_dependency() calls that re-create their toolkits there.
get_property(PLOT_ADAPTER_EXPORT_TARGETS GLOBAL PROPERTY PLOT_ADAPTER_EXPORT_TARGETS)
# Substituted into plotConfig.cmake below (@PLOT_ADAPTER_DEPENDENCIES@).
get_property(PLOT_ADAPTER_DEPENDENCIES GLOBAL PROPERTY PLOT_ADAPTER_DEPENDENCIES)
include(CMakePackageConfigHelpers)
# No INCLUDES DESTINATION here: every target already declares
# $<INSTALL_INTERFACE:include> in its own target_include_directories(), and
# asking for it twice puts the same directory in the export twice.
install(TARGETS plot_core ${PLOT_ADAPTER_EXPORT_TARGETS}
EXPORT plotTargets
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin)
install(DIRECTORY include/plot DESTINATION include)
install(EXPORT plotTargets
NAMESPACE plot::
FILE plotTargets.cmake
DESTINATION lib/cmake/plot)
configure_package_config_file(
cmake/plotConfig.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake"
INSTALL_DESTINATION lib/cmake/plot)
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake"
DESTINATION lib/cmake/plot)
endif()
+68
View File
@@ -0,0 +1,68 @@
{
"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",
"PLOT_BUILD_GLFW": true,
"PLOT_BUILD_IMGUI": true,
"PLOT_BUILD_EXAMPLES": true,
"PLOT_BUILD_QT6": true,
"PLOT_BUILD_GTK4": true
}
},
{
"name": "linux-release",
"displayName": "Linux Release",
"description": "Release artifact for install: PLOT_BUILD_TESTS is OFF so the installed libplot_core.a carries none of the test-only counter instrumentation (PLOT_PYRAMID_COUNT_*, PLOT_RING_STATS). The suite runs in linux-debug and linux-asan.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"PLOT_BUILD_TESTS": false,
"PLOT_BUILD_GLFW": true,
"PLOT_BUILD_IMGUI": true,
"PLOT_BUILD_EXAMPLES": true,
"PLOT_BUILD_QT6": true,
"PLOT_BUILD_GTK4": true
}
},
{
"name": "linux-asan",
"displayName": "Linux Debug (AddressSanitizer)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/asan",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"PLOT_SANITIZE": "address",
"PLOT_BUILD_GLFW": true,
"PLOT_BUILD_IMGUI": true,
"PLOT_BUILD_QT6": true,
"PLOT_BUILD_GTK4": true
}
}
],
"buildPresets": [
{
"name": "linux-debug",
"configurePreset": "linux-debug"
},
{
"name": "linux-release",
"configurePreset": "linux-release"
},
{
"name": "linux-asan",
"configurePreset": "linux-asan"
}
]
}
+43
View File
@@ -0,0 +1,43 @@
# The GLFW adapter: the reference host for the library. It owns the window, the
# context and the loop, and translates GLFW's events into plot::input events.
#
# GLFW is resolved by the shared plot_find_glfw() (cmake/plot_options.cmake), so a
# build that also enables the Dear ImGui adapter -- which needs GLFW for
# imgui_impl_glfw -- discovers (and at worst fetches) a single copy.
plot_find_glfw(plot_glfw_dep)
add_library(plot_glfw STATIC glfw_host.cpp)
add_library(plot::glfw ALIAS plot_glfw)
# The installed tree names it plot::glfw, like the build tree (see plot_core's).
set_target_properties(plot_glfw PROPERTIES EXPORT_NAME glfw)
# The public include root is `adapters/`, so the header is reached as
# <glfw/glfw_host.hpp> -- the toolkit's name first, like every other adapter's.
# The build interface is that root; the install one is the prefix's `include`,
# where glfw_host.hpp is installed as <glfw/glfw_host.hpp>, the same spelling.
target_include_directories(plot_glfw PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/..>
$<INSTALL_INTERFACE:include>)
# GLFW stays PRIVATE: the header forward-declares GLFWwindow and includes no GLFW
# header, so a consumer of plot::glfw does not inherit the toolkit.
target_link_libraries(plot_glfw PUBLIC plot::core PRIVATE ${plot_glfw_dep})
plot_apply_warnings(plot_glfw)
# PLOT_GLFW_DEP is either a target find_package()/pkg-config created or one
# FetchContent built. The first two can be promised to a consumer -- the installed
# config file re-runs the very same discovery -- but a fetched GLFW cannot: there
# is no glfw3Config.cmake on the consumer's machine to find, so exporting this
# adapter would hand out a target its link interface cannot satisfy. In that case
# the adapter is not installed at all.
plot_glfw_dependency_snippet(plot_glfw_dependency)
if(PLOT_GLFW_DEP_ORIGIN STREQUAL "FetchContent")
plot_record_adapter_export(plot_glfw FALSE
"GLFW was fetched with FetchContent, so a consumer's find_package(plot) cannot re-find it" "")
else()
plot_record_adapter_export(plot_glfw TRUE
"GLFW was resolved by ${PLOT_GLFW_DEP_ORIGIN}" "${plot_glfw_dependency}")
if(PLOT_INSTALL)
install(FILES glfw_host.hpp DESTINATION include/glfw)
endif()
endif()
+367
View File
@@ -0,0 +1,367 @@
#include <glfw/glfw_host.hpp>
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
// (GL_COLOR_BUFFER_BIT and friends) would rewrite `plot::gl`'s enumerators of the
// same name. The adapter needs GLFW for the window, the context and the events
// only -- every GL entry point comes from plot::gl::load().
#define GLFW_INCLUDE_NONE
#include <GLFW/glfw3.h>
#include <plot/gl/loader.hpp>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace plot::glfw {
namespace {
/// GLFW_MOD_* and `plot::input::Mod` are the same bits, by design: the
/// controller documents `Mod` as the host-translatable mask. A key event's mask
/// therefore passes through, and only a pointer or wheel event, whose callbacks
/// carry no mask, has to ask the window for the current key state.
static_assert(static_cast<u32>(GLFW_MOD_SHIFT) == static_cast<u32>(input::Mod::Shift));
static_assert(static_cast<u32>(GLFW_MOD_CONTROL) == static_cast<u32>(input::Mod::Ctrl));
static_assert(static_cast<u32>(GLFW_MOD_ALT) == static_cast<u32>(input::Mod::Alt));
static_assert(static_cast<u32>(GLFW_MOD_SUPER) == static_cast<u32>(input::Mod::Super));
u32 mods_now(GLFWwindow* w) noexcept {
u32 m = 0;
if (glfwGetKey(w, GLFW_KEY_LEFT_SHIFT) == GLFW_PRESS ||
glfwGetKey(w, GLFW_KEY_RIGHT_SHIFT) == GLFW_PRESS) {
m |= GLFW_MOD_SHIFT;
}
if (glfwGetKey(w, GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS ||
glfwGetKey(w, GLFW_KEY_RIGHT_CONTROL) == GLFW_PRESS) {
m |= GLFW_MOD_CONTROL;
}
if (glfwGetKey(w, GLFW_KEY_LEFT_ALT) == GLFW_PRESS ||
glfwGetKey(w, GLFW_KEY_RIGHT_ALT) == GLFW_PRESS) {
m |= GLFW_MOD_ALT;
}
if (glfwGetKey(w, GLFW_KEY_LEFT_SUPER) == GLFW_PRESS ||
glfwGetKey(w, GLFW_KEY_RIGHT_SUPER) == GLFW_PRESS) {
m |= GLFW_MOD_SUPER;
}
return m;
}
bool any_button_down(GLFWwindow* w) noexcept {
return glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS ||
glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_RIGHT) == GLFW_PRESS ||
glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_MIDDLE) == GLFW_PRESS;
}
/// `glfwGetProcAddress` returns GLFW's `void (*)(void)`, `plot::gl::ProcLoader`
/// returns `void*`: a function pointer respelled as an object pointer. POSIX
/// guarantees the two have the same representation, and the bit copy states that
/// where a `reinterpret_cast` would be merely conditionally supported.
void* resolve_gl(const char* name) noexcept {
GLFWglproc p = glfwGetProcAddress(name);
void* out = nullptr;
static_assert(sizeof(out) == sizeof(p), "function and object pointers must match");
std::memcpy(&out, &p, sizeof(out));
return out;
}
bool fail(Status* st, ErrorCode code, const char* message) noexcept {
if (st != nullptr) {
st->code = code;
st->message = message;
}
return false;
}
/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not
/// positive, which is "run until the window is closed".
f64 exit_after_seconds() noexcept {
const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS");
if (v == nullptr || v[0] == '\0') return 0.0;
char* end = nullptr;
const f64 ms = std::strtod(v, &end);
if (end == v || !(ms > 0.0)) return 0.0;
return ms * 1.0e-3;
}
} // namespace
Host::Host() noexcept : controller_(plot_) {}
Host::~Host() { close(); }
void Host::close() noexcept {
// The renderer's RAII handles call glDelete* from their destructors, so the
// context has to be current -- and alive -- when they run.
if (renderer_) {
if (window_ != nullptr) glfwMakeContextCurrent(window_);
renderer_.reset();
}
if (window_ != nullptr) {
glfwDestroyWindow(window_);
window_ = nullptr;
}
glfwMakeContextCurrent(nullptr);
glfwTerminate();
have_pointer_ = false;
// A closed host reports no size: a second run() must not see the previous
// window's framebuffer size before its first refresh_sizes().
fb_width_ = 0;
fb_height_ = 0;
fb_scale_x_ = 1.0f;
fb_scale_y_ = 1.0f;
}
bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
if (st != nullptr) *st = Status{};
close(); // a second run() starts from a clean window
if (glfwInit() != GLFW_TRUE) {
return fail(st, ErrorCode::Unsupported, "glfwInit() failed");
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
// Required on macOS 10.7+, ignored elsewhere.
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
// A run with a deadline is the automated path, so it stays off-screen: a
// bounded CI run must never flash a window on a real display.
const f64 exit_after_s = exit_after_seconds();
if (exit_after_s > 0.0) glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
GLFWwindow* w = glfwCreateWindow(cfg.width > 0 ? cfg.width : 1,
cfg.height > 0 ? cfg.height : 1,
cfg.title != nullptr ? cfg.title : "plot", nullptr, nullptr);
if (w == nullptr) {
const char* desc = nullptr;
(void)glfwGetError(&desc);
std::fprintf(stderr, "plot::glfw: glfwCreateWindow() failed: %s\n",
desc != nullptr ? desc : "no error description");
close();
return fail(st, ErrorCode::Unsupported,
"glfwCreateWindow() failed: no OpenGL 3.3 core context (see stderr)");
}
window_ = w;
glfwSetWindowUserPointer(w, this);
glfwMakeContextCurrent(w);
glfwSwapInterval(cfg.vsync ? 1 : 0);
glfwSetCursorPosCallback(w, &Host::cursor_pos_cb);
glfwSetMouseButtonCallback(w, &Host::mouse_button_cb);
glfwSetScrollCallback(w, &Host::scroll_cb);
glfwSetKeyCallback(w, &Host::key_cb);
glfwSetFramebufferSizeCallback(w, &Host::framebuffer_size_cb);
refresh_sizes();
if (!plot::gl::load(&resolve_gl)) {
close();
return fail(st, ErrorCode::Unsupported,
"plot::gl::load() failed: the context is missing a required OpenGL 3.3 entry point");
}
renderer_.emplace();
if (!renderer_->init(st)) {
close();
if (st != nullptr && st->code == ErrorCode::Ok) {
*st = Status{ErrorCode::GLFailure, "Renderer::init() failed"};
}
return false;
}
const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT");
const f64 start = glfwGetTime();
f64 previous = start;
while (glfwWindowShouldClose(window_) == GLFW_FALSE) {
glfwPollEvents();
refresh_sizes();
if (fb_width_ == 0 || fb_height_ == 0) {
// Minimised, or a size the compositor reports as zero: nothing to draw
// into, and nothing to hand `begin_frame` -- a stale larger size would be
// a lie about the framebuffer. Idle on events rather than spin, and still
// honour the exit deadline here: a bounded run that never gets a drawable
// must end, not idle past the deadline it was given. There is no frame to
// dump in this state, so the screenshot is skipped.
if (exit_after_s > 0.0 && glfwGetTime() - start >= exit_after_s) {
std::fprintf(stderr,
"[glfw] exit deadline reached with no drawable (framebuffer 0x0);"
" nothing to dump\n");
break;
}
glfwWaitEventsTimeout(0.05);
continue;
}
// The plot's region is the whole window here, which is what the controller
// and `begin_frame` must agree on: one rect, in framebuffer pixels, set
// before the frame callback runs so it sees the layout it draws into.
const Rect viewport{0.0f, 0.0f, static_cast<f32>(fb_width_), static_cast<f32>(fb_height_)};
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
const f64 now = glfwGetTime();
const f64 dt = now - previous;
previous = now;
if (frame) frame(plot_, dt);
const bool exiting = exit_after_s > 0.0 && (now - start) >= exit_after_s;
renderer_->begin_frame(fb_width_, fb_height_, viewport);
renderer_->draw(plot_);
if (exiting && screenshot != nullptr && screenshot[0] != '\0') {
Status shot;
const bool dumped = renderer_->dump_frame_ppm(screenshot, &shot);
std::printf("[glfw] screenshot %s: %s\n", screenshot,
dumped ? "written" : shot.message);
std::fflush(stdout);
}
renderer_->end_frame();
glfwSwapBuffers(window_);
if (exiting) break;
}
return true;
}
void Host::set_title(const char* title) noexcept {
if (window_ != nullptr) glfwSetWindowTitle(window_, title != nullptr ? title : "");
}
void Host::refresh_sizes() noexcept {
if (window_ == nullptr) return;
int fw = 0;
int fh = 0;
int ww = 0;
int wh = 0;
glfwGetFramebufferSize(window_, &fw, &fh);
glfwGetWindowSize(window_, &ww, &wh);
float sx = 1.0f;
float sy = 1.0f;
glfwGetWindowContentScale(window_, &sx, &sy);
content_scale_x_ = sx;
content_scale_y_ = sy;
// Assigned unconditionally, zero included: an iconified window really does
// report a 0x0 framebuffer (Windows, and several Linux window managers), and
// the loop's no-drawable guard reads exactly this. Only ever writing a
// positive size would leave the guard unreachable and keep handing
// `begin_frame` a stale size for a framebuffer that is no longer that big.
fb_width_ = fw > 0 ? static_cast<u32>(fw) : 0u;
fb_height_ = fh > 0 ? static_cast<u32>(fh) : 0u;
if (ww > 0 && fw > 0) fb_scale_x_ = static_cast<f32>(fw) / static_cast<f32>(ww);
if (wh > 0 && fh > 0) fb_scale_y_ = static_cast<f32>(fh) / static_cast<f32>(wh);
}
// ------------------------------------------------------------------ events
Host* Host::from(GLFWwindow* w) noexcept {
return w != nullptr ? static_cast<Host*>(glfwGetWindowUserPointer(w)) : nullptr;
}
void Host::cursor_pos_cb(GLFWwindow* w, double x, double y) {
if (Host* h = from(w)) h->on_cursor_pos(x, y);
}
void Host::mouse_button_cb(GLFWwindow* w, int button, int action, int mods) {
if (Host* h = from(w)) h->on_mouse_button(button, action, mods);
}
void Host::scroll_cb(GLFWwindow* w, double dx, double dy) {
if (Host* h = from(w)) h->on_scroll(dx, dy);
}
void Host::key_cb(GLFWwindow* w, int key, int scancode, int action, int mods) {
(void)scancode;
if (Host* h = from(w)) h->on_key(key, action, mods);
}
void Host::framebuffer_size_cb(GLFWwindow* w, int width, int height) {
(void)width;
(void)height;
if (Host* h = from(w)) h->refresh_sizes();
}
void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
// Window coordinates -> framebuffer pixels: the same translation the viewport
// and every other Rect here use, so a gesture lands where it was aimed.
const f32 x = static_cast<f32>(wx) * fb_scale_x_;
const f32 y = static_cast<f32>(wy) * fb_scale_y_;
input::PointerEvent e;
e.x = x;
e.y = y;
if (have_pointer_) {
e.dx = x - last_pointer_x_;
e.dy = y - last_pointer_y_;
}
last_pointer_x_ = x;
last_pointer_y_ = y;
have_pointer_ = true;
e.mods = mods_now(window_);
e.down = any_button_down(window_);
controller_.on_pointer(e);
}
void Host::on_mouse_button(int button, int action, int mods) noexcept {
input::Button b;
switch (button) {
case GLFW_MOUSE_BUTTON_LEFT: b = input::Button::Left; break;
case GLFW_MOUSE_BUTTON_MIDDLE: b = input::Button::Middle; break;
case GLFW_MOUSE_BUTTON_RIGHT: b = input::Button::Right; break;
default: return; // the controller has no gesture for the auxiliary buttons
}
double wx = 0.0;
double wy = 0.0;
glfwGetCursorPos(window_, &wx, &wy);
input::PointerEvent e;
e.x = static_cast<f32>(wx) * fb_scale_x_;
e.y = static_cast<f32>(wy) * fb_scale_y_;
// The press/release is also a pointer sample: the next move's delta starts
// here, or a drag's first motion would include the jump to the click.
last_pointer_x_ = e.x;
last_pointer_y_ = e.y;
have_pointer_ = true;
e.button = b;
e.mods = static_cast<u32>(mods);
e.pressed = action == GLFW_PRESS;
e.released = action == GLFW_RELEASE;
e.down = any_button_down(window_);
controller_.on_pointer(e);
}
void Host::on_scroll(f64 dx, f64 dy) noexcept {
double wx = 0.0;
double wy = 0.0;
glfwGetCursorPos(window_, &wx, &wy);
input::WheelEvent e;
e.x = static_cast<f32>(wx) * fb_scale_x_;
e.y = static_cast<f32>(wy) * fb_scale_y_;
e.dx = static_cast<f32>(dx);
e.dy = static_cast<f32>(dy); // GLFW's positive y (scroll up) is the zoom-in sign
e.mods = mods_now(window_);
controller_.on_wheel(e);
}
void Host::on_key(int key, int action, int mods) noexcept {
int mapped = 0;
switch (key) {
// The controller zooms on '+', which on most layouts is shift+'='; the '='
// key is mapped to it as well, so both spellings zoom in.
case GLFW_KEY_EQUAL:
case GLFW_KEY_KP_ADD: mapped = input::kKeyZoomIn; break;
case GLFW_KEY_MINUS:
case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break;
case GLFW_KEY_F: mapped = input::kKeyFit; break;
case GLFW_KEY_ESCAPE:
if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE);
return;
default: return;
}
input::KeyEvent e;
e.key = mapped;
e.mods = static_cast<u32>(mods);
// GLFW reports auto-repeat as its own action; a held key keeps acting, exactly
// as holding the key on a keyboard does.
e.down = action != GLFW_RELEASE;
controller_.on_key(e);
}
} // namespace plot::glfw
+154
View File
@@ -0,0 +1,154 @@
#pragma once
#include <functional>
#include <optional>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
/// GLFW's window type. Forward-declared on purpose: this header is
/// self-contained and needs no GLFW include directory, so a consumer of the
/// adapter does not inherit the toolkit's headers (and their `<GL/gl.h>`, whose
/// macros would rewrite this library's enumerators).
struct GLFWwindow;
namespace plot::glfw {
/// What `Host::run()` opens the window with.
struct RunConfig {
int width = 1280;
int height = 720;
const char* title = "plot";
bool vsync = true;
};
/// One frame of application code, called by `Host::run()` after the viewport is
/// set and before the frame is staged, so it is where data is refreshed.
/// `dt` is the wall time since the previous call, in seconds.
using FrameFn = std::function<void(plot::Plot&, f64 dt)>;
/// The reference host: owns the GLFW window and its OpenGL 3.3 core context, the
/// GL entry points, the `Renderer`, the `Plot`, the `input::Controller` and the
/// frame loop, so nothing above it names GLFW or GL.
///
/// Per-frame sequence, in this order:
///
/// poll events -> controller.set_viewport(framebuffer-pixel rect) ->
/// frame(plot, dt) -> begin_frame -> draw -> [screenshot] -> end_frame ->
/// swap buffers
///
/// Two pixel spaces meet here and must not be confused. GLFW reports the cursor
/// and the window in *window* coordinates, while the framebuffer -- what
/// `begin_frame` takes and what every `Rect` in this library speaks -- can be
/// larger under a fractional or HiDPI scale, so `glfwGetFramebufferSize()` and
/// `glfwGetWindowSize()` disagree. The host calls `begin_frame` with the
/// framebuffer size and scales every pointer coordinate by the ratio
/// `framebuffer / window`, so a gesture lands on the pixels the plot was laid out
/// in. On an unscaled display the ratio is 1.0 and nothing changes.
///
/// Headless smoke mode, read once per `run()`:
///
/// `PLOT_DEMO_EXIT_AFTER_MS=<n>` stop the loop after n ms and return
/// (default 0: run until the window is closed)
/// `PLOT_DEMO_SCREENSHOT=<path>` on that final frame, write it with
/// `Renderer::dump_frame_ppm()` before the swap
///
/// That is what lets a CI run a GUI library under `xvfb-run`: no display access
/// and no screen capture, just a PPM on disk.
///
/// A run with a deadline is that automated path, so its window is created hidden
/// (`GLFW_VISIBLE` false): an automated run must not flash a window on whatever
/// display it lands on. It also means the loop is not throttled by a display
/// refresh, so the frame times it reports are the CPU's, not a vblank's. A run
/// without a deadline shows its window, as an interactive application must.
///
/// Not thread-safe, and confined to the thread that runs `run()`.
class Host {
public:
Host() noexcept;
~Host();
Host(const Host&) = delete;
Host& operator=(const Host&) = delete;
/// Opens the window, makes a 3.3 core context current, resolves the GL entry
/// points and initialises the renderer, then runs the loop until the window is
/// closed or the exit deadline passes. Returns false with `st` filled in when
/// GLFW, the context or `Renderer::init()` fails; a failed run leaves nothing
/// open.
///
/// On success the window *stays open*, so a caller can stop and join whatever
/// feeds the plot before the GL resources go away: `close()` (or the
/// destructor) releases the renderer, the context and the window, in that
/// order, on this thread. Calling `run()` again reopens the window and keeps
/// the plot.
bool run(const RunConfig& cfg, const FrameFn& frame, Status* st = nullptr);
/// Releases the renderer and the window. Idempotent, and safe on a host that
/// never ran.
void close() noexcept;
/// The plot the frame callback populates. Valid for the host's whole life.
Plot& plot() noexcept { return plot_; }
/// The gesture controller the host feeds. Valid for the host's whole life.
input::Controller& controller() noexcept { return controller_; }
/// The renderer, or null while no window is open. Valid from inside the frame
/// callback and after a successful `run()`; `close()` releases it.
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
/// Retitles the open window -- from the frame callback, for a live
/// samples/second and frame-time readout. A no-op while no window is open.
void set_title(const char* title) noexcept;
/// Framebuffer pixels per window pixel, one ratio per axis, as of the last
/// event or frame. 1.0 on an unscaled display.
f32 framebuffer_scale_x() const noexcept { return fb_scale_x_; }
f32 framebuffer_scale_y() const noexcept { return fb_scale_y_; }
/// The window's content scale, one per axis, as of the last frame.
f32 content_scale_x() const noexcept { return content_scale_x_; }
f32 content_scale_y() const noexcept { return content_scale_y_; }
private:
/// Reads the framebuffer and window sizes and the content scale, refreshing
/// the window -> framebuffer ratios the pointer translation uses.
void refresh_sizes() noexcept;
/// Callbacks find their host through GLFW's per-window user pointer.
static Host* from(GLFWwindow* w) noexcept;
static void cursor_pos_cb(GLFWwindow* w, double x, double y);
static void mouse_button_cb(GLFWwindow* w, int button, int action, int mods);
static void scroll_cb(GLFWwindow* w, double dx, double dy);
static void key_cb(GLFWwindow* w, int key, int scancode, int action, int mods);
static void framebuffer_size_cb(GLFWwindow* w, int width, int height);
void on_cursor_pos(f64 wx, f64 wy) noexcept;
void on_mouse_button(int button, int action, int mods) noexcept;
void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept;
Plot plot_;
input::Controller controller_; ///< forwards its viewport to `plot_`
std::optional<Renderer> renderer_;
GLFWwindow* window_ = nullptr;
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
u32 fb_height_ = 0;
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports
/// rather than from the content scale, which under a fractional scale does not
/// round back to the framebuffer size.
f32 fb_scale_x_ = 1.0f;
f32 fb_scale_y_ = 1.0f;
f32 content_scale_x_ = 1.0f;
f32 content_scale_y_ = 1.0f;
/// Last pointer position in framebuffer pixels: the deltas the controller pans
/// with are the movements since the previous event, which GLFW does not report.
f32 last_pointer_x_ = 0.0f;
f32 last_pointer_y_ = 0.0f;
bool have_pointer_ = false;
};
} // namespace plot::glfw
+54
View File
@@ -0,0 +1,54 @@
# The GTK4 adapter: a GtkGLArea whose own framebuffer the plot is rendered into
# and driven from GTK's gestures and event controllers. GTK is a C library whose
# GL widget cannot be subclassed from C++ (`G_DEFINE_TYPE` declares the type's
# instance and class structs), so the adapter is a factory that builds an instance
# and wraps it -- the shape the header documents.
#
# Unlike GLFW and ImGui -- and like Qt -- this is not a fetched dependency: GTK4
# is a system SDK, and a project that enables this target is asking for the
# installed one. Discovery is pkg-config, and a missing GTK4 fails configuration
# with the fix in the message.
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(GTK4_PC QUIET IMPORTED_TARGET gtk4)
endif()
if(NOT TARGET PkgConfig::GTK4_PC)
message(FATAL_ERROR
"plot: PLOT_BUILD_GTK4 is ON but GTK4 was not found (pkg-config gtk4). Install the "
"GTK4 development package -- on Arch, gtk4 -- or configure with -DPLOT_BUILD_GTK4=OFF, "
"or point PKG_CONFIG_PATH at the GTK4 .pc file.")
endif()
add_library(plot_gtk4 STATIC gtk_plot_area.cpp gtk_plot_area.hpp gtk_input.hpp)
add_library(plot::gtk4 ALIAS plot_gtk4)
# The installed tree names it plot::gtk4, like the build tree (see plot_core's).
set_target_properties(plot_gtk4 PROPERTIES EXPORT_NAME gtk4)
# The public include root is `adapters/`, so the headers are reached as
# <gtk4/gtk_plot_area.hpp> and <gtk4/gtk_input.hpp> -- the toolkit's name first,
# like every other adapter's. The build interface is that root; the install one is
# the prefix's `include`, where both are installed under `gtk4/`, the same
# spelling.
target_include_directories(plot_gtk4 PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/..>
$<INSTALL_INTERFACE:include>)
# GTK is PUBLIC, unlike GLFW and ImGui: the adapter's factory hands out a
# GtkWidget, so a consumer that embeds the area needs GTK's headers and libraries
# to put it anywhere -- the widget *is* the toolkit's object -- and GTK's include
# directory is not one the compiler searches by default.
target_link_libraries(plot_gtk4 PUBLIC plot::core PkgConfig::GTK4_PC)
plot_apply_warnings(plot_gtk4)
# GTK came from pkg-config, which the installed config file can re-run: the
# snippet re-creates the exact PkgConfig::GTK4_PC target the adapter's public link
# interface names, so a consumer that links plot::gtk4 gets GTK's own include
# directories and libraries the same way this build did.
plot_record_adapter_export(plot_gtk4 TRUE "GTK4 was resolved by pkg-config"
"find_dependency(PkgConfig)\npkg_check_modules(GTK4_PC REQUIRED IMPORTED_TARGET gtk4)\n")
if(PLOT_INSTALL)
# gtk_input.hpp is installed with the public header: the demo and the test suite
# reach it as <gtk4/gtk_input.hpp>, and it is a toolkit-free header a consumer
# can use without GTK.
install(FILES gtk_plot_area.hpp gtk_input.hpp DESTINATION include/gtk4)
endif()
+229
View File
@@ -0,0 +1,229 @@
#pragma once
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp>
#include <optional>
namespace plot::gtk4 {
/// GDK's scroll unit (`GdkScrollUnit`), mirrored here so this header -- and a
/// test that drives it -- needs no GTK include directory. `gtk_plot_area.cpp`
/// static-asserts that the two agree with GDK's own enumerators, so a GDK that
/// renumbered them would fail to compile rather than silently mis-scale a zoom.
enum class ScrollUnit {
Wheel = 0, ///< the delta is already in wheel detents, one per notch
Surface = 1, ///< direct-scroll surface pixels, with no notch size GDK defines
///< (see kSurfacePixelsPerNotch)
};
/// Surface pixels per wheel notch, for a `ScrollUnit::Surface` delta: a
/// **calibration**, not a unit conversion.
///
/// GDK documents the surface unit as "surface pixels to scroll directly on
/// screen" and defines no notch size for it, so nothing here converts one unit
/// into the other -- how far a smooth device zooms is a choice, and this is where
/// it is made. Its assumption is stated rather than borrowed from an authority:
/// one surface unit is one pixel of a direct-scrolling gesture, and a *deliberate*
/// two-finger gesture on a touchpad is of the order of 100 of them, so 100 surface
/// pixels are taken to be one notch -- the same step one wheel click gives. A
/// gesture of that size therefore zooms by one detent rather than by several,
/// which a smaller value would make it do; a much larger one would put a wheel's
/// own single step out of a touchpad's reach.
///
/// Being a sensitivity and not a conversion, it is uncalibrated against real
/// hardware: the pixels one flick delivers depend on the platform's pixel density
/// and on the gesture itself, and a value measured on a touchpad should replace
/// this one. What is *not* a choice is the unit an event arrives in: GDK reports
/// it per event, and a delta already in wheel units is never divided by this.
inline constexpr f64 kSurfacePixelsPerNotch = 100.0;
// --- the device-pixel mapping ---------------------------------------------
/// The device-pixel mapping of one widget allocation: the factor between the
/// **logical** pixels GTK reports geometry and input in and the **device** pixels
/// the GL area renders into, together with the device viewport itself.
///
/// Derived from the two sizes GTK supplies and from nothing else. The widget's
/// logical size is `gtk_widget_get_width()`/`get_height()`; the device size is
/// what `GtkGLArea::resize` reports, which is the size of the viewport the
/// signal's default handler sets (`glViewport (0, 0, width, height)` in
/// `gtk_gl_area_resize()`) and therefore the only rectangle this adapter's
/// renderer draws into. No scale accessor takes part:
///
/// * `gtk_widget_get_scale_factor()` is documented to return "the next higher
/// integer value" on a system with fractional scaling, so a 1.25 or 1.75
/// display rounds it to 2 -- an input coordinate multiplied by it names a
/// pixel the FBO does not have there;
/// * `gdk_surface_get_scale()` is the surface's real, fractional-capable ratio
/// and the cross-check to read this against, but it describes the *surface*,
/// not the FBO an area was given (GTK 4.22 sizes that FBO as the logical size
/// times the integer factor, i.e. `ceil(scale)`).
///
/// Deriving the factor from the reported device size instead keeps the viewport,
/// the renderer and every forwarded input coordinate on one rectangle by
/// construction: all three use this single value, and a GTK that sized the FBO by
/// some other rule would be followed rather than guessed at.
///
/// A plain value with no toolkit dependency, like the Qt adapter's
/// `PointerAnchor`: the widget owns one and the tests drive it directly.
struct DeviceMapping {
f32 factor = 1.0f; ///< device pixels per logical pixel
Rect viewport{}; ///< the device rectangle, with its origin at (0, 0)
/// From GTK's two sizes: the widget's logical allocation and the device size
/// `::resize` reported. The factor comes off the width -- a widget's device size
/// is its logical size times one ratio, so the two axes cannot disagree, and one
/// factor for both keeps a gesture's dx and dy in one space. `1.0` while there
/// is no logical width to divide by, which is a widget that has not been
/// allocated yet: nothing is drawn into it and no event can reach it.
static DeviceMapping from_sizes(Vec2 logical, Vec2 device) noexcept {
DeviceMapping mapping;
mapping.factor = logical.x > 0.0f ? device.x / logical.x : 1.0f;
mapping.viewport = Rect{0.0f, 0.0f, device.x, device.y};
return mapping;
}
/// A position (or a movement, which is the difference of two of them) in the
/// device pixels `viewport` is measured in. The one place the mapping is
/// applied, so a gesture lands on the pixel the renderer drew.
Vec2 to_device(f32 x, f32 y) const noexcept { return Vec2{x * factor, y * factor}; }
Vec2 to_device(double x, double y) const noexcept {
return to_device(static_cast<f32>(x), static_cast<f32>(y));
}
};
/// The `GtkGestureDrag` anchor: the press point and the last offset the gesture
/// reported, both in **logical** widget pixels.
///
/// Logical on purpose, for the reason the Qt adapter caches its own anchor
/// logically: `GtkGestureDrag` reports `start_x`/`start_y` and `offset_x`/`offset_y`
/// in logical pixels while the factor to device pixels is re-derived on every
/// `::resize`, and that factor changes when the window moves to a monitor with
/// another scale factor (GTK re-emits `::resize` when the widget's scale-factor
/// property changes). An anchor cached in device pixels would compare a position
/// taken at the old factor with one taken at the new one: at 1.0 -> 1.25, a
/// logical press point of 500 would look like a jump of 125 device pixels, and
/// `Controller::pan` applies a delta as a fraction of the range span (clearing
/// auto scale), so a scale change mid-drag would pan the plot by 125 pixels' worth
/// of data. Keeping the anchor logical and scaling the *difference* by the factor
/// in force now makes the same gesture map to the same device distance whatever
/// the scale is.
struct DragAnchor {
bool have = false; ///< false until a drag begins, and again once it ends
Vec2 press{}; ///< logical press point, in widget pixels
Vec2 offset{}; ///< logical offset from `press` at the last sample
/// Begins a drag at the gesture's logical `start` point, with no offset yet.
void begin(f32 start_x, f32 start_y) noexcept {
press = Vec2{start_x, start_y};
offset = Vec2{0.0f, 0.0f};
have = true;
}
/// What a `drag-update` resolves to, in device pixels at the mapping in force.
struct Sample {
Vec2 position; ///< press point plus offset: where the pointer is now
Vec2 delta; ///< movement since the previous sample
};
/// Where the pointer is now, in device pixels at `mapping`: the press point plus
/// the offset of the last sample (`begin()`'s press point, before any update).
Vec2 position(const DeviceMapping& mapping) const noexcept {
return mapping.to_device(press.x + offset.x, press.y + offset.y);
}
/// One `drag-update`: where the pointer is and how far it moved since the
/// previous sample. The offset GDK reports is cached logical, and the movement is
/// a difference of two logical offsets scaled by one factor -- never the
/// difference of two device positions, which is what a factor change between the
/// two samples would corrupt.
Sample update(double offset_x, double offset_y, const DeviceMapping& mapping) noexcept {
const Vec2 next{static_cast<f32>(offset_x), static_cast<f32>(offset_y)};
const Vec2 moved{next.x - offset.x, next.y - offset.y};
offset = next;
return Sample{mapping.to_device(press.x + next.x, press.y + next.y),
mapping.to_device(moved.x, moved.y)};
}
/// Ends the drag with the gesture's final offset, returning the position in
/// device pixels at `mapping`. `drag-end` carries the authoritative end of the
/// gesture -- the controller reads its position to finish a box zoom's selection
/// -- and it may follow the last `drag-update` by however far the pointer moved
/// since, so this offset is the one cached and used, not the last sample's.
Vec2 end(double offset_x, double offset_y, const DeviceMapping& mapping) noexcept {
offset = Vec2{static_cast<f32>(offset_x), static_cast<f32>(offset_y)};
const Vec2 final_position = position(mapping);
have = false;
return final_position;
}
};
/// GTK's `GdkModifierType` bits as the controller's `input::Mod` mask.
///
/// A mask, not a reinterpretation: GDK's state carries the *mouse button* bits
/// as well (`GDK_BUTTON1_MASK` is `1 << 8`, and a release reports the button that
/// produced it), so the four modifiers the controller names are the only ones
/// that survive. Without the mask a right-drag would arrive at the controller
/// with an extra `0x400` bit that no other adapter's event carries.
inline u32 mods_from(u32 gdk_state) noexcept {
// The GDK bit values are repeated rather than included: this header names no
// GDK type, and the constants are fixed by GDK's public ABI. `gtk_plot_area.cpp`
// static-asserts them against GDK's own enumerators.
constexpr u32 kGdkShift = 1u << 0;
constexpr u32 kGdkControl = 1u << 2;
constexpr u32 kGdkAlt = 1u << 3;
constexpr u32 kGdkSuper = 1u << 26;
constexpr u32 kGdkMeta = 1u << 28; // maps to Command on macOS, Super here
u32 out = 0;
if ((gdk_state & kGdkShift) != 0u) out |= static_cast<u32>(input::Mod::Shift);
if ((gdk_state & kGdkControl) != 0u) out |= static_cast<u32>(input::Mod::Ctrl);
if ((gdk_state & kGdkAlt) != 0u) out |= static_cast<u32>(input::Mod::Alt);
if ((gdk_state & kGdkSuper) != 0u || (gdk_state & kGdkMeta) != 0u) {
out |= static_cast<u32>(input::Mod::Super);
}
return out;
}
/// The button one of GTK's gestures reports, as the controller's enum.
///
/// Buttons 1, 2 and 3 are the only ones GTK names (left, middle, right) and the
/// only ones the controller has a gesture for. This adapter listens with
/// `gtk_gesture_single_set_button(gesture, 0)`, which GTK documents as "any
/// button", so a press from button 8 or 9 (the navigation buttons) reaches the
/// handler too: `nullopt` is returned for it, and the event is dropped before it
/// can begin a pan.
inline std::optional<input::Button> button_from(u32 gdk_button) noexcept {
switch (gdk_button) {
case 1u: return input::Button::Left;
case 2u: return input::Button::Middle;
case 3u: return input::Button::Right;
default: return std::nullopt;
}
}
/// A GDK scroll delta in wheel notches, positive = zoom in.
///
/// Two steps in one place: the unit a smooth device's delta arrives in, and the
/// sign. The unit is settled per event, exactly as GDK distinguishes it:
///
/// * a `ScrollUnit::Surface` delta is scaled by `1 / kSurfacePixelsPerNotch`,
/// which is a calibration rather than a conversion (see there); a
/// `ScrollUnit::Wheel` delta is already one unit per detent, which is GDK's
/// own definition of that unit;
/// * the *sign* -- GDK's positive `dy` is a scroll south, the wheel pushed away
/// from the user, while the controller's positive amount zooms in, so the
/// value is negated. A wheel notch up arrives as `dy == -1.0` and leaves here
/// as `+1.0`.
///
/// A non-finite delta is returned unchanged: `Controller::on_wheel` refuses it as
/// a strict no-op, which is the one place that decision belongs.
inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
if (unit == ScrollUnit::Surface) return -dy / kSurfacePixelsPerNotch;
return -dy;
}
} // namespace plot::gtk4
+580
View File
@@ -0,0 +1,580 @@
#include <gtk4/gtk_plot_area.hpp>
#include <gtk4/gtk_input.hpp>
#include <plot/gl/loader.hpp>
// GTK after this library's GL surface, the order every adapter here keeps. GTK's
// headers pull in no `<GL/gl.h>` -- GdkGLContext is opaque and GtkGLArea names no
// GL type -- so unlike Qt there is no macro to dodge, but the order is the
// project's rule and it keeps the two from ever having to be thought about.
#include <gtk/gtk.h>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
// The mirrored constants in gtk_input.hpp are pinned to GDK's here: a GDK that
// renumbered the scroll units or moved a modifier bit fails to compile rather
// than mis-scaling a zoom or dropping a modifier.
static_assert(static_cast<int>(plot::gtk4::ScrollUnit::Wheel) == GDK_SCROLL_UNIT_WHEEL,
"ScrollUnit::Wheel must match GDK_SCROLL_UNIT_WHEEL");
static_assert(static_cast<int>(plot::gtk4::ScrollUnit::Surface) == GDK_SCROLL_UNIT_SURFACE,
"ScrollUnit::Surface must match GDK_SCROLL_UNIT_SURFACE");
static_assert((1u << 0) == GDK_SHIFT_MASK, "mods_from's shift bit must match GDK's");
static_assert((1u << 2) == GDK_CONTROL_MASK, "mods_from's control bit must match GDK's");
static_assert((1u << 3) == GDK_ALT_MASK, "mods_from's alt bit must match GDK's");
static_assert((1u << 26) == GDK_SUPER_MASK, "mods_from's super bit must match GDK's");
static_assert((1u << 28) == GDK_META_MASK, "mods_from's meta bit must match GDK's");
namespace plot::gtk4 {
namespace {
/// The entry points the resolver below could not provide, as passed to
/// `plot::gl::load()`. `load()` may still cover them through its own dlsym
/// fallback, but only a context-aware resolver is guaranteed to return pointers
/// valid for the current context, so a fallback is worth reporting. Process-global
/// and written only from the context's owning thread, which is the only thread
/// `::realize` runs on.
constexpr u32 kMaxUnresolved = 16;
const char* g_unresolved[kMaxUnresolved] = {};
u32 g_unresolved_count = 0;
/// `dlsym()` hands back an object pointer for a symbol that is in fact a
/// function. POSIX guarantees the two have the same representation, and the bit
/// copy states that where a `reinterpret_cast` is merely conditionally supported
/// -- the same respelling the GLFW and Qt adapters use for their own resolvers.
template <typename Fn>
Fn symbol_as(void* symbol) noexcept {
Fn fn = nullptr;
static_assert(sizeof(fn) == sizeof(symbol), "function and object pointers must match");
std::memcpy(&fn, &symbol, sizeof(fn));
return fn;
}
/// The `*GetProcAddress` of the API that owns GTK's current context, as
/// `plot::gl::ProcLoader`.
///
/// GDK4 offers no equivalent: `gdk_gl_context_get_proc_address()` was removed from
/// GTK4's public API, and GTK resolves its own GL entry points through libepoxy.
/// The context a `GtkGLArea` gets comes from EGL on both the X11 and the Wayland
/// backend, so `eglGetProcAddress()` is the context-aware resolver for it -- the
/// call every EGL client makes. GLX is tried second, for a GDK built on it. Both
/// are reached with `dlsym(RTLD_DEFAULT, ...)`, which finds them because libgtk
/// links libEGL and libGLX; neither is called when it is not there, in which case
/// the name is reported and `load()`'s own fallback gets its chance.
void* resolve_gl_proc(const char* name) noexcept {
using GetProcAddress = void* (*)(const char*);
static const GetProcAddress egl =
symbol_as<GetProcAddress>(dlsym(RTLD_DEFAULT, "eglGetProcAddress"));
static const GetProcAddress glx =
symbol_as<GetProcAddress>(dlsym(RTLD_DEFAULT, "glXGetProcAddressARB"));
void* proc = nullptr;
if (egl != nullptr) proc = egl(name);
if (proc == nullptr && glx != nullptr) proc = glx(name);
if (proc == nullptr && g_unresolved_count < kMaxUnresolved) {
g_unresolved[g_unresolved_count++] = name;
}
return proc;
}
/// Monotonic seconds, for the frame timings only.
f64 now_s() noexcept {
return std::chrono::duration<f64>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
/// `gtk_event_controller_get_current_event_state()` as the controller's mask.
u32 gesture_mods(GtkEventController* controller) noexcept {
return mods_from(static_cast<u32>(gtk_event_controller_get_current_event_state(controller)));
}
/// The position of the event a controller is handling right now, in device pixels
/// at `mapping`. False when there is no event or it has no position -- a key event
/// with a scroll controller's anchor is the case that matters.
bool current_event_position(GtkEventController* controller, const DeviceMapping& mapping,
Vec2* out) noexcept {
GdkEvent* event = gtk_event_controller_get_current_event(controller);
if (event == nullptr) return false;
double x = 0.0;
double y = 0.0;
if (!gdk_event_get_position(event, &x, &y)) return false;
*out = mapping.to_device(x, y);
return true;
}
} // namespace
PlotArea* PlotArea::create(Plot& plot) noexcept {
GtkWidget* widget = gtk_gl_area_new();
// The context is asked for at construction: GTK reads these when it creates the
// context at realize, and `gtk_gl_area_set_required_version` is documented to
// have no effect afterwards. 3.3 core is the baseline the library's loader
// resolves; the plot is 2D, so a depth buffer and a stencil buffer would only
// cost memory (GTK's own default is none, stated explicitly so a GTK that
// changed it could not silently add one).
gtk_gl_area_set_required_version(GTK_GL_AREA(widget), 3, 3);
gtk_gl_area_set_has_depth_buffer(GTK_GL_AREA(widget), FALSE);
gtk_gl_area_set_has_stencil_buffer(GTK_GL_AREA(widget), FALSE);
// A widget that cannot take focus never receives a key event, and the
// controller's `+`, `-` and `f` are key events.
gtk_widget_set_focusable(widget, TRUE);
gtk_widget_set_hexpand(widget, TRUE);
gtk_widget_set_vexpand(widget, TRUE);
auto* self = new PlotArea(plot, widget);
// The wrapper dies with the widget. `::unrealize` has already released the GL
// objects by then, while the context could still be made current.
g_object_set_data_full(G_OBJECT(widget), "plot-gtk4-area", self,
[](gpointer data) { delete static_cast<PlotArea*>(data); });
g_signal_connect(widget, "realize", G_CALLBACK(&PlotArea::on_realize), self);
g_signal_connect(widget, "unrealize", G_CALLBACK(&PlotArea::on_unrealize), self);
g_signal_connect(widget, "render", G_CALLBACK(&PlotArea::on_render), self);
g_signal_connect(widget, "resize", G_CALLBACK(&PlotArea::on_resize), self);
// One click gesture and one drag gesture, both listening to every button: the
// plot pans on left *and* right, box-zooms on shift+left, moves a cursor on
// ctrl+left and zooms an axis on middle. GTK cannot be asked for that set
// directly, so the two gestures split it (see the class comment) and the button
// and modifiers decide which gesture the controller runs.
GtkGesture* click = gtk_gesture_click_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(click), 0); // 0: any button
g_signal_connect(click, "pressed", G_CALLBACK(&PlotArea::on_click_pressed), self);
g_signal_connect(click, "released", G_CALLBACK(&PlotArea::on_click_released), self);
g_signal_connect(click, "stopped", G_CALLBACK(&PlotArea::on_click_stopped), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click));
GtkGesture* drag = gtk_gesture_drag_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 0);
g_signal_connect(drag, "drag-begin", G_CALLBACK(&PlotArea::on_drag_begin), self);
g_signal_connect(drag, "drag-update", G_CALLBACK(&PlotArea::on_drag_update), self);
g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag));
// Vertical only, as the plan asks: a purely horizontal scroll is not a zoom, and
// because this controller never sees one it cannot be mistaken for one. GTK
// reports the unit per event, so a discrete wheel arrives in notches and a
// smooth device in surface pixels; both are converted before they reach the
// controller (see scroll_notches).
GtkEventController* scroll =
gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL);
g_signal_connect(scroll, "scroll", G_CALLBACK(&PlotArea::on_scroll), self);
gtk_widget_add_controller(widget, scroll);
// Hover: the controller's cursor hit test reads the last pointer position even
// while no button is down.
GtkEventController* motion = gtk_event_controller_motion_new();
g_signal_connect(motion, "motion", G_CALLBACK(&PlotArea::on_motion), self);
gtk_widget_add_controller(widget, motion);
GtkEventController* key = gtk_event_controller_key_new();
g_signal_connect(key, "key-pressed", G_CALLBACK(&PlotArea::on_key_pressed), self);
gtk_widget_add_controller(widget, key);
return self;
}
PlotArea::PlotArea(Plot& plot, GtkWidget* widget) noexcept
: plot_(&plot), controller_(plot), widget_(widget) {}
Rect PlotArea::device_viewport() const noexcept { return mapping_.viewport; }
f32 PlotArea::device_scale() const noexcept { return mapping_.factor; }
Vec2 PlotArea::to_device(double x, double y) const noexcept {
// The mapping `device_viewport()` is the viewport of, applied to a position and
// to the offsets derived from two of them: one mapping on both sides is what
// makes a gesture land on the data under the cursor.
return mapping_.to_device(x, y);
}
void PlotArea::refresh_viewport() noexcept {
// The controller owns no viewport: this hands it to the plot (which re-lays the
// chrome out with it) and refreshes the layout. `last_device_viewport()` is
// deliberately not assigned here -- it records the region a *frame* was drawn
// into, which is `render()`'s to set.
controller_.set_viewport(device_viewport());
}
// --- the GL signals -------------------------------------------------------
void PlotArea::realize() noexcept {
// A second realize means GTK created a new context for a widget that moved to
// another surface: the names the renderer holds belonged to the old one, and
// nothing here may outlive it. Released before anything of ours exists on the
// new context, so the deletes it issues can only name objects that are not
// there.
renderer_.reset();
gtk_gl_area_make_current(GTK_GL_AREA(widget_));
GError* error = gtk_gl_area_get_error(GTK_GL_AREA(widget_));
if (error != nullptr) {
// The context GTK was asked for does not exist: no GL entry point may be
// called, and the area draws nothing. Reported, not asserted -- a GTK signal
// handler has nowhere to return a failure to.
init_status_ = Status{ErrorCode::GLFailure,
"GtkGLArea could not create a GL 3.3 context; see the GTK warning above"};
std::fprintf(stderr, "[plot::gtk4] GLFailure: %s (%s)\n", init_status_.message,
error->message != nullptr ? error->message : "no detail");
return;
}
g_unresolved_count = 0;
const bool loaded = plot::gl::load(&resolve_gl_proc);
// Printed before the failure branch: the names collected here are what explains
// a failure, so a load that failed has to report them too.
if (g_unresolved_count > 0) {
std::fprintf(stderr,
"[plot::gtk4] eglGetProcAddress() did not resolve %u required entry point(s);"
" plot::gl::load() %s for:",
g_unresolved_count, loaded ? "used its dlsym fallback" : "failed");
for (u32 i = 0; i < g_unresolved_count; ++i) std::fprintf(stderr, " %s", g_unresolved[i]);
std::fprintf(stderr, "\n");
}
if (!loaded) {
init_status_ = Status{ErrorCode::Unsupported,
"plot::gl::load() failed: the current context is missing a required"
" OpenGL 3.3 entry point"};
std::fprintf(stderr, "[plot::gtk4] %s\n", init_status_.message);
return;
}
renderer_.emplace();
Status status;
if (!renderer_->init(&status)) {
init_status_ = status.code != ErrorCode::Ok ? status
: Status{ErrorCode::GLFailure,
"Renderer::init() failed"};
// The renderer stays engaged: its destructor releases whatever the partial
// init built, and it runs in unrealize() with the context current.
std::fprintf(stderr, "[plot::gtk4] Renderer::init(): %s: %s\n", to_string(init_status_.code),
init_status_.message);
return;
}
init_status_ = Status{};
refresh_viewport();
}
void PlotArea::unrealize() noexcept {
// Made current first: the renderer's RAII handles issue `glDelete*` from their
// destructors, and GTK clears the context in its own unrealize vfunc, which runs
// after this signal's handlers -- so this is the last moment at which those
// deletes can name a live context.
gtk_gl_area_make_current(GTK_GL_AREA(widget_));
// Released unconditionally. The area's error says nothing about whether the
// renderer holds names to delete: it is set when context *creation* failed, and
// that is the path `realize()` returns from before it builds a renderer, so a
// renderer present here has GL objects behind it. Leaving it for the wrapper's
// destructor -- which runs at widget finalisation, after GTK has freed the
// context -- would issue those deletes with nothing current, which is exactly
// what the old error check was trying to avoid. `make_current()` and `reset()`
// are no-ops on a widget that never got a context and on an empty renderer.
renderer_.reset();
press_open_ = false;
drag_.have = false;
}
void PlotArea::render() noexcept {
// GTK guarantees the context is current and the area's FBO is bound when
// `::render` runs; making it current is a no-op in that case, and the error
// check is the assertion that it holds rather than an assumption. Both are
// `gtk_gl_area_make_current()`'s documented behaviour, and the check costs one
// call per frame -- the alternative is drawing through entry points that
// belonged to a context GTK has already thrown away.
gtk_gl_area_make_current(GTK_GL_AREA(widget_));
if (gtk_gl_area_get_error(GTK_GL_AREA(widget_)) != nullptr) return;
if (!renderer_ || !renderer_->ready()) return;
const Rect viewport = device_viewport();
if (viewport.w < 1.0f || viewport.h < 1.0f) return; // nothing to draw into
// The controller owns no viewport: this hands it to the plot (which re-lays the
// chrome out with it) and refreshes the layout, so every frame is laid out for
// the geometry of *this* frame. A resize changes the layout; it never scales a
// stale one.
controller_.set_viewport(viewport);
const f64 started = now_s();
const f64 dt = last_render_s_ >= 0.0 ? started - last_render_s_ : 0.0;
last_render_s_ = started;
if (frame_) frame_(*plot_, dt);
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
renderer_->draw(*plot_);
renderer_->end_frame();
const f32 frame_ms = static_cast<f32>((now_s() - started) * 1.0e3);
last_frame_ms_ = frame_ms;
// Seeded by the first frame rather than started at zero, so the mean is the mean
// of the frames that ran from the first one.
mean_frame_ms_ = frames_ == 0 ? frame_ms : mean_frame_ms_ + 0.1f * (frame_ms - mean_frame_ms_);
last_viewport_ = viewport;
++frames_;
}
void PlotArea::on_realize(GtkGLArea*, void* self) noexcept {
static_cast<PlotArea*>(self)->realize();
}
void PlotArea::on_unrealize(GtkGLArea*, void* self) noexcept {
static_cast<PlotArea*>(self)->unrealize();
}
int PlotArea::on_render(GtkGLArea*, GdkGLContext*, void* self) noexcept {
static_cast<PlotArea*>(self)->render();
// FALSE on purpose, not "I did not draw": `GtkGLArea::render`'s return value is
// accumulated with `g_signal_accumulator_true_handled`, so the first handler to
// return TRUE *stops the emission* -- and a host that connects its own handler
// (a screenshot read, a frame counter) would never be called. GTK itself does
// not read the value (it composites the FBO the area rendered into either way),
// and a GtkGLArea subclass overriding the vfunc is the only place TRUE means
// anything.
return FALSE;
}
void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
// GTK's two sizes, and the one place the mapping is derived from them. `width`
// and `height` are the device pixels of the viewport this signal's default
// handler is about to set -- `glViewport(0, 0, width, height)` in GTK's own
// `gtk_gl_area_resize()` -- and the widget's width and height, which GTK has
// already updated when it emits this, are the logical pixels that same rectangle
// is reported in. Their ratio is the factor for the viewport *and* for every
// forwarded coordinate; refreshing here means a gesture arriving between this
// resize and the next frame maps against the new geometry rather than the old.
area->mapping_ = DeviceMapping::from_sizes(
Vec2{static_cast<f32>(gtk_widget_get_width(area->widget_)),
static_cast<f32>(gtk_widget_get_height(area->widget_))},
Vec2{static_cast<f32>(width), static_cast<f32>(height)});
area->refresh_viewport();
}
// --- pointer input --------------------------------------------------------
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y) noexcept {
if (press_open_) return; // one press per button-down sequence: see drag_begin
press_open_ = true;
press_button_ = button;
press_mods_ = mods;
const Vec2 p = to_device(x, y);
pointer_ = p;
have_pointer_ = true;
input::PointerEvent e;
e.x = p.x;
e.y = p.y;
e.dx = 0.0f;
e.dy = 0.0f;
e.button = button;
e.mods = mods;
e.down = true;
e.pressed = true;
controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
void PlotArea::pointer_release(Vec2 position) noexcept {
if (!press_open_) return;
press_open_ = false;
input::PointerEvent e;
e.x = position.x;
e.y = position.y;
e.dx = 0.0f;
e.dy = 0.0f;
e.button = press_button_;
e.mods = press_mods_;
e.down = false;
e.released = true;
controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
void PlotArea::on_click_pressed(GtkGestureClick* gesture, int, double x, double y,
void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
const std::optional<input::Button> button =
button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
if (!button) return; // a button the controller has no gesture for
// The modifiers come off the *event*, not the gesture: GTK reports the state as
// it was before the press, which is what the controller's gesture table reads.
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y);
}
void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double y,
void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// The first release closes the gesture; a drag's own `drag-end` may already have
// done it (GTK emits both, `drag-end` first), and this is then a no-op.
area->pointer_release(area->to_device(x, y));
}
void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// A sequence can be cancelled rather than released -- GTK stops the click
// gesture as soon as the drag gesture claims the same sequence, which happens on
// the first motion of every drag. That stop must not end the gesture: the drag
// is still running, and its updates keep driving the plot. Only a stop with no
// drag in progress (an exotic cancellation by another widget or a device) closes
// the press, which it has to, or the controller would stay in whatever mode the
// press began.
if (area->press_open_ && !area->drag_.have) area->pointer_release(area->pointer_);
}
void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
area->drag_.begin(static_cast<f32>(start_x), static_cast<f32>(start_y));
area->pointer_ = area->drag_.position(area->mapping_);
area->have_pointer_ = true;
// GTK emits `drag-begin` before the click gesture's `pressed` (both while the
// press event is handled), so this is normally the second press of the sequence
// and `pointer_press` drops it. It is not always: if the click gesture never
// pressed -- a host that grabs the sequence, a GTK that reorders its gestures --
// the press is opened here instead, so a drag cannot be lost for want of an
// edge. The button and modifiers come from the drag gesture for that case.
const std::optional<input::Button> button =
button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
if (button) {
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y);
}
}
void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// A drag with no press open is a drag of a button the adapter does not forward
// (see button_from): its motion must not reach the controller as a held-drag
// move, which is the one event the controller acts on from its button state.
if (!area->press_open_) return;
// The anchor stays logical (see `DragAnchor`): the gesture reports the offset
// from the press point in logical pixels, and this is a logical difference
// scaled by the factor in force *now*, so a factor change between two samples
// cannot turn into a phantom movement of its own.
const DragAnchor::Sample sample =
area->drag_.update(offset_x, offset_y, area->mapping_);
area->pointer_ = sample.position;
area->have_pointer_ = true;
input::PointerEvent e;
e.x = sample.position.x;
e.y = sample.position.y;
e.dx = sample.delta.x;
e.dy = sample.delta.y;
e.button = area->press_button_;
e.mods = area->press_mods_;
e.down = true;
area->controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// The release carries the authoritative end of the gesture: the controller reads
// its position to finish a box zoom's selection, so this is the press point plus
// the offset `drag-end` itself reports -- which may be past the last update's --
// and not the last update's position.
const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_);
area->pointer_ = position;
area->pointer_release(position);
}
int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
const ScrollUnit unit = gtk_event_controller_scroll_get_unit(scroll) == GDK_SCROLL_UNIT_SURFACE
? ScrollUnit::Surface
: ScrollUnit::Wheel;
const f64 notches = scroll_notches(dy, unit);
if (notches == 0.0) {
// Not a zoom, so not this widget's event: returning FALSE lets an enclosing
// scrolled window scroll instead. A zero-notch zoom is not harmless -- it
// changes no range but still runs the range setters, which clear auto scale --
// so it must not reach the controller either.
return FALSE;
}
Vec2 at{};
if (!current_event_position(GTK_EVENT_CONTROLLER(scroll), area->mapping_, &at)) {
if (!area->have_pointer_) return FALSE; // no anchor to zoom about
at = area->pointer_; // the last position a pointer event reported
}
area->pointer_ = at;
area->have_pointer_ = true;
input::WheelEvent e;
e.x = at.x;
e.y = at.y;
e.dx = static_cast<f32>(scroll_notches(dx, unit));
e.dy = static_cast<f32>(notches);
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
area->controller_.on_wheel(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot zoomed
}
void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
const Vec2 p = area->to_device(x, y);
area->pointer_ = p;
area->have_pointer_ = true;
// Hover only, with `down` false whatever the buttons are doing: a motion event
// that claimed a button was held would be fed to `update_drag` as well, and a
// drag would then be applied twice -- once from the drag gesture's offset and
// once from this position's difference.
(void)motion;
input::PointerEvent e;
e.x = p.x;
e.y = p.y;
e.dx = 0.0f;
e.dy = 0.0f;
e.down = false;
area->controller_.on_pointer(e);
}
// --- keyboard -------------------------------------------------------------
int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
unsigned int keycode, unsigned int state, void* self) noexcept {
(void)keycode;
(void)keys;
auto* area = static_cast<PlotArea*>(self);
// The keys are named by their *symbol*: GTK reports a layout's keyval, and the
// keypad's plus and minus are the same symbol as the main row's. `'='` is mapped
// with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is*
// shift+`=`), the pair the GLFW and Qt adapters map.
int key = 0;
switch (gdk_keyval_to_unicode(keyval)) {
case static_cast<guint32>(input::kKeyZoomIn):
case static_cast<guint32>('='): key = input::kKeyZoomIn; break;
case static_cast<guint32>(input::kKeyZoomOut):
case static_cast<guint32>('_'): key = input::kKeyZoomOut; break;
case static_cast<guint32>(input::kKeyFit):
case static_cast<guint32>(input::kKeyFitUpper): key = input::kKeyFit; break;
default: return FALSE; // not a plot key: GTK and the window keep it
}
input::KeyEvent e;
e.key = key;
e.mods = mods_from(state);
// GTK reports a held key as repeated presses and sends no release the controller
// would act on anyway (its actions are press-only), so the press is the event.
e.down = true;
area->controller_.on_key(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot acted on it
}
} // namespace plot::gtk4
+338
View File
@@ -0,0 +1,338 @@
#pragma once
#include <functional>
#include <optional>
#include <utility>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
#include <gtk4/gtk_input.hpp>
// GTK's own type declarations, spelled exactly as GTK spells them
// (`typedef struct _GtkWidget GtkWidget;` and so on). Forward-declared on
// purpose, like the GLFW host's `struct GLFWwindow`: this header is
// self-contained and needs no GTK include directory, so a consumer of the
// adapter does not inherit GTK's headers -- and, unlike a toolkit whose headers
// reach `<GL/gl.h>`, either include order works, because these are the same
// typedefs GTK declares and GTK's headers pull in no GL header at all. Nor does
// `gtk_input.hpp`, included above for the device-pixel mapping and the drag
// anchor, name a GDK type: it mirrors the two scroll-unit values and GDK's
// modifier bits as plain numbers.
typedef struct _GtkWidget GtkWidget;
typedef struct _GtkGLArea GtkGLArea;
typedef struct _GtkGesture GtkGesture;
typedef struct _GtkGestureClick GtkGestureClick;
typedef struct _GtkGestureDrag GtkGestureDrag;
typedef struct _GtkEventControllerScroll GtkEventControllerScroll;
typedef struct _GtkEventControllerKey GtkEventControllerKey;
typedef struct _GtkEventControllerMotion GtkEventControllerMotion;
typedef struct _GdkGLContext GdkGLContext;
namespace plot::gtk4 {
/// One frame of host code, run by the render callback after the viewport is set
/// and before the frame is staged. It is where data is refreshed -- acquiring a
/// `data::StreamSeries`, re-fitting a live axis, moving cursors -- and `dt` is
/// the wall time since the previous call, in seconds. Optional; an area with no
/// callback just re-draws the plot as it stands.
using FrameFn = std::function<void(Plot&, f64 dt)>;
/// Embeds a `Plot` in a GTK4 widget: a `GtkGLArea` whose framebuffer the plot is
/// rendered into, with GTK's gestures and event controllers translated into
/// `plot::input` ones.
///
/// ## Why a wrapper and not a subclass
///
/// The plan calls for a `GtkWidget` subclass, and GTK4 cannot have one here: a
/// GObject type is registered with `G_DEFINE_TYPE`, which is a C macro that
/// declares the type's instance and class structs and a `*_get_type()` function,
/// and none of that is expressible around a C++ class with a vtable. GTK also
/// refuses to let an application derive from a type with private data
/// (`GtkGLArea`'s is private) outside C. So this is the factory shape instead:
/// `create()` builds a `gtk_gl_area_new()` instance, registers the signals and
/// controllers that drive it, and returns the wrapper that owns them. The
/// widget is GTK's; the wrapper is the widget's.
///
/// ## What the area draws into
///
/// `GtkGLArea` creates an FBO of its own and makes it the current draw target
/// before emitting `::render`, exactly the "currently bound framebuffer"
/// `Renderer` documents it draws into -- so this area, like the Qt widget,
/// creates no target of its own and the renderer's scissor-cleared viewport is
/// the area's whole rectangle. GTK then composites that FBO into the
/// scene graph as a texture. Nothing here renders to a texture or through a
/// second pass, and the plot cannot disturb GTK's own drawing by construction:
/// the only framebuffer the renderer is handed is one GTK created for this
/// widget, and GTK is the one that composites it. That is a structural argument,
/// not a tested property.
///
/// ## The coordinate convention: device pixels, both sides
///
/// Two pixel spaces meet here, exactly as they do in the GLFW, ImGui and Qt
/// adapters. GTK reports widget allocation and every input coordinate in
/// **logical** pixels (`gtk_widget_get_width()`, a gesture's `x`/`y`, a drag
/// offset), while the area's framebuffer -- what `Renderer::begin_frame()` takes
/// and what every `Rect` in this library speaks -- is in **device** pixels,
/// the pixels of the GL viewport this area renders into.
///
/// This area converts everything into device pixels and keeps it there:
///
/// * the viewport handed to the plot is the device size GTK reports in
/// `::resize`, which is the size of the viewport that signal's default
/// handler sets -- the exact size of the FBO GTK attaches and the renderer
/// draws into -- and
/// * every pointer position, drag offset and wheel position is multiplied by
/// that same factor before it reaches the controller.
///
/// The factor is therefore *derived*, from those two sizes and from nothing else:
/// `DeviceMapping::from_sizes()` divides the device size by the widget's logical
/// allocation, and it is the one value both sides read. No scale accessor takes
/// part: `gtk_widget_get_scale_factor()` is documented to return "the next higher
/// integer value" under fractional scaling (so a 1.25 or 1.75 display would map
/// input by 2), and `gdk_surface_get_scale()` describes the surface rather than
/// the FBO the area was given. The factor is an exact ratio, not an integer.
///
/// `device_viewport()` and `to_device()` are the only two places the mapping is
/// read, so the viewport and the input cannot disagree -- and they must not: a
/// mismatch renders correctly but pans by the wrong amount and puts a wheel's
/// anchor in the wrong place, which is invisible on an unscaled display and wrong
/// on every other one.
///
/// The *anchor* a drag's movement is measured from is the one thing kept in
/// logical pixels: the factor can change between the press and an update (the
/// window moving to a display with another scale, GTK re-emitting `::resize`), and
/// a delta is then a logical difference scaled by the factor in force now, never
/// the difference of two device positions (see `DragAnchor`).
///
/// Note that this also means the renderer's *style metrics* stay in device
/// pixels: at scale 2 the label font is 24 device px = 12 logical px, so labels
/// are smaller relative to the widget than at 1x. That is the same convention
/// the GLFW and Qt adapters use, and a host that wants a constant logical text
/// size scales `Renderer::style()` metrics by the factor itself.
///
/// ## Input
///
/// GTK decides which gesture a sequence is by recognition, so two gestures share
/// the work here, and the split is the one the observed signal order forces:
///
/// * `GtkGestureDrag` (button 0: any) supplies the *motion*. Its
/// `drag-update` carries the offset from the press point, which is what a pan,
/// a box zoom, a cursor move and an axis zoom all measure.
/// * `GtkGestureClick` (button 0: any) supplies the *edges*: the press and the
/// release, with the button and the modifier state, exactly as the Qt
/// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins
/// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there.
/// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the
/// last position even with no button down). Its positions are forwarded with
/// `down == false` on purpose: a motion event that claimed a button was down
/// would drive `update_drag` as well, and every drag would then be applied
/// twice, once from the drag offset and once from the motion delta.
/// * `GtkEventControllerScroll` (vertical only) supplies the wheel, in notches
/// (see `scroll_notches`).
/// * `GtkEventControllerKey` supplies `+` / `-` / `f`.
///
/// GTK emits `drag-begin` *before* the click gesture's `pressed` (both are
/// emitted while the press event is handled), and a press that never moves still
/// produces `drag-begin`, `drag-end` and the click's press/release pair. The
/// adapter therefore opens its gesture from the first press it sees and closes it
/// from the first release, so the two gestures cannot double-press or
/// double-release the controller.
///
/// ## Threading and repainting
///
/// Everything here runs on the thread that owns the GTK main context -- realize,
/// render, every gesture handler -- which is the thread that owns the GL context.
/// A host that feeds the plot from a producer thread must acquire its data on
/// this thread (see the frame callback); the adapter never touches the plot from
/// anywhere else, and never joins a host's thread.
///
/// GTK redraws the area when the widget is exposed, resized or asked to. This
/// area additionally queues a render after every input event that can change the
/// picture -- a press, a drag, a wheel or a plot key, but not a hover (the
/// renderer draws nothing that depends on where the pointer is) -- so a gesture is
/// visible without waiting for the host's next frame, and the frame rate stays the
/// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`.
///
/// ## Headless use
///
/// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a
/// read of that FBO (see the demo's `dump_area_ppm`), not of the window: GTK's
/// chrome lives outside it. The area also reports what happened to the GL context
/// rather than asserting: `init_status()` is `Ok` once the renderer is up, or the
/// failure that left the area drawing nothing.
class PlotArea {
public:
/// Creates the `GtkGLArea` that renders `plot`, plus the wrapper that drives
/// it. `plot` must outlive the widget. The area expands to fill its container;
/// a host that wants otherwise clears `GtkWidget:hexpand`/`vexpand`.
///
/// The returned wrapper is **owned by the widget**: GTK frees it when the
/// widget is finalized (the GL resources are released earlier, in
/// `::unrealize`, while the context may still be made current). The caller
/// therefore never deletes it, and must not keep it past the widget's life.
static PlotArea* create(Plot& plot) noexcept;
PlotArea(const PlotArea&) = delete;
PlotArea& operator=(const PlotArea&) = delete;
/// Installs the per-frame hook (see `FrameFn`); an empty function removes it.
void set_frame_callback(FrameFn frame) { frame_ = std::move(frame); }
/// The widget to put in a window or a box. Valid for the wrapper's whole life.
GtkWidget* widget() const noexcept { return widget_; }
/// The plot this area renders. Valid for the widget's whole life.
Plot& plot() noexcept { return *plot_; }
const Plot& plot() const noexcept { return *plot_; }
/// The controller this area feeds, bound to `plot()`.
input::Controller& controller() noexcept { return controller_; }
const input::Controller& controller() const noexcept { return controller_; }
/// The renderer, or null until a successful `::realize`. Valid from the first
/// render onwards, and after the widget's context exists.
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
const Renderer* renderer() const noexcept { return renderer_ ? &*renderer_ : nullptr; }
/// What `::realize` made of the context: `Ok`, or the failure that left the
/// area drawing nothing. A GTK signal handler cannot return a `Status` and
/// nothing may throw across GTK's callback, so the outcome is recorded here
/// instead. `ErrorCode::GLFailure` is what a `GtkGLArea` whose context could not
/// be created reports -- checked with `gtk_gl_area_get_error()` before any GL
/// entry point is called.
///
/// A context that GTK loses *after* realize is not reported here: GTK 4.22 stops
/// emitting `::render` for an area that carries an error (measured: the raw
/// signal count freezes on the frame the error is set), so the render callback
/// -- whose `gtk_gl_area_get_error()` check is the assertion that the context is
/// usable -- is never entered again. A host sees that as an area that stops
/// staging frames: `frames_painted()` stops advancing.
const Status& init_status() const noexcept { return init_status_; }
/// Frames actually staged, and the frame time of the last one (ms). The mean is
/// an exponential moving average seeded by the first frame, of the same
/// render-only span: the frame callback, `begin_frame`, `draw` and `end_frame`.
/// It excludes GTK's own compositing of the area, which happens outside this
/// callback and is not the library's or the adapter's work.
u64 frames_painted() const noexcept { return frames_; }
f32 last_frame_ms() const noexcept { return last_frame_ms_; }
f32 mean_frame_ms() const noexcept { return mean_frame_ms_; }
/// The region the last frame was drawn into, in device pixels -- the rect
/// `begin_frame()` was handed. Zero-sized before the first frame.
Rect last_device_viewport() const noexcept { return last_viewport_; }
/// The area's current rectangle in device pixels: the device size GTK last
/// reported in `::resize`, which is the size of the viewport that signal's
/// default handler set. This is the plot's viewport, the size of the framebuffer
/// GTK attached, and -- a screenshot helper needs it -- the size of a framebuffer
/// read. Zero-sized until GTK reports a size, which is also when there is
/// nothing to draw into.
Rect device_viewport() const noexcept;
/// Device pixels per logical pixel: the factor `DeviceMapping::from_sizes()`
/// derives from the widget's logical allocation and the device size `::resize`
/// reported, and the one factor both `device_viewport()` and every forwarded
/// input coordinate is scaled by. `1.0` before the widget is allocated, and
/// fractional under fractional scaling -- unlike `gtk_widget_get_scale_factor()`,
/// which returns its ceiling.
f32 device_scale() const noexcept;
private:
explicit PlotArea(Plot& plot, GtkWidget* widget) noexcept;
/// `::realize`: make the context current, check it exists, load the GL entry
/// points and initialise the renderer. Runs again if GTK realizes the widget
/// twice (it moved to another surface), which is why it releases the previous
/// renderer first.
void realize() noexcept;
/// `::unrealize`: release the renderer's GL objects while the context can still
/// be made current -- unconditionally, and by making it current first. GTK clears
/// the context and the area's error in its own unrealize vfunc, which runs after
/// this signal's handlers, so this is the last moment at which a `glDelete*` can
/// name a live context; afterwards the wrapper's destruction would issue those
/// deletes with none.
void unrealize() noexcept;
/// `::render`: stage one frame into the FBO GTK bound.
void render() noexcept;
/// `::resize`: hand the new geometry to the plot now, so a gesture arriving
/// before the next frame maps against the new layout instead of the old one.
void refresh_viewport() noexcept;
/// One pointer event, from whichever gesture saw it first. Owns the
/// press/release bookkeeping the two gestures share.
void pointer_press(input::Button button, u32 mods, double x, double y) noexcept;
void pointer_release(Vec2 position) noexcept;
/// Translates the gesture-reported position/delta in *logical* widget pixels
/// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept;
static void on_realize(GtkGLArea* area, void* self) noexcept;
static void on_unrealize(GtkGLArea* area, void* self) noexcept;
/// `gboolean` is `int`; a GtkGLArea render handler ignores the context.
static int on_render(GtkGLArea* area, GdkGLContext* context, void* self) noexcept;
static void on_resize(GtkGLArea* area, int width, int height, void* self) noexcept;
static void on_click_pressed(GtkGestureClick* gesture, int n_press, double x, double y,
void* self) noexcept;
static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y,
void* self) noexcept;
static void on_click_stopped(GtkGestureClick* gesture, void* self) noexcept;
static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
void* self) noexcept;
static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept;
static void on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept;
static int on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
void* self) noexcept;
static void on_motion(GtkEventControllerMotion* motion, double x, double y,
void* self) noexcept;
/// `guint keyval, guint keycode, GdkModifierType state`, all 32-bit values; the
/// modifier enum is named by its size here because this header does not include
/// GDK (see the note at the top).
static int on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
unsigned int keycode, unsigned int state, void* self) noexcept;
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
input::Controller controller_;
GtkWidget* widget_ = nullptr;
std::optional<Renderer> renderer_;
FrameFn frame_;
Status init_status_;
/// The button and modifiers of the gesture in progress, and whether one is:
/// the two gestures share one sequence, so exactly one press is open between a
/// button-down and its button-up.
bool press_open_ = false;
input::Button press_button_ = input::Button::Left;
u32 press_mods_ = 0;
/// The drag gesture's anchor: its press point and last offset, in *logical*
/// pixels. Logical on purpose: a factor change between two samples of a drag
/// cannot then turn into a phantom movement (see `DragAnchor`). The offset is
/// cached rather than re-read per event, so a `drag-update` reports the movement
/// since the previous one, and `have` is whether a drag is recognised.
DragAnchor drag_;
/// The last pointer position seen, in device pixels: the wheel's anchor when a
/// scroll event carries no position of its own.
Vec2 pointer_{};
bool have_pointer_ = false;
/// The device-pixel mapping in force, derived in `::resize` from the widget's
/// logical allocation and the device size GTK reported. `1.0` and a zero
/// viewport until then, which is before anything is allocated or drawn.
DeviceMapping mapping_;
u64 frames_ = 0;
f32 last_frame_ms_ = 0.0f;
f32 mean_frame_ms_ = 0.0f;
/// Wall clock at the last render start, or negative before the first one.
f64 last_render_s_ = -1.0;
Rect last_viewport_{};
};
} // namespace plot::gtk4
+63
View File
@@ -0,0 +1,63 @@
# The Dear ImGui adapter: a widget that draws a plot into a child region of the
# framebuffer ImGui is rendering its own chrome into, and feeds that plot
# ImGui's mouse and keyboard state.
#
# ImGui is not a packaging dependency here: it is fetched pinned to a release
# tag (never a branch, so a build is reproducible) and only the sources this
# adapter needs are compiled -- the core plus the GLFW and OpenGL 3 backends.
# The fetched sources are third-party code: they are not held to this project's
# warning set, and the adapter and its example are.
include(FetchContent)
FetchContent_Declare(imgui
GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.91.9b
GIT_SHALLOW TRUE)
# imgui has no top-level CMakeLists.txt, so MakeAvailable populates it and stops;
# the sources are added to the target below by name.
FetchContent_MakeAvailable(imgui)
if(NOT imgui_SOURCE_DIR OR NOT EXISTS "${imgui_SOURCE_DIR}/imgui.cpp")
message(FATAL_ERROR
"plot: the Dear ImGui sources for tag v1.91.9b are missing from "
"'${imgui_SOURCE_DIR}'. The FetchContent clone failed, so PLOT_BUILD_IMGUI "
"cannot link anything; either restore network access or pre-populate the "
"source with -DFETCHCONTENT_SOURCE_DIR_IMGUI=<path>.")
endif()
add_library(imgui STATIC
"${imgui_SOURCE_DIR}/imgui.cpp"
"${imgui_SOURCE_DIR}/imgui_draw.cpp"
"${imgui_SOURCE_DIR}/imgui_tables.cpp"
"${imgui_SOURCE_DIR}/imgui_widgets.cpp"
"${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp"
"${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp")
# Both include roots are public: a consumer of the backends -- the adapter and
# the example -- reaches them as <imgui.h> and <backends/imgui_impl_glfw.h>.
target_include_directories(imgui PUBLIC
"${imgui_SOURCE_DIR}"
"${imgui_SOURCE_DIR}/backends")
# imgui_impl_glfw.cpp needs GLFW; a build that also enables the GLFW adapter
# gets the same target, never a second copy.
plot_find_glfw(plot_imgui_glfw_dep)
target_link_libraries(imgui PUBLIC ${plot_imgui_glfw_dep})
target_compile_features(imgui PUBLIC cxx_std_20)
add_library(plot_imgui STATIC imgui_plot.cpp)
add_library(plot::imgui ALIAS plot_imgui)
# The public include root is `adapters/`, so the header is reached as
# <imgui/imgui_plot.hpp> -- the toolkit's name first, like every other adapter's.
target_include_directories(plot_imgui PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..")
# ImGui stays PRIVATE: the adapter's header includes no ImGui header, so a
# consumer of plot::imgui does not inherit the toolkit's headers.
target_link_libraries(plot_imgui PUBLIC plot::core PRIVATE imgui ${plot_imgui_glfw_dep})
plot_apply_warnings(plot_imgui)
# Not installed and not exported, and not because of a discovery fallback: ImGui
# is *always* fetched here, so there is no ImGui package for an installed config
# file to find. Exporting plot::imgui would name the fetched `imgui` target in its
# link interface and leave every consumer of `find_package(plot)` unable to
# configure -- so plot::imgui is a build-tree target only, and a consumer that
# wants it adds the adapter's source or fetches the same pinned tag itself.
plot_record_adapter_export(plot_imgui FALSE
"ImGui is supplied by FetchContent, so find_package(plot) cannot promise it to a consumer" "")
+271
View File
@@ -0,0 +1,271 @@
#include <imgui/imgui_plot.hpp>
#include <imgui.h>
namespace plot::imgui {
namespace {
/// ImGui's button indices are left, right, middle; the controller's enum is the
/// same three, spelled out.
input::Button button_from_index(int index) noexcept {
switch (index) {
case 1:
return input::Button::Right;
case 2:
return input::Button::Middle;
default:
return input::Button::Left;
}
}
/// ImGui's modifier booleans as the controller's translatable mask. The bit
/// values are `input::Mod`'s by definition: it is documented as the mask a host
/// translates into, and the GLFW adapter's static_asserts pin the same layout.
u32 mods_from(const ImGuiIO& io) noexcept {
u32 mods = 0;
if (io.KeyShift) mods |= static_cast<u32>(input::Mod::Shift);
if (io.KeyCtrl) mods |= static_cast<u32>(input::Mod::Ctrl);
if (io.KeyAlt) mods |= static_cast<u32>(input::Mod::Alt);
if (io.KeySuper) mods |= static_cast<u32>(input::Mod::Super);
return mods;
}
} // namespace
void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer) {
ImGuiIO& io = ImGui::GetIO();
if (!controller_ || plot_ != &plot) {
// The controller holds a Plot& (and the drag state that goes with it), so a
// different plot is a different controller; re-binding drops any drag rather
// than replaying its edges against the new plot.
controller_.emplace(plot);
plot_ = &plot;
reset_pointer();
}
// The whole translation below rests on the backend having filled in a sane
// scale; a zero one would collapse the region and silently draw nothing.
IM_ASSERT(io.DisplayFramebufferScale.x > 0.0f && io.DisplayFramebufferScale.y > 0.0f);
// The child is a plot surface, not a scrollable panel: no scrollbar, whatever
// the item inside it measures. The wheel belongs to the plot while it is
// hovered, and is taken away from window scrolling by the key owner below.
const ImGuiWindowFlags child_flags = ImGuiWindowFlags_NoScrollbar;
// The child is the plot's surface: it must paint nothing of its own over it.
// ChildBg is transparent in every stock theme, but a host may have pushed a
// panel colour; forcing it here means the widget can never cover its own plot.
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
const bool shown =
ImGui::BeginChild(id, ImVec2(size.x, size.y), ImGuiChildFlags_None, child_flags);
if (!shown) {
// Clipped away entirely (or collapsed): there is no region to draw into, and
// no rect to aim a gesture at. EndChild is still owed.
ImGui::EndChild();
ImGui::PopStyleColor();
return;
}
// A borderless child has no padding and no border, so the content region is the
// child's rectangle itself: the pixels the plot draws are exactly the ones the
// host laid out for it -- if the host leaves them visible. ImGui hands a child
// the clip rect of an ancestor that cuts it off (and the child's own rect when
// nothing does, which makes the intersection below a no-op); the widget's GL
// draws do not go through ImGui's clipping, so a child the hosting window cuts
// off would otherwise clear and paint over whatever the host drew outside it.
const ImVec2 origin = ImGui::GetCursorScreenPos();
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 clip_min = ImGui::GetWindowDrawList()->GetClipRectMin();
const ImVec2 clip_max = ImGui::GetWindowDrawList()->GetClipRectMax();
const f32 x0 = origin.x > clip_min.x ? origin.x : clip_min.x;
const f32 y0 = origin.y > clip_min.y ? origin.y : clip_min.y;
const f32 x1 = origin.x + avail.x < clip_max.x ? origin.x + avail.x : clip_max.x;
const f32 y1 = origin.y + avail.y < clip_max.y ? origin.y + avail.y : clip_max.y;
if (x1 <= x0 || y1 <= y0) {
// Nothing of the child is visible: no region to draw into, no rect to aim a
// gesture at, and InvisibleButton wants a non-zero size.
ImGui::EndChild();
ImGui::PopStyleColor();
return;
}
const ImVec2 visible{x1 - x0, y1 - y0};
// An invisible item covering the *visible* region is what makes it *the* hovered
// thing: `IsItemHovered()` is false while a drag elsewhere in the host is active,
// so the plot does not pan when the user drags a slider next to it, and
// `SetItemKeyOwner()` -- ImGui's documented way for a widget to take the wheel
// away from window scrolling -- needs an item to own it. Both buttons are
// enabled: the controller's left and right drags are the plot's own, and ImGui
// only needs to know that the pointer is interacting with this region.
ImGui::SetCursorScreenPos(ImVec2(x0, y0));
ImGui::InvisibleButton("surface", visible,
ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight |
ImGuiButtonFlags_MouseButtonMiddle);
const bool hovered = ImGui::IsItemHovered();
ImGui::SetItemKeyOwner(ImGuiKey_MouseWheelY);
const ImVec2 scale = io.DisplayFramebufferScale;
const f32 fb_w_f = io.DisplaySize.x * scale.x;
const f32 fb_h_f = io.DisplaySize.y * scale.y;
// Points -> framebuffer pixels: the assumption documented on the class. State
// it in the arithmetic so a reader sees where the mapping enters.
const Rect region{x0 * scale.x, y0 * scale.y, visible.x * scale.x, visible.y * scale.y};
if (fb_w_f >= 1.0f && fb_h_f >= 1.0f) {
// One viewport for the gesture geometry and the frame: the controller
// forwards it to the plot (and re-lays it out), and `begin_frame` gets the
// same rect and the whole framebuffer it is a sub-rectangle of.
controller_->set_viewport(region);
forward_input(hovered);
const u32 fb_w = static_cast<u32>(fb_w_f + 0.5f);
const u32 fb_h = static_cast<u32>(fb_h_f + 0.5f);
renderer.begin_frame(fb_w, fb_h, region);
renderer.draw(plot);
renderer.end_frame();
// `end_frame` leaves the GL state neutral and the viewport at the plot's
// rect; ImGui's backend sets its own viewport, blend function and so on
// before its first draw, so the chrome renders correctly after this.
}
ImGui::EndChild();
ImGui::PopStyleColor();
}
void PlotWidget::forward_input(bool hovered) {
ImGuiIO& io = ImGui::GetIO();
const ImVec2 scale = io.DisplayFramebufferScale;
// ImGui reports "the pointer is not on this window" as a sentinel position:
// imgui_impl_glfw's cursor-enter callback leaves (-FLT_MAX, -FLT_MAX) when the
// pointer leaves, and ClearInputMouse() does the same on a lost focus. Scaling
// that and differencing it against the last real position would hand the
// controller a ~1e35 "movement" for that frame -- and with a press captured over
// the plot, that movement is a pan, so the range would leave the representable
// world (and only `f` would bring it back, since a gesture that sets a range also
// clears auto scale). An invalid position is therefore no position *update*: the
// last real one stands, the movement is zero, and the button levels below still
// flow, so the release that ends a drag is never lost.
const bool pos_valid = ImGui::IsMousePosValid(&io.MousePos);
const f32 px = pos_valid ? io.MousePos.x * scale.x : prev_x_;
const f32 py = pos_valid ? io.MousePos.y * scale.y : prev_y_;
const bool down[3] = {io.MouseDown[0], io.MouseDown[1], io.MouseDown[2]};
const bool any_down = down[0] || down[1] || down[2];
const u32 mods = mods_from(io);
if ((!pos_valid && !have_prev_pos_) || (!hovered && !capturing_)) {
// Either no position has ever been reported (nothing to aim at, no movement
// to forward), or the pointer is nowhere near the plot. Remember the levels
// either way, so re-entering the region does not synthesise an edge out of the
// state it left with -- and so a re-bound plot, which keeps its state too,
// cannot fabricate one either.
prev_down_[0] = down[0];
prev_down_[1] = down[1];
prev_down_[2] = down[2];
if (pos_valid) {
prev_x_ = px;
prev_y_ = py;
have_prev_pos_ = true;
}
return;
}
// The movement since the previous frame is what a pan consumes; it rides the
// first event of this frame and nothing else, so one frame's motion can never
// be counted twice. An invalid position contributes none of it.
const f32 dx = (pos_valid && have_prev_pos_) ? px - prev_x_ : 0.0f;
const f32 dy = (pos_valid && have_prev_pos_) ? py - prev_y_ : 0.0f;
bool delta_pending = true;
const auto deliver = [&](input::Button button, bool pressed, bool released) {
input::PointerEvent e;
e.x = px;
e.y = py;
if (delta_pending) {
e.dx = dx;
e.dy = dy;
delta_pending = false;
}
e.button = button;
e.mods = mods;
e.down = any_down;
e.pressed = pressed;
e.released = released;
controller_->on_pointer(e);
};
// ImGui reports levels; the controller wants edges. One event per button whose
// level changed, which is the press or the release the host's callback would
// have carried.
for (int i = 0; i < 3; ++i) {
if (down[i] == prev_down_[i]) continue;
deliver(button_from_index(i), down[i], !down[i]);
}
if (delta_pending) {
// No transition this frame: one plain move sample, so a pan in progress
// advances by exactly the movement since the last one and
// Controller::hovered_cursor() sees where the pointer is.
deliver(input::Button::Left, false, false);
}
if (hovered && io.MouseWheel != 0.0f) {
input::WheelEvent e;
e.x = px;
e.y = py;
e.dx = io.MouseWheelH;
e.dy = io.MouseWheel; // ImGui's positive y (scroll up) is the zoom-in sign
e.mods = mods;
controller_->on_wheel(e);
}
// Keyboard gestures, on the same hover gate as the pointer: a key pressed
// while the plot is under the pointer is aimed at the plot. `+` on most
// layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter.
if (hovered && !io.WantTextInput) {
const auto key = [&](int code) {
input::KeyEvent e;
e.key = code;
e.mods = mods;
e.down = true; // ImGui already edge-detected the press
controller_->on_key(e);
};
if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) {
key(input::kKeyZoomIn);
}
if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) {
key(input::kKeyZoomOut);
}
if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit);
}
prev_down_[0] = down[0];
prev_down_[1] = down[1];
prev_down_[2] = down[2];
if (pos_valid) {
prev_x_ = px;
prev_y_ = py;
have_prev_pos_ = true;
}
// Capture: a press delivered while the pointer was over the child keeps the
// release flowing even once the pointer has left it.
capturing_ = any_down && (capturing_ || hovered);
}
void PlotWidget::reset_pointer() noexcept {
// The remembered button levels deliberately survive a re-bind, because they are
// the host's input state and not the plot's: clearing them would make the next
// frame synthesise a press out of a button that is already down, and a host
// drawing two plots through one widget re-binds twice a frame. Only the
// position memory and the capture are dropped, so the new plot's first event
// carries no movement carried over from the old one.
prev_x_ = 0.0f;
prev_y_ = 0.0f;
have_prev_pos_ = false;
capturing_ = false;
}
} // namespace plot::imgui
+166
View File
@@ -0,0 +1,166 @@
#pragma once
#include <optional>
#include <plot/core/rect.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
namespace plot::imgui {
/// Embeds a `Plot` in a Dear ImGui window as a child region, and drives that plot
/// from ImGui's own input state.
///
/// `draw()` opens a child window of the requested size, translates its screen
/// rectangle into framebuffer pixels, hands that rectangle to
/// `Renderer::begin_frame()` and draws the plot into it: a scissor- and
/// viewport-bounded sub-rectangle of the very framebuffer ImGui is about to draw
/// its chrome into, not an offscreen texture. Nothing is rendered through a
/// texture or an FBO, and ImGui's frame is left undisturbed -- the renderer
/// clears only its own rect and restores the GL state it changed, and ImGui's
/// backend re-establishes its viewport and blend state before its first draw.
///
/// Two pixel spaces meet here, exactly as they do in the GLFW adapter. ImGui
/// lays out and reports in logical *points* -- `io.DisplaySize`, the cursor's
/// screen position, a child's content region -- while this library speaks
/// *framebuffer pixels*. The bridge is `io.DisplayFramebufferScale`, which the
/// platform backend fills in from the window and framebuffer sizes; on an
/// unscaled display it is (1, 1) and nothing changes.
///
/// ## The coordinate assumption
///
/// Points -> pixels through `io.DisplayFramebufferScale` is *the whole
/// framebuffer's* mapping only when both of these hold:
///
/// * the GL viewport the host is drawing its frame with is the whole
/// framebuffer -- origin (0, 0), size `DisplaySize * DisplayFramebufferScale`
/// -- and
/// * the bound draw target is the default framebuffer, not an offscreen one.
///
/// Under that assumption -- the one every ImGui platform backend documents, and
/// what a window that spans its framebuffer gives -- the child's point rectangle
/// is the pixel rectangle the plot is laid out and scissored into, and a gesture
/// lands on the pixel it was aimed at. A host that renders into an FBO, or with
/// a viewport that is not the whole framebuffer (a tiled compositor, a scaled
/// surface, a pass into a texture), breaks the assumption. This widget cannot
/// detect it: it has no view of the host's viewport and no way to ask. Such a
/// host must place the plot itself -- `Renderer::begin_frame()` takes the
/// framebuffer size and the plot's rect directly, so it needs no widget -- or
/// apply its own points -> pixels mapping on top of `plot.set_viewport()`.
///
/// ## What the widget draws over
///
/// The widget renders *immediately*, from inside the child, before ImGui renders
/// its draw data (see the frame order in the demo). It therefore writes the
/// child's pixels before every ImGui command of that frame, and ImGui has no way
/// to be told the child is opaque. The host must not paint over the child's
/// rectangle:
///
/// * the hosting window must not fill its background across the child -- use
/// `ImGui::SetNextWindowBgAlpha(0.0f)` or `ImGuiWindowFlags_NoBackground`
/// (`ImGuiCol_WindowBg`'s default alpha would otherwise dim the plot), and
/// * no other window may overlap the rectangle with opaque content.
///
/// The child's own background is forced transparent by the widget, whatever the
/// host's `ImGuiCol_ChildBg`, so the widget itself never paints over the plot.
///
/// The converse also holds: what ImGui clips away, the widget does not paint. GL
/// draws do not go through ImGui's clipping, so the region is intersected with the
/// clip rect ImGui gives the child (its own rectangle when nothing cuts it off,
/// the visible part of it when an ancestor does) -- a child the hosting window cuts
/// off must not clear and paint over whatever the host drew beside it.
///
/// ## Input
///
/// Pointer, wheel and key state are translated into `plot::input` events and
/// delivered to the plot's `Controller` -- the one the widget owns, reachable
/// through `controller()`. ImGui reports levels (`io.MouseDown`) where the
/// controller wants edges (`PointerEvent::pressed`/`released`), so the widget
/// remembers the previous frame's buttons and synthesises the transitions.
///
/// Events are forwarded while an invisible item covering the child's visible
/// region is hovered -- `IsItemHovered()` rather than `IsWindowHovered()`, because
/// the item is the child's whole rectangle (the child has no padding) and its hover
/// is false while the host is dragging some other widget, so dragging a slider next
/// to the plot cannot pan it. A press that started over the child keeps being
/// delivered until every button is up again, so a drag that leaves the region still
/// ends instead of leaving the plot mid-pan.
///
/// An *invalid* pointer position -- ImGui's `(-FLT_MAX, -FLT_MAX)` sentinel for "the
/// pointer is not on this window", which the platform backends leave behind when the
/// pointer leaves or the window loses focus -- is no position update: the last real
/// position stands, the movement for that frame is zero (a sentinel scaled by the
/// framebuffer scale and differenced against a real position would be a ~1e35 pan),
/// and the button levels still flow, so a drag that leaves the window still ends on
/// its release.
///
/// Wheel input goes to the plot while it is hovered, and the plot's zoom wins
/// over ImGui's window scrolling: `SetItemKeyOwner(ImGuiKey_MouseWheelY)` on that
/// invisible item makes ImGui withhold the wheel from the hosting window. The
/// claim takes effect from the frame after it is made, because ImGui decides
/// window scrolling in its own `NewFrame()` before any widget runs -- so a
/// hosting window with a scroll range can still scroll on the very first wheel of
/// a hover that has only just started. (The child's
/// `ImGuiWindowFlags_NoScrollWithMouse` would not achieve this on its own: ImGui
/// deliberately bubbles the wheel up to the parent when a child refuses it.)
/// ImGui consumes `io.MouseWheel` itself at the end of the frame, so a widget
/// cannot consume it any later than this.
///
/// Not thread-safe: like everything else in the render thread's frame.
class PlotWidget {
public:
PlotWidget() noexcept = default;
PlotWidget(const PlotWidget&) = delete;
PlotWidget& operator=(const PlotWidget&) = delete;
/// Allocates a child region of `size` (ImGui points) identified by `id`, and
/// renders `plot` into the framebuffer pixels it covers with `renderer`, which
/// must already be initialised on the current context.
///
/// Must be called between `ImGui::NewFrame()` and `ImGui::Render()`, from a
/// window; the plot is drawn (GL calls issued) during the call, inside the
/// child, so the host's later ImGui commands never have to restore anything.
///
/// The plot's viewport is the child's visible rectangle, so it fills the child
/// exactly; the widget sets it every frame through the controller, and the plot
/// is re-laid out to match. A degenerate or entirely
/// clipped region draws nothing and forwards nothing, and a partially clipped one
/// draws and accepts gestures only where the host leaves the child visible.
void draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer);
/// The controller the widget feeds, or null before the first `draw()`. It is
/// bound to whichever plot the last `draw()` was given; a `draw()` for a
/// different plot re-binds it and drops any drag in progress. The remembered
/// button levels survive the re-bind -- they are the host's state, not the
/// plot's -- so a host drawing two plots through one widget does not hand the
/// second one a fabricated press.
input::Controller* controller() noexcept { return controller_ ? &*controller_ : nullptr; }
const input::Controller* controller() const noexcept {
return controller_ ? &*controller_ : nullptr;
}
private:
/// Synthesises this frame's `plot::input` events from ImGui's levels.
void forward_input(bool hovered);
/// Drops the remembered pointer position and any capture, keeping the remembered
/// button levels: they are the host's state, and synthesising an edge out of the
/// levels a re-bind started with would fabricate a press.
void reset_pointer() noexcept;
Plot* plot_ = nullptr;
std::optional<input::Controller> controller_;
/// The previous frame's button levels, in ImGui's order (left, right, middle).
bool prev_down_[3] = {false, false, false};
f32 prev_x_ = 0.0f; ///< previous pointer position, in framebuffer pixels
f32 prev_y_ = 0.0f;
bool have_prev_pos_ = false;
/// True while a button pressed over the child is still down: keeps the release
/// flowing even after the pointer has left the region.
bool capturing_ = false;
};
} // namespace plot::imgui
+56
View File
@@ -0,0 +1,56 @@
# The Qt6 adapter: a QOpenGLWidget that renders a plot into its own framebuffer
# and drives it from Qt's events. It is the third host, and the first whose
# toolkit already owns a GL widget -- so the interesting parts are the surface
# format the application must have set before the widget exists (documented on
# the class) and the device-pixel-ratio mapping between Qt's logical coordinates
# and the framebuffer pixels the library speaks.
#
# Unlike the GLFW and ImGui adapters this one is not fetched: Qt is a system SDK,
# and a project that enables this target is asking for the installed one.
find_package(Qt6 QUIET COMPONENTS Core Gui Widgets OpenGLWidgets)
if(NOT Qt6_FOUND)
message(FATAL_ERROR
"plot: PLOT_BUILD_QT6 is ON but Qt6 was not found (need the Core, Gui, Widgets"
" and OpenGLWidgets components). Install Qt6 -- on Arch, qt6-base -- or"
" configure with -DPLOT_BUILD_QT6=OFF, or point CMAKE_PREFIX_PATH at the Qt6"
" installation.")
endif()
add_library(plot_qt6 STATIC qt_plot_widget.cpp qt_plot_widget.hpp)
add_library(plot::qt6 ALIAS plot_qt6)
# The installed tree names it plot::qt6, like the build tree (see plot_core's).
set_target_properties(plot_qt6 PROPERTIES EXPORT_NAME qt6)
# The public include root is `adapters/`, so the header is reached as
# <qt6/qt_plot_widget.hpp> -- the toolkit's name first, like every other adapter's.
# The build interface is that root; the install one is the prefix's `include`,
# where both headers are installed as <qt6/...> -- the same spelling. The pointer
# anchor in qt_pointer.hpp is internal API, but it still ships: the widget's header
# includes it, so a consumer that gets one without the other does not compile.
target_include_directories(plot_qt6 PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/..>
$<INSTALL_INTERFACE:include>)
# Qt is PUBLIC, unlike GLFW and ImGui: the widget's header derives from
# QOpenGLWidget, so a consumer needs Qt's headers and libraries to use it at all.
target_link_libraries(plot_qt6 PUBLIC
plot::core
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::OpenGLWidgets)
# The widget declares Q_OBJECT, so its header has to go through moc. The property
# is set on this target rather than globally: no other target in the project has a
# Qt class in it, and AUTOMOC scans every source of a target it is set on.
set_target_properties(plot_qt6 PROPERTIES AUTOMOC ON)
plot_apply_warnings(plot_qt6)
# Qt is a system SDK found with find_package() -- a missing Qt6 is a configure
# error above, never a fetch -- so the installed config file can re-find it with
# the same components and the widget's public Qt link interface is satisfiable.
plot_record_adapter_export(plot_qt6 TRUE "Qt6 was resolved by find_package"
"find_dependency(Qt6 COMPONENTS Core Gui Widgets OpenGLWidgets)\n")
if(PLOT_INSTALL)
install(FILES qt_plot_widget.hpp qt_pointer.hpp DESTINATION include/qt6)
endif()
+302
View File
@@ -0,0 +1,302 @@
#include <qt6/qt_plot_widget.hpp>
#include <qt6/qt_pointer.hpp>
#include <plot/gl/loader.hpp>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QOpenGLContext>
#include <QPointF>
#include <QWheelEvent>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
namespace plot::qt {
namespace {
/// The entry points `QOpenGLContext::getProcAddress()` could not provide, as
/// passed to `plot::gl::load()`. `load()` may still resolve them through its
/// dlsym fallback, but only the context-aware resolver is guaranteed to return
/// pointers valid for the current context, so a fallback is worth reporting.
/// Process-global and written only from the context's owning thread, which is the
/// only thread `initializeGL()` runs on.
constexpr u32 kMaxUnresolved = 16;
const char* g_unresolved[kMaxUnresolved] = {};
u32 g_unresolved_count = 0;
/// Qt's own resolver for the current context, as `plot::gl::ProcLoader`.
///
/// `getProcAddress()` returns Qt's `QFunctionPointer` (`void (*)()`), the loader
/// wants `void*`: a function pointer respelled as an object pointer. POSIX
/// guarantees the two have the same representation, and the bit copy states that
/// where a `reinterpret_cast` is merely conditionally supported -- the same
/// reasoning the GLFW adapter uses for `glfwGetProcAddress`.
void* resolve_via_qt(const char* name) noexcept {
QOpenGLContext* context = QOpenGLContext::currentContext();
if (context == nullptr) return nullptr;
const QFunctionPointer proc = context->getProcAddress(name);
void* out = nullptr;
static_assert(sizeof(out) == sizeof(proc), "function and object pointers must match");
std::memcpy(&out, &proc, sizeof(out));
if (out == nullptr && g_unresolved_count < kMaxUnresolved) {
g_unresolved[g_unresolved_count++] = name;
}
return out;
}
/// Qt's modifier flags as the controller's translatable mask. Unlike GLFW's
/// GLFW_MOD_* these are Qt's own bits, so this is a genuine translation and not
/// the reinterpretation the GLFW adapter gets away with.
u32 mods_from(Qt::KeyboardModifiers mods) noexcept {
u32 out = 0;
if (mods.testFlag(Qt::ShiftModifier)) out |= static_cast<u32>(input::Mod::Shift);
if (mods.testFlag(Qt::ControlModifier)) out |= static_cast<u32>(input::Mod::Ctrl);
if (mods.testFlag(Qt::AltModifier)) out |= static_cast<u32>(input::Mod::Alt);
if (mods.testFlag(Qt::MetaModifier)) out |= static_cast<u32>(input::Mod::Super);
return out;
}
/// The controller has no gesture for the auxiliary buttons, and Qt always names
/// one of the three on a press or a release -- the only events whose button is
/// read. A move's `Qt::NoButton` maps to Left, which nothing looks at.
input::Button button_from(Qt::MouseButton button) noexcept {
switch (button) {
case Qt::MiddleButton: return input::Button::Middle;
case Qt::RightButton: return input::Button::Right;
default: return input::Button::Left;
}
}
/// True while any button the controller has a gesture for is held.
bool any_plot_button(Qt::MouseButtons buttons) noexcept {
return buttons.testFlag(Qt::LeftButton) || buttons.testFlag(Qt::MiddleButton) ||
buttons.testFlag(Qt::RightButton);
}
f64 now_s() noexcept {
return std::chrono::duration<f64>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
} // namespace
PlotWidget::PlotWidget(Plot& plot, QWidget* parent)
: QOpenGLWidget(parent), plot_(&plot), controller_(plot) {
// Hover has to reach the plot: the controller's cursor hit test reads the last
// pointer position even while no button is down.
setMouseTracking(true);
// The controller's keys (+, - and f) are input to the plot, and a widget without
// a focus policy never receives a key event.
setFocusPolicy(Qt::StrongFocus);
}
PlotWidget::~PlotWidget() {
// The renderer's RAII handles call glDelete*, which needs the context current
// and alive. This body runs before ~QOpenGLWidget tears the context down, which
// is what makes makeCurrent() valid here -- and `renderer_` is engaged only if
// initializeGL() ran, so there is a context to make current.
if (renderer_) {
makeCurrent();
renderer_.reset();
doneCurrent();
}
}
void PlotWidget::initializeGL() {
initializeOpenGLFunctions();
// A second call means Qt recreated the context (the widget moved to another
// screen or window): the names the renderer holds belonged to the old context,
// and nothing here may outlive it. This runs before anything of ours is created
// on the new context, so the deletes it issues can only name objects that are
// not there.
renderer_.reset();
g_unresolved_count = 0;
const bool loaded = plot::gl::load(&resolve_via_qt);
// Printed before the failure branch, not after it: the names collected here are
// precisely what explains a failure -- which 3.3 entry point this context could
// not provide -- so a load that failed must report them too, or the one failure
// this widget can have says nothing but "missing a required entry point".
if (g_unresolved_count > 0) {
std::fprintf(stderr,
"[plot::qt6] QOpenGLContext::getProcAddress() did not resolve %u required"
" entry point(s); plot::gl::load() %s for:",
g_unresolved_count, loaded ? "used its dlsym fallback" : "failed");
for (u32 i = 0; i < g_unresolved_count; ++i) std::fprintf(stderr, " %s", g_unresolved[i]);
std::fprintf(stderr, "\n");
}
if (!loaded) {
init_status_ = Status{ErrorCode::Unsupported,
"plot::gl::load() failed: the current context is missing a required"
" OpenGL 3.3 entry point"};
std::fprintf(stderr, "[plot::qt6] %s\n", init_status_.message);
return;
}
renderer_.emplace();
Status status;
if (!renderer_->init(&status)) {
init_status_ = status.code != ErrorCode::Ok
? status
: Status{ErrorCode::GLFailure, "Renderer::init() failed"};
// Leave the renderer engaged: its destructor is what releases whatever the
// partial init did build, and it runs with the context current.
std::fprintf(stderr, "[plot::qt6] Renderer::init(): %s: %s\n", to_string(init_status_.code),
init_status_.message);
return;
}
init_status_ = Status{};
refresh_viewport();
}
void PlotWidget::resizeGL(int w, int h) {
(void)w;
(void)h;
// Nothing about the size is cached: `paintGL()` derives the viewport from the
// live geometry every frame, so a frame can never be drawn at a stale size.
// Refreshing it here as well means a gesture that arrives between the resize and
// the next paint maps against the new geometry rather than the old one.
refresh_viewport();
}
void PlotWidget::paintGL() {
if (!renderer_ || !renderer_->ready()) return;
const Rect viewport = device_viewport();
if (viewport.w < 1.0f || viewport.h < 1.0f) return; // nothing to draw into
// The controller owns no viewport: this hands it to the plot (which re-lays the
// chrome out with it) and refreshes the layout, so every frame is laid out for
// the geometry of *this* frame. A resize changes the layout; it never scales a
// stale one.
controller_.set_viewport(viewport);
const f64 started = now_s();
const f64 dt = last_paint_s_ >= 0.0 ? started - last_paint_s_ : 0.0;
last_paint_s_ = started;
if (frame_) frame_(*plot_, dt);
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
renderer_->draw(*plot_);
renderer_->end_frame();
const f32 frame_ms = static_cast<f32>((now_s() - started) * 1.0e3);
last_frame_ms_ = frame_ms;
// Seeded by the first frame rather than started at zero, so the mean is the mean
// of the frames that ran from the first one.
mean_frame_ms_ = frames_ == 0 ? frame_ms : mean_frame_ms_ + 0.1f * (frame_ms - mean_frame_ms_);
last_viewport_ = viewport;
++frames_;
}
Rect PlotWidget::device_viewport() const noexcept {
// Logical -> device pixels, rounded: at 1.25 a 1023-pixel-wide widget is
// 1278.75 device pixels, which must cover 1279 framebuffer columns or the last
// one is left outside the plot (and outside the renderer's clear).
const f64 dpr = static_cast<f64>(devicePixelRatioF());
const long w = std::lround(static_cast<f64>(width()) * dpr);
const long h = std::lround(static_cast<f64>(height()) * dpr);
return Rect{0.0f, 0.0f, static_cast<f32>(w), static_cast<f32>(h)};
}
Vec2 PlotWidget::to_device(const QPointF& p) const noexcept {
// The same factor `device_viewport()` scales the widget by, applied to a
// position and to the deltas derived from two of them. One convention on both
// sides is what makes a gesture land on the data under the cursor.
const f64 dpr = static_cast<f64>(devicePixelRatioF());
return Vec2{static_cast<f32>(p.x() * dpr), static_cast<f32>(p.y() * dpr)};
}
void PlotWidget::refresh_viewport() { controller_.set_viewport(device_viewport()); }
void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released) {
// The anchor stays logical (see PointerAnchor): a move that arrives after the
// window moved to a display with another scale factor must not measure the gap
// between a position taken at the old DPR and one taken at the new one. The
// delta is the logical difference scaled by the ratio in effect now, so the same
// gesture maps to the same device distance at either scale.
const QPointF position = event->position();
const f64 dpr = static_cast<f64>(devicePixelRatioF());
const Vec2 logical{static_cast<f32>(position.x()), static_cast<f32>(position.y())};
const Vec2 p = to_device(position);
const Vec2 delta = anchor_.device_delta(logical, dpr);
input::PointerEvent e;
e.x = p.x;
e.y = p.y;
e.dx = delta.x;
e.dy = delta.y;
// A press is also a pointer sample: the next move's delta starts here, or the
// first motion of a drag would include the jump to the click.
anchor_.set(logical);
e.button = button_from(event->button());
e.mods = mods_from(event->modifiers());
e.down = any_plot_button(event->buttons());
e.pressed = pressed;
e.released = released;
controller_.on_pointer(e);
event->accept();
update();
}
void PlotWidget::mousePressEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
void PlotWidget::mouseMoveEvent(QMouseEvent* event) { forward_pointer(event, false, false); }
void PlotWidget::mouseReleaseEvent(QMouseEvent* event) { forward_pointer(event, false, true); }
void PlotWidget::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
void PlotWidget::wheelEvent(QWheelEvent* event) {
// Qt reports eighths of a degree, 120 to a notch; the controller takes notches,
// positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel
// pushed away from the user). Passing the raw delta would zoom 120x too far.
const f32 notches = static_cast<f32>(event->angleDelta().y()) / 120.0f;
if (notches == 0.0f) {
// A horizontal-only wheel is not a zoom, and it must not reach the controller:
// a zero-notch zoom changes no range but still runs the range setters, which
// clear auto scale -- so a stray horizontal scroll would silently stop a live
// axis from following its stream. Ignored, so an enclosing scroll area may
// still act on it.
event->ignore();
return;
}
const Vec2 p = to_device(event->position());
input::WheelEvent e;
e.x = p.x;
e.y = p.y;
e.dx = static_cast<f32>(event->angleDelta().x()) / 120.0f;
e.dy = notches;
e.mods = mods_from(event->modifiers());
controller_.on_wheel(e);
event->accept();
update();
}
void PlotWidget::keyPressEvent(QKeyEvent* event) {
int mapped = 0;
switch (event->key()) {
// The controller zooms on '+', which on most layouts is shift+'='; '=' is
// mapped to it as well, so both spellings zoom in -- the pair the GLFW adapter
// maps. Shift+'-' arrives as its own key, and zooms out like '-'.
case Qt::Key_Plus:
case Qt::Key_Equal: mapped = input::kKeyZoomIn; break;
case Qt::Key_Minus:
case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break;
case Qt::Key_F: mapped = input::kKeyFit; break;
default:
QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it
return;
}
input::KeyEvent e;
e.key = mapped;
e.mods = mods_from(event->modifiers());
// Qt reports a held key as repeated presses and sends no release until the key
// is physically released, so the repeat is what makes a held key keep acting.
e.down = true;
controller_.on_key(e);
event->accept();
update();
}
} // namespace plot::qt
+216
View File
@@ -0,0 +1,216 @@
#pragma once
// The plot headers come first, and must. Qt's <QOpenGLWidget> reaches
// <QtGui/qopengl.h>, which on a desktop platform includes the system <GL/gl.h>;
// that header defines GL_TRUE, GL_COLOR_BUFFER_BIT, GL_TEXTURE_2D and the rest as
// preprocessor macros, and those are exactly the names plot/gl/api.hpp declares as
// enumerators -- a macro rewrite turns each definition into nonsense. Parsing
// api.hpp before the macros exist is what lets the two coexist (the GLFW adapter
// sidesteps the same clash with GLFW_INCLUDE_NONE, which Qt offers no equivalent
// of). A translation unit that uses this widget must keep the same order for
// anything it includes itself, and must not name a `plot::gl::GL_*` enumerator
// after a Qt header that reaches GL has been included: the macro rewrites that
// token wherever it appears, namespace and all.
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
#include <qt6/qt_pointer.hpp>
#include <QOpenGLFunctions>
#include <QOpenGLWidget>
#include <functional>
#include <optional>
#include <utility>
class QKeyEvent;
class QMouseEvent;
class QPointF;
class QWheelEvent;
namespace plot::qt {
/// Embeds a `Plot` in a Qt widget: a `QOpenGLWidget` whose framebuffer the plot
/// is rendered into, with Qt's own input events translated into `plot::input`
/// ones.
///
/// `QOpenGLWidget` renders into an FBO of its own, which it binds before calling
/// `paintGL()` -- exactly the "currently bound framebuffer" `Renderer` documents
/// it draws into. The widget therefore never creates a target of its own, and the
/// renderer's scissor-cleared viewport is the widget's whole rectangle. Nothing is
/// rendered to a texture or through a second pass: the plot is what Qt composes,
/// and the widget's other content (Qt draws none; a `QOpenGLWidget` is opaque)
/// would be drawn around it.
///
/// ## The surface format is a precondition, not a setting
///
/// This widget cannot ask for a context version: Qt hands a widget the
/// *application default* `QSurfaceFormat` at the moment its context is created,
/// and a context cannot be renegotiated afterwards. An embedding application must
/// therefore have done this before the widget exists:
///
/// QSurfaceFormat fmt;
/// fmt.setVersion(3, 3);
/// fmt.setProfile(QSurfaceFormat::CoreProfile);
/// fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
/// fmt.setDepthBufferSize(0);
/// fmt.setStencilBufferSize(0);
/// QSurfaceFormat::setDefaultFormat(fmt);
/// QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); // before QApplication
///
/// The last line matters as much as the rest: an ES or ANGLE backend has no 3.3
/// core profile to give, and this library's loader resolves desktop entry points.
/// `initializeGL()` reports the failure through `init_status()` rather than
/// asserting, but it cannot repair a format that was already decided.
///
/// ## The coordinate convention: device pixels, both sides
///
/// Two pixel spaces meet here, exactly as they do in the GLFW and ImGui adapters.
/// Qt reports widget geometry and mouse positions in **logical** pixels
/// (`width()`, `QMouseEvent::position()`), while the widget's framebuffer -- what
/// `Renderer::begin_frame()` takes and what every `Rect` in this library speaks --
/// is in **device** pixels, `devicePixelRatioF()` times as large on a scaled
/// display.
///
/// This widget converts everything into device pixels and keeps it there:
///
/// * the viewport handed to the plot is `round(width() * dpr)` x
/// `round(height() * dpr)` (rounded, not truncated, so the last column of the
/// FBO is inside the plot), and
/// * every pointer and wheel position is multiplied by the same
/// `devicePixelRatioF()` before it reaches the controller.
///
/// `device_viewport()` and `to_device()` are the only two places that mapping
/// exists, so the viewport and the input cannot disagree -- and they must not: a
/// mismatch renders correctly but pans by the wrong amount, which is invisible on
/// an unscaled display and wrong on every other one.
///
/// The *anchor* a move's delta is measured from is the one thing kept in logical
/// pixels: `devicePixelRatioF()` can change between two move events, and the delta
/// is then a logical difference scaled by the ratio in effect now, never the
/// difference of two device positions taken at different ratios (see
/// `PointerAnchor`). A drag that crosses onto a display with another scale factor
/// therefore pans by the distance the pointer moved, not by a cross-scale jump.
///
/// ## Input
///
/// Qt reports levels and edges directly (`QMouseEvent` press/release are edges,
/// `buttons()` is the state after the event), so the translation is a
/// reinterpretation rather than the edge synthesis the ImGui adapter needs.
/// Wheel input is the one unit conversion: Qt's `angleDelta()` is in eighths of a
/// degree, 120 to a notch, while `Controller::on_wheel()` takes notches, positive
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event with
/// no vertical component is refused before it reaches the controller, because a
/// zero-notch zoom changes no range but still runs the range setters, which clear
/// auto scale: a horizontal scroll must not silently stop a live axis following.
///
/// ## Threading
///
/// Qt widgets live on the GUI thread, and so does everything here: `initializeGL`,
/// `paintGL` and the event handlers all run there, which is the thread that owns
/// the context. A host that feeds the plot from a producer thread acquires its
/// data on this thread (see the frame callback).
class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
Q_OBJECT
public:
/// One frame of host code, run by `paintGL()` after the viewport is set and
/// before the frame is staged. It is where data is refreshed -- acquiring a
/// `data::StreamSeries`, re-fitting a live axis, moving cursors -- and `dt` is
/// the wall time since the previous call, in seconds. Optional; a widget with
/// no callback just re-draws the plot as it stands.
using FrameFn = std::function<void(Plot&, f64)>;
/// Renders and drives `plot`, which must outlive this widget.
explicit PlotWidget(Plot& plot, QWidget* parent = nullptr);
~PlotWidget() override;
PlotWidget(const PlotWidget&) = delete;
PlotWidget& operator=(const PlotWidget&) = delete;
void set_frame_callback(FrameFn frame) { frame_ = std::move(frame); }
/// The plot this widget renders. Valid for the widget's whole life.
Plot& plot() noexcept { return *plot_; }
const Plot& plot() const noexcept { return *plot_; }
/// The controller the widget feeds, bound to `plot()`.
input::Controller& controller() noexcept { return controller_; }
const input::Controller& controller() const noexcept { return controller_; }
/// The renderer, or null until a successful `initializeGL()`. Valid from the
/// corresponding `paintGL()` onwards, and after the widget's context exists.
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
const Renderer* renderer() const noexcept { return renderer_ ? &*renderer_ : nullptr; }
/// What `initializeGL()` made of the context: `Ok`, or the failure that left
/// the widget drawing nothing. A `QOpenGLWidget` virtual cannot return a
/// `Status`, and a widget must not throw across Qt's callback, so the outcome
/// is recorded here instead.
const Status& init_status() const noexcept { return init_status_; }
/// Frames actually staged, and the frame time of the last one (ms). The mean is
/// an exponential moving average seeded by the first frame, of the same
/// render-only span: the frame callback, `begin_frame`, `draw` and `end_frame`.
u64 frames_painted() const noexcept { return frames_; }
f32 last_frame_ms() const noexcept { return last_frame_ms_; }
f32 mean_frame_ms() const noexcept { return mean_frame_ms_; }
/// The region the last frame was drawn into, in device pixels -- the rect
/// `begin_frame()` was handed. Zero-sized before the first frame.
Rect last_device_viewport() const noexcept { return last_viewport_; }
protected:
void initializeGL() override;
void resizeGL(int w, int h) override;
void paintGL() override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
/// Qt reports the second press of a double click as this event and not as a
/// press, so without it a drag that began with a double click would have no
/// press to start from. Forwarded as one.
void mouseDoubleClickEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
private:
/// The widget's rectangle in device pixels: the plot's viewport, and the size
/// of the framebuffer the frame is drawn into.
Rect device_viewport() const noexcept;
/// A Qt position (logical widget pixels) in device pixels. The one place a
/// pointer position is mapped, so everything the controller sees is scaled by
/// the same factor the viewport is -- a move's *delta*, whose ratio can change
/// between two events, is scaled by `PointerAnchor` instead.
Vec2 to_device(const QPointF& p) const noexcept;
/// Translates one mouse event and hands it to the controller. `pressed` and
/// `released` are the transitions Qt reports on the event itself.
void forward_pointer(QMouseEvent* event, bool pressed, bool released);
/// Sets the plot's viewport to the current device rect (through the controller,
/// which refreshes the layout with it).
void refresh_viewport();
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
input::Controller controller_;
std::optional<Renderer> renderer_;
FrameFn frame_;
Status init_status_;
/// The position a move's delta is measured from, in **logical** pixels: Qt
/// reports no delta of its own, and the ratio can change between two events
/// (see `PointerAnchor` for why the anchor must not be device pixels).
PointerAnchor anchor_;
u64 frames_ = 0;
f32 last_frame_ms_ = 0.0f;
f32 mean_frame_ms_ = 0.0f;
/// Wall clock at the last `paintGL()` start, or negative before the first one.
f64 last_paint_s_ = -1.0;
Rect last_viewport_{};
};
} // namespace plot::qt
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot::qt {
/// The cached pointer position a move event's movement is measured from, in
/// **logical** widget pixels.
///
/// Logical, not device, pixels on purpose. Qt reports every widget position in
/// logical pixels, and the device-pixel ratio can change between two move events
/// -- the window being dragged onto a monitor with another scale factor is the
/// case -- so an anchor cached in device pixels would compare a position computed
/// at the old ratio with one computed at the new one. At 1.0 -> 1.25 a logical
/// position of 500 would then look like a jump of 125 device pixels, and
/// `Controller::pan` applies a delta as a fraction of the range span (and clears
/// auto scale), so a scale change mid-drag would pan the plot by 125 pixels' worth
/// of data. Keeping the anchor logical and scaling the *difference* by the ratio
/// in effect now makes the same gesture map to the same device distance whatever
/// the window's scale is.
///
/// A plain value with no toolkit dependency: the widget owns one, and this is the
/// part of the adapter's translation a test can drive directly (see
/// tests/test_qt_pointer.cpp).
struct PointerAnchor {
bool have = false; ///< false until the first sample, which has no movement
f32 x = 0.0f; ///< last logical position, widget pixels
f32 y = 0.0f;
/// Movement from the cached position to `logical`, in device pixels at `dpr`.
/// Zero before the first sample, so the first motion of a drag never includes
/// the jump to where the drag began.
Vec2 device_delta(Vec2 logical, f64 dpr) const noexcept {
if (!have) return Vec2{0.0f, 0.0f};
return Vec2{static_cast<f32>((static_cast<f64>(logical.x) - static_cast<f64>(x)) * dpr),
static_cast<f32>((static_cast<f64>(logical.y) - static_cast<f64>(y)) * dpr)};
}
/// Caches `logical` as the position the next movement is measured from. Always
/// logical pixels, whatever the ratio is: see the type's comment.
void set(Vec2 logical) noexcept {
x = logical.x;
y = logical.y;
have = true;
}
};
} // namespace plot::qt
+23
View File
@@ -0,0 +1,23 @@
@PACKAGE_INIT@
# Package config for `plot`. Include it with
#
# find_package(plot 0.1 REQUIRED)
# target_link_libraries(your_target PRIVATE plot::core)
#
# `plot::core` is the library, and carries no third-party dependency at all. The
# adapter targets (`plot::glfw`, `plot::qt6`, `plot::gtk4`) are here only when
# their toolkit could be promised to a consumer: an adapter whose toolkit the
# build fetched with FetchContent is deliberately not installed, because this
# file has nothing to point a consumer at -- see the build's configure log for
# which adapters were exported and which were skipped, and why.
#
# Those toolkits are re-found below, before plotTargets.cmake is included: the
# adapter targets name them in their own link interfaces, so the imported targets
# have to exist first.
include(CMakeFindDependencyMacro)
@PLOT_ADAPTER_DEPENDENCIES@
include("${CMAKE_CURRENT_LIST_DIR}/plotTargets.cmake")
check_required_components(plot)
+22
View File
@@ -0,0 +1,22 @@
# LeakSanitizer suppressions for the `linux-asan` build (PLOT_SANITIZE=address).
# Pointed at by tests/CMakeLists.txt, which sets ASAN_OPTIONS on the test itself
# so `ctest --test-dir build/asan` gets it however it is launched.
#
# The boundary this file exists to hold: a sanitizer report from *our* code is a
# finding and must fail the run, so nothing here may ever suppress a frame under
# include/plot/ or src/. Only the GL driver may be named -- an EGL/GL stack
# (Mesa's libEGL, libgallium, libglapi, radeonsi's ACO, or the LLVM JIT behind
# llvmpipe) is a system library this project does not build and cannot fix, and
# it is loaded only by the headless EGL context the GL tests create.
#
# It is currently empty of entries on purpose. The asan suite runs clean against
# the installed driver -- 127 cases, no report of any origin (the leak checker was
# confirmed live with a deliberate 1 KB leak, which does report and exit 1) -- and
# a suppression nobody has needed is a suppression nobody can justify. If a driver
# report does appear, add the narrowest entry that names the library, e.g.
#
# leak:libEGL.so
# leak:libgallium*
#
# and leave the count in the commit message. The shape is `leak:<substring of a
# frame in the stack>`; ASan takes the same list through ASAN_OPTIONS.
+164
View File
@@ -0,0 +1,164 @@
# 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)
option(PLOT_INSTALL "Generate install and export rules (find_package(plot))" ON)
set(PLOT_SANITIZE "" CACHE STRING
"Sanitizers to build with, as the list a -fsanitize= flag takes (e.g. address); empty for none")
# Sanitizer flags go on every target in this project, fetched third-party sources
# included: a partially instrumented build reports as loudly about the
# instrumentation gap as about any real bug. The flags are one -fsanitize= list
# (PLOT_SANITIZE) rather than a set of per-sanitizer options so the preset, the
# tests' runtime environment and this file cannot disagree about what is on.
if(PLOT_SANITIZE)
add_compile_options(-fsanitize=${PLOT_SANITIZE} -fno-omit-frame-pointer)
add_link_options(-fsanitize=${PLOT_SANITIZE})
endif()
# Read by tests/CMakeLists.txt, which points ASAN_OPTIONS at this project's
# suppressions file when the address sanitizer is on.
string(REPLACE "," ";" PLOT_SANITIZE_LIST "${PLOT_SANITIZE}")
if("address" IN_LIST PLOT_SANITIZE_LIST)
set(PLOT_ASAN_ENABLED TRUE)
endif()
# --- install / export bookkeeping --------------------------------------------
#
# An adapter can only be installed and exported when its toolkit can be found
# again on the consumer's machine. A dependency resolved by find_package() or
# pkg-config can be: the installed config file re-finds it with the very same
# call. A dependency that FetchContent supplied cannot -- there is nothing on the
# consumer's machine to point at, and promising it would make every consumer
# clone and build that toolkit at the same revision -- so those adapters stay out
# of the export set and the configure log says why (see each adapter's
# CMakeLists.txt for its own resolution order).
#
# The results are global properties, because the adapters decide inside their own
# subdirectory while the install rules run at the top level. A property rather
# than a cache entry: a cache value cannot hold the newlines a snippet of
# find_dependency() calls needs, and nothing here has to survive the configure
# pass that produced it.
# Records one adapter's install/export decision and prints it. `dependency` is
# the config-file snippet that re-creates the toolkit's imported target before
# plotTargets.cmake is included; it is empty when the target is not exported.
function(plot_record_adapter_export target exportable reason dependency)
if(NOT exportable)
message(STATUS "plot: not exporting ${target}: ${reason}")
elseif(NOT PLOT_INSTALL)
message(STATUS "plot: not exporting ${target}: PLOT_INSTALL is OFF")
else()
set_property(GLOBAL APPEND PROPERTY PLOT_ADAPTER_EXPORT_TARGETS "${target}")
# Read-modify-write, not APPEND: the snippets are one block of C++-style
# lines that must be substituted into one file, and a property read back is a
# list -- APPEND would join them with the separator ';' instead of a newline.
get_property(plot_dependencies GLOBAL PROPERTY PLOT_ADAPTER_DEPENDENCIES)
set_property(GLOBAL PROPERTY PLOT_ADAPTER_DEPENDENCIES "${plot_dependencies}${dependency}")
message(STATUS "plot: exporting ${target}: ${reason}")
endif()
endfunction()
# 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()
# Resolves GLFW once for every target that needs it -- the GLFW host adapter and
# the Dear ImGui backend both do. Discovery follows the design spec's order:
# find_package first, pkg-config next, FetchContent only when neither can supply
# GLFW (the last resort, because it needs the network). The result is cached in
# PLOT_GLFW_DEP, so a build with several adapters enabled discovers -- and at
# worst fetches -- exactly one copy. Sets <out_var> to the target to link.
#
# PLOT_GLFW_DEP_ORIGIN records which of the three supplied it, because only the
# first two can be promised to a consumer of an installed adapter (see
# plot_record_adapter_export()).
function(plot_find_glfw out_var)
if(NOT DEFINED PLOT_GLFW_DEP)
set(plot_glfw_dep "")
set(plot_glfw_origin "")
find_package(glfw3 3.3 QUIET)
if(TARGET glfw)
set(plot_glfw_dep glfw)
set(plot_glfw_origin find_package)
elseif(TARGET glfw3::glfw3)
set(plot_glfw_dep glfw3::glfw3)
set(plot_glfw_origin find_package)
endif()
if(NOT plot_glfw_dep)
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(GLFW_PC QUIET IMPORTED_TARGET glfw3)
if(TARGET PkgConfig::GLFW_PC)
set(plot_glfw_dep PkgConfig::GLFW_PC)
set(plot_glfw_origin pkg-config)
endif()
endif()
endif()
if(NOT plot_glfw_dep)
message(STATUS "plot: glfw3 not found via find_package or pkg-config; fetching it")
include(FetchContent)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_INSTALL OFF CACHE BOOL "" FORCE)
FetchContent_Declare(glfw
GIT_REPOSITORY https://github.com/glfw/glfw.git
GIT_TAG 3.4
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(glfw)
set(plot_glfw_dep glfw)
set(plot_glfw_origin FetchContent)
endif()
set(PLOT_GLFW_DEP "${plot_glfw_dep}" CACHE INTERNAL
"GLFW target the plot adapters link against")
set(PLOT_GLFW_DEP_ORIGIN "${plot_glfw_origin}" CACHE INTERNAL
"How plot resolved GLFW: find_package, pkg-config or FetchContent")
message(STATUS "plot: GLFW adapter dependency: ${plot_glfw_dep}")
endif()
set(${out_var} "${PLOT_GLFW_DEP}" PARENT_SCOPE)
endfunction()
# The find_dependency() snippet that re-creates an exported adapter's toolkit
# target on a consumer's machine, for the toolkits a config file can re-find.
# Adapters call these when they record their export decision; the snippets are
# substituted into cmake/plotConfig.cmake.in.
function(plot_glfw_dependency_snippet out_var)
if(PLOT_GLFW_DEP_ORIGIN STREQUAL "find_package")
set(${out_var} "find_dependency(glfw3 3.3)\n" PARENT_SCOPE)
else()
set(${out_var}
"find_dependency(PkgConfig)\npkg_check_modules(GLFW_PC REQUIRED IMPORTED_TARGET glfw3)\n"
PARENT_SCOPE)
endif()
endfunction()
+76
View File
@@ -0,0 +1,76 @@
# Keeps include/plot/plot.hpp honest against the plan's Global Constraints:
# every public header under include/plot/ must be included by the umbrella, and
# every <plot/...> the umbrella names must be a file that actually exists.
#
# "Public" is everything the recursive glob finds except the macro-gated
# */detail/ instrumentation, which is documented as not part of the surface and
# deliberately stays out of the umbrella.
#
# Runs twice: tests/CMakeLists.txt includes it at configure time (so a header
# added without its umbrella line stops the configure rather than the run), and
# ctest runs it again as `plot_umbrella_covers_public_headers` via `cmake -P`.
# Both read the same file so a header the build sees is a header this checks --
# there is no hand-maintained list to fall out of date.
#
# Inputs (set by the including scope, or passed with -D in script mode):
# PLOT_INCLUDE_DIR the installed-header root, e.g. <source>/include
# PLOT_UMBRELLA the umbrella header, e.g. <PLOT_INCLUDE_DIR>/plot/plot.hpp
if(NOT DEFINED PLOT_INCLUDE_DIR)
message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set")
endif()
if(NOT DEFINED PLOT_UMBRELLA)
set(PLOT_UMBRELLA "${PLOT_INCLUDE_DIR}/plot/plot.hpp")
endif()
file(GLOB_RECURSE plot_umbrella_headers
RELATIVE "${PLOT_INCLUDE_DIR}"
"${PLOT_INCLUDE_DIR}/plot/*.hpp")
list(SORT plot_umbrella_headers)
file(READ "${PLOT_UMBRELLA}" plot_umbrella_text)
# The umbrella is a public header too, but it cannot include itself.
file(RELATIVE_PATH plot_umbrella_relative "${PLOT_INCLUDE_DIR}" "${PLOT_UMBRELLA}")
# Every other public header is named by the umbrella.
set(plot_umbrella_missing "")
foreach(header IN LISTS plot_umbrella_headers)
if(header MATCHES "/detail/" OR header STREQUAL plot_umbrella_relative)
continue()
endif()
string(FIND "${plot_umbrella_text}" "#include <${header}>" plot_umbrella_pos)
if(plot_umbrella_pos EQUAL -1)
list(APPEND plot_umbrella_missing "${header}")
endif()
endforeach()
if(plot_umbrella_missing)
string(REPLACE ";" "\n " plot_umbrella_missing_text "${plot_umbrella_missing}")
message(FATAL_ERROR
"plot: ${PLOT_UMBRELLA} does not include every public header under "
"include/plot/ -- missing:\n ${plot_umbrella_missing_text}\n"
"Add each as `#include <plot/...>`, or move it under a */detail/ directory "
"if it is implementation-only and not part of the public surface.")
endif()
# And every header the umbrella names is a file that exists (a rename or a
# deleted header cannot leave a dangling include behind).
string(REGEX MATCHALL "#include <plot/[^>]+>" plot_umbrella_includes "${plot_umbrella_text}")
set(plot_umbrella_dangling "")
foreach(plot_umbrella_include IN LISTS plot_umbrella_includes)
string(REGEX REPLACE "#include <([^>]+)>" "\\1" plot_umbrella_header "${plot_umbrella_include}")
if(NOT EXISTS "${PLOT_INCLUDE_DIR}/${plot_umbrella_header}")
list(APPEND plot_umbrella_dangling "${plot_umbrella_header}")
endif()
endforeach()
if(plot_umbrella_dangling)
string(REPLACE ";" "\n " plot_umbrella_dangling_text "${plot_umbrella_dangling}")
message(FATAL_ERROR
"plot: ${PLOT_UMBRELLA} includes headers that do not exist under "
"include/plot/:\n ${plot_umbrella_dangling_text}")
endif()
list(LENGTH plot_umbrella_headers plot_umbrella_header_count)
message(STATUS
"plot: umbrella covers every public header (${plot_umbrella_header_count} headers globbed)")
@@ -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`.
@@ -482,7 +485,8 @@ git add -A && git commit -m "feat(gl): dependency-free GL loader, RAII handles,
**Interfaces:**
- Consumes: `plot::Range`, `plot::f32/f64/u64`, `plot::kInvalid`.
- Produces: `plot::data::RingBuffer`, `plot::data::MinMaxPyramid`, `plot::data::Series`, `plot::data::ArraySeries`, `plot::data::StreamSeries` exactly as declared in spec §6, plus `plot::data::MinMax{min,max}`, `plot::data::kBucket == 64`, and `MinMaxPyramid::sum_range`/`sumsq_range` (exact interval sums, O(kBucket) worst case — consumed by Task 5 measurements and by auto-scale).
- 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**
@@ -493,6 +497,7 @@ git add -A && git commit -m "feat(gl): dependency-free GL loader, RAII handles,
#include <plot/data/minmax_pyramid.hpp>
#include <random>
using plot::data::kBucket;
using plot::data::MinMaxPyramid;
using plot::Range;
@@ -501,28 +506,40 @@ PLOT_TEST(pyramid_bucket_extremes_preserved) {
p.reserve(1000);
for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f));
PLOT_CHECK(p.size() == 1000);
const Range r = p.reduce(0, 1000);
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_matches_bruteforce) {
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 += 137) {
for (u64 b = a + 1; b <= 5000; b += 511) {
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 < b; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
const Range r = p.reduce(a, b);
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);
@@ -544,18 +561,18 @@ PLOT_TEST(pyramid_snap_returns_nearest_sample) {
PLOT_CHECK(p.snap(1e9, 0.0, 1.0) == 99); // clamped
}
PLOT_TEST(pyramid_sums_match_bruteforce) {
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 += 311) {
for (u64 b = a + 1; b <= 3000; b += 419) {
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_range(a, b), s, 1e-3);
PLOT_CHECK_NEAR(p.sumsq_range(a, b), sq, 1e-2);
PLOT_CHECK_NEAR(p.sum_buckets(a, b), s, 1e-3);
PLOT_CHECK_NEAR(p.sumsq_buckets(a, b), sq, 1e-2);
}
}
}
@@ -568,7 +585,9 @@ PLOT_TEST(pyramid_clear_and_rebuild) {
PLOT_CHECK(p.level_count() <= 1);
p.append(7.0f);
PLOT_CHECK(p.size() == 1);
PLOT_CHECK_NEAR(p.reduce(0, 1).max, 7.0, 1e-6);
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);
}
```
@@ -596,6 +615,14 @@ PLOT_TEST(ring_clear_resets) {
`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);
@@ -618,7 +645,7 @@ PLOT_TEST(stream_series_acquire_publishes_snapshot) {
s.acquire();
PLOT_CHECK(s.size() == 100);
// Pyramid must track the published samples.
PLOT_CHECK_NEAR(s.pyramid().reduce(0, 100).max, 99.0, 1e-4);
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);
@@ -631,6 +658,41 @@ PLOT_TEST(stream_series_drops_oldest_when_full) {
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**
@@ -656,39 +718,59 @@ class MinMaxPyramid {
public:
void reserve(u64 n);
void append(f32 v);
void drop_before(u64 absolute_sample); // slide the retained window to an absolute sample
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;
Range reduce(u64 begin, u64 end) const; // half-open [begin, end)
f64 sum_range(u64 begin, u64 end) const; // exact sum over [begin, end)
f64 sumsq_range(u64 begin, u64 end) const; // exact sum of squares
u64 size() const noexcept; // retained-window samples
u64 window_base() const noexcept; // absolute index of window index 0
u64 window_end() const noexcept; // absolute index one past the last append
u32 level_count() const noexcept;
u64 level_base(u32 l) const noexcept; // absolute level-l bucket index of entry 0
u64 level_size(u32 l) const noexcept;
MinMax bucket(u32 l, u64 i) const; // absolute level-l bucket i
// 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.
// Window coordinates: the grid is absolute, so the intersection runs through
// window_base().
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
// One bucket: its extremes plus its own exact sum and sum of squares. The two
// doubles take a bucket from 8 bytes to 24 -- level 0 costs 0.375n bytes, the
// whole pyramid ≈0.75n, ≈19% of 4-byte raw samples -- and are what let
// mean/RMS/integral come from whole buckets. Absolute prefix sums cannot: they
// grow without bound and cannot survive a sliding front.
struct Entry { MinMax mm; f64 sum = 0.0; f64 sumsq = 0.0; };
struct Level { std::deque<Entry> entries; u64 base = 0; }; // absolute bucket space
u64 count_ = 0; // samples ever appended
u64 base_ = 0; // absolute index of the window's oldest sample
u32 live_levels_ = 1;
std::vector<Level> levels_{1};
Entry pending_; // the in-progress level-0 bucket
};
} // namespace plot::data
```
Implementation rules for `src/data/minmax_pyramid.cpp`:
- `append(v)`: write/merge into the last bucket of `levels_[0]`, then for each level `l`, if level `l` gained a complete pair, merge the last two entries of level `l` into a new entry of level `l+1`; otherwise stop early. Amortised O(1).
- `reduce(begin, end)`: `if (end <= begin) return {0,0};` walk the range: handle the partial head bucket and partial tail bucket by consulting `levels_[0]`, and cover the aligned middle by descending to the coarsest level whose buckets are fully inside the range. Never scan raw samples — the pyramid stores no raw samples.
- `level_for(n, spp)`: `if (spp <= f64(kBucket)) return 0;` else `u32 l = u32(std::floor(std::log2(spp / f64(kBucket))));` clamp to `level_count() - 1`, and additionally clamp so that `level(l).size() >= 2`.
- `append(v)`: fold the sample into `pending_`, the in-progress level-0 bucket; when that bucket completes, push it as an entry and merge upward while `kBucket << l` divides the sample count, stopping at the first level that gained no entry. Amortised O(1) -- `1/kBucket + 1/(2·kBucket) + … = 2/kBucket` merges per sample, independent of the number of live levels -- and no entry is ever rewritten, which is what lets a window slide over the pyramid instead of forcing a rebuild.
- `drop_before(u)`: set the window base and discard, per level, the leading entries whose span starts before `u`; level 0 also drops its in-progress bucket. Memory then tracks the window, not the samples ever pushed.
- `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, folding each covered entry's own `sum`/`sumsq`.
- `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_size(l) >= 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 void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;` documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level `level`; level 0 emits raw samples".
`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 "level 0 emits the raw samples of [begin, end); level >= 1 emits one (min, max) envelope pair per complete retained bucket, bracketed by the partial edge buckets drawn as raw samples", so the geometry spans the requested range exactly without the pyramid storing a partial bucket. `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`).
`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.
`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.
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**
@@ -859,6 +941,12 @@ git add -A && git commit -m "feat(render): style, GLSL line/text pipelines, stag
`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);
@@ -893,6 +981,14 @@ PLOT_TEST(degenerate_ranges_do_not_hang) {
`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);
@@ -940,17 +1036,23 @@ Expected: FAIL to compile — `plot/plot/ticks.hpp` not found.
`src/plot/ticks.cpp` — the nice-number algorithm:
```cpp
// generate_ticks(range, px, min_px, out):
// if (!(range.span() > 0) || px <= 0) { out.clear(); return; }
// 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)
// const f64 step = mag * (norm <= 1.5 ? 1.0 : norm <= 3.0 ? 2.0 : norm <= 7.0 ? 5.0 : 10.0);
// // 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, {}});
// // minor ticks: step/5 (or step/4 when step starts with 2, step/5 when with 5), same loop.
// // hard guard: if (out.size() > 256) out.resize(256);
// // 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`.
@@ -960,9 +1062,11 @@ Labels are assigned by `format_si` unless the axis is a time axis, in which case
Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
Measurement r{};
if (end <= begin || end > s.size()) return r;
// Sums (mean, rms, integral): O(1) interior from level-0 prefix sums plus a
// scan of at most two partial edge buckets (<= 2*kBucket samples).
// Extrema: pyramid().reduce(begin, end), O(log n), never scans.
// 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];
@@ -970,18 +1074,26 @@ Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
return r;
}
```
`reduce()` supplies `ymin`/`ymax` in O(log n) and never scans the series. mean/rms/integral use `MinMaxPyramid::sum_range`/`sumsq_range` (Task 3): O(1) over bucket-aligned interiors from the level-0 prefix sums, plus a direct scan of at most two partial edge buckets (<= 2 x kBucket = 128 samples). A measurement therefore never scales with series length, which is exactly what `measurement_uses_pyramid_not_scan` gates. `integral = sum_range(begin,end) * dx`, `mean = sum_range/n`, `rms = sqrt(sumsq_range/n)`, where `n = end - begin`.
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;
Rect axes; // plot area: excludes tick labels AND titles
Rect y_label_area, x_label_area; // one text line each, for tick labels
Rect y_title_area, x_title_area; // one text line each, for axis titles
f32 y_axis_width = 56.0f;
f32 x_axis_height = 22.0f;
f32 y_title_width = 0.0f; // set from the title's measured extent
f32 x_title_height = 0.0f;
};
```
`layout()` reserves one text line for tick labels and a second for the axis title on
each occupied edge, and insets `axes` by all of them. A title drawn inside `axes`
sits on top of the data and is a defect — the reserved strips exist to make that
impossible rather than merely unlikely. An axis with no title reserves nothing.
`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.
@@ -1032,14 +1144,22 @@ PLOT_TEST(left_drag_pans_without_changing_span) {
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();
const f64 grabbed = p.x_transform(ax).to_data_x(100.0f); // data under the press point
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
// Assert the INVARIANT, not a pixel fraction. Grab-and-drag means the data
// grabbed at the press point is under the pointer afterwards. Pixels map to data
// through layout().axes (744 px wide for an 800 px viewport given the default
// y_axis_width of 56), NOT through the whole viewport: the viewport model makes
// dragged data lag the cursor by the label-strip fraction (56/800 = 7%), which
// is a defect no pixel-fraction assertion can see because it certifies its own
// arithmetic.
PLOT_CHECK_NEAR(p.x_transform(ax).to_data_x(180.0f), grabbed, 1e-9);
PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down reveals higher y
}
PLOT_TEST(shift_drag_box_zooms_to_selection) {
@@ -1047,14 +1167,18 @@ PLOT_TEST(shift_drag_box_zooms_to_selection) {
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
const f64 want_lo = p.x_transform(ax).to_data_x(100.0f);
const f64 want_hi = p.x_transform(ax).to_data_x(300.0f);
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);
// The box frames exactly the data it encloses: the ranges come from the axes
// transform, and the selection is clamped to layout().axes, not the viewport.
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
}
PLOT_TEST(auto_scale_disabled_by_user_interaction) {
@@ -1079,8 +1203,8 @@ Expected: FAIL to compile — `plot/input/controller.hpp` not found.
| 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) |
| left drag | `Pan` | translate both ranges so the grabbed data point stays under the pointer: the pixel-to-data scale is `span / layout().axes.w` for x and `span / layout().axes.h` for y (inverted), i.e. translate by `-dx_px / axes.w * x_span`. Using the whole-viewport width here makes dragged data lag the cursor by the label-strip fraction. |
| shift + left drag | `BoxZoom` | record `selection_rect()`; on release set both ranges from the rect. Clamp the rect to `layout().axes`, map it with `x_transform(i).to_data_x(l/r)` and `y_transform(i).to_data_y(bottom/top)`, and ignore sub-4px rects (treat as a click). The **release event's coordinates are authoritative** for the gesture end: refresh the selection from them before applying, or a host that reports only press and release leaves the box empty and the zoom silently degrades to 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 |
@@ -82,8 +82,19 @@ approach that fails at high sample rates.
Vertex buffers hold raw *data* coordinates. Screen mapping is
`x' = x * sx + tx`, `y' = y * sy + ty` evaluated in the vertex shader from a
`Transform` uniform. Panning and zooming therefore touch one uniform and zero
vertex data. Only new samples or an LOD-level change cause an upload.
`Transform` uniform. The CPU never transforms a vertex, and a pan or zoom changes
one uniform and no vertex *values*.
The staged window is view-dependent: each frame the renderer gathers just the
visible sample range (or the visible buckets of the chosen LOD level) and uploads
it. That per-frame cost is **O(visible pixels)** — about two segments per pixel
column, flat in series length — so the property this section exists for holds.
A staging cache keyed on `(series, level, first, last, viewport)` would let a
static plot skip the redundant gather and upload entirely; it is not implemented,
because it changes no asymptotics and would add an invalidation surface (new
samples, LOD changes, series mutation, streaming window slide) whose bugs are
worse than the redundant work it removes. Recorded here as deliberate future
work, not as a missing feature.
Log axes are handled inside the same transform (`log10` before scaling) so no
separate code path or pre-transformed buffer is needed.
@@ -91,23 +102,52 @@ separate code path or pre-transformed buffer is needed.
### 4.2 Min/max LOD pyramid
Every series owns a mip chain: level 0 buckets `kBucket = 64` consecutive samples
and stores one `(min, max)` pair per bucket; each subsequent level halves the
bucket count. Memory cost is `n/8` bytes at level 0 (2 floats per 64 samples) times the geometric sum `1 + ½ + ¼ + …`, giving `n/4` bytes total — ≈6% of the 4n bytes of raw samples, not 2×.
and stores one entry per bucket; each subsequent level halves the bucket count.
Each entry is 24 bytes — a `MinMax` pair (8 bytes) plus the exact `sum` and `sumsq`
doubles the range queries fold over (16 bytes) — so level 0 costs
`24n/64 = 0.375n` bytes and the geometric sum `1 + ½ + ¼ + …` brings the pyramid to
≈`0.75n` bytes, about 19% of the `4n` bytes of raw samples, not 2×. A min/max-only
index would be `n/4`, ≈6%: the two doubles are the trade that makes an interior
sum/mean/RMS/integral O(log n) whole-bucket additions instead of a scan of raw
samples, at every level, for every series that uses the measurement index.
- Built incrementally: `append()` updates only the last bucket and the O(log n)
chain above it — amortised O(1) per sample.
- Built incrementally, in **absolute bucket space**: level `l` bucket `k` covers
samples `[k · (kBucket << l), (k + 1) · (kBucket << l))`. A bucket's span
therefore never depends on where the current window starts, which is what lets a
window slide over a pyramid instead of forcing it to be rebuilt. `append()`
folds a sample into the single in-progress level-0 bucket and, exactly when that
bucket completes, pushes it and merges upward, stopping at the first level that
gains no new entry: level `l` gains one entry per `kBucket << l` samples, so an
append costs amortised O(1) (`1/kBucket + 1/(2·kBucket) + … = 2/kBucket` merges)
and never walks the live levels.
- A sliding window (a `StreamSeries` ring) drops the leading buckets it no longer
holds and appends only the newly produced samples, so nothing is recomputed and
memory stays bounded by the retained window rather than the total ever pushed.
- The renderer picks the level where samples-per-pixel ≈ 2, so a 100M-sample
series rasterises ≈ `2 · viewport_width` vertices instead of 100M.
- Buckets preserve extremes, so spikes are never dropped by decimation.
### 4.3 Hybrid line rasterisation
### 4.3 One rasteriser, two sampling levels
Chosen per trace, per frame, from observed samples-per-pixel:
Every trace, every frame, is drawn by one path: instanced anti-aliased quads.
One instance per segment, four corners expanded from `gl_VertexID` in the vertex
shader, a fragment shader that does distance-to-segment SDF for smooth edges and
joins. There is no second primitive anywhere in the renderer — in particular no
`GL_LINE_STRIP` path — so a sparse trace and a dense one differ in the geometry
*staged*, never in the call that draws it.
| samples/px | path |
What the samples-per-pixel threshold selects is therefore the gather level:
| samples/px | geometry staged |
|---|---|
| `< 1.5` | `GL_LINE_STRIP`, 1px, no AA — dense data hides aliasing; ~10× cheaper |
| `>= 1.5` | instanced anti-aliased quads: one instance per segment, 4 verts expanded in the vertex shader, fragment shader does distance-to-segment SDF for smooth edges and joins |
| `< dense_threshold_spp` (1.5) | the visible window's raw samples: a level-0 `Series::gather()`, a polyline through the window, one instance per sample pair |
| `>= dense_threshold_spp` (1.5) | the pyramid level whose buckets are ~1 per pixel (`pyramid().level_for(n, samples_per_pixel)`): a min/max envelope pair per bucket, so O(pixels) instances instead of O(samples) |
`Style::dense_threshold_spp` is that threshold and nothing more: it picks the
gather level, changes no draw call, and the two sides of it are the same shader.
The dense side is where the work is saved (a 100M-sample window stages O(pixels)
vertices); the sparse side stages the window itself, which at ~1 sample per pixel
is ~1 instance per column anyway.
A geometry-shader expansion path was rejected: poor performance and it is the
one stage macOS cannot be relied on for.
@@ -115,15 +155,29 @@ one stage macOS cannot be relied on for.
### 4.4 Interval reduction reuses the pyramid
Cursor measurements (`Δx`, `Δy`, slope, RMS, mean, min/max, integral over a
dragged region) walk the same min/max chain and return in `O(log n)`; the series
is never scanned linearly. `pyramid().reduce(a, b)` is the single implementation
used both by measurement and by auto-scale.
dragged region) are answered from the same pyramid instead of by scanning the
series. `Series::reduce`/`sum`/`sumsq` cover the bucket-aligned interior with
pyramid aggregates and scan at most two partial edge buckets (≤ 2 × kBucket = 128
samples), so a query's cost is independent of series length. `Series::reduce` is
the single implementation used by measurement, auto-scale and hit-testing.
The split matters: a min/max pyramid stores *aggregates per bucket*, so it can
only answer exactly for whole buckets — a range with partial edge buckets would
otherwise silently include samples outside the range. Exactness therefore lives
on `Series` (which owns the raw samples), and `MinMaxPyramid` exposes only
bucket-exact queries, named `*_buckets` so they cannot be mistaken for exact ones.
The bucket grid is absolute while a sliding window's base is not, so that split is
computed in absolute coordinates — `Series` asks the pyramid for `window_base()`
and aligns `ceil`/`floor` there before mapping back to window indices. That is
also how a stream's `y_bounds()` stays exact in O(log n + kBucket) per frame: it
is `reduce(0, size())` over the window rather than a scan of it.
### 4.5 Decimation approach — decision
| | Approach | Verdict |
|---|---|---|
| **A** | **CPU min/max pyramid, incremental** | **chosen** — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈6% memory. |
| **A** | **CPU min/max pyramid, incremental** | **chosen** — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈19% memory (24-byte buckets: extremes plus exact per-bucket sums). |
| B | GPU compute decimation every frame | rejected — re-reads the whole series each frame (real bandwidth at 1M samples/frame), no 3.3 fallback, untestable headlessly. |
| C | no decimation | rejected — dies above ~500k samples. |
@@ -188,21 +242,50 @@ namespace data {
class MinMaxPyramid {
public:
void reserve(u64 n); void append(f32 v); void clear();
u64 size() const;
Range reduce(u64 begin, u64 end) const; // O(log n)
// Absolute bucket space: level l entry i covers the absolute samples
// [i * (kBucket << l), (i + 1) * (kBucket << l)). The window slides over the
// same buckets; queries are in window coordinates (index 0 = oldest retained).
void drop_before(u64 absolute_sample);
u64 size() const; // retained window size
u64 window_base() const; // abs index of index 0
u64 window_end() const; // abs index past the last
u32 level_count() const;
u64 level_base(u32 l) const, level_size(u32 l) const;
MinMax bucket(u32 l, u64 absolute_bucket) const;
u32 level_for(u64 n, f64 samples_per_pixel) const;
std::span<const MinMax> level(u32 l) const;
u64 snap(f64 x, f64 x0, f64 dx) const; // nearest sample index
// Whole-bucket only: aggregates over the level-0 buckets fully contained in
// [begin, end). Partial edges are excluded, not approximated. Callers wanting
// an exact answer over an arbitrary range use Series::reduce/sum/sumsq.
Range reduce_buckets(u64 begin, u64 end) const;
f64 sum_buckets(u64 begin, u64 end) const;
f64 sumsq_buckets(u64 begin, u64 end) const;
};
class Series { // abstract
public:
virtual ~Series() = default;
virtual u64 size() const = 0;
virtual f64 x0() const = 0; // x of sample 0
virtual f64 dx() const = 0; // uniform x spacing
virtual Range x_bounds() const = 0;
virtual Range y_bounds() const = 0;
virtual const MinMaxPyramid& pyramid() const = 0;
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
// Exact, O(kBucket) worst case regardless of series length: the bucket-aligned
// interior comes from the pyramid, the at-most-two partial edge buckets are
// read through copy_raw() and scanned. Implemented once, non-virtually, in
// src/data/series.cpp. Preconditions: begin < end, end <= size().
Range reduce(u64 begin, u64 end) const;
f64 sum(u64 begin, u64 end) const;
f64 sumsq(u64 begin, u64 end) const;
protected:
// Copies raw y samples for [begin, end) into `out`, which the caller sized to
// (end - begin). Handles ring wraparound. Only ever called with small edge
// spans by the queries above; not on a per-sample path.
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
};
class ArraySeries final : public Series { // own storage, uniform dx
@@ -279,17 +362,39 @@ namespace input {
- Producers write to `data::StreamSeries` from an acquisition thread through a
lock-free SPSC ring. No mutex on the sample path.
- A frame begins with `StreamSeries::acquire()`, which publishes a consistent
`[0, produced)` snapshot and extends the pyramid by exactly the newly produced
samples. Readers use the snapshot; the producer never blocks.
`[0, produced)` snapshot: it slides the pyramid's retained window to the new
base, dropping the leading buckets the ring has overwritten, then extends the
pyramid by exactly the newly produced samples. Its cost is **O(samples produced
since the last acquire)** — independent of both the window size and the series
length — and so is its memory, because the drops bound the pyramid to the
retained window instead of the total ever pushed. A frame's total cost is
therefore O(new samples + visible pixels), and no part of the frame path is
proportional to the window. The one exception is a producer that laps the
consumer by more than `capacity()` samples between two acquires: the samples in
between were never appended, so that acquire re-reads the retained window from
scratch. Readers use the snapshot; the producer never blocks.
- `Renderer`, `Plot`, and `Controller` are confined to the render thread.
## 8. Error handling
- No exceptions cross the public boundary. Fallible operations return `Status`
or `bool` plus an out-parameter `Status*`.
- Library-originated failures never throw: every one of them returns `Status`
(or `bool` plus an out-parameter `Status*`), and no code path reports an error
by throwing. An allocation failure is the host's process running out of memory,
not a library error, and the entry points that allocate host memory --
constructing a series, `Plot::add_*`, a frame that stages geometry,
`Renderer::dump_frame_ppm()` -- propagate `std::bad_alloc` from the container
that ran out. They are therefore not annotated `noexcept`; the entries that can
neither fail nor allocate are, so the surface says per entry point what it does
rather than claiming a blanket `noexcept`.
- GL failures are surfaced as `ErrorCode::GLFailure` from `Renderer::init` and
recorded on the first occurrence per frame; `PLOT_GL_CHECK()` is a no-op in
release builds.
recorded for the frame: `Renderer::end_frame()` drains `glGetError()` in a loop
and sets the flag `last_frame_had_gl_error()` reports, so the first error cannot
hide the ones after it and a stale error cannot be attributed to a later frame.
The instance stores report their own refusals through the same flag: a storage,
map or fence the driver refuses drops that store to the orphan-and-refill
baseline (see §4.5) and marks the frame. There is no `PLOT_GL_CHECK()` macro and
no build-dependent error checking -- the drain runs in every build, and it is
the flag, not a macro, that a host observes.
- Unsupported-but-optional capabilities (compute, persistent mapping) degrade
silently by selecting the 3.3 path; unsupported-and-required ones return
`ErrorCode::Unsupported` from `init`.
+41
View File
@@ -0,0 +1,41 @@
# The example programs. Each one is guarded by the adapter it needs, so
# -DPLOT_BUILD_EXAMPLES=ON without -DPLOT_BUILD_GLFW=ON is a no-op rather than a
# configure error.
find_package(Threads REQUIRED) # the GLFW demo's producer thread
if(TARGET plot::glfw)
add_executable(plot_glfw_demo glfw_demo.cpp)
target_link_libraries(plot_glfw_demo PRIVATE plot::glfw Threads::Threads)
plot_apply_warnings(plot_glfw_demo)
endif()
if(TARGET plot::imgui)
# The demo owns the window, the ImGui context and the frame loop itself, so it
# links the toolkit and its backends directly; the adapter is only the widget.
plot_find_glfw(plot_imgui_demo_glfw_dep)
add_executable(plot_imgui_demo imgui_demo.cpp)
target_link_libraries(plot_imgui_demo PRIVATE
plot::imgui
imgui
${plot_imgui_demo_glfw_dep}
Threads::Threads)
plot_apply_warnings(plot_imgui_demo)
endif()
if(TARGET plot::qt6)
# The demo owns the QApplication, the QMainWindow and the toolbar; the adapter is
# only the widget, which the window adopts as its central widget. No macro
# processing is needed here: the demo declares no Qt class of its own.
add_executable(plot_qt_demo qt_demo.cpp)
target_link_libraries(plot_qt_demo PRIVATE plot::qt6 Threads::Threads)
plot_apply_warnings(plot_qt_demo)
endif()
if(TARGET plot::gtk4)
# The demo owns the GtkApplication, the window and the header bar; the adapter is
# only the plot area, which the window adopts as its content. GTK's include
# directories come with plot::gtk4 (see the adapter's CMakeLists).
add_executable(plot_gtk_demo gtk_demo.cpp)
target_link_libraries(plot_gtk_demo PRIVATE plot::gtk4 Threads::Threads)
plot_apply_warnings(plot_gtk_demo)
endif()
+306
View File
@@ -0,0 +1,306 @@
// The reference application: the whole library running as a real program, fed by
// a 1 MS/s producer thread through the GLFW adapter.
//
// What it demonstrates, and what the other three adapters copy:
//
// - a `StreamSeries` at the retention limit (1 << 20 samples, 1.048576 s at this
// rate) pushed from a background thread and acquired exactly once per frame on
// the render thread;
// - a signal whose decimation correctness is visible: a 437 Hz sine plus a small
// noise term plus a narrow spike every 100 ms. A bucket that kept only its
// first sample would drop the spike; a bucket that keeps its extrema cannot;
// - a plot the user can drive: grid, both axes titled and labelled (the time
// axis with `format_time` clock labels, the value axis with SI ones), one
// trace, two cursors and the measurement readout the plot layer draws itself
// (shaded band plus a `dx/dy/rms/mean` line) -- no drawing code here;
// - the headless smoke mode the adapter implements once
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), so CI can run this under
// `xvfb-run` and check the frame it wrote.
//
// The producer is joined before the stream it writes into is destroyed, and
// before the host closes the GL context (main's destruction order does that).
#include <glfw/glfw_host.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/plot/ticks.hpp>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <numbers>
#include <thread>
#include <utility>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// One sample per microsecond: 1 MS/s.
constexpr f64 kSampleRate = 1.0e6;
constexpr f64 kDx = 1.0 / kSampleRate;
/// The retained window: 1 << 20 samples = 1.048576 s. A power of two, so the
/// ring never has to round it up.
constexpr u64 kCapacity = 1u << 20;
/// Producer wakeup: about a millisecond of samples per push batch.
constexpr u64 kBatch = 1u << 10;
/// Cap on one push batch, so a process that was stopped and resumed catches up
/// over several iterations instead of allocating a batch for the whole gap.
constexpr u64 kMaxBatch = 1u << 16;
/// The signal: a few hundred Hz sine, a small noise term, a narrow spike.
constexpr f64 kSineHz = 437.0;
constexpr f64 kNoiseAmp = 0.02;
constexpr f64 kSpikePeriodS = 0.1;
constexpr f64 kSpikeWidthS = 2.0e-6;
constexpr f64 kSpikeAmp = 3.0;
// The trace colour the render smoke test's blue-dominance predicate expects, so
// the same predicate reads this demo's screenshot.
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020);
constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8);
/// y(t) for sample index `i`: sine + deterministic noise + a two-sample spike.
///
/// The noise hash takes the low 32 bits of the index, so its pattern repeats
/// every ~4295 s at 1 MS/s -- far beyond any run of this demo, and it keeps the
/// sample path allocation-free and branch-light, unlike a PRNG.
f32 sample_at(u64 i) noexcept {
const f64 t = static_cast<f64>(i) * kDx;
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
u32 h = static_cast<u32>(i) * 2654435761u;
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
const f64 phase = std::fmod(t, kSpikePeriodS);
if (phase < kSpikeWidthS) {
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
}
return static_cast<f32>(y);
}
/// Feeds a `StreamSeries` from its own thread at 1 MS/s on average, paced by
/// absolute deadlines off a steady clock: every wakeup produces every sample
/// whose time has already come, so a late wakeup is caught up instead of pushing
/// the phase back, and the achieved rate cannot drift the way a fixed
/// `sleep_for()` per batch does.
class Producer {
public:
explicit Producer(plot::data::StreamSeries& series)
: series_(&series), thread_([this] { run(); }) {}
~Producer() { stop(); }
Producer(const Producer&) = delete;
Producer& operator=(const Producer&) = delete;
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls
/// it, so the thread is never detached and never outlives the stream.
void stop() noexcept {
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) thread_.join();
}
/// Samples pushed so far. Written by the producer, read by the render thread.
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
private:
void run() {
using clock = std::chrono::steady_clock;
const auto start = clock::now();
u64 next = 0;
while (!stop_.load(std::memory_order_relaxed)) {
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
const u64 due = static_cast<u64>(elapsed / kDx) + 1; // samples whose time has come
if (due > next) {
u64 n = due - next;
if (n > kMaxBatch) n = kMaxBatch;
batch_.resize(static_cast<plot::usize>(n));
for (u64 k = 0; k < n; ++k) {
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
}
series_->push(batch_.data(), n);
next += n;
produced_.store(next, std::memory_order_relaxed);
}
// Sleep until one batch past the last produced sample: an absolute
// deadline, so the phase is fixed at `start` and never accumulates.
const f64 wake_s = static_cast<f64>(next + kBatch) * kDx;
std::this_thread::sleep_until(
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
}
}
plot::data::StreamSeries* series_;
std::vector<f32> batch_;
std::atomic<u64> produced_{0};
std::atomic<bool> stop_{false};
std::thread thread_; ///< declared last: the lambda runs once everything is up
};
/// The live readout: the achieved push rate, the frame time and the frame count.
struct Meter {
f64 rate = 0.0; ///< samples/s, refreshed a few times a second
f64 frame_ms = 0.0; ///< exponential moving average of the frame time
f64 elapsed = 0.0; ///< seconds of frame callback time
f64 last_report = 0.0;
u64 last_produced = 0;
u64 frames = 0;
};
} // namespace
int main() {
plot::glfw::Host host;
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
Producer producer(stream);
plot::Plot& p = host.plot();
// Time axis: clock labels, which `TickFormat::Time` routes through
// `format_time`. The value axis keeps SI labels, which is `format_si`.
const u32 axis_x = p.add_x_axis(
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
const u32 axis_y = p.add_y_axis(
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
const u32 trace = p.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y});
// Snapped cursors sit exactly on a sample, so the readout measures samples
// rather than a rounded position between two of them.
const u32 cursor_a = p.add_cursor(0.0, kCursorAColor);
const u32 cursor_b = p.add_cursor(0.0, kCursorBColor);
p.set_cursor_snap(cursor_a, plot::SnapMode::NearestSample);
p.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample);
const u32 measure = p.add_measure(cursor_a, cursor_b, trace);
Meter meter;
// The cursors ride the live window until the user takes over: the first
// ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale,
// stops them where the user put them.
bool cursors_follow = true;
const plot::glfw::FrameFn frame = [&](plot::Plot& graph, plot::f64 dt) {
stream.acquire(); // once per frame, on the render thread
// Re-frame the live stream while the user has not overridden the ranges.
// `auto_fit_*` re-arms auto scale, so it may only run while auto scale is
// still on: calling it after a pan would undo the pan every frame.
if (graph.x_axis(axis_x).auto_scale) graph.auto_fit_x();
if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y);
if (cursors_follow) {
if (host.controller().drag_mode() == plot::input::DragMode::MoveCursor ||
!graph.x_axis(axis_x).auto_scale) {
cursors_follow = false;
} else {
const plot::Range xr = graph.x_range(axis_x);
graph.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span());
graph.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span());
}
}
meter.elapsed += dt;
meter.frames += 1;
meter.frame_ms += 0.1 * (dt * 1.0e3 - meter.frame_ms); // EMA
if (meter.elapsed - meter.last_report >= 0.25) {
const u64 produced = producer.produced();
meter.rate = static_cast<f64>(produced - meter.last_produced) /
(meter.elapsed - meter.last_report);
meter.last_produced = produced;
meter.last_report = meter.elapsed;
// The title carries the live readout: samples/second, frame time, and the
// samples per pixel that say which rasterisation path the trace took. The
// stats are the previous frame's, since this runs before draw().
const plot::Renderer* r = host.renderer();
const plot::FrameStats prev = r != nullptr ? r->stats() : plot::FrameStats{};
const plot::Rect axes = std::as_const(graph).layout().axes;
const f64 spp = axes.w > 0.0f ? static_cast<f64>(prev.visible_samples) /
static_cast<f64>(axes.w)
: 0.0;
char title[192];
std::snprintf(title, sizeof title,
"plot glfw demo | %.3f MS/s | %.2f ms/frame | %.0f samples/px | %s | %llu frames",
meter.rate * 1.0e-6, meter.frame_ms, spp,
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"),
static_cast<unsigned long long>(meter.frames));
host.set_title(title);
std::printf("[glfw_demo] t=%.1fs %.3f MS/s %.2f ms/frame %.0f samples/px %s\n",
meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms, spp,
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"));
std::fflush(stdout);
}
};
// vsync on: this is a real application, and the headless mode below is what
// makes it verifiable without a display.
plot::glfw::RunConfig cfg;
cfg.title = "plot glfw demo";
cfg.vsync = true;
plot::Status st;
const bool ok = host.run(cfg, frame, &st);
producer.stop(); // no thread pushes into the stream after this point
if (!ok) {
std::fprintf(stderr, "plot_glfw_demo: %s: %s\n", plot::to_string(st.code), st.message);
return 1;
}
const plot::Renderer* r = host.renderer();
const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{};
const plot::Rect axes = std::as_const(p).layout().axes;
const f64 spp =
axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) / static_cast<f64>(axes.w) : 0.0;
const f64 dense = r != nullptr ? static_cast<f64>(r->style().dense_threshold_spp) : 0.0;
const plot::Measurement m = p.measurement(measure);
const f64 mean_ms =
meter.frames > 0 ? meter.elapsed / static_cast<f64>(meter.frames) * 1.0e3 : 0.0;
const plot::Range yr = stream.y_bounds();
std::printf("\n[glfw_demo] ---- run summary ----\n");
// The rate is the last metered interval, not produced/elapsed: this thread's
// clock starts when the first frame runs, and the producer starts ~0.4 s
// earlier, still building the window, so a cumulative division would report a
// rate the producer never sustained.
std::printf("[glfw_demo] producer %llu samples pushed, last metered %.3f MS/s\n",
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
std::printf("[glfw_demo] frames %llu in %.3f s, mean %.2f ms/frame (%.1f fps),"
" last metered %.2f ms/frame\n",
static_cast<unsigned long long>(meter.frames), meter.elapsed, mean_ms,
mean_ms > 0.0 ? 1.0e3 / mean_ms : 0.0, meter.frame_ms);
std::printf("[glfw_demo] retained %llu samples, x [%.6f, %.6f] s, y [%.3f, %.3f]\n",
static_cast<unsigned long long>(stream.size()), stream.x_bounds().min,
stream.x_bounds().max, yr.min, yr.max);
std::printf("[glfw_demo] pixel spaces viewport (framebuffer px) %.0fx%.0f, window->framebuffer"
" scale %.2fx%.2f, content scale %.2fx%.2f\n",
static_cast<double>(p.viewport().w), static_cast<double>(p.viewport().h),
static_cast<double>(host.framebuffer_scale_x()),
static_cast<double>(host.framebuffer_scale_y()),
static_cast<double>(host.content_scale_x()),
static_cast<double>(host.content_scale_y()));
std::printf("[glfw_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s\n", spp,
dense, stats.lod_traces > 0 ? "LOD pyramid" : "raw samples");
std::printf("[glfw_demo] last frame visible_samples %llu, axes %.0fx%.0f, staged_segments %u"
", draw_calls %u, lod %u raw %u\n",
static_cast<unsigned long long>(stats.visible_samples),
static_cast<double>(axes.w), static_cast<double>(axes.h), stats.staged_segments,
stats.draw_calls, stats.lod_traces, stats.raw_traces);
std::printf("[glfw_demo] measurement %s dx %g s dy %g rms %g mean %g\n",
m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean);
std::printf("[glfw_demo] upload path %s, gl error on the last frame %d\n",
r != nullptr && r->using_persistent_upload() ? "persistent-mapped ring" : "orphan",
r != nullptr && r->last_frame_had_gl_error() ? 1 : 0);
return 0; // ~producer (no-op after stop), ~stream, then ~host closes GL
}
+664
View File
@@ -0,0 +1,664 @@
// The GTK4 application: the same 1 MS/s stream the other demos draw, as the
// content of a GtkApplicationWindow whose header bar carries its own "Fit"
// button, with GTK drawing the window chrome around a plot the area renders into
// an FBO of its own.
//
// What it demonstrates:
//
// - `plot::gtk4::PlotArea`: a GtkGLArea (GTK's own framebuffer, no texture, no
// offscreen pass of this project's) driven by GTK's gestures and event
// controllers -- drag to pan, shift+drag to box zoom, ctrl+drag to move a
// cursor, middle-drag on a label strip to zoom one axis, wheel to zoom about
// the pointer, `+` / `-` / `f` on the keyboard;
// - the device-pixel mapping: GTK allocates and reports in logical pixels, the
// area's framebuffer is in device pixels, and the area converts both the
// viewport and every input coordinate with the same factor (see the header);
// - the demo-side controls the area knows nothing about -- the header bar's "Fit";
// - a scripted resize before the exit frame, so the headless run proves the area
// re-lays the plot out for the new geometry rather than scaling a stale one;
// - the headless smoke mode every demo here supports
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT). The screenshot is the GL
// area's *own framebuffer*, read back by `dump_area_ppm` in the render
// callback, so the PPM holds the plot surface: the header bar and the window
// frame are GTK's and are not in that framebuffer. (GTK composites the area's
// FBO into the window as a texture, so a whole-window shot would need GTK's
// snapshot machinery; this one is honest about what it shows.)
//
// The producer is joined before the stream it writes into is destroyed, and
// before the area's GL resources are released with the widget.
// This library's GL surface first, then GTK -- the order every adapter here keeps.
// GTK's headers pull in no `<GL/gl.h>`, so unlike Qt there is no macro to dodge.
#include <gtk4/gtk_plot_area.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/gl/api.hpp>
#include <plot/plot/ticks.hpp>
#include <gtk/gtk.h>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <numbers>
#include <thread>
#include <utility>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// One sample per microsecond: 1 MS/s.
constexpr f64 kDx = 1.0e-6;
/// The retained window: 1 << 20 samples = 1.048576 s.
constexpr u64 kCapacity = 1u << 20;
/// Producer wakeup: about a millisecond of samples per push batch.
constexpr u64 kBatch = 1u << 10;
/// Cap on one push batch, so a process that was stopped and resumed catches up
/// over several iterations instead of allocating a batch for the whole gap.
constexpr u64 kMaxBatch = 1u << 16;
constexpr f64 kSineHz = 437.0;
constexpr f64 kNoiseAmp = 0.02;
constexpr f64 kSpikePeriodS = 0.1;
constexpr f64 kSpikeWidthS = 2.0e-6;
constexpr f64 kSpikeAmp = 3.0;
/// The trace colour the render smoke test's blue-dominance predicate expects, so
/// the same predicate reads this demo's screenshot.
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020);
constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8);
/// Logical pixels. The area's framebuffer -- the area's GL viewport -- is in
/// device pixels.
constexpr int kWindowWidth = 1200;
constexpr int kWindowHeight = 800;
/// The size the scripted resize before the exit frame goes to: different on both
/// axes, so a screenshot that kept the old layout is visibly the old size.
constexpr int kExitWidth = 820;
constexpr int kExitHeight = 620;
/// y(t) for sample index `i`: sine + deterministic noise + a narrow spike, the
/// signal the other demos use.
f32 sample_at(u64 i) noexcept {
const f64 t = static_cast<f64>(i) * kDx;
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
u32 h = static_cast<u32>(i) * 2654435761u;
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
const f64 phase = std::fmod(t, kSpikePeriodS);
if (phase < kSpikeWidthS) {
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
}
return static_cast<f32>(y);
}
/// Feeds a `StreamSeries` from its own thread at 1 MS/s on average, paced by
/// absolute deadlines off a steady clock: every wakeup produces every sample
/// whose time has already come, so a late wakeup is caught up instead of pushing
/// the phase back.
class Producer {
public:
explicit Producer(plot::data::StreamSeries& series)
: series_(&series), thread_([this] { run(); }) {}
~Producer() { stop(); }
Producer(const Producer&) = delete;
Producer& operator=(const Producer&) = delete;
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls it,
/// so the thread is never detached and never outlives the stream.
void stop() noexcept {
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) thread_.join();
}
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
private:
void run() {
using clock = std::chrono::steady_clock;
const auto start = clock::now();
u64 next = 0;
while (!stop_.load(std::memory_order_relaxed)) {
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
const u64 due = static_cast<u64>(elapsed / kDx) + 1;
if (due > next) {
u64 n = due - next;
if (n > kMaxBatch) n = kMaxBatch;
batch_.resize(static_cast<plot::usize>(n));
for (u64 k = 0; k < n; ++k) {
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
}
series_->push(batch_.data(), n);
next += n;
produced_.store(next, std::memory_order_relaxed);
}
const f64 wake_s = static_cast<f64>(next + kBatch) * kDx;
std::this_thread::sleep_until(
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
}
}
plot::data::StreamSeries* series_;
std::vector<f32> batch_;
std::atomic<u64> produced_{0};
std::atomic<bool> stop_{false};
std::thread thread_; ///< declared last: the lambda runs once everything is up
};
/// The live readout, refreshed a few times a second.
struct Meter {
f64 elapsed = 0.0;
f64 last_report = 0.0;
u64 last_produced = 0;
f64 rate = 0.0; ///< samples/s over the last metered interval
u64 frames = 0; ///< frame callbacks run (one per staged frame)
};
f64 now_s() noexcept {
return std::chrono::duration<f64>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not
/// positive, which is "run until the window is closed".
f64 exit_after_seconds() noexcept {
const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS");
if (v == nullptr || v[0] == '\0') return 0.0;
char* end = nullptr;
const f64 ms = std::strtod(v, &end);
if (end == v || !(ms > 0.0)) return 0.0;
return ms * 1.0e-3;
}
/// Writes the GL area's own framebuffer out as a binary PPM (P6, 8 bits per
/// channel, rows top-down).
///
/// Demo-side, not library API -- hence the name: this is the smoke mode's picture
/// of the *area's* framebuffer, which is what GTK composites into the window.
/// `glReadPixels`'s origin is the framebuffer's bottom-left and a PPM's is its
/// top-left, so the rows are written in reverse; the alpha channel is dropped,
/// and the plot clears its whole viewport to an opaque background, so the RGB is
/// final. An `GL_RGBA` row is `width * 4` bytes -- a multiple of four -- so the
/// default `GL_PACK_ALIGNMENT` holds and no pixel-store state has to be touched.
bool dump_area_ppm(const char* path, int w, int h) {
if (path == nullptr || path[0] == '\0' || w <= 0 || h <= 0) return false;
const plot::usize width = static_cast<plot::usize>(w);
const plot::usize height = static_cast<plot::usize>(h);
std::vector<plot::u8> pixels(width * height * 4u);
plot::gl::glReadPixels(0, 0, w, h, plot::gl::GL_RGBA, plot::gl::GL_UNSIGNED_BYTE,
pixels.data());
std::FILE* file = std::fopen(path, "wb");
if (file == nullptr) return false;
if (std::fprintf(file, "P6\n%d %d\n255\n", w, h) < 0) {
std::fclose(file);
return false;
}
const plot::usize row_bytes = width * 3u;
std::vector<plot::u8> row(row_bytes);
bool ok = true;
for (plot::usize y = height; y-- > 0u && ok;) {
const plot::u8* src = pixels.data() + y * width * 4u;
for (plot::usize x = 0; x < width; ++x) {
row[x * 3u + 0u] = src[x * 4u + 0u];
row[x * 3u + 1u] = src[x * 4u + 1u];
row[x * 3u + 2u] = src[x * 4u + 2u];
}
ok = std::fwrite(row.data(), 1u, row_bytes, file) == row_bytes;
}
if (!ok) {
std::fclose(file);
return false;
}
return std::fclose(file) == 0;
}
/// The run summary's numbers, taken while the widget -- and with it the area and
/// its renderer -- is still alive. GTK frees the wrapper when the widget is
/// finalized, so the summary prints this instead of reading through a pointer
/// that may already be gone.
struct Snapshot {
bool have_renderer = false;
plot::ErrorCode init_code = plot::ErrorCode::Ok;
const char* init_message = "";
u64 frames = 0;
f32 mean_ms = 0.0f;
f32 last_ms = 0.0f;
plot::FrameStats stats{};
plot::Rect axes{};
f32 dense_spp = 0.0f;
bool persistent = false;
bool gl_error = false;
};
/// Everything the demo owns, reachable from the signal callbacks and from the
/// exit path.
struct Demo {
plot::data::StreamSeries* stream = nullptr;
Producer* producer = nullptr;
plot::Plot* graph = nullptr;
plot::gtk4::PlotArea* area = nullptr; ///< null once GTK has destroyed the widget
GtkWindow* window = nullptr;
GtkApplication* app = nullptr;
u32 axis_x = 0;
u32 axis_y = 0;
u32 cursor_a = 0;
u32 cursor_b = 0;
u32 measure = 0;
/// A weak pointer on the area's widget: GTK frees the wrapper with it, so this
/// is how the callbacks that outlive the window (the pump, the summary) know
/// whether the widget can still be touched.
GtkWidget* widget = nullptr;
bool automated = false;
f64 exit_after_s = 0.0;
f64 run_start = 0.0;
const char* screenshot = nullptr;
/// Set by the exit path, read by the after-render handler that takes the
/// screenshot and ends the run. Non-null means "this render is the exit frame".
const char* dump_path = nullptr;
bool exit_pending = false;
bool summary_printed = false;
/// The two exit sequences, each guarded so it runs exactly once: the timer's
/// block (which pumps the main context and can be re-entered by its own timeout)
/// and the teardown (scheduled as an idle source).
bool exit_started = false;
bool torn_down = false;
bool cursors_follow = true;
Meter meter;
Snapshot snapshot;
};
void capture_snapshot(Demo& demo) {
Snapshot& s = demo.snapshot;
s.init_code = demo.area->init_status().code;
s.init_message = demo.area->init_status().message;
plot::Renderer* const renderer = demo.area->renderer();
s.have_renderer = renderer != nullptr;
s.frames = demo.area->frames_painted();
s.mean_ms = demo.area->mean_frame_ms();
s.last_ms = demo.area->last_frame_ms();
s.axes = std::as_const(*demo.graph).layout().axes;
if (renderer != nullptr) {
s.stats = renderer->stats();
s.dense_spp = renderer->style().dense_threshold_spp;
s.persistent = renderer->using_persistent_upload();
s.gl_error = renderer->last_frame_had_gl_error();
}
}
/// A window closed by hand -- or by the window manager -- takes the ordering the
/// automated path takes: the producer is joined *before* GTK destroys the widget,
/// and with it the renderer whose frames read the stream. GTK emits this before its
/// own handler destroys the window, and returning FALSE lets that default happen.
gboolean on_close_request(GtkWindow*, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
demo.producer->stop();
return FALSE;
}
/// Runs the window's destruction and the quit outside any signal emission: see
/// `on_render_after`.
gboolean on_teardown(gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
// The teardown runs once, whatever scheduled it (the exit frame's idle source, or
// a second one if two renders were pending): the window is destroyed and the
// application quit here, and both of those must not happen twice.
if (demo.torn_down) return G_SOURCE_REMOVE;
demo.torn_down = true;
// The producer is joined *first*, while the widget and the stream it feeds are
// both still alive: the join is what makes "no thread pushes into the stream
// after this point" true for the destruction below, and for the frames the
// widget may still stage while it is being torn down.
demo.producer->stop();
// Destroyed explicitly rather than left to the application's shutdown, so an
// automated run exercises the widget's teardown -- the renderer's GL objects are
// released in `::unrealize`, with the context still current -- and a fault in it
// shows up in the run's exit status rather than after the output has been read.
gtk_window_destroy(demo.window);
g_application_quit(G_APPLICATION(demo.app));
return G_SOURCE_REMOVE;
}
/// The repaint pump. GTK only redraws the area when something asks it to, so this
/// is the frame rate the demo runs at (a real application drives the same timer,
/// or its toolkit's equivalent, from whatever it animates). Nothing here is
/// library API: `gtk_gl_area_queue_render` is GTK's.
gboolean on_repaint(gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
if (demo.widget == nullptr) { // the window was closed: the wrapper is gone too
demo.area = nullptr;
return G_SOURCE_REMOVE;
}
capture_snapshot(demo);
gtk_gl_area_queue_render(GTK_GL_AREA(demo.area->widget()));
return G_SOURCE_CONTINUE;
}
/// Connected with `g_signal_connect_after`, so it runs once the adapter's own
/// handler has staged the frame and the area's FBO holds it. It is also the one
/// place that sees a render after a failed init -- the adapter's handler returns
/// early then, but GTK still emits the signal -- which keeps the snapshot's
/// status meaningful.
gboolean on_render_after(GtkGLArea*, GdkGLContext*, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
if (demo.area != nullptr) capture_snapshot(demo);
if (!demo.exit_pending) return FALSE;
demo.exit_pending = false;
if (demo.dump_path != nullptr && demo.dump_path[0] != '\0') {
const plot::Rect viewport = demo.area->device_viewport();
const bool written = dump_area_ppm(demo.dump_path, static_cast<int>(viewport.w),
static_cast<int>(viewport.h));
std::printf("[gtk_demo] screenshot %s (%dx%d GL px): %s\n", demo.dump_path,
static_cast<int>(viewport.w), static_cast<int>(viewport.h),
written ? "written" : "FAILED");
std::fflush(stdout);
}
// The teardown is *deferred*: closing the window destroys this very widget, and
// the render signal is still being emitted, so doing it on this stack would free
// the area GTK is in the middle of calling back into. An idle source runs on the
// next main-loop iteration, after the emission has returned.
g_idle_add(&on_teardown, &demo);
return FALSE;
}
/// The header bar's "Fit": one-shot framing of the data. `auto_fit_*` re-arms
/// auto scale, which the frame callback then follows, so this is also how a pan
/// is undone.
void on_fit_clicked(GtkButton*, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
demo.graph->auto_fit_x();
demo.graph->auto_fit_y(demo.axis_y);
// A click moves focus to the button, which would take the controller's keys away
// from the plot; hand it back, or `+`/`-`/`f` would stop reaching the plot.
gtk_widget_grab_focus(demo.area->widget());
gtk_gl_area_queue_render(GTK_GL_AREA(demo.area->widget()));
}
/// The headless exit path: at the deadline, resize the window (scripted), check
/// that the layout was recomputed for the new geometry, then queue the exit frame
/// and let `on_render_after` take the screenshot and end the run.
gboolean on_exit_timer(gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
if (!demo.automated || now_s() - demo.run_start < demo.exit_after_s) return G_SOURCE_CONTINUE;
// The pump below iterates the main context, so this very timeout can fire from
// inside this call; the guard is what keeps the exit sequence -- the scripted
// resize, its check, the exit frame -- to one run.
if (demo.exit_started) return G_SOURCE_REMOVE;
demo.exit_started = true;
if (demo.widget == nullptr) return G_SOURCE_REMOVE; // the window is already gone
const plot::PlotLayout before = std::as_const(*demo.graph).layout();
const plot::Rect viewport_before = demo.area->device_viewport();
const int width_before = gtk_widget_get_width(demo.area->widget());
const int height_before = gtk_widget_get_height(demo.area->widget());
// The resize goes through the window system, so pump the main context until the
// area's allocation follows, with a deadline. The pump is what lets the resize
// and the frames in between (which re-derive the viewport) actually happen.
gtk_window_set_default_size(demo.window, kExitWidth, kExitHeight);
const f64 deadline = now_s() + 1.0;
while (gtk_widget_get_width(demo.area->widget()) == width_before && now_s() < deadline) {
while (g_main_context_pending(nullptr)) g_main_context_iteration(nullptr, FALSE);
g_usleep(1000);
}
const plot::PlotLayout after = std::as_const(*demo.graph).layout();
const plot::Rect viewport = demo.area->device_viewport();
// The evidence that the layout was *recomputed and not scaled*: the y label
// strip is a number of *font pixels*, so it stays the same size while the axes takes
// the rest of the new allocation. Two things are checked, and neither is "the
// strip is the same number":
//
// * the axes and its strips tile the *current* viewport exactly -- a layout
// left over from the old geometry would still tile the old one;
// * the strip is nearer the previous strip than that strip scaled by the resize
// ratio -- a layout whose strips were *scaled* would land on the second.
//
// The strip is not invariant, and the check must not pretend it is: it is the
// widest major tick label plus padding, and the tick *step* follows the axes
// length, which the resize changes. Measured at scale 2: 18 majors over 1440
// device px with "-800m" the widest label, 8 over 1080 with "-500m" -- 78 and
// 76 px in this bitmap font, whose '8' is the wider glyph. That is 2 px of
// recomputed strip, against 174 * 1620/2380 = 118 px of scaled strip.
const f32 resize_ratio = viewport_before.w > 0.0f ? viewport.w / viewport_before.w : 1.0f;
const f32 scaled_strip = before.axes.x * resize_ratio;
const f32 drift = std::fabs(after.axes.x - before.axes.x);
const bool tiles = std::fabs(after.axes.w - (viewport.w - after.axes.x)) <= 0.5f &&
std::fabs(after.axes.h - (viewport.h - after.axes.y - after.x_axis_height -
after.x_title_height)) <= 0.5f;
const bool metric_strip = drift < std::fabs(after.axes.x - scaled_strip);
const bool recomputed = after.axes.w != before.axes.w && tiles && metric_strip;
std::printf("\n[gtk_demo] ---- exit frame ----\n");
std::printf("[gtk_demo] resize logical %dx%d -> %dx%d, device viewport %ux%u, scale %g\n",
width_before, height_before, gtk_widget_get_width(demo.area->widget()),
gtk_widget_get_height(demo.area->widget()), static_cast<unsigned>(viewport.w),
static_cast<unsigned>(viewport.h), static_cast<double>(demo.area->device_scale()));
std::printf("[gtk_demo] layout axes %.0fx%.0f at (%.0f,%.0f) -> %.0fx%.0f at (%.0f,%.0f): %s\n",
static_cast<double>(before.axes.w), static_cast<double>(before.axes.h),
static_cast<double>(before.axes.x), static_cast<double>(before.axes.y),
static_cast<double>(after.axes.w), static_cast<double>(after.axes.h),
static_cast<double>(after.axes.x), static_cast<double>(after.axes.y),
recomputed ? "recomputed for the new viewport (strips metric, not scaled)"
: "FAIL: stale or rescaled layout");
std::printf("[gtk_demo] layout y label strip %.0f -> %.0f px of a %.0f -> %.0f px wide axes,"
" %.0f px of drift against a scaled %.0f\n",
static_cast<double>(before.axes.x), static_cast<double>(after.axes.x),
static_cast<double>(before.axes.w), static_cast<double>(after.axes.w),
static_cast<double>(drift), static_cast<double>(scaled_strip));
std::printf("[gtk_demo] init %s: %s\n", plot::to_string(demo.area->init_status().code),
demo.area->init_status().message);
std::fflush(stdout);
demo.dump_path = demo.screenshot;
demo.exit_pending = true;
gtk_gl_area_queue_render(GTK_GL_AREA(demo.area->widget()));
return G_SOURCE_REMOVE;
}
void on_activate(GtkApplication* app, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
demo.app = app;
GtkWidget* window = gtk_application_window_new(app);
demo.window = GTK_WINDOW(window);
gtk_window_set_title(GTK_WINDOW(window), "plot gtk4 demo");
gtk_window_set_default_size(GTK_WINDOW(window), kWindowWidth, kWindowHeight);
// The hand-close path's half of the shutdown ordering (see `on_close_request`);
// the automated path stops the producer in `on_teardown` instead, because
// `gtk_window_destroy()` does not emit this signal.
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), &demo);
// The plot area is the window's content. GTK owns the widget from here on: the
// wrapper is freed with it, which is also what releases the plot's GL resources.
demo.area = plot::gtk4::PlotArea::create(*demo.graph);
gtk_window_set_child(GTK_WINDOW(window), demo.area->widget());
// The weak pointer has to be seeded with the widget it watches: GLib only
// remembers the *address* and clears it at finalization, it does not read or
// store the object (this is why the demo's copy is NULL until the two lines
// below -- and why a missing assignment would leave every callback believing
// the window was already gone).
demo.widget = demo.area->widget();
g_object_add_weak_pointer(G_OBJECT(demo.widget), reinterpret_cast<gpointer*>(&demo.widget));
GtkWidget* header = gtk_header_bar_new();
gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), gtk_label_new("plot gtk4 demo"));
GtkWidget* fit = gtk_button_new_with_label("Fit");
g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &demo);
gtk_header_bar_pack_start(GTK_HEADER_BAR(header), fit);
gtk_window_set_titlebar(GTK_WINDOW(window), header);
// The exit frame's screenshot is taken from the render signal, after the
// adapter's handler has drawn the frame: `_after` is what guarantees the order.
g_signal_connect_after(demo.area->widget(), "render", G_CALLBACK(on_render_after), &demo);
// The frame callback: the same host-side work the other demos do, on the thread
// that owns the GL context (the GTK main thread), never on the producer's.
demo.area->set_frame_callback([&demo](plot::Plot& p, plot::f64 dt) {
demo.stream->acquire(); // once per frame, on the render thread
// Re-frame the live stream while auto scale is armed. `auto_fit_*` re-arms it,
// so this may only run while it is still on: calling it after a pan would undo
// the pan every frame.
if (p.x_axis(demo.axis_x).auto_scale) p.auto_fit_x();
if (p.y_axis(demo.axis_y).auto_scale) p.auto_fit_y(demo.axis_y);
if (demo.cursors_follow) {
if (demo.area->controller().drag_mode() == plot::input::DragMode::MoveCursor ||
!p.x_axis(demo.axis_x).auto_scale) {
demo.cursors_follow = false;
} else {
const plot::Range xr = p.x_range(demo.axis_x);
p.set_cursor_x(demo.cursor_a, xr.min + 0.35 * xr.span());
p.set_cursor_x(demo.cursor_b, xr.min + 0.65 * xr.span());
}
}
demo.meter.frames += 1;
demo.meter.elapsed += dt;
if (demo.meter.elapsed - demo.meter.last_report >= 0.5) {
const u64 produced = demo.producer->produced();
demo.meter.rate = static_cast<f64>(produced - demo.meter.last_produced) /
(demo.meter.elapsed - demo.meter.last_report);
demo.meter.last_produced = produced;
demo.meter.last_report = demo.meter.elapsed;
const plot::Rect viewport = demo.area->last_device_viewport();
const plot::Rect axes = std::as_const(p).layout().axes;
const plot::Range xr = p.x_range(demo.axis_x);
std::printf(
"[gtk_demo] t=%.1fs %.3f MS/s %.2f ms/frame scale %g fbo %ux%u axes %.0fx%.0f"
" x [%.6f, %.6f] span %.6f auto %d %llu frames\n",
demo.meter.elapsed, demo.meter.rate * 1.0e-6,
static_cast<double>(demo.area->mean_frame_ms()),
static_cast<double>(demo.area->device_scale()), static_cast<unsigned>(viewport.w),
static_cast<unsigned>(viewport.h),
static_cast<double>(axes.w), static_cast<double>(axes.h), xr.min, xr.max, xr.span(),
p.x_axis(demo.axis_x).auto_scale ? 1 : 0,
static_cast<unsigned long long>(demo.meter.frames));
std::fflush(stdout);
}
});
gtk_window_present(GTK_WINDOW(window));
// So the controller's keys reach the plot: a key event goes to the focused
// widget, and a `GtkGLArea` is focusable because the adapter says so.
gtk_window_set_focus(GTK_WINDOW(window), demo.area->widget());
// The repaint pump (~60 Hz, the display rate this demo runs at) and -- in the
// automated mode -- the deadline that runs the exit path.
g_timeout_add(16, &on_repaint, &demo);
if (demo.automated) g_timeout_add(20, &on_exit_timer, &demo);
}
/// The run summary, printed after the main loop: the stream, the plot and the
/// producer are the demo's, and outlive the window, so only the renderer's
/// numbers come from the snapshot.
void print_summary(Demo& demo) {
const Snapshot& s = demo.snapshot;
const plot::Measurement m = demo.graph->measurement(demo.measure);
const f64 spp = s.axes.w > 0.0f ? static_cast<f64>(s.stats.visible_samples) /
static_cast<f64>(s.axes.w)
: 0.0;
std::printf("\n[gtk_demo] ---- run summary ----\n");
std::printf("[gtk_demo] producer %llu samples pushed, last metered %.3f MS/s\n",
static_cast<unsigned long long>(demo.producer->produced()), demo.meter.rate * 1.0e-6);
std::printf("[gtk_demo] frames %llu staged (%llu frame callbacks) in %.3f s, mean %.2f"
" ms/frame (%.1f fps), last %.2f ms\n",
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(demo.meter.frames), demo.meter.elapsed,
static_cast<double>(s.mean_ms),
s.mean_ms > 0.0f ? 1.0e3 / static_cast<double>(s.mean_ms) : 0.0,
static_cast<double>(s.last_ms));
std::printf("[gtk_demo] retained %llu samples, x [%.6f, %.6f] s\n",
static_cast<unsigned long long>(demo.stream->size()),
demo.stream->x_bounds().min, demo.stream->x_bounds().max);
std::printf("[gtk_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s, visible %llu,"
" axes %.0fx%.0f, %u segments, %u draw calls\n",
spp, static_cast<double>(s.dense_spp),
s.stats.lod_traces > 0 ? "LOD pyramid" : "raw samples",
static_cast<unsigned long long>(s.stats.visible_samples),
static_cast<double>(s.axes.w), static_cast<double>(s.axes.h), s.stats.staged_segments,
s.stats.draw_calls);
std::printf("[gtk_demo] measurement %s dx %g s dy %g rms %g mean %g\n",
m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean);
std::printf("[gtk_demo] gl %s upload, gl error on the last frame %d\n",
s.persistent ? "persistent-mapped ring" : "orphan", s.gl_error ? 1 : 0);
std::printf("[gtk_demo] init %s: %s\n", plot::to_string(s.init_code), s.init_message);
std::fflush(stdout);
}
} // namespace
int main(int argc, char** argv) {
const f64 exit_after_s = exit_after_seconds();
const bool automated = exit_after_s > 0.0;
const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT");
// --- the stream and the plot the area draws -----------------------------
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
Producer producer(stream);
plot::Plot graph;
// Time axis: clock labels, which `TickFormat::Time` routes through
// `format_time`. The value axis keeps SI labels, which is `format_si`.
const u32 axis_x = graph.add_x_axis(
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
const u32 axis_y = graph.add_y_axis(
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y});
// Snapped cursors sit exactly on a sample, so the readout measures samples and
// not a rounded position between two of them.
const u32 cursor_a = graph.add_cursor(0.0, kCursorAColor);
const u32 cursor_b = graph.add_cursor(0.0, kCursorBColor);
graph.set_cursor_snap(cursor_a, plot::SnapMode::NearestSample);
graph.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample);
const u32 measure = graph.add_measure(cursor_a, cursor_b, trace);
Demo demo;
demo.stream = &stream;
demo.producer = &producer;
demo.graph = &graph;
demo.axis_x = axis_x;
demo.axis_y = axis_y;
demo.cursor_a = cursor_a;
demo.cursor_b = cursor_b;
demo.measure = measure;
demo.automated = automated;
demo.exit_after_s = exit_after_s;
demo.run_start = now_s();
demo.screenshot = screenshot;
GtkApplication* app = gtk_application_new("org.plot.gtk_demo", G_APPLICATION_DEFAULT_FLAGS);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), &demo);
const int app_result = g_application_run(G_APPLICATION(app), argc, argv);
// The window is gone by now -- `on_render_after` destroyed it before quitting --
// so only what the demo owns is read below.
producer.stop(); // already stopped by on_teardown; idempotent, and this is the
// path a window closed by hand takes (no on_teardown then)
print_summary(demo);
std::printf("[gtk_demo] teardown the area widget is %s after the main loop\n",
demo.widget != nullptr ? "still alive" : "destroyed (the wrapper and its GL"
" resources went with it)");
g_object_unref(app);
return app_result;
}
+561
View File
@@ -0,0 +1,561 @@
// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a
// widget inside an ImGui window that also owns a few controls, with ImGui drawing
// its own chrome into the same framebuffer.
//
// What it demonstrates:
//
// - `plot::imgui::PlotWidget`: a child region whose framebuffer rectangle the
// plot renders into directly (no texture, no FBO), while ImGui's chrome is
// drawn around it in the same pass;
// - the demo-side controls the widget deliberately knows nothing about: a
// slider for the producer's ingest rate, a button that injects a spike into
// the `StreamSeries`, and a checkbox toggling the LOD statistics overlay;
// - the frame order the embedding needs -- NewFrame, build UI (the widget draws
// the plot here), ImGui::Render, then the backend's draw data -- and that
// ImGui still renders correctly after the plot renderer has come and gone;
// - the headless smoke mode every demo here supports
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), whose screenshot this
// demo writes as the *whole* framebuffer (dump_full_framebuffer_ppm below), so
// the PPM holds the ImGui chrome as well as the plot.
//
// The producer is joined before the stream it writes into is destroyed, and
// before the GL context goes away.
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
// would rewrite the enumerators of plot/gl/api.hpp, which this file includes.
#define GLFW_INCLUDE_NONE
#include <GLFW/glfw3.h>
#include <backends/imgui_impl_glfw.h>
#include <backends/imgui_impl_opengl3.h>
#include <imgui.h>
#include <imgui/imgui_plot.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/gl/loader.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <numbers>
#include <optional>
#include <thread>
#include <utility>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// The sample *spacing* of the series: 1 microsecond, as in the GLFW demo. The
/// rate slider below changes how fast samples arrive, not this spacing (a
/// `StreamSeries`' dx is fixed at construction), so a slower ingest simply
/// advances the window more slowly.
constexpr f64 kDx = 1.0e-6;
/// The retained window: 1 << 20 samples = 1.048576 s of samples.
constexpr u64 kCapacity = 1u << 20;
/// Producer wakeup: about a millisecond of samples per push batch.
constexpr u64 kBatch = 1u << 10;
/// Cap on one push batch, so a process stopped and resumed catches up over
/// several iterations instead of allocating a batch for the whole gap.
constexpr u64 kMaxBatch = 1u << 16;
/// Longest the producer sleeps in one go, so a slider move is picked up promptly.
constexpr f64 kMaxSleepS = 0.05;
constexpr f64 kSineHz = 437.0;
constexpr f64 kNoiseAmp = 0.02;
constexpr f64 kSpikePeriodS = 0.1;
constexpr f64 kSpikeWidthS = 2.0e-6;
constexpr f64 kSpikeAmp = 3.0;
/// The manual spike the button injects: tall enough to be the maximum of every
/// LOD bucket it falls in, so it survives decimation.
constexpr f64 kInjectedSpikeAmp = 5.0;
/// The trace colour the render smoke test's blue-dominance predicate expects, so
/// the same predicate reads this demo's screenshot.
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
/// The host's own background, behind everything ImGui and the plot do: kept
/// distinct from the renderer's background so the PPM shows that the plot cleared
/// only its own rectangle.
constexpr plot::Color kHostClearColor = plot::Color::hex(0x263145);
constexpr f32 kWindowWidth = 960.0f;
constexpr f32 kWindowHeight = 680.0f;
/// y(t) for sample index `i`: sine + deterministic noise + a periodic narrow
/// spike. Same signal as the GLFW demo, so the two screenshots show the same
/// trace from the same source.
f32 sample_at(u64 i) noexcept {
const f64 t = static_cast<f64>(i) * kDx;
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
u32 h = static_cast<u32>(i) * 2654435761u;
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
const f64 phase = std::fmod(t, kSpikePeriodS);
if (phase < kSpikeWidthS) {
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
}
return static_cast<f32>(y);
}
/// Feeds a `StreamSeries` from its own thread at a settable rate, paced by
/// absolute deadlines off a steady clock: every wakeup produces every sample
/// whose time has already come, so a late wakeup is caught up instead of pushing
/// the phase back. The rate is settable from the render thread, and a requested
/// spike rides the middle sample of the batch in flight.
class Producer {
public:
explicit Producer(plot::data::StreamSeries& series)
: series_(&series), thread_([this] { run(); }) {}
~Producer() { stop(); }
Producer(const Producer&) = delete;
Producer& operator=(const Producer&) = delete;
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls
/// it, so the thread is never detached and never outlives the stream.
void stop() noexcept {
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) thread_.join();
}
/// Samples per second the producer aims for. Written by the render thread.
void set_rate(f64 samples_per_second) noexcept {
rate_.store(samples_per_second > 0.0 ? samples_per_second : 1.0, std::memory_order_relaxed);
}
/// Asks the next full batch to carry one tall sample. Accumulates, so a rapid
/// double click injects two.
void request_spike() noexcept { spikes_.fetch_add(1, std::memory_order_relaxed); }
/// Samples pushed so far. Written by the producer, read by the render thread.
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
private:
void run() {
using clock = std::chrono::steady_clock;
const auto start = clock::now();
u64 next = 0;
while (!stop_.load(std::memory_order_relaxed)) {
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
const f64 rate = rate_.load(std::memory_order_relaxed);
const u64 due = static_cast<u64>(elapsed * rate) + 1; // samples whose time has come
if (due > next) {
u64 n = due - next;
if (n > kMaxBatch) n = kMaxBatch;
batch_.resize(static_cast<plot::usize>(n));
for (u64 k = 0; k < n; ++k) {
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
}
if (n > 0 && spikes_.load(std::memory_order_relaxed) > 0) {
batch_[static_cast<plot::usize>(n / 2)] += static_cast<f32>(kInjectedSpikeAmp);
spikes_.fetch_sub(1, std::memory_order_relaxed);
}
series_->push(batch_.data(), n);
next += n;
produced_.store(next, std::memory_order_relaxed);
}
// Sleep until a batch past the last produced sample, capped so a rate
// change is noticed quickly instead of at the old rate's next deadline.
const f64 wake_s =
(static_cast<f64>(next) + static_cast<f64>(kBatch)) / rate;
const f64 sleep_s = wake_s < kMaxSleepS ? wake_s : kMaxSleepS;
std::this_thread::sleep_until(
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(sleep_s)));
}
}
plot::data::StreamSeries* series_;
std::vector<f32> batch_;
std::atomic<f64> rate_{1.0e6};
std::atomic<u64> produced_{0};
std::atomic<int> spikes_{0};
std::atomic<bool> stop_{false};
std::thread thread_; ///< declared last: the lambda runs once everything is up
};
/// The live readout: the achieved push rate, the frame time and the frame count.
struct Meter {
f64 frame_ms = 0.0; ///< exponential moving average of the whole frame
f64 elapsed = 0.0; ///< seconds of loop time
f64 last_report = 0.0;
u64 last_produced = 0;
u64 frames = 0;
f64 rate = 0.0; ///< samples/s over the last metered interval
};
/// `glfwGetProcAddress` returns GLFW's `void (*)(void)`, `plot::gl::ProcLoader`
/// returns `void*`: a function pointer respelled as an object pointer. POSIX
/// guarantees the two have the same representation, and the bit copy states that
/// where a `reinterpret_cast` would be merely conditionally supported.
void* resolve_gl(const char* name) noexcept {
GLFWglproc p = glfwGetProcAddress(name);
void* out = nullptr;
static_assert(sizeof(out) == sizeof(p), "function and object pointers must match");
std::memcpy(&out, &p, sizeof(out));
return out;
}
void report_failure(const plot::Status& st, const char* what) noexcept {
std::fprintf(stderr, "plot_imgui_demo: %s: %s: %s\n", what, plot::to_string(st.code), st.message);
}
/// Writes the *whole* bound framebuffer as a PPM (P6).
///
/// Demo-side, not library API: `Renderer::dump_frame_ppm()` covers the plot's
/// viewport only, and this smoke mode has to show the ImGui chrome as well. Reads
/// RGBA (4-byte pixels have no row padding whatever the pack alignment) and
/// writes the rows top-down, since glReadPixels hands them back bottom-up.
bool dump_full_framebuffer_ppm(const char* path, int fb_w, int fb_h) {
if (path == nullptr || path[0] == '\0' || fb_w <= 0 || fb_h <= 0) return false;
const plot::usize w = static_cast<plot::usize>(fb_w);
const plot::usize h = static_cast<plot::usize>(fb_h);
std::vector<unsigned char> pixels(w * h * 4u, 0u);
plot::gl::glPixelStorei(plot::gl::GL_PACK_ALIGNMENT, 4);
plot::gl::glReadPixels(0, 0, fb_w, fb_h, plot::gl::GL_RGBA, plot::gl::GL_UNSIGNED_BYTE,
pixels.data());
std::FILE* file = std::fopen(path, "wb");
if (file == nullptr) return false;
std::fprintf(file, "P6\n%d %d\n255\n", fb_w, fb_h);
std::vector<unsigned char> row(w * 3u);
for (plot::usize y = 0; y < h; ++y) {
const unsigned char* src = pixels.data() + (h - 1u - y) * w * 4u;
for (plot::usize x = 0; x < w; ++x) {
row[x * 3u] = src[x * 4u];
row[x * 3u + 1u] = src[x * 4u + 1u];
row[x * 3u + 2u] = src[x * 4u + 2u];
}
if (std::fwrite(row.data(), 1u, row.size(), file) != row.size()) {
std::fclose(file);
return false;
}
}
return std::fclose(file) == 0;
}
/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not
/// positive, which is "run until the window is closed".
f64 exit_after_seconds() noexcept {
const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS");
if (v == nullptr || v[0] == '\0') return 0.0;
char* end = nullptr;
const f64 ms = std::strtod(v, &end);
if (end == v || !(ms > 0.0)) return 0.0;
return ms * 1.0e-3;
}
} // namespace
int main() {
const f64 exit_after_s = exit_after_seconds();
const bool automated = exit_after_s > 0.0;
const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT");
// --- window and 3.3 core context ------------------------------------
if (glfwInit() != GLFW_TRUE) {
std::fprintf(stderr, "plot_imgui_demo: glfwInit() failed\n");
return 1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
// A run with a deadline is the automated path: it stays off-screen, so a CI run
// never flashes a window on a real display.
if (automated) glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
GLFWwindow* const window =
glfwCreateWindow(1280, 720, "plot imgui demo", nullptr, nullptr);
if (window == nullptr) {
const char* desc = nullptr;
(void)glfwGetError(&desc);
std::fprintf(stderr, "plot_imgui_demo: glfwCreateWindow() failed: %s\n",
desc != nullptr ? desc : "no error description");
glfwTerminate();
return 1;
}
glfwMakeContextCurrent(window);
// The bounded run is not throttled by a display refresh, so its frame times are
// the CPU's and not a vblank's.
glfwSwapInterval(automated ? 0 : 1);
plot::Status st;
if (!plot::gl::load(&resolve_gl)) {
st = plot::Status{plot::ErrorCode::Unsupported,
"plot::gl::load() failed: missing OpenGL 3.3 entry point"};
report_failure(st, "gl::load()");
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
std::optional<plot::Renderer> renderer;
renderer.emplace();
if (!renderer->init(&st)) {
if (st.code == plot::ErrorCode::Ok) {
st = plot::Status{plot::ErrorCode::GLFailure, "Renderer::init() failed"};
}
report_failure(st, "Renderer::init()");
renderer.reset();
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
// --- Dear ImGui -----------------------------------------------------
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui::GetIO().IniFilename = nullptr; // no imgui.ini written next to the binary
ImGui::StyleColorsDark();
const bool platform_ok = ImGui_ImplGlfw_InitForOpenGL(window, true);
const bool backend_ok = platform_ok && ImGui_ImplOpenGL3_Init("#version 330 core");
if (!backend_ok) {
st = plot::Status{plot::ErrorCode::Unsupported,
"Dear ImGui backend init failed (ImGui_ImplGlfw_InitForOpenGL / "
"ImGui_ImplOpenGL3_Init): no GL loader for the current context"};
report_failure(st, "imgui backend");
if (platform_ok) ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
renderer.reset();
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
// --- the plot and its stream ----------------------------------------
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
Producer producer(stream);
producer.set_rate(1.0e6);
plot::Plot graph;
const u32 axis_x = graph.add_x_axis(
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
const u32 axis_y = graph.add_y_axis(
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y});
plot::imgui::PlotWidget widget;
// Demo state: the widget knows about none of it.
f32 rate_msamples = 1.0f;
bool show_lod_stats = true;
Meter meter;
const f64 start = glfwGetTime();
f64 previous = start;
while (glfwWindowShouldClose(window) == GLFW_FALSE) {
glfwPollEvents();
int fb_w = 0;
int fb_h = 0;
glfwGetFramebufferSize(window, &fb_w, &fb_h);
if (fb_w <= 0 || fb_h <= 0) {
// Minimised or otherwise undrawable: nothing to render and no screenshot to
// take. Still honour the deadline, so a bounded run ends rather than idling
// past the one it was given.
if (automated && glfwGetTime() - start >= exit_after_s) {
std::fprintf(stderr,
"[imgui_demo] exit deadline reached with no drawable (framebuffer 0x0);"
" nothing to dump\n");
break;
}
glfwWaitEventsTimeout(0.05);
continue;
}
stream.acquire(); // once per frame, on the render thread
const f64 now = glfwGetTime();
const f64 dt = now - previous;
previous = now;
// The host clears its own frame first: the renderer clears only the plot's
// rectangle, so everything outside it is ours to paint.
plot::gl::glViewport(0, 0, fb_w, fb_h);
plot::gl::glClearColor(kHostClearColor.r, kHostClearColor.g, kHostClearColor.b,
kHostClearColor.a);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
ImGui_ImplGlfw_NewFrame();
ImGui_ImplOpenGL3_NewFrame(); // builds the font atlas/texture on its first call
ImGui::NewFrame();
// Re-frame the live stream while the user has not overridden the ranges:
// auto_fit_* re-arms auto scale, so it may only run while that is still on.
if (graph.x_axis(axis_x).auto_scale) graph.auto_fit_x();
if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y);
const std::chrono::steady_clock::time_point frame_begin =
std::chrono::steady_clock::now();
ImGui::SetNextWindowPos(ImVec2(24.0f, 24.0f), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(kWindowWidth, kWindowHeight), ImGuiCond_Once);
// NoBackground: the window's own fill would otherwise be drawn over the
// child's rectangle after the widget has already rendered the plot there.
// This is the host's side of the widget's documented precondition.
ImGui::Begin("plot imgui demo", nullptr, ImGuiWindowFlags_NoBackground);
ImGui::Text("%.3f Msamples/s | %.2f ms/frame | %llu frames | %dx%d fb",
meter.rate * 1.0e-6, meter.frame_ms,
static_cast<unsigned long long>(meter.frames), fb_w, fb_h);
ImGui::SliderFloat("ingest rate", &rate_msamples, 0.05f, 2.0f, "%.2f Msamples/s");
ImGui::SameLine();
if (ImGui::Button("inject spike")) producer.request_spike();
ImGui::Checkbox("LOD statistics overlay", &show_lod_stats);
ImGui::Separator();
producer.set_rate(static_cast<f64>(rate_msamples) * 1.0e6);
// The widget takes the rest of the window, and draws the plot into exactly
// that rectangle of the framebuffer, now, before ImGui renders its chrome.
const ImVec2 plot_origin = ImGui::GetCursorScreenPos();
const ImVec2 plot_size = ImGui::GetContentRegionAvail();
widget.draw("plot", graph, plot::Vec2{plot_size.x, plot_size.y}, *renderer);
if (show_lod_stats) {
// Demo-side overlay: the renderer's own statistics for the frame it just
// drew, painted on top of the plot through ImGui's draw list. Nothing here
// is library API, and the widget is unaware of it.
const plot::FrameStats stats = renderer->stats();
const plot::Rect axes = std::as_const(graph).layout().axes;
const f64 spp = axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) /
static_cast<f64>(axes.w)
: 0.0;
char line_a[160];
char line_b[160];
std::snprintf(line_a, sizeof line_a,
"LOD %s | %llu samples in window | %.0f samples/px | axes %.0fx%.0f",
stats.lod_traces > 0 ? "pyramid" : (stats.raw_traces > 0 ? "raw" : "--"),
static_cast<unsigned long long>(stats.visible_samples), spp,
static_cast<double>(axes.w), static_cast<double>(axes.h));
std::snprintf(line_b, sizeof line_b,
"%u segments | %u draw calls | lod %u raw %u | %.2f ms/frame",
stats.staged_segments, stats.draw_calls, stats.lod_traces, stats.raw_traces,
meter.frame_ms);
const ImVec2 size_a = ImGui::CalcTextSize(line_a);
const ImVec2 size_b = ImGui::CalcTextSize(line_b);
const ImVec2 pad{6.0f, 4.0f};
const ImVec2 box{std::max(size_a.x, size_b.x), size_a.y + size_b.y};
const ImVec2 at{plot_origin.x + 10.0f, plot_origin.y + 10.0f};
ImDrawList* const dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(ImVec2(at.x - pad.x, at.y - pad.y),
ImVec2(at.x + box.x + pad.x, at.y + box.y + pad.y),
IM_COL32(8, 12, 18, 190), 3.0f);
dl->AddText(at, IM_COL32(235, 240, 248, 255), line_a);
dl->AddText(ImVec2(at.x, at.y + size_a.y), IM_COL32(150, 200, 240, 255), line_b);
}
ImGui::End();
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
meter.frames += 1;
const f64 frame_ms =
std::chrono::duration<f64>(std::chrono::steady_clock::now() - frame_begin).count() * 1.0e3;
meter.frame_ms += 0.1 * (frame_ms - meter.frame_ms); // EMA
meter.elapsed += dt;
if (meter.elapsed - meter.last_report >= 0.5) {
const u64 produced = producer.produced();
meter.rate = static_cast<f64>(produced - meter.last_produced) /
(meter.elapsed - meter.last_report);
meter.last_produced = produced;
meter.last_report = meter.elapsed;
const plot::Range xr = graph.x_range(axis_x);
const bool auto_x = graph.x_axis(axis_x).auto_scale;
const bool auto_y = graph.y_axis(axis_y).auto_scale;
std::printf("[imgui_demo] t=%.1fs %.3f Msamples/s %.2f ms/frame %llu frames"
" x=[%.4f, %.4f] s (span %.4f) auto x=%d y=%d\n",
meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms,
static_cast<unsigned long long>(meter.frames), xr.min, xr.max, xr.span(),
auto_x ? 1 : 0, auto_y ? 1 : 0);
std::fflush(stdout);
}
const bool exiting = automated && (glfwGetTime() - start) >= exit_after_s;
if (exiting && screenshot != nullptr && screenshot[0] != '\0') {
const bool dumped = dump_full_framebuffer_ppm(screenshot, fb_w, fb_h);
std::printf("[imgui_demo] screenshot %s: %s\n", screenshot,
dumped ? "written" : "FAILED");
std::fflush(stdout);
}
glfwSwapBuffers(window);
if (exiting) break;
}
// --- teardown, in the order the resources depend on each other --------
int last_fb_w = 0;
int last_fb_h = 0;
int last_window_w = 0;
int last_window_h = 0;
float scale_x = 1.0f;
float scale_y = 1.0f;
glfwGetFramebufferSize(window, &last_fb_w, &last_fb_h);
glfwGetWindowSize(window, &last_window_w, &last_window_h);
glfwGetWindowContentScale(window, &scale_x, &scale_y);
const plot::FrameStats last_stats = renderer->stats();
const f64 dense_spp = renderer->style().dense_threshold_spp;
const bool persistent_upload = renderer->using_persistent_upload();
const bool gl_error = renderer->last_frame_had_gl_error();
producer.stop(); // no thread pushes into the stream after this point
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
// The renderer's RAII handles call glDelete*, so the context must be current --
// and alive -- when they run.
renderer.reset();
glfwDestroyWindow(window);
glfwTerminate();
const f64 mean_ms =
meter.frames > 0 ? meter.elapsed / static_cast<f64>(meter.frames) * 1.0e3 : 0.0;
std::printf("\n[imgui_demo] ---- run summary ----\n");
std::printf("[imgui_demo] producer %llu samples pushed, last metered %.3f Msamples/s\n",
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
std::printf("[imgui_demo] frames %llu in %.3f s, mean %.2f ms/frame (%.1f fps),"
" last metered %.2f ms/frame\n",
static_cast<unsigned long long>(meter.frames), meter.elapsed, mean_ms,
mean_ms > 0.0 ? 1.0e3 / mean_ms : 0.0, meter.frame_ms);
const plot::Rect last_axes = std::as_const(graph).layout().axes;
const f64 last_spp = last_axes.w > 0.0f ? static_cast<f64>(last_stats.visible_samples) /
static_cast<f64>(last_axes.w)
: 0.0;
std::printf("[imgui_demo] retained %llu samples, x [%.6f, %.6f] s\n",
static_cast<unsigned long long>(stream.size()), stream.x_bounds().min,
stream.x_bounds().max);
std::printf("[imgui_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s\n", last_spp,
dense_spp, last_stats.lod_traces > 0 ? "LOD pyramid" : "raw samples");
std::printf("[imgui_demo] last frame visible_samples %llu, axes %.0fx%.0f, staged_segments %u,"
" draw_calls %u, lod %u raw %u\n",
static_cast<unsigned long long>(last_stats.visible_samples),
static_cast<double>(last_axes.w), static_cast<double>(last_axes.h),
last_stats.staged_segments, last_stats.draw_calls, last_stats.lod_traces,
last_stats.raw_traces);
std::printf("[imgui_demo] pixel spaces framebuffer %dx%d, window %dx%d, content scale %.2fx%.2f\n",
last_fb_w, last_fb_h, last_window_w, last_window_h, static_cast<double>(scale_x),
static_cast<double>(scale_y));
std::printf("[imgui_demo] upload path %s, gl error on the last frame %d\n",
persistent_upload ? "persistent-mapped ring" : "orphan", gl_error ? 1 : 0);
return 0;
}
+560
View File
@@ -0,0 +1,560 @@
// The Qt application: the same 1 MS/s stream the other demos draw, as the central
// widget of a QMainWindow that also carries its own toolbar, with Qt drawing the
// window chrome around a plot the widget renders into its own framebuffer.
//
// What it demonstrates:
//
// - `plot::qt::PlotWidget`: a QOpenGLWidget whose FBO the plot is drawn into
// (no texture, no offscreen pass of its own), driven by Qt's mouse, wheel and
// key events;
// - the surface format an application must have set *before* the widget exists --
// a `QOpenGLWidget` is handed the application default format and cannot ask for
// another one afterwards -- and `Qt::AA_UseDesktopOpenGL`, so Qt cannot pick an
// ES or ANGLE backend that has no 3.3 core profile to give;
// - the device-pixel-ratio mapping: Qt lays out and reports in logical pixels,
// the widget's framebuffer is in device pixels, and the widget converts both the
// viewport and every input coordinate with the same factor (see the header);
// - the demo-side controls the widget knows nothing about -- Fit, Autoscale and
// Add cursor on the toolbar;
// - a scripted resize before the exit frame, so the headless run proves the widget
// re-lays the plot out for the new geometry rather than scaling a stale one;
// - the headless smoke mode every demo here supports
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT). The screenshot is the whole
// *widget*, via `QOpenGLWidget::grabFramebuffer()` (dump_widget_ppm below), so
// the PPM holds the plot's own chrome; the window's toolbar is Qt's, and is not
// in the widget's framebuffer.
//
// The producer is joined before the stream it writes into is destroyed, and before
// the widget's GL resources are released.
// The widget's header first: it brings this library's GL surface in before Qt's
// <GL/gl.h> macros exist (see the include-order note there).
#include <qt6/qt_plot_widget.hpp>
#include <plot/data/stream_series.hpp>
#include <QAction>
#include <QApplication>
#include <QCoreApplication>
#include <QImage>
#include <QMainWindow>
#include <QMouseEvent>
#include <QSize>
#include <QSurfaceFormat>
#include <QTimer>
#include <QToolBar>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <numbers>
#include <thread>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// One sample per microsecond: 1 MS/s.
constexpr f64 kDx = 1.0e-6;
/// The retained window: 1 << 20 samples = 1.048576 s.
constexpr u64 kCapacity = 1u << 20;
/// Producer wakeup: about a millisecond of samples per push batch.
constexpr u64 kBatch = 1u << 10;
/// Cap on one push batch, so a process that was stopped and resumed catches up
/// over several iterations instead of allocating a batch for the whole gap.
constexpr u64 kMaxBatch = 1u << 16;
constexpr f64 kSineHz = 437.0;
constexpr f64 kNoiseAmp = 0.02;
constexpr f64 kSpikePeriodS = 0.1;
constexpr f64 kSpikeWidthS = 2.0e-6;
constexpr f64 kSpikeAmp = 3.0;
/// The trace colour the render smoke test's blue-dominance predicate expects, so
/// the same predicate reads this demo's screenshot.
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020);
constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8);
/// The colours "Add cursor" cycles through.
constexpr plot::Color kExtraCursorColors[] = {plot::Color::hex(0xFF6B6B),
plot::Color::hex(0xB08CFF),
plot::Color::hex(0x6BE3FF),
plot::Color::hex(0xE8E86B)};
/// Logical pixels. The widget's framebuffer is `devicePixelRatioF()` times this.
constexpr int kWindowWidth = 1200;
constexpr int kWindowHeight = 800;
/// The size the scripted resize before the exit frame goes to: different on both
/// axes, so a screenshot that kept the old layout is visibly the old size.
constexpr int kExitWidth = 820;
constexpr int kExitHeight = 620;
/// y(t) for sample index `i`: sine + deterministic noise + a narrow spike, the
/// signal the other demos use.
f32 sample_at(u64 i) noexcept {
const f64 t = static_cast<f64>(i) * kDx;
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
u32 h = static_cast<u32>(i) * 2654435761u;
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
const f64 phase = std::fmod(t, kSpikePeriodS);
if (phase < kSpikeWidthS) {
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
}
return static_cast<f32>(y);
}
/// Feeds a `StreamSeries` from its own thread at 1 MS/s on average, paced by
/// absolute deadlines off a steady clock: every wakeup produces every sample
/// whose time has already come, so a late wakeup is caught up instead of pushing
/// the phase back.
class Producer {
public:
explicit Producer(plot::data::StreamSeries& series)
: series_(&series), thread_([this] { run(); }) {}
~Producer() { stop(); }
Producer(const Producer&) = delete;
Producer& operator=(const Producer&) = delete;
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls it,
/// so the thread is never detached and never outlives the stream.
void stop() noexcept {
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) thread_.join();
}
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
private:
void run() {
using clock = std::chrono::steady_clock;
const auto start = clock::now();
u64 next = 0;
while (!stop_.load(std::memory_order_relaxed)) {
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
const u64 due = static_cast<u64>(elapsed / kDx) + 1;
if (due > next) {
u64 n = due - next;
if (n > kMaxBatch) n = kMaxBatch;
batch_.resize(static_cast<plot::usize>(n));
for (u64 k = 0; k < n; ++k) {
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
}
series_->push(batch_.data(), n);
next += n;
produced_.store(next, std::memory_order_relaxed);
}
const f64 wake_s = static_cast<f64>(next + kBatch) * kDx;
std::this_thread::sleep_until(
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
}
}
plot::data::StreamSeries* series_;
std::vector<f32> batch_;
std::atomic<u64> produced_{0};
std::atomic<bool> stop_{false};
std::thread thread_; ///< declared last: the lambda runs once everything is up
};
/// The live readout, refreshed a few times a second.
struct Meter {
f64 elapsed = 0.0;
f64 last_report = 0.0;
u64 last_produced = 0;
f64 rate = 0.0; ///< samples/s over the last metered interval
u64 frames = 0; ///< frame callbacks run (one per paint)
};
f64 now_s() noexcept {
return std::chrono::duration<f64>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not
/// positive, which is "run until the window is closed".
f64 exit_after_seconds() noexcept {
const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS");
if (v == nullptr || v[0] == '\0') return 0.0;
char* end = nullptr;
const f64 ms = std::strtod(v, &end);
if (end == v || !(ms > 0.0)) return 0.0;
return ms * 1.0e-3;
}
/// Writes `image` out as a binary PPM (P6, 8 bits per channel, rows top-down).
///
/// Demo-side, not library API: `Renderer::dump_frame_ppm()` covers the plot's
/// viewport only, and this smoke mode has to show the widget as Qt composes it.
/// `QOpenGLWidget::grabFramebuffer()` hands back the widget's FBO in Qt's default
/// 32-bit format, which on a little-endian machine stores each pixel as
/// B,G,R,A -- not the R,G,B a PPM holds -- so the image is converted to
/// `QImage::Format_RGB888` first, the one Qt format whose in-memory byte order is
/// the file's. Scanlines are padded to a 4-byte boundary whatever the format, so
/// each row is copied through `constScanLine()` rather than assuming a tightly
/// packed block.
bool dump_widget_ppm(const QImage& image, const char* path) {
if (image.isNull() || path == nullptr || path[0] == '\0') return false;
const QImage rgb = image.format() == QImage::Format_RGB888
? image
: image.convertToFormat(QImage::Format_RGB888);
if (rgb.isNull()) return false;
std::FILE* file = std::fopen(path, "wb");
if (file == nullptr) return false;
if (std::fprintf(file, "P6\n%d %d\n255\n", rgb.width(), rgb.height()) < 0) {
std::fclose(file);
return false;
}
const plot::usize row_bytes = static_cast<plot::usize>(rgb.width()) * 3u;
for (int y = 0; y < rgb.height(); ++y) {
if (std::fwrite(rgb.constScanLine(y), 1u, row_bytes, file) != row_bytes) {
std::fclose(file);
return false;
}
}
return std::fclose(file) == 0;
}
} // namespace
int main(int argc, char** argv) {
const f64 exit_after_s = exit_after_seconds();
const bool automated = exit_after_s > 0.0;
const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT");
const f64 run_start = now_s();
// --- the surface format, before anything can create a context -----------
// Qt gives a widget the *application default* format at the moment its context
// is created and never renegotiates it, so this has to happen before the widget
// exists -- and, on some platforms, before the QApplication itself.
QSurfaceFormat format;
format.setVersion(3, 3);
format.setProfile(QSurfaceFormat::CoreProfile);
format.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
// The plot is 2D: the renderer never sets a depth test and never touches the
// stencil, so asking for either would only cost memory.
format.setDepthBufferSize(0);
format.setStencilBufferSize(0);
QSurfaceFormat::setDefaultFormat(format);
// ...and it has to be *desktop* GL: an ES or ANGLE backend has no 3.3 core
// profile to give, and this library's loader resolves desktop entry points.
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL);
QApplication app(argc, argv);
// --- the stream and the plot the widget draws --------------------------
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
Producer producer(stream);
plot::Plot graph;
// Time axis: clock labels, which `TickFormat::Time` routes through
// `format_time`. The value axis keeps SI labels, which is `format_si`.
const u32 axis_x = graph.add_x_axis(
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
const u32 axis_y = graph.add_y_axis(
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y});
// Snapped cursors sit exactly on a sample, so the readout measures samples and
// not a rounded position between two of them. The pair and its measurement are
// plot state; the toolbar's "Add cursor" adds more.
const u32 cursor_a = graph.add_cursor(0.0, kCursorAColor);
const u32 cursor_b = graph.add_cursor(0.0, kCursorBColor);
graph.set_cursor_snap(cursor_a, plot::SnapMode::NearestSample);
graph.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample);
const u32 measure = graph.add_measure(cursor_a, cursor_b, trace);
// --- the window, its toolbar and the widget ----------------------------
QMainWindow window;
window.setWindowTitle("plot qt demo");
// The widget is Qt's to own from here on: `setCentralWidget()` reparents it, so
// ~QMainWindow deletes it -- which is also what releases the plot's GL resources
// while the context is still alive, since `window` goes out of scope before
// `graph` does.
auto* const widget = new plot::qt::PlotWidget(graph);
window.setCentralWidget(widget);
QToolBar* const tools = window.addToolBar("plot");
tools->setMovable(false);
QAction* const fit_action = tools->addAction("Fit");
QAction* const autoscale_action = tools->addAction("Autoscale");
autoscale_action->setCheckable(true);
autoscale_action->setChecked(true);
QAction* const add_cursor_action = tools->addAction("Add cursor");
// A toolbar click moves focus to the tool button, which would take the
// controller's keys away from the plot, so every action hands focus back.
const auto keep_focus = [widget] { widget->setFocus(); };
Meter meter;
// The cursors ride the live window until the user takes over: the first
// ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale,
// stops them where the user put them.
bool cursors_follow = true;
plot::usize extra_cursor = 0;
widget->set_frame_callback([&](plot::Plot& p, plot::f64 dt) {
stream.acquire(); // once per frame, on the render thread
// Re-frame the live stream while auto scale is armed. `auto_fit_*` re-arms it,
// so this may only run while it is still on: calling it after a pan would undo
// the pan every frame.
if (p.x_axis(axis_x).auto_scale) p.auto_fit_x();
if (p.y_axis(axis_y).auto_scale) p.auto_fit_y(axis_y);
if (cursors_follow) {
if (widget->controller().drag_mode() == plot::input::DragMode::MoveCursor ||
!p.x_axis(axis_x).auto_scale) {
cursors_follow = false;
} else {
const plot::Range xr = p.x_range(axis_x);
p.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span());
p.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span());
}
}
meter.frames += 1;
meter.elapsed += dt;
if (meter.elapsed - meter.last_report >= 0.5) {
const u64 produced = producer.produced();
meter.rate = static_cast<f64>(produced - meter.last_produced) /
(meter.elapsed - meter.last_report);
meter.last_produced = produced;
meter.last_report = meter.elapsed;
const plot::Rect viewport = widget->last_device_viewport();
std::printf("[qt_demo] t=%.1fs %.3f MS/s %.2f ms/frame %.2f dpr fbo %ux%u %llu frames\n",
meter.elapsed, meter.rate * 1.0e-6, static_cast<double>(widget->mean_frame_ms()),
static_cast<double>(widget->devicePixelRatioF()),
static_cast<unsigned>(viewport.w), static_cast<unsigned>(viewport.h),
static_cast<unsigned long long>(meter.frames));
std::fflush(stdout);
}
});
// The toolbar's actions are the demo's, not the widget's: the plot is the only
// thing that knows what fitting, following or cursoring means.
QObject::connect(fit_action, &QAction::triggered, &window, [&] {
keep_focus();
// One-shot: frame the data now. `auto_fit_*` re-arms auto scale, which the
// frame callback then follows, so this is also how a pan is undone.
graph.auto_fit_x();
graph.auto_fit_y(axis_y);
});
QObject::connect(autoscale_action, &QAction::toggled, &window, [&](bool on) {
keep_focus();
// Auto scale is what the frame callback follows; arming it here is what makes
// checking the box resume the live framing after a pan.
graph.set_x_auto_scale(axis_x, on);
graph.set_y_auto_scale(axis_y, on);
});
QObject::connect(add_cursor_action, &QAction::triggered, &window, [&] {
keep_focus();
const plot::Range xr = graph.x_range(axis_x);
const plot::Color color =
kExtraCursorColors[extra_cursor % (sizeof kExtraCursorColors / sizeof kExtraCursorColors[0])];
graph.add_cursor(xr.min + 0.5 * xr.span(), color);
++extra_cursor;
});
window.resize(kWindowWidth, kWindowHeight);
window.show();
widget->setFocus(); // so the controller's keys reach the plot
// The repaint loop. A QOpenGLWidget only draws when Qt asks it to, so something
// has to ask: a 60 Hz timer is the display rate this demo runs at (a real
// application drives the same timer, or its toolkit's equivalent, from whatever
// it animates). Nothing here is library API -- `update()` is Qt's.
QTimer repaint_timer;
repaint_timer.setTimerType(Qt::PreciseTimer);
QObject::connect(&repaint_timer, &QTimer::timeout, widget, [widget] { widget->update(); });
repaint_timer.start(16); // ~60 Hz
// --- the headless exit path -------------------------------------------
QTimer exit_timer;
QObject::connect(&exit_timer, &QTimer::timeout, &app, [&] {
if (!automated || now_s() - run_start < exit_after_s) return;
exit_timer.stop();
const plot::PlotLayout before = std::as_const(graph).layout();
const QSize logical_before = widget->size();
// Scripted resize before the exit frame: a screenshot taken after the widget
// has been resized is what proves the widget re-lays the plot out for the new
// geometry instead of scaling a stale one. The resize is asynchronous (it goes
// through the window system), so the event queue is drained before the grab.
window.resize(kExitWidth, kExitHeight);
for (int i = 0; i < 8; ++i) QCoreApplication::processEvents();
// Renders the widget at its current size and reads the FBO back, so the image
// is the widget as Qt composes it -- including the frames painted by the
// resize above.
const QImage image = widget->grabFramebuffer();
const plot::PlotLayout after = std::as_const(graph).layout();
std::printf("\n[qt_demo] ---- exit frame ----\n");
std::printf("[qt_demo] widget %.2f dpr, logical %dx%d -> %dx%d, fbo %ux%u\n",
static_cast<double>(widget->devicePixelRatioF()), logical_before.width(),
logical_before.height(), widget->width(), widget->height(),
static_cast<unsigned>(widget->last_device_viewport().w),
static_cast<unsigned>(widget->last_device_viewport().h));
std::printf("[qt_demo] grabbed %dx%d px, image dpr %.2f\n", image.width(), image.height(),
image.isNull() ? 0.0 : static_cast<double>(image.devicePixelRatio()));
std::printf("[qt_demo] layout axes before %.0fx%.0f at (%.0f, %.0f), after %.0fx%.0f at"
" (%.0f, %.0f)\n",
static_cast<double>(before.axes.w), static_cast<double>(before.axes.h),
static_cast<double>(before.axes.x), static_cast<double>(before.axes.y),
static_cast<double>(after.axes.w), static_cast<double>(after.axes.h),
static_cast<double>(after.axes.x), static_cast<double>(after.axes.y));
if (screenshot != nullptr && screenshot[0] != '\0') {
const bool written = dump_widget_ppm(image, screenshot);
std::printf("[qt_demo] screenshot %s: %s\n", screenshot, written ? "written" : "FAILED");
}
std::printf("[qt_demo] init %s: %s\n", plot::to_string(widget->init_status().code),
widget->init_status().message);
// --- scripted input check --------------------------------------------
// The widget's own event handlers are driven with synthetic events, because
// the one bug the picture cannot show is the coordinate convention: a widget
// that scales the viewport but not the pointer renders correctly and pans by
// the wrong amount. Both boxes below are specified in LOGICAL pixels and
// turned into the device-pixel expectation the widget must produce, so at a
// scale factor other than 1.0 an unscaled event lands 1.25x off and the
// comparison fails.
const plot::Rect check_axes = std::as_const(graph).layout().axes; // device pixels
const f64 check_dpr = static_cast<f64>(widget->devicePixelRatioF());
const auto to_logical = [check_dpr](f32 device_x, f32 device_y) {
return QPointF(static_cast<qreal>(device_x) / check_dpr,
static_cast<qreal>(device_y) / check_dpr);
};
const auto send_mouse = [widget](QEvent::Type type, QPointF at, Qt::MouseButton button,
Qt::MouseButtons held, Qt::KeyboardModifiers mods) {
QMouseEvent event(type, at, at, button, held, mods);
QCoreApplication::sendEvent(widget, &event);
};
// A shift+drag box inside the axes: the resulting x range must be exactly the
// data the box covered on screen.
const plot::Rect box{check_axes.x + 0.25f * check_axes.w, check_axes.y + 0.25f * check_axes.h,
0.5f * check_axes.w, 0.5f * check_axes.h};
const plot::Transform x_before = graph.x_transform(axis_x);
const f64 box_expect_min = x_before.to_data_x(static_cast<f64>(box.x));
const f64 box_expect_max = x_before.to_data_x(static_cast<f64>(box.right()));
{
const QPointF a = to_logical(box.x, box.y);
const QPointF b = to_logical(box.right(), box.bottom());
send_mouse(QEvent::MouseButtonPress, a, Qt::LeftButton, Qt::LeftButton, Qt::ShiftModifier);
send_mouse(QEvent::MouseMove, b, Qt::NoButton, Qt::LeftButton, Qt::ShiftModifier);
send_mouse(QEvent::MouseButtonRelease, b, Qt::LeftButton, Qt::NoButton, Qt::ShiftModifier);
}
const plot::Range box_got = graph.x_range(axis_x);
const f64 box_tol = 1.0e-6 * (box_expect_max - box_expect_min);
const bool box_ok = std::fabs(box_got.min - box_expect_min) <= box_tol &&
std::fabs(box_got.max - box_expect_max) <= box_tol;
// A plain drag then translates the range by the drag's own fraction of the
// axes and must not change its span: the deltas the controller pans with come
// from two scaled positions, so this is what pins the delta path too. The
// axes rect is read again, through the *recomputing* layout(), on purpose: the
// box zoom changed the y tick labels, the narrower label strip gave the axes
// more width, and that width is what the controller pans by -- it refreshes the
// layout itself, in on_pointer, before it acts.
const plot::Rect pan_axes = graph.layout().axes;
const f64 drag_device_dx = 0.2 * static_cast<f64>(pan_axes.w);
const plot::Range before_drag = graph.x_range(axis_x);
const f64 drag_expect_min =
before_drag.min - drag_device_dx / static_cast<f64>(pan_axes.w) * before_drag.span();
{
const f32 x0 = pan_axes.x + 0.4f * pan_axes.w;
const f32 y0 = pan_axes.y + 0.5f * pan_axes.h;
const QPointF a = to_logical(x0, y0);
const QPointF b = to_logical(x0 + static_cast<f32>(drag_device_dx), y0);
send_mouse(QEvent::MouseButtonPress, a, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
send_mouse(QEvent::MouseMove, b, Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
send_mouse(QEvent::MouseButtonRelease, b, Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
}
const plot::Range drag_got = graph.x_range(axis_x);
const f64 drag_tol = 1.0e-6 * before_drag.span();
const bool drag_ok = std::fabs(drag_got.min - drag_expect_min) <= drag_tol &&
std::fabs(drag_got.span() - before_drag.span()) <= drag_tol;
std::printf("[qt_demo] input check dpr %.2f, axes %.0fx%.0f device px\n", check_dpr,
static_cast<double>(check_axes.w), static_cast<double>(check_axes.h));
std::printf("[qt_demo] input check box-zoom %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n",
box_ok ? "PASS" : "FAIL", box_expect_min, box_expect_max, box_got.min, box_got.max);
std::printf("[qt_demo] input check pan %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n",
drag_ok ? "PASS" : "FAIL", drag_expect_min, drag_expect_min + before_drag.span(),
drag_got.min, drag_got.max);
// The two gestures above left the plot zoomed and panned (that is what makes
// them checks). Put the live framing back before the run summary reads the
// last frame's statistics.
graph.auto_fit_x();
graph.auto_fit_y(axis_y);
std::fflush(stdout);
QCoreApplication::quit();
});
if (automated) exit_timer.start(20);
const int app_result = app.exec();
// --- teardown and the run summary -------------------------------------
producer.stop(); // no thread pushes into the stream after this point
const plot::Renderer* const renderer = widget->renderer();
const plot::FrameStats stats = renderer != nullptr ? renderer->stats() : plot::FrameStats{};
const plot::Rect axes = std::as_const(graph).layout().axes;
const f64 spp =
axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) / static_cast<f64>(axes.w) : 0.0;
const f64 dense = renderer != nullptr ? static_cast<f64>(renderer->style().dense_threshold_spp)
: 0.0;
const plot::Measurement m = graph.measurement(measure);
const f64 mean_ms = widget->mean_frame_ms();
std::printf("\n[qt_demo] ---- run summary ----\n");
std::printf("[qt_demo] producer %llu samples pushed, last metered %.3f MS/s\n",
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
std::printf("[qt_demo] frames %llu painted (%llu frame callbacks) in %.3f s, mean %.2f"
" ms/frame (%.1f fps), last %.2f ms\n",
static_cast<unsigned long long>(widget->frames_painted()),
static_cast<unsigned long long>(meter.frames), meter.elapsed,
static_cast<double>(mean_ms), mean_ms > 0.0f ? 1.0e3 / static_cast<double>(mean_ms) : 0.0,
static_cast<double>(widget->last_frame_ms()));
std::printf("[qt_demo] retained %llu samples, x [%.6f, %.6f] s\n",
static_cast<unsigned long long>(stream.size()), stream.x_bounds().min,
stream.x_bounds().max);
std::printf("[qt_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s, visible %llu,"
" axes %.0fx%.0f, %u segments, %u draw calls\n",
spp, dense, stats.lod_traces > 0 ? "LOD pyramid" : "raw samples",
static_cast<unsigned long long>(stats.visible_samples),
static_cast<double>(axes.w), static_cast<double>(axes.h), stats.staged_segments,
stats.draw_calls);
std::printf("[qt_demo] measurement %s dx %g s dy %g rms %g mean %g\n",
m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean);
std::printf("[qt_demo] gl %s upload, gl error on the last frame %d\n",
renderer != nullptr && renderer->using_persistent_upload() ? "persistent-mapped ring"
: "orphan",
renderer != nullptr && renderer->last_frame_had_gl_error() ? 1 : 0);
std::fflush(stdout);
// What goes out of scope here, in order: `window`, taking the widget with it
// (so the plot's GL resources are released while their context is still alive,
// and before `graph`, which the widget points at), then `graph`, then the
// already-joined `producer`, then `stream` itself.
return app_result;
}
+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
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace plot {
using f32 = float;
using f64 = double;
using u8 = std::uint8_t;
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
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
namespace plot::data {
/// Series over a uniform-dx sample vector.
///
/// The vector is taken by value and owned from then on, so the caller cannot
/// mutate the samples behind the series' back; the store changes only through
/// reset() (a whole new vector) or commit(), which rebuilds the pyramid and the
/// cached bounds for whatever y_ currently holds.
class ArraySeries final : public Series {
public:
ArraySeries(std::vector<f32> y, f64 x0, f64 dx);
/// Replaces samples and grid, then rebuilds derived state (== commit()).
void reset(std::vector<f32> y, f64 x0, f64 dx);
/// Rebuilds the pyramid and cached bounds from y_ as it stands now.
void commit();
u64 size() const override;
f64 x0() const override;
f64 dx() const override;
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
std::vector<f32> y_;
f64 x0_ = 0.0;
f64 dx_ = 1.0;
MinMaxPyramid pyramid_;
Range x_bounds_{};
Range y_bounds_{};
};
} // namespace plot::data
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#ifdef PLOT_PYRAMID_COUNT_COVERS
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::data::detail {
/// Test-only instrumentation. Counts the pyramid entries the covering
/// decomposition has emitted since the counter was last reset.
///
/// The decomposition's *cost* is invisible in every answer -- a decomposition
/// that walks the range one level-0 bucket at a time returns exactly the same
/// min/max/sum as the O(log n) one -- so a test that wants to pin "at most two
/// entries per level" has to count entries. The counter exists only when
/// PLOT_PYRAMID_COUNT_COVERS is defined (the test build defines it), so every
/// normal build carries no counter and no increment on the query path.
///
/// Not part of the query surface and not meant for production use.
inline std::atomic<u64>& cover_entry_count() {
static std::atomic<u64> count{0};
return count;
}
} // namespace plot::data::detail
#endif // PLOT_PYRAMID_COUNT_COVERS
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#ifdef PLOT_PYRAMID_COUNT_MERGES
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::data::detail {
/// Test-only instrumentation. Counts the bucket merges the append path has
/// performed since the counter was last reset: one for the bucket pushed at
/// level 0 when it completes, plus one for every parent bucket written above it.
///
/// The append path's *cost* is invisible in every answer -- a pyramid that walks
/// every live level per sample returns exactly the same min/max/sum as one that
/// stops at the first level that gained no entry -- so a test that wants to pin
/// "amortised O(1) per sample, independent of the number of live levels" has to
/// count merges. The counter exists only when PLOT_PYRAMID_COUNT_MERGES is
/// defined (the test build defines it), so every normal build carries no counter
/// and no increment on the append path.
///
/// Not part of the query surface and not meant for production use.
inline std::atomic<u64>& merge_count() {
static std::atomic<u64> count{0};
return count;
}
} // namespace plot::data::detail
#endif // PLOT_PYRAMID_COUNT_MERGES
+149
View File
@@ -0,0 +1,149 @@
#pragma once
#include <deque>
#include <vector>
#include <plot/core/types.hpp>
namespace plot::data {
/// Aggregate of one bucket: the extremes of the samples it covers.
struct MinMax {
f32 min = 0.0f;
f32 max = 0.0f;
};
/// Samples per level-0 bucket. Fixed so the LOD geometry is identical for every
/// series and the level maths is a shift.
inline constexpr u32 kBucket = 64;
/// Incremental min/max LOD pyramid, indexed in absolute bucket space.
///
/// Level `l` bucket `k` covers the absolute samples
/// `[k * (kBucket << l), (k + 1) * (kBucket << l))`. Bucket boundaries are
/// therefore fixed for the pyramid's whole life: appending never re-buckets an
/// existing aggregate, and the window a caller reads can slide over the same
/// buckets instead of being rebuilt from the raw samples.
///
/// Each level holds its complete buckets in a deque (O(1) push_back *and*
/// pop_front) plus a `base`, the absolute level-`l` bucket index of its first
/// entry. `append()` folds a sample into the in-progress level-0 bucket and, when
/// that bucket completes, pushes it and walks up -- stopping at the first level
/// that gains no new entry. Level `l` gains one entry per `kBucket << l` samples,
/// so an append costs amortised O(1) and never walks every live level.
///
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
/// level discards the buckets whose span starts before `u`, so the memory is
/// bounded by the retained window and not by the number of samples ever appended.
/// A bucket entry is 24 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
/// range queries fold over -- so level 0 costs 0.375n bytes and the geometric tail
/// brings the whole pyramid to about 0.75n bytes, ~19% of the 4n bytes of raw
/// samples. (A min/max-only index would be 0.25n, ~6%: the two doubles are the
/// price of answering mean/RMS/integral from whole buckets instead of raw
/// samples.) Every stored entry is the complete aggregate of its own span and
/// every stored span lies inside the retained window, so no aggregate is ever
/// recomputed or invalidated.
///
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
/// `size()` is the retained sample count, and `window_base()` is the absolute
/// index window index 0 refers to. That is the same window `Series` publishes,
/// which is why a `StreamSeries` can hand the pyramid a moving base and keep
/// answering in its own coordinates.
///
/// A pyramid stores *aggregates*, so it can only answer exactly for whole
/// buckets; a range with partial edge buckets would silently pull in samples
/// outside the range. Every query here is therefore bucket-grained and named
/// *_buckets, and exact range queries live on Series, which owns the raw samples.
class MinMaxPyramid {
public:
MinMaxPyramid() = default;
/// Sizes the level table for a window of `n` samples so a following run of
/// appends never grows the level vector. The bucket deques themselves grow in
/// chunks: a sliding window cannot know their final length up front.
void reserve(u64 n);
void append(f32 v);
/// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
/// pyramid is not exempt, because a caller whose window already starts past
/// everything appended so far has to be able to say so (that is exactly what a
/// ring that saturated before the first acquire() passes here).
///
/// An argument past `window_end()` -- a producer that outran the consumer by
/// more than the ring held -- drops every aggregate and restarts the window at
/// `absolute_sample`: the samples in between were never appended, so they are
/// not representable, and the window the caller reads is the new one.
void drop_before(u64 absolute_sample);
void clear();
/// Samples in the retained window.
u64 size() const noexcept { return count_ - base_; }
/// Absolute index of the sample window index 0 refers to.
u64 window_base() const noexcept { return base_; }
/// Absolute index one past the last appended sample: the first sample index a
/// following append() will occupy.
u64 window_end() const noexcept { return count_; }
/// At least 1, even when empty, so level_count() - 1 is always valid.
u32 level_count() const noexcept { return live_levels_; }
/// Absolute level-`l` bucket index of level `l`'s first entry, and how many
/// entries it holds. They cover `[level_base(l), level_base(l) + level_size(l))`:
/// entry `i` is the aggregate of the absolute samples
/// `[i * (kBucket << l), (i + 1) * (kBucket << l))`. A level that exists but
/// holds nothing, or a level past level_count(), reports size 0 (its base is
/// then meaningless).
u64 level_base(u32 l) const noexcept;
u64 level_size(u32 l) const noexcept;
/// Aggregate of the absolute level-`l` bucket `i`. Precondition:
/// level_base(l) <= i < level_base(l) + level_size(l).
MinMax bucket(u32 l, u64 i) const;
/// Min/max over the whole level-0 buckets fully contained in the window range
/// [begin, end). Partial edge buckets are excluded, never approximated; an
/// empty result is a degenerate Range with span() == 0.
Range reduce_buckets(u64 begin, u64 end) const;
/// Sum of the samples in those same whole buckets, accumulated in double.
f64 sum_buckets(u64 begin, u64 end) const;
/// Sum of squares of the samples in those same whole buckets.
f64 sumsq_buckets(u64 begin, u64 end) const;
/// Pyramid level whose buckets hold about `samples_per_pixel` samples, for a
/// window of `n` samples: 0 when a pixel covers at most one bucket. Clamped to
/// the levels that exist, to levels with more than one bucket, and to levels
/// that give the window at least two buckets.
u32 level_for(u64 n, f64 samples_per_pixel) const;
/// Index of the sample nearest x on the uniform grid (x0 + i*dx), clamped to
/// [0, size() - 1]. Returns 0 for an empty pyramid or a non-positive dx.
u64 snap(f64 x, f64 x0, f64 dx) const;
private:
/// One bucket: its extremes plus its own exact sum and sum of squares. The two
/// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/
/// integral come from whole buckets instead of raw samples.
struct Entry {
MinMax mm;
f64 sum = 0.0;
f64 sumsq = 0.0;
};
/// One level's complete buckets, in absolute bucket-index order.
struct Level {
std::deque<Entry> entries;
u64 base = 0; ///< absolute level-l bucket index of entries.front()
};
void add_level();
/// Appends `e` at absolute level-`l` bucket `k`, which must be one past the
/// level's last entry.
void push(u32 l, u64 k, const Entry& e);
/// Entry for absolute level-`l` bucket `i`; the caller has checked the range.
const Entry& at(u32 l, u64 i) const {
return levels_[l].entries[static_cast<usize>(i - levels_[l].base)];
}
u64 count_ = 0; ///< absolute index one past the last appended sample
u64 base_ = 0; ///< absolute index of the window's oldest sample
u32 live_levels_ = 1;
std::vector<Level> levels_{1};
Entry pending_; ///< the in-progress level-0 bucket, not yet an entry
};
} // namespace plot::data
+111
View File
@@ -0,0 +1,111 @@
#pragma once
#include <atomic>
#include <bit>
#include <vector>
#include <plot/core/types.hpp>
namespace plot::data {
/// Single-producer / single-consumer ring of at most `capacity` elements.
///
/// The capacity is rounded up to a power of two so slot addressing is a mask
/// instead of a modulo. The producer writes a slot and then publishes the new
/// sample count with a release store; a consumer that observes that count with an
/// acquire load is guaranteed to see the slot contents written before it.
///
/// Slots are std::atomic and every slot access is relaxed. Because the ring
/// *overwrites*, the producer's next push reuses the slot of the snapshot's
/// oldest retained element while the consumer may still be reading it, so a plain
/// load/store there would be a data race (UB), not merely a stale value. A
/// relaxed atomic load/store is a plain mov/ldr/str on the supported targets, so
/// the sample path costs the same. Relaxed is enough because the ordering lives in
/// the count handshake above; the count fixes *which* indices a snapshot reads,
/// not that the slots still hold those samples (see StreamSeries).
///
/// A push into a full ring silently discards the oldest element: for a live
/// plotting stream the newest sample is the interesting one and the retained
/// window slides forward.
///
/// Two indexing conventions, deliberately named apart:
/// - retained-window (operator[], index 0 = oldest element still held) is a
/// single-threaded view: it re-derives the window from the live count, so a
/// producer pushing concurrently would move it under the reader's feet.
/// - absolute (base(), copy_from()) fixes one count for a whole read, which is
/// what a consumer holding a snapshot must use. StreamSeries::acquire() takes
/// such a snapshot and then reads only absolute indices.
template <typename T>
class RingBuffer {
static_assert(std::atomic<T>::is_always_lock_free,
"RingBuffer slots are atomic and must be lock-free: the sample path may not lock");
public:
explicit RingBuffer(u64 capacity)
: cap_(std::bit_ceil(capacity < 1 ? u64{1} : capacity)),
mask_(cap_ - 1),
buf_(static_cast<usize>(cap_)) {}
/// Producer thread only.
void push(T v) {
const u64 i = produced_.load(std::memory_order_relaxed);
buf_[static_cast<usize>(i & mask_)].store(v, std::memory_order_relaxed);
produced_.store(i + 1, std::memory_order_release);
}
/// Producer thread only. Equivalent to n calls to push().
void push(const T* vs, u64 n) {
for (u64 i = 0; i < n; ++i) push(vs[i]);
}
/// Consumer: retained element count, saturating at capacity().
u64 size() const noexcept {
const u64 p = produced_.load(std::memory_order_acquire);
return p < cap_ ? p : cap_;
}
/// Consumer: total number of elements ever pushed (monotonic).
u64 produced() const noexcept { return produced_.load(std::memory_order_acquire); }
u64 capacity() const noexcept { return cap_; }
/// Consumer: absolute index of the oldest element still retained. 0 until the
/// ring has wrapped.
u64 base() const noexcept {
const u64 p = produced_.load(std::memory_order_acquire);
return p > cap_ ? p - cap_ : 0;
}
/// Single-threaded: retained-window index (0 = oldest retained). `i` must be
/// < size(). Returns by value: the slot is an atomic, so a relaxed load is the
/// only way to observe it.
T operator[](u64 i) const noexcept {
return buf_[static_cast<usize>((base() + i) & mask_)].load(std::memory_order_relaxed);
}
/// Consumer: copies the `n` elements at absolute indices [absolute, absolute+n)
/// into `out`, masking the index to wrap. The caller must hold a snapshot
/// that retains those indices; this never consults the live count, so
/// a pushing producer cannot shift the read -- but it fixes only *which*
/// indices are read, not that the slots still hold those samples: a producer
/// that overtakes capacity() has already rewritten the oldest ones (see the
/// class note).
void copy_from(u64 absolute, u64 n, T* out) const {
if (n == 0) return;
// Explicit relaxed loads: std::copy cannot read atomic slots, and the mask
// makes the wrap split unnecessary.
for (u64 i = 0; i < n; ++i) {
out[i] = buf_[static_cast<usize>((absolute + i) & mask_)].load(std::memory_order_relaxed);
}
}
/// Consumer thread only: discards every element (not safe while a producer is
/// pushing).
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
private:
u64 cap_;
u64 mask_;
std::vector<std::atomic<T>> buf_;
std::atomic<u64> produced_{0};
};
} // namespace plot::data
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
namespace plot::data {
/// A uniform-grid series of y samples: sample i lives at x = x0() + i*dx().
///
/// Every concrete series answers range queries exactly, and never by scanning the
/// whole range: the bucket-aligned interior comes from the pyramid (O(log n)
/// bucket aggregates) and at most two partial edge buckets are read through
/// copy_raw() and scanned. Cost is independent of series length and no query
/// allocates.
///
/// Indexing is over the series' own window: index 0 is the oldest sample the
/// series currently holds (for a ring-backed series that is the oldest retained
/// one), so reduce(start, end) and gather() are in the same coordinates as
/// size(), x0() and pyramid().
class Series {
public:
virtual ~Series() = default;
virtual u64 size() const = 0;
/// x of sample 0, i.e. of the oldest sample currently held.
virtual f64 x0() const = 0;
/// Uniform x spacing between consecutive samples.
virtual f64 dx() const = 0;
virtual Range x_bounds() const = 0;
virtual Range y_bounds() const = 0;
virtual const MinMaxPyramid& pyramid() const = 0;
/// Appends interleaved (x, y) pairs in data coordinates for level `level`.
///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one (min, max)
/// envelope pair per *complete* level bucket wholly inside the retained window,
/// bounded by the two partial buckets at the edges of the requested range — the
/// bucket the window starts inside and the bucket the newest samples are in —
/// which it emits as raw samples through copy_raw(). A bucket that is only partly
/// retained holds aggregates of samples the window no longer has and a partial
/// trailing bucket is not stored at all, so neither may be drawn as an envelope.
///
/// The emitted geometry therefore always spans [begin, end) exactly, for at most
/// two buckets of extra raw work (about two pixels at the level level_for()
/// picks), and never reaches outside the retained window. Pairs are appended to
/// out_xy, which is never cleared.
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
/// Exact min/max, sum and sum-of-squares over [begin, end).
/// Preconditions: begin < end, end <= size(); a degenerate range yields a
/// zero-span Range and zero sums. Implemented once, non-virtually, over
/// copy_raw() in src/data/series.cpp.
Range reduce(u64 begin, u64 end) const;
f64 sum(u64 begin, u64 end) const;
f64 sumsq(u64 begin, u64 end) const;
protected:
/// Copies raw y samples for [begin, end) into `out`, which the caller sized to
/// (end - begin). Handles ring wraparound. Only ever called with the small edge
/// spans of the queries above, never on a per-sample path. Out-of-range spans
/// are clamped and the unwritten tail is zero-filled.
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
/// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket min/max envelope above it.
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
private:
/// [begin, end) split into the bucket-aligned interior the pyramid can answer
/// exactly, plus the at most two partial edge spans it cannot.
struct QueryParts {
u64 interior_begin = 0, interior_end = 0; // whole level-0 buckets, may be empty
u64 left_begin = 0, left_end = 0; // partial head bucket
u64 right_begin = 0, right_end = 0; // partial tail bucket
};
/// Needs the pyramid's window base: the bucket grid it aligns to is absolute,
/// while begin/end are window coordinates.
QueryParts split_query(u64 begin, u64 end) const;
/// Reads both edge spans through copy_raw() into `out` (which must hold at
/// least 2*kBucket samples) and returns how many were written.
u64 copy_edges(const QueryParts& parts, f32* out) const;
/// Appends the raw samples of [begin, end) as (x, y) pairs, in copy_raw()'s
/// bounded chunks. The one path that reads samples rather than aggregates: level
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
/// begin < end, end <= size().
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const;
};
} // namespace plot::data
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
namespace plot::data {
/// Single-producer / single-consumer streaming series.
///
/// The producer thread only ever calls push()/push(ys, n) (and produced()): those
/// touch the ring buffer and nothing else -- no lock, no pyramid, no GL, so the
/// acquisition thread can never stall the renderer. The consumer thread calls
/// acquire() to publish a coherent snapshot, then queries it.
///
/// Indexing is the *retained window published by the last acquire()*: size(),
/// reduce(), sum(), sumsq(), gather() and pyramid() all use it, and index 0 is
/// the oldest sample still held.
///
/// The pyramid behind those queries is indexed in absolute sample space, so a
/// window that slides only makes it drop the leading buckets the ring has
/// overwritten and append the samples produced since the last acquire(): acquire()
/// costs O(samples produced), not O(capacity()), and the aggregates that stay in
/// the window are never recomputed. The one exception is a producer that laps the
/// consumer -- more than capacity() samples between two acquires -- where the
/// samples in between were never appended and the pyramid restarts at the new
/// base, so that acquire re-reads the whole window.
///
/// x0() is therefore the x of the oldest retained sample, x_bounds().min == x0(),
/// and y_bounds() covers exactly the published samples.
///
/// THREADING NOTE. The ring overwrites the oldest samples -- that is what keeps a
/// live stream showing the newest data, and what the drop test pins down -- so a
/// producer can overwrite the slots a consumer is reading even while it holds a
/// snapshot. Those slots are relaxed atomics (see RingBuffer), so this is not a
/// data race and no value tears; what it is not is a frozen window.
///
/// The bound is the sample count, not the geometry. A snapshot count fixes *which
/// absolute indices* a read walks, not that the slots still hold those samples:
/// every push after the snapshot overwrites exactly one slot, the one belonging to
/// the sample capacity() positions behind it. So if the producer pushes k samples
/// while a read is in flight, the first k positions of that read window can hold
/// one of those k newer samples (or a still-newer sample, once the producer has
/// lapped again) instead of the sample the snapshot names. Nothing else moves: the
/// count, the absolute index -> x mapping and the newest end of the window are
/// derived from one published count, and the next acquire() slides the pyramid's
/// window to the base it observes (dropping the leading buckets, appending what is
/// new), so the pyramid never mixes two windows. Ingest with no reuse
/// underneath a read at all needs the producer to stop before unread data
/// (drop-on-full) or a claim protocol, i.e. a different contract from the one this
/// class specifies.
class StreamSeries final : public Series {
public:
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
/// Producer thread only.
void push(f32 y);
/// Producer thread only.
void push(const f32* ys, u64 n);
/// Producer thread only: number of samples ever pushed (monotonic).
u64 produced() const;
/// Consumer thread only: snapshots the produced count and brings the pyramid
/// and bounds up to it.
void acquire();
u64 size() const override;
f64 x0() const override;
f64 dx() const override;
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
RingBuffer<f32> ring_;
f64 x0_ = 0.0;
f64 dx_ = 1.0;
// The pyramid owns the published window: its window_base() is the absolute
// index of retained index 0 and its size()/window_end() are the snapshot's
// extent, so there is no second copy of that state to keep in step.
MinMaxPyramid pyramid_;
Range y_bounds_{};
};
} // namespace plot::data
+208
View File
@@ -0,0 +1,208 @@
#pragma once
#include <plot/core/types.hpp>
// The GL surface of this library is hand-written on purpose: the core target
// links and includes nothing third-party, so there is no glad/GLEW/GLM and no
// Khronos header. Everything below is either a typedef fixed by the OpenGL
// specification or an entry point resolved at runtime by `plot::gl::load()`
// (see loader.hpp). Only OpenGL 3.3 core is assumed; anything newer is guarded
// by the capability probe in caps.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 GLubyte = unsigned char;
using GLchar = char;
using GLintptr = std::intptr_t;
using GLsizeiptr = std::ptrdiff_t;
using GLuint64 = u64;
using GLsync = void*; // opaque sync object (`struct __GLsync*`)
inline constexpr GLboolean GL_FALSE = 0;
inline constexpr GLboolean GL_TRUE = 1;
// Enumerant values are fixed by the OpenGL specification; comment each group
// with the version or extension that introduced it.
enum : GLenum {
// Errors and primitives.
GL_NO_ERROR = 0x0000,
GL_LINE_STRIP = 0x0003,
GL_TRIANGLES = 0x0004,
GL_TRIANGLE_STRIP = 0x0005,
// Clear state.
GL_COLOR_BUFFER_BIT = 0x00004000,
GL_COLOR_CLEAR_VALUE = 0x0C22,
// Data types (unsigned short is the index/16-bit data type).
GL_UNSIGNED_BYTE = 0x1401,
GL_UNSIGNED_SHORT = 0x1403,
GL_FLOAT = 0x1406,
// Strings and context queries.
GL_VENDOR = 0x1F00,
GL_RENDERER = 0x1F01,
GL_VERSION = 0x1F02,
GL_EXTENSIONS = 0x1F03,
GL_NUM_EXTENSIONS = 0x821D /* 3.0 */,
GL_CONTEXT_PROFILE_MASK = 0x9126 /* 3.2 */,
GL_CONTEXT_CORE_PROFILE_BIT = 0x00000001,
// Blending.
GL_BLEND = 0x0BE2,
GL_SRC_ALPHA = 0x0302,
GL_ONE_MINUS_SRC_ALPHA = 0x0303,
// Pixel store state.
GL_UNPACK_ALIGNMENT = 0x0CF5,
GL_PACK_ALIGNMENT = 0x0D05,
// Shaders and programs.
GL_FRAGMENT_SHADER = 0x8B30,
GL_VERTEX_SHADER = 0x8B31,
GL_COMPILE_STATUS = 0x8B81,
GL_LINK_STATUS = 0x8B82,
// Buffers (3.3 baseline plus the 4.4 storage flags used by the caps-driven
// persistent-mapping path).
GL_ARRAY_BUFFER = 0x8892,
GL_DYNAMIC_DRAW = 0x88E8,
GL_STATIC_DRAW = 0x88E4,
GL_STREAM_DRAW = 0x88E0,
GL_MAP_WRITE_BIT = 0x0002 /* 4.4 */,
GL_MAP_PERSISTENT_BIT = 0x0040 /* 4.4 */,
GL_MAP_COHERENT_BIT = 0x0080 /* 4.4 */,
// Rasterisation state (1.0).
GL_SCISSOR_TEST = 0x0C11,
// Textures.
GL_TEXTURE_2D = 0x0DE1,
GL_TEXTURE0 = 0x84C0,
GL_TEXTURE_MAG_FILTER = 0x2800,
GL_TEXTURE_MIN_FILTER = 0x2801,
GL_TEXTURE_WRAP_S = 0x2802,
GL_TEXTURE_WRAP_T = 0x2803,
GL_NEAREST = 0x2600,
GL_LINEAR = 0x2601,
GL_CLAMP_TO_EDGE = 0x812F,
GL_RED = 0x1903,
GL_RGBA = 0x1908,
GL_R8 = 0x8229,
GL_RGBA8 = 0x8058,
// Framebuffer objects (renderbuffers back depth attachments).
GL_FRAMEBUFFER = 0x8D40,
GL_COLOR_ATTACHMENT0 = 0x8CE0,
GL_FRAMEBUFFER_COMPLETE = 0x8CD5,
GL_RENDERBUFFER = 0x8D41,
// Sync objects (3.2).
GL_SYNC_GPU_COMMANDS_COMPLETE = 0x9117,
GL_SYNC_FLUSH_COMMANDS_BIT = 0x00000001,
GL_ALREADY_SIGNALED = 0x911A,
GL_CONDITION_SATISFIED = 0x911C,
};
// Single source of truth for the runtime entry points: every entry is declared
// as a function pointer here and resolved by `plot::gl::load()`. Adding an
// entry makes it required, so only add what the library actually calls.
#define PLOT_GL_FUNCS(X) \
/* context and state */ \
X(const GLubyte*, glGetString, (GLenum)) \
X(const GLubyte*, glGetStringi, (GLenum, GLuint)) \
X(void, glGetIntegerv, (GLenum, GLint*)) \
X(void, glGetFloatv, (GLenum, float*)) \
X(GLenum, glGetError, (void)) \
X(void, glFinish, (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(void, glPixelStorei, (GLenum, GLint)) \
X(void, glDisable, (GLenum)) \
X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \
/* shaders and programs */ \
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(void, glDeleteShader, (GLuint)) \
X(GLuint, glCreateProgram, (void)) \
X(void, glAttachShader, (GLuint, GLuint)) \
X(void, glDetachShader, (GLuint, GLuint)) \
X(void, glLinkProgram, (GLuint)) \
X(void, glGetProgramiv, (GLuint, GLenum, GLint*)) \
X(void, glGetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(void, glDeleteProgram, (GLuint)) \
X(void, glUseProgram, (GLuint)) \
X(GLint, glGetUniformLocation, (GLuint, const GLchar*)) \
X(void, glUniform1i, (GLint, GLint)) \
X(void, glUniform1f, (GLint, float)) \
X(void, glUniform2f, (GLint, float, float)) \
X(void, glUniform4f, (GLint, float, float, float, float)) \
X(void, glUniform1fv, (GLint, GLsizei, const float*)) \
X(void, glUniform2fv, (GLint, GLsizei, const float*)) \
/* buffers */ \
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*, glMapBufferRange, (GLenum, GLintptr, GLsizeiptr, GLbitfield)) \
X(GLboolean, glUnmapBuffer, (GLenum)) \
/* vertex arrays */ \
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, glVertexAttribDivisor, (GLuint, GLuint)) \
/* drawing */ \
X(void, glDrawArrays, (GLenum, GLint, GLsizei)) \
X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \
/* textures */ \
X(void, glGenTextures, (GLsizei, GLuint*)) \
X(void, glDeleteTextures, (GLsizei, const GLuint*)) \
X(void, glActiveTexture, (GLenum)) \
X(void, glBindTexture, (GLenum, GLuint)) \
X(void, glTexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*)) \
X(void, glTexParameteri, (GLenum, GLenum, GLint)) \
/* framebuffers */ \
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)) \
/* sync objects */ \
X(GLsync, glFenceSync, (GLenum, GLbitfield)) \
X(GLenum, glClientWaitSync, (GLsync, GLbitfield, GLuint64)) \
X(void, glDeleteSync, (GLsync))
// Entry points above the 3.3 baseline, resolved best-effort: a driver that lacks
// one leaves it null and `load()` still succeeds, so the library keeps working on
// a 3.3-only context. Every one of these is reached only after `caps()` -- or an
// explicit null check -- says the context can do it (see caps.hpp).
#define PLOT_GL_OPTIONAL_FUNCS(X) \
/* persistent buffer mapping, GL 4.4 / ARB_buffer_storage */ \
X(void, glBufferStorage, (GLenum, GLsizeiptr, const void*, GLbitfield))
#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args;
PLOT_GL_FUNCS(PLOT_GL_DECL)
PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_DECL)
#undef PLOT_GL_DECL
} // namespace plot::gl
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <plot/gl/api.hpp>
namespace plot::gl {
/// What the current context can do. Only OpenGL 3.3 core is required; every
/// field above that baseline is false when the driver does not offer it.
struct Caps {
int major = 0;
int minor = 0;
/// GL_VERSION / GL_RENDERER / GL_VENDOR strings. They are owned by the
/// context: valid until it is destroyed, and empty ("") when nothing has
/// been probed yet.
const char* renderer = "";
const char* vendor = "";
bool compute = false; ///< GL 4.3+ (compute shaders)
bool buffer_storage = false; ///< GL 4.4+ or ARB_buffer_storage
bool persistent_map = false; ///< GL 4.4+ or ARB_buffer_storage
bool core_profile = false; ///< GL_CONTEXT_CORE_PROFILE_BIT
};
/// Capabilities of the context that is current on the calling thread, probed
/// once on the first successful query and cached afterwards. Safe to call
/// before load(): with no usable context (or no resolved entry points) every
/// field stays zero. `load()` invalidates the cache. The cache is
/// process-global and unsynchronised: probe from the thread that owns the
/// current context.
const Caps& caps() noexcept;
} // namespace plot::gl
+242
View File
@@ -0,0 +1,242 @@
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/api.hpp>
#include <plot/math/vec2.hpp>
namespace plot::gl {
/// Move-only ownership of one GL object name (`GLuint`).
///
/// `Derived` supplies `static void destroy(GLuint) noexcept`. Copying is
/// deleted and moving transfers the name, leaving the source empty.
/// Destruction and `reset()` are no-ops when `id() == 0`, so an empty handle is
/// safe to destroy at any point — including after its context is gone. A
/// non-empty handle must not outlive the context that owns the name; call
/// `reset()` first when the context is about to be destroyed.
template <class Derived>
class Handle {
public:
Handle() noexcept = default;
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
~Handle() { reset(); }
/// GL object name, or 0 when the handle is empty.
GLuint id() const noexcept { return id_; }
explicit operator bool() const noexcept { return id_ != 0; }
/// Identity of the object this handle names, as a counter that only ever
/// advances: every `reset()` that deleted a name, every allocating `create()`
/// and every move advances it. A driver is free to hand out a name it has
/// already used, so state that captured the old object -- the attribute
/// pointers of a vertex array above all -- must compare this counter rather
/// than the name (see `render::detail::InstanceStore::epoch()`).
u32 generation() const noexcept { return generation_; }
/// Deletes the GL object, if any, and empties the handle.
void reset() noexcept {
if (id_ != 0) {
Derived::destroy(id_);
id_ = 0;
++generation_;
}
}
protected:
Handle(Handle&& other) noexcept : id_(other.id_), generation_(other.generation_) {
other.id_ = 0;
++generation_; // the object is this handle's now
++other.generation_; // and no longer the other's
}
Handle& operator=(Handle&& other) noexcept {
if (this != &other) {
reset(); // advances the generation when it deleted a name
id_ = other.id_;
other.id_ = 0;
++generation_;
++other.generation_;
}
return *this;
}
/// Advances the generation for a name the derived class has just allocated and
/// stored: every allocating `create()` calls it, so the counter is a complete
/// record of the handle's identity. A failed allocation needs no call -- it has
/// already gone through `reset()`, which advanced it.
void note_allocated() noexcept { ++generation_; }
GLuint id_ = 0;
private:
u32 generation_ = 0;
};
/// Vertex buffer object. All fallible operations report through `Status`;
/// the void ones assume that `plot::gl::load()` ran and a context is current.
class Buffer final : public Handle<Buffer> {
public:
/// Allocates a buffer name. Fails with `Unsupported` when the entry points
/// are not resolved yet and `GLFailure` when the driver returns name 0.
bool create(Status* st = nullptr) noexcept;
void bind(GLenum target = GL_ARRAY_BUFFER) const noexcept;
/// Replaces the whole store with `bytes` bytes of `data` (`data` may be null
/// to allocate without initialising).
void upload(const void* data, usize bytes, GLenum usage) const noexcept;
/// Overwrites `bytes` bytes starting at `offset`; the store must already be
/// at least `offset + bytes` large.
void upload_sub(const void* data, usize bytes, usize offset) const noexcept;
/// Replaces the store with `bytes` bytes of immutable storage carrying `flags`
/// (`GL_MAP_WRITE_BIT` and friends). GL 4.4 / ARB_buffer_storage: only legal on
/// a context whose caps() report `buffer_storage`, and the entry point may be
/// null even on such a context (it is resolved best-effort, see api.hpp), so a
/// caller checks `glBufferStorage != nullptr` first. The contents start
/// undefined.
void storage(usize bytes, GLbitfield flags) const noexcept;
/// Maps `bytes` bytes from `offset` with `access`, returning the pointer or
/// null. GL 3.0 core, but a `GL_MAP_PERSISTENT_BIT` map needs the same
/// capability as `storage()` above.
void* map_range(usize offset, usize bytes, GLbitfield access) const noexcept;
/// Unmaps the store's data; the pointer from map_range() is invalid after this.
bool unmap() const noexcept;
/// Exposed for the CRTP base. Deleting an unknown name is a GL no-op.
static void destroy(GLuint id) noexcept;
};
/// Vertex array object holding the attribute layout of the bound buffer.
class VertexArray final : public Handle<VertexArray> {
public:
bool create(Status* st = nullptr) noexcept;
void bind() const noexcept;
/// Enables attribute `index` as `components` floats read from the currently
/// bound `GL_ARRAY_BUFFER`, advancing `stride` bytes per vertex and starting
/// `offset` bytes into it. Requires this vertex array to be bound.
void attrib(u32 index, i32 components, usize stride, usize offset) const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// Linked shader program. Constructed only through `from_source()`.
class Program final : public Handle<Program> {
public:
/// Compiles and links a vertex/fragment source pair. On failure the returned
/// program is empty and `st` (when non-null) carries `GLFailure` plus the
/// driver's info log, or `Unsupported` when `load()` was never called.
static Program from_source(const char* vertex_src, const char* fragment_src,
Status* st = nullptr) noexcept;
void use() const noexcept;
/// Sets a float uniform; a name that is not an active uniform of this program
/// is a silent no-op.
void set_uniform(const char* name, f32 value) const noexcept;
/// Sets a two-component float uniform.
void set_uniform(const char* name, Vec2 value) const noexcept;
/// Sets a four-component float uniform, e.g. the colour of a pipeline.
void set_uniform(const char* name, Color value) const noexcept;
/// Sets an integer uniform, e.g. a sampler's texture unit. Deliberately not
/// an overload of the float setter: an untyped literal such as `0` must not
/// turn a float uniform into a `glUniform1i` call.
void set_uniform_int(const char* name, i32 value) const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// 2D texture. Creation only allocates storage; `upload_*` replaces the whole
/// image (`glTexImage2D`), which is what the plot pipelines need for atlases
/// and one-shot uploads.
class Texture final : public Handle<Texture> {
public:
/// Allocates `width` x `height` 8-bit RGBA storage (`GL_RGBA8`), linear
/// filtering, clamped edges.
bool create_rgba(u32 width, u32 height, Status* st = nullptr) noexcept;
/// Allocates `width` x `height` single-channel 8-bit storage (`GL_R8`) for
/// coverage masks and narrow series data.
bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept;
/// Replaces the whole image with `width * height * 4` bytes of RGBA data.
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
/// Replaces the whole image with `width * height` bytes of single-channel
/// data (row padding is disabled, so any width is valid). It sets
/// `GL_UNPACK_ALIGNMENT` to 1 to do so and leaves it there -- a caller that
/// shares the context with other uploaders saves and restores it, as
/// `Renderer` does around its atlas and its frames.
void upload_r8(u32 width, u32 height, const void* pixels) const noexcept;
/// Binds to `unit` (e.g. `GL_TEXTURE0`).
void bind(GLenum unit = GL_TEXTURE0) const noexcept;
void set_filter(GLenum min, GLenum mag) const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// Framebuffer object, the render target of the off-screen paths.
class Framebuffer final : public Handle<Framebuffer> {
public:
bool create(Status* st = nullptr) noexcept;
void bind(GLenum target = GL_FRAMEBUFFER) const noexcept;
/// Attaches `texture`'s level 0 to `GL_COLOR_ATTACHMENT0`.
void attach_color(const Texture& texture) const noexcept;
/// True when the attachment set is `GL_FRAMEBUFFER_COMPLETE`.
bool complete() const noexcept;
/// Exposed for the CRTP base.
static void destroy(GLuint id) noexcept;
};
/// Move-only GL sync object (`GLsync`, GL 3.2) used to guard reuse of buffers
/// that the GPU may still be reading.
class Fence {
public:
Fence() noexcept = default;
Fence(const Fence&) = delete;
Fence& operator=(const Fence&) = delete;
Fence(Fence&& other) noexcept : id_(other.id_) { other.id_ = nullptr; }
Fence& operator=(Fence&& other) noexcept {
if (this != &other) {
reset();
id_ = other.id_;
other.id_ = nullptr;
}
return *this;
}
/// Deleting no fence is a no-op, so an empty fence is always safe.
~Fence() { reset(); }
/// Inserts a fence in the GL command stream.
bool create(Status* st = nullptr) noexcept;
/// Waits up to `timeout_ns` nanoseconds for the fence to be signalled,
/// flushing the command stream first. True when it signalled in time.
bool wait(u64 timeout_ns) const noexcept;
GLsync id() const noexcept { return id_; }
explicit operator bool() const noexcept { return id_ != nullptr; }
void reset() noexcept;
private:
GLsync id_ = nullptr;
};
} // namespace plot::gl
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <plot/gl/api.hpp>
namespace plot::gl {
/// Entry point resolver supplied by the caller, normally the *GetProcAddress
/// of the API that owns the current context: `eglGetProcAddress`,
/// `glXGetProcAddressARB` or `wglGetProcAddress`.
using ProcLoader = void* (*)(const char*);
/// Resolves every entry point of `PLOT_GL_FUNCS` (see api.hpp) into the global
/// table declared there, so the rest of the library can call `gl*` directly.
/// The above-baseline entries of `PLOT_GL_OPTIONAL_FUNCS` are resolved the same
/// way but never fail the load: a driver without them leaves those pointers
/// null, and callers reach them only behind `caps()`.
///
/// Returns false when any required name cannot be found; entries that did
/// resolve keep their pointers, so a failed load() never breaks a table that
/// was already usable. `loader` is consulted first; when it is null, or returns
/// null for a name, the fallback is `dlsym(RTLD_DEFAULT, name)` and then
/// `dlopen("libGL.so.1")` + `dlsym`. The fallback makes the library usable
/// without a caller-supplied resolver, but only a context-aware resolver is
/// guaranteed to return pointers valid for the *current* context — pass one
/// whenever a context is current.
///
/// A load() attempt invalidates the caps() cache. The table is process-global
/// and is not synchronised, so call from the thread that owns the current
/// context.
bool load(ProcLoader loader = nullptr) noexcept;
/// True once a `load()` has resolved every required entry point of the global
/// table. This is the gate superseding per-pointer null checks: while it is
/// false the table may be only partially populated (see load()), so no `gl*`
/// entry point may be called. The fallible handle constructors report
/// `ErrorCode::Unsupported` until it becomes true.
bool available() noexcept;
} // namespace plot::gl
+115
View File
@@ -0,0 +1,115 @@
#pragma once
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
namespace plot {
class Plot;
}
namespace plot::input {
/// What a drag in progress is doing.
enum class DragMode {
None,
Pan, ///< translate every range (left or right drag)
BoxZoom, ///< a shift+left selection is open; the ranges change on release
MoveCursor, ///< ctrl+left drag is moving `hovered_cursor()`
AxisZoomX, ///< middle drag inside the x label strip; zooms x only
AxisZoomY, ///< middle drag inside the y label strip; zooms y only
};
/// Turns host events into plot gestures, knowing nothing about Qt, GTK, GLFW or
/// ImGui -- Tasks 8-11 translate their native events into the value types above.
///
/// It is pure CPU and needs no GL context and no font: everything it reads comes
/// from `Plot`'s layout, ranges and transforms. The controller owns no viewport
/// of its own: set_viewport() forwards to `Plot::set_viewport` (and refreshes the
/// layout) so the two can never disagree, and every gesture runs through
/// `Plot`'s range setters, which clear `auto_scale` in one place.
///
/// Gesture table (grab-and-drag: the data follows the cursor):
/// | gesture | mode | effect |
/// |---|---|---|
/// | left drag | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left |
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
/// | ctrl + left drag near a cursor | `MoveCursor` | move that cursor |
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
/// | `+` / `-` | -- | zoom about the plot centre |
/// | `f` | -- | auto-fit every axis |
///
/// Both axis-zoom drags read the vertical `dy` in pointer pixels with the
/// wheel's sign convention (dragging down zooms in), anchored at the pointer's
/// data coordinate on the axis they zoom. The drag has its own per-pixel
/// sensitivity -- a pixel is not a wheel notch -- of one decade per 200 px, so
/// a 200 px drag scales the span by 10.
///
/// A drag that changes no range is harmless: a box-zoom selection under 4 px on
/// either side is a click, and leaves the ranges alone.
class Controller {
public:
explicit Controller(Plot& plot) noexcept : plot_(&plot) {}
/// Sets the plot's viewport (the controller keeps none of its own) and
/// refreshes the layout, so gesture geometry and the host agree.
void set_viewport(Rect px);
/// Events are sanitised before they are acted on: a non-finite coordinate or
/// delta -- a host's "no pointer" sentinel (ImGui reports `-FLT_MAX`), a NaN
/// from a degenerate mapping, an infinity from a lost device -- becomes the last
/// known position and no movement. Garbage must not reach the ranges: a gesture
/// that sets a range also clears auto scale, so a range driven to 1e35 is one the
/// user can neither pan nor zoom back. The buttons in the same event are still
/// acted on, so a drag whose release arrives with a bad position still ends.
void on_pointer(const PointerEvent& e);
/// Same, and a wheel amount that is not a number is *refused* rather than
/// zeroed: a zero-notch zoom changes no range but still goes through the range
/// setters, which clear auto scale -- so a sanitised amount would quietly disarm
/// auto-fit. An unusable amount leaves the plot exactly as it was; an unusable
/// *position* only loses its anchor, falling back to the last known one.
void on_wheel(const WheelEvent& e);
void on_key(const KeyEvent& e);
DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
Rect selection_rect() const noexcept { return selection_; }
/// The cursor under the pointer, or `kInvalid` when the pointer is outside the
/// plot area or no cursor is within a few pixels.
u64 hovered_cursor() const;
private:
void begin_drag(const PointerEvent& e);
void update_drag(const PointerEvent& e);
void end_drag(const PointerEvent& e);
void pan(const PointerEvent& e);
void box_zoom_update(const PointerEvent& e);
void apply_box_zoom();
void move_cursor(const PointerEvent& e);
void axis_zoom(const PointerEvent& e);
/// The nearest visible cursor within the hit radius of (px, py), or kInvalid.
/// kInvalid when (px, py) is outside the plot axes.
u64 cursor_at(f32 px, f32 py) const;
/// Zoom one axis about `anchor` (a data coordinate) by `factor`, recomputing
/// the range so the anchor keeps its screen position. Leaves `auto_scale` false.
void zoom_x_axis(u32 axis, f64 anchor, f64 factor);
void zoom_y_axis(u32 axis, f64 anchor, f64 factor);
Plot* plot_ = nullptr;
DragMode mode_ = DragMode::None;
Rect selection_{};
f32 origin_x_ = 0.0f;
f32 origin_y_ = 0.0f;
u64 drag_cursor_ = kInvalid;
bool has_pointer_ = false;
f32 pointer_x_ = 0.0f;
f32 pointer_y_ = 0.0f;
};
} // namespace plot::input
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <plot/core/types.hpp>
namespace plot::input {
/// Mouse buttons a host can report. The controller never assumes a fixed
/// mapping: right-drag pans as well as left, for hosts that reserve left-drag.
enum class Button { Left, Middle, Right };
/// Keyboard modifier bits for `PointerEvent::mods` / `WheelEvent::mods`.
enum class Mod { None = 0, Shift = 1, Ctrl = 2, Alt = 4, Super = 8 };
/// True when `mods` holds `m`. The single place modifier bits are tested, so a
/// call site never hand-rolls a mask.
constexpr bool has_mod(u32 mods, Mod m) noexcept {
return (mods & static_cast<u32>(m)) != 0u;
}
/// A pointer event in viewport pixels (origin top-left, the same space as
/// `Rect`). `dx`/`dy` are the movement since the previous event; the controller
/// reads them for panning and the absolute `x`/`y` for anchoring and hit tests.
/// `pressed` and `released` are the transitions; `down` is the state after the
/// event. A plain move has all three false/true as appropriate (down only).
struct PointerEvent {
f32 x = 0.0f;
f32 y = 0.0f;
f32 dx = 0.0f;
f32 dy = 0.0f;
Button button = Button::Left;
u32 mods = 0;
bool down = false; ///< the button is held after this event
bool pressed = false; ///< a press transition happened on this event
bool released = false; ///< a release transition happened on this event
};
/// A wheel/scroll event at a pixel position. A positive `dy` (scroll up) zooms
/// in; a positive `dx` scrolls right. Only `dy` drives the controller's zoom.
struct WheelEvent {
f32 x = 0.0f;
f32 y = 0.0f;
f32 dx = 0.0f;
f32 dy = 0.0f;
u32 mods = 0;
};
/// A key event. `key` is an ASCII codepoint for printable keys; hosts that only
/// have scancodes translate them before calling. `down` distinguishes the press
/// from the release -- the controller's actions are press-only.
struct KeyEvent {
int key = 0;
u32 mods = 0;
bool down = false;
};
/// The printable keys the controller reacts to, named so no call site spells a
/// bare character literal. `kKeyZoomIn`/`kKeyZoomOut` zoom about the plot centre,
/// `kKeyFit` re-fits every axis to its traces.
inline constexpr int kKeyZoomIn = '+';
inline constexpr int kKeyZoomOut = '-';
inline constexpr int kKeyFit = 'f';
/// Hosts that report shifted keys uppercase can pass this unchanged.
inline constexpr int kKeyFitUpper = 'F';
} // namespace plot::input
+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;
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
/// Umbrella header: pulls in every public component of the plot library, in
/// layer order (`core/math -> gl -> data -> render -> plot -> input`), so a
/// translation unit can include this one header and use the whole surface.
///
/// It deliberately stops at the library: the adapters under `adapters/` are
/// separate targets -- each one drags in GLFW, Dear ImGui, Qt6 or GTK4 -- and
/// pulling any of them in here would destroy the core's zero-dependency
/// property, which is the point of `plot_core`.
///
/// The macro-gated instrumentation under `*/detail/` is not here either: those
/// headers exist for the test suite's counters, define nothing without their
/// PLOT_*_COUNT/PLOT_RING_STATS macro, and are documented as not part of the
/// public surface.
#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>
#include <plot/gl/api.hpp>
#include <plot/gl/caps.hpp>
#include <plot/gl/handle.hpp>
#include <plot/gl/loader.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/renderer.hpp>
#include <plot/render/shaders.hpp>
#include <plot/render/style.hpp>
#include <plot/render/text.hpp>
#include <plot/render/vertex_array.hpp>
#include <plot/plot/annotation.hpp>
#include <plot/plot/axis.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/measure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/plot/ticks.hpp>
#include <plot/plot/trace.hpp>
#include <plot/input/controller.hpp>
#include <plot/input/events.hpp>
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <string>
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
namespace plot {
/// A pinned point with a text caption. `x`/`y` are data coordinates on the
/// `axis_x`/`axis_y` pair; the text is UTF-8 and is measured through the plot's
/// text backend, exactly like an axis label.
struct Annotation {
f64 x = 0.0;
f64 y = 0.0;
std::string text;
Color color = Color::hex(0xE8E8E8);
u32 axis_x = 0;
u32 axis_y = 0;
bool visible = true;
};
} // namespace plot
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <string>
#include <vector>
#include <plot/core/types.hpp>
#include <plot/plot/ticks.hpp>
namespace plot {
/// Where an axis sits around the plot area.
enum class AxisPlace { Bottom, Top, Left, Right };
/// Everything a caller can say about an axis at creation time. The six fields are
/// the five of the design spec plus the label format; the trailing default keeps
/// the spec's five-field aggregate initialisation working unchanged.
struct AxisDesc {
std::string label;
Range range{0.0, 1.0};
bool auto_scale = true;
AxisPlace place = AxisPlace::Bottom;
bool log = false;
TickFormat format = TickFormat::Si;
};
/// A live axis: its range and presentation flags, plus the tick list `refresh()`
/// regenerates for the current pixel size. `auto_scale` and `log` are public
/// because the input controller and the renderer both read and write them.
struct Axis {
std::string label;
Range range{0.0, 1.0};
bool auto_scale = true;
AxisPlace place = AxisPlace::Bottom;
bool log = false;
TickFormat format = TickFormat::Si;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks;
/// Regenerates `ticks` for an axis `px` pixels long, majors at least `min_px`
/// apart. `include_minors` additionally emits the grid subdivisions. Labels use
/// `format` -- `TickFormat::Time` relabels the majors with `format_time`, which
/// `generate_ticks` cannot know about.
void refresh(f32 px, f32 min_px, bool include_minors = true);
};
} // namespace plot
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <string>
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
namespace plot {
/// Where a cursor sits relative to the data grid.
enum class SnapMode {
Free, ///< the cursor stays exactly where the caller put it.
NearestSample, ///< the cursor is pulled onto the nearest sample of the trace's x grid.
};
/// A vertical marker at data x. `axis_x` names the x axis whose data coordinate
/// `x` is in; `label` is an optional caption drawn next to the marker.
struct Cursor {
f64 x = 0.0;
SnapMode snap = SnapMode::Free;
Color color = Color::hex(0xFFB020);
std::string label;
u32 axis_x = 0;
bool visible = true;
};
} // namespace plot
@@ -0,0 +1,30 @@
#pragma once
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::detail {
/// Test-only instrumentation. Counts the measurements `Plot::measurement()` has
/// actually re-evaluated since the counter was last reset.
///
/// Whether a call was a cache hit is invisible in the result -- a measurement
/// frozen on an old window and a current one are both "a Measurement" -- so a
/// test that has to tell "the cache recomputed because the window slid" from
/// "the cache is stuck on the first saturated frame" counts re-evaluations. The
/// counter exists only when PLOT_COUNT_MEASURE_RECOMPUTES is defined (the test
/// build defines it), so every normal build carries no counter and no increment
/// on the measurement path.
///
/// Not part of the public surface and not meant for production use.
inline std::atomic<u64>& measure_recomputes() {
static std::atomic<u64> count{0};
return count;
}
} // namespace plot::detail
#endif // PLOT_COUNT_MEASURE_RECOMPUTES
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/plot/axis.hpp>
#include <plot/plot/ticks.hpp>
namespace plot {
/// One grid line as a segment in DATA coordinates. The renderer maps the
/// endpoints through its transform, so pan and zoom move grid lines for free --
/// baking screen coordinates in here would break that uniform-only contract.
struct GridLine {
f64 x0 = 0.0, y0 = 0.0, x1 = 0.0, y1 = 0.0;
bool major = true;
};
/// Tick-aligned grid geometry: one segment per tick, spanning the perpendicular
/// axis' full range.
class Grid {
public:
bool vertical = true; ///< emit lines at x ticks
bool horizontal = true; ///< emit lines at y ticks
bool show_minor = true; ///< keep minor-tick lines
/// Rebuilds the segments from each axis' ticks and range. Ticks outside the
/// perpendicular range (or non-finite) are skipped, so a stale tick list can
/// never draw a line off the plot area. `out`-style: clears then appends.
void build(const std::vector<Tick>& x_ticks, Range x_range,
const std::vector<Tick>& y_ticks, Range y_range);
const std::vector<GridLine>& lines() const noexcept { return lines_; }
usize size() const noexcept { return lines_.size(); }
void clear() noexcept { lines_.clear(); }
private:
std::vector<GridLine> lines_;
};
} // namespace plot
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
namespace plot {
/// Interval statistics over the half-open sample range [begin, end).
///
/// `dx` is the x distance between the first and the last sample of the interval
/// (0 for a single-sample interval), `dy` the corresponding y difference,
/// `slope` = `dy / dx`, `mean` and `rms` over the `end - begin` samples, `ymin`
/// / `ymax` the exact extremes, and `integral` the area under the samples.
///
/// `integral` is deliberately a LEFT-RIEMANN sum, `sum(begin, end) * dx`: the
/// series' samples are taken as the value at the left edge of each step. A
/// trapezoid rule would be equally defensible and compute a different number for
/// every reported interval, so this choice is part of the result's contract, not
/// an implementation detail. Do not "fix" it without changing every documented
/// value and every test that pins one.
///
/// `valid` is false (and every field zero) for an interval that is empty,
/// inverted or out of range; such a call never reads the series.
struct Measurement {
f64 dx = 0.0;
f64 dy = 0.0;
f64 slope = 0.0;
f64 rms = 0.0;
f64 mean = 0.0;
f64 ymin = 0.0;
f64 ymax = 0.0;
f64 integral = 0.0;
bool valid = false;
};
/// The cursor pair a measure spans, as the plot stored it. `valid` is false for
/// an id that names no measure and for a measure whose cursors are not both
/// registered, so a caller can tell "no band to shade" from "a band between
/// cursors 0 and 0". The renderer uses it to place the band a measurement's
/// readout belongs to.
struct MeasureSpan {
u32 cursor_a = 0;
u32 cursor_b = 0;
bool valid = false;
};
/// Stateless interval measurement over a data::Series.
struct Measure {
/// Evaluates [begin, end). Every quantity comes from `Series::reduce`/`sum`/
/// `sumsq` -- never from the pyramid's `*_buckets` forms, which exclude partial
/// edge buckets and would silently return wrong extrema and sums for a range
/// that is not bucket-aligned. Each of those queries is O(kBucket) worst case
/// (bucket-aligned interior from the pyramid, at most two partial edge buckets
/// read raw), so a measurement never scans the series and its cost does not
/// grow with series length.
static Measurement evaluate(const data::Series& s, u64 begin, u64 end);
};
} // namespace plot
+276
View File
@@ -0,0 +1,276 @@
#pragma once
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/math/transform.hpp>
#include <plot/plot/annotation.hpp>
#include <plot/plot/axis.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/measure.hpp>
#include <plot/plot/trace.hpp>
#include <plot/render/style.hpp>
#include <plot/render/text.hpp>
namespace plot {
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left). `axes` is the area data is drawn into; the label
/// areas are the strips the tick labels live in and the title areas the strips
/// the axis titles live in, each outside `axes` and so never over the data.
///
/// `y_*` strips are left of `axes` and `x_*` ones below it, whatever an axis'
/// `place` says: an axis placed on the opposite edge still has its labels and
/// its title on the side the layout reserves, exactly as it always has.
struct PlotLayout {
Rect axes;
Rect y_label_area;
Rect x_label_area;
/// One text line each, just outside the matching label area. Zero-sized (and
/// unoccupied) when no axis of that direction has a title.
Rect y_title_area;
Rect x_title_area;
/// Left strip width. The default is used when no text backend has been set.
f32 y_axis_width = 56.0f;
/// Bottom strip height. The default is used when no text backend has been set.
f32 x_axis_height = 22.0f;
/// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing
/// is reserved. Every titled axis of a direction gets its own text line inside
/// its strip -- which is what keeps a Left/Right (or Bottom/Top) pair from
/// drawing two titles over each other -- so `x_title_height` is one line per
/// titled x axis; `y_title_width` is the widest y title plus the padding, since
/// y titles are horizontal runs that stack down the axes-tall strip and need
/// width, not another row of it. With no text backend there is no title to
/// measure and both stay 0.0f.
f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f;
};
/// The public façade: axes, traces, cursors, annotations and their layout.
///
/// It is pure CPU state -- no GL context, no font of its own. The renderer hands
/// it the font to measure labels with (set_text_backend) and reads layout(),
/// the transforms and the measurement results back out. Everything the renderer
/// draws (tick geometry, grid segments, cursor x in data space) is described in
/// data coordinates and pixels here; nothing is uploaded anywhere.
///
/// Owned collections are indexed by the `u32` id the matching `add_*` returned.
/// A stale or unknown id is not fatal: accessors return a default-constructed
/// object and measurement() returns an invalid result.
class Plot {
public:
Plot() = default;
// --- viewport, style, text metrics ------------------------------------
/// The rectangle this plot occupies on screen, in framebuffer pixels.
void set_viewport(Rect px) noexcept { viewport_ = px; }
const Rect& viewport() const noexcept { return viewport_; }
/// Set from the renderer, which owns presentation: `Renderer::draw()` copies
/// its own `style()` in here every frame, so a style set on the plot is what a
/// plot drawn *without* a renderer lays out with -- a frame drawn by one always
/// uses the renderer's values.
void set_style(const render::Style& s) noexcept { style_ = s; }
const render::Style& style() const noexcept { return style_; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
/// is measured: a plot with no renderer lays out with whatever the host sets
/// here, which is for consumers that draw the plot themselves.
///
/// A plot drawn by a `Renderer` is the resolution the other way round: the
/// renderer draws the labels with its own bitmap font, so it installs that font
/// here on every `draw()` and the strips are measured with the metrics the
/// labels are actually drawn with. A backend installed before the first draw is
/// therefore replaced, with a debug-build diagnostic naming the mismatch.
void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; }
/// The backend layout() measures labels with, or null when none is set. A
/// renderer replaces whatever it finds here with its own font (see
/// set_text_backend()); a plot nobody draws keeps it.
const render::TextBackend* text_backend() const noexcept { return text_; }
// --- construction -----------------------------------------------------
u32 add_x_axis(const AxisDesc& desc);
u32 add_y_axis(const AxisDesc& desc);
u32 add_trace(const TraceDesc& desc);
u32 add_cursor(f64 x, Color color);
/// Measures the interval between two cursors over the series of `trace`.
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
u32 add_annotation(const Annotation& annotation);
// --- accessors --------------------------------------------------------
// An id that does not name an object yields a default-constructed one, so a
// stale id can never read out of bounds.
u32 x_axis_count() const noexcept { return static_cast<u32>(x_axes_.size()); }
u32 y_axis_count() const noexcept { return static_cast<u32>(y_axes_.size()); }
/// Axes of that direction that carry a title, i.e. that `layout()` reserves a
/// text line for and `Renderer::draw_titles()` draws one for. Zero when no
/// backend can measure a title, whatever the labels say.
u32 titled_x_axis_count() const noexcept;
u32 titled_y_axis_count() const noexcept;
u32 trace_count() const noexcept { return static_cast<u32>(traces_.size()); }
u32 cursor_count() const noexcept { return static_cast<u32>(cursors_.size()); }
u32 measure_count() const noexcept { return static_cast<u32>(measures_.size()); }
u32 annotation_count() const noexcept { return static_cast<u32>(annotations_.size()); }
const Axis& x_axis(u32 axis) const;
const Axis& y_axis(u32 axis) const;
const Trace& trace(u32 id) const;
const Cursor& cursor(u32 id) const;
const Annotation& annotation(u32 id) const;
// --- ranges and transforms --------------------------------------------
Range x_range(u32 axis) const;
Range y_range(u32 axis) const;
/// Sets an explicit range. Choosing a range is a user decision, so it also
/// clears that axis' auto_scale: a later layout() must not snap the range back.
/// auto_fit_x()/auto_fit_y() re-arm it.
void set_x_range(u32 axis, Range r);
void set_y_range(u32 axis, Range r);
/// Re-arms (or disarms) the per-layout auto scaling of one axis.
void set_x_auto_scale(u32 axis, bool on);
void set_y_auto_scale(u32 axis, bool on);
/// Data -> pixels for one axis, built from the axis' range, log flag and the
/// current layout. Always invertible: a collapsed range or a collapsed axes
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
Transform x_transform(u32 axis) const;
Transform y_transform(u32 axis) const;
/// Fits every x axis to the union of the series bounds of the traces bound to
/// it, padded by 2% of the span, and re-arms auto scale on the axes it fitted.
/// An axis with no visible trace is left exactly as it was.
///
/// A log axis is fitted in log space and needs a positive value to show. When
/// its traces hold none (every sample negative or zero) the fit is refused: the
/// axis keeps its current range, auto scale is not re-armed, and the next
/// auto-fit tries again -- so a stream that starts at zero begins fitting on its
/// own once it goes positive. A log axis is therefore never left with `min >=
/// max` or a non-positive bound by an auto-fit.
void auto_fit_x();
/// Same for one y axis, over that axis' traces' y bounds.
void auto_fit_y(u32 axis);
// --- cursors and measurements -----------------------------------------
/// Moves a cursor and invalidates the measurements that depend on cursors.
/// A SnapMode::NearestSample cursor is pulled onto the nearest sample of the
/// first visible trace on its x axis.
void set_cursor_x(u32 id, f64 x);
void set_cursor_snap(u32 id, SnapMode mode);
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same
/// (`x0()` is the third key -- see MeasureSlot). Never once per frame. A
/// saturated stream therefore keeps reporting live values with a stationary
/// cursor pair. An unknown id, a null-series trace, an inverted cursor pair or
/// an empty series yields `valid == false`. const because recomputing fills a
/// cache: nothing a caller can observe changes.
const Measurement& measurement(u32 id) const;
/// The cursor pair `id` was created over. The renderer needs it to place the
/// shaded band the measurement readout belongs to; the statistics themselves
/// are in measurement().
MeasureSpan measure_span(u32 id) const noexcept;
/// Drops every cached measurement. Needed after a series is mutated in place
/// (data::ArraySeries::commit() with the same sample count), which a pointer
/// and size check cannot detect.
void invalidate_measurements() noexcept;
// --- layout -----------------------------------------------------------
/// Recomputes the layout, every axis' ticks and the grid, then returns the
/// layout. The label strips come from the text backend's metrics when one is
/// set (`measure()` of the widest y tick label, `height()` for the x strip) and
/// from PlotLayout's defaults otherwise.
///
/// `axes` is inset by the label strip of each occupied edge and, when that
/// direction has a titled axis, by the strip that title lives in (`y_title_width`
/// and `x_title_height` say how much that is): a title can then only be drawn
/// outside the data, whatever the title says.
///
/// The chrome is capped so `axes` keeps half the viewport on each dimension
/// whatever the labels and titles ask for: a plot with a title wider than a
/// narrow panel shrinks, it does not vanish -- an axes rect with no width or no
/// height selects no level, stages no segment and draws no trace, and says
/// nothing about why. The label strip is what makes the tick values readable, so
/// it is served first and the title strip takes what is left of the allowance.
///
/// Call this once per frame -- it rebuilds the ticks and the grid -- and pass
/// the result down.
const PlotLayout& layout();
/// The layout as of the last refresh. The const overload never recomputes, so
/// a lookup on a const Plot cannot move the axes out from under the caller.
const PlotLayout& layout() const noexcept { return layout_; }
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
const Grid& grid() const noexcept { return grid_; }
private:
/// One measure plus its cache. The dirty flag is what keeps measurement() from
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
/// trace being pointed at a different series, a series that grew, and a ring
/// whose window slid under a constant sample count -- the last of which neither
/// of the other two sees, since a saturated stream keeps its size while every
/// sample it holds changes.
struct MeasureSlot {
u32 cursor_a = 0;
u32 cursor_b = 0;
u32 trace = 0;
mutable Measurement result;
mutable bool dirty = true;
mutable const data::Series* last_series = nullptr;
mutable u64 last_size = 0;
mutable f64 last_x0 = 0.0;
};
/// Fit one axis to its traces' bounds, leaving auto_scale alone. False when the
/// axis cannot be fitted (no visible trace bound to it, or a log axis whose
/// traces hold no positive value), in which case the range is untouched.
bool fit_x_axis(u32 axis);
bool fit_y_axis(u32 axis);
/// Union of the bounds of the visible traces bound to one axis (`x_side`
/// selects x or y). False when there is no such trace; `out` then retains
/// whatever it held.
bool trace_bounds_union(bool x_side, u32 axis, Range& out) const;
/// Refit every axis that is still on auto scale. layout() runs this, so a
/// growing stream stays framed until a caller gesture turns auto scale off.
void autoscale_axes();
void refresh_ticks();
void build_grid();
void mark_measures_dirty() noexcept;
/// `c.x` pulled onto the nearest sample when the cursor asks to snap.
f64 snapped_x(const Cursor& c) const;
/// Widest y tick label plus the label padding on both sides: the left strip the
/// text backend asks for. Zero with no backend or no y axis.
f32 y_label_width() const;
/// Widest y axis title plus the label padding on both sides: the vertical strip
/// a horizontal y title fits in. Zero with no backend or no titled y axis.
f32 y_title_strip_width() const;
Rect viewport_{};
render::Style style_{};
const render::TextBackend* text_ = nullptr;
std::vector<Axis> x_axes_;
std::vector<Axis> y_axes_;
std::vector<Trace> traces_;
std::vector<Cursor> cursors_;
std::vector<MeasureSlot> measures_;
std::vector<Annotation> annotations_;
PlotLayout layout_{};
Grid grid_;
};
} // namespace plot
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <string>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// One axis tick. `value` is in data coordinates; `major` marks the labelled
/// ticks (minor ticks carry an empty label); `label` is UTF-8.
struct Tick {
f64 value = 0.0;
bool major = true;
std::string label;
};
/// How an axis renders its tick labels.
enum class TickFormat {
Si, ///< SI-prefixed numbers, e.g. `1.5u`, `2.5G`, `1.23k`.
Time, ///< clock strings, e.g. `1:30`, `1:00:00`.
};
/// Writes an SI-prefixed rendering of `v` to `buf`, always NUL-terminated and
/// never writing past `n` bytes (a too-small buffer truncates, it does not
/// overflow). ASCII plus the UTF-8 micro sign U+00B5 (0xC2 0xB5) for 1e-6, so
/// `plot::render::BitmapFont` renders it instead of mojibake.
///
/// `format_si(0.0)` is exactly `"0"`. Values use three significant digits, so
/// `1234.0` renders as `1.23k`. The sign of a negative value is kept. A value
/// whose engineering exponent falls outside the prefix table (or a label that
/// would exceed 12 characters) falls back to `"%.3g"`, and a non-finite value
/// always does -- `nan`/`inf` are never silently dropped, they are printed by the
/// fallback exactly as `%.3g` prints them.
///
/// `unit` is appended after the prefix when non-null, which is how a caller
/// spells a unit the library cannot know: `format_si(v, buf, n, "\xC2\xB0")` for
/// degrees, `"\xCE\xA9"` for ohms.
void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
/// Writes `seconds` as a compact clock string to `buf`, NUL-terminated and never
/// past `n` bytes. Negatives keep their sign; a non-finite value falls back to
/// `"%.3g"`.
///
/// The whole value is rounded to a single whole-second count first and every
/// field is derived from that one number, so no field can ever reach its radix:
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
///
/// Shapes, by magnitude: below 1 s `500ms`; below 60 s `1.5s`; below 1 h `1:30`;
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
/// boundaries are inclusive of the rounded value: 59.5 s is `1:00`.
void format_time(f64 seconds, char* buf, usize n);
/// The hard cap on `generate_ticks` output, minor ticks included. A range dense
/// enough to need more is truncated rather than allowed to grow without bound.
inline constexpr usize kMaxTicks = 256;
/// Nice-number ticks for the closed interval `range` on an axis `px` pixels long,
/// with major ticks at least `min_px` pixels apart. Steps are 1/2/5 x 10^n, chosen
/// by rounding the ideal step *up* to the next nice value so major spacing really
/// does meet `min_px`. Ticks are appended to `out`, which is cleared first, in
/// ascending value order and labelled by `format_si`.
///
/// `include_minors` adds, for grid geometry, the minor ticks subdividing each
/// major step by 5 (by 4 when the step's leading digit is 2). Without it `out`
/// holds exactly the labelled major ticks -- the labelled set a caller draws
/// numbers for, with no unlabelled entries to filter. Either way the total is
/// capped at kMaxTicks.
///
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
/// (a zero or negative span, which includes the collapsed range of a
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
/// overflows to infinity.
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
bool include_minors = false);
} // namespace plot
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
namespace plot {
/// How a caller asks for a trace. The series pointer is borrowed and must outlive
/// the plot's use of it; `axis_x`/`axis_y` index the plot's x and y axis lists.
struct TraceDesc {
const data::Series* series = nullptr;
Color color = Color::hex(0x4FA3FF);
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
};
/// A series bound to a colour, width and axis pair. The series pointer may be
/// null (a trace whose data has not arrived yet): every consumer skips such a
/// trace rather than dereferencing it.
struct Trace {
const data::Series* series = nullptr;
Color color = Color::hex(0x4FA3FF);
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// True when the trace has a series with at least one sample to draw.
bool visible() const noexcept { return series != nullptr && series->size() > 0; }
};
} // namespace plot
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/text.hpp>
namespace plot::render {
/// 8x8 bitmap font, the text backend of the renderer.
///
/// The glyphs are 1-bit, one byte per row, bit 0 the leftmost pixel; the table
/// and its provenance are in `src/render/bitmap_font.cpp`. Nothing here needs a
/// font file, FreeType or stb_truetype: `plot_core` stays dependency-free.
///
/// Metrics are proportional, not fixed width: an advance is the glyph's ink
/// width plus one pixel of spacing, floored at two pixels, computed once when
/// the font is constructed from the same bitmaps the atlas uploads. Whitespace
/// is a blank glyph with a nonzero advance, and every codepoint without a glyph
/// draws a visible box, so a missing glyph is obvious rather than silently
/// blank.
///
/// Metrics are in unscaled font pixels, one per glyph texel. The text pipeline
/// draws a cell `u_px_range` pixels across, so a run at another size measures
/// `measure(utf8) * u_px_range.x / kGlyphPx`; `TextBatch::add_text` takes that
/// cell size and applies the same scale to the pen it advances.
class BitmapFont final : public TextBackend {
public:
// Atlas geometry. It is part of the render pipeline's contract: the text
// vertex shader expands a glyph cell by `u_px_range` pixels and reads the cell
// at the instance's texel origin, so the atlas is a grid of 8x8 cells whose
// width must stay a whole number of cells.
static constexpr u32 kGlyphPx = 8; ///< glyph cell edge, texels
static constexpr u32 kFirstChar = 32; ///< U+0020
static constexpr u32 kAsciiGlyphs = 95; ///< U+0020..U+007E
static constexpr u32 kDegreeSlot = kAsciiGlyphs; ///< U+00B0 DEGREE SIGN
static constexpr u32 kMicroSlot = kAsciiGlyphs + 1u; ///< U+00B5 MICRO SIGN
static constexpr u32 kOhmSlot = kAsciiGlyphs + 2u; ///< U+03A9 OHM
static constexpr u32 kFallbackSlot = kAsciiGlyphs + 3u; ///< missing-glyph box
static constexpr u32 kGlyphCount = kAsciiGlyphs + 4u;
static constexpr u32 kAtlasCols = 16;
static constexpr u32 kAtlasRows = 7;
static constexpr u32 kAtlasWidth = kAtlasCols * kGlyphPx; ///< 128 texels
static constexpr u32 kAtlasHeight = kAtlasRows * kGlyphPx; ///< 56 texels
static_assert(kAtlasCols * kAtlasRows >= kGlyphCount, "atlas too small for the glyph set");
/// Reported by `next_codepoint` for a byte sequence that is not valid UTF-8.
/// It is not a Unicode scalar value, so it can only map to the fallback box.
static constexpr u32 kInvalidCodepoint = 0xFFFFFFFFu;
/// Decodes one UTF-8 sequence from `p` and advances `p` past it — one byte
/// when the sequence is malformed, so a damaged label still terminates. The
/// text batch and `measure` share it, which is what keeps a staged run exactly
/// `measure`-wide.
static u32 next_codepoint(const char*& p) noexcept;
BitmapFont() noexcept;
/// Width of `utf8`, the sum of the glyph advances: zero for the empty string
/// and for null. The ASCII path allocates nothing.
f32 measure(const char* utf8) const override;
/// Cell height in pixels (one glyph row of text occupies `height()` pixels).
f32 height() const override;
/// Top of the cell to the baseline, in pixels.
f32 ascent() const override;
/// Advance of one ASCII character in pixels. A byte that is not printable
/// ASCII reports the missing-glyph box, matching what `measure` gives that
/// byte on its own.
f32 advance(char c) const noexcept;
/// Advance of a decoded Unicode codepoint, in pixels.
f32 advance_codepoint(u32 codepoint) const noexcept;
/// Glyph slot for a codepoint: printable ASCII is its own slot, the three SI
/// codepoints `format_si` emits map to real glyphs, and anything else maps to
/// the visible missing-glyph box.
static u32 glyph_index(u32 codepoint) noexcept;
/// Rows in a glyph bitmap, always `kGlyphPx`.
u32 glyph_rows(char c) const noexcept;
/// The `kGlyphPx` rows of one ASCII character's bitmap, top row first.
const u8* glyph_bitmap(char c) const noexcept;
/// The `kGlyphPx` rows of a glyph slot.
static const u8* glyph_bitmap_of(u32 slot) noexcept;
/// The packed atlas image, ready for `plot::gl::Texture::upload_r8`: rows of
/// `kAtlasWidth` bytes, top row first, 255 for ink and 0 for background
/// (straight alpha, no dilation or gamma applied).
static const u8* atlas_image() noexcept;
/// Texel origin of a glyph slot in the atlas; this is the per-instance `a_uv`
/// the text pipeline reads.
static Vec2 atlas_origin(u32 slot) noexcept;
private:
f32 advances_[kGlyphCount];
};
} // namespace plot::render
+194
View File
@@ -0,0 +1,194 @@
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/handle.hpp>
namespace plot::render {
/// Per-instance payload of the line pipeline: one segment in data space. It
/// mirrors the interleaved floats `LineBatch::data()` produces, which is why the
/// attribute offsets in vertex_array.cpp come from `offsetof` rather than from
/// hand-written numbers.
struct LineInstance {
f32 x0;
f32 y0;
f32 x1;
f32 y1;
};
/// Per-instance payload of the text pipeline: the glyph quad's top-left corner
/// in Rect pixels plus the glyph's atlas texel origin.
struct TextInstance {
f32 x;
f32 y;
f32 u;
f32 v;
};
static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats");
static_assert(sizeof(TextInstance) == 4 * sizeof(f32), "text instance must stay 4 floats");
/// How a store's staged instances reach the GPU. Semantics are identical either
/// way -- only the transfer differs, and `Renderer::init()` picks one from
/// `plot::gl::caps()`:
///
/// - `Orphan`: the GL 3.3 baseline. Every upload re-specifies the store with a
/// null `glBufferData` (orphaning it, so the driver never has to wait for the
/// draw that is still reading the previous one) and then refills it.
/// - `PersistentMapped`: GL 4.4 / ARB_buffer_storage. Immutable storage is mapped
/// once and written directly, one slot per upload. A slot is never written
/// twice within a frame, one fence is recorded after the frame's last draw, and
/// the only wait is at the start of the next frame -- so the render thread
/// never synchronises with GPU work it has just issued. The vertex array's
/// attribute pointers move with the slot.
enum class UploadMode { Orphan, PersistentMapped };
namespace detail {
/// The growth half of an instance buffer, shared by both pipelines so neither
/// duplicates the GL ownership or the transfer strategy. This is the one place
/// vertex data is handed to the GPU: `LineBuffer::upload` and
/// `TextBuffer::upload` both land here, and no caller of either can tell which
/// `UploadMode` is live.
class InstanceStore {
public:
/// Slots the persistent ring starts with. The floor, not the ring's size: it
/// grows to cover the uploads a frame actually performs (see `upload()`), and
/// never shrinks, so a steady workload grows it at most a logarithmic number of
/// times over the life of the store.
static constexpr u32 kInitialSlots = 3;
/// Upper bound on the wait for the previous frame's draws to retire: a driver
/// that has stopped making progress must not stall the render thread forever.
static constexpr u64 kFenceWaitNs = 1'000'000'000ull;
/// Creates the vertex buffer in `mode`. Reports `ErrorCode::Unsupported` while
/// the GL entry points are unresolved and `GLFailure` if the driver hands out
/// no name. A caller asking for `PersistentMapped` must already have checked
/// that the context and the entry points can do it (see Renderer::init): a
/// store created that way on a machine without them would never draw.
bool create(UploadMode mode, Status* st = nullptr) noexcept;
/// Starts a frame: waits, once, for the frame before it to retire the draws
/// that read the ring, so no slot about to be written can still be being read.
/// False when that wait timed out -- or when a slot was written without a fence
/// ever being recorded for it -- in which case the store has dropped to the
/// baseline path, which has no such hazard, and the caller reports it (see
/// `Renderer::begin_frame`).
///
/// One wait covers every slot because no slot is written twice within a frame
/// (`upload()` grows the ring rather than wrapping it), so each slot this frame
/// touches was last written in the previous frame or earlier -- and the
/// previous frame's fence sits behind every draw that read one.
bool begin_frame() noexcept;
/// Uploads `count` instances of `instance_bytes`. `Orphan` mode orphans and
/// refills; `PersistentMapped` mode writes the next slot of the ring, growing
/// the ring first when the frame has already written all of its slots.
void upload(const void* data, u32 count, usize instance_bytes) noexcept;
/// Records the frame's single fence, behind every draw that read the ring, so
/// the next `begin_frame()` can wait for them. Called once per frame, after the
/// last draw that consumes the store. True in `Orphan` mode, where nothing
/// reads the store after the orphan, and true when the frame drew nothing from
/// the ring.
///
/// False when the driver refused the fence: the slots written this frame would
/// stay unsynchronised, so the store drops the mapped ring for the orphan path
/// instead of rotating through it (the caller reports the failure -- see
/// `Renderer::end_frame`).
bool mark_drawn() noexcept;
/// Byte offset the current slot's instances start at: 0 in `Orphan` mode,
/// `slot * slot_bytes` in `PersistentMapped` mode. The vertex array re-points
/// its attribute pointers whenever `epoch()` changes.
usize slot_offset() const noexcept {
return mode_ == UploadMode::PersistentMapped ? usize(slot_) * slot_bytes_ : 0u;
}
/// Changes whenever the staged instances moved (a new slot) or the GL buffer
/// was recreated, i.e. whenever attribute pointers into this store went stale.
///
/// Derived from the two things that can make them stale rather than bumped by
/// hand at every site that replaces the buffer object: `gl::Buffer::generation()`
/// advances on every allocation and deletion (so a fallback that re-creates the
/// buffer cannot leave the vertex array bound to a deleted one), and `slot_epoch_`
/// advances on every upload that lands in a new slot. Both counters only ever
/// increase, so their sum changes whenever either does, and never repeats.
u32 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
void bind() const noexcept { buffer_.bind(); }
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
private:
/// (Re)allocates the mapped ring with `slots` slots of `bytes` each. False when
/// the driver refuses the storage or the map, in which case the store has fallen
/// back to a fresh `Orphan` buffer and the frame still draws.
bool grow_persistent(usize bytes, u32 slots) noexcept;
/// Deletes the frame fence, unmaps and releases the GL buffer. `slot_bytes_`
/// drops to 0 so the next upload regrows.
void release_storage() noexcept;
/// Abandons the mapped ring for the baseline path and re-creates the empty
/// buffer that path needs. Used wherever the ring can no longer be guarded.
void drop_to_orphan() noexcept;
gl::Buffer buffer_;
UploadMode mode_ = UploadMode::Orphan;
u32 capacity_ = 0; ///< instances the orphan store can hold
usize slot_bytes_ = 0; ///< bytes per slot of the persistent ring
u32 slot_count_ = kInitialSlots; ///< slots the ring has; grows, never shrinks
u32 slot_ = 0; ///< slot the last upload wrote
/// Slots written since this frame began, and the index the next upload takes.
/// It is the ring index itself, which is what keeps a frame's slots distinct.
u32 frame_uploads_ = 0;
/// Times an upload landed in a new slot. Kept apart from the buffer's own
/// identity (see `epoch()`) so neither has to know about the other.
u32 slot_epoch_ = 0;
u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode
/// Guards every slot the frame just ended wrote. Recorded once, after its last
/// draw; waited on once, at the start of the next frame.
gl::Fence frame_fence_;
};
} // namespace detail
/// Instance buffer of the line pipeline: one `LineInstance` per segment.
class LineBuffer {
public:
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept {
return store_.create(mode, st);
}
bool begin_frame() noexcept { return store_.begin_frame(); }
void upload(const f32* interleaved, u32 count) noexcept {
store_.upload(interleaved, count, sizeof(LineInstance));
}
bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); }
u32 epoch() const noexcept { return store_.epoch(); }
bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); }
private:
detail::InstanceStore store_;
};
/// Instance buffer of the text pipeline: one `TextInstance` per glyph.
class TextBuffer {
public:
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept {
return store_.create(mode, st);
}
bool begin_frame() noexcept { return store_.begin_frame(); }
void upload(const f32* interleaved, u32 count) noexcept {
store_.upload(interleaved, count, sizeof(TextInstance));
}
bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); }
u32 epoch() const noexcept { return store_.epoch(); }
bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); }
private:
detail::InstanceStore store_;
};
} // namespace plot::render
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#ifdef PLOT_RING_STATS
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::render::detail {
/// Test-only instrumentation for the persistent-mapped instance ring (see
/// `InstanceStore`). Every counter exists only when PLOT_RING_STATS is defined
/// -- the test build defines it, on the library and on the suite -- and none of
/// them is a data member of any class, so a consumer compiled without the macro
/// still lays `InstanceStore` out identically. No other build carries a counter
/// or an increment on the upload, frame or wait path.
///
/// The properties they pin are invisible in the pixels a frame draws: a ring that
/// syncs mid-frame rasterises exactly what one that does not does, and a ring that
/// recycles a slot within a frame draws correctly until the GPU happens to be
/// slower than the CPU. So `InstanceStore` counts
///
/// * `ring_uploads()` -- one per upload, i.e. a frame's upload count;
/// * `ring_waits()` -- one per fence wait. At most one per store per frame:
/// the wait is at the start of the frame, on the previous frame's fence;
/// * `ring_slots()` -- the largest ring any store has grown to, which must
/// cover the uploads a frame performs.
///
/// They are process-global rather than per-store because a test that drives one
/// `InstanceStore` reads them directly; a test that drives the whole `Renderer`
/// reads the aggregate, where there are two stores and so at most two waits a
/// frame.
///
/// Not part of any query surface and not meant for production use.
inline std::atomic<u64>& ring_uploads() {
static std::atomic<u64> count{0};
return count;
}
inline std::atomic<u64>& ring_waits() {
static std::atomic<u64> count{0};
return count;
}
inline std::atomic<u64>& ring_slots() {
static std::atomic<u64> count{0};
return count;
}
/// Records `slots` as the ring's largest size so far. A compare-exchange loop
/// rather than a store because two stores report into one counter.
inline void note_ring_slots(u64 slots) noexcept {
std::atomic<u64>& count = ring_slots();
u64 largest = count.load(std::memory_order_relaxed);
while (largest < slots &&
!count.compare_exchange_weak(largest, slots, std::memory_order_relaxed)) {
}
}
} // namespace plot::render::detail
#endif // PLOT_RING_STATS
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/bitmap_font.hpp>
#include <vector>
namespace plot::render {
/// CPU-side staging for one instanced-AA line draw call.
///
/// `begin()` starts a frame and `clear()` empties the batch; neither releases
/// the store, and both leave capacity alone so a steady-state frame re-uses the
/// previous allocation instead of reallocating per frame. The renderer re-uses
/// one batch for the whole run and uploads `data()` once per frame.
///
/// The payload is interleaved `x0,y0,x1,y1` per segment — exactly the
/// per-instance attribute layout the line shader declares (see buffer.hpp and
/// shaders.hpp), so the intersection of `data()` with an `f32` span can be
/// uploaded verbatim.
class LineBatch {
public:
/// Starts a frame: empties the batch, keeps its capacity.
void begin() noexcept;
/// Same as `begin()`, so the render loop can read as begin/stage/draw.
void clear() noexcept;
/// Stages one segment: one instance, two quad corners per end.
void add_segment(Vec2 a, Vec2 b);
/// Stages `n - 1` segments joining consecutive points. A null pointer or
/// fewer than two points stages nothing.
void add_polyline(const Vec2* pts, u32 n);
/// Same, for `n` points already interleaved as `x,y` floats -- the layout
/// `data::Series::gather` appends to, so a gathered window is staged without a
/// copy or a reinterpret_cast of its storage.
void add_polyline_xy(const f32* xy, u32 n);
/// Segments staged so far, one instance each.
u32 segment_count() const noexcept { return static_cast<u32>(v_.size() / 4u); }
/// Staging vertices, two per staged segment. The pipeline draws one instanced
/// quad per segment (its four `GL_TRIANGLE_STRIP` corners come from
/// `gl_VertexID`), so the instance count a draw passes is `vertex_count() / 2`
/// — handing `vertex_count()` to `glDrawArraysInstanced` would rasterise
/// twice the geometry the batch staged.
u32 vertex_count() const noexcept { return segment_count() * 2u; }
/// Interleaved `x0,y0,x1,y1`, four floats per staged segment.
const std::vector<f32>& data() const noexcept { return v_; }
private:
std::vector<f32> v_;
};
/// CPU-side staging for one instanced text draw call.
///
/// Like `LineBatch` it keeps its capacity across frames. Each staged glyph is
/// one instance: the glyph quad's top-left corner in pixels, top-left origin
/// like `plot::Rect`, plus the glyph's atlas texel origin, interleaved as
/// `x,y,u,v` — the layout the text shader declares (see buffer.hpp).
class TextBatch {
public:
/// Starts a frame: empties the batch, keeps its capacity.
void begin() noexcept;
/// Same as `begin()`.
void clear() noexcept;
/// Stages one glyph cell at `pos_px` (its top-left corner), reading the cell
/// from `slot` in the font atlas (see `BitmapFont::atlas_origin`). `slot`
/// indexes the atlas, not a codepoint.
void add_glyph(Vec2 pos_px, u32 slot);
/// Stages every codepoint of `utf8` and returns the pen past the last glyph,
/// so the run is exactly `font.measure(utf8) * px_per_cell /
/// BitmapFont::kGlyphPx` pixels wide — the width the draw covers when it is
/// told the same cell size as `u_px_range`.
///
/// `px_per_cell` is the on-screen size of one glyph cell: pass
/// `BitmapFont::kGlyphPx` for 1:1 text, and anything else to scale the run.
/// The glyph quad itself is sized by the shader's `u_px_range`, which must be
/// the same value. A null or empty string stages nothing and returns `pen_px`.
Vec2 add_text(Vec2 pen_px, const char* utf8, const BitmapFont& font,
f32 px_per_cell = static_cast<f32>(BitmapFont::kGlyphPx));
/// Glyphs staged so far, one instance each.
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 4u); }
/// Interleaved `x,y,u,v`, four floats per staged glyph.
const std::vector<f32>& data() const noexcept { return v_; }
private:
std::vector<f32> v_;
};
} // namespace plot::render
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/status.hpp>
#include <plot/gl/handle.hpp>
#include <plot/math/vec2.hpp>
namespace plot::render {
/// Linked line program and its uniform surface.
///
/// It owns the `plot::gl::Program` and knows the GLSL pair (shaders.hpp), so no
/// caller spells out a uniform name that could drift from the shader. Creating
/// it propagates what the GL layer reports: `ErrorCode::Unsupported` while
/// `plot::gl::available()` is false, `GLFailure` plus the driver's log when the
/// GLSL does not compile or link.
class LineProgram {
public:
bool create(Status* st = nullptr) noexcept;
bool valid() const noexcept { return program_.id() != 0; }
void bind() const noexcept { program_.use(); }
/// `u_scale`: data space to pixels.
void set_scale(Vec2 data_to_px) const noexcept;
/// `u_offset`: pixel offset added after `set_scale`.
void set_offset(Vec2 px) const noexcept;
/// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept;
/// `u_width`: stroke width in pixels.
void set_width(f32 px) const noexcept;
/// `u_color`: stroke colour, alpha included.
void set_color(Color color) const noexcept;
private:
gl::Program program_;
};
/// Linked text program and its uniform surface. It fails exactly the way
/// `LineProgram::create()` does, propagating `Unsupported` and `GLFailure`.
///
/// `create()` also points the `u_atlas` sampler at texture unit 0, which is the
/// unit `plot::gl::Texture::bind()` defaults to, and leaves this program bound.
/// The atlas itself stays with the caller, who owns its lifetime.
class TextProgram {
public:
bool create(Status* st = nullptr) noexcept;
bool valid() const noexcept { return program_.id() != 0; }
void bind() const noexcept { program_.use(); }
/// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept;
/// `u_px_range`: pixels per glyph cell, i.e. the on-screen size of one glyph.
void set_px_range(Vec2 px) const noexcept;
/// `u_tex_size`: atlas size in texels.
void set_texture_size(Vec2 texels) const noexcept;
/// `u_color`: tint colour, alpha included.
void set_color(Color color) const noexcept;
private:
gl::Program program_;
};
} // namespace plot::render
+286
View File
@@ -0,0 +1,286 @@
#pragma once
#include <span>
#include <vector>
#include <plot/core/color.hpp>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/handle.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/style.hpp>
#include <plot/render/vertex_array.hpp>
namespace plot {
/// What one frame staged and drew. It exists because pixels alone cannot say
/// which of the two rasterisation paths a trace took: a 1M-sample series drawn
/// from the LOD pyramid and one drawn from raw samples can cover the same
/// columns, so the smoke test asserts the path here and the pixels there.
struct FrameStats {
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 draw_calls = 0; ///< draw calls issued, line and text
};
/// Draws a `Plot` into whatever framebuffer is bound.
///
/// The renderer never creates, binds or resizes a framebuffer: `begin_frame()`
/// takes the framebuffer size for the projection and a `Rect` for the region the
/// plot occupies, clears exactly that region (`GL_SCISSOR_TEST` around the clear,
/// so a host that has already painted the rest of its surface keeps it) and sets
/// `glViewport` to the flipped rect. That is the whole embedding contract of the
/// adapters (Qt/GTK/ImGui widgets).
///
/// Two properties drive the rest of the design:
///
/// - **Only the visible window is gathered.** A trace's x range is mapped onto
/// sample indices and `Series::gather` is asked for that window -- never the
/// whole series -- at `pyramid().level_for(n, samples_per_pixel)` whenever a
/// pixel covers `dense_threshold_spp` samples or more. A 100M-sample series
/// therefore stages O(pixels) vertices, and `FrameStats` records which way each
/// trace went.
/// - **Vertex data stays in data space.** Segments are staged in data
/// coordinates and mapped to pixels by the line program's `u_scale`/`u_offset`
/// uniforms, so nothing is ever baked into screen space per primitive; a log
/// axis is staged in the transform's pre-affine coordinate (`log10`), which is
/// what the affine part expects. Pan and zoom are uniform updates.
///
/// The vertex transfer itself is one of two strategies, chosen once in `init()`
/// from `plot::gl::caps()` and invisible to every caller afterwards: a
/// persistent-mapped, fence-guarded ring on 4.3+/ARB_buffer_storage, and the
/// orphan-and-refill baseline on 3.3. Setting `PLOT_FORCE_GL33_UPLOAD=1` in the
/// environment forces the baseline path, which is how both are exercised on one
/// machine. A driver that refuses the storage, a map or a fence drops the store
/// that hit it to the baseline path -- the ring is never rotated through without
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
///
/// Not thread-safe, and confined to the thread that owns the current context.
class Renderer {
public:
Renderer() noexcept = default;
~Renderer() = default;
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
/// Compiles both programs, builds the glyph atlas and allocates the vertex
/// buffers. The calling thread must have the context current and
/// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is
/// `Unsupported`; anything the driver refuses is `GLFailure` plus its log.
bool init(Status* st = nullptr) noexcept;
/// Starts a frame: records `fb_w` x `fb_h` for the projection, flips
/// `viewport_px` (top-left origin, like every `Rect` in this library) into GL's
/// bottom-left origin once, clears that region to `style().background` and
/// empties the staging batches.
///
/// It is also where the instance stores wait -- once each -- for the previous
/// frame's draws to retire the ring they read, before this frame writes any
/// slot (see `InstanceStore::begin_frame`): a frame never synchronises with the
/// work it issued itself. A store that cannot retire that frame drops to the
/// baseline path, which this frame reports (see `last_frame_had_gl_error()`).
void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept;
/// Draws `plot`, refreshing its layout, ticks and grid first.
///
/// The renderer owns the presentation, style and text both: `draw()` copies
/// `style()` into the plot and installs its own font backend on it, so the strips
/// and tick densities the plot lays out with are the values the renderer draws
/// with -- set colours and metrics here, not on the plot, and expect a backend
/// set with `Plot::set_text_backend()` to be replaced on the first frame (a
/// debug build names the one it dropped). The renderer must therefore outlive the
/// plot's use of it.
///
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
/// it runs the auto scale, the strip fixed-point loop and the grid build, so
/// re-deriving it per helper would rebuild the ticks and the grid per helper.
void draw(Plot& plot);
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
/// attributed to a later frame (see `last_frame_had_gl_error()`).
///
/// It also records each instance store's single fence for the frame, behind
/// every draw that read the ring, which is what the next `begin_frame()` waits
/// on (see `InstanceStore::mark_drawn`).
///
/// The GL state it leaves behind, so a host can embed the plot without guessing:
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
/// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the
/// viewport left at the frame's rect (the caller sets its own for the next pass).
/// Two pieces of global state the frame *changed* are handed back rather than
/// left where the frame wanted them: `GL_UNPACK_ALIGNMENT`, which `init()` set
/// to 1 for the glyph atlas' single-channel rows, and the clear colour, which is
/// `style().background` only for the duration of the frame. GL keeps no history
/// for the clear colour, so the host's value is queried on the first
/// `begin_frame()`; that first value is what comes back here, so a colour the
/// host changes after the first frame is restored over by it.
/// The framebuffer binding is never touched -- it is the caller's, and so is the
/// pixel-pack alignment if `dump_frame_ppm()` ran.
void end_frame() noexcept;
/// The frame's colours and metrics; `draw()` hands them to the plot as well, so
/// the layout it computes matches what is drawn. `line_width_px` is the width of
/// the strokes framing the data (axis lines, cursor markers) -- grid lines stay
/// hairlines and every trace carries its own width.
render::Style& style() noexcept { return style_; }
const render::Style& style() const noexcept { return style_; }
/// True when the last frame reported a GL failure: an error drained from
/// `glGetError()` in `end_frame()`, or a fence the driver refused, which the
/// store reports when the frame's fence is recorded or waited on (such a
/// frame has already fallen back to the baseline path). Errors are drained in a
/// loop, so a stale one cannot be attributed to a later frame.
bool last_frame_had_gl_error() const noexcept { return gl_error_; }
/// Statistics of the most recently drawn frame.
const FrameStats& stats() const noexcept { return stats_; }
bool ready() const noexcept { return ready_; }
/// True while both pipelines are uploading through the persistent-mapped ring:
/// `init()` selects it from the caps and the entry points (4.3+ with
/// ARB_buffer_storage, and `PLOT_FORCE_GL33_UPLOAD` unset), and a driver failure
/// later -- a refused map or fence -- leaves the store that hit it on the
/// baseline path, which this then says.
bool using_persistent_upload() const noexcept {
return line_vao_.persistent() && text_vao_.persistent();
}
/// The axis text size the renderer actually draws with: `style().axis_font_px`
/// rounded to the nearest whole multiple of `render::BitmapFont::kGlyphPx`,
/// because a 1-bit glyph only scales cleanly by whole factors.
f32 axis_font_px() const noexcept { return font_backend_.cell_px(); }
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
/// channel, rows top-down) so a headless render can be looked at without a
/// window. Reads the framebuffer that is bound now, which is the one the frame
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
/// unreadable pixel.
///
/// The one entry point here that is not `noexcept`: it sizes its readback hole
/// and its output row, so an allocation failure propagates `std::bad_alloc`
/// rather than terminating a process that could have carried on without a
/// screenshot. A dump is a diagnostic, and no `Status` code could say more than
/// the exception already does.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
private:
/// The bitmap font as the plot layer sees it, at the size the text pipeline
/// draws it: `measure`/`height`/`ascent` scale with the glyph cell, so a label
/// can never be laid out for one size and drawn at another. The 8x8 font scales
/// by whole multiples of its cell only -- anything else lands a 1-bit glyph's
/// strokes unevenly -- so `reset()` picks the multiple nearest the requested
/// size.
class AxisFont final : public render::TextBackend {
public:
void reset(const render::BitmapFont& font, f32 requested_px) noexcept;
f32 measure(const char* utf8) const override;
f32 height() const override;
f32 ascent() const override;
/// On-screen size of one glyph cell, in pixels.
f32 cell_px() const noexcept { return cell_px_; }
private:
const render::BitmapFont* font_ = nullptr;
f32 cell_px_ = 1.0f;
f32 scale_ = 1.0f;
};
/// One axis pair's data -> pixel mapping, already shifted so the viewport's
/// top-left corner is the origin (the shaders emit NDC relative to the GL
/// viewport, not the framebuffer).
struct Mapping {
Vec2 scale{1.0f, 1.0f};
Vec2 offset{0.0f, 0.0f};
};
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept;
/// The identity mapping for geometry already staged in viewport pixels.
Mapping pixel_mapping() const noexcept;
/// Shifts one framebuffer-space pixel position into viewport space.
Vec2 to_viewport(Vec2 px) const noexcept;
/// The single transfer point of the line pipeline: the staged `LineBatch`
/// payload (four floats per instance) goes to whichever strategy `init()`
/// selected. Callers of this helper never see which one is live.
void upload_vertices(std::span<const f32> interleaved) noexcept;
/// Same, for the text pipeline's four-float glyph instances.
void upload_text_vertices(std::span<const f32> interleaved) noexcept;
/// Uploads the staged line batch, maps it with (`scale`, `offset`), rasterises
/// it in one colour and empties the batch. A no-op for an empty batch, so the
/// caller can flush unconditionally.
void flush_lines(const Mapping& map, Color color, f32 width) noexcept;
/// Draws every staged glyph in `style().axis_text` and empties the batch. Text
/// is texture-bound, so this runs once per frame, after all line geometry.
void flush_text() noexcept;
/// Stages one data-space segment, taking the two axes' log flags into account.
void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` data-space points (a gathered window).
void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it, shifting
/// it into viewport space. Returns the pen past the run.
Vec2 stage_text(Vec2 pos_px, const char* utf8);
/// Width of the longest prefix of `utf8` that fits `budget_px`, at the size the
/// text pipeline draws with: exactly what `stage_text_clipped()` stages.
f32 clipped_text_width(const char* utf8, f32 budget_px) const;
/// Stages as much of `utf8` as fits `budget_px` at `pos_px` and returns the
/// width it staged. Titles are the runs that need this: a title longer than the
/// strip the layout reserved for it is cut there, rather than being allowed to
/// grow the strip (which would shrink the plot) or to spill over the tick labels
/// and the data (which is the defect the strips exist to prevent).
f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px);
void draw_grid(const Plot& plot);
void draw_axis_lines(const Plot& plot);
/// The chrome helpers take the frame's layout: `plot.layout()` recomputes the
/// ticks, the strips and the grid, so it belongs to `draw()` alone.
void draw_tick_labels(const Plot& plot, const PlotLayout& layout);
void draw_titles(const Plot& plot, const PlotLayout& layout);
/// The data helpers take it too; they read the axes rect and the transforms it
/// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout);
void draw_cursors(const Plot& plot, const PlotLayout& layout);
void draw_measures(const Plot& plot, const PlotLayout& layout);
void draw_annotations(const Plot& plot);
render::BitmapFont font_;
AxisFont font_backend_;
render::Style style_;
gl::Texture atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_;
render::LineBatch lines_;
render::TextBatch glyphs_;
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// The host's `GL_UNPACK_ALIGNMENT`, saved by `init()` before the atlas upload
/// set it to 1 and handed back by `end_frame()`. GL's default is 4.
gl::GLint host_unpack_alignment_ = 4;
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
/// has no other way to recover it once the frame has set it) and handed back by
/// `end_frame()`.
Color host_clear_{};
bool host_clear_saved_ = false;
Rect viewport_; ///< the frame's region, top-left origin, framebuffer px
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
u32 fb_w_ = 0;
u32 fb_h_ = 0;
bool ready_ = false;
bool gl_error_ = false;
FrameStats stats_{};
};
} // namespace plot
+46
View File
@@ -0,0 +1,46 @@
#pragma once
namespace plot::render {
/// GLSL source of the instanced antialiased line pipeline.
///
/// The vertex shader takes one segment per instance (`a_p0` at location 0,
/// `a_p1` at location 1, both with a vertex attribute divisor of 1), expands it
/// in pixel space along its normal by `u_width * 0.5 + 1.0` pixels of AA fringe,
/// and emits a four-vertex `GL_TRIANGLE_STRIP` quad whose corners come from
/// `gl_VertexID` (0 and 1 are the two sides of `a_p0`, 2 and 3 of `a_p1`). It
/// outputs `v_dist`, the signed distance from the segment axis in pixels, and
/// `v_along`, the distance travelled from `a_p0` in pixels.
///
/// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, pixels),
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_color` (vec4).
/// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is
/// flipped exactly once, here.
///
/// A degenerate segment (`a_p0 == a_p1`) keeps a finite normal and is stretched
/// into a square mark one stroke width and fringe across, rather than
/// collapsing to zero area and rasterising nothing — the dense path stages
/// `min == max` buckets as exactly such segments.
///
/// The fragment shader discards outside the fringe and ramps alpha over the last
/// pixel of the half width with `smoothstep`, which is what keeps a 1px trace
/// from aliasing into dashes.
extern const char* const kLineVS;
extern const char* const kLineFS;
/// GLSL source of the instanced text pipeline.
///
/// One glyph cell per instance: `a_pos` at location 0 is the quad's top-left
/// corner in Rect pixels and `a_uv` at location 1 is the glyph's atlas texel
/// origin, both with a vertex attribute divisor of 1. The quad grows by
/// `u_px_range` pixels per cell and the fragment shader samples the
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST`
/// never lands on a cell boundary. `u_color` tints the straight alpha.
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, pixels per glyph
/// cell), `u_tex_size` (vec2, atlas texels), `u_atlas` (sampler2D, unit 0),
/// `u_color` (vec4).
extern const char* const kTextVS;
extern const char* const kTextFS;
} // namespace plot::render
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <plot/core/color.hpp>
namespace plot::render {
/// Every colour and metric the renderer draws with. Defaults are the dark theme
/// the plan's render smoke test asserts against; a caller swaps a field and the
/// next frame picks it up, so nothing here is baked into a shader.
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: samples per pixel at or above
///< which a trace is gathered from the LOD
///< pyramid instead of from its raw samples.
///< It selects the gather level, never a
///< primitive: both sides draw through the one
///< instanced anti-aliased quad path.
f32 tick_min_px = 60.0f; ///< minimum spacing between major ticks.
};
} // namespace plot::render
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <plot/core/types.hpp>
namespace plot::render {
/// Text metrics the plot layer lays axes and labels out with. It is a pure
/// interface so the layout code never depends on the bitmap font, and so a
/// caller can measure with a host toolkit's font instead.
///
/// Text is passed as UTF-8, not as a byte-per-character string: `plot::format_si`
/// emits U+00B5, U+00B0 and U+03A9 for micro, degree and ohm, and an
/// implementation that ignores that prints mojibake.
class TextBackend {
public:
TextBackend() noexcept = default;
TextBackend(const TextBackend&) = default;
TextBackend& operator=(const TextBackend&) = default;
virtual ~TextBackend() = default;
/// Width of `utf8` in the same units as `height()` (pixels for the bitmap
/// font). Zero for the empty string.
virtual f32 measure(const char* utf8) const = 0;
/// Line height: the cell a single line of text occupies.
virtual f32 height() const = 0;
/// Distance from the top of the cell to the baseline, so a caller can align
/// text bottoms instead of cell bottoms.
virtual f32 ascent() const = 0;
};
} // namespace plot::render
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/handle.hpp>
#include <plot/render/buffer.hpp>
namespace plot::render {
/// Vertex array plus instance buffer of the line pipeline.
///
/// The attribute layout is fixed by the line shader (see shaders.hpp): `a_p0` at
/// location 0 and `a_p1` at location 1, two floats each, advancing once per
/// instance. A caller never writes a location, stride or offset; it stages a
/// `LineBatch` and uploads its `data()`.
///
/// The instance buffer may sit behind either transfer strategy (see
/// `UploadMode`). Only the mapped one moves the instances between slots, so
/// `upload()` re-points the attributes when `InstanceStore::epoch()` changes and
/// otherwise leaves them alone; the caller never has to know which path is live.
class LineVertexArray {
public:
/// Creates the vertex array, the instance buffer and the attribute layout.
/// `mode` selects the instance buffer's transfer strategy and defaults to the
/// GL 3.3 baseline; a caller asking for `UploadMode::PersistentMapped` must
/// have checked the context can do it (see `Renderer::init()`).
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept;
/// Uploads `segments` instances from interleaved `x0,y0,x1,y1` floats and
/// records how many the next draw should rasterise.
void upload(const f32* interleaved, u32 segments) noexcept;
void bind() const noexcept { vao_.bind(); }
/// Waits, once, for the previous frame's draws to retire the ring before any
/// slot of this frame is written (see `InstanceStore::begin_frame`). False when
/// the store had to drop to the baseline path; the caller reports it.
bool begin_frame() noexcept { return buffer_.begin_frame(); }
/// Records the frame's single fence, behind every draw that read the ring (see
/// `InstanceStore::mark_drawn`); called once per frame, after its last draw.
/// False only when the driver refused the fence: the buffer has then fallen
/// back to the baseline path, and `persistent()` says so from the next call on.
bool mark_drawn() noexcept { return buffer_.mark_drawn(); }
/// True while this array's instance buffer is the mapped ring (see
/// `UploadMode`); a failure that falls back clears it.
bool persistent() const noexcept { return buffer_.persistent(); }
bool valid() const noexcept { return vao_.id() != 0; }
u32 instance_count() const noexcept { return instances_; }
private:
LineBuffer buffer_;
gl::VertexArray vao_;
u32 instances_ = 0;
/// Instance-buffer generation the attribute pointers currently point into.
u32 epoch_ = 0;
};
/// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0
/// and the glyph's atlas texel origin `a_uv` at location 1, two floats each,
/// advancing once per instance.
class TextVertexArray {
public:
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept;
/// Uploads `glyphs` instances from interleaved `x,y,u,v` floats.
void upload(const f32* interleaved, u32 glyphs) noexcept;
void bind() const noexcept { vao_.bind(); }
/// The line pipeline's frame pair, for the text store (see
/// `LineVertexArray::begin_frame` / `mark_drawn`).
bool begin_frame() noexcept { return buffer_.begin_frame(); }
bool mark_drawn() noexcept { return buffer_.mark_drawn(); }
bool persistent() const noexcept { return buffer_.persistent(); }
bool valid() const noexcept { return vao_.id() != 0; }
u32 instance_count() const noexcept { return instances_; }
private:
TextBuffer buffer_;
gl::VertexArray vao_;
u32 instances_ = 0;
u32 epoch_ = 0;
};
} // namespace plot::render
+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
+68
View File
@@ -0,0 +1,68 @@
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <cstddef>
#include <utility>
namespace plot::data {
ArraySeries::ArraySeries(std::vector<f32> y, f64 x0, f64 dx) { reset(std::move(y), x0, dx); }
void ArraySeries::reset(std::vector<f32> y, f64 x0, f64 dx) {
y_ = std::move(y);
x0_ = x0;
dx_ = dx;
commit();
}
void ArraySeries::commit() {
const u64 n = static_cast<u64>(y_.size());
pyramid_.clear();
pyramid_.reserve(n);
f32 lo = 0.0f, hi = 0.0f;
bool first = true;
for (const f32 v : y_) {
pyramid_.append(v);
if (first) {
lo = v;
hi = v;
first = false;
} else {
lo = std::min(lo, v);
hi = std::max(hi, v);
}
}
y_bounds_ = first ? Range{0.0, 0.0} : Range{f64(lo), f64(hi)};
x_bounds_ = n == 0 ? Range{x0_, x0_} : Range{x0_, x0_ + f64(n - 1) * dx_};
}
u64 ArraySeries::size() const { return static_cast<u64>(y_.size()); }
f64 ArraySeries::x0() const { return x0_; }
f64 ArraySeries::dx() const { return dx_; }
Range ArraySeries::x_bounds() const { return x_bounds_; }
Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
}
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
const u64 want = end > begin ? end - begin : 0;
if (want == 0) return;
const u64 n = static_cast<u64>(y_.size());
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
const auto first = y_.begin();
std::copy(first + static_cast<std::ptrdiff_t>(b), first + static_cast<std::ptrdiff_t>(e), out);
std::fill(out + static_cast<std::ptrdiff_t>(e - b),
out + static_cast<std::ptrdiff_t>(want), 0.0f);
}
} // namespace plot::data
+285
View File
@@ -0,0 +1,285 @@
#include <plot/data/detail/cover_count.hpp>
#include <plot/data/detail/merge_count.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace plot::data {
namespace {
/// Samples covered by one level-l bucket.
constexpr u64 level_span(u32 l) { return u64{kBucket} << l; }
/// Level-l buckets needed to hold n samples (ceiling division).
constexpr u64 buckets_for(u64 n, u32 l) {
const u64 span = level_span(l);
return n / span + (n % span == 0 ? u64{0} : u64{1});
}
/// Ceiling division: first level-l bucket whose span starts at or after v.
constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); }
/// First level-0 bucket boundary at or after v.
constexpr u64 ceil_to_bucket(u64 v) { return ceil_div(v, kBucket) * kBucket; }
/// Canonical, coarsest-first decomposition of the level-0 bucket span [b0, b1)
/// into pyramid entries, calling fn(level, absolute_bucket_index) for each.
///
/// A level-l entry is emitted only when the whole level-l bucket exists (all
/// kBucket << l samples are present, so its aggregate is final). Callers
/// guarantee b0 >= the first whole bucket of the window and b1 <= the number of
/// complete buckets, so every emission both exists and lies inside the window.
///
/// The level is chosen per *emission*, not once per pass: at each cursor the
/// largest level is taken whose bucket is aligned with the cursor, lies wholly
/// inside [b0, b1) and has already been materialised. A cursor that starts
/// misaligned (an odd first whole bucket, i.e. half of all measurement ranges)
/// therefore burns at most one level-0 bucket and then resumes the coarse
/// levels, so the decomposition is at most two entries per level and every query
/// below is O(log n) for any start. Choosing the level once per pass instead left
/// the level-0 loop to consume the whole remainder one bucket at a time, because
/// no coarser level is ever reachable from an odd cursor.
template <typename Fn>
void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) {
// One past the last complete level-0 bucket: levels sit on top of those, so
// this also bounds every coarser bucket the cursor may take.
const u64 limit = std::min(b1, p.level_base(0) + p.level_size(0));
const u32 top = p.level_count() - 1; // level_count() is at least 1
u64 b = b0;
while (b < b1) {
u32 li = top;
for (;;) {
const u64 lb = u64{1} << li;
const u64 i = b >> li;
if ((b % lb) == 0 && b + lb <= limit && i >= p.level_base(li) &&
i - p.level_base(li) < p.level_size(li)) {
break;
}
if (li == 0) break;
--li;
}
#ifdef PLOT_PYRAMID_COUNT_COVERS
detail::cover_entry_count().fetch_add(1, std::memory_order_relaxed);
#endif
fn(li, b >> li);
b += u64{1} << li;
}
}
/// Folds the decomposition of [b0, b1) into a min/max Range. Returns a
/// zero-span Range when there is nothing to fold.
Range fold_range(const MinMaxPyramid& p, u64 b0, u64 b1) {
Range r{};
bool first = true;
cover_buckets(p, b0, b1, [&](u32 l, u64 i) {
const MinMax m = p.bucket(l, i);
if (first) {
r.min = f64(m.min);
r.max = f64(m.max);
first = false;
} else {
r.min = std::min(r.min, f64(m.min));
r.max = std::max(r.max, f64(m.max));
}
});
return first ? Range{0.0, 0.0} : r;
}
} // namespace
void MinMaxPyramid::add_level() { levels_.emplace_back(); }
void MinMaxPyramid::reserve(u64 n) {
// Materialise every level n samples can need -- level 0 plus one per doubling
// of the bucket -- so a run of appends never grows the level vector. See the
// header for why the bucket deques themselves cannot be pre-sized.
u32 want = 1;
for (u64 span = level_span(0); span <= n && want < 63; span <<= 1) ++want;
if (levels_.size() < want) levels_.resize(want);
}
void MinMaxPyramid::push(u32 l, u64 k, const Entry& e) {
Level& lv = levels_[l];
// The first entry of a level fixes its base; every later entry is the next
// absolute bucket, which is what keeps a level one contiguous run.
if (lv.entries.empty()) lv.base = k;
lv.entries.push_back(e);
#ifdef PLOT_PYRAMID_COUNT_MERGES
detail::merge_count().fetch_add(1, std::memory_order_relaxed);
#endif
}
void MinMaxPyramid::append(f32 v) {
const f64 vd = f64(v);
// The new sample always lands in the in-progress level-0 bucket, which is the
// only bucket any append ever touches: a bucket becomes an entry exactly once,
// complete, and is never rewritten.
if (count_ % kBucket == 0) {
pending_.mm.min = v;
pending_.mm.max = v;
pending_.sum = vd;
pending_.sumsq = vd * vd;
} else {
pending_.mm.min = std::min(pending_.mm.min, v);
pending_.mm.max = std::max(pending_.mm.max, v);
pending_.sum += vd;
pending_.sumsq += vd * vd;
}
++count_;
if (count_ % kBucket != 0) return; // the bucket is still partial: nothing to push
const u64 k0 = count_ / kBucket - 1; // the level-0 bucket that just completed
if (k0 < level_base(0)) return; // its span starts before the window: never read
const Entry done = pending_;
push(0, k0, done);
// Cascade: level l gains an entry exactly when kBucket << l divides the sample
// count, so the walk stops at the first level that gained nothing -- amortised
// O(1) per sample (1/kBucket + 1/(2*kBucket) + ... = 1/(kBucket/2) merges).
for (u32 l = 1; l < 63; ++l) {
const u64 span = level_span(l);
if (count_ % span != 0) break;
const u64 k = count_ / span - 1; // the level-l bucket that just completed
const u64 child = 2 * k; // its left child, at level l-1
if (child < level_base(l - 1)) break; // a child is gone: this parent is not retained
if (l == static_cast<u32>(levels_.size())) add_level();
if (l >= live_levels_) live_levels_ = l + 1;
const Entry& a = at(l - 1, child);
const Entry& b = at(l - 1, child + 1);
Entry m;
m.mm.min = std::min(a.mm.min, b.mm.min);
m.mm.max = std::max(a.mm.max, b.mm.max);
m.sum = a.sum + b.sum;
m.sumsq = a.sumsq + b.sumsq;
push(l, k, m);
}
}
void MinMaxPyramid::drop_before(u64 absolute_sample) {
if (absolute_sample <= base_) return;
if (absolute_sample > count_) {
// The producer produced more than the ring held between two acquires: every
// appended sample is gone and the ones in between were never appended, so the
// pyramid restarts empty at the new base rather than splicing a gap.
base_ = absolute_sample;
count_ = absolute_sample;
pending_ = Entry{};
live_levels_ = 1;
for (u32 l = 0; l < levels_.size(); ++l) {
levels_[l].entries.clear();
levels_[l].base = ceil_div(absolute_sample, level_span(l));
}
return;
}
base_ = absolute_sample;
for (u32 l = 0; l < levels_.size(); ++l) {
Level& lv = levels_[l];
// A bucket is dropped once its span starts before the window: the first
// retained bucket of every level is the first one wholly inside the window,
// which is exactly what the queries assume.
const u64 keep = ceil_div(base_, level_span(l));
if (keep <= lv.base) continue;
const u64 have = static_cast<u64>(lv.entries.size());
const u64 drop = std::min(keep - lv.base, have);
lv.entries.erase(lv.entries.begin(),
lv.entries.begin() + static_cast<std::ptrdiff_t>(drop));
// The base is where the next entry goes: past the last one if the level was
// emptied, which is how the cursor catches up with the sliding window.
if (drop == have) {
lv.base = keep;
} else {
lv.base += drop;
}
}
// The in-progress level-0 bucket may now start before the window's base: its
// aggregate covers samples that are gone. It belongs to the bucket the window
// starts inside, which is never read (the queries take whole buckets and the
// Series scans the two partial edges raw), so discarding it is lossless.
if (ceil_div(base_, kBucket) > count_ / kBucket) pending_ = Entry{};
while (live_levels_ > 1 && level_size(live_levels_ - 1) == 0) --live_levels_;
}
void MinMaxPyramid::clear() {
count_ = 0;
base_ = 0;
live_levels_ = 1;
levels_.resize(1);
levels_[0].entries.clear();
levels_[0].base = 0;
pending_ = Entry{};
}
u64 MinMaxPyramid::level_base(u32 l) const noexcept {
return l < levels_.size() ? levels_[l].base : u64{0};
}
u64 MinMaxPyramid::level_size(u32 l) const noexcept {
return l < levels_.size() ? static_cast<u64>(levels_[l].entries.size()) : u64{0};
}
MinMax MinMaxPyramid::bucket(u32 l, u64 i) const { return at(l, i).mm; }
Range MinMaxPyramid::reduce_buckets(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return Range{0.0, 0.0};
// The bucket grid is absolute while these are window coordinates: window index
// i is absolute window_base() + i, so the first whole bucket inside the range is
// the first absolute bucket boundary at or after that.
const u64 b0 = ceil_to_bucket(base_ + begin) / kBucket;
const u64 b1 = (base_ + e) / kBucket;
if (b1 <= b0) return Range{0.0, 0.0};
return fold_range(*this, b0, b1);
}
f64 MinMaxPyramid::sum_buckets(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return 0.0;
const u64 b0 = ceil_to_bucket(base_ + begin) / kBucket;
const u64 b1 = (base_ + e) / kBucket;
if (b1 <= b0) return 0.0;
f64 total = 0.0;
cover_buckets(*this, b0, b1, [&](u32 l, u64 i) { total += at(l, i).sum; });
return total;
}
f64 MinMaxPyramid::sumsq_buckets(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return 0.0;
const u64 b0 = ceil_to_bucket(base_ + begin) / kBucket;
const u64 b1 = (base_ + e) / kBucket;
if (b1 <= b0) return 0.0;
f64 total = 0.0;
cover_buckets(*this, b0, b1, [&](u32 l, u64 i) { total += at(l, i).sumsq; });
return total;
}
u32 MinMaxPyramid::level_for(u64 n, f64 samples_per_pixel) const {
// The ratio test also rejects NaN, and the clamp keeps a huge (or infinite)
// samples-per-pixel from making the floor-to-u32 conversion ill-formed.
const f64 ratio = samples_per_pixel / f64(kBucket);
if (n == 0 || !(ratio > 1.0)) return 0;
const f64 lg = std::floor(std::log2(ratio));
u32 l = lg >= f64(62) ? 62u : static_cast<u32>(lg);
while (l > 0) {
const bool exists = l < level_count() && level_size(l) >= 2;
if (exists && buckets_for(n, l) >= 2) break;
--l;
}
return l;
}
u64 MinMaxPyramid::snap(f64 x, f64 x0, f64 dx) const {
if (dx <= 0.0 || size() == 0) return 0;
const f64 i = (x - x0) / dx;
// Bound the round before it: llround() is undefined for values a long long
// cannot hold, and this also maps NaN to index 0.
if (!(i > 0.0)) return 0;
if (i >= f64(size())) return size() - 1;
const u64 idx = static_cast<u64>(std::llround(i));
return idx >= size() ? size() - 1 : idx;
}
} // namespace plot::data
+188
View File
@@ -0,0 +1,188 @@
#include <plot/data/series.hpp>
#include <algorithm>
#include <cstddef>
namespace plot::data {
namespace {
/// Raw samples read per copy_raw() call while gathering level 0. Bounded so the
/// gather path never allocates, and large enough to amortise the call.
constexpr u64 kGatherChunk = 256;
} // namespace
Series::QueryParts Series::split_query(u64 begin, u64 end) const {
QueryParts p;
// The pyramid's buckets are absolute while these are window coordinates, so its
// level-0 boundaries sit at window index `window_base() rounded up to the next
// multiple of kBucket`, and every kBucket after that. Align the split in
// absolute coordinates and come back to the window: aligning in window
// coordinates would treat index 0 as a boundary and, for a window that slid
// into the middle of a bucket, route samples through the pyramid that it does
// not cover.
const u64 origin = pyramid().window_base();
const u64 a_begin = origin + begin;
const u64 a_end = origin + end;
const u64 up = (a_begin / kBucket + (a_begin % kBucket == 0 ? u64{0} : u64{1})) * kBucket;
const u64 down = (a_end / kBucket) * kBucket;
if (up < down) {
p.interior_begin = up - origin; // up >= a_begin >= origin
p.interior_end = down - origin;
}
// The two edge spans never overlap and never exceed one bucket each: when the
// interior is empty they tile [begin, end) together, otherwise they are the
// head and tail buckets half inside the range.
p.left_begin = begin;
p.left_end = std::min(end, up - origin);
p.right_begin = std::max(down > origin ? down - origin : u64{0}, p.left_end);
p.right_end = end;
return p;
}
u64 Series::copy_edges(const QueryParts& p, f32* out) const {
u64 n = 0;
if (p.left_begin < p.left_end) {
copy_raw(p.left_begin, p.left_end, out);
n = p.left_end - p.left_begin;
}
if (p.right_begin < p.right_end) {
copy_raw(p.right_begin, p.right_end, out + static_cast<std::ptrdiff_t>(n));
n += p.right_end - p.right_begin;
}
return n;
}
Range Series::reduce(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return Range{0.0, 0.0};
const QueryParts p = split_query(begin, e);
Range r{};
bool first = true;
if (p.interior_begin < p.interior_end) {
r = pyramid().reduce_buckets(p.interior_begin, p.interior_end);
first = false;
}
// At most 2*kBucket - 1 edge samples, so the buffer is enough without
// allocating: each edge span is under one bucket, and when the interior is
// empty the two spans tile a range shorter than two buckets.
f32 edge[2 * kBucket];
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
if (first) {
r.min = v;
r.max = v;
first = false;
} else {
r.min = std::min(r.min, v);
r.max = std::max(r.max, v);
}
}
return first ? Range{0.0, 0.0} : r;
}
f64 Series::sum(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return 0.0;
const QueryParts p = split_query(begin, e);
f64 total = 0.0;
if (p.interior_begin < p.interior_end) {
total = pyramid().sum_buckets(p.interior_begin, p.interior_end);
}
f32 edge[2 * kBucket];
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) total += f64(edge[i]);
return total;
}
f64 Series::sumsq(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return 0.0;
const QueryParts p = split_query(begin, e);
f64 total = 0.0;
if (p.interior_begin < p.interior_end) {
total = pyramid().sumsq_buckets(p.interior_begin, p.interior_end);
}
f32 edge[2 * kBucket];
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
total += v * v;
}
return total;
}
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
const f64 x_0 = x0();
const f64 step = dx();
f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(x_0 + f64(i + k) * step));
out_xy.push_back(raw[k]);
}
i += take;
}
}
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
if (level == 0) {
gather_raw(b, e, out_xy);
return;
}
// Level >= 1: one envelope pair per *retained* bucket the range overlaps, x at
// the bucket's first sample (min) and at its last sample (max), which keeps x
// monotonic while covering the bucket's span. The pyramid's buckets are
// absolute, so map the window range onto its grid and clamp to the buckets that
// are both complete and wholly inside the window: a bucket that is only partly
// retained aggregates samples the window no longer has, and a partial trailing
// bucket is not stored at all.
//
// Those two excluded buckets are still part of the range, and one of them holds
// the newest samples -- for a live trace, the part being watched. They are drawn
// from the raw samples instead: at most one bucket at each end, about a pixel
// each at the level level_for() picks, so the envelope path stays O(buckets)
// and the geometry still spans the requested range exactly.
const f64 x_0 = x0();
const f64 step = dx();
const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level);
const u64 lend = lbase + pyramid().level_size(level);
const u64 first = std::max((origin + b) / span, lbase);
const u64 last = std::min((origin + e - 1) / span + 1, lend);
if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed
// into one bucket -- so the raw samples are the only exact geometry there is.
gather_raw(b, e, out_xy);
return;
}
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
for (u64 bi = first; bi < last; ++bi) {
const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(x_0 + f64(first_sample) * step));
out_xy.push_back(m.min);
out_xy.push_back(f32(x_0 + f64(last_sample) * step));
out_xy.push_back(m.max);
}
const u64 trail = last * span - origin; // one past the last emitted bucket's span
if (trail < e) gather_raw(trail, e, out_xy);
}
} // namespace plot::data
+93
View File
@@ -0,0 +1,93 @@
#include <plot/data/stream_series.hpp>
#include <algorithm>
#include <cstddef>
namespace plot::data {
namespace {
/// Samples copied per ring read on the ingest path, so appending the samples
/// produced between two frames costs no allocation.
constexpr u64 kReadChunk = 256;
} // namespace
StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx)
: ring_(capacity), x0_(x0), dx_(dx) {
// Size the pyramid's level table for a full window up front: the ring never
// holds more than capacity() samples, so appending never grows it.
pyramid_.reserve(capacity);
}
void StreamSeries::push(f32 y) { ring_.push(y); }
void StreamSeries::push(const f32* ys, u64 n) { ring_.push(ys, n); }
u64 StreamSeries::produced() const { return ring_.produced(); }
void StreamSeries::acquire() {
// One atomic read: every absolute index below is relative to this single count,
// so the producer may keep pushing without the snapshot shifting under us.
const u64 p = ring_.produced();
const u64 cap = ring_.capacity();
const u64 base = p > cap ? p - cap : 0; // oldest index the ring still holds
// Slide the pyramid's window: the buckets that covered samples the ring has
// overwritten are dropped, and the rest stay exactly as they were built --
// their boundaries are absolute, so nothing has to be recomputed. This is also
// where a producer that lapped the consumer by more than the ring holds is
// handled: the pyramid restarts at the new base.
pyramid_.drop_before(base);
// Append exactly the samples produced since the last acquire(). Normally that
// is one frame's worth; after a lapped producer it is the whole window.
f32 chunk[kReadChunk];
for (u64 a = pyramid_.window_end(); a < p;) {
const u64 take = std::min<u64>(kReadChunk, p - a);
ring_.copy_from(a, take, chunk);
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
a += take;
}
// Exact bounds for the published window, in the same O(log n + kBucket) as any
// other exact query over it: the bucket-aligned interior comes from the
// pyramid and the two partial edge spans are scanned through copy_raw(). An
// incrementally extended bound cannot work here -- it would have to know
// whether the extreme it is holding has just slid out of the window.
y_bounds_ = reduce(0, size());
}
u64 StreamSeries::size() const { return pyramid_.size(); }
f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; }
f64 StreamSeries::dx() const { return dx_; }
Range StreamSeries::x_bounds() const {
const f64 n = f64(size());
return n == 0.0 ? Range{x0(), x0()} : Range{x0(), x0() + (n - 1.0) * dx_};
}
Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
}
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
const u64 want = end > begin ? end - begin : 0;
if (want == 0) return;
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
// Retained index b is absolute window_base() + b: fixed for the whole copy, so
// a pushing producer cannot shift the window midway (the pyramid is written by
// this thread alone, so its window cannot move under the read either).
ring_.copy_from(pyramid_.window_base() + b, e - b, out);
std::fill(out + static_cast<std::ptrdiff_t>(e - b),
out + static_cast<std::ptrdiff_t>(want), 0.0f);
}
} // namespace plot::data
+409
View File
@@ -0,0 +1,409 @@
#include <plot/gl/handle.hpp>
#include <plot/gl/loader.hpp>
#include <cstdint>
#include <cstdio>
namespace plot::gl {
namespace {
constexpr const char* kNoLoader = "plot::gl::load() has not been called";
/// `Status::message` is a non-owning `const char*`, so the text of a failure
/// lives here: valid until the next failure reported on the same thread.
thread_local char g_message[1024];
bool fail(Status* st, ErrorCode code, const char* text) noexcept {
if (st != nullptr) {
std::snprintf(g_message, sizeof g_message, "%s", text);
st->code = code;
st->message = g_message;
}
return false;
}
/// As `fail()`, but appends driver text (a shader or program info log).
bool fail_log(Status* st, const char* what, const char* log) noexcept {
if (st != nullptr) {
std::snprintf(g_message, sizeof g_message, "%s: %s", what, log != nullptr ? log : "");
st->code = ErrorCode::GLFailure;
st->message = g_message;
}
return false;
}
/// Compiles one stage and returns its shader name, or 0 on failure (the reason
/// is already in `st`). The shader is deleted if it fails to compile.
GLuint compile_shader(GLenum stage, const char* source, const char* what, Status* st) noexcept {
const GLuint shader = glCreateShader(stage);
if (shader == 0) {
fail(st, ErrorCode::GLFailure, "glCreateShader returned 0");
return 0;
}
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == 0) {
char log[1024] = {};
GLsizei written = 0;
glGetShaderInfoLog(shader, static_cast<GLsizei>(sizeof log - 1), &written, log);
glDeleteShader(shader);
fail_log(st, what, log);
return 0;
}
return shader;
}
/// Allocates 2D storage with the sampling state every texture here shares:
/// linear filtering, clamped edges. Returns the name, or 0 if the driver
/// handed out none.
GLuint alloc_texture(GLenum internal_format, GLenum format, u32 width, u32 height) noexcept {
GLuint name = 0;
glGenTextures(1, &name);
if (name == 0) {
return 0;
}
glBindTexture(GL_TEXTURE_2D, name);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(internal_format), static_cast<GLsizei>(width),
static_cast<GLsizei>(height), 0, format, GL_UNSIGNED_BYTE, nullptr);
return name;
}
} // namespace
// ------------------------------------------------------------------- Buffer
bool Buffer::create(Status* st) noexcept {
if (!available()) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (glGenBuffers == nullptr) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
reset();
GLuint name = 0;
glGenBuffers(1, &name);
if (name == 0) {
return fail(st, ErrorCode::GLFailure, "glGenBuffers returned 0");
}
id_ = name;
note_allocated();
return true;
}
void Buffer::bind(GLenum target) const noexcept { glBindBuffer(target, id_); }
void Buffer::upload(const void* data, usize bytes, GLenum usage) const noexcept {
bind();
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(bytes), data, usage);
}
void Buffer::upload_sub(const void* data, usize bytes, usize offset) const noexcept {
bind();
glBufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(offset), static_cast<GLsizeiptr>(bytes),
data);
}
void Buffer::storage(usize bytes, GLbitfield flags) const noexcept {
if (glBufferStorage == nullptr) {
return; // not resolved on this driver: the caller checked caps() first
}
bind();
glBufferStorage(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(bytes), nullptr, flags);
}
void* Buffer::map_range(usize offset, usize bytes, GLbitfield access) const noexcept {
bind();
return glMapBufferRange(GL_ARRAY_BUFFER, static_cast<GLintptr>(offset),
static_cast<GLsizeiptr>(bytes), access);
}
bool Buffer::unmap() const noexcept {
bind();
return glUnmapBuffer(GL_ARRAY_BUFFER) == GL_TRUE;
}
void Buffer::destroy(GLuint id) noexcept { glDeleteBuffers(1, &id); }
// -------------------------------------------------------------- VertexArray
bool VertexArray::create(Status* st) noexcept {
if (!available()) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (glGenVertexArrays == nullptr) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
reset();
GLuint name = 0;
glGenVertexArrays(1, &name);
if (name == 0) {
return fail(st, ErrorCode::GLFailure, "glGenVertexArrays returned 0");
}
id_ = name;
note_allocated();
return true;
}
void VertexArray::bind() const noexcept { glBindVertexArray(id_); }
void VertexArray::attrib(u32 index, i32 components, usize stride, usize offset) const noexcept {
const GLuint attrib = static_cast<GLuint>(index);
glEnableVertexAttribArray(attrib);
glVertexAttribPointer(attrib, static_cast<GLint>(components), GL_FLOAT, GL_FALSE,
static_cast<GLsizei>(stride),
reinterpret_cast<const void*>(static_cast<std::uintptr_t>(offset)));
}
void VertexArray::destroy(GLuint id) noexcept { glDeleteVertexArrays(1, &id); }
// ------------------------------------------------------------------ Program
Program Program::from_source(const char* vertex_src, const char* fragment_src,
Status* st) noexcept {
Program program;
if (!available()) {
fail(st, ErrorCode::Unsupported, kNoLoader);
return program;
}
if (glCreateShader == nullptr || glCreateProgram == nullptr) {
fail(st, ErrorCode::Unsupported, kNoLoader);
return program;
}
if (vertex_src == nullptr || fragment_src == nullptr) {
fail(st, ErrorCode::InvalidArgument, "shader source must not be null");
return program;
}
const GLuint vertex = compile_shader(GL_VERTEX_SHADER, vertex_src, "vertex shader failed", st);
if (vertex == 0) {
return program;
}
const GLuint fragment =
compile_shader(GL_FRAGMENT_SHADER, fragment_src, "fragment shader failed", st);
if (fragment == 0) {
glDeleteShader(vertex);
return program;
}
const GLuint name = glCreateProgram();
if (name == 0) {
glDeleteShader(vertex);
glDeleteShader(fragment);
fail(st, ErrorCode::GLFailure, "glCreateProgram returned 0");
return program;
}
glAttachShader(name, vertex);
glAttachShader(name, fragment);
glLinkProgram(name);
// The program owns the linked code from here on, so the shader objects can
// be detached and deleted.
glDetachShader(name, vertex);
glDetachShader(name, fragment);
glDeleteShader(vertex);
glDeleteShader(fragment);
GLint linked = 0;
glGetProgramiv(name, GL_LINK_STATUS, &linked);
if (linked == 0) {
char log[1024] = {};
GLsizei written = 0;
glGetProgramInfoLog(name, static_cast<GLsizei>(sizeof log - 1), &written, log);
glDeleteProgram(name);
fail_log(st, "program link failed", log);
return program;
}
program.id_ = name;
program.note_allocated();
return program;
}
void Program::use() const noexcept { glUseProgram(id_); }
void Program::set_uniform(const char* name, f32 value) const noexcept {
if (id_ == 0 || name == nullptr) {
return;
}
const GLint location = glGetUniformLocation(id_, name);
if (location >= 0) {
glUniform1f(location, value);
}
}
void Program::set_uniform(const char* name, Vec2 value) const noexcept {
if (id_ == 0 || name == nullptr) {
return;
}
const GLint location = glGetUniformLocation(id_, name);
if (location >= 0) {
glUniform2f(location, value.x, value.y);
}
}
void Program::set_uniform(const char* name, Color value) const noexcept {
if (id_ == 0 || name == nullptr) {
return;
}
const GLint location = glGetUniformLocation(id_, name);
if (location >= 0) {
glUniform4f(location, value.r, value.g, value.b, value.a);
}
}
void Program::set_uniform_int(const char* name, i32 value) const noexcept {
if (id_ == 0 || name == nullptr) {
return;
}
const GLint location = glGetUniformLocation(id_, name);
if (location >= 0) {
glUniform1i(location, value);
}
}
void Program::destroy(GLuint id) noexcept { glDeleteProgram(id); }
// ------------------------------------------------------------------ Texture
bool Texture::create_rgba(u32 width, u32 height, Status* st) noexcept {
if (!available()) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (glGenTextures == nullptr) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (width == 0 || height == 0) {
return fail(st, ErrorCode::InvalidArgument, "texture size must be non-zero");
}
reset();
const GLuint name = alloc_texture(GL_RGBA8, GL_RGBA, width, height);
if (name == 0) {
return fail(st, ErrorCode::GLFailure, "glGenTextures returned 0");
}
id_ = name;
note_allocated();
return true;
}
bool Texture::create_r8(u32 width, u32 height, Status* st) noexcept {
if (!available()) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (glGenTextures == nullptr) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (width == 0 || height == 0) {
return fail(st, ErrorCode::InvalidArgument, "texture size must be non-zero");
}
reset();
const GLuint name = alloc_texture(GL_R8, GL_RED, width, height);
if (name == 0) {
return fail(st, ErrorCode::GLFailure, "glGenTextures returned 0");
}
id_ = name;
note_allocated();
return true;
}
void Texture::upload_rgba(u32 width, u32 height, const void* pixels) const noexcept {
glBindTexture(GL_TEXTURE_2D, id_);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(GL_RGBA8), static_cast<GLsizei>(width),
static_cast<GLsizei>(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
}
void Texture::upload_r8(u32 width, u32 height, const void* pixels) const noexcept {
glBindTexture(GL_TEXTURE_2D, id_);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // single-channel rows have no padding
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(GL_R8), static_cast<GLsizei>(width),
static_cast<GLsizei>(height), 0, GL_RED, GL_UNSIGNED_BYTE, pixels);
}
void Texture::bind(GLenum unit) const noexcept {
glActiveTexture(unit);
glBindTexture(GL_TEXTURE_2D, id_);
}
void Texture::set_filter(GLenum min, GLenum mag) const noexcept {
glBindTexture(GL_TEXTURE_2D, id_);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, static_cast<GLint>(min));
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, static_cast<GLint>(mag));
}
void Texture::destroy(GLuint id) noexcept { glDeleteTextures(1, &id); }
// -------------------------------------------------------------- Framebuffer
bool Framebuffer::create(Status* st) noexcept {
if (!available()) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (glGenFramebuffers == nullptr) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
reset();
GLuint name = 0;
glGenFramebuffers(1, &name);
if (name == 0) {
return fail(st, ErrorCode::GLFailure, "glGenFramebuffers returned 0");
}
id_ = name;
note_allocated();
return true;
}
void Framebuffer::bind(GLenum target) const noexcept { glBindFramebuffer(target, id_); }
void Framebuffer::attach_color(const Texture& texture) const noexcept {
bind();
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture.id(), 0);
}
bool Framebuffer::complete() const noexcept {
bind();
return glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
}
void Framebuffer::destroy(GLuint id) noexcept { glDeleteFramebuffers(1, &id); }
// -------------------------------------------------------------------- Fence
bool Fence::create(Status* st) noexcept {
if (!available()) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
if (glFenceSync == nullptr) {
return fail(st, ErrorCode::Unsupported, kNoLoader);
}
reset();
const GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
if (sync == nullptr) {
return fail(st, ErrorCode::GLFailure, "glFenceSync returned no sync object");
}
id_ = sync;
return true;
}
bool Fence::wait(u64 timeout_ns) const noexcept {
if (id_ == nullptr) {
return true; // nothing in flight
}
const GLenum result = glClientWaitSync(id_, GL_SYNC_FLUSH_COMMANDS_BIT, timeout_ns);
return result == GL_ALREADY_SIGNALED || result == GL_CONDITION_SATISFIED;
}
void Fence::reset() noexcept {
if (id_ != nullptr) {
glDeleteSync(id_);
id_ = nullptr;
}
}
} // namespace plot::gl
+208
View File
@@ -0,0 +1,208 @@
#include <plot/gl/loader.hpp>
#include <plot/gl/caps.hpp>
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
// Debug-only diagnostic for the first entry point the loader cannot resolve;
// the failure itself is reported through load()'s return value, so this never
// aborts a release build or a test run.
#ifndef NDEBUG
#define PLOT_ASSERT(cond, message) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "plot::gl::load: cannot resolve %s (%s:%d)\n", (message), \
__FILE__, __LINE__); \
} \
} while (false)
#else
#define PLOT_ASSERT(cond, message) ((void)0)
#endif
namespace plot::gl {
// The one definition of the entry point table declared in api.hpp.
#define PLOT_GL_DEF(ret, name, args) ret (*name) args = nullptr;
PLOT_GL_FUNCS(PLOT_GL_DEF)
PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_DEF)
#undef PLOT_GL_DEF
namespace {
/// False until a `load()` has resolved every required entry point of the table
/// above, which may stay partially populated until then. Read via available().
bool g_table_ready = false;
/// The system GL library, opened on the first name that neither the caller's
/// resolver nor the global namespace can supply. Never closed: the entry
/// points handed out must stay valid for the whole process.
void* system_gl() noexcept {
static void* lib = nullptr;
static bool opened = false;
if (!opened) {
opened = true;
lib = dlopen("libGL.so.1", RTLD_LAZY | RTLD_LOCAL);
}
return lib;
}
void* resolve(ProcLoader loader, const char* name) noexcept {
if (loader != nullptr) {
if (void* symbol = loader(name)) {
return symbol;
}
}
if (void* symbol = dlsym(RTLD_DEFAULT, name)) {
return symbol;
}
if (void* lib = system_gl()) {
return dlsym(lib, name);
}
return nullptr;
}
// ---------------------------------------------------------------- caps probe
Caps g_caps;
bool g_caps_valid = false;
const char* gl_string(GLenum name) noexcept {
const char* text = reinterpret_cast<const char*>(glGetString(name));
return text != nullptr ? text : "";
}
/// True when `all` (the legacy space-separated GL_EXTENSIONS string) contains
/// `name` as a whole token.
bool has_token(const char* all, const char* name) noexcept {
if (all == nullptr || name == nullptr) {
return false;
}
const usize len = std::strlen(name);
for (const char* hit = std::strstr(all, name); hit != nullptr; hit = std::strstr(hit + len, name)) {
const bool at_start = hit == all || hit[-1] == ' ';
const bool at_end = hit[len] == '\0' || hit[len] == ' ';
if (at_start && at_end) {
return true;
}
}
return false;
}
/// Tests for an extension in the form the current context exposes it: the
/// indexed list on 3.0+, the legacy string before that (where
/// `glGetString(GL_EXTENSIONS)` is still legal).
bool has_extension(int major, const char* name) noexcept {
if (major >= 3) {
if (glGetStringi == nullptr) {
return false; // indexed list unavailable: the legacy string is illegal on 3.0+ core
}
GLint count = 0;
glGetIntegerv(GL_NUM_EXTENSIONS, &count);
for (GLint i = 0; i < count; ++i) {
const char* ext =
reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, static_cast<GLuint>(i)));
if (ext != nullptr && std::strcmp(ext, name) == 0) {
return true;
}
}
return false;
}
// Before 3.0 the legacy space-separated string is the only enumeration.
return has_token(reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS)), name);
}
/// Fills `out` from the context that is current on this thread. Returns false
/// when there is no usable context, leaving `out` untouched.
bool query_caps(Caps& out) noexcept {
if (glGetString == nullptr || glGetIntegerv == nullptr) {
return false;
}
const char* version = reinterpret_cast<const char*>(glGetString(GL_VERSION));
if (version == nullptr) {
return false; // no current context
}
int major = 0;
int minor = 0;
// Drivers report e.g. "4.6 (Compatibility Profile) Mesa 25.2.0"; only the
// leading "major.minor" is part of the specification.
if (std::sscanf(version, "%d.%d", &major, &minor) != 2) {
return false;
}
out.major = major;
out.minor = minor;
out.renderer = gl_string(GL_RENDERER);
out.vendor = gl_string(GL_VENDOR);
const bool has_44 = major > 4 || (major == 4 && minor >= 4);
out.compute = major > 4 || (major == 4 && minor >= 3);
const bool storage = has_44 || has_extension(major, "GL_ARB_buffer_storage");
out.buffer_storage = storage;
out.persistent_map = storage;
GLint profile = 0;
if (major > 3 || (major == 3 && minor >= 2)) {
// GL_CONTEXT_PROFILE_MASK only exists from 3.2 on; older contexts would
// raise GL_INVALID_ENUM and leave the value at 0.
glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &profile);
}
out.core_profile = (static_cast<GLuint>(profile) & GL_CONTEXT_CORE_PROFILE_BIT) != 0u;
return true;
}
} // namespace
bool load(ProcLoader loader) noexcept {
// A partially resolved table must not be used: the fallible constructors
// consult available() and report Unsupported until the whole table is good.
g_table_ready = false;
const char* missing = nullptr;
#define PLOT_GL_RESOLVE(ret, name, args) \
do { \
void* symbol = resolve(loader, #name); \
if (symbol != nullptr) { \
name = reinterpret_cast<ret (*) args>(symbol); \
} else if (missing == nullptr) { \
missing = #name; \
} \
} while (false);
PLOT_GL_FUNCS(PLOT_GL_RESOLVE)
// Above-baseline entries: resolved the same way, but a name the driver does not
// have leaves its pointer null instead of failing the load, so a 3.3-only
// context keeps working. Callers gate them on caps() (see api.hpp).
PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_RESOLVE)
#undef PLOT_GL_RESOLVE
// The table may have changed, so whatever caps() cached for the old one is
// stale — including when this load() fails.
g_caps_valid = false;
if (missing != nullptr) {
PLOT_ASSERT(false, missing);
return false;
}
g_table_ready = true;
return true;
}
bool available() noexcept { return g_table_ready; }
const Caps& caps() noexcept {
if (!g_caps_valid) {
if (query_caps(g_caps)) {
g_caps_valid = true;
} else {
// No context (or no resolved entry points): report an empty probe rather
// than stale or uninitialised values.
g_caps = Caps{};
}
}
return g_caps;
}
} // namespace plot::gl
+367
View File
@@ -0,0 +1,367 @@
#include <plot/input/controller.hpp>
#include <plot/math/transform.hpp>
#include <plot/plot/plot.hpp>
#include <algorithm>
#include <cmath>
#include <numbers>
#include <utility>
namespace plot::input {
namespace {
/// Zoom step for one wheel notch (dy == 1). A notch changes the span by
/// `exp(-0.15)` (~14%), a step that feels like one detent on a normal mouse.
constexpr f64 kZoomPerNotch = 0.15;
/// A keyboard zoom press is one wheel notch.
constexpr f64 kKeyZoomNotches = 1.0;
/// Zoom per pixel of a middle-drag along an axis strip. A pointer drag reports
/// pixels, a different unit from a wheel notch, so it must not reuse
/// kZoomPerNotch: 200 px of drag is one decade, exp(-200 * ln10/200) == 0.1.
constexpr f64 kZoomPerPixel = std::numbers::ln10 / 200.0;
/// How close (in pixels) the pointer must be to a cursor to grab it.
constexpr f32 kCursorHitRadiusPx = 5.0f;
/// A box-zoom selection narrower than this on either side is a click.
constexpr f32 kMinBoxPixels = 4.0f;
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
/// axis translates and scales multiplicatively, exactly as its transform maps.
f64 to_space(f64 v, bool log) noexcept {
return log ? Transform::log10_positive(v) : v;
}
f64 from_space(f64 v, bool log) noexcept {
return log ? std::pow(10.0, v) : v;
}
/// Zoom factor for a number of wheel notches: positive notches (scroll up, `+`
/// key) zoom in, so the span shrinks by `exp(-notches * kZoomPerNotch)`.
f64 zoom_factor(f64 notches) noexcept { return std::exp(-notches * kZoomPerNotch); }
/// True when a coordinate is one arithmetic can work with. Every event here comes
/// from a host, and a host can hand over anything: a toolkit's "no pointer"
/// sentinel, a NaN from a degenerate mapping of its own, an infinity from a lost
/// device. A non-finite value rides the pan and zoom arithmetic into a range that
/// is no longer a range, and since a gesture that sets a range also clears auto
/// scale, `f` would be the only way back -- so it must not get in.
bool finite(f32 v) noexcept { return std::isfinite(v); }
/// `e`, with an unusable position replaced by the last known one and an unusable
/// delta by no movement. Sanitising rather than rejecting keeps the buttons in the
/// same event alive: a drag whose release arrived with a garbage position must
/// still end.
PointerEvent sanitized(const PointerEvent& e, bool have_pointer, f32 last_x,
f32 last_y) noexcept {
PointerEvent in = e;
if (!finite(in.x) || !finite(in.y)) {
in.x = have_pointer ? last_x : 0.0f;
in.y = have_pointer ? last_y : 0.0f;
in.dx = 0.0f;
in.dy = 0.0f;
return in;
}
if (!finite(in.dx)) in.dx = 0.0f;
if (!finite(in.dy)) in.dy = 0.0f;
return in;
}
/// `e`, with an unusable position replaced by the last known one. The wheel's
/// amount is deliberately left alone: `on_wheel` rejects an unusable one *before*
/// the event is acted on, because a zeroed amount is a zoom of exactly one, and
/// running that over an unchanged range would still clear every axis' auto scale.
WheelEvent sanitized(const WheelEvent& e, bool have_pointer, f32 last_x, f32 last_y) noexcept {
WheelEvent in = e;
if (!finite(in.x) || !finite(in.y)) {
in.x = have_pointer ? last_x : 0.0f;
in.y = have_pointer ? last_y : 0.0f;
}
return in;
}
} // namespace
void Controller::set_viewport(Rect px) {
// The controller owns no viewport: it forwards the caller's rect to the plot
// (so the two can never disagree) and refreshes the layout the hit tests and
// transforms read.
plot_->set_viewport(px);
plot_->layout();
}
void Controller::on_pointer(const PointerEvent& raw) {
const PointerEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
has_pointer_ = true;
pointer_x_ = e.x;
pointer_y_ = e.y;
plot_->layout(); // keep the chrome geometry the gestures map through current
if (e.pressed && e.down) {
begin_drag(e);
return;
}
if (e.released) {
end_drag(e);
return;
}
if (e.down && mode_ != DragMode::None) update_drag(e);
}
void Controller::on_wheel(const WheelEvent& raw) {
// An amount that is not a number is not a wheel: sanitising it to zero would look
// like a zero-notch zoom -- a factor of exactly one -- and the zoom body would
// still run, calling the range setters with the unchanged range. Those clear
// `auto_scale` unconditionally, so a single garbage wheel would silently disarm
// auto-fit on every axis. Return before the event can mutate anything.
if (!std::isfinite(raw.dy)) return;
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
plot_->layout();
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
if (!(factor > 0.0)) return;
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
// Shift alone restricts the zoom to x, ctrl alone to y; neither (or both)
// zooms both axes. The data coordinate under the pointer stays put.
const bool zoom_x = !(ctrl && !shift);
const bool zoom_y = !(shift && !ctrl);
if (zoom_x) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, plot_->x_transform(i).to_data_x(static_cast<f64>(e.x)), factor);
}
}
if (zoom_y) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
}
}
}
void Controller::on_key(const KeyEvent& e) {
if (!e.down) return; // every action below is press-only
if (e.key == kKeyZoomIn || e.key == kKeyZoomOut) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
const f64 factor = zoom_factor(notches);
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
}
return;
}
if (e.key == kKeyFit || e.key == kKeyFitUpper) {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
}
}
u64 Controller::hovered_cursor() const {
if (!has_pointer_) return kInvalid;
return cursor_at(pointer_x_, pointer_y_);
}
// --- drag dispatch --------------------------------------------------------
void Controller::begin_drag(const PointerEvent& e) {
origin_x_ = e.x;
origin_y_ = e.y;
drag_cursor_ = kInvalid;
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
mode_ = DragMode::BoxZoom;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
}
if (e.button == Button::Left && has_mod(e.mods, Mod::Ctrl)) {
const u64 hit = cursor_at(e.x, e.y);
if (hit != kInvalid) {
mode_ = DragMode::MoveCursor;
drag_cursor_ = hit;
return;
}
// Ctrl far from any cursor falls through to pan, like an unmodified drag.
}
if (e.button == Button::Middle) {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (lay.x_label_area.contains(e.x, e.y)) {
mode_ = DragMode::AxisZoomX;
} else if (lay.y_label_area.contains(e.x, e.y)) {
mode_ = DragMode::AxisZoomY;
} else {
mode_ = DragMode::None; // a middle drag off the label strips does nothing
}
return;
}
mode_ = DragMode::Pan; // left and right drag both pan
}
void Controller::update_drag(const PointerEvent& e) {
switch (mode_) {
case DragMode::Pan: pan(e); break;
case DragMode::BoxZoom: box_zoom_update(e); break;
case DragMode::MoveCursor: move_cursor(e); break;
case DragMode::AxisZoomX:
case DragMode::AxisZoomY: axis_zoom(e); break;
case DragMode::None: break;
}
}
void Controller::end_drag(const PointerEvent& e) {
// The release carries the authoritative end of the gesture: refresh the
// selection from it first, so a host that reports only a press and a release
// (no motion event in between) still frames the box the release describes
// instead of leaving selection_ at 0x0 and degrading the zoom to a click.
if (mode_ == DragMode::BoxZoom) {
box_zoom_update(e);
apply_box_zoom();
}
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
}
// --- gestures -------------------------------------------------------------
void Controller::pan(const PointerEvent& e) {
// The transforms map a range onto layout().axes, so the pixel-to-data scale is
// the range span over the axes rect -- not over the whole viewport. Using the
// viewport (56 px of label strip on an 800 px plot) makes dragged data lag the
// pointer by the strip's fraction, breaking grab-and-drag.
const Rect axes = std::as_const(*plot_).layout().axes;
if (!(axes.w > 0.0f) || !(axes.h > 0.0f)) return;
const f64 fx = static_cast<f64>(e.dx) / static_cast<f64>(axes.w);
const f64 fy = static_cast<f64>(e.dy) / static_cast<f64>(axes.h);
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = -fx * (hi - lo); // drag right reveals earlier (smaller) data
plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = fy * (hi - lo); // screen y is inverted: dragging down raises y
plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::box_zoom_update(const PointerEvent& e) {
const f32 x = std::min(origin_x_, e.x);
const f32 y = std::min(origin_y_, e.y);
selection_ = Rect{x, y, std::fabs(e.x - origin_x_), std::fabs(e.y - origin_y_)};
}
void Controller::apply_box_zoom() {
const Rect sel = selection_;
// A selection under a few pixels on either side is a click: no zoom.
if (sel.w < kMinBoxPixels || sel.h < kMinBoxPixels) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
const Rect axes = std::as_const(*plot_).layout().axes;
const f64 ax = static_cast<f64>(axes.x);
const f64 ar = static_cast<f64>(axes.right());
const f64 ay = static_cast<f64>(axes.y);
const f64 ab = static_cast<f64>(axes.bottom());
const f64 l = std::clamp(static_cast<f64>(sel.x), ax, ar);
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
if (!(r > l) || !(b > t)) return;
// The ranges come straight from the transforms, so the box frames exactly the
// data under it; the selection's bottom edge is the range minimum on y.
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
}
}
void Controller::move_cursor(const PointerEvent& e) {
if (drag_cursor_ == kInvalid) return;
const u32 id = static_cast<u32>(drag_cursor_);
const Cursor& cursor = plot_->cursor(id);
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
}
void Controller::axis_zoom(const PointerEvent& e) {
// A pointer drag reports pixels, not wheel notches, so it uses its own
// sensitivity: 200 px is one decade.
const f64 factor = std::exp(-static_cast<f64>(e.dy) * kZoomPerPixel);
if (!(factor > 0.0)) return;
if (mode_ == DragMode::AxisZoomX) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, plot_->x_transform(i).to_data_x(static_cast<f64>(e.x)), factor);
}
} else {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
}
}
}
// --- helpers --------------------------------------------------------------
u64 Controller::cursor_at(f32 px, f32 py) const {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (!lay.axes.contains(px, py)) return kInvalid;
u64 best = kInvalid;
f32 best_d = 0.0f;
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
const Cursor& cursor = plot_->cursor(i);
if (!cursor.visible) continue;
const f32 sx = static_cast<f32>(plot_->x_transform(cursor.axis_x).to_screen_x(cursor.x));
const f32 d = std::fabs(sx - px);
if (d > kCursorHitRadiusPx) continue;
if (best == kInvalid || d < best_d) {
best = i;
best_d = d;
}
}
return best;
}
void Controller::zoom_x_axis(u32 axis, f64 anchor, f64 factor) {
const Axis& a = plot_->x_axis(axis);
const Range r = a.range;
if (!(r.span() > 0.0) || !(factor > 0.0)) return;
const f64 lo = to_space(r.min, a.log);
const f64 hi = to_space(r.max, a.log);
const f64 aa = to_space(anchor, a.log);
// Recomputing the range with the anchor held fixed keeps the anchor's screen
// position invariant, for linear and log axes alike.
plot_->set_x_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log),
from_space(aa + (hi - aa) * factor, a.log)});
}
void Controller::zoom_y_axis(u32 axis, f64 anchor, f64 factor) {
const Axis& a = plot_->y_axis(axis);
const Range r = a.range;
if (!(r.span() > 0.0) || !(factor > 0.0)) return;
const f64 lo = to_space(r.min, a.log);
const f64 hi = to_space(r.max, a.log);
const f64 aa = to_space(anchor, a.log);
plot_->set_y_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log),
from_space(aa + (hi - aa) * factor, a.log)});
}
} // namespace plot::input
+64
View File
@@ -0,0 +1,64 @@
#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;
}
}
/// Scale mapping `[lo,hi]` (transform space, already widened by `axis_ends`)
/// onto the pixel interval `[p0,p1]`.
///
/// A collapsed pixel span (`|p1 - p0| <= kMinPixelSpan`, e.g. a zero-height
/// panel or an untouched layout) would yield a zero scale, so `to_data_*` would
/// divide by zero and return NaN or +/-inf. Instead the span is substituted by
/// the unit span `p0 -> p0 + 1`: the scale is then finite and non-zero and the
/// mapping stays invertible (`to_data_x(to_screen_x(v)) == v`), the axis simply
/// occupying one pixel rather than none. Non-collapsed spans are used verbatim,
/// so ordinary ranges are unaffected.
f64 axis_scale(f64 lo, f64 hi, f64 p0, f64 p1) noexcept {
/// Pixel coordinates in this library are O(1e3), so 1e-12 is
/// indistinguishable from zero while staying far below any span a real
/// viewport can produce.
constexpr f64 kMinPixelSpan = 1e-12;
const f64 span = p1 - p0;
const f64 effective_span = std::fabs(span) <= kMinPixelSpan ? 1.0 : span;
return effective_span / (hi - lo);
}
} // 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 = axis_scale(x_lo, x_hi, px0, px1);
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 = axis_scale(y_lo, y_hi, py0, py1);
t.ty = py0 - y_lo * t.sy;
return t;
}
} // namespace plot::math
+18
View File
@@ -0,0 +1,18 @@
#include <plot/plot/axis.hpp>
namespace plot {
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
generate_ticks(range, px, min_px, ticks, include_minors);
if (format != TickFormat::Time) return;
// generate_ticks labels with format_si; a time axis relabels its majors here,
// because the tick generator has no business knowing about a clock.
char buf[64];
for (Tick& tick : ticks) {
if (!tick.major) continue;
format_time(tick.value, buf, sizeof buf);
tick.label = buf;
}
}
} // namespace plot
+45
View File
@@ -0,0 +1,45 @@
#include <plot/plot/grid.hpp>
#include <algorithm>
#include <cmath>
namespace plot {
namespace {
/// True when `v` lies in `r`, allowing the rounding slack a generated tick can
/// carry at the range's edge: a tick that is within 1e-9 relative of the edge is
/// the edge, and dropping it would leave the frame un-drawn.
bool inside(f64 v, Range r) noexcept {
const f64 tolerance = std::max(std::fabs(r.min), std::fabs(r.max)) * 1e-9 + 1e-12;
return v >= r.min - tolerance && v <= r.max + tolerance;
}
} // namespace
void Grid::build(const std::vector<Tick>& x_ticks, Range x_range,
const std::vector<Tick>& y_ticks, Range y_range) {
lines_.clear();
if (!std::isfinite(x_range.min) || !std::isfinite(x_range.max) ||
!std::isfinite(y_range.min) || !std::isfinite(y_range.max)) {
return;
}
// Segments stay in data space: the renderer applies the transform, so a pan or
// zoom needs no grid rebuild other than for a changed tick set.
if (vertical) {
for (const Tick& tick : x_ticks) {
if (!tick.major && !show_minor) continue;
if (!std::isfinite(tick.value) || !inside(tick.value, x_range)) continue;
lines_.push_back(GridLine{tick.value, y_range.min, tick.value, y_range.max, tick.major});
}
}
if (horizontal) {
for (const Tick& tick : y_ticks) {
if (!tick.major && !show_minor) continue;
if (!std::isfinite(tick.value) || !inside(tick.value, y_range)) continue;
lines_.push_back(GridLine{x_range.min, tick.value, x_range.max, tick.value, tick.major});
}
}
}
} // namespace plot
+43
View File
@@ -0,0 +1,43 @@
#include <plot/plot/measure.hpp>
#include <cmath>
namespace plot {
Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
Measurement m;
// Both guards run before any query, so an empty, inverted or out-of-range
// interval returns an invalid result without touching the series.
if (end <= begin || end > s.size()) return m;
// Exact extremes and sums come from Series -- never from the pyramid's
// *_buckets forms, which answer in whole buckets only and would drop up to two
// partial edge buckets, silently returning wrong extrema and sums for any
// interval that is not bucket-aligned. Each query here is O(kBucket) worst
// case, so a measurement never scales with series length.
const Range extremes = s.reduce(begin, end);
const f64 total = s.sum(begin, end);
const f64 squared = s.sumsq(begin, end);
const f64 n = static_cast<f64>(end - begin);
const f64 step = s.dx();
m.ymin = extremes.min;
m.ymax = extremes.max;
m.mean = total / n;
m.rms = std::sqrt(squared / n);
m.integral = total * step; // left-Riemann sum: see the note in measure.hpp
if (end - begin >= 2) {
// First and last sample of the interval: a single-sample query reduces to
// that sample's value, and costs the same O(kBucket) as any other.
const f64 first = s.reduce(begin, begin + 1).min;
const f64 last = s.reduce(end - 1, end).min;
m.dy = last - first;
m.dx = static_cast<f64>(end - 1 - begin) * step;
m.slope = m.dx != 0.0 ? m.dy / m.dx : 0.0;
}
m.valid = true;
return m;
}
} // namespace plot
+461
View File
@@ -0,0 +1,461 @@
#include <plot/plot/plot.hpp>
#include <plot/plot/detail/measure_count.hpp>
#include <algorithm>
#include <cmath>
namespace plot {
namespace {
/// Layout passes. The left strip's width depends on the tick labels, which depend
/// on the plot width the strip leaves; the loop stops as soon as the strip stops
/// changing, so a normal plot converges on the second pass and never reaches this.
constexpr u32 kMaxLayoutPasses = 4;
/// Share of the viewport the axes rect keeps however much chrome the label and
/// title strips ask for. A long SI label or a title wider than a narrow panel can
/// ask for the whole viewport, and an axes rect with no width or no height draws
/// nothing at all -- no grid, no tick, no trace -- without reporting anything, so
/// the strips give way instead. The label strip is what makes the tick values
/// readable, so it is served first and the title strip takes what is left; a title
/// longer than its share of the strip is clipped to it rather than allowed to grow
/// it (see `Renderer::draw_titles()`).
constexpr f32 kMinAxesShare = 0.5f;
const Axis& default_axis() noexcept {
static const Axis axis{};
return axis;
}
const Measurement& invalid_measurement() noexcept {
static const Measurement measurement{};
return measurement;
}
Axis axis_from(const AxisDesc& desc) {
Axis axis;
axis.label = desc.label;
axis.range = desc.range;
axis.auto_scale = desc.auto_scale;
axis.place = desc.place;
axis.log = desc.log;
axis.format = desc.format;
return axis;
}
/// Fits `axis`' range to the union of its traces' bounds, returning false when the
/// union cannot be fitted and the range is then left untouched.
///
/// A linear axis takes 2% of the span on both ends; a zero-span union (a
/// single-sample series) is widened instead of padded, because a degenerate range
/// has no span to take 2% of and would leave the transform's inverse ambiguous.
///
/// A log axis needs strictly positive data, and this is where a log axis could
/// otherwise be left broken: an all-negative union has no positive value to show,
/// and padding it would produce `min > max` once the lower edge was clamped to a
/// positive bound. Instead the positive part of the union is used -- `[hi*1e-3,
/// hi]` when the union straddles or starts below zero, since zero has no place on
/// a log axis -- the pad is multiplicative (2% of the span in log space), and a
/// union with no positive value at all is refused outright. The caller keeps its
/// range and is free to retry, which is what lets a stream that starts at zero
/// begin fitting by itself once it goes positive.
bool fit_axis_range(Axis& axis, Range bounds) {
const f64 lo = std::min(bounds.min, bounds.max);
const f64 hi = std::max(bounds.min, bounds.max);
if (!std::isfinite(lo) || !std::isfinite(hi)) return false;
if (axis.log) {
if (!(hi > 0.0)) return false; // no positive sample: not auto-fittable
const f64 lower = lo > 0.0 ? lo : hi * 1e-3;
if (!(lower > 0.0) || lower > hi) return false;
if (lower == hi) {
axis.range = Range{lower / 10.0, hi * 10.0}; // one decade either side
return true;
}
const f64 factor = std::pow(hi / lower, 0.02); // 2% of the span, in log space
axis.range = Range{lower / factor, hi * factor};
return true;
}
if (hi > lo) {
const f64 pad = (hi - lo) * 0.02;
axis.range = Range{lo - pad, hi + pad};
return true;
}
const f64 pad = std::fabs(lo) * 1e-3 + 0.5;
axis.range = Range{lo - pad, hi + pad};
return true;
}
} // namespace
u32 Plot::add_x_axis(const AxisDesc& desc) {
x_axes_.push_back(axis_from(desc));
return static_cast<u32>(x_axes_.size() - 1);
}
u32 Plot::add_y_axis(const AxisDesc& desc) {
y_axes_.push_back(axis_from(desc));
return static_cast<u32>(y_axes_.size() - 1);
}
u32 Plot::add_trace(const TraceDesc& desc) {
Trace trace;
trace.series = desc.series;
trace.color = desc.color;
trace.width = desc.width;
trace.axis_x = desc.axis_x;
trace.axis_y = desc.axis_y;
traces_.push_back(trace);
mark_measures_dirty();
return static_cast<u32>(traces_.size() - 1);
}
u32 Plot::add_cursor(f64 x, Color color) {
Cursor cursor;
cursor.x = x;
cursor.color = color;
cursor.x = snapped_x(cursor);
cursors_.push_back(cursor);
mark_measures_dirty();
return static_cast<u32>(cursors_.size() - 1);
}
u32 Plot::add_measure(u32 cursor_a, u32 cursor_b, u32 trace) {
MeasureSlot slot;
slot.cursor_a = cursor_a;
slot.cursor_b = cursor_b;
slot.trace = trace;
measures_.push_back(slot);
return static_cast<u32>(measures_.size() - 1);
}
u32 Plot::add_annotation(const Annotation& annotation) {
annotations_.push_back(annotation);
return static_cast<u32>(annotations_.size() - 1);
}
const Axis& Plot::x_axis(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis] : default_axis();
}
const Axis& Plot::y_axis(u32 axis) const {
return axis < y_axes_.size() ? y_axes_[axis] : default_axis();
}
const Trace& Plot::trace(u32 id) const {
static const Trace fallback{};
return id < traces_.size() ? traces_[id] : fallback;
}
const Cursor& Plot::cursor(u32 id) const {
static const Cursor fallback{};
return id < cursors_.size() ? cursors_[id] : fallback;
}
const Annotation& Plot::annotation(u32 id) const {
static const Annotation fallback{};
return id < annotations_.size() ? annotations_[id] : fallback;
}
Range Plot::x_range(u32 axis) const { return x_axis(axis).range; }
Range Plot::y_range(u32 axis) const { return y_axis(axis).range; }
void Plot::set_x_range(u32 axis, Range r) {
if (axis >= x_axes_.size()) return;
x_axes_[axis].range = r;
x_axes_[axis].auto_scale = false;
}
void Plot::set_y_range(u32 axis, Range r) {
if (axis >= y_axes_.size()) return;
y_axes_[axis].range = r;
y_axes_[axis].auto_scale = false;
}
void Plot::set_x_auto_scale(u32 axis, bool on) {
if (axis < x_axes_.size()) x_axes_[axis].auto_scale = on;
}
void Plot::set_y_auto_scale(u32 axis, bool on) {
if (axis < y_axes_.size()) y_axes_[axis].auto_scale = on;
}
Transform Plot::x_transform(u32 axis) const {
const Axis& a = x_axis(axis);
const Rect r = layout_.axes;
// Only the x half of the transform is meaningful; the dummy y range keeps the
// returned Transform's y mapping an identity rather than a second, conflicting
// axis mapping. A collapsed rect or range is widened inside from_range.
return Transform::from_range(a.range, Range{}, static_cast<f64>(r.x),
static_cast<f64>(r.right()), 0.0, 1.0, a.log, false);
}
Transform Plot::y_transform(u32 axis) const {
const Axis& a = y_axis(axis);
const Rect r = layout_.axes;
// Screen y grows downwards, so the range's minimum maps to the rect's bottom
// and its maximum to the top: py0/py1 are passed bottom-first on purpose.
return Transform::from_range(Range{}, a.range, 0.0, 1.0, static_cast<f64>(r.bottom()),
static_cast<f64>(r.y), false, a.log);
}
bool Plot::trace_bounds_union(bool x_side, u32 axis, Range& out) const {
bool any = false;
for (const Trace& t : traces_) {
if ((x_side ? t.axis_x : t.axis_y) != axis || !t.visible()) continue; // null series: skip
const Range bounds = x_side ? t.series->x_bounds() : t.series->y_bounds();
if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue;
if (!any) {
out = bounds;
any = true;
} else {
out.min = std::min(out.min, bounds.min);
out.max = std::max(out.max, bounds.max);
}
}
return any;
}
bool Plot::fit_x_axis(u32 axis) {
Range union_range{};
if (!trace_bounds_union(/*x_side=*/true, axis, union_range)) return false;
return fit_axis_range(x_axes_[axis], union_range);
}
bool Plot::fit_y_axis(u32 axis) {
Range union_range{};
if (!trace_bounds_union(/*x_side=*/false, axis, union_range)) return false;
return fit_axis_range(y_axes_[axis], union_range);
}
void Plot::auto_fit_x() {
for (u32 i = 0; i < x_axes_.size(); ++i) {
// Fitting re-arms auto scaling, so the next layout keeps the axis framed. An
// axis with nothing bound to it has nothing to fit and nothing to re-arm.
if (fit_x_axis(i)) x_axes_[i].auto_scale = true;
}
}
void Plot::auto_fit_y(u32 axis) {
if (axis >= y_axes_.size()) return;
if (fit_y_axis(axis)) y_axes_[axis].auto_scale = true;
}
void Plot::autoscale_axes() {
for (u32 i = 0; i < x_axes_.size(); ++i) {
if (x_axes_[i].auto_scale) fit_x_axis(i);
}
for (u32 i = 0; i < y_axes_.size(); ++i) {
if (y_axes_[i].auto_scale) fit_y_axis(i);
}
}
void Plot::set_cursor_x(u32 id, f64 x) {
if (id >= cursors_.size()) return;
cursors_[id].x = x;
cursors_[id].x = snapped_x(cursors_[id]);
mark_measures_dirty();
}
void Plot::set_cursor_snap(u32 id, SnapMode mode) {
if (id >= cursors_.size()) return;
cursors_[id].snap = mode;
cursors_[id].x = snapped_x(cursors_[id]);
mark_measures_dirty();
}
const Measurement& Plot::measurement(u32 id) const {
if (id >= measures_.size()) return invalid_measurement();
const MeasureSlot& slot = measures_[id];
const data::Series* series = nullptr;
if (slot.trace < traces_.size()) series = traces_[slot.trace].series;
const u64 samples = series != nullptr ? series->size() : 0;
// A saturated ring keeps `samples` constant while its window slides: x0() is
// what moves, and with it the sample each cursor snaps to and the interval they
// measure. Caching on (pointer, count) alone would freeze the readout at the
// first saturated frame until a cursor moved.
const f64 x0 = series != nullptr ? series->x0() : 0.0;
// Recompute only when someone moved a cursor, replaced the trace's series, grew
// it, or slid the window under it (x0 above). Everything else is a cache hit,
// which is what keeps a steady frame from re-evaluating every measure.
if (slot.dirty || series != slot.last_series || samples != slot.last_size ||
x0 != slot.last_x0) {
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
detail::measure_recomputes().fetch_add(1, std::memory_order_relaxed);
#endif
slot.result = Measurement{};
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
slot.cursor_b < cursors_.size()) {
const u64 a = series->pyramid().snap(cursors_[slot.cursor_a].x, series->x0(), series->dx());
const u64 b = series->pyramid().snap(cursors_[slot.cursor_b].x, series->x0(), series->dx());
// snap() clamps to [0, size-1], so [a, b+1) is inside the series whenever
// the cursor pair is not inverted. An inverted pair is not an interval.
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
}
slot.last_series = series;
slot.last_size = samples;
slot.last_x0 = x0;
slot.dirty = false;
}
return slot.result;
}
void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); }
MeasureSpan Plot::measure_span(u32 id) const noexcept {
// An id that names nothing, or a measure built from cursor ids that were never
// registered, is not a span between two markers: `valid` says so rather than
// letting a caller shade a band between cursors 0 and 1 by accident.
if (id >= measures_.size()) return MeasureSpan{};
const MeasureSlot& slot = measures_[id];
if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{};
return MeasureSpan{slot.cursor_a, slot.cursor_b, true};
}
const PlotLayout& Plot::layout() {
// Axes still on auto scale refit here, once per layout, so a growing stream
// stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range),
// which is precisely what stops this from fighting the range the user chose.
autoscale_axes();
const f32 pad = style_.grid_label_pad;
// Seeds: PlotLayout's defaults when nobody can measure text, otherwise the
// metrics the text backend does know (its line height, and just the padding
// until the first tick list says how wide the labels are).
f32 y_width = PlotLayout{}.y_axis_width;
f32 x_height = PlotLayout{}.x_axis_height;
if (text_ != nullptr) {
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
x_height = text_->height() + 2.0f * pad;
}
// The title strips, though, are needed before the loop and never change, unlike
// the label strips: a strip holds exactly one text line per titled axis and no
// tick label can change that. Nothing at all is reserved without a text backend
// -- there is then no title to measure, which is what keeps the two defaults
// above meaningful for the no-text-backend case. The x strip is one text line
// per titled x axis tall (they are stacked in it); the y strip is as wide as the
// widest y title, since a y title is a horizontal run that stacks down the strip
// and so needs width rather than another row of it (see y_title_strip_width).
const u32 x_titles = titled_x_axis_count();
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
const f32 x_title_h = text_ != nullptr && x_titles != 0u
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
: 0.0f;
for (u32 pass = 0;; ++pass) {
// Chrome is measured against the viewport, then capped so the axes keeps
// kMinAxesShare of it -- the label strip first, the title strip out of what is
// left of the allowance -- which is what keeps axes.w and axes.h positive
// however long the labels and titles are. `viewport_` is what a huge label set
// is clamped to first, so neither strip can push the rect outside it.
y_width = std::clamp(y_width, 0.0f, viewport_.w);
x_height = std::clamp(x_height, 0.0f, viewport_.h);
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w;
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h;
y_width = std::min(y_width, y_budget);
x_height = std::min(x_height, x_budget);
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
layout_.y_axis_width = y_width;
layout_.x_axis_height = x_height;
layout_.y_title_width = y_title_width;
layout_.x_title_height = x_title_height;
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
viewport_.w - (y_width + y_title_width),
viewport_.h - (x_height + x_title_height)};
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
layout_.y_label_area =
Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h};
layout_.x_label_area = Rect{layout_.axes.x, layout_.axes.bottom(), layout_.axes.w, x_height};
layout_.x_title_area = Rect{layout_.axes.x, layout_.x_label_area.bottom(), layout_.axes.w,
x_title_height};
refresh_ticks();
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
// The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass.
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
const f32 need_x =
std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget);
if (need_y == y_width && need_x == x_height) break; // strips are a fixed point
y_width = need_y;
x_height = need_x;
}
build_grid();
return layout_;
}
void Plot::refresh_ticks() {
// Ticks are generated for the final axes size: an x axis' density depends on
// the plot's width and a y axis' on its height.
for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.tick_min_px, true);
for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.tick_min_px, true);
}
void Plot::build_grid() {
if (x_axes_.empty() || y_axes_.empty()) {
grid_.clear();
return;
}
grid_.build(x_axes_[0].ticks, x_axes_[0].range, y_axes_[0].ticks, y_axes_[0].range);
}
void Plot::mark_measures_dirty() noexcept {
for (MeasureSlot& slot : measures_) slot.dirty = true;
}
f64 Plot::snapped_x(const Cursor& c) const {
if (c.snap != SnapMode::NearestSample) return c.x;
for (const Trace& t : traces_) {
if (t.axis_x != c.axis_x || !t.visible()) continue;
const data::Series& s = *t.series;
const u64 i = s.pyramid().snap(c.x, s.x0(), s.dx());
return s.x0() + static_cast<f64>(i) * s.dx();
}
return c.x;
}
u32 Plot::titled_x_axis_count() const noexcept {
u32 n = 0;
for (const Axis& a : x_axes_) {
if (!a.label.empty()) ++n;
}
return n;
}
u32 Plot::titled_y_axis_count() const noexcept {
u32 n = 0;
for (const Axis& a : y_axes_) {
if (!a.label.empty()) ++n;
}
return n;
}
f32 Plot::y_title_strip_width() const {
if (text_ == nullptr) return 0.0f;
f32 widest = 0.0f;
for (const Axis& a : y_axes_) {
if (a.label.empty()) continue;
widest = std::max(widest, text_->measure(a.label.c_str()));
}
return widest > 0.0f ? widest + 2.0f * style_.grid_label_pad : 0.0f;
}
f32 Plot::y_label_width() const {
if (text_ == nullptr || y_axes_.empty()) return 0.0f;
f32 widest = 0.0f;
for (const Axis& a : y_axes_) {
for (const Tick& tick : a.ticks) {
if (!tick.major || tick.label.empty()) continue;
widest = std::max(widest, text_->measure(tick.label.c_str()));
}
}
return widest + 2.0f * style_.grid_label_pad;
}
} // namespace plot
+195
View File
@@ -0,0 +1,195 @@
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <cstdio>
#include <cstring>
namespace plot {
namespace {
/// Longest label a tick keeps before falling back to `%.3g`.
constexpr usize kMaxLabelChars = 12;
/// Scratch for one label; longest possible output is a `%.3g` fallback plus a
/// prefix plus a unit, far under this.
constexpr usize kLabelBuf = 64;
/// SI prefixes indexed by engineering exponent / 3, from 10^-24 to 10^24. The
/// micro sign is UTF-8 U+00B5 (0xC2 0xB5), not the Greek mu U+03BC: the bitmap
/// font has a glyph for U+00B5 and would draw a missing-glyph box for U+03BC.
struct SiPrefix {
int index;
const char* text;
};
constexpr SiPrefix kPrefixes[] = {
{-8, "y"}, {-7, "z"}, {-6, "a"}, {-5, "f"}, {-4, "p"}, {-3, "n"}, {-2, "\xC2\xB5"},
{-1, "m"}, {0, ""}, {1, "k"}, {2, "M"}, {3, "G"}, {4, "T"}, {5, "P"},
{6, "E"}, {7, "Z"}, {8, "Y"},
};
const char* prefix_for(int index) noexcept {
for (const SiPrefix& p : kPrefixes) {
if (p.index == index) return p.text;
}
return "";
}
/// `%.3g` with a NUL-terminated, never-overflowing write. Used for every value
/// the SI path refuses.
void fallback_number(f64 v, const char* unit, char* buf, usize n) noexcept {
std::snprintf(buf, n, "%.3g%s", v, unit);
}
} // namespace
void format_si(f64 v, char* buf, usize n, const char* unit) {
if (buf == nullptr || n == 0) return;
buf[0] = '\0';
const char* const u = unit != nullptr ? unit : "";
if (!std::isfinite(v)) {
fallback_number(v, u, buf, n);
return;
}
if (v == 0.0) {
std::snprintf(buf, n, "0%s", u);
return;
}
// Engineering exponent: |v| == scaled * 10^(3*e3) with scaled in [1, 1000).
const f64 magnitude = std::fabs(v);
const int e3 = static_cast<int>(std::floor(std::log10(magnitude) / 3.0));
if (e3 < kPrefixes[0].index || e3 > kPrefixes[sizeof(kPrefixes) / sizeof(kPrefixes[0]) - 1].index) {
fallback_number(v, u, buf, n);
return;
}
f64 scaled = v / std::pow(10.0, 3.0 * static_cast<f64>(e3));
int index = e3;
// Rounding to three significant digits must not spill the scale: 999.9 goes
// to 1.00 of the next prefix rather than to `1e+03` of this one.
if (std::fabs(scaled) >= 999.5) {
index = e3 + 1;
if (index > kPrefixes[sizeof(kPrefixes) / sizeof(kPrefixes[0]) - 1].index) {
fallback_number(v, u, buf, n);
return;
}
scaled = v / std::pow(10.0, 3.0 * static_cast<f64>(index));
}
std::snprintf(buf, n, "%.3g%s%s", scaled, prefix_for(index), u);
if (std::strlen(buf) > kMaxLabelChars) {
// A 12-character budget keeps axis labels readable; anything longer is a
// scientific-notation value the caller is better served by verbatim.
fallback_number(v, u, buf, n);
}
}
void format_time(f64 seconds, char* buf, usize n) {
if (buf == nullptr || n == 0) return;
buf[0] = '\0';
if (!std::isfinite(seconds)) {
std::snprintf(buf, n, "%.3g", seconds);
return;
}
const char* const sign = seconds < 0.0 ? "-" : "";
const f64 t = std::fabs(seconds);
if (t < 1.0) {
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
// notation (it would print "1e+03ms") right below the one-second boundary.
const f64 ms = t * 1000.0;
if (ms < 999.5) {
std::snprintf(buf, n, "%s%.3gms", sign, ms);
} else {
std::snprintf(buf, n, "%s%.3gs", sign, t);
}
return;
}
// ONE rounding decision for the whole value. Deriving the fields from a single
// rounded second count is what keeps a field from reaching its radix: rounding
// the seconds leftover independently would print 119.5 s as "1:60".
const f64 whole = std::round(t);
if (whole >= 86400.0) {
// At or above a day the day field appears, so the h:mm:ss shape never sees an
// hour of 24 or more.
std::snprintf(buf, n, "%s%.3gd", sign, whole / 86400.0);
return;
}
const auto total = static_cast<unsigned long long>(whole);
if (whole >= 3600.0) {
std::snprintf(buf, n, "%s%llu:%02llu:%02llu", sign, total / 3600ull,
(total / 60ull) % 60ull, total % 60ull);
} else if (whole >= 60.0) {
std::snprintf(buf, n, "%s%llu:%02llu", sign, total / 60ull, total % 60ull);
} else {
std::snprintf(buf, n, "%s%.3gs", sign, t);
}
}
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors) {
out.clear();
const f64 lo = range.min;
const f64 hi = range.max;
const f64 span = hi - lo;
// Non-finite endpoints, an inverted range and a zero span (a collapsed range,
// e.g. the x bounds of a single-sample series) all have nothing to tick.
if (!std::isfinite(lo) || !std::isfinite(hi) || !(span > 0.0)) return;
if (!(px > 0.0f) || !(min_px > 0.0f)) return;
const f64 target = std::max(1.0, static_cast<f64>(px) / static_cast<f64>(min_px));
const f64 raw_step = span / target;
if (!std::isfinite(raw_step) || !(raw_step > 0.0)) return;
const f64 magnitude = std::pow(10.0, std::floor(std::log10(raw_step)));
if (!std::isfinite(magnitude) || !(magnitude > 0.0)) return;
const f64 normalised = raw_step / magnitude; // in [1, 10)
// Round the step UP to the next 1/2/5 x 10^n, so the major spacing really does
// meet min_px instead of landing just under it.
const f64 step = magnitude * (normalised <= 1.0 ? 1.0
: normalised <= 2.0 ? 2.0
: normalised <= 5.0 ? 5.0
: 10.0);
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
const f64 first_major = std::ceil(lo / step) * step;
if (!std::isfinite(first_major)) return;
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
// 2, by 5 for 1 and 5 (and for a step of 10, i.e. a leading 1 one decade up).
const f64 lead = step / magnitude;
const f64 minor_ratio = (lead > 1.5 && lead < 3.0) ? 4.0 : 5.0;
const f64 minor_step = step / minor_ratio;
if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return;
const f64 stride = include_minors ? minor_step : step;
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
if (!std::isfinite(first)) return;
char label[kLabelBuf];
for (f64 v = first; v <= hi + stride * 1e-9;) {
if (out.size() >= kMaxTicks) break;
const bool major = !include_minors ||
std::fabs(v - std::round(v / step) * step) <= step * 1e-9;
Tick tick;
tick.value = v;
tick.major = major;
if (major) {
// format_si owns both label guards: it never writes past the buffer, and it
// falls back to %.3g for a non-finite value or a label over 12 characters.
format_si(v, label, sizeof label);
tick.label = label;
}
out.push_back(std::move(tick));
const f64 next = v + stride;
// No progress would mean an infinite loop on a denormal stride far from the
// origin; stop instead of spinning.
if (!(next > v)) break;
v = next;
}
}
} // namespace plot
+277
View File
@@ -0,0 +1,277 @@
#include <plot/render/bitmap_font.hpp>
#include <array>
namespace plot::render {
namespace {
// Glyph data and its provenance
// -----------------------------
// The 95 printable ASCII glyphs (slots 0..94, U+0020..U+007E) are the
// `font8x8_basic` table of the font8x8 project by Daniel Hepper, released into
// the public domain. It is itself based on Marcel Sondaar's public-domain 8x8
// VGA font (via the IBM public-domain VGA fonts). Fetched on 2026-09-27 from
// https://raw.githubusercontent.com/dhepper/font8x8/master/font8x8_basic.h
// The glyphs the plot layer's SI labels need come from the same project:
// U+00B0 DEGREE SIGN and U+00B5 MICRO SIGN from font8x8_ext_latin.h (which
// covers U+00A0..U+00FF), and U+03A9 GREEK CAPITAL LETTER OMEGA from
// font8x8_greek.h (which covers U+0390..U+03C9).
// The missing-glyph box in the last slot is authored here: a hollow 8x8 square.
// Nothing else in this table is hand-drawn, and none of it is a library: it is
// public-domain data, so `plot_core` still links nothing third-party.
//
// Row 0 is the top row and bit 0 is the leftmost pixel, one byte per row.
constexpr u8 kGlyphBits[BitmapFont::kGlyphCount][BitmapFont::kGlyphPx] = {
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 0] U+0020 space
{0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // [ 1] U+0021 '!'
{0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 2] U+0022 '"'
{0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // [ 3] U+0023 '#'
{0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // [ 4] U+0024 '$'
{0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // [ 5] U+0025 '%'
{0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // [ 6] U+0026 '&'
{0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 7] U+0027 '''
{0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // [ 8] U+0028 '('
{0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // [ 9] U+0029 ')'
{0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // [10] U+002A '*'
{0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // [11] U+002B '+'
{0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // [12] U+002C ','
{0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // [13] U+002D '-'
{0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // [14] U+002E '.'
{0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // [15] U+002F '/'
{0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // [16] U+0030 '0'
{0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // [17] U+0031 '1'
{0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // [18] U+0032 '2'
{0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // [19] U+0033 '3'
{0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // [20] U+0034 '4'
{0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // [21] U+0035 '5'
{0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // [22] U+0036 '6'
{0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // [23] U+0037 '7'
{0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // [24] U+0038 '8'
{0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // [25] U+0039 '9'
{0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // [26] U+003A ':'
{0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // [27] U+003B ';'
{0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // [28] U+003C '<'
{0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // [29] U+003D '='
{0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // [30] U+003E '>'
{0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // [31] U+003F '?'
{0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // [32] U+0040 '@'
{0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // [33] U+0041 'A'
{0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // [34] U+0042 'B'
{0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // [35] U+0043 'C'
{0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // [36] U+0044 'D'
{0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // [37] U+0045 'E'
{0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // [38] U+0046 'F'
{0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // [39] U+0047 'G'
{0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // [40] U+0048 'H'
{0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [41] U+0049 'I'
{0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // [42] U+004A 'J'
{0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // [43] U+004B 'K'
{0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // [44] U+004C 'L'
{0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // [45] U+004D 'M'
{0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // [46] U+004E 'N'
{0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // [47] U+004F 'O'
{0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // [48] U+0050 'P'
{0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // [49] U+0051 'Q'
{0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // [50] U+0052 'R'
{0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // [51] U+0053 'S'
{0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [52] U+0054 'T'
{0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // [53] U+0055 'U'
{0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // [54] U+0056 'V'
{0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // [55] U+0057 'W'
{0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // [56] U+0058 'X'
{0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // [57] U+0059 'Y'
{0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // [58] U+005A 'Z'
{0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // [59] U+005B '['
{0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // [60] U+005C '\'
{0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // [61] U+005D ']'
{0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // [62] U+005E '^'
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // [63] U+005F '_'
{0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // [64] U+0060 '`'
{0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // [65] U+0061 'a'
{0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // [66] U+0062 'b'
{0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // [67] U+0063 'c'
{0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00}, // [68] U+0064 'd'
{0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00}, // [69] U+0065 'e'
{0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00}, // [70] U+0066 'f'
{0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // [71] U+0067 'g'
{0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // [72] U+0068 'h'
{0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [73] U+0069 'i'
{0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // [74] U+006A 'j'
{0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // [75] U+006B 'k'
{0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [76] U+006C 'l'
{0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // [77] U+006D 'm'
{0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // [78] U+006E 'n'
{0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // [79] U+006F 'o'
{0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // [80] U+0070 'p'
{0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // [81] U+0071 'q'
{0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // [82] U+0072 'r'
{0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // [83] U+0073 's'
{0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // [84] U+0074 't'
{0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // [85] U+0075 'u'
{0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // [86] U+0076 'v'
{0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // [87] U+0077 'w'
{0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // [88] U+0078 'x'
{0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // [89] U+0079 'y'
{0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // [90] U+007A 'z'
{0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // [91] U+007B '{'
{0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // [92] U+007C '|'
{0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // [93] U+007D '}'
{0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [94] U+007E '~'
{0x1C, 0x36, 0x36, 0x1C, 0x00, 0x00, 0x00, 0x00}, // [95] U+00B0 degree
{0x00, 0x00, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x03}, // [96] U+00B5 micro
{0x3E, 0x63, 0x63, 0x63, 0x36, 0x36, 0x77, 0x00}, // [97] U+03A9 ohm
{0x7E, 0x42, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x00}, // [98] missing-glyph box, authored here
};
/// Repacks the glyph-major table into the atlas image the text pipeline
/// uploads: one 8x8 cell per slot, 255 for ink, straight alpha, no dilation.
constexpr std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> pack_atlas() noexcept {
std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> image{};
for (u32 slot = 0; slot < BitmapFont::kGlyphCount; ++slot) {
const u32 cell_x = (slot % BitmapFont::kAtlasCols) * BitmapFont::kGlyphPx;
const u32 cell_y = (slot / BitmapFont::kAtlasCols) * BitmapFont::kGlyphPx;
for (u32 y = 0; y < BitmapFont::kGlyphPx; ++y) {
const u8 bits = kGlyphBits[slot][y];
for (u32 x = 0; x < BitmapFont::kGlyphPx; ++x) {
image[(cell_y + y) * BitmapFont::kAtlasWidth + cell_x + x] =
static_cast<u8>(((bits >> x) & 1u) != 0u ? 0xFFu : 0x00u);
}
}
}
return image;
}
constexpr std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> kAtlasImage =
pack_atlas();
/// Span of set bits in a glyph's rows: 0 for a blank glyph such as space.
constexpr u32 ink_width(const u8* rows) noexcept {
u32 lo = BitmapFont::kGlyphPx;
u32 hi = 0;
bool any = false;
for (u32 y = 0; y < BitmapFont::kGlyphPx; ++y) {
for (u32 x = 0; x < BitmapFont::kGlyphPx; ++x) {
if (((rows[y] >> x) & 1u) != 0u) {
if (x < lo) {
lo = x;
}
if (x > hi) {
hi = x;
}
any = true;
}
}
}
return any ? hi - lo + 1u : 0u;
}
} // namespace
// The decoder is public because the text batch stages glyphs from the same
// codepoints `measure` sums: one implementation keeps "what a string is worth"
// and "what a string draws" the same thing.
u32 BitmapFont::next_codepoint(const char*& p) noexcept {
const auto b0 = static_cast<u32>(static_cast<unsigned char>(p[0]));
if (b0 < 0x80u) {
++p;
return b0;
}
u32 codepoint = 0;
u32 extra = 0;
if ((b0 & 0xE0u) == 0xC0u) {
codepoint = b0 & 0x1Fu;
extra = 1u;
} else if ((b0 & 0xF0u) == 0xE0u) {
codepoint = b0 & 0x0Fu;
extra = 2u;
} else if ((b0 & 0xF8u) == 0xF0u) {
codepoint = b0 & 0x07u;
extra = 3u;
} else {
++p;
return kInvalidCodepoint;
}
for (u32 i = 1; i <= extra; ++i) {
const auto b = static_cast<u32>(static_cast<unsigned char>(p[i]));
if ((b & 0xC0u) != 0x80u) { // truncated: stop before the terminator
++p;
return kInvalidCodepoint;
}
codepoint = (codepoint << 6) | (b & 0x3Fu);
}
p += extra + 1u;
constexpr u32 kMinForLength[4] = {0u, 0x80u, 0x800u, 0x10000u};
if (codepoint < kMinForLength[extra] || codepoint > 0x10FFFFu ||
(codepoint >= 0xD800u && codepoint <= 0xDFFFu)) {
return kInvalidCodepoint; // overlong, out of range or a surrogate
}
return codepoint;
}
BitmapFont::BitmapFont() noexcept {
for (u32 slot = 0; slot < kGlyphCount; ++slot) {
const u32 ink = ink_width(kGlyphBits[slot]);
const u32 with_spacing = ink + 1u;
advances_[slot] = static_cast<f32>(with_spacing < 2u ? 2u : with_spacing);
}
}
f32 BitmapFont::measure(const char* utf8) const {
if (utf8 == nullptr) {
return 0.0f;
}
f32 width = 0.0f;
for (const char* p = utf8; *p != '\0';) {
width += advances_[glyph_index(next_codepoint(p))];
}
return width;
}
f32 BitmapFont::height() const { return static_cast<f32>(kGlyphPx); }
f32 BitmapFont::ascent() const { return static_cast<f32>(kGlyphPx - 1u); }
f32 BitmapFont::advance(char c) const noexcept {
return advances_[glyph_index(static_cast<u32>(static_cast<unsigned char>(c)))];
}
f32 BitmapFont::advance_codepoint(u32 codepoint) const noexcept {
return advances_[glyph_index(codepoint)];
}
u32 BitmapFont::glyph_index(u32 codepoint) noexcept {
if (codepoint >= kFirstChar && codepoint < kFirstChar + kAsciiGlyphs) {
return codepoint - kFirstChar;
}
switch (codepoint) {
case 0x00B0u:
return kDegreeSlot;
case 0x00B5u:
return kMicroSlot;
case 0x03A9u:
return kOhmSlot;
default:
return kFallbackSlot;
}
}
u32 BitmapFont::glyph_rows(char) const noexcept { return kGlyphPx; }
const u8* BitmapFont::glyph_bitmap(char c) const noexcept {
return glyph_bitmap_of(glyph_index(static_cast<u32>(static_cast<unsigned char>(c))));
}
const u8* BitmapFont::glyph_bitmap_of(u32 slot) noexcept {
return kGlyphBits[slot < kGlyphCount ? slot : kFallbackSlot];
}
const u8* BitmapFont::atlas_image() noexcept { return kAtlasImage.data(); }
Vec2 BitmapFont::atlas_origin(u32 slot) noexcept {
const u32 s = slot < kGlyphCount ? slot : kFallbackSlot;
return Vec2{static_cast<f32>((s % kAtlasCols) * kGlyphPx),
static_cast<f32>((s / kAtlasCols) * kGlyphPx)};
}
} // namespace plot::render
+192
View File
@@ -0,0 +1,192 @@
#include <plot/render/buffer.hpp>
#include <plot/gl/api.hpp>
#include <plot/render/detail/ring_stats.hpp>
#include <algorithm>
#include <cstring>
namespace plot::render {
namespace detail {
bool InstanceStore::create(UploadMode mode, Status* st) noexcept {
// plot::gl::Buffer::create() already reports Unsupported while the entry
// points are unresolved and GLFailure when the driver hands out no name, so
// the wrapper only has to propagate its answer.
if (!buffer_.create(st)) {
return false;
}
capacity_ = 0;
slot_bytes_ = 0;
slot_count_ = kInitialSlots;
slot_ = 0;
frame_uploads_ = 0;
// The mapped path additionally needs its entry points, which are resolved
// best-effort (see api.hpp): without them the store would never be able to
// write anything, so it stays on the baseline path instead.
mode_ = mode == UploadMode::PersistentMapped && gl::glBufferStorage != nullptr &&
gl::glMapBufferRange != nullptr
? UploadMode::PersistentMapped
: UploadMode::Orphan;
return true;
}
void InstanceStore::release_storage() noexcept {
if (mapped_ != nullptr) {
// A persistent mapping outlives the frame that created it, so it has to be
// dropped before the buffer it belongs to is deleted.
buffer_.unmap();
mapped_ = nullptr;
}
frame_fence_.reset();
buffer_.reset();
slot_bytes_ = 0;
}
void InstanceStore::drop_to_orphan() noexcept {
// The hazard the ring exists to prevent -- writing storage the GPU may still be
// reading -- can no longer be guarded, and a frame that draws through the
// baseline path beats one that draws nothing. The old buffer is deleted while
// the GPU may still read it, which is safe: GL keeps its storage alive until the
// last command that references it retires.
release_storage();
mode_ = UploadMode::Orphan;
capacity_ = 0;
frame_uploads_ = 0;
Status retry;
(void)buffer_.create(&retry); // a failed retry leaves nothing to draw with
}
bool InstanceStore::grow_persistent(usize bytes, u32 slots) noexcept {
release_storage();
Status st;
if (!buffer_.create(&st)) {
mode_ = UploadMode::Orphan; // nothing left to draw with at all
capacity_ = 0;
return false;
}
const gl::GLbitfield flags =
gl::GL_MAP_WRITE_BIT | gl::GL_MAP_PERSISTENT_BIT | gl::GL_MAP_COHERENT_BIT;
const usize total = usize(slots) * bytes;
buffer_.storage(total, flags);
mapped_ = static_cast<u8*>(buffer_.map_range(0, total, flags));
if (mapped_ == nullptr) {
// The driver refused the storage or the mapping. A frame that draws through
// the baseline path beats one that draws nothing, so re-create the empty
// buffer the orphan path needs and let the next upload use it. The vertex
// array's attribute pointers went out with the buffer they captured, and the
// epoch reports it (`gl::Buffer::generation()`), which is what re-points them
// at this buffer before the draw.
drop_to_orphan();
return false;
}
slot_bytes_ = bytes;
slot_count_ = slots;
slot_ = 0;
// Fresh storage: every one of its slots is unwritten, so the frame's slot
// cursor restarts at 0. The draws that read the old buffer are already in the
// command stream and keep it alive until they retire.
frame_uploads_ = 0;
#ifdef PLOT_RING_STATS
note_ring_slots(slot_count_);
#endif
return true;
}
bool InstanceStore::begin_frame() noexcept {
const u32 written = frame_uploads_;
frame_uploads_ = 0;
if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) {
return true; // nothing the GPU reads, so no slot has to be waited for
}
if (written == 0) {
return true; // the frame before this one never read the ring
}
if (!frame_fence_) {
// Slots were written and no fence was ever recorded for them: the caller
// skipped mark_drawn(), so those slots are unguarded and writing any of them
// again could race a read still in flight. The ring is abandoned rather than
// reused, exactly as a refused fence's is.
drop_to_orphan();
return false;
}
#ifdef PLOT_RING_STATS
ring_waits().fetch_add(1, std::memory_order_relaxed);
#endif
// The slots this frame is about to write were all last written a frame ago, and
// this one fence sits behind every draw that read them -- unless the driver has
// not retired it, in which case writing would race the GPU and the ring is
// abandoned for the baseline path. The non-blocking check is the common case.
const bool retired = frame_fence_.wait(0) || frame_fence_.wait(kFenceWaitNs);
frame_fence_.reset();
if (retired) {
return true;
}
drop_to_orphan();
return false;
}
void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) noexcept {
if (buffer_.id() == 0 || data == nullptr || count == 0 || instance_bytes == 0) {
return;
}
const usize bytes = usize(count) * instance_bytes;
if (mode_ == UploadMode::PersistentMapped &&
(bytes > slot_bytes_ || frame_uploads_ >= slot_count_)) {
// The frame has written every slot the ring has, or this instance payload is
// wider than a slot: grow rather than wrap. Covering the frame's upload count
// is what lets a single fence, recorded after its last draw, guard all of its
// slots -- and doubling makes the growth rare for a workload whose upload
// count has settled.
const usize want_bytes = std::max(bytes, slot_bytes_);
const u32 want_slots = frame_uploads_ >= slot_count_
? std::max(frame_uploads_ + 1u, slot_count_ * 2u)
: slot_count_;
(void)grow_persistent(want_bytes, want_slots); // a failure leaves the orphan path
}
if (mode_ == UploadMode::Orphan) {
if (count > capacity_) {
capacity_ = count; // grow only when the workload outgrows the store
}
buffer_.upload(nullptr, usize(capacity_) * instance_bytes, gl::GL_STREAM_DRAW);
buffer_.upload_sub(data, bytes, 0);
return;
}
// The slot this frame has not written yet: the frame's write cursor is the ring
// index, so a frame writes 0, 1, 2, ... and never reaches a slot it already
// filled. That is the whole reason one fence at the end of the frame is enough.
slot_ = frame_uploads_;
#ifdef PLOT_RING_STATS
ring_uploads().fetch_add(1, std::memory_order_relaxed);
#endif
// MAP_COHERENT storage needs no explicit flush, so this is the whole transfer.
std::memcpy(mapped_ + usize(slot_) * slot_bytes_, data, bytes);
++frame_uploads_;
++slot_epoch_;
}
bool InstanceStore::mark_drawn() noexcept {
if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) {
return true; // nothing reads the store after the draw
}
if (frame_uploads_ == 0) {
return true; // no draw read the ring this frame, so it needs no guard
}
Status st;
frame_fence_.reset();
if (frame_fence_.create(&st)) {
return true;
}
// A frame whose fence the driver refused leaves the slots it wrote
// unsynchronised, and such a slot can never be written again safely: the next
// upload would memcpy into storage the GPU may still be reading, which is the
// tearing the ring exists to prevent. So the ring is abandoned for the orphan
// path, which is what the map failure's treatment does one step earlier.
drop_to_orphan();
return false;
}
} // namespace detail
} // namespace plot::render
+74
View File
@@ -0,0 +1,74 @@
#include <plot/render/primitives.hpp>
namespace plot::render {
// ----------------------------------------------------------------- LineBatch
void LineBatch::begin() noexcept { v_.clear(); }
void LineBatch::clear() noexcept { v_.clear(); }
void LineBatch::add_segment(Vec2 a, Vec2 b) {
v_.push_back(a.x);
v_.push_back(a.y);
v_.push_back(b.x);
v_.push_back(b.y);
}
void LineBatch::add_polyline(const Vec2* pts, u32 n) {
if (pts == nullptr || n < 2u) {
return; // no pair of points, no segment
}
// Grow once for the whole polyline rather than per segment, so a frame that
// stages the same workload as the last one never reallocates.
v_.reserve(v_.size() + 4u * (n - 1u));
for (u32 i = 1; i < n; ++i) {
add_segment(pts[i - 1u], pts[i]);
}
}
void LineBatch::add_polyline_xy(const f32* xy, u32 n) {
if (xy == nullptr || n < 2u) {
return;
}
v_.reserve(v_.size() + 4u * (n - 1u));
for (u32 i = 1; i < n; ++i) {
v_.push_back(xy[2u * (i - 1u)]);
v_.push_back(xy[2u * (i - 1u) + 1u]);
v_.push_back(xy[2u * i]);
v_.push_back(xy[2u * i + 1u]);
}
}
// ----------------------------------------------------------------- TextBatch
void TextBatch::begin() noexcept { v_.clear(); }
void TextBatch::clear() noexcept { v_.clear(); }
void TextBatch::add_glyph(Vec2 pos_px, u32 slot) {
const Vec2 origin = BitmapFont::atlas_origin(slot);
v_.push_back(pos_px.x);
v_.push_back(pos_px.y);
v_.push_back(origin.x);
v_.push_back(origin.y);
}
Vec2 TextBatch::add_text(Vec2 pen_px, const char* utf8, const BitmapFont& font, f32 px_per_cell) {
if (utf8 == nullptr) {
return pen_px;
}
const f32 scale = px_per_cell / static_cast<f32>(BitmapFont::kGlyphPx);
for (const char* p = utf8; *p != '\0';) {
// Same codepoints `measure` sums and the same advances, scaled by the
// on-screen cell size: a run staged here is exactly what the plot layer
// measured, times the scale it draws at, so a label never overruns the
// space that was reserved for it.
const u32 codepoint = BitmapFont::next_codepoint(p);
add_glyph(pen_px, BitmapFont::glyph_index(codepoint));
pen_px.x += font.advance_codepoint(codepoint) * scale;
}
return pen_px;
}
} // namespace plot::render
+50
View File
@@ -0,0 +1,50 @@
#include <plot/render/program.hpp>
#include <plot/render/shaders.hpp>
namespace plot::render {
bool LineProgram::create(Status* st) noexcept {
// plot::gl::Program::from_source() reports Unsupported while
// plot::gl::available() is false and GLFailure when the driver rejects the
// GLSL. Propagating it is the whole point of returning a bool here: the
// caller must not end up drawing with a program that never linked.
program_ = gl::Program::from_source(kLineVS, kLineFS, st);
return program_.id() != 0;
}
void LineProgram::set_scale(Vec2 data_to_px) const noexcept {
program_.set_uniform("u_scale", data_to_px);
}
void LineProgram::set_offset(Vec2 px) const noexcept { program_.set_uniform("u_offset", px); }
void LineProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
void LineProgram::set_width(f32 px) const noexcept { program_.set_uniform("u_width", px); }
void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
bool TextProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
if (program_.id() == 0) {
return false;
}
// The atlas is bound to texture unit 0 by plot::gl::Texture::bind(), so the
// sampler has to point there. It is set once, here, not per frame.
program_.use();
program_.set_uniform_int("u_atlas", 0);
return true;
}
void TextProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
void TextProgram::set_px_range(Vec2 px) const noexcept { program_.set_uniform("u_px_range", px); }
void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels);
}
void TextProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
} // namespace plot::render
+741
View File
@@ -0,0 +1,741 @@
#include <plot/render/renderer.hpp>
#include <plot/gl/api.hpp>
#include <plot/gl/caps.hpp>
#include <plot/gl/loader.hpp>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <vector>
namespace plot {
namespace {
/// `PLOT_FORCE_GL33_UPLOAD=1` forces the baseline transfer, like
/// `PLOT_HEADLESS_FORCE_PBUFFER` forces the EGL fallback: presence is the switch,
/// so both strategies can be exercised on one machine.
bool force_gl33_upload() noexcept { return std::getenv("PLOT_FORCE_GL33_UPLOAD") != nullptr; }
bool fail(Status* st, ErrorCode code, const char* message) noexcept {
if (st != nullptr) {
st->code = code;
st->message = message;
}
return false;
}
/// A data-space point in the coordinate the line program's affine part expects:
/// the value itself on a linear axis, `log10` of it on a log axis -- exactly what
/// `math::Transform::to_screen_*` applies before scaling. A non-positive value on
/// a log axis takes the transform's own clamp, so it lands off-scale rather than
/// as NaN.
Vec2 pre_affine(f64 x, f64 y, bool log_x, bool log_y) noexcept {
return Vec2{static_cast<f32>(log_x ? Transform::log10_positive(x) : x),
static_cast<f32>(log_y ? Transform::log10_positive(y) : y)};
}
/// Sample index for a (possibly fractional, possibly non-finite) position in a
/// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a
/// range the axis cannot map still yields a valid window.
u64 clamp_index(f64 v, u64 n) noexcept {
if (!(v > 0.0)) return 0;
if (v >= static_cast<f64>(n)) return n;
return static_cast<u64>(v);
}
/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
/// three significant digits plus a prefix and sign.
constexpr usize kNumberChars = 24;
constexpr usize kReadoutChars = 160;
/// Walks the longest prefix of `utf8` that fits `budget_px`, calling `emit(pen,
/// slot)` for every glyph it keeps -- `pen` in viewport pixels, `slot` in the font
/// atlas -- and returning the width that prefix occupies. Both the measuring and
/// the staging of a clipped run walk this, so the cut lands on the same pixel
/// either way; the codepoints, the advances and the scale are the ones
/// `TextBatch::add_text()` uses, so a clipped run still ends where the plot layer
/// measured its title to end.
template <class Emit>
f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::BitmapFont& font,
f32 px_per_cell, Emit emit) {
if (utf8 == nullptr || !(budget_px > 0.0f)) {
return 0.0f; // no room at all: a strip clipped away stages nothing
}
const f32 scale = px_per_cell / static_cast<f32>(render::BitmapFont::kGlyphPx);
f32 used = 0.0f;
for (const char* p = utf8; *p != '\0';) {
const u32 codepoint = render::BitmapFont::next_codepoint(p);
const f32 advance = font.advance_codepoint(codepoint) * scale;
if (used + advance > budget_px) break; // the next glyph does not fit: cut here
emit(pen, render::BitmapFont::glyph_index(codepoint));
pen.x += advance;
used += advance;
}
return used;
}
#ifndef NDEBUG
/// A host that installed its own `TextBackend` is told its metrics are gone: the
/// renderer draws every label with its artwork's font, so laying the strips out
/// with a different one puts the labels wherever that font's metrics happen to
/// fall. A diagnostic rather than an abort -- a host's frame must still draw --
/// and debug-only, like `plot::gl::load()`'s unresolved-entry-point notice.
void warn_replaced_text_backend(const render::TextBackend* previous) noexcept {
std::fprintf(stderr,
"plot::Renderer::draw: replacing the Plot's TextBackend (%p) with the renderer's "
"own bitmap font; a plot drawn by a Renderer measures the labels it draws.\n",
static_cast<const void*>(previous));
}
#endif
} // namespace
void Renderer::AxisFont::reset(const render::BitmapFont& font, f32 requested_px) noexcept {
font_ = &font;
const f32 native = static_cast<f32>(render::BitmapFont::kGlyphPx);
// Whole multiples of the native cell only: a 1-bit glyph drawn at 1.5x lands
// its strokes on uneven pixel counts, and the layout has to agree with what the
// pipeline draws, so the requested size picks the nearest multiple.
f32 steps = std::round(requested_px / native);
if (!(steps >= 1.0f)) steps = 1.0f; // also catches NaN
cell_px_ = steps * native;
scale_ = cell_px_ / native;
}
f32 Renderer::AxisFont::measure(const char* utf8) const {
return font_ != nullptr ? font_->measure(utf8) * scale_ : 0.0f;
}
f32 Renderer::AxisFont::height() const {
return font_ != nullptr ? font_->height() * scale_ : 0.0f;
}
f32 Renderer::AxisFont::ascent() const {
return font_ != nullptr ? font_->ascent() * scale_ : 0.0f;
}
// ------------------------------------------------------------------ init
bool Renderer::init(Status* st) noexcept {
if (!gl::available()) {
return fail(st, ErrorCode::Unsupported,
"plot::gl::load() has not resolved the GL entry points");
}
// The transfer strategy is decided once, from the capability probe, and never
// revisited. Both pointers are checked as well as the caps: they are resolved
// best-effort (see api.hpp), so a context can advertise the extension without
// the entry points being reachable.
const gl::Caps& caps = gl::caps();
const bool persistent = caps.buffer_storage && caps.persistent_map &&
gl::glBufferStorage != nullptr && gl::glMapBufferRange != nullptr &&
!force_gl33_upload();
const render::UploadMode mode =
persistent ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
font_backend_.reset(font_, style_.axis_font_px);
// The atlas upload needs GL_UNPACK_ALIGNMENT 1 (single-channel rows have no
// padding), which is the only global state `init()` changes; the host's value is
// read first so `end_frame()` can hand it back rather than leave the host's own
// uploads to the alignment this renderer wanted.
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &host_unpack_alignment_);
if (!atlas_.create_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight, st)) {
return false;
}
atlas_.upload_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight,
render::BitmapFont::atlas_image());
// Texel-for-texel: the text shader samples the nearest texel centre of the
// glyph cell, and linear filtering would smear one cell into its neighbour.
atlas_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
if (!line_program_.create(st) || !text_program_.create(st)) {
return false;
}
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) {
return false;
}
ready_ = true;
return true;
}
// ------------------------------------------------------------ frame cycle
void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
fb_w_ = fb_w;
fb_h_ = fb_h;
viewport_ = viewport_px;
lines_.clear();
glyphs_.clear();
stats_ = FrameStats{};
gl_error_ = false;
// Rect is top-left origin, GL is bottom-left: flip once, here, and never again.
gl_viewport_ = Rect{viewport_px.x,
static_cast<f32>(fb_h) - (viewport_px.y + viewport_px.h),
viewport_px.w, viewport_px.h};
if (!ready_) {
return;
}
// One wait per store, before any slot of this frame is written: the ring never
// synchronises with the work its own frame issued, only with the frame before
// it. A store that cannot retire that frame has dropped to the baseline path,
// which the frame reports.
const bool lines_retired = line_vao_.begin_frame();
const bool glyphs_retired = text_vao_.begin_frame();
if (!lines_retired || !glyphs_retired) {
gl_error_ = true;
}
const gl::GLint vx = static_cast<gl::GLint>(gl_viewport_.x);
const gl::GLint vy = static_cast<gl::GLint>(gl_viewport_.y);
const gl::GLsizei vw = static_cast<gl::GLsizei>(gl_viewport_.w);
const gl::GLsizei vh = static_cast<gl::GLsizei>(gl_viewport_.h);
// Clear only the plot's region: a host that embeds the plot in a surface it has
// already painted keeps everything outside the rect. The scissor box is in
// framebuffer coordinates and is not affected by glViewport, which is why the
// clear happens before the viewport is set.
//
// The host's clear colour is queried once, before the first frame sets its own:
// GL keeps no history for it, so this is the only moment the value can be
// recovered for `end_frame()` to hand back. A host that changes its own clear
// colour *after* the first frame therefore gets the first frame's value restored
// -- the renderer cannot tell its own writes from the host's -- which is still
// the host's colour, and stated as such in end_frame()'s contract.
if (!host_clear_saved_) {
f32 rgba[4] = {0.0f, 0.0f, 0.0f, 0.0f};
gl::glGetFloatv(gl::GL_COLOR_CLEAR_VALUE, rgba);
host_clear_ = Color{rgba[0], rgba[1], rgba[2], rgba[3]};
host_clear_saved_ = true;
}
gl::glEnable(gl::GL_SCISSOR_TEST);
gl::glScissor(vx, vy, vw, vh);
const Color& background = style_.background;
gl::glClearColor(background.r, background.g, background.b, background.a);
gl::glClear(gl::GL_COLOR_BUFFER_BIT);
gl::glDisable(gl::GL_SCISSOR_TEST);
gl::glViewport(vx, vy, vw, vh);
// The pipelines write straight alpha and rely on blending being on; setting it
// per frame costs nothing and makes the renderer independent of what the host
// left behind.
gl::glEnable(gl::GL_BLEND);
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
}
void Renderer::draw(Plot& plot) {
stats_ = FrameStats{};
if (!ready_) {
return;
}
// The plot reserves its label strips from TextBackend metrics, so it must
// measure with the size the text pipeline draws with, and re-read the style
// each frame so a changed axis_font_px takes effect on the next one.
font_backend_.reset(font_, style_.axis_font_px);
#ifndef NDEBUG
// The renderer owns the text of the frames it draws: every label goes through
// the text pipeline in `font_`, so the plot is handed this font's metrics
// whatever a host installed. A host backend is therefore replaced on the first
// draw -- said out loud in a debug build, since its disappearance is otherwise
// silent -- and a Plot nobody draws keeps it (see Plot::set_text_backend()).
if (const render::TextBackend* previous = plot.text_backend();
previous != nullptr && previous != &font_backend_) {
warn_replaced_text_backend(previous);
}
#endif
plot.set_text_backend(&font_backend_);
// One style, not two: the plot reserves its strips and picks its tick density
// from the values the renderer draws with, so the label pad is never one number
// for the layout and another for the text placed inside it.
plot.set_style(style_);
// One layout for the whole frame: autoscale, the strip fixed-point loop with
// its tick rebuilds, and the grid build all run in here, and every helper
// below reads the strips, the axes rect and the transforms it pins.
const PlotLayout& layout = plot.layout();
// Draw order: chrome, then data, then the annotations over it, and the text
// last (it is texture-bound, so it is one draw call after every line batch).
draw_grid(plot);
draw_axis_lines(plot);
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
draw_traces(plot, layout);
draw_cursors(plot, layout);
draw_measures(plot, layout);
draw_annotations(plot);
flush_text();
}
void Renderer::end_frame() noexcept {
if (!ready_) {
return;
}
// The frame's single fence per store, behind every draw that read the ring: the
// next frame's begin_frame() waits for it, and until then nothing is written to
// a slot these draws may still be reading. Recording it here -- after the last
// draw, not after each upload -- is what keeps a frame from waiting on itself.
const bool lines_guarded = line_vao_.mark_drawn();
const bool glyphs_guarded = text_vao_.mark_drawn();
if (!lines_guarded || !glyphs_guarded) {
gl_error_ = true;
}
// Leave the host's context as close to neutral as a frame can: nothing bound to
// a pipeline stage, off every state bit the frame turned on, and the two values
// the frame *changed* rather than merely used handed back -- the unpack
// alignment `init()` set for the atlas, and the host's clear colour. What
// remains is the viewport (the caller's to set for its next pass), the pixel-pack
// alignment a PPM dump may have set, and the vertex buffers' own contents.
gl::glBindVertexArray(0);
gl::glUseProgram(0);
gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0);
gl::glActiveTexture(gl::GL_TEXTURE0);
gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
gl::glDisable(gl::GL_BLEND);
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_unpack_alignment_);
if (host_clear_saved_) {
gl::glClearColor(host_clear_.r, host_clear_.g, host_clear_.b, host_clear_.a);
}
// Drain, do not peek: the first error hides every later one, and a frame that
// produced several must be reported as having produced one.
while (gl::glGetError() != gl::GL_NO_ERROR) {
gl_error_ = true;
}
}
// ------------------------------------------------------------ submission
void Renderer::upload_vertices(std::span<const f32> interleaved) noexcept {
// Four floats per instance is the line pipeline's layout (see LineInstance).
// Both transfer strategies live behind the vertex array, so this is the only
// place the line pipeline's staged bytes reach the GPU and no caller of it can
// tell which path ran.
line_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 4u));
}
void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 4u));
}
void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept {
const u32 segments = lines_.segment_count();
if (segments != 0 && ready_) {
upload_vertices(lines_.data());
line_program_.bind();
line_program_.set_scale(map.scale);
line_program_.set_offset(map.offset);
line_program_.set_viewport({viewport_.w, viewport_.h});
line_program_.set_width(width);
line_program_.set_color(color);
line_vao_.bind();
// One triangle strip per instance, four corners from gl_VertexID.
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(line_vao_.instance_count()));
stats_.staged_segments += segments;
++stats_.draw_calls;
}
lines_.clear(); // keeps the capacity, so the next group re-uses the store
}
void Renderer::flush_text() noexcept {
const u32 glyphs = glyphs_.glyph_count();
if (glyphs != 0 && ready_) {
upload_text_vertices(glyphs_.data());
text_program_.bind();
text_program_.set_viewport({viewport_.w, viewport_.h});
const f32 cell = font_backend_.cell_px();
text_program_.set_px_range({cell, cell});
text_program_.set_texture_size(
{static_cast<f32>(render::BitmapFont::kAtlasWidth),
static_cast<f32>(render::BitmapFont::kAtlasHeight)});
text_program_.set_color(style_.axis_text);
atlas_.bind();
text_vao_.bind();
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(text_vao_.instance_count()));
++stats_.draw_calls;
}
glyphs_.clear();
}
// ----------------------------------------------------------- coordinates
Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept {
const Transform tx = plot.x_transform(axis_x);
const Transform ty = plot.y_transform(axis_y);
// The plot's transforms already map data to framebuffer pixels; the shaders
// emit NDC relative to the GL viewport, so the viewport origin comes off here
// -- once per mapping, never per primitive.
return Mapping{Vec2{static_cast<f32>(tx.sx), static_cast<f32>(ty.sy)},
Vec2{static_cast<f32>(tx.tx) - viewport_.x,
static_cast<f32>(ty.ty) - viewport_.y}};
}
Renderer::Mapping Renderer::pixel_mapping() const noexcept {
// Geometry already in framebuffer pixels needs no scaling, and the line
// program's offset uniform does the viewport shift. (The text pipeline has no
// offset uniform, so stage_text() shifts its positions itself.)
return Mapping{Vec2{1.0f, 1.0f}, Vec2{-viewport_.x, -viewport_.y}};
}
Vec2 Renderer::to_viewport(Vec2 px) const noexcept {
return Vec2{px.x - viewport_.x, px.y - viewport_.y};
}
void Renderer::stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y) {
lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y));
}
void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) {
if (!log_x && !log_y) {
// The common case: gathered points are already interleaved `x,y`, which is
// the batch's own payload, so this is a straight copy.
lines_.add_polyline_xy(xy, n);
return;
}
for (u32 i = 0; i + 1u < n; ++i) {
stage_segment(xy[2u * i], xy[2u * i + 1u], xy[2u * i + 2u], xy[2u * i + 3u], log_x, log_y);
}
}
Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
// Text is staged in viewport pixels: the text program has no offset uniform.
return glyphs_.add_text(to_viewport(pos_px), utf8, font_, font_backend_.cell_px());
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const {
return walk_text_clipped(Vec2{}, utf8, budget_px, font_, font_backend_.cell_px(),
[](Vec2, u32) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px) {
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, font_, font_backend_.cell_px(),
[this](Vec2 pen, u32 slot) { glyphs_.add_glyph(pen, slot); });
}
// -------------------------------------------------------------------- grid
void Renderer::draw_grid(const Plot& plot) {
const Grid& grid = plot.grid();
const Mapping map = mapping(plot, 0, 0);
// The grid is built from the primary axis pair, so it is mapped with their
// transforms even when a trace uses another pair.
const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_axis(0).log;
// Two colour groups, one pass over the segments each: `LineBatch::clear()`
// keeps its capacity, so the second group re-uses the first one's storage.
for (u32 pass = 0; pass < 2u; ++pass) {
const bool majors = pass == 0u;
for (const GridLine& line : grid.lines()) {
if (line.major != majors) continue; // grid.build() already dropped non-finite ones
stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y);
}
flush_lines(map, majors ? style_.grid_major : style_.grid_minor, 1.0f);
}
}
// ------------------------------------------------------------- axis chrome
void Renderer::draw_axis_lines(const Plot& plot) {
const Range y_range = plot.y_range(0);
const Range x_range = plot.x_range(0);
const bool log_x0 = plot.x_axis(0).log;
const bool log_y0 = plot.y_axis(0).log;
// An x axis draws along its edge of the axes rect, spanning its own range; the
// perpendicular extent is the primary y axis' range, which is exactly what
// maps to the rect's top and bottom.
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i);
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0);
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.line_width_px);
}
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i);
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log);
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.line_width_px);
}
}
void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 line_h = font_backend_.height();
// X labels: centred under their tick, on the strip's single text line.
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
const Transform transform = plot.x_transform(i);
for (const Tick& tick : plot.x_axis(i).ticks) {
if (!tick.major || tick.label.empty()) continue;
const f32 width = font_backend_.measure(tick.label.c_str());
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)},
tick.label.c_str());
}
}
// Y labels: right-aligned in their strip, centred on the tick and pulled back
// inside the strip at the two extremes (a label centred on the top tick would
// otherwise hang above the plot area).
const f32 top_limit =
std::max(layout.y_label_area.y, layout.y_label_area.bottom() - line_h);
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Transform transform = plot.y_transform(i);
for (const Tick& tick : plot.y_axis(i).ticks) {
if (!tick.major || tick.label.empty()) continue;
const f32 width = font_backend_.measure(tick.label.c_str());
const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value));
const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit);
stage_text({layout.y_label_area.right() - style_.grid_label_pad - width, top},
tick.label.c_str());
}
}
}
void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// Every title goes in the strip its direction reserved for it, and every titled
// axis gets its own text line inside that strip: one row per axis is what keeps
// a Left/Right (or Bottom/Top) pair of titles from being drawn over each other,
// where the pass before this one put them all on one centred run. Each run is
// also cut to its strip, so a title longer than the strip -- which the layout
// caps, because a title may shrink the plot but never erase it -- stops at the
// strip's padding instead of spilling over the labels or the data.
const f32 pad = style_.grid_label_pad;
const f32 line_h = font_backend_.height();
// X titles: the strip's rows top down, one per titled axis.
for (u32 i = 0, row = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i);
if (axis.label.empty()) continue;
const Rect& strip = layout.x_title_area;
const f32 budget = strip.w - 2.0f * pad;
const f32 width = clipped_text_width(axis.label.c_str(), budget);
// Centred on the strip: the tick labels have their own rows above it, so the
// title needs no alignment of its own.
stage_text_clipped({strip.x + 0.5f * (strip.w - width),
strip.y + pad + static_cast<f32>(row) * line_h},
axis.label.c_str(), budget);
++row;
}
// Y titles: the same, stacked down the axes-tall strip and centred on it as a
// group, so the single titled axis an ordinary plot has keeps the row it always
// had. A y title reads horizontally like the x ones, so its width is what the
// strip had to reserve; the extra lines cost height, which the axes already
// provides.
const f32 stack = static_cast<f32>(plot.titled_y_axis_count()) * line_h;
for (u32 i = 0, row = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i);
if (axis.label.empty()) continue;
const Rect& strip = layout.y_title_area;
const f32 budget = strip.w - 2.0f * pad;
const f32 width = clipped_text_width(axis.label.c_str(), budget);
const f32 top = strip.y + std::max(0.0f, 0.5f * (strip.h - stack)) +
static_cast<f32>(row) * line_h;
stage_text_clipped({strip.x + 0.5f * (strip.w - width), top}, axis.label.c_str(), budget);
++row;
}
}
// ------------------------------------------------------------------- data
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// Both dimensions: a zero-width rect selects no level, and a zero-height one
// would flatten every trace onto a single row -- a frame that looks like a
// rendering choice rather than the collapsed plot it is.
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) {
return;
}
for (u32 i = 0; i < plot.trace_count(); ++i) {
const Trace& trace = plot.trace(i);
if (!trace.visible()) continue; // null series, or one with no sample yet
const data::Series& series = *trace.series;
const f64 dx = series.dx();
const u64 n = series.size();
if (!(dx > 0.0) || n == 0) continue;
// Map the visible x range onto sample indices. One sample of slack on each
// side covers a partially visible end bucket; the range is clamped to the
// series, so a zoomed-out view never asks for more than exists.
const Range x_range = plot.x_range(trace.axis_x);
f64 lo = (x_range.min - series.x0()) / dx;
f64 hi = (x_range.max - series.x0()) / dx;
if (hi < lo) std::swap(lo, hi);
const u64 first = clamp_index(std::floor(lo) - 1.0, n);
const u64 last = clamp_index(std::ceil(hi) + 1.0, n);
if (last <= first) continue;
// This is the whole performance contract: the window, never the series.
const u64 visible = last - first;
const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w);
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
? series.pyramid().level_for(n, samples_per_pixel)
: 0u;
gather_.clear();
series.gather(level, first, last, gather_);
if (gather_.size() < 4u) continue; // fewer than two points is no segment
stats_.visible_samples += visible;
if (level == 0u) {
++stats_.raw_traces;
} else {
++stats_.lod_traces;
}
stage_xy(gather_.data(), static_cast<u32>(gather_.size() / 2u),
plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log);
flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color, trace.width);
}
}
void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
const Range y_range = plot.y_range(0);
const bool log_y = plot.y_axis(0).log;
for (u32 i = 0; i < plot.cursor_count(); ++i) {
const Cursor& cursor = plot.cursor(i);
if (!cursor.visible) continue;
const Transform transform = plot.x_transform(cursor.axis_x);
const f32 px = static_cast<f32>(transform.to_screen_x(cursor.x));
// A cursor scrolled out of the visible x range is off the plot: drawing it
// would put a marker over the label strip.
if (px < layout.axes.x || px >= layout.axes.right()) continue;
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.line_width_px);
if (!cursor.label.empty()) {
stage_text({px + 3.0f, layout.axes.y + 2.0f}, cursor.label.c_str());
}
}
}
void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
const Rect& axes = layout.axes;
const Range y_range = plot.y_range(0);
const bool log_y = plot.y_axis(0).log;
const f32 line_h = font_backend_.height();
for (u32 i = 0; i < plot.measure_count(); ++i) {
const MeasureSpan span = plot.measure_span(i);
const Measurement& measure = plot.measurement(i);
if (!span.valid || !measure.valid) continue;
const Cursor& a = plot.cursor(span.cursor_a);
const Cursor& b = plot.cursor(span.cursor_b);
const Transform transform = plot.x_transform(a.axis_x);
f64 left_px = transform.to_screen_x(a.x);
f64 right_px = transform.to_screen_x(b.x);
if (right_px < left_px) std::swap(left_px, right_px);
const f32 x0 = std::max(static_cast<f32>(left_px), axes.x);
const f32 x1 = std::min(static_cast<f32>(right_px), axes.right());
if (x1 > x0) {
// The shaded band: one vertical stroke per pixel column of the interval.
// The line pipeline has no filled primitive, and the band is bounded by the
// plot's width, so a per-column fill stays O(pixels) and needs no second
// pipeline. Strokes are staged in data space like everything else, so the
// band follows a pan or a zoom through the uniform alone.
for (f32 px = x0; px < x1; px += 1.0f) {
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
}
flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f);
}
char dx[kNumberChars];
char dy[kNumberChars];
char rms[kNumberChars];
char mean[kNumberChars];
format_si(measure.dx, dx, kNumberChars);
format_si(measure.dy, dy, kNumberChars);
format_si(measure.rms, rms, kNumberChars);
format_si(measure.mean, mean, kNumberChars);
char readout[kReadoutChars];
std::snprintf(readout, kReadoutChars, "dx %s dy %s rms %s mean %s", dx, dy, rms, mean);
const f32 width = font_backend_.measure(readout);
const f32 limit = std::max(axes.x + 2.0f, axes.right() - width - 2.0f);
const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + 2.0f, limit);
// The second line of the plot area, not the first: the first belongs to the
// cursor labels, and a wide readout clamped to the left edge would overprint
// them. The y axis title is not a consideration here -- it lives in its own
// strip outside the axes.
stage_text({left, axes.y + line_h + 2.0f}, readout);
}
}
void Renderer::draw_annotations(const Plot& plot) {
const f32 line_h = font_backend_.height();
for (u32 i = 0; i < plot.annotation_count(); ++i) {
const Annotation& annotation = plot.annotation(i);
if (!annotation.visible) continue;
const f32 px = static_cast<f32>(plot.x_transform(annotation.axis_x).to_screen_x(annotation.x));
const f32 py = static_cast<f32>(plot.y_transform(annotation.axis_y).to_screen_y(annotation.y));
// A small cross pins the point; the caption sits to its right. Both are in
// framebuffer pixels, hence the identity mapping for the marker.
lines_.add_segment({px - 3.0f, py}, {px + 3.0f, py});
lines_.add_segment({px, py - 3.0f}, {px, py + 3.0f});
flush_lines(pixel_mapping(), annotation.color, 1.0f);
if (!annotation.text.empty()) {
stage_text({px + 5.0f, py - 0.5f * line_h}, annotation.text.c_str());
}
}
}
// -------------------------------------------------------------------- dump
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
if (path == nullptr || path[0] == '\0') {
return fail(st, ErrorCode::InvalidArgument, "PPM path is empty");
}
if (!ready_) {
return fail(st, ErrorCode::Unsupported, "renderer was never initialised");
}
const u32 width = static_cast<u32>(gl_viewport_.w);
const u32 height = static_cast<u32>(gl_viewport_.h);
if (width == 0 || height == 0) {
return fail(st, ErrorCode::InvalidArgument, "no frame has been drawn yet");
}
// RGBA, not RGB: 4-byte pixels have no row padding whatever the pack alignment,
// so a host's GL_PACK_ALIGNMENT cannot make the read overrun the buffer.
readback_.assign(usize(width) * height * 4u, 0u);
gl::glPixelStorei(gl::GL_PACK_ALIGNMENT, 4);
gl::glReadPixels(static_cast<gl::GLint>(gl_viewport_.x), static_cast<gl::GLint>(gl_viewport_.y),
static_cast<gl::GLsizei>(width), static_cast<gl::GLsizei>(height), gl::GL_RGBA,
gl::GL_UNSIGNED_BYTE, readback_.data());
std::FILE* file = std::fopen(path, "wb");
if (file == nullptr) {
return fail(st, ErrorCode::InvalidArgument, "cannot open the PPM path for writing");
}
std::fprintf(file, "P6\n%u %u\n255\n", width, height);
std::vector<u8> row(usize(width) * 3u);
bool ok = true;
for (u32 y = 0; y < height && ok; ++y) {
// glReadPixels hands back rows bottom-up; a PPM's rows run top-down.
const u8* src = readback_.data() + usize(height - 1u - y) * usize(width) * 4u;
for (u32 x = 0; x < width; ++x) {
row[usize(x) * 3u] = src[usize(x) * 4u];
row[usize(x) * 3u + 1u] = src[usize(x) * 4u + 1u];
row[usize(x) * 3u + 2u] = src[usize(x) * 4u + 2u];
}
ok = std::fwrite(row.data(), 1, row.size(), file) == row.size();
}
if (std::fclose(file) != 0) ok = false;
if (!ok) {
return fail(st, ErrorCode::InvalidArgument, "writing the PPM failed");
}
return true;
}
} // namespace plot
+115
View File
@@ -0,0 +1,115 @@
#include <plot/render/shaders.hpp>
// Both pipelines are `#version 330 core` and stay untouched by the upload
// strategy: the 4.3+ path changes how vertex data reaches the GPU, never the
// GLSL, so one program pair serves every context the library supports. The
// declarations here are the contract the render layer's attribute layouts and
// uniform setters mirror (see program.hpp, vertex_array.hpp, shaders.hpp).
namespace plot::render {
const char* const kLineVS = R"(#version 330 core
layout(location = 0) in vec2 a_p0; // per-instance segment start, data space
layout(location = 1) in vec2 a_p1; // per-instance segment end
uniform vec2 u_scale; // data -> pixels
uniform vec2 u_offset; // pixels
uniform vec2 u_viewport; // render target size, pixels
uniform float u_width; // stroke width, pixels
out float v_dist; // signed distance to the segment axis, pixels
out float v_along; // distance travelled from a_p0, pixels
void main() {
vec2 p0 = a_p0 * u_scale + u_offset;
vec2 p1 = a_p1 * u_scale + u_offset;
vec2 d = p1 - p0;
float len = length(d);
float half_w = u_width * 0.5 + 1.0; // + one pixel of AA fringe
// A degenerate segment has no direction: pin the normal, and give the quad
// area by stretching its two ends apart along x. Collapsing all four corners
// onto p0 would rasterise nothing, and a flat trace staged as p == p (the
// dense path's min == max buckets) would then silently vanish. When
// len > 1e-6 the two ends are p0 and p1 exactly as before.
vec2 n = len > 1e-6 ? vec2(-d.y, d.x) / len : vec2(0.0, 1.0);
vec2 e0 = len > 1e-6 ? p0 : p0 - vec2(half_w, 0.0);
vec2 e1 = len > 1e-6 ? p1 : p0 + vec2(half_w, 0.0);
// gl_VertexID picks the corner: 0,1 are the sides of the segment's start and
// 2,3 of its end, which is the order GL_TRIANGLE_STRIP expects.
float side = (gl_VertexID & 1) == 0 ? -1.0 : 1.0;
vec2 base = (gl_VertexID & 2) == 0 ? e0 : e1;
vec2 px = base + n * (side * half_w);
// Rect is top-left origin, GL is bottom-left: flip y once, here, and never
// again in the fragment shader or in a caller.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
v_dist = side * half_w;
v_along = (gl_VertexID & 2) == 0 ? 0.0 : len;
}
)";
const char* const kLineFS = R"(#version 330 core
in float v_dist; // signed distance to the segment axis, pixels
uniform float u_width; // stroke width, pixels
uniform vec4 u_color;
out vec4 o_color;
void main() {
float half_w = u_width * 0.5;
float d = abs(v_dist);
if (d > half_w + 1.0) {
discard; // outside the fringe; nothing to blend
}
// Ramp over the last pixel of the half width so a thin stroke keeps its
// weight instead of aliasing into a dashed line.
float alpha = 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, d);
o_color = vec4(u_color.rgb, u_color.a * alpha);
}
)";
const char* const kTextVS = R"(#version 330 core
layout(location = 0) in vec2 a_pos; // per-instance glyph quad top-left, pixels
layout(location = 1) in vec2 a_uv; // per-instance glyph atlas texel origin
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_px_range; // pixels per glyph cell on screen
out vec2 v_texel; // atlas texel coordinate of this fragment
const vec2 kGlyphTexels = vec2(8.0); // BitmapFont::kGlyphPx
void main() {
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
vec2 px = a_pos + corner * u_px_range;
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
v_texel = a_uv + corner * kGlyphTexels;
}
)";
const char* const kTextFS = R"(#version 330 core
in vec2 v_texel; // atlas texel coordinate of this fragment
uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
uniform vec2 u_tex_size; // atlas size in texels
uniform vec4 u_color;
out vec4 o_color;
void main() {
// Sample the centre of the texel the fragment lands in. GL_NEAREST alone
// would tie at a texel boundary, where the driver may pick the neighbouring
// glyph cell; rounding to a centre removes the tie by construction.
vec2 texel = clamp(floor(v_texel), vec2(0.0), u_tex_size - vec2(1.0));
float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
o_color = vec4(u_color.rgb, u_color.a * coverage);
}
)";
} // namespace plot::render
+98
View File
@@ -0,0 +1,98 @@
#include <plot/render/vertex_array.hpp>
#include <plot/gl/api.hpp>
#include <cstddef>
namespace plot::render {
namespace {
/// Instance data advances one step per instance, never per vertex: both
/// pipelines draw one quad per instance with corners synthesised from
/// `gl_VertexID`, so every attribute has a divisor of 1 (core since GL 3.3).
constexpr gl::GLuint kPerInstance = 1;
} // namespace
bool LineVertexArray::create(Status* st, UploadMode mode) noexcept {
if (!vao_.create(st)) {
return false;
}
if (!buffer_.create(st, mode)) {
vao_.reset(); // do not leave a vertex array behind on the failure path
return false;
}
// Attribute pointers capture the bound GL_ARRAY_BUFFER, so bind both first.
vao_.bind();
buffer_.bind();
// Offsets come from the struct, not from counting floats: the layout is the
// one shaders.hpp declares, and it stays in step with LineInstance.
vao_.attrib(0u, 2, sizeof(LineInstance), offsetof(LineInstance, x0));
vao_.attrib(1u, 2, sizeof(LineInstance), offsetof(LineInstance, x1));
gl::glVertexAttribDivisor(0u, kPerInstance);
gl::glVertexAttribDivisor(1u, kPerInstance);
gl::glBindVertexArray(0); // leave the binding as we found it
epoch_ = buffer_.epoch();
return true;
}
void LineVertexArray::upload(const f32* interleaved, u32 segments) noexcept {
// An array that was never created reports nothing to draw, so a caller that
// ignored create()'s status rasterises nothing instead of stale memory.
if (interleaved == nullptr || vao_.id() == 0) {
instances_ = 0;
return;
}
buffer_.upload(interleaved, segments);
// Only the mapped transfer moves the instances between slots, and the
// attribute offsets have to follow them. The generation changes for a new slot
// or a re-created buffer, never otherwise, so the baseline path re-points at
// most once.
if (buffer_.epoch() != epoch_) {
const usize base = buffer_.slot_offset();
vao_.bind();
buffer_.bind();
vao_.attrib(0u, 2, sizeof(LineInstance), base + offsetof(LineInstance, x0));
vao_.attrib(1u, 2, sizeof(LineInstance), base + offsetof(LineInstance, x1));
epoch_ = buffer_.epoch();
}
instances_ = segments;
}
bool TextVertexArray::create(Status* st, UploadMode mode) noexcept {
if (!vao_.create(st)) {
return false;
}
if (!buffer_.create(st, mode)) {
vao_.reset();
return false;
}
vao_.bind();
buffer_.bind();
vao_.attrib(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u));
gl::glVertexAttribDivisor(0u, kPerInstance);
gl::glVertexAttribDivisor(1u, kPerInstance);
gl::glBindVertexArray(0);
epoch_ = buffer_.epoch();
return true;
}
void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept {
if (interleaved == nullptr || vao_.id() == 0) {
instances_ = 0;
return;
}
buffer_.upload(interleaved, glyphs);
if (buffer_.epoch() != epoch_) {
const usize base = buffer_.slot_offset();
vao_.bind();
buffer_.bind();
vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u));
epoch_ = buffer_.epoch();
}
instances_ = glyphs;
}
} // namespace plot::render
+159
View File
@@ -0,0 +1,159 @@
# EGL powers the surfaceless headless context the GL tests assert pixels
# against. It is a *test-only* dependency: plot_core never links or includes it,
# so a machine without EGL can still build the library (-DPLOT_BUILD_TESTS=OFF).
# Prefer CMake's own module, fall back to pkg-config, and fail loudly if neither
# can supply EGL rather than silently dropping the GL tests.
find_package(EGL QUIET)
if(NOT EGL_FOUND)
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(EGL_PC QUIET IMPORTED_TARGET egl)
endif()
endif()
if(TARGET EGL::EGL)
set(plot_egl_library EGL::EGL)
elseif(TARGET PkgConfig::EGL_PC)
set(plot_egl_library PkgConfig::EGL_PC)
else()
message(FATAL_ERROR
"plot: PLOT_BUILD_TESTS=ON requires EGL (EGL/egl.h plus libEGL) for the headless "
"OpenGL context used by the GL tests. Install it (e.g. libegl-dev, libegl1-mesa-dev "
"or mesa-libEGL-devel) or configure with -DPLOT_BUILD_TESTS=OFF.")
endif()
find_package(Threads REQUIRED) # std::thread in the push/read race test
add_executable(plot_tests
main.cpp
test_transform.cpp
test_gl_context.cpp
test_primitives.cpp
test_ring_buffer.cpp
test_minmax_pyramid.cpp
test_series.cpp
test_ticks.cpp
test_measure.cpp
test_plot.cpp
test_render_smoke.cpp
test_controller.cpp
egl_headless.cpp
)
target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads)
# The Dear ImGui widget is the one adapter with a test here: its input translation
# (edge synthesis, the hover gate, drag capture, wheel containment) is logic this
# project owns, not a toolkit's. The suite is otherwise ImGui-free, so the source
# and the link are added only when the adapter exists -- which the top-level
# CMakeLists.txt guarantees by adding the adapters before this directory.
if(TARGET plot::imgui)
target_sources(plot_tests PRIVATE test_imgui_widget.cpp)
target_link_libraries(plot_tests PRIVATE plot::imgui imgui)
endif()
# The Qt adapter's pointer anchor is deliberately toolkit-free (see
# adapters/qt6/qt_pointer.hpp), so this one piece of that adapter's translation is
# tested without linking Qt -- and without `PLOT_BUILD_QT6`, since no Qt target has
# to exist for it to compile.
target_sources(plot_tests PRIVATE test_qt_pointer.cpp)
# Same for the GTK adapter's input translation (see adapters/gtk4/gtk_input.hpp):
# the device-pixel mapping it derives from GTK's two sizes, the pixel space its drag
# anchor is kept in, the wheel's unit and sign and the modifier mask are this
# project's logic, and a headless X server at an integer scale can exercise only
# part of it.
target_sources(plot_tests PRIVATE test_gtk_input.cpp)
target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
# Same switches the library is built with: the cost tests read the counters
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
PLOT_PYRAMID_COUNT_MERGES
PLOT_RING_STATS
PLOT_COUNT_MEASURE_RECOMPUTES)
plot_apply_warnings(plot_tests)
add_test(NAME plot_tests COMMAND plot_tests)
# The GL tests create a real EGL context, and llvmpipe's teardown leaks driver
# allocations that are not this library's. The suppressions file names those
# libraries and nothing else (see cmake/plot_asan_suppressions.txt); a report from
# anything under include/plot/ or src/ is still an error. The environment is set
# on the test rather than in a preset so `ctest --test-dir build/asan` gets it
# however it is launched, and print_suppressions makes the counts visible -- a run
# must show what it suppressed, not hide it.
if(PLOT_ASAN_ENABLED)
set_tests_properties(plot_tests PROPERTIES ENVIRONMENT
"ASAN_OPTIONS=detect_leaks=1:print_suppressions=1:suppressions=${CMAKE_SOURCE_DIR}/cmake/plot_asan_suppressions.txt")
endif()
# --- public headers: standalone compilation and umbrella coverage -------------
#
# The plan's Global Constraints require every header under include/plot/ to
# compile standalone, and include/plot/plot.hpp to include every public one
# (never an adapter). Both were verified by hand and nothing kept them true, so
# a header added later could break either silently. These targets make the build
# enforce both and the ctest entries below make the suite fail when the checks
# do. The header list is globbed at configure time (CONFIGURE_DEPENDS, so adding
# or removing a header re-globs) -- a hand-kept list would defeat the point.
file(GLOB_RECURSE plot_public_headers CONFIGURE_DEPENDS
RELATIVE "${CMAKE_SOURCE_DIR}/include"
"${CMAKE_SOURCE_DIR}/include/plot/*.hpp")
list(SORT plot_public_headers)
# One generated TU per header, each holding only that header's #include. An
# OBJECT library: this is a compile check, with no link step, so any header that
# needs something it does not itself include fails the build here.
set(plot_header_check_sources "")
foreach(plot_header IN LISTS plot_public_headers)
string(MAKE_C_IDENTIFIER "${plot_header}" plot_header_slug)
set(plot_header_check_source "${CMAKE_CURRENT_BINARY_DIR}/header_checks/${plot_header_slug}.cpp")
set(plot_header_check_content
"// Generated by tests/CMakeLists.txt: <${plot_header}> must compile on its own.\n#include <${plot_header}>\n")
file(GENERATE OUTPUT "${plot_header_check_source}" CONTENT "${plot_header_check_content}")
list(APPEND plot_header_check_sources "${plot_header_check_source}")
endforeach()
add_library(plot_header_self_containment OBJECT ${plot_header_check_sources})
target_link_libraries(plot_header_self_containment PRIVATE plot::core)
plot_apply_warnings(plot_header_self_containment)
# The umbrella on its own: the generated TU includes only <plot/plot.hpp> and
# constructs one of each layer's entry type, so an umbrella that misses a header
# -- or that is unusable without further includes -- fails to build. The test
# runs the linked binary, so a build that produced nothing usable still fails.
set(plot_umbrella_check_source "${CMAKE_CURRENT_BINARY_DIR}/umbrella_check.cpp")
file(GENERATE OUTPUT "${plot_umbrella_check_source}" CONTENT
"// Generated by tests/CMakeLists.txt: the umbrella must be usable by itself.
#include <plot/plot.hpp>
int main() {
plot::Plot plot;
plot::Renderer renderer;
plot::data::StreamSeries series(16, 0.0, 1.0);
plot::input::Controller controller(plot);
(void)renderer;
(void)series;
(void)controller;
return 0;
}
")
add_executable(plot_umbrella_check "${plot_umbrella_check_source}")
target_link_libraries(plot_umbrella_check PRIVATE plot::core)
plot_apply_warnings(plot_umbrella_check)
# Coverage check on the umbrella. Included here so a public header missing from
# plot.hpp stops the configure; the ctest entry below runs the same script again
# so the check is also a visible test.
set(PLOT_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include")
set(PLOT_UMBRELLA "${CMAKE_SOURCE_DIR}/include/plot/plot.hpp")
include("${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
add_test(NAME plot_headers_self_contained
COMMAND ${CMAKE_COMMAND} --build "${CMAKE_BINARY_DIR}" --target plot_header_self_containment)
add_test(NAME plot_umbrella_covers_public_headers
COMMAND ${CMAKE_COMMAND}
-DPLOT_INCLUDE_DIR=${CMAKE_SOURCE_DIR}/include
-DPLOT_UMBRELLA=${CMAKE_SOURCE_DIR}/include/plot/plot.hpp
-P "${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
add_test(NAME plot_umbrella_check COMMAND plot_umbrella_check)
+206
View File
@@ -0,0 +1,206 @@
#include "egl_headless.hpp"
#include <plot/gl/api.hpp>
#include <plot/gl/loader.hpp>
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <cstdio>
#include <cstdlib>
namespace plottest {
namespace {
/// `eglGetPlatformDisplayEXT`, declared locally so the harness does not depend
/// on the exact spelling of the EGL_EXT_platform_base typedef.
using GetPlatformDisplayEXT = EGLDisplay (*)(EGLenum platform, void* native_display,
const EGLint* attrib_list);
/// Keeps the text of the last EGL failure alive for the returned Status.
thread_local char g_message[512];
/// Records the pending EGL error in `g_message`, echoes it to stderr so a
/// failing CTest run shows the driver's own error code, and returns false.
bool fail(plot::Status* st, const char* what) noexcept {
const EGLint error = eglGetError();
std::snprintf(g_message, sizeof g_message, "%s (EGL error 0x%04X)", what,
static_cast<unsigned>(error));
std::fprintf(stderr, "plot_tests: headless EGL: %s\n", g_message);
if (st != nullptr) {
st->code = plot::ErrorCode::GLFailure;
st->message = g_message;
}
return false;
}
} // namespace
HeadlessContext::HeadlessContext(u32 w, u32 h) noexcept : width(w), height(h) {}
HeadlessContext::~HeadlessContext() { destroy(); }
bool HeadlessContext::create(plot::Status* st) noexcept {
if (framebuffer_.id() != 0) {
return true; // already created
}
if (width == 0 || height == 0) {
if (st != nullptr) {
st->code = plot::ErrorCode::InvalidArgument;
st->message = "headless context size must be non-zero";
}
return false;
}
auto abort = [&](const char* what) {
const bool reported = fail(st, what);
destroy();
return reported;
};
// 1. Prefer the surfaceless platform: no window system at all. Fall back to
// the default display (still surfaceless when it can be), and only then
// accept a pbuffer surface.
const GetPlatformDisplayEXT get_platform_display = reinterpret_cast<GetPlatformDisplayEXT>(
eglGetProcAddress("eglGetPlatformDisplayEXT"));
EGLDisplay display = EGL_NO_DISPLAY;
if (get_platform_display != nullptr) {
display = get_platform_display(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr);
}
if (display == EGL_NO_DISPLAY) {
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
}
if (display == EGL_NO_DISPLAY) {
return abort("eglGetPlatformDisplayEXT(EGL_PLATFORM_SURFACELESS_MESA) and eglGetDisplay failed");
}
display_ = display;
// 2. Initialise the display for the desktop GL API.
EGLint egl_major = 0;
EGLint egl_minor = 0;
if (eglInitialize(display, &egl_major, &egl_minor) != EGL_TRUE) {
return abort("eglInitialize failed");
}
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
return abort("eglBindAPI(EGL_OPENGL_API) failed");
}
// 3. An RGBA8 config that can back an OpenGL context.
const EGLint config_attribs[] = {
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT,
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
EGL_DEPTH_SIZE, 0, EGL_NONE,
};
EGLConfig config = nullptr;
EGLint config_count = 0;
if (eglChooseConfig(display, config_attribs, &config, 1, &config_count) != EGL_TRUE ||
config_count == 0) {
return abort("no EGL config offers 8-bit RGBA with EGL_OPENGL_BIT");
}
// 4. OpenGL 3.3 core, the baseline the library targets.
const EGLint context_attribs[] = {
EGL_CONTEXT_MAJOR_VERSION,
3,
EGL_CONTEXT_MINOR_VERSION,
3,
EGL_CONTEXT_OPENGL_PROFILE_MASK,
EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR,
EGL_NONE,
};
const EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs);
if (context == EGL_NO_CONTEXT) {
return abort("eglCreateContext(OpenGL 3.3 core) failed");
}
context_ = context;
// 5. Surfaceless current context first; a 1x1 pbuffer only if the driver
// lacks EGL_KHR_surfaceless_context. PLOT_HEADLESS_FORCE_PBUFFER=1 skips
// straight to the fallback, so that path can be verified somewhere the
// surfaceless path would otherwise always win.
const bool force_pbuffer = std::getenv("PLOT_HEADLESS_FORCE_PBUFFER") != nullptr;
if (!force_pbuffer &&
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context) == EGL_TRUE) {
egl_path = "egl surfaceless";
} else {
const EGLint pbuffer_attribs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
const EGLSurface surface = eglCreatePbufferSurface(display, config, pbuffer_attribs);
if (surface == EGL_NO_SURFACE ||
eglMakeCurrent(display, surface, surface, context) != EGL_TRUE) {
return abort("neither a surfaceless context nor a 1x1 pbuffer surface could be made current");
}
surface_ = surface;
egl_path = "egl pbuffer";
}
// 6. Resolve the entry points against the context that is now current.
if (!plot::gl::load(reinterpret_cast<plot::gl::ProcLoader>(eglGetProcAddress))) {
if (st != nullptr) {
st->code = plot::ErrorCode::GLFailure;
st->message = "plot::gl::load(eglGetProcAddress) could not resolve every entry point";
}
std::fprintf(stderr, "plot_tests: headless EGL: %s\n", st != nullptr ? st->message : "");
destroy();
return false;
}
// 7. The off-screen target: an RGBA8 texture on colour attachment 0.
plot::Status gl_status;
if (!color_.create_rgba(width, height, &gl_status) ||
!framebuffer_.create(&gl_status)) {
if (st != nullptr) {
*st = gl_status;
}
std::fprintf(stderr, "plot_tests: headless EGL: %s\n",
gl_status.message != nullptr ? gl_status.message : "framebuffer setup failed");
destroy();
return false;
}
framebuffer_.attach_color(color_);
if (!framebuffer_.complete()) {
std::fprintf(stderr, "plot_tests: headless EGL: framebuffer is not complete\n");
if (st != nullptr) {
st->code = plot::ErrorCode::GLFailure;
st->message = "framebuffer is not complete";
}
destroy();
return false;
}
return true;
}
void HeadlessContext::destroy() noexcept {
// GL names belong to the context that created them, so drop them while it is
// still current.
framebuffer_.reset();
color_.reset();
if (display_ != nullptr) {
const EGLDisplay display = static_cast<EGLDisplay>(display_);
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (context_ != nullptr) {
eglDestroyContext(display, static_cast<EGLContext>(context_));
}
if (surface_ != nullptr) {
eglDestroySurface(display, static_cast<EGLSurface>(surface_));
}
eglTerminate(display);
}
display_ = nullptr;
context_ = nullptr;
surface_ = nullptr;
egl_path = "";
}
bool HeadlessContext::read_pixel(u32 x, u32 y, std::uint8_t rgba[4]) const noexcept {
if (framebuffer_.id() == 0 || x >= width || y >= height) {
return false;
}
framebuffer_.bind();
plot::gl::glReadPixels(static_cast<plot::gl::GLint>(x), static_cast<plot::gl::GLint>(y), 1, 1,
plot::gl::GL_RGBA, plot::gl::GL_UNSIGNED_BYTE, rgba);
return plot::gl::glGetError() == plot::gl::GL_NO_ERROR;
}
} // namespace plottest
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/handle.hpp>
#include <cstdint>
namespace plottest {
using plot::u32;
/// A surfaceless EGL context plus an off-screen RGBA framebuffer, so renderer
/// output can be asserted pixel by pixel without a window or a display server.
///
/// Test-only: `plot_core` neither links nor includes EGL.
class HeadlessContext {
public:
explicit HeadlessContext(u32 w = 256, u32 h = 256) noexcept;
~HeadlessContext();
HeadlessContext(const HeadlessContext&) = delete;
HeadlessContext& operator=(const HeadlessContext&) = delete;
/// Makes an OpenGL 3.3 core context current on this thread, resolves the GL
/// entry points against it (`plot::gl::load(eglGetProcAddress)`) and
/// allocates the colour attachment. Idempotent. On failure `st` (when
/// non-null) carries the reason, every resource already created is released,
/// and the error is also printed to stderr so a failing CTest run is
/// diagnosable.
bool create(plot::Status* st = nullptr) noexcept;
/// Releases the framebuffer, the GL context and the EGL display. Safe to call
/// more than once, and to call after a failed create().
void destroy() noexcept;
/// Binds the off-screen target and reads one RGBA8 pixel, in GL's
/// origin-at-the-bottom-left convention. False when it cannot be read.
bool read_pixel(u32 x, u32 y, std::uint8_t rgba[4]) const noexcept;
u32 width = 0;
u32 height = 0;
/// The headless path create() selected ("egl surfaceless" or "egl pbuffer");
/// empty before a successful create() and after destroy(). Setting
/// PLOT_HEADLESS_FORCE_PBUFFER in the environment skips the surfaceless
/// attempt, so the pbuffer fallback can be exercised on a driver that does
/// support surfaceless contexts.
const char* egl_path = "";
private:
plot::gl::Texture color_;
plot::gl::Framebuffer framebuffer_;
void* display_ = nullptr; // EGLDisplay
void* context_ = nullptr; // EGLContext
void* surface_ = nullptr; // EGLSurface, null on the surfaceless path
};
} // namespace plottest
+85
View File
@@ -0,0 +1,85 @@
#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;
};
/// Compares two doubles within `eps`. `PLOT_CHECK_NEAR(a, b)` leaves it to the
/// default below; pass an explicit tolerance for computed values.
inline constexpr double kNearEps = 1e-9;
inline void check_near(double a, double b, double eps, const char* file, int line,
const char* lhs, const char* rhs) {
if (!(std::fabs(a - b) <= eps)) {
throw Failure{std::string(file) + ":" + std::to_string(line) + " CHECK_NEAR(" + lhs + ", " +
rhs + ") got " + std::to_string(a) + " vs " + std::to_string(b)};
}
}
inline void check_near(double a, double b, const char* file, int line, const char* lhs,
const char* rhs) {
check_near(a, b, kNearEps, file, line, lhs, rhs);
}
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, ...) \
::plottest::check_near((a), (b) __VA_OPT__(,) __VA_ARGS__, __FILE__, __LINE__, #a, #b)
+3
View File
@@ -0,0 +1,3 @@
#include "harness.hpp"
int main() { return plottest::run_all(); }
+360
View File
@@ -0,0 +1,360 @@
#include "harness.hpp"
#include <plot/input/controller.hpp>
#include <plot/plot/plot.hpp>
#include <plot/data/array_series.hpp>
#include <cmath>
#include <limits>
#include <utility>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
constexpr u32 kShift = static_cast<u32>(plot::input::Mod::Shift);
constexpr u32 kCtrl = static_cast<u32>(plot::input::Mod::Ctrl);
constexpr u32 kNoAxis = ~u32{0};
/// A 800x400 plot with one x axis spanning [0, 100] and the controller attached.
struct Fixture {
plot::Plot plot;
u32 ax = kNoAxis;
u32 ay = kNoAxis;
plot::input::Controller controller{plot};
Fixture() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
}
};
f32 screen_x_of(const plot::Plot& p, u32 axis, f64 data) {
return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
}
} // namespace
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();
const f64 grabbed = p.x_transform(ax).to_data_x(100.0f); // data under the press point
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
// Assert the INVARIANT, not a pixel fraction. Grab-and-drag means the data
// grabbed at the press point is under the pointer afterwards. Pixels map to
// data through layout().axes, NOT the whole viewport: the viewport model makes
// dragged data lag the cursor by the label-strip fraction, a defect no
// pixel-fraction assertion can see because it certifies its own arithmetic.
PLOT_CHECK_NEAR(p.x_transform(ax).to_data_x(180.0f), grabbed, 1e-9);
PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down raises y range
PLOT_CHECK(!p.x_axis(ax).auto_scale); // a pan is a user choice, not a fit
}
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
const f64 want_lo = p.x_transform(ax).to_data_x(100.0f);
const f64 want_hi = p.x_transform(ax).to_data_x(300.0f);
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});
// The box frames exactly the data it encloses: the ranges come from the axes
// transform, and the selection is clamped to layout().axes, not the viewport.
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
}
PLOT_TEST(box_zoom_uses_release_position_without_a_move) {
// A host that reports only a press and a release -- no motion event between
// them -- MUST still frame the box the release describes: the release position
// is authoritative for the end of the gesture.
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 f64 want_lo = p.x_transform(ax).to_data_x(100.0f);
const f64 want_hi = p.x_transform(ax).to_data_x(300.0f);
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
// No move: selection_ is still 0x0 until the release supplies the far corner.
c.on_pointer({300, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
PLOT_CHECK(!p.x_axis(ax).auto_scale); // a box zoom is a user choice, not a fit
}
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);
}
// --- resolutions of the brief's ambiguity, pinned ------------------------
PLOT_TEST(box_zoom_below_threshold_is_a_click) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({102, 52, 2, 2, plot::input::Button::Left, kShift, true, false, false});
c.on_pointer({102, 52, 0, 0, plot::input::Button::Left, kShift, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(p.x_range(ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 800.0, 1e-9);
}
PLOT_TEST(shift_wheel_zooms_x_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kShift});
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
}
PLOT_TEST(ctrl_wheel_zooms_y_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kCtrl});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
PLOT_CHECK(f.plot.y_range(f.ay).span() < 2.0);
}
PLOT_TEST(hovered_cursor_only_inside_plot_area) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f32 sx = screen_x_of(p, ax, 50.0);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
PLOT_CHECK(c.hovered_cursor() == cid);
// The left label strip is outside the plot area.
c.on_pointer({10, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
PLOT_CHECK(c.hovered_cursor() == cid);
}
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f32 sx = screen_x_of(p, ax, 20.0);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false});
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(p.cursor(cid).x > 20.0);
PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9);
}
PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
const plot::PlotLayout& l = f.plot.layout();
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
// Same sign convention as the wheel: a positive dy zooms in.
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
// 20 px of drag is a tenth of a decade: the span is 10^(-20/200) of its
// former self. A pixel is not a wheel notch, so this pins the magnitude.
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched y axis keeps auto scale
}
PLOT_TEST(middle_drag_on_y_strip_zooms_y_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const plot::PlotLayout& l = f.plot.layout();
const f32 strip_x = l.y_label_area.x + 0.5f * l.y_label_area.w;
const f32 strip_y = l.axes.y + 0.5f * l.axes.h;
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomY);
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
}
PLOT_TEST(key_plus_zooms_in_about_the_centre) {
Fixture f;
const f64 mid_before = 0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max);
f.controller.on_key({plot::input::kKeyZoomIn, 0, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
PLOT_CHECK_NEAR(0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max), mid_before, 1e-9);
const f64 span_after = f.plot.x_range(f.ax).span();
f.controller.on_key({plot::input::kKeyZoomIn, 0, false}); // key-up is ignored
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), span_after, 1e-12);
}
PLOT_TEST(key_minus_zooms_out) {
Fixture f;
f.controller.on_key({plot::input::kKeyZoomOut, 0, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
}
/// A host can hand the controller a coordinate that is not a number: ImGui reports
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis
/// transforms, so such a value would leave a range that is not a range -- and
/// because a gesture that sets a range also clears auto scale, panning and zooming
/// could not bring it back.
PLOT_TEST(non_finite_pointer_does_not_move_a_range) {
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 plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
const float inf = std::numeric_limits<float>::infinity();
const float nan = std::numeric_limits<float>::quiet_NaN();
const float sentinel = -std::numeric_limits<float>::max(); // ImGui's "no pointer"
// The pointer-left-the-window sentinel arrives at the unrepresentable end of the
// axis, so scaling and differencing it is a 1e35 pan. It must read as no
// movement at all.
c.on_pointer({sentinel, sentinel, inf, -inf, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan); // still the same drag
// A usable position with an unusable delta is no movement either, and a later
// usable position is not differenced against the sentinel.
c.on_pointer({180, 130, inf, nan, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
c.on_pointer({220, 160, 40, 30, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK(std::isfinite(p.x_range(ax).min)); // a real move still pans
PLOT_CHECK(p.x_range(ax).min < xr.min);
// The buttons in a sanitised event still act: the release ends the drag.
c.on_pointer({220, 160, 0, 0, plot::input::Button::Left, 0, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
}
PLOT_TEST(non_finite_wheel_does_not_break_a_range) {
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 float inf = std::numeric_limits<float>::infinity();
const float nan = std::numeric_limits<float>::quiet_NaN();
// A zoom is anchored at the data coordinate under the pointer, so a position that
// is not a number must not become the anchor: the axis would be re-centred on
// NaN, which no gesture could undo. The fallback is a real coordinate and the
// zoom still happens. (A *finite* sentinel -- ImGui's `-FLT_MAX` for "no pointer"
// -- is not the controller's to interpret: finite coordinates are legal input.
// The adapter translates that one; see tests/test_imgui_widget.cpp.)
const double span_before = p.x_range(ax).span();
c.on_wheel({nan, nan, 0.0f, 1.0f, 0});
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
PLOT_CHECK(std::fabs(p.x_range(ax).min) < 1e6 && std::fabs(p.x_range(ax).max) < 1e6);
PLOT_CHECK(p.x_range(ax).span() < span_before);
// A non-finite amount is no zoom at all -- and, just as important, it must not
// touch the axes. A *zeroed* amount would be a zero-notch zoom (a factor of
// exactly one): the range would survive, but the range setters it runs through
// clear auto scale, so one garbage wheel would quietly disarm auto-fit.
p.set_x_auto_scale(ax, true);
p.set_y_auto_scale(ay, true);
const plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
c.on_wheel({400.0f, 200.0f, 0.0f, nan, 0});
c.on_wheel({400.0f, 200.0f, 0.0f, inf, 0});
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-12);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-12);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-12);
PLOT_CHECK(p.x_axis(ax).auto_scale);
PLOT_CHECK(p.y_axis(ay).auto_scale);
}
PLOT_TEST(key_f_auto_fits_traces) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(51);
for (u64 i = 0; i < ys.size(); ++i) ys[i] = static_cast<f32>(i) * 0.1f; // 0.0 .. 5.0
plot::data::ArraySeries s(std::move(ys), 0.0, 1.0); // x = 0 .. 50
p.add_trace({&s, plot::Color::hex(0x4FA3FF), 1.5f, 0, 0});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_key({plot::input::kKeyFit, 0, true});
PLOT_CHECK(p.x_range(ax).min < 0.0);
PLOT_CHECK(p.x_range(ax).max < 100.0); // fitted to the series' 0..50
PLOT_CHECK(p.y_range(ay).min < 0.0);
PLOT_CHECK(p.y_range(ay).max > 1.0); // fitted to the series' 0..5
PLOT_CHECK(p.x_axis(ax).auto_scale); // a fit re-arms auto scale
PLOT_CHECK(p.y_axis(ay).auto_scale);
}
+403
View File
@@ -0,0 +1,403 @@
#include "harness.hpp"
#include "egl_headless.hpp"
#include <EGL/egl.h> // eglGetProcAddress: the context-aware entry point resolver
#include <plot/gl/caps.hpp>
#include <plot/gl/handle.hpp>
#include <plot/gl/loader.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/shaders.hpp>
#include <plot/render/vertex_array.hpp>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
#include <utility>
namespace {
/// `eglGetProcAddress` is the context-aware resolver of the EGL context: in a
/// surfaceless context there is no GLX/WGL dispatch to fall back on, so this is
/// the path that must work.
plot::gl::ProcLoader egl_loader() noexcept {
return reinterpret_cast<plot::gl::ProcLoader>(eglGetProcAddress);
}
constexpr const char* kVertexSrc = R"(#version 330 core
layout(location = 0) in vec2 a_pos;
uniform vec2 u_offset;
void main() { gl_Position = vec4(a_pos + u_offset, 0.0, 1.0); }
)";
constexpr const char* kFragmentSrc = R"(#version 330 core
uniform float u_alpha;
out vec4 o_color;
void main() { o_color = vec4(1.0, 0.0, 0.0, u_alpha); }
)";
/// Deliberately unparseable GLSL, to exercise the failure path of
/// `Program::from_source`.
constexpr const char* kBrokenFragmentSrc = R"(#version 330 core
out vec4 o_color;
void main() { o_color = this is not glsl; }
)";
} // namespace
// Must stay the first GL case in this translation unit: `caps()` caches after
// the first successful query, so "before any load()" is observable only once.
PLOT_TEST(caps_before_load_is_zeroed) {
const plot::gl::Caps& caps = plot::gl::caps();
PLOT_CHECK(caps.major == 0 && caps.minor == 0);
PLOT_CHECK(!caps.compute && !caps.buffer_storage && !caps.persistent_map);
PLOT_CHECK(!caps.core_profile);
PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] == '\0');
PLOT_CHECK(caps.vendor != nullptr && caps.vendor[0] == '\0');
}
PLOT_TEST(headless_context_provides_gl33) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st)); // fails loudly if surfaceless EGL is unavailable
// Primary path: resolve through the current context.
PLOT_CHECK(plot::gl::load(egl_loader()));
const plot::gl::Caps& caps = plot::gl::caps();
PLOT_CHECK(caps.major > 3 || (caps.major == 3 && caps.minor >= 3));
PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] != '\0');
PLOT_CHECK(caps.vendor != nullptr && caps.vendor[0] != '\0');
// EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR is requested, so this checks our profile-mask parsing.
PLOT_CHECK(caps.core_profile);
const char* version = reinterpret_cast<const char*>(plot::gl::glGetString(plot::gl::GL_VERSION));
PLOT_CHECK(version != nullptr);
const int major = caps.major;
const int minor = caps.minor;
std::printf("[info] GL %d.%d, renderer \"%s\", vendor \"%s\", %s profile, EGL path \"%s\"\n",
major, minor, caps.renderer, caps.vendor, caps.core_profile ? "core" : "compat",
ctx.egl_path);
std::printf("[info] caps: compute=%d buffer_storage=%d persistent_map=%d core_profile=%d\n",
caps.compute ? 1 : 0, caps.buffer_storage ? 1 : 0, caps.persistent_map ? 1 : 0,
caps.core_profile ? 1 : 0);
// The dlsym(RTLD_DEFAULT) / libGL.so.1 fallback must resolve the whole table
// too, and its pointers must still dispatch to the current EGL context. Only
// a host that ships desktop libGL can exercise it; on a GLES-only host there
// is nothing to assert.
if (dlopen("libGL.so.1", RTLD_LAZY) != nullptr) {
PLOT_CHECK(plot::gl::load(nullptr));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
const char* fallback_version =
reinterpret_cast<const char*>(plot::gl::glGetString(plot::gl::GL_VERSION));
PLOT_CHECK(fallback_version != nullptr);
PLOT_CHECK(std::strcmp(fallback_version, version) == 0); // same context, same driver
const plot::gl::Caps& reprobed = plot::gl::caps(); // load() invalidated the cache
PLOT_CHECK(reprobed.major == major && reprobed.minor == minor);
PLOT_CHECK(reprobed.renderer != nullptr && reprobed.renderer[0] != '\0');
}
// Hand the table back to the context-aware resolver so teardown (deleting the
// framebuffer) runs through the entry points of the context it belongs to.
PLOT_CHECK(plot::gl::load(egl_loader()));
ctx.destroy();
}
PLOT_TEST(headless_framebuffer_reads_back_clear_color) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(egl_loader()));
plot::gl::glClearColor(0.25f, 0.5f, 0.75f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
std::uint8_t px[4] = {0, 0, 0, 0};
PLOT_CHECK(ctx.read_pixel(0, 0, px));
PLOT_CHECK_NEAR(px[0], 64, 2); // round(0.25 * 255)
PLOT_CHECK_NEAR(px[1], 128, 2);
PLOT_CHECK_NEAR(px[2], 191, 2);
PLOT_CHECK_NEAR(px[3], 255, 2);
// A second corner proves the whole attachment is readable.
PLOT_CHECK(ctx.read_pixel(ctx.width - 1, ctx.height - 1, px));
PLOT_CHECK_NEAR(px[2], 191, 2);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
PLOT_TEST(raii_handles_round_trip) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(egl_loader()));
const float verts[6] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f};
plot::gl::Buffer vbo;
PLOT_CHECK(vbo.create(&st));
PLOT_CHECK(vbo.id() != 0);
vbo.upload(verts, sizeof verts, plot::gl::GL_STATIC_DRAW);
vbo.upload_sub(verts, sizeof verts, 0);
plot::gl::VertexArray vao;
PLOT_CHECK(vao.create(&st));
vao.bind();
vbo.bind();
vao.attrib(0, 2, 2 * sizeof(float), 0);
const std::uint8_t texels[8 * 8 * 4] = {0};
plot::gl::Texture color;
PLOT_CHECK(color.create_rgba(8, 8, &st));
color.upload_rgba(8, 8, texels);
const std::uint8_t coverage[8 * 8] = {0};
plot::gl::Texture mask;
PLOT_CHECK(mask.create_r8(8, 8, &st));
mask.upload_r8(8, 8, coverage);
mask.set_filter(plot::gl::GL_NEAREST, plot::gl::GL_NEAREST);
mask.bind();
plot::gl::Framebuffer fbo;
PLOT_CHECK(fbo.create(&st));
fbo.attach_color(color);
PLOT_CHECK(fbo.complete());
plot::gl::Fence fence;
PLOT_CHECK(fence.create(&st));
plot::gl::glFinish();
PLOT_CHECK(fence.wait(0));
// Move-only: the source is emptied and the GL name survives.
plot::gl::Buffer moved = std::move(vbo);
PLOT_CHECK(moved.id() != 0);
PLOT_CHECK(vbo.id() == 0);
moved.reset();
PLOT_CHECK(moved.id() == 0);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
PLOT_TEST(program_compiles_trivial_shaders) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(egl_loader()));
plot::Status ok;
plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &ok);
PLOT_CHECK(bool(ok));
PLOT_CHECK(program.id() != 0);
program.use();
program.set_uniform("u_offset", plot::Vec2{1.0f, 2.0f});
program.set_uniform("u_alpha", 0.5f);
program.set_uniform("u_missing", 0.25f); // unknown name: silent no-op
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
plot::Status bad;
plot::gl::Program broken = plot::gl::Program::from_source(kVertexSrc, kBrokenFragmentSrc, &bad);
PLOT_CHECK(!bad);
PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure);
PLOT_CHECK(bad.message != nullptr && bad.message[0] != '\0');
PLOT_CHECK(broken.id() == 0);
ctx.destroy();
}
// The GLSL of both pipelines has to be accepted by the real driver: a typo that
// no CPU test can catch (a missing varying, a reserved word) fails here.
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;
plot::Status s1;
plot::gl::Program line =
plot::gl::Program::from_source(plot::render::kLineVS, plot::render::kLineFS, &s0);
plot::gl::Program text =
plot::gl::Program::from_source(plot::render::kTextVS, plot::render::kTextFS, &s1);
PLOT_CHECK(bool(s0));
PLOT_CHECK(bool(s1));
PLOT_CHECK(line.id() != 0);
PLOT_CHECK(text.id() != 0);
ctx.destroy();
}
// A renderer wrapper that was never successfully created must be a harmless
// no-op: Task 7 checks `create()`'s bool and then keeps drawing with whatever
// else it has, so the setters must not reach an empty program.
PLOT_TEST(render_programs_are_inert_until_created) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::LineProgram line;
plot::render::TextProgram text;
PLOT_CHECK(!line.valid());
PLOT_CHECK(!text.valid());
line.bind();
line.set_scale({1.0f, 1.0f});
line.set_offset({0.0f, 0.0f});
line.set_viewport({256.0f, 256.0f});
line.set_width(2.0f);
line.set_color(plot::Color::hex(0xFFFFFF));
text.bind();
text.set_viewport({256.0f, 256.0f});
text.set_px_range({8.0f, 8.0f});
text.set_texture_size({128.0f, 56.0f});
text.set_color(plot::Color::hex(0xFFFFFF));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// End to end on the real driver: the instanced attribute layout, the
// triangle-strip expansion, the single y flip between Rect pixels and GL, and
// the AA ramp that makes a thin trace legible.
PLOT_TEST(line_pipeline_draws_an_aa_strip) {
plottest::HeadlessContext ctx(256, 256);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::LineProgram program;
PLOT_CHECK(program.create(&st));
plot::render::LineVertexArray lines;
PLOT_CHECK(lines.create(&st));
// Three instances: two segments at different y, so a wrong attribute divisor
// cannot pass (with a divisor of zero the second quad would be assembled from
// the first instance's leftover attribute values), plus a degenerate one at
// Rect (64, 140) — the shape a dense trace's min == max bucket stages, which
// must still leave a mark instead of collapsing to zero area.
const f32 segments[12] = {0.0f, 0.0f, 40.0f, 0.0f, 0.0f, 20.0f,
40.0f, 20.0f, 0.0f, 40.0f, 0.0f, 40.0f};
lines.upload(segments, 3);
PLOT_CHECK(lines.instance_count() == 3);
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
program.bind();
program.set_scale({1.0f, 1.0f});
program.set_offset({64.0f, 100.0f});
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_width(3.0f);
program.set_color(plot::Color::hex(0xFF00FF));
lines.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3);
plot::u8 px[4] = {};
// Rect y = 100 is GL row 155.5, so rows 155 and 156 straddle the axis
// (d = 0.5), rows 154 and 157 are the half-covered AA fringe (d = 1.5), and
// row 152 is outside the quad. Getting the flip wrong, or applying it twice,
// moves the whole strip to the other half of the target.
PLOT_CHECK(ctx.read_pixel(84, 155, px));
PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 156, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 157, px));
PLOT_CHECK(px[0] > 60 && px[0] < 200);
PLOT_CHECK(ctx.read_pixel(84, 152, px));
PLOT_CHECK(px[0] + px[2] < 40);
// The second instance is where its own segment says: Rect y = 120 is rows
// 135/136, and the gap between the two strips stays background.
PLOT_CHECK(ctx.read_pixel(84, 135, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 136, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(84, 145, px));
PLOT_CHECK(px[0] + px[2] < 40);
// Past the end of the segment (flat cap) and far from both strips.
PLOT_CHECK(ctx.read_pixel(110, 155, px));
PLOT_CHECK(px[0] + px[2] < 40);
// The degenerate instance: Rect y = 140 is rows 115/116 at x = 64, and the
// mark must be visible rather than a zero-area quad, while staying inside its
// own stroke width + fringe (columns 62..66).
PLOT_CHECK(ctx.read_pixel(64, 115, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(64, 116, px));
PLOT_CHECK(px[0] > 200 && px[2] > 200);
PLOT_CHECK(ctx.read_pixel(64, 121, px));
PLOT_CHECK(px[0] + px[2] < 40);
PLOT_CHECK(ctx.read_pixel(70, 115, px));
PLOT_CHECK(px[0] + px[2] < 40);
PLOT_CHECK(ctx.read_pixel(128, 0, px));
PLOT_CHECK(px[0] + px[2] < 40);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
// The text pipeline end to end: the R8 atlas upload, the per-instance glyph UV
// origin, the NEAREST texel-centre sampling, the tint and the same single y
// flip. Every one of the cell's 64 pixels is checked against the glyph bitmap,
// so a wrong atlas cell, a wrong UV or a transposed axis cannot pass.
PLOT_TEST(text_pipeline_draws_a_glyph) {
using plot::render::BitmapFont;
plottest::HeadlessContext ctx(256, 256);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
// The renderer's atlas: single channel, GL_NEAREST, straight alpha.
plot::gl::Texture atlas;
PLOT_CHECK(atlas.create_r8(BitmapFont::kAtlasWidth, BitmapFont::kAtlasHeight, &st));
atlas.upload_r8(BitmapFont::kAtlasWidth, BitmapFont::kAtlasHeight, BitmapFont::atlas_image());
atlas.set_filter(plot::gl::GL_NEAREST, plot::gl::GL_NEAREST);
plot::render::TextProgram program;
PLOT_CHECK(program.create(&st));
plot::render::TextVertexArray glyphs;
PLOT_CHECK(glyphs.create(&st));
BitmapFont font;
plot::render::TextBatch batch;
batch.begin();
batch.add_text({64.0f, 100.0f}, "A", font); // one cell at Rect (64, 100)
PLOT_CHECK(batch.glyph_count() == 1);
glyphs.upload(batch.data().data(), batch.glyph_count());
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
program.bind();
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_px_range({static_cast<f32>(BitmapFont::kGlyphPx), static_cast<f32>(BitmapFont::kGlyphPx)});
program.set_texture_size({static_cast<f32>(BitmapFont::kAtlasWidth),
static_cast<f32>(BitmapFont::kAtlasHeight)});
program.set_color(plot::Color::hex(0xFF00FF));
atlas.bind();
glyphs.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
// One cell is 8x8 texels and u_px_range is 8, so texel (gx, gy) lands on one
// target pixel: Rect x = 64 + gx, and Rect y = 100 + gy is GL row 155 - gy.
const plot::u8* rows = font.glyph_bitmap('A');
plot::u8 px[4] = {};
for (plot::u32 gy = 0; gy < BitmapFont::kGlyphPx; ++gy) {
for (plot::u32 gx = 0; gx < BitmapFont::kGlyphPx; ++gx) {
const bool ink = ((rows[gy] >> gx) & 1u) != 0u;
PLOT_CHECK(ctx.read_pixel(64u + gx, 155u - gy, px));
if (ink) {
PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200);
} else {
PLOT_CHECK(px[0] < 40 && px[2] < 40);
}
}
}
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
+211
View File
@@ -0,0 +1,211 @@
// The GTK adapter's input translation, driven directly.
//
// Four pieces of it are this project's logic and not a toolkit's, and none of them
// shows up in a screenshot: the device-pixel mapping GTK's two sizes imply, the
// pixel space a drag anchor is kept in, the wheel's unit and sign, and the
// modifier mask. A headless X server delivers discrete wheel notches through XTEST
// (and only at an integer scale), so the surface branch of `scroll_notches()` and
// this session's fractional scales have no event to arrive on -- and the mask
// matters because GDK's state carries the mouse-button bits, which would otherwise
// ride into the controller's events.
//
// `gtk_input.hpp` names no GDK type (it mirrors the two scroll-unit values), so
// this compiles without GTK and without PLOT_BUILD_GTK4.
#include "harness.hpp"
#include <gtk4/gtk_input.hpp>
#include <cmath>
#include <optional>
using plot::f64;
using plot::u32;
using plot::gtk4::DeviceMapping;
using plot::gtk4::DragAnchor;
using plot::gtk4::ScrollUnit;
using plot::gtk4::button_from;
using plot::gtk4::kSurfacePixelsPerNotch;
using plot::gtk4::mods_from;
using plot::gtk4::scroll_notches;
// GDK's positive `dy` is a scroll *south* (the wheel pushed away from the user) and
// a `ScrollUnit::Wheel` delta is already one unit per detent, so a notch up --
// `dy == -1.0`, what an X11 button-4 press arrives as -- has to leave as `+1.0`.
// Getting the sign wrong would invert every zoom, which looks like a working plot
// with an upside-down wheel.
PLOT_TEST(gtk_scroll_wheel_notches_are_already_notches_and_the_sign_flips) {
PLOT_CHECK_NEAR(scroll_notches(-1.0, ScrollUnit::Wheel), 1.0);
PLOT_CHECK_NEAR(scroll_notches(1.0, ScrollUnit::Wheel), -1.0);
PLOT_CHECK_NEAR(scroll_notches(-3.0, ScrollUnit::Wheel), 3.0);
PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Wheel), 0.0);
}
// A smooth device reports surface pixels, and GDK defines no notch size for them
// ("surface pixels to scroll directly on screen" is its whole description), so the
// divisor is this adapter's *calibration* and not a conversion -- see
// kSurfacePixelsPerNotch, and note that it is uncalibrated against real hardware.
// What a test can hold is the reasoning rather than the number: the constant is
// what one notch is, the scaling is linear and unquantised (passing a pixel delta
// through unscaled would make a flick of a few hundred pixels tens of notches, a
// zoom no gesture can undo as fast as it happens), and the value is chosen so that
// a deliberate two-finger gesture -- of the order of 100 surface pixels -- is one
// detent rather than several.
PLOT_TEST(gtk_scroll_surface_deltas_are_scaled_by_the_calibration) {
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Surface), 1.0);
PLOT_CHECK_NEAR(scroll_notches(-2.0 * kSurfacePixelsPerNotch, ScrollUnit::Surface), 2.0);
PLOT_CHECK_NEAR(scroll_notches(3000.0, ScrollUnit::Surface), -30.0);
// The calibration's own assumption, as an invariant that survives a re-tune: a
// deliberate gesture of 100 px is at most one detent, never several.
PLOT_CHECK(kSurfacePixelsPerNotch >= 100.0);
PLOT_CHECK(scroll_notches(-100.0, ScrollUnit::Surface) <= 1.0 + 1e-9);
// The sign is the wheel's, and a smooth device's tail is not quantised away: the
// scale is a pure division, so a 0.1-pixel event stays a 0.001-notch zoom
// (0.015% of the span). What the caller can refuse is an *exactly* zero amount:
// no zoom, so the event is not this widget's (see the adapter's `on_scroll`).
PLOT_CHECK_NEAR(scroll_notches(+kSurfacePixelsPerNotch, ScrollUnit::Surface), -1.0);
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch / 1000.0, ScrollUnit::Surface), 0.001,
1e-12);
PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Surface), 0.0);
// The unit decides which path runs: a delta GDK reported in wheel units is one
// unit per detent however large it is, and is never divided by the calibration.
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Wheel),
kSurfacePixelsPerNotch);
}
// The device-pixel mapping is derived from GTK's two sizes -- the widget's logical
// allocation and the device size `GtkGLArea::resize` reports for the viewport its
// default handler sets -- and from no scale accessor. The defect this pins:
// `gtk_widget_get_scale_factor()` is documented to return "the next higher integer
// value" under fractional scaling, so an 800-logical-pixel widget whose viewport
// is 1000 device pixels (a 1.25 display) had its input multiplied by 2: a pointer
// at logical x=100 was forwarded as 200, 75 device pixels past where the FBO puts
// it, and `device_viewport()` claimed 1600 of them. The ratio of the two sizes is
// the space the renderer draws into, so it is the one input must use.
PLOT_TEST(gtk_device_mapping_is_the_ratio_of_the_two_sizes) {
const DeviceMapping quarter = DeviceMapping::from_sizes({800.0f, 600.0f}, {1000.0f, 750.0f});
PLOT_CHECK_NEAR(quarter.factor, 1.25, 1e-6);
PLOT_CHECK_NEAR(quarter.viewport.x, 0.0);
PLOT_CHECK_NEAR(quarter.viewport.y, 0.0);
PLOT_CHECK_NEAR(quarter.viewport.w, 1000.0);
PLOT_CHECK_NEAR(quarter.viewport.h, 750.0);
PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).x, 125.0, 1e-4);
PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).y, 250.0, 1e-4);
const DeviceMapping three_quarters =
DeviceMapping::from_sizes({800.0f, 600.0f}, {1400.0f, 1050.0f});
PLOT_CHECK_NEAR(three_quarters.factor, 1.75, 1e-6);
PLOT_CHECK_NEAR(three_quarters.viewport.w, 1400.0);
PLOT_CHECK_NEAR(three_quarters.viewport.h, 1050.0);
PLOT_CHECK_NEAR(three_quarters.to_device(400.0, 0.0).x, 700.0, 1e-4);
}
// At the integer scales the headless evidence runs at, the derived factor is the
// integer one -- 1600/800 is exactly 2, 800/800 exactly 1 -- so the mapping the
// previous code built from `gtk_widget_get_scale_factor()` is unchanged there, and
// only a fractional scale can tell the two derivations apart. A widget with no
// allocation has no ratio to take: it maps 1:1 and reports the (zero) size GTK
// gave, which is the state before anything is drawn or any event can arrive.
PLOT_TEST(gtk_device_mapping_is_unchanged_at_the_integer_scales) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({800.0f, 600.0f}, {800.0f, 600.0f});
PLOT_CHECK(at_1.factor == 1.0f);
PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).x, 120.0, 1e-6);
PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).y, 40.0, 1e-6);
const DeviceMapping at_2 = DeviceMapping::from_sizes({800.0f, 600.0f}, {1600.0f, 1200.0f});
PLOT_CHECK(at_2.factor == 2.0f);
PLOT_CHECK_NEAR(at_2.viewport.w, 1600.0);
PLOT_CHECK_NEAR(at_2.to_device(120.0, 40.0).x, 240.0, 1e-6);
const DeviceMapping unallocated = DeviceMapping::from_sizes({0.0f, 0.0f}, {0.0f, 0.0f});
PLOT_CHECK(unallocated.factor == 1.0f);
PLOT_CHECK_NEAR(unallocated.viewport.w, 0.0);
PLOT_CHECK_NEAR(unallocated.viewport.h, 0.0);
}
// The drag anchor stays *logical* while the factor is re-derived per update: GTK
// re-emits `::resize` when the widget's scale-factor property changes, so a drag
// can span a scale change. Cached in device pixels, the same gesture reported
// `20 * 1.25 - 10 * 1.0 = 15` device pixels of movement where the pointer moved
// 12.5, and a position of `100 * 1.0 + 20 * 1.25 = 125` where the FBO puts it at
// 150. `Controller::pan` applies such a delta as a fraction of the range span and
// clears auto scale, so the phantom 2.5 pixels are a real pan of the plot and a
// live axis silently disarmed -- the same defect the Qt adapter's `PointerAnchor`
// pins for `QDpi` changes.
PLOT_TEST(gtk_drag_anchor_stays_logical_across_a_scale_change) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1000.0f, 800.0f});
const DeviceMapping at_125 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1250.0f, 1000.0f});
PLOT_CHECK(at_1.factor == 1.0f);
PLOT_CHECK_NEAR(at_125.factor, 1.25, 1e-6);
DragAnchor anchor;
anchor.begin(100.0f, 100.0f);
PLOT_CHECK(anchor.have);
// The press point itself is reported in device pixels at the factor in force.
PLOT_CHECK_NEAR(anchor.position(at_1).x, 100.0, 1e-6);
PLOT_CHECK_NEAR(anchor.position(at_1).y, 100.0, 1e-6);
// First sample, before the move: the pointer moved 10 logical pixels.
const DragAnchor::Sample first = anchor.update(10.0, 0.0, at_1);
PLOT_CHECK_NEAR(first.delta.x, 10.0, 1e-6);
PLOT_CHECK_NEAR(first.position.x, 110.0, 1e-6);
// The window is now on the 1.25 display: the same gesture has moved 10 further
// logical pixels, so the movement is 10 * 1.25 = 12.5 device pixels and the
// position is (100 + 20) * 1.25 = 150, whatever the press was scaled by.
const DragAnchor::Sample second = anchor.update(20.0, 0.0, at_125);
PLOT_CHECK_NEAR(second.delta.x, 12.5, 1e-6);
PLOT_CHECK_NEAR(second.delta.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(second.position.x, 150.0, 1e-6);
// The release is the press point plus the offset `drag-end` reports, at the
// factor in force then -- not the last sample's position, and not an anchor
// cached in device pixels. It may be past the last update's offset, which is why
// `end()` takes its own.
PLOT_CHECK_NEAR(anchor.end(25.0, 0.0, at_125).x, 156.25, 1e-6);
PLOT_CHECK(!anchor.have);
}
// An amount the controller must refuse is not silently turned into a number here:
// `Controller::on_wheel` rejects a non-finite delta as a strict no-op, and that
// decision belongs to it alone.
PLOT_TEST(gtk_scroll_passes_a_non_finite_delta_through) {
PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Wheel)));
PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Surface)));
}
// GDK reports the button bits inside the same state word as the modifiers -- a
// release arrives with `GDK_BUTTON1_MASK` (1 << 8) set, a right-drag with `1 << 10`
// -- so the translation is a mask. Without it every drag's events would carry bits
// no other adapter's do.
PLOT_TEST(gtk_mods_ignore_the_mouse_button_bits) {
PLOT_CHECK(mods_from(0x100u) == 0u); // GDK_BUTTON1_MASK
PLOT_CHECK(mods_from(0x400u) == 0u); // GDK_BUTTON3_MASK
PLOT_CHECK(mods_from(0x501u) == static_cast<u32>(plot::input::Mod::Shift));
const u32 shift = static_cast<u32>(plot::input::Mod::Shift);
const u32 ctrl = static_cast<u32>(plot::input::Mod::Ctrl);
const u32 alt = static_cast<u32>(plot::input::Mod::Alt);
const u32 super = static_cast<u32>(plot::input::Mod::Super);
PLOT_CHECK(mods_from(1u) == shift); // GDK_SHIFT_MASK
PLOT_CHECK(mods_from(4u) == ctrl); // GDK_CONTROL_MASK
PLOT_CHECK(mods_from(8u) == alt); // GDK_ALT_MASK
PLOT_CHECK(mods_from(1u << 26) == super); // GDK_SUPER_MASK
PLOT_CHECK(mods_from(1u << 28) == super); // GDK_META_MASK, Command on macOS
PLOT_CHECK(mods_from(1u | 4u | 8u | (1u << 26)) == (shift | ctrl | alt | super));
// GDK_LOCK_MASK (CapsLock) is not a modifier the controller names.
PLOT_CHECK(mods_from(2u) == 0u);
}
// The gestures listen to "any button", so the numbers that reach the mapping are
// 1, 2, 3 and whatever the device reports. Only the three the controller has a
// gesture for are accepted: an unhandled button is dropped rather than defaulting
// to a left-drag pan.
PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) {
PLOT_CHECK(button_from(1u) == std::optional<plot::input::Button>(plot::input::Button::Left));
PLOT_CHECK(button_from(2u) == std::optional<plot::input::Button>(plot::input::Button::Middle));
PLOT_CHECK(button_from(3u) == std::optional<plot::input::Button>(plot::input::Button::Right));
PLOT_CHECK(!button_from(0u).has_value());
PLOT_CHECK(!button_from(8u).has_value()); // a navigation button
PLOT_CHECK(!button_from(9u).has_value());
}
+454
View File
@@ -0,0 +1,454 @@
// The Dear ImGui adapter's own logic, pinned against real ImGui frames on the
// tests' surfaceless EGL context -- no window, no display server.
//
// What is tested here is what this project owns, not the toolkit's: ImGui reports
// input as *levels* where `plot::input` wants edges, so the widget synthesises
// them, gates delivery on hover, captures a drag across a region exit, and takes
// the wheel away from window scrolling; and because it draws through GL rather
// than ImGui's draw list, it has to keep its painting inside the child's *visible*
// rectangle itself. The suite is otherwise ImGui-free, so this file and its link
// are added only when the adapter is built (tests/CMakeLists.txt).
#include "harness.hpp"
#include "egl_headless.hpp"
#include <imgui/imgui_plot.hpp>
#include <imgui.h>
#include <plot/data/stream_series.hpp>
#include <plot/gl/api.hpp>
#include <plot/render/renderer.hpp>
#include <cmath>
#include <cstdint>
#include <limits>
#include <utility>
#include <vector>
namespace {
/// The off-screen target and the ImGui display are the same size: the widget's
/// region is a sub-rectangle of the framebuffer, which is the point of the test.
constexpr u32 kFb = 512;
constexpr float kDisplay = 512.0f;
constexpr float kChild = 400.0f;
/// A plot the widget drives, and the host frame around it.
struct Fixture {
plottest::HeadlessContext ctx;
plot::Renderer renderer;
plot::data::StreamSeries series{4096, 0.0, 0.01};
plot::Plot plot;
plot::imgui::PlotWidget widget;
u32 ax = 0;
u32 ay = 0;
/// A second plot, so a host that draws two through one widget can be tested.
plot::Plot other;
u32 ax_other = 0;
u32 ay_other = 0;
float win_w = kDisplay;
float win_h = kDisplay;
float child_w = kChild;
float child_h = kChild;
/// Content past the window, so the hosting window has a scroll range: the wheel
/// containment test needs one to be able to observe a scroll at all.
bool tall_parent = false;
bool prev_down = false;
ImVec2 child_origin{0.0f, 0.0f}; ///< the child's top-left, read in-frame (points)
float scroll_y = 0.0f; ///< the hosting window's scroll, read in-frame
/// The pixels the widget painted: the bounding box of everything that is not the
/// host's clear colour, in top-left framebuffer coordinates.
u32 paint_min_x = kFb;
u32 paint_min_y = kFb;
u32 paint_max_x = 0;
u32 paint_max_y = 0;
/// The host's own frame. Green on purpose: no colour the plot draws, nor any
/// blend of them, is (0, 192, 0), so "the host's pixel survived" is decidable.
plot::Color host_color = plot::Color::hex(0x00C000);
std::uint8_t host_rgb_[3] = {0, 0, 0};
Fixture() : ctx(kFb, kFb) {}
~Fixture() { ImGui::DestroyContext(); }
Fixture(const Fixture&) = delete;
Fixture& operator=(const Fixture&) = delete;
bool init() {
plot::Status st;
if (!ctx.create(&st)) {
std::printf("[FAIL] headless context: %s\n", st.message);
return false;
}
if (!renderer.init(&st)) {
std::printf("[FAIL] renderer init: %s\n", st.message);
return false;
}
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr;
io.DisplaySize = ImVec2(kDisplay, kDisplay);
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
io.Fonts->AddFontDefault();
std::uint8_t* pixels = nullptr;
int tw = 0;
int th = 0;
// The widget needs a built atlas exactly as a platform backend would leave it;
// no renderer backend is involved, so the texture id is a placeholder.
io.Fonts->GetTexDataAsRGBA32(&pixels, &tw, &th);
io.Fonts->SetTexID(static_cast<ImTextureID>(1));
for (int i = 0; i < 4096; ++i) series.push(static_cast<f32>(std::sin(0.05 * i)));
series.acquire();
ax = plot.add_x_axis({"time", {0.0, 20.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"signal", {-1.2, 1.2}, false, plot::AxisPlace::Left, false});
plot.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
plot.set_x_range(ax, {0.0, 20.0}); // explicit: clears auto scale, so a pan shows
plot.set_y_range(ay, {-1.2, 1.2});
other.set_viewport(plot.viewport());
ax_other = other.add_x_axis({"time", {0.0, 50.0}, false, plot::AxisPlace::Bottom, false});
ay_other = other.add_y_axis({"signal", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
other.add_trace({&series, plot::Color::hex(0xFFB020), 1.5f, ax_other, ay_other});
other.set_x_range(ax_other, {0.0, 50.0});
other.set_y_range(ay_other, {-1.0, 1.0});
// The host colour as the pipeline actually produced it: the pixel tests compare
// bytes, not a rounding of the clear value.
clear_host();
std::uint8_t probe[4] = {0, 0, 0, 0};
pixel(0, 0, probe);
host_rgb_[0] = probe[0];
host_rgb_[1] = probe[1];
host_rgb_[2] = probe[2];
return true;
}
void clear_host() {
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(host_color.r, host_color.g, host_color.b, host_color.a);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
}
/// One widget frame with the host's input queued exactly as a platform backend
/// would queue it. `pos` is in ImGui points; `-FLT_MAX` is ImGui's "the pointer is
/// not on this window" sentinel.
void frame(const ImVec2& pos, bool down, float wheel = 0.0f, plot::Plot* target = nullptr) {
plot::Plot& shown = target != nullptr ? *target : plot;
ImGuiIO& io = ImGui::GetIO();
io.AddMousePosEvent(pos.x, pos.y);
if (down != prev_down) {
io.AddMouseButtonEvent(0, down);
prev_down = down;
}
if (wheel != 0.0f) io.AddMouseWheelEvent(0.0f, wheel);
// A bare window: no background to paint over the plot, and no title bar or
// scrollbar to add pixels the scan below would mistake for the widget's.
const ImGuiWindowFlags host_flags = ImGuiWindowFlags_NoBackground |
ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::NewFrame();
ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(win_w, win_h), ImGuiCond_Always);
ImGui::Begin("host", nullptr, host_flags);
child_origin = ImGui::GetCursorScreenPos(); // where the widget's child goes
widget.draw("plot", shown, plot::Vec2{child_w, child_h}, renderer);
if (tall_parent) ImGui::Dummy(ImVec2(10.0f, 900.0f)); // content past the window
scroll_y = ImGui::GetScrollY();
ImGui::End();
ImGui::Render();
}
/// The framebuffer pixel at a *top-left* coordinate. `read_pixel` speaks GL's
/// bottom-left convention, as the render smoke test notes.
void pixel(u32 x, u32 y, std::uint8_t out[4]) {
if (!ctx.read_pixel(x, ctx.height - 1u - y, out)) PLOT_CHECK(false);
}
bool is_host(const std::uint8_t px[4]) const {
return px[0] == host_rgb_[0] && px[1] == host_rgb_[1] && px[2] == host_rgb_[2];
}
/// Records the bounding box of the pixels that are not the host's clear colour.
/// Reads the whole framebuffer once, so a draw that escaped the widget's region
/// shows up even if it is nowhere near the pixels a point check samples.
void scan_painted() {
std::uint8_t probe[4] = {0, 0, 0, 0};
pixel(0, 0, probe); // read_pixel binds the target the frame was drawn into
std::vector<std::uint8_t> rgba(static_cast<plot::usize>(kFb) * kFb * 4u, 0u);
plot::gl::glPixelStorei(plot::gl::GL_PACK_ALIGNMENT, 4);
plot::gl::glReadPixels(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height), plot::gl::GL_RGBA,
plot::gl::GL_UNSIGNED_BYTE, rgba.data());
paint_min_x = kFb;
paint_min_y = kFb;
paint_max_x = 0;
paint_max_y = 0;
for (u32 y = 0; y < kFb; ++y) {
// glReadPixels hands rows back bottom-up; this scan speaks top-left, the
// convention every Rect here uses.
const std::uint8_t* row =
rgba.data() + static_cast<plot::usize>(kFb - 1u - y) * kFb * 4u;
for (u32 x = 0; x < kFb; ++x) {
if (is_host(row + static_cast<plot::usize>(x) * 4u)) continue;
if (x < paint_min_x) paint_min_x = x;
if (x > paint_max_x) paint_max_x = x;
if (y < paint_min_y) paint_min_y = y;
if (y > paint_max_y) paint_max_y = y;
}
}
}
plot::Range x_range() const { return plot.x_range(ax); }
bool panning() const {
const plot::input::Controller* c = widget.controller();
return c != nullptr && c->drag_mode() == plot::input::DragMode::Pan;
}
};
constexpr float kMaxFloat = std::numeric_limits<float>::max();
} // namespace
PLOT_TEST(imgui_widget_pans_only_while_hovered_and_dragging) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
// A hovered pointer with no button moves nothing: the pointer must be *over* the
// plot and a drag must be in progress.
f.frame(ImVec2(200.0f, 200.0f), false);
const plot::Range hovered = f.x_range();
f.frame(ImVec2(260.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().min, hovered.min, 1e-9);
PLOT_CHECK_NEAR(f.x_range().max, hovered.max, 1e-9);
// A press begins a pan, but a press alone moves nothing either.
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
PLOT_CHECK_NEAR(f.x_range().min, hovered.min, 1e-9);
// The drag pans (grab-and-drag: the data follows the cursor) and keeps the span.
const plot::Range pressed = f.x_range();
f.frame(ImVec2(260.0f, 200.0f), true);
PLOT_CHECK(f.x_range().min < pressed.min - 1e-6);
PLOT_CHECK_NEAR(f.x_range().span(), pressed.span(), 1e-9);
// The release ends it, and the next hovered move must not pan again.
f.frame(ImVec2(260.0f, 200.0f), false);
PLOT_CHECK(!f.panning());
const plot::Range released = f.x_range();
f.frame(ImVec2(300.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().min, released.min, 1e-9);
PLOT_CHECK_NEAR(f.x_range().max, released.max, 1e-9);
// A press and drag *outside* the child -- the slider-in-the-same-window case --
// must not touch the plot at all.
f.frame(ImVec2(470.0f, 470.0f), false);
f.frame(ImVec2(470.0f, 470.0f), true);
f.frame(ImVec2(440.0f, 440.0f), true);
PLOT_CHECK_NEAR(f.x_range().min, released.min, 1e-9);
f.frame(ImVec2(440.0f, 440.0f), false);
}
PLOT_TEST(imgui_widget_drag_capture_survives_leaving_the_child) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range pressed = f.x_range();
// Leftwards inside the child: the range minimum rises.
f.frame(ImVec2(160.0f, 200.0f), true);
PLOT_CHECK(f.x_range().min > pressed.min + 1e-6);
const plot::Range inside = f.x_range();
// Rightwards, with the pointer now outside the child: a captured drag keeps
// panning, or the gesture would silently stop following the cursor.
f.frame(ImVec2(470.0f, 470.0f), true);
PLOT_CHECK(f.x_range().min < inside.min - 1e-6);
// ... and the release arriving out there still ends it.
const plot::Range outside = f.x_range();
f.frame(ImVec2(470.0f, 470.0f), false);
PLOT_CHECK(!f.panning());
f.frame(ImVec2(430.0f, 430.0f), false);
PLOT_CHECK_NEAR(f.x_range().min, outside.min, 1e-9);
}
PLOT_TEST(imgui_widget_wheel_zooms_when_hovered_and_does_not_scroll_the_host) {
Fixture f;
f.tall_parent = true; // the hosting window can scroll, so containment is testable
if (!f.init()) PLOT_CHECK(false);
// Control: the wheel scrolls the hosting window when it is over the window's own
// area. Downwards, because ImGui clamps a scroll at the top and would prove
// nothing. Two frames of quiet first: ImGui sizes a window's content (and so its
// scroll range) from the previous frame, and a wheel arriving before that range
// is known is not scrolled anywhere.
f.frame(ImVec2(470.0f, 470.0f), false);
f.frame(ImVec2(470.0f, 470.0f), false);
const float scroll_before = f.scroll_y;
f.frame(ImVec2(470.0f, 470.0f), false, -1.0f);
PLOT_CHECK(f.scroll_y > scroll_before + 1.0f);
// The same wheel over the parent must not zoom the plot: it is not hovered.
const plot::Range over_parent = f.x_range();
f.frame(ImVec2(470.0f, 470.0f), false, -1.0f);
PLOT_CHECK_NEAR(f.x_range().span(), over_parent.span(), 1e-9);
// Over the plot, the wheel zooms it (positive scrolls up, which zooms in) and
// the hosting window does not scroll.
f.frame(ImVec2(200.0f, 200.0f), false); // hover: claims the wheel for the plot
const float scroll_before_plot = f.scroll_y;
const double span_before = f.x_range().span();
f.frame(ImVec2(200.0f, 200.0f), false, -1.0f);
f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK(f.x_range().span() > span_before);
PLOT_CHECK_NEAR(static_cast<double>(f.scroll_y), static_cast<double>(scroll_before_plot), 1e-3);
const double span_before_in = f.x_range().span();
f.frame(ImVec2(200.0f, 200.0f), false, 1.0f);
PLOT_CHECK(f.x_range().span() < span_before_in);
}
PLOT_TEST(imgui_widget_invalid_position_does_not_move_the_range) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press over the plot: capture
const plot::Range pressed = f.x_range();
// The platform backends leave (-FLT_MAX, -FLT_MAX) behind when the pointer leaves
// the window (imgui_impl_glfw's cursor-enter callback) or the window loses focus
// (ClearInputMouse). Scaled by the framebuffer scale and differenced against the
// last real position, that is a ~1e35 movement -- and with a press captured, a
// ~1e35 pan, which `set_x_range` would take (and, clearing auto scale, would not
// give back).
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
PLOT_CHECK(std::isfinite(f.x_range().min) && std::isfinite(f.x_range().max));
PLOT_CHECK_NEAR(f.x_range().min, pressed.min, 1e-9);
PLOT_CHECK_NEAR(f.x_range().max, pressed.max, 1e-9);
PLOT_CHECK(f.panning()); // still captured: only the position was unusable
// The release still ends the drag even with no usable position in the event.
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), false);
PLOT_CHECK(!f.panning());
// A drag held across a sentinel, then a real position again. The movement must be
// measured from the last *real* position -- 100 px from (200, 200) to (300, 300) --
// and not from the sentinel. This is asserted with the drag alive on purpose:
// nothing can move a range once the button is up, so the same check after the
// release could not fail and would prove nothing.
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range second_press = f.x_range();
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
PLOT_CHECK(f.panning());
const double axes_w = static_cast<double>(std::as_const(f.plot).layout().axes.w);
const double expected_shift = 100.0 / axes_w * second_press.span();
f.frame(ImVec2(300.0f, 300.0f), true);
PLOT_CHECK(f.panning());
PLOT_CHECK(std::isfinite(f.x_range().min));
PLOT_CHECK_NEAR(f.x_range().min, second_press.min - expected_shift, 0.05 * expected_shift);
f.frame(ImVec2(300.0f, 300.0f), false);
PLOT_CHECK(!f.panning());
}
PLOT_TEST(imgui_widget_paints_the_whole_child_when_nothing_clips_it) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.clear_host();
f.frame(ImVec2(200.0f, 200.0f), false);
f.scan_painted();
// The requested child rectangle, in top-left coordinates.
const u32 x0 = static_cast<u32>(f.child_origin.x);
const u32 y0 = static_cast<u32>(f.child_origin.y);
const u32 x1 = x0 + static_cast<u32>(f.child_w);
const u32 y1 = y0 + static_cast<u32>(f.child_h);
// The clamp against the clip rect must be a no-op when nothing clips the child:
// the painted rectangle is exactly the child's, corner to corner.
PLOT_CHECK(f.paint_min_x <= x0 + 1u && f.paint_min_y <= y0 + 1u);
PLOT_CHECK(f.paint_max_x >= x1 - 2u && f.paint_max_y >= y1 - 2u);
// ... and nothing of it escapes: the pixels past the child's edge are still the
// host's.
std::uint8_t out[4] = {0, 0, 0, 0};
f.pixel(x1 + 6u, y1 + 6u, out);
PLOT_CHECK(f.is_host(out));
f.pixel(x1 + 6u, y0 + 6u, out);
PLOT_CHECK(f.is_host(out));
}
PLOT_TEST(imgui_widget_rebinding_a_plot_does_not_fabricate_a_press) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press over the first plot
PLOT_CHECK(f.panning());
// The host switches to its second plot with the button still held. ImGui reports
// a *level*, so there is no press edge in this frame: synthesising one out of the
// clear state a re-bind starts from would begin a drag the user never made.
f.frame(ImVec2(210.0f, 200.0f), true, 0.0f, &f.other);
PLOT_CHECK(f.widget.controller() != nullptr);
PLOT_CHECK(!f.panning());
const plot::Range other_before = f.other.x_range(f.ax_other);
f.frame(ImVec2(260.0f, 200.0f), true, 0.0f, &f.other);
PLOT_CHECK(!f.panning());
PLOT_CHECK_NEAR(f.other.x_range(f.ax_other).min, other_before.min, 1e-9);
f.frame(ImVec2(260.0f, 200.0f), false, 0.0f, &f.other); // release
PLOT_CHECK(!f.panning());
// Back on the first plot, a press that *is* a press still starts a drag: the
// button state was kept across the re-bind, not frozen.
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
}
PLOT_TEST(imgui_widget_paints_only_the_visible_part_of_a_clipped_child) {
Fixture f;
f.win_w = 200.0f; // the child (400x400) cannot fit, so ImGui clips it
f.win_h = 200.0f;
if (!f.init()) PLOT_CHECK(false);
f.clear_host();
// The pointer is outside the window entirely: this is about painting, not input.
f.frame(ImVec2(300.0f, 300.0f), false);
f.scan_painted();
const u32 x0 = static_cast<u32>(f.child_origin.x);
const u32 y0 = static_cast<u32>(f.child_origin.y);
const u32 x1 = x0 + static_cast<u32>(f.child_w); // the *requested* rectangle
const u32 y1 = y0 + static_cast<u32>(f.child_h);
// GL draws do not go through ImGui's clipping, so a child the window cuts off
// would otherwise clear and paint over the host's pixels beside it. The painted
// rectangle must stop well short of the requested one ...
PLOT_CHECK(f.paint_max_x + 20u < x1);
PLOT_CHECK(f.paint_max_y + 20u < y1);
// ... while still covering the part that is visible: this is a clamp, not a
// refusal to draw.
PLOT_CHECK(f.paint_max_x - f.paint_min_x > 100u);
PLOT_CHECK(f.paint_max_y - f.paint_min_y > 100u);
// The pixel deep inside the requested rectangle but outside the visible part kept
// the host's colour, and the one near the visible corner was painted.
std::uint8_t out[4] = {0, 0, 0, 0};
f.pixel(x1 - 5u, y1 - 5u, out);
PLOT_CHECK(f.is_host(out));
f.pixel(f.paint_min_x + 40u, f.paint_min_y + 40u, out);
PLOT_CHECK(!f.is_host(out));
}
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more