From 9c377b561980cb34b1115b1f5385f4a2c336e9ca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 15 Nov 2026 01:41:33 -0800 Subject: [PATCH] Selection: one limit on the symbol art a click or marquee walks A click or marquee outside a symbol instance's box walks the instance's placed art, and an instance nested in that art walked its own art with no shared limit, so deeply nested or widely repeated instances could make one click walk far more objects than the canvas draws. Clicks, Select Behind, the locked-object and hidden-selection lookups, Direct Selection's part lookup and marquees now share one limit of MAX_SYMBOL_NODES objects across every level of nested art. Once it is used up, an instance is no longer hit or touched outside its box; inside its box it still is. The art walk also checks the instance's reach before starting. --- crates/doc/src/hit.rs | 110 +++++++++++++++++++++++++++-- crates/doc/src/hit/tests.rs | 137 ++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 04b48634ed..8ecae63b0d 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -266,6 +266,7 @@ pub fn hit_test_visible_skipping(doc: &Document, p: Point, opt: HitOptions, skip /// (`opt.scope`) or on a locked artboard. On a layer whose Preview is off it counts by its /// outline, as a click there hits it. pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option { + let _walk = ArtWalk::open(); let symbols = doc.symbol_boxes(); let locks = doc.locked_artboards(); selected.iter().rev().copied().find(|&id| { @@ -322,6 +323,7 @@ fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option) -> Vec { + let walk = ArtWalk::open(); let opt = HitOptions { scope: scope.or(opt.scope), ..opt }; // Normally one hit test per *object* suffices: layers and sublayers aren't selectable, // while a group (including a clipping group) is selected as one object. Repeatedly @@ -337,6 +339,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option Vec { + let _walk = ArtWalk::open(); let symbols = doc.symbol_boxes(); let mut out = Vec::new(); if let Some(scope) = opt.scope @@ -506,6 +512,7 @@ fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editab /// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art /// ([`Node::geometric_bounds_with`]), and are also hit outside that box where their art paints. fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option { + let _walk = ArtWalk::open(); let finish = |mut h: Hit| { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); @@ -1172,6 +1179,15 @@ impl<'a> InstanceArt<'a> { whole.or_else(|| self.map.as_ref()?.one_cell_with(n, &self.boxes)) } + /// How many objects placing the whole art makes: the objects of the art with their opacity + /// masks' art ([`Node::count_with_masks`]), or, through the guides, the most that + /// [`crate::NineSliceMap::most_placed_with`] counts. + fn size(&self) -> u64 { + self.map + .as_ref() + .map_or_else(|| u64::try_from(self.art.count_with_masks()).unwrap_or(u64::MAX), |m| m.most_placed_with(&self.art, &self.boxes)) + } + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as @@ -1219,6 +1235,7 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// layer, is tested in outline ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or /// no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { + let _walk = ArtWalk::open(); let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; @@ -1250,7 +1267,7 @@ impl FindPart<'_> { /// What a click at `p` hits on object `n` of the art, with `path` the child indices that lead /// to what it hits. `outline`: the view is in Outline, or a layer around `n` has Preview off. fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> Option { - if !n.visible || !self.whole_instance && (n.locked || n.is_template()) || self.skips_guide(n) { + if !n.visible || !self.whole_instance && (n.locked || n.is_template()) || self.skips_guide(n) || !ArtWalk::take(1) { return None; } let outline = outline || previews_off(n); @@ -1298,17 +1315,89 @@ impl FindPart<'_> { /// Outline, [`HitOptions::outline`]): its strokes, and the art that the live geometry effects of a /// clipping path in it show past the art's box. `None` for an instance of a missing symbol, of a /// symbol that holds an instance of itself or nests too deep ([`SymbolBoxes::art`]), and of art past -/// the 9-slice object limit ([`InstanceArt::of`]). +/// the 9-slice object limit ([`InstanceArt::of`]), when `p` is outside the instance's reach +/// ([`Node::reach_bounds_with`]), and once the click has visited as many objects of symbol art as +/// [`ArtWalk`] allows. fn instance_art_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return None }; - if opt.outline || symbols.art(symbol).is_none() { + if opt.outline || symbols.art(symbol).is_none() || !n.reach_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return None; + } + let _walk = ArtWalk::open(); + if ArtWalk::used_up() { return None; } let art = InstanceArt::of(symbols.document(), n)?; + #[cfg(test)] + ARTS_WALKED.with(|c| c.set(c.get() + 1)); let opt = HitOptions { clip_outlines: false, ..opt }; FindPart { art: &art, p, opt, symbols, whole_instance: true }.find(&art.art, &mut vec![], None, false) } +thread_local! { + /// How many more objects of symbol art the click or marquee under way may visit ([`ArtWalk`]), + /// `None` outside one. + static ART_WALK_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +thread_local! { + /// The number of objects [`ArtWalk::open`] allows on this thread in place of + /// [`crate::MAX_SYMBOL_NODES`]. + static ART_WALK_LIMIT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// How many symbol instances' art clicks and marquees walked on this thread. + static ARTS_WALKED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The objects of symbol art that one click or one marquee may visit: [`crate::MAX_SYMBOL_NODES`] +/// in all, the art of the instances nested in that art included, at every level. The first +/// [`ArtWalk::open`] of a click or a marquee sets the limit, and the walks opened while its guard +/// lives share it. Once the limit is used up, an instance is no longer hit or touched outside its +/// box; inside its box it still is. +struct ArtWalk { + outermost: bool, +} + +impl ArtWalk { + fn open() -> Self { + let walk = Self { outermost: ART_WALK_LEFT.get().is_none() }; + walk.restart(); + walk + } + + /// For the outermost guard, sets what is left back to the whole limit. + fn restart(&self) { + if self.outermost { + #[cfg(test)] + let limit = ART_WALK_LIMIT.get().unwrap_or(crate::MAX_SYMBOL_NODES); + #[cfg(not(test))] + let limit = crate::MAX_SYMBOL_NODES; + ART_WALK_LEFT.set(Some(limit)); + } + } + + /// Is nothing left? + fn used_up() -> bool { + ART_WALK_LEFT.get() == Some(0) + } + + /// Takes `n` objects from what is left. `false` when fewer are left, and nothing is left after. + fn take(n: u64) -> bool { + let Some(left) = ART_WALK_LEFT.get() else { return true }; + let rest = left.checked_sub(n); + ART_WALK_LEFT.set(Some(rest.unwrap_or(0))); + rest.is_some() + } +} + +impl Drop for ArtWalk { + fn drop(&mut self) { + if self.outermost { + ART_WALK_LEFT.set(None); + } + } +} + /// Is `n` below a layer whose Preview is off, so that the canvas draws it in outline? As for clicks /// ([`path_options`]), the Preview of the isolated container `scope` and of the layers around it /// does not count. @@ -1502,6 +1591,7 @@ fn previews_off(n: &Node) -> bool { } fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, whole_compounds: bool, outline: bool) -> Vec { + let _walk = ArtWalk::open(); let mut out = Vec::new(); // The objects the marquee may touch ([`Top`]). let tops: Vec = match scope.and_then(|s| doc.node(s)) { @@ -1629,14 +1719,24 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh /// Does the marquee `r` touch the art of symbol instance `n` placed as the canvas draws it, as /// it touches a group of that art? Only for art whose clipping paths have live geometry effects /// ([`SymbolBoxes::art`]), and only when `r` meets the instance's reach - /// ([`Node::reach_bounds_with`]): elsewhere the art lies within the instance's box. + /// ([`Node::reach_bounds_with`]): elsewhere the art lies within the instance's box. Not once the + /// marquee has placed as many objects of symbol art as [`ArtWalk`] allows. fn instance_art_touches(n: &Node, s: &SymbolBoxes, r: Rect, whole_compounds: bool) -> bool { let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return false }; let meets = |b: Rect| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; if s.art(symbol).is_none_or(|a| a.reshaped.is_none()) || !n.reach_bounds_with(s).is_some_and(meets) { return false; } + let _walk = ArtWalk::open(); + if ArtWalk::used_up() { + return false; + } let Some(art) = InstanceArt::of(s.document(), n) else { return false }; + if !ArtWalk::take(art.size()) { + return false; + } + #[cfg(test)] + ARTS_WALKED.with(|c| c.set(c.get() + 1)); art.place(&art.art, None).is_some_and(|placed| placed.visible && touches(&placed, s, r, None, whole_compounds, false)) } fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, outline: bool, out: &mut Vec) { diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 1c817f2bf1..ec8e198baa 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1961,6 +1961,143 @@ fn a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box() { assert_eq!(at(108.0, HitOptions { outline: true, ..HitOptions::default() }), None, "Outline view"); } +/// Adds the symbol `Clipped`: a clip group whose clipping path's region `test.move` moves to +/// 100..200 × 0..100, past the 0..100 box, over a 0..200 × 0..100 rectangle shown when `shown`. +/// A click at (150, 50) hits an instance of it on the rectangle, or, with the rectangle hidden, +/// walks its art and finds nothing. +fn clipped_symbol(d: &mut Document, shown: bool) { + set_clip_region(moved_clip_region); + let (mut mask, mut art) = half_clipped(d); + mask.appearance.effects.push(test_move()); + art.visible = shown; + let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(clip)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(root) }); +} + +/// A symbol named `name` whose art is a group of `members`. +fn group_symbol(d: &mut Document, name: &str, members: Vec>) { + let root = Node::group(d.alloc_id(), members); + d.symbols.push(crate::Symbol { dynamic: false, name: name.into(), art: Arc::new(root) }); +} + +/// An instance of the symbol named `symbol`, not moved. +fn instance_of(d: &mut Document, symbol: &str) -> Arc { + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY })) +} + +/// 30 small squares at the origin, which a click at (150, 50) passes by. +fn far_squares(d: &mut Document) -> Vec> { + (0..30).map(|_| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()))).collect() +} + +/// Runs `f` with [`ArtWalk`] allowing `limit` objects, and returns its result with how many +/// leaves [`hit_leaf`] tested and how many instances' art was walked meanwhile. +fn with_art_limit(limit: u64, f: impl FnOnce() -> R) -> (R, usize, usize) { + ART_WALK_LIMIT.with(|c| c.set(Some(limit))); + LEAVES_TESTED.with(|c| c.set(0)); + ARTS_WALKED.with(|c| c.set(0)); + let r = f(); + ART_WALK_LIMIT.with(|c| c.set(None)); + (r, LEAVES_TESTED.with(std::cell::Cell::get), ARTS_WALKED.with(std::cell::Cell::get)) +} + +/// One click visits at most the art walk's limit of objects in a chain of nested symbol +/// instances, though each level's art alone is well within it. A click on the art of an +/// instance nested in another is still found. +#[test] +fn a_click_walks_a_chain_of_nested_symbol_instances_within_one_limit() { + // Level 0 is `Clipped`; each level above holds an instance of the level below and 30 squares: + // 32 objects to visit at each level. + let chain = |shown: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + clipped_symbol(&mut d, shown); + for level in 1..12 { + let below = if level == 1 { "Clipped".to_string() } else { format!("Level {}", level - 1) }; + let mut members = vec![instance_of(&mut d, &below)]; + members.extend(far_squares(&mut d)); + group_symbol(&mut d, &format!("Level {level}"), members); + } + let inst = instance_of(&mut d, "Level 11"); + let id = inst.id; + d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + (d, id) + }; + let at = |d: &Document, x: f64| hit_test(d, Point::new(x, 50.0), HitOptions::default()).map(|h| h.leaf); + // Without one limit for the click, the miss walks the art of all 12 levels. + let (d, inst) = chain(false); + let (hit, _, arts) = with_art_limit(100, || at(&d, 150.0)); + assert_eq!(hit, None); + assert!(arts <= 4, "{arts} levels walked"); + assert_eq!(with_art_limit(100, || at(&d, 50.0)).0, Some(inst), "in the box"); + // Past the limit, a click that the art would take finds only the box. + let (d, inst) = chain(true); + assert_eq!(with_art_limit(100, || at(&d, 150.0)).0, None, "past the limit"); + assert_eq!(at(&d, 150.0), Some(inst), "within the limit"); + // Two levels: a click on the inner instance's art is found within the limit. + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + clipped_symbol(&mut d, true); + let inner = instance_of(&mut d, "Clipped"); + group_symbol(&mut d, "Outer", vec![inner]); + let inst = instance_of(&mut d, "Outer"); + let id = inst.id; + d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + assert_eq!(with_art_limit(100, || at(&d, 150.0)).0, Some(id), "nested"); +} + +/// One click, and one marquee, visit at most the art walk's limit of objects in art that repeats +/// one nested instance many times, though the art of each instance alone is well within it. A +/// click on one of the nested instances' art is still found. +#[test] +fn a_click_and_a_marquee_walk_repeated_nested_symbol_instances_within_one_limit() { + // 50 instances of `Clipped` in one symbol's art, its rectangle shown when `shown`. + let repeated = |shown: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + clipped_symbol(&mut d, shown); + let members = (0..50).map(|_| instance_of(&mut d, "Clipped")).collect(); + group_symbol(&mut d, "Repeated", members); + let inst = instance_of(&mut d, "Repeated"); + let id = inst.id; + d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + (d, id) + }; + let (d, inst) = repeated(false); + // Without one limit for the click, the miss walks the art of all 51 instances. The outer art + // takes 1 object of the 100, and each nested instance with its art 4. + let (hit, _, arts) = with_art_limit(100, || hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).map(|h| h.leaf)); + assert_eq!(hit, None); + assert!(arts <= 26, "{arts} instances' art walked"); + // Select Behind over 60 instances of `Clipped` on the layer shares one limit too: each walk + // takes 3 objects of the 100. + let mut stack = Document::new(600.0, 400.0); + let l = stack.layers[0].id; + clipped_symbol(&mut stack, false); + for _ in 0..60 { + let inst = instance_of(&mut stack, "Clipped"); + stack.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + } + let (behind, _, arts) = with_art_limit(100, || objects_at(&stack, Point::new(150.0, 50.0), HitOptions::default(), None)); + assert!(behind.is_empty()); + assert!(arts <= 34, "Select Behind: {arts} instances' art walked"); + // Without one limit for the marquee, it places the art of all 51 instances, inside the moved + // region (within each instance's reach and outside its box). Placing the outer art takes 51 + // objects of the 100, and each nested art 4. + let inside = Rect::new(140.0, 40.0, 160.0, 60.0); + let (touched, _, arts) = with_art_limit(100, || marquee(&d, inside, None, false)); + assert!(touched.is_empty()); + assert!(arts <= 14, "{arts} instances' art placed"); + assert_eq!(with_art_limit(100, || marquee(&d, Rect::new(40.0, 40.0, 60.0, 60.0), None, false)).0, [inst], "in the box"); + // A click and a marquee on the nested instances' art are found within the limit. + let (d, inst) = repeated(true); + assert_eq!(with_art_limit(100, || hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).map(|h| h.leaf)).0, Some(inst), "click"); + let edge = Rect::new(140.0, -5.0, 160.0, 5.0); + assert_eq!(with_art_limit(100, || marquee(&d, edge, None, false)).0, [inst], "marquee across the art's edge"); +} + /// In a dynamic symbol's art that placing through its 9-slice guides could make more than /// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the /// canvas draws nothing of it. At the art's natural size the same art is picked.