feat(qt6): QOpenGLWidget-based plot widget and demo

This commit is contained in:
Martino Ferrari committed 2026-09-28 01:17:45 +02:00
1 parent dc0a977c86
commit 1ee2dd9c3d
7 files changed
+1113 -2

No files matched your search

+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;
}