diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index eb6dabfd5..8e16f1ed0 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -541,13 +541,16 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> out } /// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip - /// ([`shaper_hides`]). + /// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched + /// by the copies it draws, as clicks hit them. Objects of every other kind except paths, + /// compound paths, layers and groups are touched by their box. fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option) -> bool { match &n.kind { NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => marquee_in(n, r) + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r) .is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))), NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)), + NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)), // 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| { @@ -558,7 +561,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> } fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, out: &mut Vec) { match &n.kind { - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { let Some(r) = marquee_in(n, r) else { return }; for (i, c) in children.iter().enumerate() { if c.visible && !c.locked && !shaper_hides(n, i, scope) { @@ -822,6 +825,92 @@ mod tests { assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]); } + /// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and + /// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing. + /// Across a member's edge it selects the group, and with the leaves that Direct and Group + /// Selection take, or in isolation mode, it selects the member. + #[test] + fn a_marquee_meets_an_intertwine_group_by_its_members() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // Two squares, 0..100 and 150..250, with a gap between them inside the group's box. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0)))); + let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art()); + let (a_id, g) = (a.id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap(); + let gap = Rect::new(110.0, 40.0, 140.0, 60.0); + assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses"); + assert!(marquee(&d, gap, None, false).is_empty()); + assert!(marquee(&d, gap, None, true).is_empty()); + assert!(marquee(&d, gap, Some(g), false).is_empty()); + assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill"); + // This marquee crosses the first square's left edge. + let edge = Rect::new(-10.0, 40.0, 10.0, 60.0); + assert_eq!(marquee(&d, edge, None, false), vec![g]); + assert_eq!(marquee(&d, edge, None, true), vec![a_id]); + assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]); + } + + /// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a + /// grid repeat it selects nothing, and across a copy's edge it takes the repeat. + #[test] + fn a_marquee_meets_a_repeat_by_its_copies() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + // Copies at 0..20, 40..60 and 80..100. + let kind = crate::pattern::RepeatKind::Grid { + h_spacing: 20.0, + v_spacing: 20.0, + rows: 1, + cols: 3, + grid_type: crate::pattern::TileType::Grid, + flip_rows: false, + flip_cols: false, + }; + let r = d.alloc_id(); + d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap(); + let gap = Rect::new(25.0, 5.0, 35.0, 15.0); + assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses"); + assert!(marquee(&d, gap, None, false).is_empty()); + assert!(marquee(&d, gap, None, true).is_empty()); + assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge"); + assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]); + } + + /// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there + /// too: inside the box, away from every key object's and the content's outline, it takes them. + #[test] + fn a_marquee_meets_blends_and_envelopes_by_their_box() { + let mut d = Document::new(300.0, 300.0); + let l = d.layers[0].id; + let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())); + let blend = d.alloc_id(); + let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)]; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap(); + let envelope = d.alloc_id(); + let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))]; + let e = Node::new( + envelope, + NodeKind::Envelope { + content, + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + fidelity: 100.0, + editing: false, + options: Default::default(), + frame: Box::new(vectorcraft_geom::Affine::IDENTITY), + }, + ); + d.insert(Some(l), 1, e).unwrap(); + for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] { + assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id)); + assert_eq!(marquee(&d, r, None, false), vec![id]); + assert_eq!(marquee(&d, r, None, true), vec![id]); + } + } + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test] diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 2030f9276..90afca003 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1450,6 +1450,35 @@ mod tests { assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": rect.0, "anchors": [[0, 1]]}], "mode": "set"}))]); } + /// A marquee across a member of an Intertwine group selects the member's anchors, as in a + /// plain group, and not the Intertwine group whole. + #[test] + fn marquee_selects_the_anchors_of_intertwine_members() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (a, b, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id, x| { + std::sync::Arc::new(vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), + vectorcraft_doc::Appearance::default_art(), + )) + }; + let intertwine = vectorcraft_doc::Node::new(g, vectorcraft_doc::NodeKind::Intertwine { children: vec![path(a, 100.0), path(b, 300.0)] }); + d.insert(Some(l), 0, intertwine).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + // Around the first member's top-right corner. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + let items = json!([{"id": a.0, "anchors": [[0, 1]]}]); + assert_eq!(actions, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))], "group: {group}"); + } + } + /// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too. #[test] fn shift_marquee_toggles_anchors() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1884db250..ba29c1816 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -504,6 +504,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Lasso picks anchor points only on art a click can pick, and the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchor points and segments only on the selected paths and on art a click can pick: not on a Shaper Group's original shapes outside Construction mode, and in isolation mode only inside the isolated group or object. Smart Guides and Snap to Point do not snap to a Shaper Group's original shapes, and the reference point of the transform tools snaps to one only when it is selected, or under the pointer in Construction mode. A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345). A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points. + A marquee selects an Intertwine group only when it reaches one of the group's members, as for a plain group, and it selects a repeat only when it reaches one of the copies the repeat draws. Direct and Group Selection's marquees select the anchor points of an Intertwine group's members, not the group, and Group Selection's E marquee selects the members inside it. A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to. With two or more objects selected and no key object, Align to Key Object makes the topmost of them the key, whatever order they were selected in, and the Align panel, the Control bar and the Properties panel align to it. Align To keeps its Selection or Artboard choice and shows it again once the key is cleared. Object › Align aligns objects as the align buttons do: it aligns one object to the active artboard whatever Align To is set to, and otherwise it aligns to the key object while there is one, else to the Align To choice, which is saved with the preferences (`ui.inspect` → `ui.align_to`). With direct-selected anchor points, Object › Align aligns the points to their own bounds. While one object is selected, Align To shows Artboard.