feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
This commit is contained in:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+447 -2
View File
@@ -18,6 +18,7 @@
#include <plot/render/renderer.hpp>
#include <plot/input/controller.hpp>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <csignal>
@@ -309,7 +310,6 @@ InkBox draw_text_run(plottest::HeadlessContext& ctx, plot::render::TextAtlas& at
program.set_glyph_uv(cell);
program.set_px_range({cell.x * texels.x * px, cell.y * texels.y * px});
program.set_texture_size(texels);
program.set_color(text);
atlas.bind();
vao.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4,
@@ -2315,7 +2315,7 @@ PLOT_TEST(legend_swatch_and_label_are_drawn) {
const plot::PlotLayout layout = p.layout();
const plot::LegendLayout legend = layout.legend;
PLOT_CHECK(legend.rows.size() == 2u);
const plot::Legend& cfg = p.legend();
const plot::Legend cfg = p.legend(); // copy: set_legend() below swaps the member
const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) *
static_cast<plot::u32>(cfg.swatch_h);
@@ -5364,3 +5364,448 @@ PLOT_TEST(dash_phase_is_stable_across_frames) {
ctx.destroy();
}
// ------------------------------------------------- B1: relative-x staging (epoch axes)
/// The B1 defect: gathering and staging an absolute epoch x as f32 quantised it to
/// a grid coarser than a pixel (f32 carries 24 bits -- about 128 s at 1.7e9), so an
/// epoch-window trace collapsed onto a few columns while its zero-origin twin drew
/// correctly. The fix stages `to_space(x_abs) - to_space(origin)` -- O(visible
/// pixels) -- and folds the origin into the offset uniform in f64, so the two
/// windows rasterise to the same geometry.
///
/// Both plots below draw the *same samples* with the same width; the only
/// difference is the series' `x0()` and the axis window's offset. The assertion is
/// the per-column trace-ink pattern: identical column sets, not merely similar
/// counts, which a quantised draw could not satisfy.
namespace {
/// Columns (every x of the axes) that hold at least one trace-coloured pixel.
std::vector<bool> trace_column_mask(plottest::HeadlessContext& ctx, const plot::Rect& a) {
const plot::u32 first_row = static_cast<plot::u32>(a.y);
const plot::u32 rows = static_cast<plot::u32>(a.h);
std::vector<bool> mask(a.w > 0.0f ? static_cast<std::size_t>(a.w) : 1u, false);
for (plot::u32 x = 0; x < static_cast<plot::u32>(a.w); ++x) {
const plot::u32 px_x = static_cast<plot::u32>(a.x) + x;
for (plot::u32 yy = 0; yy < rows; ++yy) {
plot::u8 px[4] = {};
if (ctx.read_pixel(px_x, ctx.height - 1u - (first_row + yy), px) && trace_pixel(px)) {
mask[x] = true;
break;
}
}
}
return mask;
}
} // namespace
PLOT_TEST(epoch_window_and_zero_origin_twin_rasterise_identically) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// A sparse sine: one raw sample per column, so every stage of the pipeline is
// exercised at pixel resolution -- the f32 defect shows on the x coordinates.
std::vector<plot::f32> ys(640u);
for (plot::u32 i = 0; i < ys.size(); ++i) {
ys[i] = 0.8f * std::sin(0.05f * static_cast<plot::f32>(i));
}
const f64 epoch = 1'700'000'000.0;
plot::data::ArraySeries zero(ys, 0.0, 1.0);
plot::data::ArraySeries shifted(ys, epoch, 1.0);
plot::Plot p0;
p0.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax0 = p0.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay0 = p0.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p0.add_trace({&zero, plot::Color::hex(0x40C0FF), 1.5f, ax0, ay0});
plot::Plot p1;
p1.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax1 =
p1.add_x_axis({"t", {epoch, epoch + 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay1 = p1.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p1.add_trace({&shifted, plot::Color::hex(0x40C0FF), 1.5f, ax1, ay1});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p0);
r.end_frame();
const std::vector<bool> columns_zero = trace_column_mask(ctx, p0.layout().axes);
r.begin_frame(kW, kH, vp);
r.draw(p1);
r.end_frame();
const std::vector<bool> columns_epoch = trace_column_mask(ctx, p1.layout().axes);
PLOT_CHECK(columns_zero == columns_epoch);
// The premise: the twin really drew a trace across the axes (the comparison is
// not vacuously true on two empty frames).
PLOT_CHECK(std::count(columns_zero.begin(), columns_zero.end(), true) >
static_cast<std::size_t>(p0.layout().axes.w) * 9u / 10u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- B6: two text batches
/// The B6 defect: one text batch, flushed after everything, put the legend's
/// names *over* the crosshair's readout panel -- text above the backplate that
/// exists to occlude it. The under batch (legend names included) is now flushed
/// immediately before the panel, so a legend name the panel covers is occluded.
///
/// The scene: a legend in the bottom-left corner with three wide names, and a
/// crosshair parked just inside that corner, whose readout panel flips up and
/// right -- on top of the legend rows. The assertion reads the legend name boxes
/// *minus* every rect the readout legitimately paints (its own label and value
/// runs, and the crosshair's strokes): what is left of a covered name box must
/// hold no text ink with the readout on, and held ink without it.
namespace {
/// Text ink inside `area`, skipping every pixel inside any of `exclude`.
plot::u32 text_ink_outside(const std::vector<plot::u8>& frame, const plot::Rect& area,
const std::vector<plot::Rect>& exclude) {
plot::u32 hit = 0;
const plot::u32 x0 = static_cast<plot::u32>(std::max(0.0f, area.x));
const plot::u32 y0 = static_cast<plot::u32>(std::max(0.0f, area.y));
const plot::u32 x1 = static_cast<plot::u32>(std::min(area.right(), kFrameRect.right()));
const plot::u32 y1 = static_cast<plot::u32>(std::min(area.bottom(), kFrameRect.bottom()));
for (plot::u32 y = y0; y < y1; ++y) {
for (plot::u32 x = x0; x < x1; ++x) {
bool skipped = false;
for (const plot::Rect& e : exclude) {
if (e.contains(static_cast<plot::f32>(x) + 0.5f, static_cast<plot::f32>(y) + 0.5f)) {
skipped = true;
break;
}
}
if (skipped) continue;
if (text_pixel(&frame[(plot::usize(y) * kW + x) * 4u])) ++hit;
}
}
return hit;
}
} // namespace
PLOT_TEST(readout_panel_occludes_the_legend_names_it_covers) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::Legend legend;
legend.place = plot::LegendPlace::BottomLeft;
p.set_legend(legend);
// Flat traces at distinct heights, wide names: three tall legend rows.
plot::data::ArraySeries a(std::vector<plot::f32>(640u, 0.7f), 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
plot::data::ArraySeries c(std::vector<plot::f32>(640u, 0.3f), 0.0, 1.0);
p.add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "channel one"});
p.add_trace({&b, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "channel two long name"});
p.add_trace({&c, plot::Color::hex(0x50D050), 1.5f, ax, ay, "channel three"});
// Warm-up frame (installs the text backend), then the legend-only frame.
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> legend_only = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
PLOT_CHECK(layout.legend.rows.size() == 3u);
PLOT_CHECK(layout.legend.panel.w > 0.0f);
// The crosshair just inside the bottom-left corner: its panel flips up and
// right, over the legend rows.
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
p.set_crosshair(tx.to_data_x(layout.axes.x + 30.0f),
ty.to_data_y(layout.axes.bottom() - 30.0f), true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> with_readout = read_frame(ctx);
// Fresh layout: the crosshair's own geometry only exists now that it is placed
// (the copy above was taken before `set_crosshair`).
const plot::CrosshairLayout crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.active);
PLOT_CHECK(crosshair.panel.w > 0.0f);
// The exclusions: the readout's own runs and the crosshair's strokes.
std::vector<plot::Rect> exclude;
for (const plot::CrosshairRow& row : crosshair.rows) {
if (row.listed) {
exclude.push_back(row.label_box);
exclude.push_back(row.value_box);
exclude.push_back(row.swatch); // the row's own swatch fill
}
}
exclude.push_back(crosshair.vertical);
exclude.push_back(crosshair.horizontal);
// The premise: the panel really does cover at least one legend name box.
bool covers = false;
for (const plot::LegendRow& row : layout.legend.rows) {
if (row.ellipsis) continue;
if (crosshair.panel.x < row.name.right() && row.name.x < crosshair.panel.right() &&
crosshair.panel.y < row.name.bottom() && row.name.y < crosshair.panel.bottom()) {
covers = true;
}
}
PLOT_CHECK(covers);
// Each legend name box: ink without the readout; in the part the panel covers,
// none of it left (outside the readout's own runs) once the panel is over it.
// The uncovered remainder of a partially covered box keeps its ink -- that is
// the panel doing its job and nothing more.
bool checked = false;
for (const plot::LegendRow& row : layout.legend.rows) {
const bool overlaps = crosshair.panel.x < row.name.right() &&
row.name.x < crosshair.panel.right() &&
crosshair.panel.y < row.name.bottom() &&
row.name.y < crosshair.panel.bottom();
if (row.ellipsis || !overlaps) continue;
// Inset past the panel's own border ring: the border is a hairline of the
// bright crosshair colour (see draw_readout_panel), which the ink predicate
// cannot tell from text -- it is the panel's chrome, not legend ink.
const plot::Rect inner{crosshair.panel.x + 2.0f, crosshair.panel.y + 2.0f,
crosshair.panel.w - 4.0f, crosshair.panel.h - 4.0f};
const plot::Rect covered{
std::max(inner.x, row.name.x), std::max(inner.y, row.name.y),
std::min(inner.right(), row.name.right()) - std::max(inner.x, row.name.x),
std::min(inner.bottom(), row.name.bottom()) - std::max(inner.y, row.name.y)};
PLOT_CHECK(covered.w > 0.0f && covered.h > 0.0f);
PLOT_CHECK(text_ink_outside(legend_only, covered, {}) > 0u);
PLOT_CHECK(text_ink_outside(with_readout, covered, exclude) == 0u);
checked = true;
}
PLOT_CHECK(checked);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- markers
/// Markers are staged as line segments through the trace's own batch: a
/// marker'd trace lights pixels a plain line does not (the shapes extend past
/// the stroke), and the stride is literal -- `marker_every = 3` stages exactly a
/// third of the markers of `marker_every = 1` over the same window.
PLOT_TEST(markers_light_more_than_the_line_and_stride_is_literal) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// 32 sparse points: the auto stride would be ~1 marker per point anyway, so
// `marker_every = 1` vs `3` differ by construction.
std::vector<plot::f32> ys(32u);
for (plot::u32 i = 0; i < ys.size(); ++i) {
ys[i] = 0.5f + 0.4f * std::sin(0.9f * static_cast<plot::f32>(i));
}
plot::data::ArraySeries s(ys, 0.0, 20.0);
plot::Plot plain_p;
plain_p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = plain_p.add_x_axis({"t", {0.0, 620.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = plain_p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::Plot marked_p = plain_p;
plot::Plot every3_p = plain_p;
marked_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 1u});
every3_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 3u});
plain_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(plain_p);
r.end_frame();
const plot::u32 plain_ink = predicate_pixels(read_frame(ctx), kW, plain_p.layout().axes,
magenta_pixel);
r.begin_frame(kW, kH, vp);
r.draw(marked_p);
r.end_frame();
const plot::u32 marked_ink = predicate_pixels(read_frame(ctx), kW, marked_p.layout().axes,
magenta_pixel);
const plot::u32 marked_count = r.stats().staged_markers;
r.begin_frame(kW, kH, vp);
r.draw(every3_p);
r.end_frame();
const plot::u32 every3_count = r.stats().staged_markers;
// The premise: the line drew something; the markers drew strictly more.
PLOT_CHECK(plain_ink > 0u);
PLOT_CHECK(marked_ink > plain_ink);
// The stride is literal: a third of the markers, ±1 for the window's edge
// sample count.
PLOT_CHECK(marked_count >= 30u);
PLOT_CHECK(every3_count * 3u + 2u >= marked_count);
PLOT_CHECK(every3_count * 3u <= marked_count + 2u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- text colour
/// The readout's VALUE column draws in the row trace's own colour (resolved from
/// the plot at draw time), the labels in the axis text colour: per-instance
/// `a_colour` instead of one text tint per draw.
PLOT_TEST(readout_value_draws_in_the_row_trace_colour) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
const auto magenta = plot::Color::hex(0xFF5090);
p.add_trace({&s, magenta, 1.5f, ax, ay, "row"});
p.set_crosshair(300.0, 0.5, true);
p.set_crosshair_readout(true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].listed);
PLOT_CHECK(crosshair.rows[0].value_box.w > 0.0f);
// The value box holds pixels the row trace's colour explains (magenta
// dominance at any blend over the dark panel) -- the old single-tint pipeline
// drew every glyph in the axis text grey, so this is the defect's own pixel.
plot::u32 coloured = 0;
const plot::Rect box = crosshair.rows[0].value_box;
for (plot::u32 y = static_cast<plot::u32>(box.y); y < static_cast<plot::u32>(box.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(box.x); x < static_cast<plot::u32>(box.right());
++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (magenta_pixel(px)) ++coloured;
}
}
PLOT_CHECK(coloured > 0u);
// The label box, same frame, is the axis text colour, not the trace's.
plot::u32 label_grey = 0;
const plot::Rect labels = crosshair.rows[0].label_box;
for (plot::u32 y = static_cast<plot::u32>(labels.y); y < static_cast<plot::u32>(labels.bottom());
++y) {
for (plot::u32 x = static_cast<plot::u32>(labels.x); x < static_cast<plot::u32>(labels.right());
++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (text_pixel(px)) ++label_grey;
}
}
PLOT_CHECK(label_grey > 0u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- R6: GL state residue and shutdown
/// `begin_frame()` saves the host's blend function and enable bit; `end_frame()`
/// hands both back -- a host that blends its own way before the frame must not
/// inherit the frame's `SRC_ALPHA / ONE_MINUS_SRC_ALPHA`.
PLOT_TEST(a_frame_hands_the_blend_state_back) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// The host's own blend: function ONE/ZERO, enabled.
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_ONE, plot::gl::GL_ZERO);
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
plot::gl::GLint src = -1;
plot::gl::GLint dst = -1;
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_SRC_RGB, &src);
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_DST_RGB, &dst);
PLOT_CHECK(src == static_cast<plot::gl::GLint>(plot::gl::GL_ONE));
PLOT_CHECK(dst == static_cast<plot::gl::GLint>(plot::gl::GL_ZERO));
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) != plot::gl::GL_FALSE);
// And with blending off, it comes back off.
plot::gl::glDisable(plot::gl::GL_BLEND);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) == plot::gl::GL_FALSE);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
/// `release_gl()` destroys every GL handle while the context is current: after
/// it the renderer is uninitialised, the driver holds no plot objects (a
/// re-init and frame still draw, which is the observable half), and the same
/// renderer object can be brought back.
PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
r.release_gl();
PLOT_CHECK(!r.ready());
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
// Re-init on the same object draws again -- the handles were released, not
// half-destroyed.
PLOT_CHECK(r.init(&st));
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}