perf: retain unchanged architecture grid geometry

This commit is contained in:
2026-09-24 23:36:55 -07:00
parent c0908f101a
commit 636093aae6
3 changed files with 432 additions and 17 deletions
+6 -17
View File
@@ -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<multiselect::Press>,
work: Option<Arc<crate::app::allocation_drag::Press>>,
@@ -1251,23 +1253,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
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<Message> for DiagramCanvas<'_> {
..Default::default()
});
}
vec![f.into_geometry(), overlay.into_geometry()]
vec![background, f.into_geometry(), overlay.into_geometry()]
}
fn mouse_interaction(
&self,
+50
View File
@@ -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<Option<(f32, Vector)>>,
#[cfg(test)]
builds: Cell<usize>,
}
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;
+376
View File
@@ -0,0 +1,376 @@
use super::*;
use iced::advanced::{
graphics::geometry::Renderer as _,
renderer::{Headless, Renderer as _},
};
async fn renderer() -> Renderer {
<Renderer as Headless>::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<canvas::Geometry> {
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<canvas::Geometry> {
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<canvas::Geometry>) -> Vec<u8> {
// 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);
}