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:
Karim Jerbi 2026-08-26 23:51:37 +01:00
commit 513ab958a8

View file

@ -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));
}
}