Merge pull request #3246 from addibble/fix/clip-path-outline-hit

G1: selection tools take a clipping path on its outline
This commit is contained in:
BFlat
2026-10-21 16:19:37 +07:00
committed by GitHub
10 changed files with 346 additions and 22 deletions
+3
View File
@@ -144,3 +144,6 @@ sync_github_name = true
[people.odysseexp]
real_name = "Harley Paiva"
sync_github_name = true
[people.addibble]
sync_github_name = true
+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:?}");
}
}
+36 -4
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,6 +486,7 @@ impl Tool for DirectSelectionTool {
if crate::rulerguide::guide_at(cx, p).is_some() {
return vec![];
}
// A double-click edits what is drawn: not a clipping path's outline.
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(_))) {
return vec![Action::SwitchTool("type".into())];
}
@@ -573,7 +574,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 +943,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 +953,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 +1078,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();
@@ -2018,6 +2040,16 @@ mod tests {
}
}
/// Direct Selection's double-click on type where a layer's clipping mask edge runs through it
/// edits the type, though a press there takes the mask's segment.
#[test]
fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() {
let (d, _, at) = doc_with_type_on_a_layer_masks_edge();
let (s, p) = (Selection::default(), paint());
let acts = DirectSelectionTool::new(false).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::DoubleClick, at.x, at.y));
assert_eq!(acts, vec![Action::SwitchTool("type".into())]);
}
#[test]
fn double_clicking_type_switches_to_type_tool() {
use std::sync::Arc;
+56
View File
@@ -413,8 +413,19 @@ 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`]).
/// Only for what a press or a hover selects: a double-click, loaded text or a guide's release
/// acts on what is drawn and takes [`Self::hit_options`].
///
/// [`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 +871,51 @@ 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)
}
/// A new layer of `d`, above the others, with the unpainted rectangle `mask` as its clipping
/// mask and `art` clipped by it: the layer and the clipping mask.
pub fn add_masked_layer(d: &mut Document, mask: Rect, art: Node) -> (NodeId, NodeId) {
let l = d.add_layer(Some("Masked"));
let clip = d.alloc_id();
let mut path = Node::path(clip, shapes::rectangle(mask), Default::default());
if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
d.insert(Some(l), 0, path).unwrap();
d.insert(Some(l), 1, art).unwrap();
d.node_mut(l).unwrap().set_clips(true);
(l, clip)
}
/// A layer clipped to (250, 250)–(360, 400), its mask's right edge through 120 × 40 area type
/// at (300, 300): the type, and a point on it just inside the mask's edge, within the
/// selection tolerance, where the selection tools take the mask.
pub fn doc_with_type_on_a_layer_masks_edge() -> (Document, NodeId, Point) {
let (mut d, _) = doc_with_rect();
let id = d.alloc_id();
let mut t = vectorcraft_doc::TextObject::point(Point::new(300.0, 300.0), "Some words", Default::default());
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 40.0)) };
add_masked_layer(&mut d, Rect::new(250.0, 250.0, 360.0, 400.0), Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t))));
(d, id, Point::new(358.5, 320.0))
}
/// [`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();
+77 -4
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,6 +372,7 @@ 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))
};
// The art the text flows into: what is drawn, not a clipping path's outline.
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));
match to {
@@ -488,6 +489,7 @@ impl Tool for SelectionTool {
if crate::rulerguide::guide_at(cx, p).is_some() {
return vec![];
}
// A double-click edits what is drawn: not a clipping path's outline.
if let Some(h) = hit_test(cx.doc, p, cx.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(_))) {
@@ -567,7 +569,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 +791,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 +804,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()
}
@@ -873,6 +875,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]
@@ -2072,6 +2089,62 @@ mod tests {
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]);
}
/// Loaded text clicked just inside a layer's clipping mask, where art is drawn over the mask's
/// edge, threads into that art, not into the unpainted mask: placing text acts on what is drawn,
/// so it takes the ordinary hit, not the selection tools' clipping-path outline.
#[test]
fn loaded_text_threads_into_the_art_on_a_layer_masks_edge() {
let (mut d, id, _, port) = overflowing_area_type();
let art = d.alloc_id();
let picture = vectorcraft_doc::Node::path(
art,
vectorcraft_geom::shapes::rectangle(Rect::new(350.0, 350.0, 550.0, 550.0)),
vectorcraft_doc::Appearance::default_art(),
);
add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), picture);
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
assert!(cx.selection_hit_options().clip_outlines, "the selection tools would take the mask here");
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y));
t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y));
t.pointer(&cx, &ev(PointerKind::Down, 498.5, 450.0));
assert_eq!(
t.pointer(&cx, &ev(PointerKind::Up, 498.5, 450.0)),
vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "to": art.0}))]
);
}
/// A double-click on type where a layer's clipping mask edge runs through it edits the type,
/// though a press there selects the mask: what a double-click edits is what is drawn.
#[test]
fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() {
let (d, _, at) = doc_with_type_on_a_layer_masks_edge();
let (s, p) = (Selection::default(), paint());
let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, at.x, at.y));
assert_eq!(a, vec![Action::SwitchTool("type".into())]);
}
/// A Cmd/Ctrl+Shift double-click releases a guide path where it crosses a layer's clipping
/// mask edge (its left side, x = 450, just inside the mask's bottom): the mask, unpainted and
/// never a guide, doesn't stand in front of it.
#[test]
fn a_guide_on_a_layer_masks_edge_is_released() {
let (mut d, _) = doc_with_rect();
let id = d.alloc_id();
let mut guide_path =
vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(450.0, 350.0, 550.0, 550.0)), Default::default());
let vectorcraft_doc::NodeKind::Path { guide, .. } = &mut guide_path.kind else { panic!("a path") };
*guide = true;
add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), guide_path);
let (s, p) = (Selection::default(), paint());
let both = Mods { cmd: true, shift: true, ..Mods::default() };
let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, 450.0, 498.5).with_mods(both));
assert_eq!(a, vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]);
}
/// Escape drops the loaded text, and the next click is an ordinary one; type that fits its
/// frame shows no out port to click.
#[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();
+13 -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).
@@ -2014,6 +2022,7 @@ fn hit_with<T>(
type_path_only: prefs.type_selection_by_path_only,
scope: app.session.active().and_then(|st| st.isolation),
skip_guides: !app.ui.view.guides,
..Default::default()
};
test(&app.session.active()?.doc, p, opt)
}
@@ -2030,7 +2039,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 +2134,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