G1: selection tools take a clipping path on its outline

A clip group answered a press only strictly inside its clipping path,
and there the art above the path came first in the hit test, so where
the clipped art extends past the mask's edge the clipping path's
segments could not be reached on the canvas.

With the new HitOptions::clip_outlines, which the Selection, Direct
Selection and Group Selection tools set
(ToolContext::selection_hit_options), a press within the selection
tolerance of a clipping path's outline, inside or just outside it,
hits the clipping path (HitKind::Outline) before the art it clips:
Direct Selection takes its segments and the Selection tool the clip
group. It applies to a clip group's clipping path and to a layer's
clipping mask, and only to one a click may hit (may_hit: visible,
editable, within reach). Inside the mask the art is hit as before.

The other tools, which act on what is drawn (the eyedropper, type,
gradients...), leave the option off, and hit_test_visible never
applies it, so they still take the art at the mask's edge rather
than the mask's empty appearance. Select Behind's one-pass walk takes
a layer mask's outline first too, so it lists what the repeated hit
test finds. Object Highlighting and a right-click's selection in the
canvas (selection_hit_at) take what those tools take; dropping a
swatch on art still hits what is drawn.

The reference app reshapes a clipping path with Direct Selection:
https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html

Tests in doc: a_clipping_path_is_hit_on_its_outline_before_its_art,
only_the_selection_tools_hit_a_clipping_path_on_its_outline and
select_behind_takes_a_layer_masks_outline_first; the random Select
Behind test now clips layers and sublayers and sets clip_outlines,
and fails without the Select Behind change. In tools,
a_press_on_a_clipping_path_takes_its_segment_over_the_art fails
without the change; select::a_press_on_a_masks_edge_selects_its_clip_group
fails when the Selection tool uses the plain options; the eyedropper
copies the art on the edge. Recorded in docs/ui-parity.md,
docs/gaps.md and docs/target-app-parity.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Drew Dibble
2026-10-21 15:42:37 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 923c49eda0
commit 061e905724
9 changed files with 253 additions and 27 deletions
+132 -7
View File
@@ -66,11 +66,16 @@ pub struct HitOptions {
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
/// on or the guides are hidden.
pub skip_guides: bool,
/// The selection tools: a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips, so that the mask can be reached where the art covers
/// its edge. Off for the tools that act on what is drawn (sampling, type, gradients…);
/// [`hit_test_visible`] never does it.
pub clip_outlines: bool,
}
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None }
}
}
@@ -143,8 +148,16 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
/// measured by their art ([`Node::geometric_bounds_with`]).
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
let Some(children) = layer.children() else { return true };
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return true;
if layer.clips()
&& let Some(clip) = children.first()
{
// As the hit test does: the clipping mask's outline first, then only inside the mask.
if clip_outline_hit(layer, clip, p, opt, symbols, true).is_some() && seen.insert(clip.id) {
tops.push(clip.id);
}
if !clip_contains(clip, p) {
return true;
}
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
for (i, c) in children.iter().enumerate().rev() {
@@ -363,6 +376,31 @@ fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
}
/// A press on the outline of clipping path `clip` (a path or a compound path; type clips by its
/// glyphs, which this crate cannot outline), within the tolerance.
fn clip_outline(clip: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
let bp = match &clip.kind {
NodeKind::Path { path, .. } => path.to_bezpath(),
NodeKind::Compound { .. } => clip.stroke_path()?,
_ => return None,
};
stroke_contains(&bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
/// For the selection tools ([`HitOptions::clip_outlines`]), a press on the outline of `clip`, the
/// clipping path of `parent` (a clip group or a layer with a clipping mask), within the tolerance:
/// it is hit before the art it clips. There Direct Selection takes its segments, and the
/// Selection tool the clip group. The reference app reshapes a clipping path with Direct Selection
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>);
/// without this, art drawn over the path's edge is hit first and its segments can't be dragged.
/// The clipping path must be one a click may hit ([`may_hit`]: visible, editable, within reach).
fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> Option<HitKind> {
if !opt.clip_outlines || !editable_only || !may_hit(parent, 0, clip, p, opt, symbols, editable_only) {
return None;
}
clip_outline(clip, p, opt)
}
fn hit_children(
parent: &Node,
p: Point,
@@ -373,12 +411,17 @@ fn hit_children(
editable_only: bool,
) -> Option<Hit> {
let children = parent.children()?;
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
if parent.clips()
&& let Some(clip) = children.first()
&& !clip_contains(clip, p)
{
return None;
if let Some(kind) = clip_outline_hit(parent, clip, p, opt, symbols, editable_only).filter(|_| !skip(clip.id)) {
chain.push(clip.id);
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
}
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
if !clip_contains(clip, p) {
return None;
}
}
for i in hit_order(parent, children, p) {
let Some(c) = children.get(i) else { continue };
@@ -949,6 +992,81 @@ mod tests {
}
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art());
if layer {
d.insert(Some(l), 0, path).unwrap();
d.insert(Some(l), 1, picture).unwrap();
d.node_mut(l).unwrap().set_clips(true);
(d, l, clip, art)
} else {
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap();
(d, g, clip, art)
}
}
const SELECTING: HitOptions =
HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None };
/// For the selection tools a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips and just outside it too; inside, the art is hit.
#[test]
fn a_clipping_path_is_hit_on_its_outline_before_its_art() {
let (d, g, clip, art) = masked_square(false);
for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] {
let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap();
assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}");
}
let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap();
assert_eq!((h.leaf, h.top_object(None)), (art, g));
// Beyond the tolerance outside it, the clipped art is not there to hit.
assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none());
}
/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling
/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints
/// nothing there.
#[test]
fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() {
let (d, _, clip, art) = masked_square(false);
let p = Point::new(138.5, 90.0);
assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art));
assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
// A hidden or locked clipping path is passed over.
for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] {
let mut d = d.clone();
change(d.node_mut(clip).unwrap());
assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
}
}
/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the
/// repeated hit test finds: the mask on and just outside its edge, then the art inside it;
/// well inside, the art alone (the mask paints nothing there).
#[test]
fn select_behind_takes_a_layer_masks_outline_first() {
let (d, _, clip, art) = masked_square(true);
for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] {
let p = Point::new(x, 90.0);
assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}");
assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}");
}
// Without clip_outlines, as before.
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
}
/// 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]
@@ -1243,9 +1361,16 @@ world",
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
// A clipping mask on the layer, or on a sublayer, now and then.
let sublayers: Vec<NodeId> = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect();
for id in std::iter::once(layer).chain(sublayers) {
if next(3) == 0 {
d.node_mut(id).unwrap().set_clips(true);
}
}
for _ in 0..12 {
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
+29 -5
View File
@@ -437,7 +437,7 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
return out;
}
let Some(mut h) = hit_test(cx.doc, p, cx.hit_options()) else {
let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else {
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
return vec![];
};
@@ -486,7 +486,10 @@ impl Tool for DirectSelectionTool {
if crate::rulerguide::guide_at(cx, p).is_some() {
return vec![];
}
if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) {
if hit_test(cx.doc, p, cx.selection_hit_options())
.and_then(|h| cx.doc.node(h.leaf))
.is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
{
return vec![Action::SwitchTool("type".into())];
}
vec![]
@@ -573,7 +576,7 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
return out;
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) {
// A segment pick keeps its endpoints available for movement without selecting
// the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks
// the whole path (and Alt-drag copies it), as with Group Selection.
@@ -942,7 +945,7 @@ impl DirectSelectionTool {
if m.shift || self.guide.cursor(cx, p).is_some() {
return None;
}
let h = hit_test(cx.doc, p, cx.hit_options())?;
let h = hit_test(cx.doc, p, cx.selection_hit_options())?;
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
let curved = matches!(h.kind, HitKind::Stroke | HitKind::Outline) && segment_at(cx, h.leaf, p).is_some_and(|s| s.curved);
(!whole && curved).then_some(Cursor::ReshapeSegment)
@@ -952,7 +955,7 @@ impl DirectSelectionTool {
/// The anchor within a press's reach of `p` ([`ToolContext::point_tol`]), of a selected path or the
/// path under `p`: what Highlight anchors on mouse over marks (a press there picks it).
fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> {
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf);
let under = hit_test(cx.doc, p, cx.selection_hit_options()).map(|h| h.leaf);
let tol = cx.point_tol();
cx.selection.objects.iter().copied().filter(|id| in_scope(cx.doc, cx.isolation, *id)).chain(under).find_map(|id| {
let path = cx.doc.node(id).and_then(editable_path)?;
@@ -1077,6 +1080,27 @@ mod tests {
assert_ne!(press(&zoomed, 102.6, 100.2), corner);
}
/// A press on a clip group's clipping path, where the art it clips lies over its edge, takes
/// the clipping path's segment, so the mask can be reshaped on the canvas; inside the mask the
/// art is taken, as before.
#[test]
fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() {
let (d, _, clip, art) = doc_with_masked_square();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let press = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y));
let names = |actions: &[Action], id: NodeId| {
actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(id.0) || v["ids"] == json!([id.0])))
};
// The middle of the right edge, on it and just inside it.
for x in [500.0, 498.5] {
let a = press(x, 450.0);
assert!(names(&a, clip), "at {x}: {a:?}");
}
let a = press(450.0, 450.0);
assert!(names(&a, art) && !names(&a, clip), "{a:?}");
}
/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected.
fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, _) = doc_with_rect();
+27
View File
@@ -413,8 +413,17 @@ impl ToolContext<'_> {
type_path_only: self.type_path_only,
scope: self.isolation,
skip_guides: !self.guides,
clip_outlines: false,
}
}
/// [`Self::hit_options`] for the selection tools (Selection, Direct Selection, Group
/// Selection): a clipping path is also hit on its outline ([`HitOptions::clip_outlines`]).
///
/// [`HitOptions::clip_outlines`]: vectorcraft_doc::hit::HitOptions::clip_outlines
pub fn selection_hit_options(&self) -> vectorcraft_doc::hit::HitOptions {
vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..self.hit_options() }
}
}
/// Visual feedback drawn by the UI in screen space (coordinates here are document space).
@@ -860,6 +869,24 @@ pub(crate) mod testutil {
(d, id)
}
/// [`doc_with_rect`] plus a clip group: a filled 200 × 200 square (350, 350)–(550, 550)
/// clipped to an unpainted rectangle (400, 400)–(500, 500), so the art lies over the mask's
/// edges. The group, the clipping path and the art.
pub fn doc_with_masked_square() -> (Document, NodeId, NodeId, NodeId) {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = Node::path(clip, shapes::rectangle(Rect::new(400.0, 400.0, 500.0, 500.0)), Default::default());
if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
let picture = Node::path(art, shapes::rectangle(Rect::new(350.0, 350.0, 550.0, 550.0)), Appearance::default_art());
let group =
Node::new(g, vectorcraft_doc::NodeKind::Group { children: vec![std::sync::Arc::new(path), std::sync::Arc::new(picture)], clip: true });
d.insert(Some(l), 1, group).unwrap();
(d, g, clip, art)
}
/// [`doc_with_rect`] plus 120 × 40 area type at (300, 300).
pub fn doc_with_area_type() -> (Document, NodeId) {
let (mut d, _) = doc_with_rect();
+24 -8
View File
@@ -321,7 +321,7 @@ impl SelectionTool {
return None;
}
let reach = cx.tol(HOVER_REACH);
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation));
let under = hit_test(cx.doc, p, cx.selection_hit_options()).map(|h| h.top_object(cx.isolation));
let nearest = |points: &mut dyn Iterator<Item = Point>| {
points.filter(|q| q.distance(p) <= reach).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p)))
};
@@ -372,8 +372,9 @@ fn thread_params(cx: &ToolContext, from: NodeId, start: Point, end: Point) -> Va
let frame = |n: &vectorcraft_doc::Node| {
is_area_type(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed))
};
let to =
hit_test(cx.doc, start, cx.hit_options()).map(|h| h.top_object(cx.isolation)).filter(|id| *id != from && cx.doc.node(*id).is_some_and(frame));
let to = hit_test(cx.doc, start, cx.selection_hit_options())
.map(|h| h.top_object(cx.isolation))
.filter(|id| *id != from && cx.doc.node(*id).is_some_and(frame));
match to {
Some(to) => json!({ "from": from.0, "to": to.0 }),
None => json!({ "from": from.0, "at": [start.x, start.y] }),
@@ -488,7 +489,7 @@ impl Tool for SelectionTool {
if crate::rulerguide::guide_at(cx, p).is_some() {
return vec![];
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) {
let top = h.top_object(cx.isolation);
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
return vec![Action::SwitchTool("type".into())];
@@ -567,7 +568,7 @@ impl Tool for SelectionTool {
self.state = State::Moving { start: p, began: false, deselect: None, key: None };
return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))];
}
match hit_test(cx.doc, p, cx.hit_options()) {
match hit_test(cx.doc, p, cx.selection_hit_options()) {
Some(h) => {
let top = h.top_object(cx.isolation);
let mut out = vec![];
@@ -789,7 +790,7 @@ impl Tool for SelectionTool {
if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) {
return Cursor::ArrowHollow;
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) {
if cx.selection.contains(h.top_object(cx.isolation)) || m.alt {
return Cursor::Move;
}
@@ -802,7 +803,7 @@ impl Tool for SelectionTool {
/// Select Behind: of the objects under `p` (topmost first), the one below the lowest selected
/// one, back to the topmost after the bottom one; the topmost when none is selected.
fn object_behind(cx: &ToolContext, p: Point) -> Option<NodeId> {
let stack = objects_at(cx.doc, p, cx.hit_options(), cx.isolation);
let stack = objects_at(cx.doc, p, cx.selection_hit_options(), cx.isolation);
let next = stack.iter().rposition(|id| cx.selection.contains(*id)).map_or(0, |i| i + 1);
stack.get(next).or(stack.first()).copied()
}
@@ -844,7 +845,7 @@ pub(crate) fn release_guide_at(cx: &ToolContext, p: Point, m: Mods) -> Option<Ac
if let Some(i) = crate::rulerguide::guide_at(cx, p) {
return Some(Action::Exec("view.guides.release".into(), json!({ "index": i })));
}
let leaf = hit_test(cx.doc, p, cx.hit_options())?.leaf;
let leaf = hit_test(cx.doc, p, cx.selection_hit_options())?.leaf;
let guide = cx.doc.node(leaf).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Path { guide: true, .. }));
guide.then(|| Action::Exec("view.guides.release".into(), json!({ "ids": [leaf.0] })))
}
@@ -873,6 +874,21 @@ mod tests {
PointerEvent::new(kind, x, y)
}
/// A Selection tool press on a clip group's mask edge, where the art it clips lies over it,
/// selects the clip group, as a press on the art inside does.
#[test]
fn a_press_on_a_masks_edge_selects_its_clip_group() {
let (d, g, _, _) = doc_with_masked_square();
let (s, p) = (Selection::default(), paint());
for x in [500.0, 501.5, 450.0] {
let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, x, 450.0));
assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([g.0]))), "at {x}: {a:?}");
}
// Just outside the mask, beyond the tolerance, the clipped-away art isn't there.
let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 520.0, 450.0));
assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}");
}
/// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands
/// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it.
#[test]
+21
View File
@@ -146,6 +146,27 @@ mod tests {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
}
/// On a clip group's mask edge, where the art it clips lies over it, the eyedropper copies the
/// art's appearance, not the unpainted clipping path's (the selection tools take the path there).
#[test]
fn on_a_masks_edge_the_eyedropper_copies_the_art() {
let (d, _, _, art) = doc_with_masked_square();
let (s, p) = (Selection::default(), paint());
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 498.5, 450.0));
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": art.0}))]);
}
/// The tools that act on what is drawn hit with options that don't take a clipping path on
/// its outline; only the selection tools' do.
#[test]
fn only_the_selection_options_take_clipping_paths_on_their_outline() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let cx = cx(&d, &s, &p);
assert!(!cx.hit_options().clip_outlines);
assert!(cx.selection_hit_options().clip_outlines);
}
#[test]
fn the_stop_option_samples_into_the_selected_stop_then_switches_back() {
let (d, _) = doc_with_rect();
+12 -4
View File
@@ -1991,12 +1991,20 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
}
}
/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick it
/// ([`hit_with`]).
/// The topmost editable object under document point `p` at `zoom`, as what is drawn there
/// ([`hit_with`]): a swatch or a symbol dropped on art.
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test)
}
/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick
/// it: [`hit_at`], and a clipping path on its outline before the art it clips
/// ([`vectorcraft_doc::hit::HitOptions::clip_outlines`]). Object Highlighting and a right-click's
/// selection show and take what a click of those tools would.
fn selection_hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
hit_with(app, p, zoom, |doc, p, opt| vectorcraft_doc::hit::hit_test(doc, p, vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..opt }))
}
/// `test` on the active document at document point `p`, with the options the selection tools hit
/// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only,
/// Type › Type Object Selection by Path Only, isolation mode).
@@ -2030,7 +2038,7 @@ fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt)));
app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked);
if let Some(st) = app.session.active()
&& let Some(top) = hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation))
&& let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation))
&& !st.selection.contains(top)
{
// A locked or hidden object can't be selected; the menu is then for the selection as is.
@@ -2125,7 +2133,7 @@ fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
return;
}
let Some(st) = app.session.active() else { return };
let Some(hit) = hit_at(app, h, xf.zoom) else { return };
let Some(hit) = selection_hit_at(app, h, xf.zoom) else { return };
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
if st.selection.contains(id) {
return;
+3 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long