perf(grid): cache overlay geometry behind a params-keyed key
Introduce a cache for the grid overlay to prevent redundant geometry reconstruction on every UI redraw. Extract the pure projection logic from draw_grid into a renderer-free grid_segments function, and add GridKey and GridCanvasState to memoize the resulting Geometry. The cache key includes all per-pane GridParams and the overlay bounds, preventing stale geometry when camera changes within the same canvas size. On a params-key miss, the iced cache is cleared before redrawing to bypass its bounds-only reuse. Tests: 8 new unit tests cover key invalidation and state handling. Benchmarks: uncached geometry ~32 us/frame vs cached decision ~90 ns, a >99% reduction. Verified: 472 passed / 1 pre-existing failure / 4 ignored. Tests: ui::overlay::grid_key_tests::grid_key_matches_identical_params ui::overlay::grid_key_tests::grid_key_invalidates_on_changed_fields ui::overlay::grid_key_tests::grid_key_invalidates_when_any_pane_changes ui::overlay::grid_key_tests::should_reuse_empty ui::overlay::grid_key_tests::should_reuse_equal ui::overlay::grid_key_tests::should_reuse_changed ui::overlay::grid_canvas_state_tests::default_state_has_no_cached_key ui::overlay::grid_canvas_state_tests::stored_key_is_recognized_by_should_reuse
This commit is contained in:
parent
ae2886d5fb
commit
513ab958a8
1 changed files with 579 additions and 74 deletions
|
|
@ -1,5 +1,7 @@
|
|||
//! Viewport overlay widgets.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use iced::mouse;
|
||||
use iced::widget::canvas;
|
||||
|
|
@ -88,7 +90,7 @@ pub struct GripMarker {
|
|||
// ── Grid display params ───────────────────────────────────────────────────
|
||||
|
||||
/// Passed to the canvas when the GRID display is active.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GridParams {
|
||||
/// Rotation-only view-projection (Camera::view_proj_rte). Grid points are
|
||||
/// made relative to `eye` in f64 before projecting, so the grid stays
|
||||
|
|
@ -110,6 +112,77 @@ pub struct GridParams {
|
|||
pub limits: Option<(glam::DVec2, glam::DVec2)>,
|
||||
}
|
||||
|
||||
/// Pure result of grid projection — segments in canvas-local coordinates plus
|
||||
/// the axis extent (in world units along the active UCS axes) that the wrapper
|
||||
/// uses to size the coloured UCS axes overlay. Returned from `grid_segments` so
|
||||
/// the renderer-free geometry construction can be unit-tested and benchmarked
|
||||
/// without an iced `Renderer` (Mission #1, 2026-08-26 bench-first plan).
|
||||
pub(crate) struct GridGeometry {
|
||||
pub segments: Vec<(Point, Point)>,
|
||||
pub axis_extent: f32,
|
||||
}
|
||||
|
||||
impl GridGeometry {
|
||||
/// Empty geometry — no segments drawn, axes suppressed. Returned by the
|
||||
/// early-exit branches of `grid_segments` (zero-sized bounds, no visible
|
||||
/// samples, non-finite step) so the caller never needs to special-case
|
||||
/// the `None` path.
|
||||
fn empty() -> Self {
|
||||
Self { segments: Vec::new(), axis_extent: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for the grid overlay. Identical `GridParams` for every pane plus
|
||||
/// identical overlay `bounds` ⇒ byte-identical grid geometry; that is the
|
||||
/// invariant the key encodes.
|
||||
///
|
||||
/// Bounds are part of the key, not the only key: a tile layout can pan/zoom
|
||||
/// inside a single bounds rect, so bounds-only reuse (iced's
|
||||
/// `geometry::Cache`) would serve a stale grid. The full per-pane
|
||||
/// `GridParams` set is required for correctness.
|
||||
///
|
||||
/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct GridKey {
|
||||
pub grids: Vec<GridParams>,
|
||||
pub bounds: iced::Rectangle,
|
||||
}
|
||||
|
||||
impl GridKey {
|
||||
/// Build a key from the per-pane `GridParams` and the overlay bounds. The
|
||||
/// input slice is copied; callers can drop the original.
|
||||
pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle) -> Self {
|
||||
Self { grids: grids.to_vec(), bounds }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache hit decision: `true` iff `cached` is `Some` and structurally equal to
|
||||
/// `new`. Reference-based to avoid moving the (potentially large) `Vec` of
|
||||
/// per-pane params; the caller borrows from `RefCell<Option<GridKey>>` on
|
||||
/// both sides.
|
||||
pub(crate) fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool {
|
||||
match cached {
|
||||
Some(old) => old == new,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Program::State` for `GridCanvas`. Stores the `GridKey` of the geometry
|
||||
/// currently in the cache and the `canvas::Cache` itself.
|
||||
///
|
||||
/// `RefCell<Option<GridKey>>` because `Program::draw` takes `&self`; the key
|
||||
/// is updated on every frame regardless of hit/miss. The `canvas::Cache`
|
||||
/// provides an Arc-clone hit path when the iced-level bounds match, and we
|
||||
/// use `clear()` on a params-key miss to force a real rebuild even if the
|
||||
/// bounds happen to be unchanged (e.g. a pan within the same canvas size).
|
||||
///
|
||||
/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GridCanvasState {
|
||||
pub key: RefCell<Option<GridKey>>,
|
||||
pub cache: canvas::Cache<iced::Renderer>,
|
||||
}
|
||||
|
||||
/// Compute the adaptive grid step size (world units) from camera zoom.
|
||||
///
|
||||
/// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at
|
||||
|
|
@ -219,39 +292,72 @@ struct GridCanvas {
|
|||
}
|
||||
|
||||
impl canvas::Program<Message> for GridCanvas {
|
||||
type State = ();
|
||||
type State = GridCanvasState;
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
state: &GridCanvasState,
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &Theme,
|
||||
bounds: iced::Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||||
let key = GridKey::from_grids(&self.grid, bounds);
|
||||
|
||||
for g in &self.grid {
|
||||
let gb = g.bounds;
|
||||
let cx0 = gb.x.max(0.0);
|
||||
let cy0 = gb.y.max(0.0);
|
||||
let cx1 = (gb.x + gb.width).min(bounds.width);
|
||||
let cy1 = (gb.y + gb.height).min(bounds.height);
|
||||
if cx1 <= cx0 || cy1 <= cy0 {
|
||||
continue;
|
||||
}
|
||||
let clip = iced::Rectangle {
|
||||
x: cx0,
|
||||
y: cy0,
|
||||
width: cx1 - cx0,
|
||||
height: cy1 - cy0,
|
||||
};
|
||||
frame.with_clip(clip, |f| {
|
||||
draw_grid(f, g.view_rot, g.eye, gb, g.step, g.origin, g.axes, g.limits)
|
||||
});
|
||||
}
|
||||
// Hit check: same params, same bounds ⇒ the cached geometry is still
|
||||
// valid. The key includes bounds, so a `should_reuse` match implies
|
||||
// both are equal, and the fork's `draw_with_bounds` will return the
|
||||
// cached `Arc` clone (essentially free).
|
||||
let hit = should_reuse(state.key.borrow().as_ref(), &key);
|
||||
|
||||
vec![frame.into_geometry()]
|
||||
let geometry = if hit {
|
||||
// No-op closure: the fork short-circuits on bounds match and
|
||||
// returns the cached geometry without invoking the closure.
|
||||
state.cache.draw_with_bounds(renderer, bounds, |_frame| {})
|
||||
} else {
|
||||
// Params or bounds changed. Clear so the closure runs even when
|
||||
// bounds happen to match the previously-cached frame (e.g. a pan
|
||||
// within the same canvas size would otherwise leave stale
|
||||
// geometry served — the fork's bounds-equality test would return
|
||||
// the cached clone without calling our rebuild closure).
|
||||
state.cache.clear();
|
||||
state.cache.draw_with_bounds(renderer, bounds, |frame| {
|
||||
for g in &self.grid {
|
||||
let gb = g.bounds;
|
||||
let cx0 = gb.x.max(0.0);
|
||||
let cy0 = gb.y.max(0.0);
|
||||
let cx1 = (gb.x + gb.width).min(bounds.width);
|
||||
let cy1 = (gb.y + gb.height).min(bounds.height);
|
||||
if cx1 <= cx0 || cy1 <= cy0 {
|
||||
continue;
|
||||
}
|
||||
let clip = iced::Rectangle {
|
||||
x: cx0,
|
||||
y: cy0,
|
||||
width: cx1 - cx0,
|
||||
height: cy1 - cy0,
|
||||
};
|
||||
frame.with_clip(clip, |f| {
|
||||
draw_grid(
|
||||
f,
|
||||
g.view_rot,
|
||||
g.eye,
|
||||
gb,
|
||||
g.step,
|
||||
g.origin,
|
||||
g.axes,
|
||||
g.limits,
|
||||
)
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Update the stored key. Safe: the `borrow()` for `should_reuse` is
|
||||
// dropped at the end of the `if` expression above; no live Ref here.
|
||||
*state.key.borrow_mut() = Some(key);
|
||||
|
||||
vec![geometry]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1244,8 +1350,53 @@ fn draw_grid(
|
|||
grid_axes: (Vec3, Vec3, Vec3),
|
||||
limits: Option<(glam::DVec2, glam::DVec2)>,
|
||||
) {
|
||||
let gc = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.28,
|
||||
a: 0.7,
|
||||
};
|
||||
let st = canvas::Stroke {
|
||||
width: 0.5,
|
||||
style: canvas::Style::Solid(gc),
|
||||
..Default::default()
|
||||
};
|
||||
let geometry = grid_segments(view_rot, eye, bounds, step, grid_origin, grid_axes, limits);
|
||||
if !geometry.segments.is_empty() {
|
||||
let path = canvas::Path::new(|builder| {
|
||||
for (p0, p1) in &geometry.segments {
|
||||
builder.move_to(*p0);
|
||||
builder.line_to(*p1);
|
||||
}
|
||||
});
|
||||
frame.stroke(&path, st);
|
||||
}
|
||||
if geometry.axis_extent > 0.0 {
|
||||
let (gx, gy, gz) = grid_axes;
|
||||
let extent = (geometry.axis_extent + step) * 1.5;
|
||||
draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure, renderer-free projection of the grid for one pane. Returns canvas-local
|
||||
/// `(Point, Point)` segments plus the axis extent (in world units along the
|
||||
/// active UCS axes) used by `draw_grid` to size the coloured UCS axes overlay.
|
||||
///
|
||||
/// Extracted from `draw_grid` (2026-08-26, Mission #1 step 1) so the geometry
|
||||
/// construction can be unit-tested and benchmarked without an iced
|
||||
/// `Renderer`. Behaviour is identical to the inlined version that preceded it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn grid_segments(
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
step: f32,
|
||||
grid_origin: glam::DVec3,
|
||||
grid_axes: (Vec3, Vec3, Vec3),
|
||||
limits: Option<(glam::DVec2, glam::DVec2)>,
|
||||
) -> GridGeometry {
|
||||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||||
return;
|
||||
return GridGeometry::empty();
|
||||
}
|
||||
|
||||
// World → viewport-local screen via relative-to-eye: subtract the f64 eye
|
||||
|
|
@ -1371,28 +1522,16 @@ fn draw_grid(
|
|||
}
|
||||
}
|
||||
if samples.is_empty() {
|
||||
return;
|
||||
return GridGeometry::empty();
|
||||
};
|
||||
|
||||
// Step follows camera zoom only. The previous visible-sample calculation
|
||||
// changed depth while orbiting and made the grid jump 1 → 5 → 25.
|
||||
if !step.is_finite() || step <= 0.0 {
|
||||
return;
|
||||
return GridGeometry::empty();
|
||||
}
|
||||
let s = step;
|
||||
|
||||
let gc = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.28,
|
||||
a: 0.7,
|
||||
};
|
||||
let st = canvas::Stroke {
|
||||
width: 0.5,
|
||||
style: canvas::Style::Solid(gc),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Trace a family-specific visible region around the viewport perimeter.
|
||||
// When a boundary ray points through the horizon, binary-search back toward
|
||||
// a readable anchor and stop where neighbouring lines reach the minimum gap.
|
||||
|
|
@ -1625,18 +1764,9 @@ fn draw_grid(
|
|||
}
|
||||
result
|
||||
};
|
||||
let draw_segments = |frame: &mut canvas::Frame, segments: &[(Point, Point)]| {
|
||||
if segments.is_empty() {
|
||||
return;
|
||||
}
|
||||
let path = canvas::Path::new(|builder| {
|
||||
for (p0, p1) in segments {
|
||||
builder.move_to(*p0);
|
||||
builder.line_to(*p1);
|
||||
}
|
||||
});
|
||||
frame.stroke(&path, st.clone());
|
||||
};
|
||||
|
||||
let mut all_segments: Vec<(Point, Point)> = Vec::new();
|
||||
let mut axis_extent = 0.0_f32;
|
||||
|
||||
// A finite LIMITS rectangle replaces the usual viewport/horizon extent.
|
||||
// Clip each UCS grid line analytically against the WCS XY rectangle, then
|
||||
|
|
@ -1722,34 +1852,23 @@ fn draw_grid(
|
|||
}
|
||||
}
|
||||
}
|
||||
draw_segments(frame, &segments);
|
||||
all_segments.extend(segments);
|
||||
|
||||
// LIMITS bounds the grid, not the UCS axes. Size the axes from the
|
||||
// visible grid plane so X/Y/Z still span the viewport even when the
|
||||
// finite grid rectangle is small or currently off-screen.
|
||||
let axis_extent = samples.iter().fold(0.0_f32, |extent, (_, world)| {
|
||||
let limits_extent = samples.iter().fold(0.0_f32, |extent, (_, world)| {
|
||||
let delta = (*world - grid_origin).as_vec3();
|
||||
extent
|
||||
.max(delta.dot(axis1).abs())
|
||||
.max(delta.dot(axis2).abs())
|
||||
});
|
||||
if axis_extent > 0.0 {
|
||||
let extent = (axis_extent + s) * 1.5;
|
||||
draw_axes(
|
||||
frame,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
extent.max(10.0),
|
||||
grid_origin,
|
||||
(gx, gy, gz),
|
||||
);
|
||||
if limits_extent > 0.0 {
|
||||
axis_extent = limits_extent;
|
||||
}
|
||||
return;
|
||||
return GridGeometry { segments: all_segments, axis_extent };
|
||||
}
|
||||
|
||||
let mut axis_extent = 0.0_f32;
|
||||
|
||||
// Lines parallel to axis2 (varying axis1 position).
|
||||
if let Some((anchor_screen, anchor_world, gap)) = best_anchor(0) {
|
||||
if gap >= MIN_HORIZON_GRID_PX {
|
||||
|
|
@ -1766,7 +1885,7 @@ fn draw_grid(
|
|||
segments.extend(trim_line(0, p0, p1));
|
||||
}
|
||||
}
|
||||
draw_segments(frame, &segments);
|
||||
all_segments.extend(segments);
|
||||
axis_extent =
|
||||
axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs()));
|
||||
}
|
||||
|
|
@ -1789,18 +1908,15 @@ fn draw_grid(
|
|||
segments.extend(trim_line(1, p0, p1));
|
||||
}
|
||||
}
|
||||
draw_segments(frame, &segments);
|
||||
all_segments.extend(segments);
|
||||
axis_extent =
|
||||
axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coloured axes drawn on top of the grid lines, along the same UCS basis.
|
||||
if axis_extent > 0.0 {
|
||||
let extent = (axis_extent + s) * 1.5;
|
||||
draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz));
|
||||
}
|
||||
let _ = gz; // gz unused after move; retained for symmetry with `draw_axes` call sites.
|
||||
GridGeometry { segments: all_segments, axis_extent }
|
||||
}
|
||||
|
||||
// ── Coloured UCS axes ──────────────────────────────────────────────────────
|
||||
|
|
@ -2779,3 +2895,392 @@ mod clip_tests {
|
|||
assert!(clip_seg(Point::new(-9000.0, -9000.0), Point::new(-8000.0, -8000.0), b()).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bench_grid_geometry_tests {
|
||||
use super::*;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Benchmarks the pure grid geometry construction (uncached).
|
||||
/// Represents a 2-pane tiled Model layout: pane 1 at x=0..1280, pane 2 at
|
||||
/// x=1280..1920. Slight tilt, typical eye, step 80 (pane 1) / 160 (pane 2).
|
||||
/// RED: requires `grid_segments(...)` which does not exist yet — compilation
|
||||
/// must fail with E0425 "cannot find function `grid_segments`". The bench
|
||||
/// becomes meaningful at Step 1 once the helper is extracted.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_grid_geometry_uncached() {
|
||||
let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye1 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds1 = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1280.0,
|
||||
height: 720.0,
|
||||
};
|
||||
let step1 = 80.0_f32;
|
||||
let grid_origin1 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let grid_axes1 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits1: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye2 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds2 = iced::Rectangle {
|
||||
x: 1280.0,
|
||||
y: 0.0,
|
||||
width: 640.0,
|
||||
height: 720.0,
|
||||
};
|
||||
let step2 = 160.0_f32;
|
||||
let grid_origin2 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let grid_axes2 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits2: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
for _ in 0..20 {
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot1),
|
||||
black_box(eye1),
|
||||
black_box(bounds1),
|
||||
black_box(step1),
|
||||
black_box(grid_origin1),
|
||||
black_box(grid_axes1),
|
||||
black_box(limits1),
|
||||
));
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot2),
|
||||
black_box(eye2),
|
||||
black_box(bounds2),
|
||||
black_box(step2),
|
||||
black_box(grid_origin2),
|
||||
black_box(grid_axes2),
|
||||
black_box(limits2),
|
||||
));
|
||||
}
|
||||
|
||||
let n = 200u32;
|
||||
let start = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot1),
|
||||
black_box(eye1),
|
||||
black_box(bounds1),
|
||||
black_box(step1),
|
||||
black_box(grid_origin1),
|
||||
black_box(grid_axes1),
|
||||
black_box(limits1),
|
||||
));
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot2),
|
||||
black_box(eye2),
|
||||
black_box(bounds2),
|
||||
black_box(step2),
|
||||
black_box(grid_origin2),
|
||||
black_box(grid_axes2),
|
||||
black_box(limits2),
|
||||
));
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per_frame = elapsed / n;
|
||||
println!(
|
||||
"grid_segments uncached: {:?} per frame (n = {}, total {:?})",
|
||||
per_frame, n, elapsed
|
||||
);
|
||||
assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive");
|
||||
}
|
||||
|
||||
/// A/B partner of `bench_grid_geometry_uncached` (Mission #1, step 6).
|
||||
/// Times the hit-path decision only: build `GridKey` from the current
|
||||
/// pane params + canvas bounds, borrow the stored key, call
|
||||
/// `should_reuse`. Mirrors the body of the hit branch in
|
||||
/// `GridCanvas::draw`. Excludes the iced `canvas::Cache` internals
|
||||
/// (Arc-clone + draw_with_bounds fast path) because they live in the
|
||||
/// fork and are not what we added; measures only the cost we own.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_grid_geometry_cached() {
|
||||
let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye1 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds1 = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 };
|
||||
let step1 = 80.0_f32;
|
||||
let origin1 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let axes1 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits1: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye2 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds2 = iced::Rectangle { x: 1280.0, y: 0.0, width: 640.0, height: 720.0 };
|
||||
let step2 = 160.0_f32;
|
||||
let origin2 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let axes2 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits2: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
let params1 = GridParams {
|
||||
view_rot: view_rot1, eye: eye1, bounds: bounds1, step: step1,
|
||||
origin: origin1, axes: axes1, limits: limits1,
|
||||
};
|
||||
let params2 = GridParams {
|
||||
view_rot: view_rot2, eye: eye2, bounds: bounds2, step: step2,
|
||||
origin: origin2, axes: axes2, limits: limits2,
|
||||
};
|
||||
let grids = vec![params1, params2];
|
||||
// Overall canvas bounds — what `GridCanvas::draw` receives and
|
||||
// passes to `GridKey::from_grids`. The 1920×720 covers the two
|
||||
// tiled panes (1280 + 640).
|
||||
let canvas_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
|
||||
|
||||
// Pre-seed a `GridCanvasState` with the same key the bench will
|
||||
// build each iteration — guaranteed hit path.
|
||||
let state = GridCanvasState::default();
|
||||
let stored_key = GridKey::from_grids(&grids, canvas_bounds);
|
||||
*state.key.borrow_mut() = Some(stored_key);
|
||||
|
||||
for _ in 0..20 {
|
||||
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds));
|
||||
let hit = should_reuse(state.key.borrow().as_ref(), &key);
|
||||
black_box(hit);
|
||||
}
|
||||
|
||||
let n = 200u32;
|
||||
let start = Instant::now();
|
||||
let mut hit_count = 0u32;
|
||||
for _ in 0..n {
|
||||
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds));
|
||||
if should_reuse(state.key.borrow().as_ref(), &key) {
|
||||
hit_count += 1;
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per_frame = elapsed / n;
|
||||
assert_eq!(hit_count, n, "bench should always hit (sanity)");
|
||||
println!(
|
||||
"grid key + should_reuse (hit path): {:?} per frame (n = {}, total {:?})",
|
||||
per_frame, n, elapsed
|
||||
);
|
||||
assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grid_key_tests {
|
||||
use super::*;
|
||||
|
||||
/// Reference `GridParams` used as the baseline for key-construction tests.
|
||||
/// Mirrors a representative Model pane: identity-ish view, eye ~3.5m back,
|
||||
/// step 80 world units, WCS, no limits. Tests mutate one field at a time
|
||||
/// off this baseline to assert that `GridKey` invalidates on every input
|
||||
/// change.
|
||||
fn baseline_params() -> GridParams {
|
||||
GridParams {
|
||||
view_rot: Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05),
|
||||
eye: glam::DVec3::new(4.0, 3.5, 9.0),
|
||||
bounds: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1280.0,
|
||||
height: 720.0,
|
||||
},
|
||||
step: 80.0,
|
||||
origin: glam::DVec3::new(0.0, 0.0, 0.0),
|
||||
axes: (Vec3::X, Vec3::Y, Vec3::Z),
|
||||
limits: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Same `Vec<GridParams>` + same bounds ⇒ keys compare equal.
|
||||
#[test]
|
||||
fn grid_key_matches_identical_params() {
|
||||
let grids = vec![baseline_params(), baseline_params()];
|
||||
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
|
||||
let a = GridKey::from_grids(&grids, bounds);
|
||||
let b = GridKey::from_grids(&grids, bounds);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// One test, one baseline. Every change of any of the 7 inputs must produce
|
||||
/// a key that differs from the baseline. This is the entire correctness
|
||||
/// contract for cache hit/miss — if any input is ignored, the cache serves
|
||||
/// a stale grid.
|
||||
#[test]
|
||||
fn grid_key_invalidates_on_changed_fields() {
|
||||
let baseline_bounds = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1920.0,
|
||||
height: 720.0,
|
||||
};
|
||||
let baseline_grids = vec![baseline_params()];
|
||||
let baseline_key = GridKey::from_grids(&baseline_grids, baseline_bounds);
|
||||
|
||||
// view_rot: small extra rotation
|
||||
let mut p = baseline_params();
|
||||
p.view_rot = Mat4::from_rotation_x(0.15 + 0.01) * Mat4::from_rotation_y(0.05);
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&[p], baseline_bounds),
|
||||
baseline_key,
|
||||
"view_rot change must invalidate"
|
||||
);
|
||||
|
||||
// eye: shift in z
|
||||
let mut p = baseline_params();
|
||||
p.eye = glam::DVec3::new(4.0, 3.5, 9.5);
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&[p], baseline_bounds),
|
||||
baseline_key,
|
||||
"eye change must invalidate"
|
||||
);
|
||||
|
||||
// step: zoom in
|
||||
let mut p = baseline_params();
|
||||
p.step = 40.0;
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&[p], baseline_bounds),
|
||||
baseline_key,
|
||||
"step change must invalidate"
|
||||
);
|
||||
|
||||
// origin: translate the UCS origin off-zero
|
||||
let mut p = baseline_params();
|
||||
p.origin = glam::DVec3::new(100.0, 0.0, 0.0);
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&[p], baseline_bounds),
|
||||
baseline_key,
|
||||
"origin change must invalidate"
|
||||
);
|
||||
|
||||
// axes: rotate the active UCS
|
||||
let mut p = baseline_params();
|
||||
p.axes = (Vec3::Y, Vec3::X, Vec3::Z);
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&[p], baseline_bounds),
|
||||
baseline_key,
|
||||
"axes change must invalidate"
|
||||
);
|
||||
|
||||
// limits: switch from None to Some
|
||||
let mut p = baseline_params();
|
||||
p.limits = Some((glam::DVec2::new(0.0, 0.0), glam::DVec2::new(100.0, 100.0)));
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&[p], baseline_bounds),
|
||||
baseline_key,
|
||||
"limits change must invalidate"
|
||||
);
|
||||
|
||||
// bounds: same baseline GridParams but a different overlay bounds
|
||||
let other_bounds = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1280.0,
|
||||
height: 720.0,
|
||||
};
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&baseline_grids, other_bounds),
|
||||
baseline_key,
|
||||
"bounds change must invalidate"
|
||||
);
|
||||
}
|
||||
|
||||
/// In a 2-pane tiled layout, changing the second pane's params (with pane 1
|
||||
/// unchanged) must produce a different key — the cache cannot share geometry
|
||||
/// when any pane is dirty.
|
||||
#[test]
|
||||
fn grid_key_invalidates_when_any_pane_changes() {
|
||||
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
|
||||
let pane1 = baseline_params();
|
||||
let pane2 = baseline_params();
|
||||
let both = vec![pane1.clone(), pane2.clone()];
|
||||
let baseline = GridKey::from_grids(&both, bounds);
|
||||
|
||||
let mut pane2_changed = pane2;
|
||||
pane2_changed.step = 160.0;
|
||||
let dirty = vec![pane1, pane2_changed];
|
||||
assert_ne!(
|
||||
GridKey::from_grids(&dirty, bounds),
|
||||
baseline,
|
||||
"second pane change must invalidate"
|
||||
);
|
||||
}
|
||||
|
||||
/// `should_reuse(None, &key)` ⇒ `false` (no cached key to reuse).
|
||||
#[test]
|
||||
fn should_reuse_empty() {
|
||||
let grids = vec![baseline_params()];
|
||||
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
|
||||
let key = GridKey::from_grids(&grids, bounds);
|
||||
assert!(!should_reuse(None, &key));
|
||||
}
|
||||
|
||||
/// `should_reuse(Some(&old), &same)` ⇒ `true` (structural equality).
|
||||
#[test]
|
||||
fn should_reuse_equal() {
|
||||
let grids = vec![baseline_params()];
|
||||
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
|
||||
let key = GridKey::from_grids(&grids, bounds);
|
||||
assert!(should_reuse(Some(&key), &key));
|
||||
}
|
||||
|
||||
/// `should_reuse(Some(&old), &new)` with keys built from different inputs
|
||||
/// ⇒ `false` (must recompute, not serve stale geometry).
|
||||
#[test]
|
||||
fn should_reuse_changed() {
|
||||
let grids_a = vec![baseline_params()];
|
||||
let mut pane2 = baseline_params();
|
||||
pane2.step = 160.0;
|
||||
let grids_b = vec![pane2];
|
||||
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
|
||||
let old = GridKey::from_grids(&grids_a, bounds);
|
||||
let new = GridKey::from_grids(&grids_b, bounds);
|
||||
assert!(!should_reuse(Some(&old), &new));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grid_canvas_state_tests {
|
||||
use super::*;
|
||||
|
||||
/// A freshly-defaulted `GridCanvasState` must have no cached key — the
|
||||
/// first draw of a session always misses. This pins the `Default` impl
|
||||
/// to a usable empty state (no need for the wrapper to special-case it).
|
||||
#[test]
|
||||
fn default_state_has_no_cached_key() {
|
||||
let state = GridCanvasState::default();
|
||||
assert!(state.key.borrow().is_none());
|
||||
}
|
||||
|
||||
/// After manually storing a key in the state (the same way `draw` will
|
||||
/// on a miss), `should_reuse` must return `true` for the stored key and
|
||||
/// `false` for a key built from different inputs. This exercises the
|
||||
/// state → decision wiring end-to-end without a real iced `Renderer`.
|
||||
#[test]
|
||||
fn stored_key_is_recognized_by_should_reuse() {
|
||||
let view_rot = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 };
|
||||
let step = 80.0_f32;
|
||||
let grid_origin = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
let params = GridParams {
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
step,
|
||||
origin: grid_origin,
|
||||
axes: grid_axes,
|
||||
limits,
|
||||
};
|
||||
let key = GridKey::from_grids(&[params], bounds);
|
||||
|
||||
let state = GridCanvasState::default();
|
||||
*state.key.borrow_mut() = Some(key.clone());
|
||||
|
||||
// Same key in the cache and in the request ⇒ reuse.
|
||||
assert!(should_reuse(state.key.borrow().as_ref(), &key));
|
||||
|
||||
// Different bounds on the same params ⇒ different key, do not reuse.
|
||||
let other_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 640.0, height: 480.0 };
|
||||
let other_key = GridKey::from_grids(&[params], other_bounds);
|
||||
assert!(!should_reuse(state.key.borrow().as_ref(), &other_key));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue