Files
plotter/include/plot/render/renderer.hpp
T
Martino FerrariandClaude Opus 4.6 525759f9a9 fix(render): filter the glyph atlas, snap chrome hairlines to pixels
Text was point-sampled (`GL_NEAREST` plus `floor(v_uv * u_tex_size)`), which
at a non-integer scale *deletes* texels instead of blending them. The library's
own default -- a 12 px style over the 13 px embedded bake -- dropped one of
every 8 columns and one of every 17 rows of every glyph, so whole strokes went
missing and which ones depended on where the unsnapped pen fell. `kTextFS` now
does a 2x2 bilinear fetch clamped to the instance's own cell: the clamp keeps
the no-bleed guarantee the sampler mode protects (the atlas is a tight strip of
cells with no padding), and at scale 1 with a whole pen the weights are exactly
0, so it stays as crisp as the point sample.

Grid and axis lines were staged in data space and mapped in the vertex shader,
so a 1 px line's centre landed on an arbitrary fraction -- x = 100.0 lights two
columns at half coverage, x = 100.5 lights one crisply, and a frame mixed both.
`u_snap` rounds an axis-aligned segment's ink *leading edge* onto a pixel
boundary, which reduces to integer+0.5 for odd widths and to an integer for
even ones with no case analysis. It is on for the chrome that frames a plot and
off for anything drawn at a data position, since the rounding moves a line by
up to half a pixel. Diagonals are untouched.

`text_pixel`'s threshold drops to 160 because filtered glyphs legitimately peak
at 0.844 coverage at 12/13; the 180 threshold found text only while the sampler
was also deleting a column and a row of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-02 00:22:08 +02:00

