perf: retain unchanged architecture grid geometry
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user