Selection: a selected object's masked art is reached the way a click goes

The check of a selected object's masked art looked at its own place
only. A click goes down from the layer (or the isolated container) and
never reaches a Shaper group's original shapes outside Construction
mode, however deep the object lies in them; and below a layer whose
Preview is off it hits art in outline. A press inside such a path's
fill, or on a path in a Shaper group's hidden shapes, started a move.

The way down to the selected object is now checked as a click goes:
a Shaper group's part a click can't reach on it rules the object out,
and a Preview-off layer on it (below the isolated container) makes the
object count by its outline.

Tests also cover Shift with Direct and Group Selection (a marquee) and
an object a click hits over the masked art (it is selected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjfPR4a1jPek7r3nyFFNYv
This commit is contained in:
nevarem
2026-10-29 18:46:47 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent f793547a7f
commit 87fc61df79
4 changed files with 84 additions and 1 deletions
+25 -1
View File
@@ -116,7 +116,9 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
/// art instead of starting a marquee: an image in a mask, picked in the Layers panel, can be
/// dragged by its hidden part, whose frame shows while it is selected. An object that a click
/// can't hit anyway doesn't count: hidden, locked, a template, a guide while guides are locked
/// or hidden, outside the isolated container (`opt.scope`) or on a locked artboard.
/// or hidden, in a Shaper group's part a click can't reach, outside the isolated container
/// (`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<NodeId> {
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
@@ -130,6 +132,8 @@ pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &
{
return false;
}
// How a click reaches it, if one does.
let Some(opt) = path_options(&nodes, &chain, opt) else { return false };
// Hidden by a mask: one of the containers around it clips, and not to `p`.
let masked = nodes
.iter()
@@ -146,6 +150,26 @@ pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &
})
}
/// The options a click goes down `nodes` (an object's ancestry, `chain`, the layer first) with, as
/// [`hit_test`] goes: from the isolated container (`opt.scope`), else from the layer, in outline
/// below a layer whose Preview is off. `None` when a Shaper group's part it can't reach lies on
/// the way ([`shaper_hides`]).
fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option<HitOptions> {
let scope_at = opt.scope.and_then(|s| chain.iter().position(|a| *a == s));
let child_index = |parent: &Node, child: &Node| parent.children().and_then(|c| c.iter().position(|c| c.id == child.id));
let hidden = nodes.windows(2).skip(scope_at.unwrap_or(0)).any(|w| match w {
[parent, child] => child_index(parent, child).is_some_and(|i| shaper_hides(parent, i, opt.scope)),
_ => false,
});
if hidden {
return None;
}
// The isolated container's own Preview doesn't count: a click starts inside it.
let below = scope_at.map_or(0, |i| i + 1);
let outline = nodes.iter().skip(below).any(|n| matches!(n.kind, NodeKind::Layer { preview: false, .. }));
Some(if outline { HitOptions { outline: true, ..opt } } else { opt })
}
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
+45
View File
@@ -174,6 +174,51 @@ fn a_selected_guide_hidden_by_a_mask_counts_only_while_guides_can_be_clicked() {
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { skip_guides: true, ..opt }, &[guide_id]), None);
}
/// A selected path in a Shaper group's original shapes, where an outer mask hides it, counts only
/// while those shapes can be clicked (Construction mode, with them isolated), however deep it
/// lies in them.
#[test]
fn a_selected_path_in_a_shaper_groups_hidden_shapes_counts_only_in_construction_mode() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// The outer clip group masks to 0..100; the path, 100..200, lies in the Shaper group's
// original shapes (its first child), under (150, 50).
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let path = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
let path_id = path.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(path)]);
sources.name = Some(crate::shaper::SOURCES.into());
let sources_id = sources.id;
let face = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
let mut shaper = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
shaper.extras_mut().shaper = Some(Box::default());
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(shaper)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[path_id]), None);
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(sources_id), ..opt }, &[path_id]), Some(path_id));
}
/// On a layer whose Preview is off, a click hits art in outline: a selected object's hidden art
/// counts on its outline, not inside its fill.
#[test]
fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(l).unwrap().kind {
*preview = false;
}
let opt = HitOptions::default();
assert_eq!(selected_hidden_at(&d, Point::new(150.0, 50.0), opt, &[art_id]), None, "inside the fill");
assert_eq!(selected_hidden_at(&d, Point::new(200.0, 50.0), opt, &[art_id]), Some(art_id), "on the outline");
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
+7
View File
@@ -21,6 +21,13 @@ fn a_press_where_a_mask_hides_a_selected_object_drags_it() {
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0));
assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "{a:?} (group: {group})");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 390.0, 470.0));
// With Shift, a marquee.
let shift = Mods { shift: true, ..Mods::default() };
let mut t = DirectSelectionTool::new(group);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0).with_mods(shift));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0).with_mods(shift));
assert!(!a.iter().any(|a| matches!(a, Action::Begin(_))), "{a:?} (group: {group})");
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_))), "a marquee (group: {group})");
let c = cx(&d, &none, &p);
let mut t = DirectSelectionTool::new(group);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0));
+7
View File
@@ -117,6 +117,13 @@ fn a_press_where_a_mask_hides_a_selected_object_drags_it() {
let mut t = SelectionTool::default();
t.pointer(&c, &shift(PointerKind::Down, 370.0, 450.0));
assert!(!t.pointer(&c, &shift(PointerKind::Drag, 390.0, 470.0)).iter().any(|a| matches!(a, Action::Begin(_))));
// An object a click hits there wins: a press on a square over that point selects it.
let mut d = d;
let (front, l) = (d.alloc_id(), d.layers[0].id);
let square = vectorcraft_geom::shapes::rectangle(Rect::new(350.0, 430.0, 390.0, 470.0));
d.insert(Some(l), 2, vectorcraft_doc::Node::path(front, square, vectorcraft_doc::Appearance::default_art())).unwrap();
let a = SelectionTool::default().pointer(&cx(&d, &picked, &p), &ev(PointerKind::Down, 370.0, 450.0));
assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([front.0]))), "{a:?}");
}
/// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands