Shaper: a marquee does not select a Shaper Group by the original shape of an erased face

A Shaper Group keeps its original shapes as a transparent first child.
Clicks skip them outside Construction mode, but a marquee did not: one
drawn over the outline of an original shape whose face the group had
erased selected the group, where nothing is shown. Group Selection's E
marquee selected an original shape that lay inside it, and Direct
Selection's marquee and clicks selected the anchor points of the
original shapes.

The marquee now skips a Shaper Group's original shapes with the same
check clicks use, which moves into shaper_hides, and Direct Selection's
walk over the art it can pick skips them too. In Construction mode, with
the original shapes isolated, the marquee still selects them and Direct
Selection still selects their anchor points.

Docs: docs/shaper.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-18 01:21:28 +07:00
committed by bflatastic
parent c44fb242aa
commit 8fbd515d6c
5 changed files with 133 additions and 20 deletions
+67 -16
View File
@@ -311,8 +311,7 @@ fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
/// isn't shown, or when `p` is outside its reach.
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
if shaper_hides(parent, i, opt.scope) {
return false;
}
// Read-only samples can read locked/template art; hidden art never participates.
@@ -321,10 +320,22 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_isolated(c)
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
/// Is the first child of `n` the isolated container `scope`?
fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == scope)
}
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
/// isolated, the art the group shows.
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
}
fn hit_children(
parent: &Node,
p: Point,
@@ -493,7 +504,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
Some(s) => match (s.children(), marquee_in(s, r)) {
(Some(c), Some(r)) => c.iter().map(|c| (c, r)).collect(),
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
(Some(_), None) => vec![],
// A single object isolated: only it.
(None, _) => doc
@@ -529,13 +540,14 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
out
}
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
/// ([`shaper_hides`]).
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
marquee_in(n, r).is_some_and(|r| children.iter().any(|c| c.visible && touches(c, s, r)))
}
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r)),
NodeKind::Layer { children, .. } | NodeKind::Group { 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)),
// 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| {
@@ -544,18 +556,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}),
}
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
let Some(r) = marquee_in(n, r) else { return };
for c in children {
if c.visible && !c.locked {
collect_leaves(c, s, r, out);
for (i, c) in children.iter().enumerate() {
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
collect_leaves(c, s, r, scope, out);
}
}
}
_ => {
if touches(n, s, r) {
if touches(n, s, r, scope) {
out.push(n.id);
}
}
@@ -567,8 +579,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
continue;
}
if leaves {
collect_leaves(t, &symbols, r, &mut out);
} else if touches(t, &symbols, r) {
collect_leaves(t, &symbols, r, scope, &mut out);
} else if touches(t, &symbols, r, scope) {
out.push(t.id);
}
}
@@ -771,6 +783,45 @@ mod tests {
}
}
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
/// the original shape of a face the group erased it selects nothing, with Group Selection's
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
/// (Construction mode) it takes the original shape it reaches.
#[test]
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
let b_id = b.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
sources.name = Some(crate::shaper::SOURCES.into());
sources.opacity = 0.0;
let sources_id = sources.id;
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
let face_id = face.id;
let g = d.alloc_id();
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
group.extras_mut().shaper = Some(Box::default());
d.insert(Some(l), 0, group).unwrap();
// This marquee crosses only the right edge of the second square, whose face is erased.
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
assert!(marquee(&d, erased, None, false).is_empty());
assert!(marquee(&d, erased, None, true).is_empty());
assert!(marquee(&d, erased, Some(g), false).is_empty());
// This marquee crosses the shown face's left edge.
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
// Construction mode: the original shapes are the objects.
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_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]
+51
View File
@@ -387,6 +387,57 @@ fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() {
assert_eq!(s.doc().unwrap().selection.objects, vec![line]);
}
/// The Selection tool's marquee, Group Selection's E marquee and Direct Selection's marquee and
/// clicks reach a Shaper Group only by the art it shows: none of them selects the original shape of a
/// face the group erased, or its anchor points. In Construction mode the Selection marquee selects
/// them, and Direct Selection's marquee and clicks select their anchor points.
#[test]
fn shaper_marquees_leave_the_original_shape_of_an_erased_face() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]);
assert_eq!(fill(&s, g, 125.0, 50.0), None, "the face is erased");
let v = ViewInfo { smart_guides: false, ..Default::default() };
let drag = |s: &mut Session, tool: &str, from: [f64; 2], to: [f64; 2]| {
s.select_tool(tool, v).unwrap();
for (kind, [x, y]) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
s.doc().unwrap().selection.clone()
};
// Each marquee crosses the second rectangle's right side, where nothing is shown.
assert!(drag(&mut s, "selection", [140.0, 40.0], [160.0, 60.0]).objects.is_empty());
let sel = drag(&mut s, "directSelection", [140.0, -10.0], [160.0, 10.0]);
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
// Group Selection's E marquee around the whole of that rectangle leaves it out.
s.select_tool("groupSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 155.0, -10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 45.0, 110.0), v).unwrap();
s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 45.0, 110.0), v).unwrap();
let sel = s.doc().unwrap().selection.clone();
assert!(!sel.objects.contains(&b) && !sel.anchors.contains_key(&b), "{sel:?}");
// A Direct Selection click on that rectangle's corner selects nothing either.
s.execute("select.none", &json!({})).unwrap();
let sel = drag(&mut s, "directSelection", [150.0, 0.0], [150.0, 0.0]);
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
// Over the shown face's left side the marquees take the group and the face's anchors.
assert_eq!(drag(&mut s, "selection", [-10.0, 40.0], [10.0, 60.0]).objects, vec![g]);
let sources = node(&s, g).children().unwrap()[0].id;
let sel = drag(&mut s, "directSelection", [-10.0, -10.0], [10.0, 10.0]);
assert!(!sel.anchors.is_empty());
assert!(sel.anchors.keys().all(|id| s.doc().unwrap().doc.parent_of(*id) == Some(g)), "{sel:?}");
// Construction mode: the original shapes are what the marquees select. These marquees start
// outside them and cross the second rectangle's top right corner.
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(sources));
assert_eq!(drag(&mut s, "selection", [160.0, -10.0], [140.0, 10.0]).objects, vec![b]);
assert!(drag(&mut s, "directSelection", [160.0, -10.0], [140.0, 10.0]).anchors.contains_key(&b));
s.execute("select.none", &json!({})).unwrap();
assert!(drag(&mut s, "directSelection", [150.0, 0.0], [150.0, 0.0]).anchors.contains_key(&b));
}
#[test]
fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() {
for exits in [false, true] {
+13 -3
View File
@@ -197,14 +197,24 @@ fn in_scope(doc: &vectorcraft_doc::Document, scope: Option<NodeId>, id: NodeId)
}
/// Visit the nodes Direct Selection may pick. In isolation mode that is the isolated object and
/// its contents; art around it is locked and the walk does not reach it.
/// its contents; art around it is locked and the walk does not reach it. A Shaper Group's original
/// shapes, its first child, are left out unless they are the isolated object (Construction mode),
/// as clicks leave them out.
fn walk_pickable<'a>(doc: &'a vectorcraft_doc::Document, scope: Option<NodeId>, mut f: impl FnMut(&'a Node)) {
fn walk<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) {
walk(c, f);
}
}
if let Some(id) = scope
&& let Some(n) = doc.node(id)
{
n.walk(&mut f);
walk(n, &mut f);
} else if scope.is_none() {
doc.walk(f);
for l in &doc.layers {
walk(l, &mut f);
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ Scribble over overlapping paths to make an editable Shaper Group:
- End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there.
- Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately.
Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result.
Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result.
While the group or one of its faces is selected (Face Selection mode) and the Shaper or Selection tool is active, an arrow widget sits beside the middle of the right side of the original shapes’ bounds. Click it to enter Construction mode: the original shapes are isolated and nothing is selected, and a click with Shaper selects the group’s original shape under the pointer. Click the widget again to leave Construction mode with the group selected. The arrow points right in Face Selection mode and left in Construction mode.
+1
View File
@@ -492,6 +492,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
With the Selection or Group Selection tool, pressing E while dragging a marquee makes the marquee select only the objects that lie entirely inside it. Pressing E again switches the marquee back to its default selection.
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 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.