fix(render,input): guard the zoom gain, clip annotation captions, no-throw dump

Three findings reachable by ordinary use:

* `Controller::axis_zoom()` divided by `Plot::ui_scale()` directly, though
  `Style::device()` documents a non-positive scale as host state that must be
  tolerated. With `ui_scale == 0` and a negative drag delta, `logical_dy` was
  `-inf`, the factor `+inf`, and the guard `!(factor > 0.0)` did not reject it:
  `zoom_x_axis`/`zoom_y_axis` computed `anchor + (bound - anchor) * inf`, and
  `set_y_range()` stored it verbatim and cleared `auto_scale` -- an axis left
  non-finite until an auto-fit. A negative scale silently inverted the zoom.
  A scale that is not positive and finite now means no zoom at all.

* `draw_annotations()` stages its caption into the frame's single text batch,
  which `flush_text()` draws *after* `clear_clip()`, so a caption staged while
  the axes clip was engaged was rasterised with the viewport clip. An
  annotation anchored outside the visible range kept a caption with no cross,
  and in a `Figure` -- whose rows tile the figure -- that caption painted into
  the neighbouring row. A caption whose anchor is outside `axes` is now skipped
  with its cross, rather than splitting the frame's one text draw call (which
  would cost a draw call per row and mis-clip the labels staged outside `axes`).

* `dump_frame_ppm()` was the one public entry point not `noexcept`, so a
  `std::bad_alloc` crossed the API boundary. Its buffers are now sized before
  anything is on disk and a failure is reported as `ErrorCode::OutOfMemory`,
  exactly as `TrueTypeFont::from_file()` reports it.

Tests: `ui_scale` 0 and -1 leave the axis range finite and unchanged with
`auto_scale` intact; an annotation anchored outside a bottom figure row's y
range leaves no caption ink in the row above, while an in-range one still
draws; a `static_assert` pins the dump's `noexcept`.
This commit is contained in:
Martino Ferrari committed 2026-09-28 22:36:34 +02:00
1 parent 9aff06acee
commit 2fbd1de7d4
5 files changed
+188 -19

No files matched your search

+8 -9
View File
@@ -284,15 +284,14 @@ class Renderer {
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
/// channel, rows top-down) so a headless render can be looked at without a
/// window. Reads the framebuffer that is bound now, which is the one the frame
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
/// unreadable pixel.
///
/// The one entry point here that is not `noexcept`: it sizes its readback hole
/// and its output row, so an allocation failure propagates `std::bad_alloc`
/// rather than terminating a process that could have carried on without a
/// screenshot. A dump is a diagnostic, and no `Status` code could say more than
/// the exception already does.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
/// 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: