From 4f60e14555395ed0cb49363b45c0563049b6e317 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 16:51:33 -0800 Subject: [PATCH 1/2] Selection: symbol instances are hit where their art draws past their box A click on a symbol instance hit only its art's geometric box, and a marquee touched only that box, so art that a clipping path's live geometry effects move past the box could be seen but not selected. - Outside its box, in Preview, a click hits an instance where its placed art hits (dynamic overrides and 9-slice placement included), and a marquee touches the placed art as it touches a group of that art. Outline view and layers with Preview off keep the box alone. - The quick reach of an instance takes in the regions of the clipping paths with live effects in its art, so the groups around it no longer cull those clicks. --- crates/doc/src/hit.rs | 184 ++++++++++++++++++++++++------------ crates/doc/src/hit/tests.rs | 88 +++++++++++++++++ crates/doc/src/node/ops.rs | 30 +++++- crates/doc/src/symbols.rs | 18 +++- docs/target-app-parity.md | 1 + 5 files changed, 256 insertions(+), 65 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 5885a0ff54..04b48634ed 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -504,7 +504,7 @@ fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editab /// [`hit_test`] passing over the nodes `skip` names (and their contents), and with /// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of /// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art -/// ([`Node::geometric_bounds_with`]). +/// ([`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 finish = |mut h: Hit| { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); @@ -1055,9 +1055,17 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) } - NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => { + NodeKind::Text(_) | NodeKind::PlacedDocument(_) => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } + // A symbol instance is hit within its bounds, and outside them where its art paints + // ([`instance_art_hit`]). + NodeKind::SymbolInstance { .. } => { + if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return Some(HitKind::Bounds); + } + instance_art_hit(n, p, opt, symbols) + } // A blend or an envelope is hit within its bounds, and outside them where its evaluated // art paints: a blend's keys and steps on a spine can reach past the keys and the spine, // and the strokes of an envelope's distorted contents past its bounds. @@ -1211,64 +1219,94 @@ 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> { - struct Find<'a> { - art: &'a InstanceArt<'a>, - p: Point, - opt: HitOptions, - symbols: &'a SymbolBoxes<'a>, - } - impl Find<'_> { - fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> bool { - self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, opt, self.symbols).is_some()) - } - fn skips_guide(&self, n: &Node) -> bool { - self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) - } - /// `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) -> bool { - if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { - return false; - } - let outline = outline || previews_off(n); - let opt = HitOptions { outline, ..self.opt }; - let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; - if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { - return self.hit(n, whole, opt); - } - let whole = self.art.whole(n, whole); - if n.clips() - && let Some(first) = children.first() - { - let Some(clip) = self.art.place_unclipped(first, whole) else { return false }; - if self.opt.clip_outlines && first.visible && !first.locked && !self.skips_guide(first) && clip_outline(&clip, self.p, opt).is_some() - { - path.push(0); - return true; - } - if !clip_contains(&clip, self.p, outline) { - return false; - } - } - for (i, c) in children.iter().enumerate().rev() { - // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. - if shaper_hides(n, i, None) { - continue; - } - let Ok(i) = u32::try_from(i) else { continue }; - path.push(i); - if self.find(c, path, whole, outline) { - return true; - } - path.pop(); - } - false - } - } let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; let outline = opt.outline || below_preview_off(doc, inst, opt.scope); - Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, outline).then_some(path) + let find = FindPart { art: &art, p, opt, symbols: &symbols, whole_instance: false }; + find.find(&art.art, &mut path, None, outline).map(|_| path) +} + +/// The walk of a symbol instance's placed art that [`symbol_part_at`] and [`instance_art_hit`] make. +struct FindPart<'a> { + art: &'a InstanceArt<'a>, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + /// For [`instance_art_hit`]: locked and template objects of the art count, as the canvas draws + /// them, and clipping paths are not picked by their outline. + whole_instance: bool, +} + +impl FindPart<'_> { + 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)) + } + + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + + /// 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) { + return None; + } + let outline = outline || previews_off(n); + let opt = HitOptions { outline, ..self.opt }; + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole, opt); + } + let whole = self.art.whole(n, whole); + if n.clips() + && let Some(first) = children.first() + { + let clip = self.art.place_unclipped(first, whole)?; + if !self.whole_instance + && self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && let Some(kind) = clip_outline(&clip, self.p, opt) + { + path.push(0); + return Some(kind); + } + if !clip_contains(&clip, self.p, outline) { + return None; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if let Some(kind) = self.find(c, path, whole, outline) { + return Some(kind); + } + path.pop(); + } + None + } +} + +/// What a click at `p` hits of symbol instance `n`'s art where the canvas draws it (`None` in +/// 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`]). +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() { + return None; + } + let art = InstanceArt::of(symbols.document(), n)?; + let opt = HitOptions { clip_outlines: false, ..opt }; + FindPart { art: &art, p, opt, symbols, whole_instance: true }.find(&art.art, &mut vec![], None, false) } /// Is `n` below a layer whose Preview is off, so that the canvas draws it in outline? As for clicks @@ -1435,7 +1473,8 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured -/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a +/// by their art ([`Node::geometric_bounds_with`]), and outside that box, except in outline, they are +/// touched where the live geometry effects of a clipping path in their art show it. The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then /// only within that region's bounds. In isolation mode the clipping mask of the isolated container, /// or of the parent of the single object isolated, does not limit the marquee, as it does not limit @@ -1573,14 +1612,33 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh } NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds, outline)), NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds, outline)), - // A box with no width or no height (an instance of a symbol that draws a straight - // line) is touched where the marquee crosses it. - _ => n.geometric_bounds_with(s).is_some_and(|b| { - let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; - b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses - }), + // A symbol instance is touched by its box, and outside it where the live geometry + // effects of a clipping path in its art show the art. + NodeKind::SymbolInstance { .. } => box_touches(n, s, r) || !outline && instance_art_touches(n, s, r, whole_compounds), + _ => box_touches(n, s, r), } } + /// Does the marquee `r` touch the box of `n`? A box with no width or no height (an instance of + /// a symbol that draws a straight line) is touched where the marquee crosses it. + fn box_touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool { + n.geometric_bounds_with(s).is_some_and(|b| { + let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; + b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses + }) + } + /// 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. + 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 Some(art) = InstanceArt::of(s.document(), n) else { return false }; + 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) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index dde7365424..ffb32d851d 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1852,6 +1852,94 @@ fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { assert!(symbol_part_outline(&d, n, &[0, 1]).unwrap().elements().is_empty(), "nothing is drawn, so nothing is outlined"); } +/// A symbol instance whose art holds a clip group or a clipping layer with a live effect that moves +/// its clipping path is clicked, and touched by a marquee, where the moved region shows the art past +/// the art's box: inside a group, nested in another symbol's art, moved and scaled. In Outline view, +/// below a layer whose Preview is off, where the art is hidden and where no region is left, only the +/// box counts. +#[test] +fn a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art() { + use vectorcraft_geom::Affine; + set_clip_region(moved_clip_region); + // `effect` on the clipping path of a clip group (a clipping layer with `layer`), whose art is + // shown when `shown`; `xf` places the instance, which with `nest` shows a symbol whose art holds + // an instance of the clipped symbol. The instance is in a group: the group, the instance. + let build = |effect: &str, xf: Affine, nest: bool, shown: bool, layer: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + let (mut mask, mut art) = half_clipped(&mut d); + mask.appearance.effects.push(crate::Effect { id: effect.into(), ..test_move() }); + art.visible = shown; + let mut clip = if layer { + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer + } else { + 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) }); + let mut symbol = "Clipped"; + if nest { + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: Affine::IDENTITY }); + let root = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(root) }); + symbol = "Outer"; + } + let (g, inst) = (d.alloc_id(), d.alloc_id()); + let instance = Node::new(inst, NodeKind::SymbolInstance { symbol: symbol.into(), xf }); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(instance)])).unwrap(); + (d, g, inst) + }; + let opt = HitOptions::default(); + let outline = HitOptions { outline: true, ..opt }; + let at = |d: &Document, x: f64, y: f64, opt: HitOptions| hit_test(d, Point::new(x, y), opt).map(|h| h.leaf); + // Across the art's top edge in the moved region, and past the art's right end. + let (edge, past) = (Rect::new(140.0, -5.0, 160.0, 5.0), Rect::new(240.0, -5.0, 260.0, 5.0)); + for layer in [false, true] { + let (d, g, inst) = build("test.move", Affine::IDENTITY, false, true, layer); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "in the moved region, layer {layer}"); + assert_eq!(hit_test(&d, Point::new(150.0, 50.0), opt).map(|h| h.top_object(None)), Some(g)); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "in the box"); + assert_eq!(at(&d, 250.0, 50.0, opt), None, "past the art"); + assert_eq!(at(&d, 150.0, 150.0, opt), None, "below the art"); + assert_eq!(at(&d, 150.0, 50.0, outline), None, "Outline view draws the plain clipping path"); + assert_eq!(marquee(&d, edge, None, false), [g], "layer {layer}"); + assert_eq!(marquee(&d, edge, None, true), [inst], "layer {layer}"); + assert!(marquee(&d, past, None, false).is_empty()); + assert!(marquee_in_view(&d, edge, None, false, true).is_empty(), "Outline view"); + } + // Preview off for the layer the instance is on. + let (mut d, _, _) = build("test.move", Affine::IDENTITY, false, true, false); + if let NodeKind::Layer { preview, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *preview = false; + } + assert_eq!(at(&d, 150.0, 50.0, opt), None, "Preview off"); + assert!(marquee(&d, edge, None, false).is_empty(), "Preview off"); + // Moved, scaled (the region moves 100 pt from the placed clipping path) and nested. + let (d, _, inst) = build("test.move", Affine::translate((10.0, 20.0)), false, true, false); + assert_eq!(at(&d, 160.0, 70.0, opt), Some(inst), "moved"); + let (d, _, inst) = build("test.move", Affine::scale(2.0), false, true, false); + assert_eq!(at(&d, 250.0, 100.0, opt), Some(inst), "scaled"); + assert_eq!(at(&d, 350.0, 100.0, opt), None, "scaled, past the region"); + let (d, g, inst) = build("test.move", Affine::IDENTITY, true, true, false); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "nested"); + assert_eq!(marquee(&d, edge, None, false), [g], "nested"); + // Hidden art, and an effect that leaves no region: clicks find only the box. The marquee still + // touches the moved clipping path on the edge of its region, as in a clip group, unless no + // region is left. + for (effect, shown) in [("test.move", false), ("test.empty", true)] { + let (d, g, inst) = build(effect, Affine::IDENTITY, false, shown, false); + assert_eq!(at(&d, 150.0, 50.0, opt), None, "{effect}, shown {shown}"); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "{effect}, shown {shown}: in the box"); + let touched: &[NodeId] = if shown { &[] } else { &[g] }; + assert_eq!(marquee(&d, edge, None, false), touched, "{effect}, shown {shown}"); + } +} + /// 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. diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 1aad3a27cf..f7c8f87ded 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -228,7 +228,11 @@ impl Node { /// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice /// guides, that box also holds the art box placed by each cell's map /// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a - /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. + /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. An + /// instance whose art holds a clipping path with live geometry effects also takes in the region + /// they give it ([`crate::hit::set_clip_region`]): moved with the instance when it only moves its + /// art, otherwise placed by it and grown by as far as the region reaches past the art, times √2 + /// and the larger of 1 and the Frobenius norm of the transform's linear part. pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { symbols.quickly(|| self.reach(Some(symbols))) } @@ -268,6 +272,30 @@ impl Node { NodeKind::Layer { children, clip: true, .. } | NodeKind::Group { children, clip: true } => { vectorcraft_geom::union_opt(self.visual(symbols), children.first().and_then(|c| crate::hit::reshaped_clip_box(c))) } + // The live geometry effects of the clipping paths in the art can draw it past its box. + NodeKind::SymbolInstance { symbol, xf } => { + let Some((s, art, region)) = symbols.and_then(|s| { + let a = s.art(symbol)?; + Some((s, a, a.reshaped?)) + }) else { + return self.visual(symbols); + }; + let map = s.document().symbol_slice_map_of(symbol, art.geometric, *xf); + let placed = if moves_only(*xf) && map.is_none() { + xf.transform_rect_bbox(region) + } else { + // An effect can reach a fixed distance past the art or a distance in proportion + // to its size, as `SymbolBoxes::effects_reach` estimates it. + let g = art.geometric; + let all = region.union(g); + let past = [g.x0 - region.x0, region.x1 - g.x1, g.y0 - region.y0, region.y1 - g.y1].into_iter().fold(0.0_f64, f64::max); + let [a, b, c, d, _, _] = xf.as_coeffs(); + let grow = past * std::f64::consts::SQRT_2 * (a * a + b * b + c * c + d * d).sqrt().max(1.0); + map.map_or_else(|| xf.transform_rect_bbox(all), |m| m.placement_bounds(all)).inflate(grow, grow) + }; + let o = self.appearance.outset(); + vectorcraft_geom::union_opt(self.visual(symbols), self.projected(Some(placed)).map(|b| b.inflate(o, o))) + } _ => self.visual(symbols), } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 944accfe4b..99a65ae1f7 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -47,6 +47,11 @@ pub(crate) struct ArtBox { /// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art /// holds one at any depth. pub(crate) sliced: bool, + /// The box of the regions that the live geometry effects of the clipping paths in the art give + /// them ([`crate::hit::set_clip_region`]), and of the instances in the art whose art has such + /// regions, each measured by [`Node::reach_bounds_with`]. `None` when nothing in the art has + /// one. + pub(crate) reshaped: Option, } /// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`], @@ -562,7 +567,18 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced); } }); - Some(ArtBox { geometric, visual, reach, effects, brush, sliced }) + let mut reshaped = None; + if effects { + art.walk(&mut |n| { + let b = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if symbols.art(symbol).is_some_and(|a| a.reshaped.is_some()) => n.reach(Some(symbols)), + _ if n.clips() => n.children().and_then(|c| c.first()).and_then(|c| crate::hit::reshaped_clip_box(c)), + _ => None, + }; + reshaped = vectorcraft_geom::union_opt(reshaped, b.filter(finite)); + }); + } + Some(ArtBox { geometric, visual, reach, effects, brush, sliced, reshaped }) } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..525cac27e0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -645,6 +645,7 @@ since 2026-10-01. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. +- **Symbol instances are selected where their art draws past their box:** a click or a marquee selects a symbol instance where a clipping path in its art, moved or reshaped by its live geometry effects, shows the art past the instance's box, as it selects a group of the same art; a click also selects it where a stroke in its art paints past the box ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects.html)). The rest is VectorCraft's own rule: inside the box the instance is selected as before; in Outline view and below a layer whose Preview is off only the box counts; hidden art and the area a raster effect paints are left out. This covers instances in groups, nested in other symbols' art, moved and scaled. Regression test: `a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art` in `crates/doc/src/hit/tests.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From a4df0015c0545e79946f93e9b381b0c17e90ab25 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 19:35:07 -0800 Subject: [PATCH 2/2] Selection: test that a symbol instance is hit on its art's stroke past its box A click on a stroke of a symbol's art where it paints past the instance's box selects the instance; past the stroke, and in Outline view, it does not. The progress line names the new test. --- crates/doc/src/hit/tests.rs | 21 +++++++++++++++++++++ docs/target-app-parity.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index ffb32d851d..1c817f2bf1 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1940,6 +1940,27 @@ fn a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art } } +/// A click on the stroke of a symbol's art where it paints past the instance's box selects the +/// instance; past the stroke, and in Outline view, it does not. +#[test] +fn a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // A 20 pt stroke reaches 10 pt past the 0..100 square. + let look = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0); + let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look); + let art = Node::group(d.alloc_id(), vec![Arc::new(square)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Stroked".into(), art: Arc::new(art) }); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::IDENTITY; + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Stroked".into(), xf })).unwrap(); + let at = |x: f64, opt: HitOptions| hit_test(&d, Point::new(x, 50.0), opt).map(|h| h.leaf); + assert_eq!(at(108.0, HitOptions::default()), Some(inst), "on the stroke past the box"); + assert_eq!(at(50.0, HitOptions::default()), Some(inst), "in the box"); + assert_eq!(at(116.0, HitOptions::default()), None, "past the stroke"); + assert_eq!(at(108.0, HitOptions { outline: true, ..HitOptions::default() }), None, "Outline view"); +} + /// 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b3735636d..db2a0fcea8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -646,7 +646,7 @@ since 2026-10-01. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. -- **Symbol instances are selected where their art draws past their box:** a click or a marquee selects a symbol instance where a clipping path in its art, moved or reshaped by its live geometry effects, shows the art past the instance's box, as it selects a group of the same art; a click also selects it where a stroke in its art paints past the box ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects.html)). The rest is VectorCraft's own rule: inside the box the instance is selected as before; in Outline view and below a layer whose Preview is off only the box counts; hidden art and the area a raster effect paints are left out. This covers instances in groups, nested in other symbols' art, moved and scaled. Regression test: `a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art` in `crates/doc/src/hit/tests.rs`. +- **Symbol instances are selected where their art draws past their box:** a click or a marquee selects a symbol instance where a clipping path in its art, moved or reshaped by its live geometry effects, shows the art past the instance's box, as it selects a group of the same art; a click also selects it where a stroke in its art paints past the box ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects.html)). The rest is VectorCraft's own rule: inside the box the instance is selected as before; in Outline view and below a layer whose Preview is off only the box counts; hidden art and the area a raster effect paints are left out. This covers instances in groups, nested in other symbols' art, moved and scaled. Regression tests: `a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art` and `a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box` in `crates/doc/src/hit/tests.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.