636 lines
38 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#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/math/vec2.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/embedded_mono_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/text_atlas.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 staged_markers = 0; ///< marker shapes staged over the traces
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;
/// Releases every GL handle first (`release_gl()`), so the destructor is safe
/// whenever the context that owns them is still current -- the normal case for
/// a member renderer destroyed by its host's widget teardown.
~Renderer();
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: both programs, both vertex
/// arrays and their instance buffers, and the glyph atlas texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object.
///
/// **Teardown contract:** call this while the context that owns the names is
/// still current, before the host destroys that context -- a GL name deleted
/// after its context is gone is leaked, not deleted. The destructor calls this
/// first for the common case of a member renderer whose context outlives it;
/// a host that tears the context down before its renderer must call
/// `release_gl()` explicitly.
void release_gl() noexcept;
/// Compiles both programs, uploads the font's 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;
/// The face every frame this renderer draws uses: the embedded one until a host
/// installs another here.
/// `init()` binds `render::EmbeddedMonoFont`, and `draw()` installs the binding
/// on the plot it is given (replacing whatever a host set with
/// `Plot::set_text_backend()`, which is how a plot with no renderer keeps its
/// own), so this is the way to draw with a face of your own -- a
/// `plot::text::TrueTypeFont` from the optional target, say, or any other
/// `TextBackend`. Passing null restores the embedded face. The swap takes
/// effect on the next frame, which re-reads the backend's metrics at
/// `style().device(axis_font_px)` and re-uploads its atlas -- so nothing here
/// has to know whether the replacement is a different face, the same face
/// re-baked at another size, or a backend whose pixels changed: the revision
/// comparison in `render::TextAtlas` decides, and a replacement whose atlas is
/// already on the GPU under the same revision is uploaded anyway (see the
/// implementation for why that is the safe direction).
///
/// The backend is borrowed, not owned: it must outlive the frames drawn with it,
/// which is the same lifetime rule `Plot::set_text_backend()` states.
void set_text_backend(const render::TextBackend* font) noexcept;
/// The backend this renderer *borrows*: whatever `set_text_backend()` was given,
/// or the embedded face when nothing was installed. It is deliberately **not**
/// the `AxisFont` wrapper the renderer builds around it -- after a `draw()`,
/// `Plot::text_backend()` reads back that wrapper, because the wrapper is what
/// the plot measured with -- so a caller checking or re-pointing the face itself
/// asks here.
const render::TextBackend* text_backend() const noexcept { return font_; }
/// 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.
///
/// The scissor that bounds the clear is the frame's *baseline clip*: it stays engaged,
/// set to the same viewport rect, so every pass of the frame is bounded by the region
/// the host gave the renderer -- and `draw()` narrows and releases it again around the
/// passes that draw inside a plot's axes (see `set_clip()`). `end_frame()` turns the
/// scissor test off again, so a host never inherits a clip it did not ask for.
///
/// 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, hands it the style's `ui_scale` *before* it lays out
/// (`Plot::set_ui_scale()`) 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.
///
/// Every metric in `style()` is a logical unit, so this is also where the scale
/// becomes pixels: the font is reset at `style().device(axis_font_px)` and each
/// stroke and padding is converted through the same call. A host therefore states
/// its display once, in `style().ui_scale`, and the frame it gets back is the same
/// physical size on a 1x panel and a 2x one.
///
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
/// it runs the auto scale, the strip fixed-point loop, the grid build and the
/// region geometry, so re-deriving it per helper would rebuild all of that per
/// helper.
///
/// The handles a pointer is on are drawn highlighted -- a cursor marker is widened
/// into a tab, a region bound is drawn in the style's cursor colour at the width
/// `PlotLayout` sized its handle at -- and what is hot comes from
/// `Plot::hot_handles()`, which the input layer publishes. The renderer therefore
/// never sees a pointer, a button or a gesture: it draws what the plot says is
/// being manipulated, and only when a host drives the plot with a controller is
/// anything ever hot.
///
/// A legend the plot has is part of the frame: the panel and its swatches are
/// line geometry staged with everything else, the names are glyphs on the text
/// path, and both read the boxes `PlotLayout::legend` measured. It is the last
/// line batch, so the legend cannot be drawn under a trace, and it leaves no GL
/// state of its own: it is drawn inside the same axes clip the interior passes share,
/// and the frame's blend already says everything a fill and a hairline border need
/// (see end_frame's residue list).
///
/// Regions are part of that same evidence: the fill is one instance of the line
/// pipeline (`fill_pixels()`, the primitive the legend panel uses), the bounds are
/// vertical strokes and the label is a staged glyph run, so a frame with regions
/// leaves behind exactly what a frame without them does.
void draw(Plot& plot);
///
/// **The clip** is the one piece of state the frame moves to keep data inside the plot
/// area (spec §13): the passes that draw *inside* `axes` -- grid, traces, regions,
/// cursors, the measurement band, annotations, the legend and the crosshair -- run with the scissor
/// narrowed to `layout().axes`, intersected with the frame's viewport, while the axis
/// lines (chrome drawn on the rect's own edge, which overhangs it by half a stroke) and
/// the tick labels and titles (outside the rect by design) run with the frame's viewport
/// clip. A sample outside the visible range, or a segment the gathered window's slack
/// hangs past the end of the window, therefore cannot be drawn over the label strips --
/// or, in a `Figure`, into the row above.
///
/// Nothing moves to make that happen: the clip is a GL state change and not a geometry
/// one, so the vertices staged, the transforms and the data-space invariant are exactly
/// what they were.
/// Draws a whole `Figure`: every row in its own rect, then the figure's one shared
/// x axis.
/// Each row is drawn by the *same* path as `draw(Plot&)` -- the viewport and the
/// x-label policy come from `Figure::layout()`, which the figure hands out and
/// which lays every row out against its rect -- so a row is chrome and data drawn
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts.
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
/// that row's normal path: when the figure says labels belong on the bottom only
/// (the default), the rows above reserve no x chrome and the bottom row stages its
/// own labels and title once, inside its own strip -- the rect
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
/// its own chrome and each draws its own labels; there is then no shared axis.
/// Styling, the text backend and `ui_scale` are installed on **every** row before
/// the figure computes its layout, so the strips the rows measure and the strokes
/// this draws are one set of numbers, exactly as they are for one plot.
void draw(Figure& figure);
/// 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, scissoring off, and the
/// viewport left at the frame's rect (the caller sets its own for the next pass).
///
/// The scissor is the other piece of state a frame moves, and it ends the frame *off*:
/// `begin_frame()` engages it on the frame's viewport -- the box the clear uses --
/// `draw()` narrows it to a plot's `axes` for the interior passes and releases it back to
/// the viewport for the labels and titles, and this turns the test off. A host is
/// therefore never left holding a clip it did not ask for: not the axes rect of the last
/// interior pass, and not the viewport's either. The box itself is left at the viewport's
/// own rect, which is where that release puts it, so a host that enables the scissor
/// again without setting one gets the region the frame drew in.
/// The one piece of global state the frame *changed* is handed back rather than
/// left where the frame wanted it: the clear colour, which is
/// `style().background` only for the duration of the frame. GL keeps no history
/// for it, 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.
///
/// `GL_UNPACK_ALIGNMENT` is deliberately not in that list any more: the atlas
/// upload needs it at 1 for single-channel rows, and `render::TextAtlas` saves
/// and restores it around every upload it performs, so no frame -- initial or
/// mid-run -- leaves it moved for the host's own uploads to inherit.
/// 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 --
/// style and `ui_scale` both -- so the layout it computes matches what is drawn.
/// Every metric here is a logical unit (`Style`'s unit rule): the host writes
/// `ui_scale` from the display it is on and this converts each metric with
/// `device()`. `line_width_px` is the width of the strokes framing the data (axis
/// lines, cursor markers); the grid carries a width *per class*
/// (`grid_major_width` / `grid_minor_width`) and every trace its own, and a grid
/// class the style switches off stages no geometry at all.
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, in **device** pixels: the
/// on-screen cell height of the backend it draws with, which is
/// `style().device(Style::axis_font_px)`'s own cell rather than the logical request
/// itself. `Style::axis_font_px` is an em size, so with an ascent-to-descent cell
/// it is this that a host reads back as the line height it got -- and it is the
/// backend's own `height()` scaled, so it always agrees with the metrics the plot
/// laid the labels out with.
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, an
/// unreadable pixel, or an allocation the readback could not make -- which is
/// reported as `ErrorCode::OutOfMemory`, exactly as `TrueTypeFont::from_file()`
/// reports the same failure. `noexcept` like every other entry point here: the
/// buffers are sized before anything is on disk, so the two failures that can
/// arise from sizing them are a `Status`, not a `std::bad_alloc` across the API
/// boundary and a half-written file.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
private:
/// The backend the plot layer sees, at the size the text pipeline draws it:
/// `measure`/`height`/`ascent` scale with the run, so a label can never be laid
/// out for one size and drawn at another.
///
/// It wraps a `TextBackend` and nothing more: the scale is
/// `axis_font_px / em_px()`, a plain ratio, so an anti-aliased face is drawn at
/// the size the style asked for rather than at a whole multiple of its cell,
/// and any backend -- the embedded font, a host's TrueType face -- is drawn the
/// same way. It forwards the whole interface: the metrics and the glyph
/// bearings and advances are scaled, while the artwork itself -- the atlas
/// bytes, its size and its revision -- is the backend's own and passes through
/// untouched.
class AxisFont final : public render::TextBackend {
public:
void reset(const render::TextBackend& font, f32 requested_px) noexcept;
f32 measure(const char* utf8) const override;
f32 height() const override;
f32 ascent() const override;
f32 em_px() const override;
/// The font's glyph at this wrapper's scale: the atlas rect is unchanged (it
/// is scale-free) and the bearing and advance are multiplied by it, so one
/// `Glyph` describes the glyph this backend draws. The result lives in the
/// wrapper until the next call, which is enough for a caller measuring or
/// laying out one glyph at a time.
const render::Glyph* glyph(u32 codepoint) const override;
/// The font's cell: the atlas is the font's own, and a cell's size is a
/// property of the atlas, not of the size text is drawn at, so this forwards
/// unchanged -- which is what makes it the authoritative value the staging
/// path declares and the draw is told.
Vec2 cell_uv() const override;
const u8* atlas_pixels() const override;
u32 atlas_width() const override;
u32 atlas_height() const override;
u32 revision() const override;
/// On-screen size of one glyph cell, in pixels.
f32 cell_px() const noexcept { return cell_px_; }
/// On-screen pixels per font pixel: the factor `measure()` and `glyph()` are
/// scaled by, and what `TextBatch::add_text` is handed as its size.
f32 scale() const noexcept { return scale_; }
private:
const render::TextBackend* font_ = nullptr;
f32 cell_px_ = 1.0f;
f32 scale_ = 1.0f;
/// `glyph()`'s scaled result, returned by pointer because the interface is
/// `const Glyph*` and the scale is the one thing this wrapper adds.
mutable render::Glyph scaled_{};
};
/// 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).
///
/// `origin_x` is the data-space reference the caller staged its geometry
/// relative to: every data-space x reaches this layer as the f32
/// `to_space(x) - to_space(origin_x)` (B1), so the mapping folds the origin
/// back into the offset uniform -- `offset_x = sx*to_space(origin_x) + tx -
/// viewport.x`, computed in f64 and narrowed once -- and the staged values
/// stay O(visible pixels) however large the absolute x is (an epoch axis).
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, f64 origin_x) 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.
///
/// `dash` is the pattern in pixels for the fragment shader's discard: `x` the ink
/// length, `y` the gap, and a non-positive `y` -- the default every caller but the
/// crosshair passes and a dashed trace -- means **solid**. It is set on every flush,
/// so one draw's dash can never leak into the next. The AA fringe (`u_aa`) is
/// likewise set on every flush, from `style().line_aa_px` in device pixels.
///
/// `snap` is `LineProgram::set_snap()`'s pixel-grid rounding: on for the chrome
/// that frames a plot -- the grid, the axis lines, a panel or legend border --
/// and off for everything drawn at a data position, since it moves an
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
bool snap = false) noexcept;
/// Draws every staged glyph in `batch` -- each instance carries its own tint
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
/// once per text batch, after the line geometry it belongs over.
void flush_text(render::TextBatch& batch) noexcept;
/// Narrows the frame's clip to `rect` -- framebuffer pixels, top-left origin, like every
/// `Rect` here -- and *intersects* it with the frame's viewport, so the clip can only
/// ever be smaller than the region the frame owns: a rect the layout got wrong, or one
/// that reaches past the frame, clips the interior passes away rather than letting them
/// paint over the host's surface. Enables `GL_SCISSOR_TEST`; `clear_clip()` puts it back.
///
/// The box is integral and GL's origin is bottom-left, so this is also where a `Rect`
/// becomes a scissor box, and it rounds *inward* -- see `clip_to()`. Called once per
/// plot with `layout().axes`, and released again before the passes that draw outside the
/// axes (see `draw_plot()`).
void set_clip(const Rect& rect) noexcept;
/// Puts the frame's clip back to its viewport: the state `begin_frame()` leaves, and the
/// one the axis lines, the tick labels and the axis titles run with -- chrome that lives
/// outside `axes` by design, and would be erased under the axes clip.
void clear_clip() noexcept;
/// The one place a scissor box is computed from a rect's four edges: clamped to the
/// framebuffer's rows, rounded *inward* (`ceil` the near edges, `floor` the far ones) so
/// the box holds only pixels the rect entirely contains -- rounding outward would let a
/// trace's half-pixel fringe past the edge it is being kept behind -- and flipped to GL's
/// bottom-left origin. An empty or unordered rect (a degenerate axes, or a NaN) sets an
/// empty box, which rejects every fragment: the honest meaning of "clip to nothing".
void clip_to(f32 x0, f32 y0, f32 x1, f32 y1) noexcept;
/// Fills `rect` -- in framebuffer pixels, like `Rect` -- with `color`, in one
/// instance: a horizontal stroke whose width is the rect's height *is* the rect,
/// because a stroke's quad is its perpendicular width and the fragment shader
/// ramps on that perpendicular alone -- the ends are not capped (see
/// `fill_pixels()` in the implementation for why a per-column fill, the way the
/// measurement band fills, is the wrong primitive here). A rect with no width or
/// no height stages nothing.
void fill_pixels(const Rect& rect, Color color);
/// Stages one data-space segment as f32 values **relative to `origin_x`** in
/// the axis' pre-affine space (`log10` of it on a log axis): the mapping folds
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` points of a gathered window (see
/// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The
/// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
void stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it into
/// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run.
Vec2 stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color);
/// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at
/// the size the text pipeline draws with: exactly what `stage_text_clipped()`
/// stages.
f32 clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const;
/// Stages as much of `utf8` as fits `budget_px` along `axis` at `pos_px` and
/// returns the length 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).
///
/// `axis` is the run's own direction and a unit vector: (1, 0) for the
/// horizontal x title, which spends the strip's width, and (0,-1) for the y
/// title, which reads bottom-to-top and spends the strip's height. `pos_px` is
/// the pen -- for the rotated run, the bottom-left corner of its first cell.
f32 stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8, f32 budget_px,
Vec2 axis, Color color);
/// Pins the text pipeline for a frame: the plot layer's backend at the size the
/// style asks for, the cell every staged glyph is drawn in, and the atlas the
/// backend publishes -- re-uploaded when its revision moved. False when a driver
/// refused the re-upload, which the frame reports (see `last_frame_had_gl_error()`)
/// and carries on from, drawing with the atlas it already had.
bool begin_frame_text() noexcept;
/// Hands `plot` everything the frame draws with: the renderer's text backend (a
/// host's is replaced here, named in a debug build, as it always was), the style,
/// and the `ui_scale` the plot converts that style's logical metrics by. Called
/// before a plot is laid out, which is why it is separate from the drawing.
void prepare_plot(Plot& plot);
/// Draws one plot's frame with the layout the caller already computed:
/// `draw(Plot&)` passes `plot.layout()`, and `draw(Figure&)` the layout
/// `Figure::layout()` computed for that row -- so a figure's rows are drawn by
/// exactly this path and neither entry point lays a plot out twice.
void draw_plot(Plot& plot, const PlotLayout& layout);
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 is computed once, in `draw()`, and handed
/// down -- never re-derived per helper.
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);
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
/// range, in its own colour -- or in the accent at handle width while it is hovered or
/// grabbed, which is the same cue a region's hot bound gets. A cursor more than half a
/// stroke outside its range's mapped span (a marker scrolled past the right edge, a ruler
/// past the bottom) is skipped; one on or within half a stroke of a bound is drawn, with
/// its stroke centre held half a stroke inside the rect -- so a marker parked on an edge
/// stays visible there instead of losing its outside half, and the inside sliver of it, to
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
/// off the plot.
void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
/// own row, with the caption first on that line when there is one.
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's
/// rects.
void draw_regions(const Plot& plot, const PlotLayout& layout);
/// One region bound: a vertical stroke of `width` down the tab's own column, in
/// `color`. The column is `handle_column()` of the tab -- the bound the layout
/// placed -- and a bound outside the axes' columns is skipped, exactly as a cursor
/// marker out of the visible window is.
void draw_region_edge(const Rect& tab, const Rect& axes, Color color, f32 width);
void draw_measures(const Plot& plot, const PlotLayout& layout);
void draw_annotations(const Plot& plot);
/// The legend: the panel and its swatches as line geometry, the names on the text
/// path. It reads the boxes `Plot::layout()` measured rather than deriving any of
/// them again, so the panel a test asserts and the panel this draws are the same
/// numbers.
void draw_legend(const Plot& plot, const PlotLayout& layout);
/// The backplate the legend and the crosshair's readout share: the frame's background at
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
/// legend panel and a readout panel do not share.
void draw_readout_panel(const Rect& panel, Color border);
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
/// rows both read it from here.
void draw_swatch(const Plot& plot, u32 trace, const Rect& box);
/// The crosshair's **lines and markers**: the vertical and horizontal strokes and a small
/// cross at each trace's intersection.
///
/// Every rect -- the two stroke boxes and the markers' positions -- comes from
/// `PlotLayout::crosshair`, which placed them inside `axes`; this stages exactly that
/// geometry, so the crosshair a test measures and the crosshair a frame paints are one set of
/// numbers. Clipped to `axes` with the rest of the interior passes, which is what keeps a
/// marker for a sample far outside the visible y range inside the rect instead of over a
/// label strip.
///
/// Drawn over the data and *under* the handles -- regions, cursors -- because the crosshair
/// sits wherever the pointer is, including on top of the handle a gesture is manipulating, and
/// a hover or grab highlight must not be hidden by the instrument that happens to share its
/// column. Its readout panel is the other half (`draw_crosshair_readout()`), drawn last.
void draw_crosshair_lines(const Plot& plot, const PlotLayout& layout);
/// The crosshair's **readout panel**: the backplate, its border and one swatch, label and
/// value per listed row, all read from `PlotLayout::crosshair` -- the plot formatted the
/// values (in each trace's own y axis' format) and placed every box, so this layer formats
/// nothing and derives no rect. A row that is not listed is skipped; a row whose trace has no
/// sample at the crosshair's x stages no value at all rather than a fabricated one.
///
/// Drawn after every other interior pass -- the legend included -- because it is what the
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
/// pixel space, at `size_px` half-extent, so a marker's size is device pixels
/// whatever the data scale is.
void stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px);
/// The built-in backend the renderer owns, and the face the frames draw with:
/// `set_text_backend()` writes `font_`, which starts here and comes back here
/// when a host passes null. Every use of the face goes through the
/// `TextBackend` interface -- `self`-typed nowhere -- so a host's backend and
/// this one are interchangeable, and the only thing that has to happen on a swap
/// is the atlas re-upload `set_text_backend()` arranges.
render::EmbeddedMonoFont default_font_;
const render::TextBackend* font_ = &default_font_;
AxisFont font_backend_;
render::Style style_;
render::TextAtlas atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_;
render::LineBatch lines_;
/// The frame's two text batches (B6): `glyphs_under_` collects the text the
/// crosshair readout's panel may legitimately occlude (legend names, cursor
/// labels, measure labels, annotation captions) and is flushed immediately
/// before `draw_crosshair_readout()`; `glyphs_over_` collects what must sit
/// over everything (readout text, tick labels, titles) and is flushed at
/// `end_frame()`. One batch per z-order, one draw call each.
render::TextBatch glyphs_under_;
render::TextBatch glyphs_over_;
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
std::vector<f32> staged_; ///< scratch for one staged window, re-used
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// 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;
/// The host's blend state, saved by `begin_frame()` and handed back by
/// `end_frame()` (R6): a frame sets blending for its straight-alpha pipelines
/// and must not leave its function -- or the enable bit -- behind for the
/// host's own draws to inherit.
gl::GLint blend_src_rgb_ = gl::GL_ONE;
gl::GLint blend_src_a_ = gl::GL_ONE;
gl::GLint blend_dst_rgb_ = gl::GL_ZERO;
gl::GLint blend_dst_a_ = gl::GL_ZERO;
bool blend_was_on_ = 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