feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size

This commit is contained in:
Martino Ferrari committed 2026-09-28 12:17:53 +02:00
1 parent 1f03631e98
commit 7500fc90d2
21 files changed
+708 -82

No files matched your search

+6
View File
@@ -249,6 +249,12 @@ void Host::refresh_sizes() noexcept {
fb_height_ = fh > 0 ? static_cast<u32>(fh) : 0u;
if (ww > 0 && fw > 0) fb_scale_x_ = static_cast<f32>(fw) / static_cast<f32>(ww);
if (wh > 0 && fh > 0) fb_scale_y_ = static_cast<f32>(fh) / static_cast<f32>(wh);
// The renderer draws its logical style metrics at this same ratio, so the chrome
// is the same physical size at any scale: one value states the display, and a
// 12 px label is 24 device px on the 2x panel both these sizes describe. Read here
// rather than in the frame loop because this is the one place the two sizes are,
// and re-read whenever the window moves to a display with another scale.
if (renderer_) renderer_->style().ui_scale = fb_scale_x_;
}
// ------------------------------------------------------------------ events
+4 -1
View File
@@ -48,7 +48,10 @@ using FrameFn = std::function<void(plot::Plot&, f64 dt)>;
/// `glfwGetWindowSize()` disagree. The host calls `begin_frame` with the
/// framebuffer size and scales every pointer coordinate by the ratio
/// `framebuffer / window`, so a gesture lands on the pixels the plot was laid out
/// in. On an unscaled display the ratio is 1.0 and nothing changes.
/// in. The same ratio states the display to the renderer
/// (`Style::ui_scale`), which is what makes the chrome -- labels, strokes, paddings
/// -- one physical size at any scale. On an unscaled display the ratio is 1.0 and
/// nothing changes.
///
/// Headless smoke mode, read once per `run()`:
///
+6
View File
@@ -303,6 +303,12 @@ void PlotArea::render() noexcept {
last_render_s_ = started;
if (frame_) frame_(*plot_, dt);
// The mapping's factor is the display's scale, so the renderer draws its logical
// style metrics at the same ratio the viewport and every forwarded coordinate are
// in: a 12 px label is the same physical size on a 1x and a 2x widget. It is the
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
renderer_->style().ui_scale = mapping_.factor;
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
renderer_->draw(*plot_);
renderer_->end_frame();
+8 -5
View File
@@ -110,11 +110,14 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// a delta is then a logical difference scaled by the factor in force now, never
/// the difference of two device positions (see `DragAnchor`).
///
/// Note that this also means the renderer's *style metrics* stay in device
/// pixels: at scale 2 the label font is 24 device px = 12 logical px, so labels
/// are smaller relative to the widget than at 1x. That is the same convention
/// the GLFW and Qt adapters use, and a host that wants a constant logical text
/// size scales `Renderer::style()` metrics by the factor itself.
/// The same factor states the display to the *renderer*: it is written to
/// `Renderer::style().ui_scale` every frame, which is what makes the chrome the
/// same physical size at any scale. The style's metrics are logical (see the unit
/// rule on `render::Style` -- spec §10 reverses the older convention that had them
/// in device pixels and accepted smaller labels on a high-DPI host), so at scale 2
/// the label font is 12 logical px drawn at 24 device px: the same size on screen as
/// at 1x, not a smaller one. This is the same convention the GLFW, Qt and ImGui
/// adapters use, and none of them has to scale a style metric by hand.
///
/// ## Input
///
+5
View File
@@ -119,6 +119,11 @@ void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer)
controller_->set_viewport(region);
forward_input(hovered);
// The backend's framebuffer scale is the display's scale, so the renderer draws
// its logical style metrics at it: the same assertion above pins it positive,
// and the chrome is then the same physical size whatever surface the host
// renders into.
renderer.style().ui_scale = scale.x;
const u32 fb_w = static_cast<u32>(fb_w_f + 0.5f);
const u32 fb_h = static_cast<u32>(fb_h_f + 0.5f);
renderer.begin_frame(fb_w, fb_h, region);
+5
View File
@@ -179,6 +179,11 @@ void PlotWidget::paintGL() {
last_paint_s_ = started;
if (frame_) frame_(*plot_, dt);
// The same factor `device_viewport()` scales the widget by: it states the display
// for the renderer too, so the logical style metrics are drawn at this ratio and
// a 12 px label keeps its physical size on a 2x screen. Re-read every frame, like
// the viewport, so a window moved to another display is drawn at *its* scale.
renderer_->style().ui_scale = static_cast<f32>(devicePixelRatioF());
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
renderer_->draw(*plot_);
renderer_->end_frame();
@@ -8,7 +8,7 @@
**Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` sections 10, 11, 12. Read the relevant section before each task.
**Priority:** these three come *before* the remaining tasks of `2026-09-28-plot-interaction-and-fonts.md` (drag-and-drop regions, `Figure`, the demo/verification pass, the optional TrueType target). Task A changes the units every later visual task is written in, so it goes first; Task B is small and independent; Task C unblocks the interaction work.
**Priority:** these three come *before* the remaining tasks of `2026-09-28-plot-interaction-and-fonts.md` (drag-and-drop regions, `Figure`, the demo/verification pass, the optional TrueType target). Task 1 changes the units every later visual task is written in, so it goes first; Task 2 is small and independent; Task 3 unblocks the interaction work.
## Global Constraints
@@ -24,7 +24,7 @@
---
### Task A: UI scale — consistent physical size across DPI
### Task 1: UI scale — consistent physical size across DPI
**Files:**
- Modify: `include/plot/render/style.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/render/renderer.cpp`, `include/plot/render/renderer.hpp`, `include/plot/input/controller.hpp` (doc), `src/input/controller.cpp`
@@ -67,7 +67,7 @@ git add -A && git commit -m "feat(render): logical style metrics with a UI scale
---
### Task B: Major and minor grid as independent styled classes
### Task 2: Major and minor grid as independent styled classes
**Files:**
- Modify: `include/plot/render/style.hpp`, `include/plot/plot/grid.hpp`, `src/plot/grid.cpp`, `src/render/renderer.cpp`
@@ -109,7 +109,7 @@ git add -A && git commit -m "feat(render): independent major and minor grid clas
---
### Task C: Live follow with relative zoom and mouse panning
### Task 3: Live follow with relative zoom and mouse panning
**Files:**
- Modify: `include/plot/plot/axis.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp`, `src/render/renderer.cpp` (only if the live edge needs exposing)
+5 -3
View File
@@ -421,9 +421,11 @@ gboolean on_exit_timer(gpointer user_data) {
const plot::Rect viewport = demo.area->device_viewport();
// The evidence that the layout was *recomputed and not scaled*: the y label
// strip is a number of *font pixels*, so it stays the same size while the axes takes
// the rest of the new allocation. Two things are checked, and neither is "the
// strip is the same number":
// strip is measured from the labels' font metrics -- a logical size, converted to
// device pixels at *this* display's scale (see `Style::ui_scale`) -- so at a
// constant scale it stays the same size while the axes takes the rest of the new
// allocation. Two things are checked, and neither is "the strip is the same
// number":
//
// * the axes and its strips tile the *current* viewport exactly -- a layout
// left over from the old geometry would still tile the old one;
+17
View File
@@ -50,6 +50,23 @@ enum class DragMode {
///
/// A drag that changes no range is harmless: a box-zoom selection under 4 px on
/// either side is a click, and leaves the ranges alone.
///
/// ## Tolerances are logical, the coordinates they are compared against are not
///
/// The plot geometry the controller hit-tests in is device pixels (see
/// `PlotLayout`), and each adapter converts its pointer coordinates into the same
/// space before forwarding them. A tolerance written as a device constant would
/// therefore change its *physical* size with the display: a 5 px grab radius is
/// half the hand movement on a 2x panel, so the same cursor becomes harder to
/// catch there. Both tolerances -- the cursor grab radius and the box-zoom click
/// threshold -- are therefore logical constants multiplied by `Plot::ui_scale()`,
/// which is also why the controller reads the scale off the plot it already holds
/// instead of keeping a copy a host would have to keep in step.
///
/// The middle-drag axis zoom's per-pixel gain is deliberately *not* in that group:
/// it is a data-navigation sensitivity rather than a size (the wheel and `+`/`-`
/// are scale-free already, being counted in notches), and spec §12 reworks the
/// zoom and pan gestures that own it.
class Controller {
public:
explicit Controller(Plot& plot) noexcept : plot_(&plot) {}
+5
View File
@@ -20,6 +20,11 @@ enum class LegendPlace { TopLeft, TopRight, BottomLeft, BottomRight };
/// All of it is deliberately coarse: the size, the row heights and the panel's
/// extent come from the text backend's metrics and the names themselves, never from
/// a caller's arithmetic, so a legend can only be *placed* and never mis-measured.
///
/// The sizes below are **logical** units, like every other metric a user
/// perceives as a size (see the unit rule on `render::Style`): `Plot::layout()`
/// converts each through `Style::device()` before it measures the panel, so a
/// legend is the same physical size on a 2x panel as on a 1x one.
struct Legend {
/// Draw the legend at all. This is on by default and costs nothing to leave on:
/// a legend with no entries -- no trace named, or no backend to measure a name
+14 -3
View File
@@ -17,9 +17,11 @@
namespace plot {
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left). `axes` is the area data is drawn into; the label
/// areas are the strips the tick labels live in and the title areas the strips
/// the axis titles live in, each outside `axes` and so never over the data.
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
/// what the renderer draws with, already converted from the logical metrics in
/// `Style` (see the unit rule on `Style`). `axes` is the area data is drawn into;
/// the label areas are the strips the tick labels live in and the title areas the
/// strips the axis titles live in, each outside `axes` and so never over the data.
///
/// `y_*` strips are left of `axes` and `x_*` ones below it, whatever an axis'
/// `place` says: an axis placed on the opposite edge still has its labels and
@@ -86,6 +88,15 @@ class Plot {
/// uses the renderer's values.
void set_style(const render::Style& s) noexcept { style_ = s; }
const render::Style& style() const noexcept { return style_; }
/// Device pixels per logical unit: what every metric in `style()` means, and what
/// the controller's pointer tolerances are scaled by. Set from the renderer, which
/// owns presentation -- `Renderer::draw()` writes `style().ui_scale` here before it
/// lays the chrome out, so the strips and the strokes cannot disagree about the
/// scale -- and set directly by a host that lays a plot out itself, or by a
/// controller whose plot no renderer draws.
void set_ui_scale(f32 scale) noexcept { style_.ui_scale = scale; }
/// The scale the last layout was computed at. 1.0 until a host says otherwise.
f32 ui_scale() const noexcept { return style_.ui_scale; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
+3 -2
View File
@@ -52,8 +52,9 @@ class EmbeddedMonoFont final : public TextBackend {
/// Top of the cell to the baseline, in font pixels.
f32 ascent() const override;
/// `kEmPx`: the size the face was baked at. The renderer scales a run by
/// `Style::axis_font_px / em_px()`, so the same backend draws the size the
/// style asked for whatever it was baked at.
/// `Style::device(Style::axis_font_px) / em_px()` -- the style's logical em in
/// device pixels over this em -- so the same backend draws the size the style
/// asked for, on any DPI, whatever it was baked at.
f32 em_px() const override;
/// The glyph for `codepoint`: its atlas cell as a normalised rect, no extra
+22 -10
View File
@@ -91,13 +91,20 @@ class Renderer {
/// Draws `plot`, refreshing its layout, ticks and grid first.
///
/// The renderer owns the presentation, style and text both: `draw()` copies
/// `style()` into the plot and installs its own font backend on it, so the strips
/// `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 and the grid build, so
/// re-deriving it per helper would rebuild the ticks and the grid per helper.
@@ -136,10 +143,13 @@ class Renderer {
/// 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, so
/// the layout it computes matches what is drawn. `line_width_px` is the width of
/// the strokes framing the data (axis lines, cursor markers) -- grid lines stay
/// hairlines and every trace carries its own width.
/// 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) -- grid lines stay hairlines and every trace carries its
/// own width.
render::Style& style() noexcept { return style_; }
const render::Style& style() const noexcept { return style_; }
@@ -162,11 +172,13 @@ class Renderer {
bool using_persistent_upload() const noexcept {
return line_vao_.persistent() && text_vao_.persistent();
}
/// The axis text size the renderer actually draws with: the on-screen cell
/// height of the backend it draws with. `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.
/// 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
+63 -10
View File
@@ -6,7 +6,29 @@ namespace plot::render {
/// Every colour and metric the renderer draws with. Defaults are the dark theme
/// the plan's render smoke test asserts against; a caller swaps a field and the
/// next frame picks it up, so nothing here is baked into a shader.
///
/// ## Units: every metric below is **logical**, and `device()` converts it
///
/// A metric a user perceives as a *size* -- a font, a stroke width, a padding, a
/// marker, a swatch, the minimum spacing between ticks -- is written in logical
/// units, so a 12 px label is the same physical size on a 1x panel and a 2x one
/// and a 1 px grid is the same hairline on both. The host states what its display
/// is with `ui_scale` (device pixels per logical unit) and every consumer converts
/// exactly once, through `device()`: `Plot::layout()` when it sizes the label
/// strips, the title strips and the legend's chrome, the renderer when it converts
/// widths and font px.
///
/// The one exception is `dense_threshold_spp`, which is a **density** rather than a
/// size: samples per *rasterised* pixel. It is compared against a samples-per-pixel
/// ratio computed from the framebuffer's own width, so scaling it would make the
/// chosen LOD level depend on the display and not on the data. It is used raw.
///
/// The same rule classifies the metrics that live outside this struct, which are
/// logical too and converted by the same call: `Legend`'s pad, swatch, gap and
/// margin, and the renderer's marker arm lengths and text offsets.
struct Style {
// --- colours: neither logical nor device, so nothing to scale ------------
Color background = Color::hex(0x101418);
Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
@@ -14,16 +36,47 @@ struct Style {
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
f32 axis_font_px = 12.0f;
f32 grid_label_pad = 4.0f;
f32 line_width_px = 1.5f;
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per pixel at or above
///< which a trace is gathered from the LOD
///< pyramid instead of from its raw samples.
///< It selects the gather level, never a
///< primitive: both sides draw through the one
///< instanced anti-aliased quad path.
f32 tick_min_px = 60.0f; ///< minimum spacing between major ticks.
// --- the scale ----------------------------------------------------------
/// Device pixels per logical unit. 1.0 is the 1x display every default here was
/// chosen on. Supplied by the host (`Renderer::style()` is what an adapter
/// writes), and handed to the plot by `Renderer::draw()` before it lays out, so
/// layout and drawing cannot disagree about it.
f32 ui_scale = 1.0f;
// --- logical metrics: a size a user perceives, scaled by `device()` ------
f32 axis_font_px = 12.0f; ///< logical em the axis text is drawn at.
f32 grid_label_pad = 4.0f; ///< logical gap between a tick label and the axes,
///< and the inset inside a title strip.
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers).
f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so
///< a 2x display gets the same tick *count*.
// --- device metric: a density, never scaled ------------------------------
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per **device** pixel at or
///< above which a trace is gathered from the LOD
///< pyramid instead of from its raw samples. A
///< density, not a size: it is deliberately not
///< scaled by `ui_scale`, or the chosen level
///< would move with the display's DPI. It selects
///< the gather level, never a primitive: both
///< sides draw through the one instanced
///< anti-aliased quad path.
/// `logical` in device pixels: the single conversion point for every logical
/// metric above, so the arithmetic appears once per consumer instead of a `*
/// ui_scale` scattered over the renderer and the layout.
///
/// A scale that is not positive is not a scale -- a host that has not measured
/// its display, a field nobody filled in -- and converts 1:1 rather than
/// collapsing every strip, font and stroke to nothing.
f32 device(f32 logical) const noexcept {
return ui_scale > 0.0f ? logical * ui_scale : logical;
}
};
} // namespace plot::render
+25 -7
View File
@@ -20,10 +20,15 @@ constexpr f64 kKeyZoomNotches = 1.0;
/// pixels, a different unit from a wheel notch, so it must not reuse
/// kZoomPerNotch: 200 px of drag is one decade, exp(-200 * ln10/200) == 0.1.
constexpr f64 kZoomPerPixel = std::numbers::ln10 / 200.0;
/// How close (in pixels) the pointer must be to a cursor to grab it.
constexpr f32 kCursorHitRadiusPx = 5.0f;
/// A box-zoom selection narrower than this on either side is a click.
constexpr f32 kMinBoxPixels = 4.0f;
/// How close the pointer must be to a cursor to grab it, in **logical** pixels: the
/// listener's aim is a physical distance, so the device-pixel radius it is compared
/// against is this times `Plot::ui_scale()` (see `grab_radius()`). An unscaled
/// constant would make grabbing physically harder -- half as forgiving -- on a 2x
/// display, which is the defect the scale exists to prevent.
constexpr f32 kCursorHitRadiusLogical = 5.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
@@ -39,6 +44,15 @@ f64 from_space(f64 v, bool log) noexcept {
/// key) zoom in, so the span shrinks by `exp(-notches * kZoomPerNotch)`.
f64 zoom_factor(f64 notches) noexcept { return std::exp(-notches * kZoomPerNotch); }
/// Device pixels per logical unit for the tolerances below. Read from the plot the
/// controller already holds -- every forwarded coordinate is in device pixels, and
/// the plot is the one that knows what a device pixel is worth -- rather than kept
/// as a copy that a host would have to update in step with the renderer's.
f32 tolerance_scale(const Plot& plot) noexcept {
const f32 scale = plot.ui_scale();
return scale > 0.0f ? scale : 1.0f;
}
/// True when a coordinate is one arithmetic can work with. Every event here comes
/// from a host, and a host can hand over anything: a toolkit's "no pointer"
/// sentinel, a NaN from a degenerate mapping of its own, an infinity from a lost
@@ -267,8 +281,11 @@ void Controller::box_zoom_update(const PointerEvent& e) {
void Controller::apply_box_zoom() {
const Rect sel = selection_;
// A selection under a few pixels on either side is a click: no zoom.
if (sel.w < kMinBoxPixels || sel.h < kMinBoxPixels) return;
// A selection under a few *logical* pixels on either side is a click: no zoom.
// The selection is measured in device pixels like everything the controller is
// handed, so the threshold is the logical one at this plot's scale.
const f32 min_box = kMinBoxLogical * tolerance_scale(*plot_);
if (sel.w < min_box || sel.h < min_box) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
@@ -324,6 +341,7 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (!lay.axes.contains(px, py)) return kInvalid;
const f32 radius = kCursorHitRadiusLogical * tolerance_scale(*plot_);
u64 best = kInvalid;
f32 best_d = 0.0f;
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
@@ -331,7 +349,7 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
if (!cursor.visible) continue;
const f32 sx = static_cast<f32>(plot_->x_transform(cursor.axis_x).to_screen_x(cursor.x));
const f32 d = std::fabs(sx - px);
if (d > kCursorHitRadiusPx) continue;
if (d > radius) continue;
if (best == kInvalid || d < best_d) {
best = i;
best_d = d;
+32 -20
View File
@@ -322,7 +322,10 @@ const PlotLayout& Plot::layout() {
// which is precisely what stops this from fighting the range the user chose.
autoscale_axes();
const f32 pad = style_.grid_label_pad;
// The label padding is a logical size, so it is converted once, here: every strip
// and inset below reads this value in device pixels, like the backend metrics it
// is added to.
const f32 pad = style_.device(style_.grid_label_pad);
// Seeds: PlotLayout's defaults when nobody can measure text, otherwise the
// metrics the text backend does know (its line height, and just the padding
// until the first tick list says how wide the labels are).
@@ -398,9 +401,11 @@ const PlotLayout& Plot::layout() {
void Plot::refresh_ticks() {
// Ticks are generated for the final axes size: an x axis' density depends on
// the plot's width and a y axis' on its height.
for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.tick_min_px, true);
for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.tick_min_px, true);
// the plot's width and a y axis' on its height. The minimum spacing is a logical
// size, so it is converted here: a 2x display therefore gets the same *count* of
// major ticks over the same logical width rather than twice as many.
for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.device(style_.tick_min_px), true);
for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.device(style_.tick_min_px), true);
}
void Plot::build_grid() {
@@ -451,7 +456,7 @@ f32 Plot::y_title_strip_width() const {
if (text_ == nullptr) return 0.0f;
const u32 titles = titled_y_axis_count();
if (titles == 0u) return 0.0f;
return static_cast<f32>(titles) * text_->height() + 2.0f * style_.grid_label_pad;
return static_cast<f32>(titles) * text_->height() + 2.0f * style_.device(style_.grid_label_pad);
}
f32 Plot::y_label_width() const {
@@ -463,7 +468,7 @@ f32 Plot::y_label_width() const {
widest = std::max(widest, text_->measure(tick.label.c_str()));
}
}
return widest + 2.0f * style_.grid_label_pad;
return widest + 2.0f * style_.device(style_.grid_label_pad);
}
void Plot::build_legend() {
@@ -474,9 +479,17 @@ void Plot::build_legend() {
// plot that lays out exactly as it did before legends existed.
if (text_ == nullptr || !legend_.visible) return;
const f32 pad = legend_.pad;
// Every one of these is a logical size -- the legend is chrome a user reads --
// while `text_->height()` and the name measures are device: converting each one
// once, into a local, is what keeps the panel's arithmetic in device pixels
// throughout, like the strips it is fitted into.
const f32 pad = style_.device(legend_.pad);
const f32 margin = style_.device(legend_.margin);
const f32 swatch_w = style_.device(legend_.swatch_w);
const f32 swatch_h = style_.device(legend_.swatch_h);
const f32 row_gap = style_.device(legend_.row_gap);
const f32 line_h = text_->height();
const f32 row_h = std::max(legend_.swatch_h, line_h);
const f32 row_h = std::max(swatch_h, line_h);
// Entry widths, prefix-maxed: dropping from the end then costs no re-measure, and
// the panel's width after a drop is the width of an entry that is still listed.
legend_name_px_.clear();
@@ -492,8 +505,8 @@ void Plot::build_legend() {
if (entries == 0u) return;
const f32 ellipsis_w = text_->measure(kLegendEllipsis);
const f32 room_w = layout_.axes.w - 2.0f * legend_.margin;
const f32 room_h = layout_.axes.h - 2.0f * legend_.margin;
const f32 room_w = layout_.axes.w - 2.0f * margin;
const f32 room_h = layout_.axes.h - 2.0f * margin;
// Fit, from every entry down: the panel is a row per entry with `row_gap` between,
// and, once an entry has been dropped, one more row for the marker that says so.
@@ -510,8 +523,8 @@ void Plot::build_legend() {
const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w)
: legend_name_px_[keep - 1u];
const f32 rows = static_cast<f32>(keep + (truncated ? 1u : 0u));
panel_w = 2.0f * pad + legend_.swatch_w + pad + widest;
panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * legend_.row_gap;
panel_w = 2.0f * pad + swatch_w + pad + widest;
panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * row_gap;
if (panel_w <= room_w && panel_h <= room_h) break;
if (--keep == 0u) return; // not even one entry and its marker fit
}
@@ -519,13 +532,13 @@ void Plot::build_legend() {
// Anchored in the corner `place` names and held `margin` off both edges, which is
// the inset the fit above was measured against -- so the panel is inside `axes` by
// construction and can never cross it, whatever corner it was put in.
f32 x = layout_.axes.x + legend_.margin;
f32 y = layout_.axes.y + legend_.margin;
f32 x = layout_.axes.x + margin;
f32 y = layout_.axes.y + margin;
if (legend_.place == LegendPlace::TopRight || legend_.place == LegendPlace::BottomRight) {
x = layout_.axes.right() - legend_.margin - panel_w;
x = layout_.axes.right() - margin - panel_w;
}
if (legend_.place == LegendPlace::BottomLeft || legend_.place == LegendPlace::BottomRight) {
y = layout_.axes.bottom() - legend_.margin - panel_h;
y = layout_.axes.bottom() - margin - panel_h;
}
legend.panel = Rect{x, y, panel_w, panel_h};
@@ -534,19 +547,18 @@ void Plot::build_legend() {
// The name box starts one swatch and one gutter in -- exactly what the panel's
// width allowed for -- and both boxes are centred in the row's height, so a swatch
// shorter than the text line is centred on it instead of sitting on its baseline.
const f32 name_x = x + pad + legend_.swatch_w + pad;
const f32 name_x = x + pad + swatch_w + pad;
f32 row_y = y + pad;
for (u32 i = 0; i < traces_.size() && legend.rows.size() < keep; ++i) {
const Trace& trace = traces_[i];
if (!trace.visible() || trace.name.empty()) continue;
LegendRow row;
row.trace = i;
row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - legend_.swatch_h), legend_.swatch_w,
legend_.swatch_h};
row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - swatch_h), swatch_w, swatch_h};
row.name = Rect{name_x, row_y + 0.5f * (row_h - line_h),
text_->measure(trace.name.c_str()), line_h};
legend.rows.push_back(row);
row_y += row_h + legend_.row_gap;
row_y += row_h + row_gap;
}
if (keep < entries) {
// Entries were dropped; the marker takes the row after the last kept one, with no
+30 -17
View File
@@ -202,7 +202,7 @@ bool Renderer::init(Status* st) noexcept {
const render::UploadMode mode =
persistent ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
font_backend_.reset(default_font_, style_.axis_font_px);
font_backend_.reset(default_font_, style_.device(style_.axis_font_px));
// The atlas is the backend's: `sync()` uploads whatever it exposes, at whatever
// size it says, and restores the host's unpack alignment around the upload
@@ -296,7 +296,7 @@ void Renderer::draw(Plot& plot) {
// The plot reserves its label strips from TextBackend metrics, so it must
// measure with the size the text pipeline draws with, and re-read the style
// each frame so a changed axis_font_px takes effect on the next one.
font_backend_.reset(default_font_, style_.axis_font_px);
font_backend_.reset(default_font_, style_.device(style_.axis_font_px));
// The cell every glyph staged this frame is drawn in, declared from the backend
// rather than left to whichever glyph is staged first: the instances carry rect
// origins, and the draw carries this size (see TextBackend::cell_uv).
@@ -327,6 +327,11 @@ void Renderer::draw(Plot& plot) {
// from the values the renderer draws with, so the label pad is never one number
// for the layout and another for the text placed inside it.
plot.set_style(style_);
// The plot's chrome is laid out in device pixels and it converts the style's
// logical metrics itself, so it needs the scale *before* `layout()`: this is the
// propagation point the contract names, and it is what keeps the strips the
// layout reserves and the strokes drawn into them at one scale.
plot.set_ui_scale(style_.ui_scale);
// One layout for the whole frame: autoscale, the strip fixed-point loop with
// its tick rebuilds, and the grid build all run in here, and every helper
// below reads the strips, the axes rect and the transforms it pins.
@@ -538,6 +543,10 @@ void Renderer::draw_grid(const Plot& plot) {
// transforms even when a trace uses another pair.
const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_axis(0).log;
// A grid line is a size a user perceives, so it is one logical pixel wide at
// every DPI rather than a hairline on a 1x panel and a slab on a 2x one. (Spec §11
// gives the two classes a width each; until then both are this one logical pixel.)
const f32 grid_width = style_.device(1.0f);
// Two colour groups, one pass over the segments each: `LineBatch::clear()`
// keeps its capacity, so the second group re-uses the first one's storage.
@@ -547,7 +556,7 @@ void Renderer::draw_grid(const Plot& plot) {
if (line.major != majors) continue; // grid.build() already dropped non-finite ones
stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y);
}
flush_lines(map, majors ? style_.grid_major : style_.grid_minor, 1.0f);
flush_lines(map, majors ? style_.grid_major : style_.grid_minor, grid_width);
}
}
@@ -566,13 +575,13 @@ void Renderer::draw_axis_lines(const Plot& plot) {
const Axis& axis = plot.x_axis(i);
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0);
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.line_width_px);
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.device(style_.line_width_px));
}
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i);
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log);
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.line_width_px);
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.device(style_.line_width_px));
}
}
@@ -603,7 +612,7 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 width = font_backend_.measure(tick.label.c_str());
const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value));
const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit);
stage_text({layout.y_label_area.right() - style_.grid_label_pad - width, top},
stage_text({layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top},
tick.label.c_str());
}
}
@@ -628,7 +637,7 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// ones: keep the leading lines that fit and stage no more. A dropped title is
// chrome that had no room, which is the same bargain the label strips already make
// -- never drawn outside its strip, and never inside `axes`.
const f32 pad = style_.grid_label_pad;
const f32 pad = style_.device(style_.grid_label_pad);
const f32 line_h = font_backend_.height();
// X titles: the strip's rows top down, one per titled axis, each read left to
@@ -753,9 +762,9 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.line_width_px);
flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.device(style_.line_width_px));
if (!cursor.label.empty()) {
stage_text({px + 3.0f, layout.axes.y + 2.0f}, cursor.label.c_str());
stage_text({px + style_.device(3.0f), layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
}
}
}
@@ -790,6 +799,10 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
}
// One device pixel, not a logical one: the band is a *fill* made of one
// stroke per framebuffer column (`px += 1.0f` above), so its width is the
// column pitch itself. Scaling it would overlap the columns it does not
// match and leave gaps in the ones it does.
flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f);
}
@@ -805,13 +818,13 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
std::snprintf(readout, kReadoutChars, "dx %s dy %s rms %s mean %s", dx, dy, rms, mean);
const f32 width = font_backend_.measure(readout);
const f32 limit = std::max(axes.x + 2.0f, axes.right() - width - 2.0f);
const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + 2.0f, limit);
const f32 limit = std::max(axes.x + style_.device(2.0f), axes.right() - width - style_.device(2.0f));
const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + style_.device(2.0f), limit);
// The second line of the plot area, not the first: the first belongs to the
// cursor labels, and a wide readout clamped to the left edge would overprint
// them. The y axis title is not a consideration here -- it lives in its own
// strip outside the axes.
stage_text({left, axes.y + line_h + 2.0f}, readout);
stage_text({left, axes.y + line_h + style_.device(2.0f)}, readout);
}
}
@@ -825,11 +838,11 @@ void Renderer::draw_annotations(const Plot& plot) {
// A small cross pins the point; the caption sits to its right. Both are in
// framebuffer pixels, hence the identity mapping for the marker.
lines_.add_segment({px - 3.0f, py}, {px + 3.0f, py});
lines_.add_segment({px, py - 3.0f}, {px, py + 3.0f});
flush_lines(pixel_mapping(), annotation.color, 1.0f);
lines_.add_segment({px - style_.device(3.0f), py}, {px + style_.device(3.0f), py});
lines_.add_segment({px, py - style_.device(3.0f)}, {px, py + style_.device(3.0f)});
flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f));
if (!annotation.text.empty()) {
stage_text({px + 5.0f, py - 0.5f * line_h}, annotation.text.c_str());
stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str());
}
}
}
@@ -859,7 +872,7 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
lines_.add_segment({left, bottom}, {right, bottom});
lines_.add_segment({left, top}, {left, bottom});
lines_.add_segment({right, top}, {right, bottom});
flush_lines(pixel_mapping(), style_.axis_line, 1.0f);
flush_lines(pixel_mapping(), style_.axis_line, style_.device(1.0f));
}
for (const LegendRow& row : legend.rows) {
+72
View File
@@ -358,3 +358,75 @@ PLOT_TEST(key_f_auto_fits_traces) {
PLOT_CHECK(p.x_axis(ax).auto_scale); // a fit re-arms auto scale
PLOT_CHECK(p.y_axis(ay).auto_scale);
}
// --- tolerances are logical, coordinates are device pixels -------------------
/// The grab radius is a distance the user aims with, so it must be the same
/// *physical* distance at any DPI: the controller compares device-pixel coordinates
/// against `logical * Plot::ui_scale()`. The press positions below are the same
/// logical distance (0.9 and 1.1 of the 5 px radius) from the cursor at both scales,
/// so the same press grabs -- and the same press just outside does not.
PLOT_TEST(grab_radius_scales_with_dpi) {
for (const f32 scale : {1.0f, 2.0f}) {
plot::Plot p;
p.set_ui_scale(scale);
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
const f32 sx = screen_x_of(p, ax, 50.0);
const f32 inside = 0.9f * 5.0f * scale; // logical: within the grab radius
const f32 outside = 1.1f * 5.0f * scale; // logical: just past it
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == cid);
c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates.
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true,
false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
}
}
/// The box-zoom click threshold is a hand movement too: a selection the user
/// perceives as a few pixels is a click at any DPI, so both dimensions are compared
/// against `logical * Plot::ui_scale()`.
PLOT_TEST(box_zoom_click_threshold_scales_with_dpi) {
for (const f32 scale : {1.0f, 2.0f}) {
plot::Plot p;
p.set_ui_scale(scale);
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const plot::Range before = p.x_range(ax);
// 3 logical px on each side: a click, at both scales.
const f32 x0 = 100.0f * scale;
const f32 y0 = 50.0f * scale;
const f32 small = 3.0f * scale;
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({x0 + small, y0 + small, small, small, plot::input::Button::Left, kShift, true,
false, false});
c.on_pointer({x0 + small, y0 + small, 0, 0, plot::input::Button::Left, kShift, false, false,
true});
PLOT_CHECK_NEAR(p.x_range(ax).min, before.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, before.max, 1e-9);
// 5 logical px on each side: a box, at both scales.
const f32 large = 5.0f * scale;
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({x0 + large, y0 + large, large, large, plot::input::Button::Left, kShift, true,
false, false});
c.on_pointer({x0 + large, y0 + large, 0, 0, plot::input::Button::Left, kShift, false, false,
true});
PLOT_CHECK(p.x_range(ax).min > before.min);
PLOT_CHECK(p.x_range(ax).max < before.max);
}
}
+37
View File
@@ -452,3 +452,40 @@ PLOT_TEST(imgui_widget_paints_only_the_visible_part_of_a_clipped_child) {
f.pixel(f.paint_min_x + 40u, f.paint_min_y + 40u, out);
PLOT_CHECK(!f.is_host(out));
}
/// The widget states the display's scale to the renderer, which is what makes the
/// chrome one physical size on any DPI: `io.DisplayFramebufferScale` is the same
/// factor the widget already maps points to framebuffer pixels by, so a host on a 2x
/// surface gets a 2x *device* font and a 2x device strip -- the same logical layout
/// as on a 1x one.
PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6);
const plot::f32 cell_1x = f.renderer.axis_font_px();
const plot::f32 strip_1x = f.plot.layout().y_axis_width;
PLOT_CHECK(cell_1x > 1.0f);
PLOT_CHECK(strip_1x > 1.0f);
// The same widget and the same logical size, on a 2x surface.
io.DisplayFramebufferScale = ImVec2(2.0f, 2.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 2.0f, 1e-6);
// The renderer hands it to the plot before laying out, so the *device* chrome
// doubles while the logical figure (device / scale) is what the 1x run measured.
PLOT_CHECK_NEAR(f.plot.ui_scale(), 2.0f, 1e-6);
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), 2.0f * cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, 2.0f * strip_1x, 0.05f);
// Back to 1x: the scale is re-read every frame rather than latched at the first
// one, so a window moved to another display follows.
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6);
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_1x, 1e-3);
}
+95
View File
@@ -25,6 +25,16 @@ f32 widest_major_label(const plot::Axis& a, const plot::render::TextBackend& t)
return w;
}
/// Every major tick's value, in order: what a tick-density assertion compares, so it
/// can compare the *grid* the two scales produce rather than only a count.
std::vector<f64> major_tick_values(const plot::Axis& a) {
std::vector<f64> v;
for (const plot::Tick& tick : a.ticks) {
if (tick.major) v.push_back(tick.value);
}
return v;
}
/// A log axis must never be left inverted or non-positive by an auto-fit.
void check_log_axis_range(const plot::Range& r) {
PLOT_CHECK(std::isfinite(r.min) && std::isfinite(r.max));
@@ -839,3 +849,88 @@ PLOT_TEST(legend_hidden_by_visible_false_draws_nothing) {
scene.plot.set_legend(cfg);
PLOT_CHECK(scene.plot.layout().legend.rows.size() == 3u);
}
// --- the UI scale: logical metrics, one physical size at any DPI -------------
/// A minimum tick spacing is a physical gap between labels, so the same *logical*
/// plot width must get the same major ticks at any scale. This is the failure mode
/// on the under-eager side: an unscaled `tick_min_px` measured against a 2x-wide
/// framebuffer packs twice as many ticks onto the same panel, and the grid's density
/// becomes a property of the display instead of the data.
PLOT_TEST(tick_spacing_is_physical) {
plot::render::EmbeddedMonoFont font;
// No y axis, and so no y label strip: with a text backend present the axes width
// is then the viewport width *exactly* (`layout()` reserves a left strip only for
// the axes that have labels), and the two runs differ in the display's scale and
// nothing else. The strips' own scaling is the render smoke test's subject; this
// case isolates the tick rule.
plot::Plot one;
one.set_text_backend(&font);
one.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax1 = one.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false});
const plot::PlotLayout& lay1 = one.layout();
PLOT_CHECK_NEAR(lay1.axes.w, 800.0f, 1e-6);
plot::Plot two;
two.set_text_backend(&font);
two.set_ui_scale(2.0f);
two.set_viewport({0.0f, 0.0f, 1600.0f, 800.0f}); // the same plot, a 2x display
const u32 ax2 = two.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false});
const plot::PlotLayout& lay2 = two.layout();
PLOT_CHECK_NEAR(lay2.axes.w, 1600.0f, 1e-6);
// Both axes are 800 logical units wide, so both must carry the same major ticks.
PLOT_CHECK_NEAR(lay2.axes.w / 2.0f, lay1.axes.w, 1e-6);
const std::vector<f64> majors1 = major_tick_values(one.x_axis(ax1));
const std::vector<f64> majors2 = major_tick_values(two.x_axis(ax2));
PLOT_CHECK(majors1.size() > 2u); // not vacuous: the case has ticks to lose
PLOT_CHECK(majors2 == majors1);
// The label strip is the same *logical* height too: its padding scaled even though
// this backend's own metrics are fixed. (A frame drawn by a renderer also gets
// metrics scaled with the display -- see the smoke test's two-scale case.)
PLOT_CHECK(lay2.x_label_area.h > lay1.x_label_area.h);
}
/// The scale is an opt-in: nothing changes for a caller that never sets one, which
/// is what keeps this a unit change and not a behaviour change for existing code.
PLOT_TEST(ui_scale_defaults_to_one) {
plot::render::Style s;
PLOT_CHECK(s.ui_scale == 1.0f);
// At 1.0 the conversion is the identity, exactly -- no rounding, no reordering.
PLOT_CHECK(s.device(0.0f) == 0.0f);
PLOT_CHECK(s.device(1.5f) == 1.5f);
PLOT_CHECK(s.device(12.0f) == 12.0f);
PLOT_CHECK(s.device(1234.5f) == 1234.5f);
// A scale that is not a scale -- a host that has not measured its display -- is
// 1:1 rather than zero, which would collapse every strip and stroke to nothing.
s.ui_scale = 0.0f;
PLOT_CHECK(s.device(12.0f) == 12.0f);
s.ui_scale = -2.0f;
PLOT_CHECK(s.device(12.0f) == 12.0f);
plot::render::EmbeddedMonoFont font;
// One plot left at the default and one told the scale is 1.0 lay out identically,
// measured strips and legend included: the default *is* 1.0.
plot::Plot plain;
plot::Plot one;
one.set_ui_scale(1.0f);
plot::data::ArraySeries series(ramp(8), 0.0, 1.0);
for (plot::Plot* p : {&plain, &one}) {
p->set_text_backend(&font);
p->set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p->add_x_axis({"t", {0.0, 7.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p->add_y_axis({"v", {0.0, 7.0}, false, plot::AxisPlace::Left, false});
p->add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
}
const plot::PlotLayout& a = plain.layout();
const plot::PlotLayout& b = one.layout();
PLOT_CHECK(plain.ui_scale() == 1.0f);
PLOT_CHECK(b.y_axis_width == a.y_axis_width);
PLOT_CHECK(b.x_axis_height == a.x_axis_height);
PLOT_CHECK(b.y_title_width == a.y_title_width);
PLOT_CHECK(b.axes.x == a.axes.x);
PLOT_CHECK(b.axes.h == a.axes.h);
PLOT_CHECK(b.legend.panel.w == a.legend.panel.w);
PLOT_CHECK(b.legend.panel.h == a.legend.panel.h);
}
+250
View File
@@ -2413,3 +2413,253 @@ PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
name_ink);
ctx.destroy();
}
// --- one physical size at any DPI --------------------------------------------
namespace {
/// The chrome one render reports, in **device** pixels. Both runs of the two-scale
/// tests below fill this from a frame they really drew, so a comparison of the two
/// figures is a comparison of two frames and not of two derivations of one rule.
struct ChromeFigures {
plot::f32 font_cell = 0.0f; ///< the on-screen text cell the frame drew with
plot::f32 y_label_w = 0.0f; ///< the reserved label strip
plot::f32 x_label_h = 0.0f;
plot::f32 y_title_w = 0.0f;
plot::f32 legend_w = 0.0f;
plot::f32 legend_h = 0.0f;
plot::f32 axes_w = 0.0f;
plot::f32 axes_h = 0.0f;
plot::u32 ink_run = 0u; ///< the widest run of label ink in the y label strip
/// Every figure above in logical units, i.e. divided by the scale the frame was
/// drawn at: the numbers that must agree across displays.
ChromeFigures logical(plot::f32 scale) const noexcept;
};
ChromeFigures ChromeFigures::logical(plot::f32 scale) const noexcept {
ChromeFigures l;
l.font_cell = font_cell / scale;
l.y_label_w = y_label_w / scale;
l.x_label_h = x_label_h / scale;
l.y_title_w = y_title_w / scale;
l.legend_w = legend_w / scale;
l.legend_h = legend_h / scale;
l.axes_w = axes_w / scale;
l.axes_h = axes_h / scale;
l.ink_run = static_cast<plot::u32>(static_cast<plot::f32>(ink_run) / scale + 0.5f);
return l;
}
} // namespace
/// The requirement this task exists for, measured rather than asserted by eye: the
/// same logical plot drawn on a 1x display and on a 2x one. One framebuffer holds
/// both passes -- a 1x plot is a 640x360 viewport in it, a 2x plot the whole thing,
/// exactly as an adapter embeds a plot in a widget -- so the two frames differ only
/// in `ui_scale` and in the device size that goes with it.
///
/// The device figures must double. The logical ones -- the same figures divided by
/// the scale -- must be equal, which is what "the same physical size on any display"
/// means: the labels, strips, title column and legend occupy the same share of the
/// panel at both DPI, and each axis label is drawn with twice the pixels of ink.
PLOT_TEST(logical_metrics_are_dpi_independent) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// The axes' border line runs down the label strip's right edge and is the same
// chalk-toned colour family as the text, so it is drawn in the background colour
// here: the only ink inside the strip is then the labels themselves.
r.style().axis_line = r.style().background;
std::vector<plot::f32> ys(256);
for (plot::u64 i = 0; i < ys.size(); ++i) {
ys[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.05));
}
plot::data::ArraySeries s(std::move(ys), 0.0, 1.0);
const auto figures_at = [&](plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
const plot::Rect vp{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom,
false});
const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
r.style().ui_scale = scale;
// The framebuffer stays the big one for both runs: the plot occupies the top-left
// `vp` of it, which is where a sub-rect viewport puts it (and what the adapters
// pass), so the same readback arithmetic reads both.
r.begin_frame(kBigW, kBigH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
// The layout the renderer drew is the one the renderer laid out: `draw()` sets
// the plot's scale before `layout()`, so this is the propagation being tested.
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
PLOT_CHECK_NEAR(p.text_backend()->height(), r.axis_font_px(), 1e-6);
const plot::PlotLayout& lay = p.layout();
ChromeFigures f;
f.font_cell = r.axis_font_px();
f.y_label_w = lay.y_label_area.w;
f.x_label_h = lay.x_label_area.h;
f.y_title_w = lay.y_title_area.w;
f.legend_w = lay.legend.panel.w;
f.legend_h = lay.legend.panel.h;
f.axes_w = lay.axes.w;
f.axes_h = lay.axes.h;
const TextInk ink =
scan_text_ink(ctx, static_cast<plot::u32>(lay.y_label_area.x),
static_cast<plot::u32>(lay.y_label_area.right()),
static_cast<plot::u32>(lay.y_label_area.y),
static_cast<plot::u32>(lay.y_label_area.h), r.style().axis_text);
f.ink_run = ink.widest_row_run;
return f;
};
const ChromeFigures one = figures_at(1.0f);
const ChromeFigures two = figures_at(2.0f);
const plot::f32 k = 2.0f;
// Each run is normalised by *its own* scale -- the logical figures both displays
// must agree on.
const ChromeFigures one_l = one.logical(1.0f);
const ChromeFigures two_l = two.logical(k);
// The device figures double: the same chrome, twice the pixels.
PLOT_CHECK_NEAR(two.font_cell, k * one.font_cell, 1e-3f);
PLOT_CHECK_NEAR(two.y_label_w, k * one.y_label_w, 0.02f);
PLOT_CHECK_NEAR(two.x_label_h, k * one.x_label_h, 0.02f);
PLOT_CHECK_NEAR(two.y_title_w, k * one.y_title_w, 0.02f);
PLOT_CHECK_NEAR(two.legend_w, k * one.legend_w, 0.02f);
PLOT_CHECK_NEAR(two.legend_h, k * one.legend_h, 0.02f);
PLOT_CHECK_NEAR(two.axes_w, k * one.axes_w, 0.02f);
PLOT_CHECK_NEAR(two.axes_h, k * one.axes_h, 0.02f);
// ...and the logical figures agree, which is the requirement itself: a 640x360
// panel and a 1280x720 one show the same logical plot.
PLOT_CHECK_NEAR(two_l.font_cell, one_l.font_cell, 5e-4f);
PLOT_CHECK_NEAR(two_l.y_label_w, one_l.y_label_w, 0.01f);
PLOT_CHECK_NEAR(two_l.x_label_h, one_l.x_label_h, 0.01f);
PLOT_CHECK_NEAR(two_l.y_title_w, one_l.y_title_w, 0.01f);
PLOT_CHECK_NEAR(two_l.legend_w, one_l.legend_w, 0.01f);
PLOT_CHECK_NEAR(two_l.legend_h, one_l.legend_h, 0.01f);
PLOT_CHECK_NEAR(two_l.axes_w, one_l.axes_w, 0.01f);
PLOT_CHECK_NEAR(two_l.axes_h, one_l.axes_h, 0.01f);
// The ink is the pixels' own account: one drawn label occupies twice the device
// extent, and the same extent once normalised -- so the doubling above is drawn
// and not merely reserved.
PLOT_CHECK(one.ink_run > 8u); // labels really were drawn
PLOT_CHECK_NEAR(static_cast<plot::f32>(two.ink_run), k * static_cast<plot::f32>(one.ink_run),
2.0f);
PLOT_CHECK(two_l.ink_run <= one_l.ink_run + 1u);
PLOT_CHECK(two_l.ink_run + 1u >= one_l.ink_run);
std::printf("[ui_scale] 1x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u;"
" 2x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u; logical"
" 1x %.2f/%.2f/%.2f/%.2fx%.2f/%u vs 2x %.2f/%.2f/%.2f/%.2fx%.2f/%u\n",
one.font_cell, one.y_label_w, one.x_label_h, one.y_title_w, one.legend_w,
one.legend_h, one.ink_run, two.font_cell, two.y_label_w, two.x_label_h,
two.y_title_w, two.legend_w, two.legend_h, two.ink_run, one_l.font_cell,
one_l.y_label_w, one_l.x_label_h, one_l.y_title_w, one_l.legend_h, one_l.ink_run,
two_l.font_cell, two_l.y_label_w, two_l.x_label_h, two_l.y_title_w, two_l.legend_h,
two_l.ink_run);
ctx.destroy();
}
/// `dense_threshold_spp` is a **density** -- samples per rasterised pixel -- and not a
/// size, so the display's scale must not move it. This is the failure mode on the
/// over-eager side: scaling the threshold with the panel would call the LOD pyramid
/// less readily at 2x, so the same data would be rasterised by a different path
/// depending on the display.
///
/// The sample count grows with the device width, so both runs present the same
/// samples-per-device-pixel: if the threshold were scaled, the 2x run would fall back
/// to raw samples while the 1x one still took the pyramid. The threshold is set to a
/// value between them so the test can tell the two apart at all -- at the default 1.5
/// the comparison is decided far from the threshold and any scale below 64x would be
/// invisible (`level_for` returns level 0 -- the same raw gather -- until a pixel
/// covers 64 samples).
PLOT_TEST(dense_threshold_stays_a_device_ratio) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
constexpr plot::f32 kThreshold = 1500.0f;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
r.style().dense_threshold_spp = kThreshold;
const auto series_of = [](plot::u64 n) {
std::vector<plot::f32> y(n);
for (plot::u64 i = 0; i < n; ++i) {
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.0001));
}
return y;
};
struct Decision {
plot::u32 lod = 0u;
plot::u32 raw = 0u;
plot::f64 spp = 0.0; ///< samples per *device* pixel the frame was drawn at
plot::u32 level = 0u; ///< the pyramid level that density selects
};
const auto decision_at = [&](plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
plot::data::ArraySeries s(series_of(static_cast<plot::u64>(scale) * 1'000'000u), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.style().ui_scale = scale;
r.begin_frame(kBigW, kBigH, {0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
Decision d;
d.lod = r.stats().lod_traces;
d.raw = r.stats().raw_traces;
// What the renderer actually drew at: the samples its gathered window covered
// over the *device* width of the axes it laid out. Recomputed here rather than
// taken on trust, so a spp divided by the logical width instead would show up as
// a doubled density and a different level.
const plot::f32 axes_w = p.layout().axes.w;
PLOT_CHECK(axes_w > 0.0f);
d.spp = static_cast<plot::f64>(r.stats().visible_samples) / static_cast<plot::f64>(axes_w);
d.level = s.pyramid().level_for(s.size(), d.spp);
return d;
};
const Decision one = decision_at(1.0f);
const Decision two = decision_at(2.0f);
// The renderer took the pyramid at both scales...
PLOT_CHECK(one.lod == 1u && one.raw == 0u);
PLOT_CHECK(two.lod == 1u && two.raw == 0u);
// ...and the density that decision rests on is the same in device terms at both
// scales, which is what makes the level below identical rather than merely non-zero.
PLOT_CHECK_NEAR(two.spp, one.spp, 0.5);
PLOT_CHECK(one.spp > static_cast<plot::f64>(kThreshold)); // not vacuous: over the line
PLOT_CHECK(one.level > 0u); // and a level, not the raw path
PLOT_CHECK(two.level == one.level);
std::printf("[dense] 1x spp %.1f -> level %u (lod %u raw %u); 2x spp %.1f -> level %u"
" (lod %u raw %u); threshold %.1f\n",
one.spp, one.level, one.lod, one.raw, two.spp, two.level, two.lod, two.raw,
static_cast<double>(kThreshold));
ctx.destroy();
}