diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 22766f4e7..1e3154055 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -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, + 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>` 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>` 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>, + pub cache: canvas::Cache, +} + /// 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 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 { - 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` + 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)); + } +}