From 636093aae67aa48fa1d35e403a205227e81d04de Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 23:36:55 -0700 Subject: [PATCH] perf: retain unchanged architecture grid geometry --- crates/oa-desktop/src/diagram.rs | 23 +- crates/oa-desktop/src/diagram/grid.rs | 50 +++ crates/oa-desktop/src/diagram/grid/tests.rs | 376 ++++++++++++++++++++ 3 files changed, 432 insertions(+), 17 deletions(-) create mode 100644 crates/oa-desktop/src/diagram/grid.rs create mode 100644 crates/oa-desktop/src/diagram/grid/tests.rs diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 464c085b..23779ccd 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -42,6 +42,7 @@ mod port_labels; pub use port_labels::PortLabels; #[path = "diagram/focus.rs"] pub(crate) mod focus; +mod grid; pub(crate) mod group_gesture; mod multiselect; #[cfg(test)] @@ -71,6 +72,7 @@ pub struct DiagramCanvas<'a> { } #[derive(Default)] pub struct State { + grid: grid::Grid, modifiers: iced::keyboard::Modifiers, group: Option, work: Option>, @@ -1251,23 +1253,10 @@ impl canvas::Program for DiagramCanvas<'_> { bounds: Rectangle, _: mouse::Cursor, ) -> Vec { + let background = state + .grid + .draw(renderer, bounds.size(), self.zoom, self.pan); let mut f = Frame::new(renderer, bounds.size()); - f.fill_rectangle( - Point::ORIGIN, - bounds.size(), - Color::from_rgb8(246, 248, 251), - ); - let step = (24. * self.zoom).max(14.); - let ox = self.pan.x.rem_euclid(step); - let oy = self.pan.y.rem_euclid(step); - for x in 0..(bounds.width / step) as i32 + 1 { - for y in 0..(bounds.height / step) as i32 + 1 { - f.fill( - &Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65), - style::LINE, - ); - } - } f.translate(self.pan); f.scale(self.zoom); let scene = self.scene(state); @@ -1670,7 +1659,7 @@ impl canvas::Program for DiagramCanvas<'_> { ..Default::default() }); } - vec![f.into_geometry(), overlay.into_geometry()] + vec![background, f.into_geometry(), overlay.into_geometry()] } fn mouse_interaction( &self, diff --git a/crates/oa-desktop/src/diagram/grid.rs b/crates/oa-desktop/src/diagram/grid.rs new file mode 100644 index 00000000..05e13704 --- /dev/null +++ b/crates/oa-desktop/src/diagram/grid.rs @@ -0,0 +1,50 @@ +//! Screen-space background geometry, independent of the model and pointer. +use super::*; +use std::cell::Cell; + +#[derive(Default)] +pub(super) struct Grid { + geometry: canvas::Cache, + camera: Cell>, + #[cfg(test)] + builds: Cell, +} + +impl Grid { + pub(super) fn draw( + &self, + renderer: &Renderer, + size: Size, + zoom: f32, + pan: Vector, + ) -> canvas::Geometry { + // Cache::draw handles exact size changes. Raw camera equality is + // deliberately conservative: do not quantize fractional antialiasing. + if self.camera.replace(Some((zoom, pan))) != Some((zoom, pan)) { + self.geometry.clear(); + } + self.geometry.draw(renderer, size, |frame| { + #[cfg(test)] + self.builds.set(self.builds.get() + 1); + paint(frame, size, zoom, pan); + }) + } +} + +fn paint(frame: &mut Frame, size: Size, zoom: f32, pan: Vector) { + frame.fill_rectangle(Point::ORIGIN, size, Color::from_rgb8(246, 248, 251)); + let step = (24. * zoom).max(14.); + let ox = pan.x.rem_euclid(step); + let oy = pan.y.rem_euclid(step); + for x in 0..(size.width / step) as i32 + 1 { + for y in 0..(size.height / step) as i32 + 1 { + frame.fill( + &Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65), + style::LINE, + ); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/diagram/grid/tests.rs b/crates/oa-desktop/src/diagram/grid/tests.rs new file mode 100644 index 00000000..2d303189 --- /dev/null +++ b/crates/oa-desktop/src/diagram/grid/tests.rs @@ -0,0 +1,376 @@ +use super::*; +use iced::advanced::{ + graphics::geometry::Renderer as _, + renderer::{Headless, Renderer as _}, +}; + +async fn renderer() -> Renderer { + ::new(iced::Font::DEFAULT, 16.into(), None) + .await + .expect("grid tests require the supported WGPU headless renderer") +} + +#[tokio::test] +async fn cache_reuses_unchanged_inputs_and_rebuilds_for_each_camera_or_size_change() { + let renderer = renderer().await; + let grid = Grid::default(); + let original = Size::new(320., 240.); + let threshold = 14. / 24.; + // Each row changes exactly one dependency from its predecessor. Returning + // to old dimensions must invalidate too; this cache holds one viewport. + let cases = [ + ("initial", original, 1., Vector::ZERO), + ("positive x pan", original, 1., Vector::new(24., 0.)), + ("negative x pan", original, 1., Vector::new(-24., 0.)), + ("fractional x pan", original, 1., Vector::new(-24.25, 0.)), + ("positive y pan", original, 1., Vector::new(-24.25, 18.)), + ("negative y pan", original, 1., Vector::new(-24.25, -18.)), + ("fractional y pan", original, 1., Vector::new(-24.25, -18.5)), + ("return to zero pan", original, 1., Vector::ZERO), + ("zoom at zero pan", original, 1.25, Vector::ZERO), + ( + "below spacing threshold", + original, + threshold - 0.001, + Vector::ZERO, + ), + ("at spacing threshold", original, threshold, Vector::ZERO), + ( + "above spacing threshold", + original, + threshold + 0.001, + Vector::ZERO, + ), + ( + "width only", + Size::new(321., 240.), + threshold + 0.001, + Vector::ZERO, + ), + ("return width", original, threshold + 0.001, Vector::ZERO), + ( + "height only", + Size::new(320., 241.), + threshold + 0.001, + Vector::ZERO, + ), + ("return height", original, threshold + 0.001, Vector::ZERO), + ( + "fractional width", + Size::new(320.25, 240.), + threshold + 0.001, + Vector::ZERO, + ), + ( + "fractional height", + Size::new(320.25, 240.5), + threshold + 0.001, + Vector::ZERO, + ), + ]; + for (index, (label, size, zoom, pan)) in cases.into_iter().enumerate() { + let _ = grid.draw(&renderer, size, zoom, pan); + assert_eq!(grid.builds.get(), index + 1, "{label} must rebuild"); + for _ in 0..3 { + let _ = grid.draw(&renderer, size, zoom, pan); + assert_eq!(grid.builds.get(), index + 1, "{label} must reuse"); + } + } +} + +// Independent reference copied from the former DiagramCanvas::draw background +// pass. Do not call grid::paint here: a shared implementation would hide changes +// in spacing, phase, radius, color, or coverage from the parity comparison. +fn legacy_background(frame: &mut Frame, size: Size, zoom: f32, pan: Vector) { + frame.fill_rectangle(Point::ORIGIN, size, Color::from_rgb8(246, 248, 251)); + let spacing = (24. * zoom).max(14.); + let offset_x = pan.x.rem_euclid(spacing); + let offset_y = pan.y.rem_euclid(spacing); + for column in 0..(size.width / spacing) as i32 + 1 { + for row in 0..(size.height / spacing) as i32 + 1 { + frame.fill( + &Path::circle( + Point::new( + offset_x + column as f32 * spacing, + offset_y + row as f32 * spacing, + ), + 0.65, + ), + Color::from_rgb(0.86, 0.89, 0.91), + ); + } + } +} + +fn foreground(frame: &mut Frame, zoom: f32, pan: Vector, changed: bool) { + frame.translate(pan); + frame.scale(zoom); + // Crossing paths pass behind opaque cards. The long paths also cross the + // screen-space warning, making geometry order observable. + for (start, end, color) in [ + (Point::new(0., 20.), Point::new(300., 210.), style::TEAL), + (Point::new(0., 210.), Point::new(300., 20.), style::BOUNDARY), + ] { + frame.stroke( + &Path::line(start, end), + Stroke::default().with_color(color).with_width(3.), + ); + } + let shift = if changed { 19.5 } else { 0. }; + frame.fill_rectangle( + Point::new(12., 92.), + Size::new(270., 35.), + Color::from_rgba(0.1, 0.6, 0.5, 0.25), + ); + for (position, size, title) in [ + (Point::new(32. + shift, 58.), Size::new(116., 72.), "Sensor"), + (Point::new(172., 139.), Size::new(118., 66.), "Controller"), + ] { + frame.fill_rectangle(position, size, Color::WHITE); + frame.stroke( + &Path::rectangle(position, size), + Stroke::default().with_color(style::BOUNDARY).with_width(1.), + ); + frame.fill_text(Text { + content: title.into(), + position: position + Vector::new(8., 12.), + color: style::INK, + size: 13.into(), + ..Default::default() + }); + } + // Selection paint and handles remain live foreground data, including when + // the underlying background cache is reused after an edit. + frame.stroke( + &Path::rectangle(Point::new(29. + shift, 55.), Size::new(122., 78.)), + Stroke::default().with_color(style::TEAL).with_width(2.), + ); + frame.fill_rectangle(Point::new(26. + shift, 52.), Size::new(6., 6.), style::TEAL); + frame.fill_text(Text { + content: if changed { + "Edited connection" + } else { + "Connection" + } + .into(), + position: Point::new(116., 113.), + color: style::INK, + size: 11.into(), + ..Default::default() + }); +} + +fn warning(frame: &mut Frame, size: Size) { + frame.fill_rectangle( + Point::new(8., 8.), + Size::new((size.width - 16.).max(0.), 30.), + Color::WHITE, + ); + frame.fill_text(Text { + content: "1 connection route needs attention".into(), + position: Point::new(16., 15.), + color: style::MUTED, + size: 12.into(), + ..Default::default() + }); +} + +fn legacy_geometry( + renderer: &Renderer, + size: Size, + zoom: f32, + pan: Vector, + changed: bool, +) -> Vec { + let mut combined = Frame::new(renderer, size); + legacy_background(&mut combined, size, zoom, pan); + foreground(&mut combined, zoom, pan, changed); + let mut overlay = Frame::new(renderer, size); + warning(&mut overlay, size); + vec![combined.into_geometry(), overlay.into_geometry()] +} + +fn cached_geometry( + grid: &Grid, + renderer: &Renderer, + size: Size, + zoom: f32, + pan: Vector, + changed: bool, +) -> Vec { + let background = grid.draw(renderer, size, zoom, pan); + let mut content = Frame::new(renderer, size); + foreground(&mut content, zoom, pan, changed); + let mut overlay = Frame::new(renderer, size); + warning(&mut overlay, size); + vec![background, content.into_geometry(), overlay.into_geometry()] +} + +fn pixels(renderer: &mut Renderer, size: Size, geometry: Vec) -> Vec { + // Keep physical screenshot dimensions and renderer viewport consistent even + // for fractional canvas bounds; the geometry itself keeps its exact size. + let physical = Size::new(size.width.ceil() as u32, size.height.ceil() as u32); + renderer.reset(Rectangle::with_size(Size::new( + physical.width as f32, + physical.height as f32, + ))); + for layer in geometry { + renderer.draw_geometry(layer); + } + let pixels = Headless::screenshot(renderer, physical, 1., style::BG); + assert_eq!( + pixels.len(), + physical.width as usize * physical.height as usize * 4 + ); + pixels +} + +fn assert_same_pixels(expected: &[u8], actual: &[u8], label: &str) { + assert_eq!(expected.len(), actual.len(), "{label}: dimensions"); + let differing = expected + .chunks_exact(4) + .zip(actual.chunks_exact(4)) + .filter(|(a, b)| a != b) + .count(); + assert_eq!(differing, 0, "{label}: changed RGBA pixels"); +} + +#[tokio::test] +async fn cached_background_preserves_pixels_and_live_foreground_across_camera_and_size_changes() { + let mut renderer = renderer().await; + let grid = Grid::default(); + let original = Size::new(320., 240.); + let threshold = 14. / 24.; + for (label, size, zoom, pan) in [ + ("origin", original, 1., Vector::ZERO), + ("positive pan", original, 1., Vector::new(17., 11.)), + ("negative pan", original, 1., Vector::new(-17., -11.)), + ("fractional pan", original, 1., Vector::new(-17.25, 11.5)), + ("zoom zero pan", original, 1.25, Vector::ZERO), + ("below threshold", original, threshold - 0.001, Vector::ZERO), + ("at threshold", original, threshold, Vector::ZERO), + ("above threshold", original, threshold + 0.001, Vector::ZERO), + ("width resize", Size::new(327., 240.), 1., Vector::ZERO), + ("height resize", Size::new(327., 247.), 1., Vector::ZERO), + ("return size", original, 1., Vector::ZERO), + ( + "fractional bounds", + Size::new(319.25, 239.5), + 1., + Vector::new(-0.5, 0.25), + ), + ] { + let old = legacy_geometry(&renderer, size, zoom, pan, false); + let expected = pixels(&mut renderer, size, old); + let layers = cached_geometry(&grid, &renderer, size, zoom, pan, false); + let actual = pixels(&mut renderer, size, layers); + assert_same_pixels(&expected, &actual, label); + let builds = grid.builds.get(); + let reused = cached_geometry(&grid, &renderer, size, zoom, pan, false); + let reused = pixels(&mut renderer, size, reused); + assert_same_pixels(&expected, &reused, label); + assert_eq!(grid.builds.get(), builds, "{label}: screenshot reuse"); + + let edited_old = legacy_geometry(&renderer, size, zoom, pan, true); + let edited_expected = pixels(&mut renderer, size, edited_old); + assert_ne!( + expected, edited_expected, + "{label}: edit must visibly change foreground" + ); + let edited = cached_geometry(&grid, &renderer, size, zoom, pan, true); + let edited = pixels(&mut renderer, size, edited); + assert_same_pixels(&edited_expected, &edited, label); + assert_eq!( + grid.builds.get(), + builds, + "{label}: foreground edit must reuse grid" + ); + } +} + +#[tokio::test] +async fn actual_canvas_keeps_background_cached_while_selection_and_revision_change() { + use super::super::{Context, DiagramCanvas, State}; + let mut renderer = renderer().await; + let mut project = Project::empty(); + let a = project + .add_element(Kind::Entity, Layer::Oa, "Sensor", None) + .unwrap(); + let b = project + .add_element(Kind::Actor, Layer::Oa, "Controller", None) + .unwrap(); + let diagram = project.ensure_diagram(Layer::Oa); + project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview; + let node = project.place(diagram, a, Position { x: 20., y: 50. }); + project.place(diagram, b, Position { x: 100., y: 160. }); + project.relate(RelationKind::CommunicationMean, "Link", a, b); + let mut canvas = DiagramCanvas { + selection: None, + allocation: None, + project: &project, + view_index: None, + diagram, + selected: a, + navigation_focus: None, + zoom: 0.75, + pan: Vector::ZERO, + context: Context { + document: project.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + selected_attachment: None, + editable: true, + placing: false, + connection: None, + }; + let state = State::default(); + let size = Size::new(360., 280.); + let bounds = Rectangle::with_size(size); + let draw = |canvas: &DiagramCanvas<'_>, renderer: &Renderer, bounds| { + canvas::Program::draw( + canvas, + &state, + renderer, + &crate::style::theme(), + bounds, + mouse::Cursor::Unavailable, + ) + }; + let layers = draw(&canvas, &renderer, bounds); + assert_eq!(layers.len(), 3, "background, scene, warning overlay"); + let original = pixels(&mut renderer, size, layers); + assert_eq!(state.grid.builds.get(), 1); + canvas.selected = b; + let layers = draw(&canvas, &renderer, bounds); + let selected = pixels(&mut renderer, size, layers); + assert_ne!(original, selected, "selection must remain live"); + assert_eq!(state.grid.builds.get(), 1); + let mut changed = project.clone(); + changed + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .position + .x += 30.; + canvas.project = &changed; + canvas.context.revision += 1; + let layers = draw(&canvas, &renderer, bounds); + let moved = pixels(&mut renderer, size, layers); + assert_ne!(selected, moved, "new model revision must remain live"); + assert_eq!(state.grid.builds.get(), 1); + canvas.pan.x = -0.25; + let _ = draw(&canvas, &renderer, bounds); + assert_eq!(state.grid.builds.get(), 2); + let _ = draw( + &canvas, + &renderer, + Rectangle::with_size(Size::new(361., 280.)), + ); + assert_eq!(state.grid.builds.get(), 3); +}