From 1cd87502f6ef5a431fde416e274a213989a8998c Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 24 Oct 2026 03:19:58 +0200 Subject: [PATCH 1/3] Bound the document from its outermost layers and skip targets that cannot extend the bounds --- .../utility_types/document_metadata.rs | 36 ++++++---- .../network_interface/queries.rs | 65 ++++++++++++------- 2 files changed, 65 insertions(+), 36 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index a522314885b..ec84f750fe9 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -283,22 +283,32 @@ impl DocumentMetadata { /// Get the bounding box of the click target of the specified layer in the specified transform space pub fn bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> { - let own_bounds = self - .own_visual_targets(layer) - .into_iter() - .flatten() - .filter_map(|click_target| click_target.bounding_box_with_transform(transform)) - .reduce(Quad::combine_bounds); + let mut bounds = None; + self.extend_bounds_with_transform(layer, transform, &mut bounds); + bounds + } + + /// Grows `bounds` to hold the layer's visual targets and, for a group, its descendants', each bounded in its own space. + fn extend_bounds_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2, bounds: &mut Option<[DVec2; 2]>) { + for target in self.own_visual_targets(layer).into_iter().flatten() { + // A target whose loose bounds lie within the bounds so far cannot extend them, so its tight bounds are never computed + if let (Some([min, max]), Some(loose)) = (*bounds, target.bounding_box()) { + let [loose_min, loose_max] = (transform * Quad::from_box(loose)).bounding_box(); + if loose_min.cmpge(min).all() && loose_max.cmple(max).all() { + continue; + } + } - if !self.group_layers.contains(&layer) { - return own_bounds; + if let Some(tight) = target.bounding_box_with_transform(transform) { + *bounds = Some(bounds.map_or(tight, |bounds| Quad::combine_bounds(bounds, tight))); + } } - // Each descendant is bounded in its own space, without composing copies of its targets - self.children_in_group_space(layer) - .filter_map(|(child, child_to_layer)| self.bounding_box_with_transform(child, transform * child_to_layer)) - .chain(own_bounds) - .reduce(Quad::combine_bounds) + if self.group_layers.contains(&layer) { + for (child, child_to_layer) in self.children_in_group_space(layer) { + self.extend_bounds_with_transform(child, transform * child_to_layer, bounds); + } + } } /// Calculate the corners of the bounding box but with a nonzero size. diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index fb5ad08c501..0a8fc240952 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -827,31 +827,50 @@ impl NodeNetworkInterface { self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document(layer)) } + /// The union of every layer's bounds, with a layer inside a clipping artboard contributing only its part within the artboard. + /// A layer's bounds hold its descendants', so only the outermost layers below the root and each artboard are bounded, in O(layers). fn combined_document_bounds(&self, include_artboards: bool, layer_bounds: impl Fn(&DocumentMetadata, LayerNodeIdentifier) -> Option<[DVec2; 2]>) -> Option<[DVec2; 2]> { - self.document_metadata - .all_layers() - .filter(|layer| include_artboards || !self.is_artboard(&layer.to_node(), &[])) - .filter_map(|layer| { - // A layer clipped by its artboard contributes only the intersection of the two bounds - if !self.is_artboard(&layer.to_node(), &[]) - && let Some(artboard_node_identifier) = layer - .ancestors(self.document_metadata()) - .find(|ancestor| *ancestor != LayerNodeIdentifier::ROOT_PARENT && self.is_artboard(&ancestor.to_node(), &[])) - && let Some(artboard) = self.document_node(&artboard_node_identifier.to_node(), &[]) - && let Some(clip_input) = artboard.inputs.get(ARTBOARD_CLIP_INPUT_INDEX) - && let NodeInput::Value { tagged_value, .. } = clip_input - && tagged_value.clone().deref() == &TaggedValue::Bool(true) - { - return Some(Quad::clip( - layer_bounds(&self.document_metadata, layer).unwrap_or_default(), - self.document_metadata.bounding_box_document(artboard_node_identifier).unwrap_or_default(), - )); + let metadata = &self.document_metadata; + let is_finite = |[min, max]: &[DVec2; 2]| min.is_finite() && max.is_finite(); + + let mut combined: Option<[DVec2; 2]> = None; + let mut add = |bounds: [DVec2; 2]| combined = Some(combined.map_or(bounds, |combined| Quad::combine_bounds(combined, bounds))); + + // Each layer with the bounds of the nearest artboard above it, if that one clips its content + let mut pending: Vec<(LayerNodeIdentifier, Option<[DVec2; 2]>)> = LayerNodeIdentifier::ROOT_PARENT.children(metadata).map(|layer| (layer, None)).collect(); + while let Some((layer, clip)) = pending.pop() { + if self.is_artboard(&layer.to_node(), &[]) { + let clips_content = self.artboard_clips_content(layer); + + if include_artboards && let Some(bounds) = layer_bounds(metadata, layer).filter(is_finite) { + add(bounds); + + // Content clipped to the artboard lies within the bounds just added + if clips_content { + continue; + } } - layer_bounds(&self.document_metadata, layer) - }) - // Skip any layer bounds containing NaN to avoid poisoning the combined result - .filter(|[min, max]| min.is_finite() && max.is_finite()) - .reduce(Quad::combine_bounds) + + let child_clip = clips_content.then(|| metadata.bounding_box_document(layer).unwrap_or_default()); + pending.extend(layer.children(metadata).map(|child| (child, child_clip))); + continue; + } + + // Bounds containing NaN would poison the combined result, so the layer's descendants are bounded instead + match layer_bounds(metadata, layer).filter(is_finite) { + Some(bounds) => add(clip.map_or(bounds, |clip| Quad::clip(bounds, clip))), + None => pending.extend(layer.children(metadata).map(|child| (child, clip))), + } + } + + combined + } + + /// Whether the artboard layer clips the content inside it to its bounds. + fn artboard_clips_content(&self, artboard: LayerNodeIdentifier) -> bool { + self.document_node(&artboard.to_node(), &[]) + .and_then(|artboard| artboard.inputs.get(ARTBOARD_CLIP_INPUT_INDEX)) + .is_some_and(|clip_input| matches!(clip_input, NodeInput::Value { tagged_value, .. } if tagged_value.clone().deref() == &TaggedValue::Bool(true))) } pub fn document_bounds_viewport_space(&self, include_artboards: bool) -> Option<[DVec2; 2]> { From 412b920826384a1dae2ade23a58150a80db98342 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 24 Oct 2026 03:36:54 +0200 Subject: [PATCH 2/3] Cache the document bounds until the layers change --- .../utility_types/document_metadata.rs | 34 ++++++++++++++++++- .../network_interface/queries.rs | 8 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index ec84f750fe9..442eb0cf679 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -39,6 +39,8 @@ pub struct DocumentMetadata { pub group_layers: HashSet, /// Each group's targets composed with its descendants' into its space. composed_targets: HashMap, + /// The whole document's bounds, one slot per way of combining them (see [`Self::document_bounds`]), each computed on first query after a change. + document_bounds: [OnceLock>; 4], /// Per-layer text frame from item 0's `editor:text_frame` attribute (`DAffine2` mapping the (0, 0)-(1, 1) unit square onto the frame). /// The Text tool composes this with `transform_to_viewport(layer)` to position its drag cage. pub text_frames: HashMap, @@ -259,9 +261,16 @@ impl DocumentMetadata { .flat_map(move |viewport_to_group| group.children(self).map(move |child| (child, viewport_to_group * self.transform_to_viewport(child)))) } - /// Forgets every group's composed targets, after the layers' targets, transforms, or structure change. + /// Forgets every group's composed targets and the document's bounds, after the layers' targets, transforms, or structure change. pub fn reset_composed_targets(&mut self) { self.composed_targets = self.group_layers.iter().map(|&group| (group, ComposedTargets::default())).collect(); + self.document_bounds = Default::default(); + } + + /// The whole document's bounds with or without artboards and strokes, from `compute` on the first query after the layers change. + pub fn document_bounds(&self, include_artboards: bool, with_stroke: bool, compute: impl FnOnce() -> Option<[DVec2; 2]>) -> Option<[DVec2; 2]> { + let slot = usize::from(include_artboards) + 2 * usize::from(with_stroke); + *self.document_bounds[slot].get_or_init(compute) } /// Whether `hit` accepts a click target of the layer or of any descendant, each passed with its own layer's transform to document space. @@ -764,6 +773,29 @@ mod tests { assert!(generations.iter().enumerate().all(|(index, generation)| !generations[..index].contains(generation))); } + // The document's bounds are computed once per way of combining them, and again once the layers change + #[test] + fn document_bounds_stay_cached_until_the_layers_change() { + let mut metadata = DocumentMetadata::default(); + let computed = std::cell::Cell::new(0); + let compute = || { + computed.set(computed.get() + 1); + Some([DVec2::ZERO, DVec2::ONE]) + }; + + metadata.document_bounds(true, false, compute); + metadata.document_bounds(true, false, compute); + assert_eq!(computed.get(), 1); + + metadata.document_bounds(false, false, compute); + metadata.document_bounds(true, true, compute); + assert_eq!(computed.get(), 3); + + metadata.mark_changed(); + metadata.document_bounds(true, false, compute); + assert_eq!(computed.get(), 4); + } + #[test] fn test_tree() { let mut metadata = DocumentMetadata::default(); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index 0a8fc240952..1bcb5b37268 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -824,7 +824,9 @@ impl NodeNetworkInterface { /// Calculates the document bounds in document space pub fn document_bounds_document_space(&self, include_artboards: bool) -> Option<[DVec2; 2]> { - self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document(layer)) + self.document_metadata.document_bounds(include_artboards, false, || { + self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document(layer)) + }) } /// The union of every layer's bounds, with a layer inside a clipping artboard contributing only its part within the artboard. @@ -883,7 +885,9 @@ impl NodeNetworkInterface { /// Calculates the document bounds in document space, expanding vector layer bounds to include the rendered /// stroke width. Used for export so the output canvas captures strokes that overflow the path geometry. pub fn document_bounds_document_space_with_stroke(&self, include_artboards: bool) -> Option<[DVec2; 2]> { - self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document_with_stroke(layer)) + self.document_metadata.document_bounds(include_artboards, true, || { + self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document_with_stroke(layer)) + }) } /// Calculates the selected layer bounds in document space From 3597b4e2abec056ae4cda318fbaeb7900920c0b8 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 24 Oct 2026 10:27:14 +0200 Subject: [PATCH 3/3] Skip non-finite bounds left by clipping to an artboard --- .../document/utility_types/network_interface/queries.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index 1bcb5b37268..cd8229f4bf3 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -860,7 +860,12 @@ impl NodeNetworkInterface { // Bounds containing NaN would poison the combined result, so the layer's descendants are bounded instead match layer_bounds(metadata, layer).filter(is_finite) { - Some(bounds) => add(clip.map_or(bounds, |clip| Quad::clip(bounds, clip))), + Some(bounds) => { + let clipped = clip.map_or(bounds, |clip| Quad::clip(bounds, clip)); + if is_finite(&clipped) { + add(clipped); + } + } None => pending.extend(layer.children(metadata).map(|child| (child, clip))), } }