From 74bf5c26184fac5bae566db6d83fdd40ab5270f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 15 Nov 2026 06:36:16 -0800 Subject: [PATCH] Selection: an Intertwine group's members in symbol art count toward a click's object limit A click walks a symbol instance's art within one limit of objects, but the members of an Intertwine group in that art, and the objects of groups nested deeper than the part path depth, were tested without taking from it. Each object hit_any visits in placed symbol art now takes one object from the limit, and testing stops once the limit is used up. --- crates/doc/src/hit.rs | 43 +++++++++++++++++++++++++++++++++---- crates/doc/src/hit/tests.rs | 31 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index d49776b2cb..a286c25ffc 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1121,10 +1121,12 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool { stroke_contains(&path.to_bezpath(), 0.0, tol, p) } -/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). +/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). In symbol art +/// placed by [`FindPart`], each object visited takes one object of the [`ArtWalk`] limit, and +/// nothing more is tested once the limit is used up. fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { // Hidden art does not hit. - if !n.visible { + if !n.visible || ArtWalk::charged() && !ArtWalk::take(1) { return None; } // Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are @@ -1257,8 +1259,12 @@ struct FindPart<'a> { } impl FindPart<'_> { + /// What a click at `p` hits on object `n` of the art, placed. [`hit_any`] takes `n` and each + /// object it visits inside `n` from the [`ArtWalk`] limit. fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> Option { - self.art.place(n, whole).and_then(|n| hit_any(&n, self.p, opt, self.symbols)) + let placed = self.art.place(n, whole)?; + let _charged = ArtWalk::charge(); + hit_any(&placed, self.p, opt, self.symbols) } fn skips_guide(&self, n: &Node) -> bool { @@ -1268,7 +1274,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) || !ArtWalk::take(1) { + if !n.visible || !self.whole_instance && (n.locked || n.is_template()) || self.skips_guide(n) || ArtWalk::used_up() { return None; } let outline = outline || previews_off(n); @@ -1277,6 +1283,9 @@ impl FindPart<'_> { if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { return self.hit(n, whole, opt); } + if !ArtWalk::take(1) { + return None; + } let whole = self.art.whole(n, whole); if n.clips() && let Some(first) = children.first() @@ -1370,6 +1379,8 @@ 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) }; + /// Whether [`hit_any`] is testing symbol art that [`FindPart`] placed ([`ArtWalk::charge`]). + static ART_WALK_CHARGED: std::cell::Cell = const { std::cell::Cell::new(false) }; } #[cfg(test)] @@ -1420,6 +1431,19 @@ impl ArtWalk { ART_WALK_LEFT.set(Some(rest.unwrap_or(0))); rest.is_some() } + + /// While the guard lives, each object [`hit_any`] visits takes one object from what is left: + /// [`FindPart`] holds one while it tests a placed object of symbol art, so that the members of an + /// Intertwine group, and the objects of groups nested deeper than + /// [`crate::symbol_overrides::MAX_PATH_DEPTH`], count toward the limit. + fn charge() -> ArtWalkCharge { + ArtWalkCharge { was: ART_WALK_CHARGED.replace(true) } + } + + /// Does [`hit_any`] take the objects it visits from what is left ([`Self::charge`])? + fn charged() -> bool { + ART_WALK_CHARGED.get() + } } impl Drop for ArtWalk { @@ -1430,6 +1454,17 @@ impl Drop for ArtWalk { } } +/// The guard of [`ArtWalk::charge`]. +struct ArtWalkCharge { + was: bool, +} + +impl Drop for ArtWalkCharge { + fn drop(&mut self) { + ART_WALK_CHARGED.set(self.was); + } +} + /// 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. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 74bd959a7f..f2582c17fa 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -2140,6 +2140,37 @@ fn a_click_and_a_marquee_walk_repeated_nested_symbol_instances_within_one_limit( assert_eq!(with_art_limit(100, || marquee(&d, edge, None, false)).0, [inst], "marquee across the art's edge"); } +/// The members of an Intertwine group in symbol art count toward the art walk's limit of objects +/// for a click, as the members of a plain group do. A click on a member's path past the instance's +/// box is still found within the limit. +#[test] +fn a_click_counts_the_members_of_an_intertwine_group_in_symbol_art_toward_one_limit() { + set_clip_region(moved_clip_region); + // A clip group whose clipping region `test.move` moves to 100..200 × 0..100, past the 0..100 + // box, over an Intertwine group of 200 squares at 140..160 × 40..60. + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + let (mut mask, _) = half_clipped(&mut d); + mask.appearance.effects.push(test_move()); + let squares = (0..200) + .map(|_| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(140.0, 40.0, 160.0, 60.0)), Appearance::default_art()))) + .collect(); + let woven = Arc::new(Node::new(d.alloc_id(), NodeKind::Intertwine { children: squares })); + let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), woven]); + clip.set_clips(true); + group_symbol(&mut d, "Woven", vec![Arc::new(clip)]); + let inst = instance_of(&mut d, "Woven"); + let id = inst.id; + d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + let opt = HitOptions { path_only: true, ..HitOptions::default() }; + let at = |x: f64| hit_test(&d, Point::new(x, 50.0), opt).map(|h| h.leaf); + // Inside the squares and away from their paths, every member is tested and none is hit. + let (hit, leaves, _) = with_art_limit(100, || at(150.0)); + assert_eq!(hit, None); + assert!(leaves <= 100, "{leaves} leaves tested"); + assert_eq!(with_art_limit(100, || at(140.0)).0, Some(id), "on the squares' paths"); +} + /// 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.