// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 interleaved) noexcept; /// Same, for the text pipeline's four-float glyph instances. void upload_text_vertices(std::span 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); /// `Plot::x_note()`, in the bottom-left corner of the axes on the legend's backplate. It is /// measured here rather than in `Plot::layout()` on purpose: unlike a legend it reserves no /// space and nothing can be picked on it, so there is no measurement a host or a test needs to /// read back, and a caption that explains the x labels must not be able to move the layout the /// labels were measured into. void draw_x_note(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 gather_; ///< scratch for one gathered window, re-used std::vector staged_; ///< scratch for one staged window, re-used mutable std::vector 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