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.