Path Precision Tools: Trim at Crossings leaves clipping paths and art outside isolation alone

- The clipping path of a clip group or a clipped layer takes no part,
  the members of a compound clipping path included. Before, only a
  plain clipping path was left out.
- In isolation mode only paths inside the isolated object are trimmed;
  paths outside it still count as crossing.
- A rectangle or line that reaches so many paths that comparing their
  boxes would take more than 50,000,000 tests is refused before any
  work starts.
- A tool drag that goes 3 screen pixels or more from the press stays a
  rectangle or line when it ends near the press. Before, it ran as a
  click and could remove a whole path the final rectangle did not
  touch.

Docs: docs/mcp.md.
This commit is contained in:
dotCooCoo
2026-11-13 10:44:07 -08:00
parent 3a336fd32f
commit e25795f73e
4 changed files with 138 additions and 16 deletions
+35 -6
View File
@@ -3,6 +3,8 @@
//! touches, or every stretch a line through some points crosses. It works on every path that can
//! be edited, selected or not.
use std::collections::HashSet;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind};
use vectorcraft_geom::kurbo::{Line, PathSeg};
@@ -33,7 +35,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Trim at Crossings",
[],
None,
"{at: [x, y], tolerance?: pt 0..100 (3; how near `at` a path counts as under it)} or {rect: [x, y, width, height]} or {points: [[x, y]…] (2 to 100000 points)} remove stretches of paths, a stretch running between two neighboring places where the path is crossed by another path or by itself, or from such a place to an open end: `at` removes the stretch of the nearest path at that point, `rect` every stretch the rectangle touches, `points` every stretch the line through the points crosses. Give exactly one of the three. Every path that can be edited takes part, selected or not, compound path members included; hidden and locked paths, guides and clipping paths are neither trimmed nor counted as crossing. A path that ends on another marks a place on the other; paths that lie along each other don't cross. A closed path that loses a stretch becomes open, an open path that loses a middle stretch becomes two paths with its appearance in its place in the stacking order, and a path nothing crosses is removed whole; a live shape becomes a plain path. One undo step, none when nothing is removed → {ids (the paths left of those trimmed), removed (stretches)}",
"{at: [x, y], tolerance?: pt 0..100 (3; how near `at` a path counts as under it)} or {rect: [x, y, width, height]} or {points: [[x, y]…] (2 to 100000 points)} remove stretches of paths, a stretch running between two neighboring places where the path is crossed by another path or by itself, or from such a place to an open end: `at` removes the stretch of the nearest path at that point, `rect` every stretch the rectangle touches, `points` every stretch the line through the points crosses. Give exactly one of the three. Every path that can be edited takes part, selected or not, compound path members included; hidden and locked paths, guides and clipping paths (compound ones' members included) are neither trimmed nor counted as crossing; in isolation mode only paths inside the isolated object are trimmed. A path that ends on another marks a place on the other; paths that lie along each other don't cross. A closed path that loses a stretch becomes open, an open path that loses a middle stretch becomes two paths with its appearance in its place in the stacking order, and a path nothing crosses is removed whole; a live shape becomes a plain path. One undo step, none when nothing is removed → {ids (the paths left of those trimmed), removed (stretches)}",
has_doc,
trim
)]
@@ -91,12 +93,24 @@ fn pick(p: &Value) -> Result<Pick> {
type Part<'a> = (NodeId, &'a PathData, Rect);
/// The paths that take part, bottom to top: the ones that can be edited, without guides and
/// clipping paths.
/// clipping paths. The clipping path of a clip group or a clipped layer is left out whole, the
/// members of a compound clipping path included.
fn parts(d: &Document) -> Vec<Part<'_>> {
let editable = d.editable();
let mut masks = HashSet::new();
d.walk(|n| {
if n.clips()
&& let Some(first) = n.children().and_then(|c| c.first())
{
first.walk(&mut |m| {
masks.insert(m.id);
});
}
});
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
&& !masks.contains(&n.id)
&& let Some(b) = path.control_bounds()
&& editable(n.id)
{
@@ -108,6 +122,8 @@ fn parts(d: &Document) -> Vec<Part<'_>> {
/// Most box tests one command makes between a line's pieces and the paths' segments.
const MAX_LINE_TESTS: usize = 60_000_000;
/// Most tests one command makes between the boxes of the paths to trim and of the other paths.
const MAX_BOX_TESTS: usize = 50_000_000;
/// Does the line through `pts` cross `sp`? Each piece of the line compared costs one of `budget`;
/// `None` once it is spent.
@@ -203,14 +219,22 @@ fn trimmed(
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.trim";
let pick = pick(p)?;
let d = &s.doc()?.doc;
let st = s.doc()?;
let d = &st.doc;
let parts = parts(d);
// In isolation mode only the isolated object's paths are trimmed; paths outside it still cross them.
let root = crate::cmd::ordinary_isolation(st);
let in_scope = |id: NodeId| root.is_none_or(|r| id == r || crate::cmd::inside_isolation(d, r, id));
// The paths to trim, each with the place `at` falls on.
let targets: Vec<(usize, Option<pt::Place>)> = match &pick {
Pick::At(at, tolerance) => {
let mut best: Option<(usize, pt::Place, f64)> = None;
for (i, (_, path, b)) in parts.iter().enumerate() {
for (i, (id, path, b)) in parts.iter().enumerate() {
if !in_scope(*id) {
continue;
}
let b = b.inflate(*tolerance, *tolerance);
if !(b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1) {
continue;
@@ -225,9 +249,14 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
}
best.map(|(i, on, _)| (i, Some(on))).into_iter().collect()
}
Pick::Rect(r) => parts.iter().enumerate().filter(|(_, (_, _, b))| b.overlaps(*r)).map(|(i, _)| (i, None)).collect(),
Pick::Line(_, r) => parts.iter().enumerate().filter(|(_, (_, _, b))| b.overlaps(*r)).map(|(i, _)| (i, None)).collect(),
Pick::Rect(r) | Pick::Line(_, r) => {
parts.iter().enumerate().filter(|(_, (id, _, b))| in_scope(*id) && b.overlaps(*r)).map(|(i, _)| (i, None)).collect()
}
};
// Each path to trim has its box tested against every other path's.
if targets.len().saturating_mul(parts.len()) > MAX_BOX_TESTS {
return Err(bad(C, "the rectangle or line reaches too many paths to check"));
}
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
let mut removed = 0;
let mut budget = MAX_LINE_TESTS;
@@ -252,6 +252,73 @@ fn a_clipping_path_is_not_trimmed_and_does_not_count_as_crossing() {
assert!(!exists(&s, h) && exists(&s, t));
}
#[test]
fn the_members_of_a_compound_clipping_path_are_not_trimmed_and_do_not_count_as_crossing() {
let mut s = session();
let (h, v1, _) = across_and_uprights(&mut s);
let extra = line(&mut s, &[(250.0, 250.0), (260.0, 260.0)], false);
s.execute("select.set", &json!({"ids": [v1.0, extra.0]})).unwrap();
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [h.0, compound.0]})).unwrap();
s.execute("object.clippingMask.make", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let n = steps(&s);
// A click on the clipping path's member changes nothing, and the rectangle over it neither.
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [245, 245, 20, 20]})).unwrap()["removed"], json!(0));
assert_eq!((steps(&s), path(&s, v1).anchor_count(), path(&s, extra).anchor_count()), (n, 2, 2));
// The clipped line across is crossed by the second upright only: its stretch from the start goes.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
}
#[test]
fn in_isolation_mode_only_the_isolated_objects_paths_are_trimmed() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
s.execute("select.set", &json!({"ids": [v1.0, v2.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("select.none", &json!({})).unwrap();
// The line across is outside the group: a click or a rectangle on it leaves it whole.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(0));
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// It still crosses the uprights: a rectangle over the tops of both removes them down to it.
assert_eq!(s.execute(TRIM, &json!({"rect": [90, 40, 120, 20]})).unwrap()["removed"], json!(2));
assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v2).0, 200.0, 100.0));
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// An isolated path is trimmed itself.
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("object.isolate", &json!({"id": h.0})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 100.0, 100.0));
}
#[test]
fn a_rectangle_over_too_many_paths_to_check_is_refused() {
let mut s = session();
// 7,072 short lines apart from each other: each of them against every other is past the limit.
s.edit("many", |d, _| {
let layer = d.layers[0].id;
for i in 0..7072 {
let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0);
let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(x, y), Point::new(x + 1.0, y)], false);
let id = d.alloc_id();
let top = d.node(layer).unwrap().children().unwrap().len();
d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?;
}
Ok(())
})
.unwrap();
let n = steps(&s);
let err = s.execute(TRIM, &json!({"rect": [-10, -10, 400, 300]})).unwrap_err().to_string();
assert!(err.contains("too many paths"), "{err}");
assert_eq!((steps(&s), stack(&s).len()), (n, 7072));
// A rectangle over a few of them is checked.
assert_eq!(s.execute(TRIM, &json!({"rect": [-1, -1, 5, 2]})).unwrap()["removed"], json!(2));
}
#[test]
fn a_path_too_large_to_search_is_refused_only_when_touched() {
let mut s = session();
+29 -6
View File
@@ -30,6 +30,8 @@ pub struct TrimTool {
/// An Alt-drag's freehand line (empty for a rectangle).
line: Vec<Point>,
freehand: bool,
/// The pointer went `MIN_DRAG_PX` or more from the press: the gesture is a drag from then on.
moved: bool,
}
impl TrimTool {
@@ -37,6 +39,7 @@ impl TrimTool {
self.start = None;
self.line.clear();
self.freehand = false;
self.moved = false;
}
}
@@ -61,11 +64,13 @@ impl Tool for TrimTool {
self.start = Some(ev.pos);
self.end = ev.pos;
self.freehand = ev.mods.alt;
self.moved = false;
self.line = if self.freehand { vec![ev.pos] } else { vec![] };
vec![]
}
PointerKind::Drag if self.start.is_some() => {
self.end = ev.pos;
self.moved |= self.start.is_some_and(|start| start.distance(ev.pos) >= cx.tol(MIN_DRAG_PX));
if self.freehand && self.line.last().is_none_or(|p| p.distance(ev.pos) >= cx.tol(1.0)) {
// A line cut short would cross other stretches than the one drawn: drop it.
if self.line.len() >= MAX_POINTS {
@@ -79,12 +84,11 @@ impl Tool for TrimTool {
PointerKind::Up => {
let Some(start) = self.start else { return vec![] };
let mut line = std::mem::take(&mut self.line);
let freehand = self.freehand;
let (freehand, moved) = (self.freehand, self.moved);
self.reset();
// A press that stayed near where it started is a click, even when a freehand line
// took a few points on the way.
let near = |p: &Point| start.distance(*p) < cx.tol(MIN_DRAG_PX);
if near(&ev.pos) && line.iter().all(near) {
// took a few points on the way. One that went farther stays a drag when it comes back.
if !moved && start.distance(ev.pos) < cx.tol(MIN_DRAG_PX) {
return vec![Action::Exec(COMMAND.into(), json!({"at": [start.x, start.y], "tolerance": cx.tol(REACH).min(100.0)}))];
}
if !freehand {
@@ -112,7 +116,7 @@ impl Tool for TrimTool {
self.start.is_some() && key == ToolKey::Escape
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let Some(start) = self.start else { return vec![] };
if self.freehand {
return if self.line.len() < 2 {
@@ -121,7 +125,7 @@ impl Tool for TrimTool {
vec![Overlay::Path { path: polygon(&self.line, false), color: FEEDBACK, width: 1.0, dashed: false }]
};
}
if start.distance(self.end) < cx.tol(MIN_DRAG_PX) { vec![] } else { vec![Overlay::Marquee(Rect::from_points(start, self.end))] }
if self.moved { vec![Overlay::Marquee(Rect::from_points(start, self.end))] } else { vec![] }
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
@@ -175,6 +179,25 @@ mod tests {
assert!(t.pointer(&cx, &ev(PointerKind::Up, 60.0, 60.0)).is_empty());
}
#[test]
fn a_drag_that_comes_back_near_the_press_stays_a_rectangle() {
let (d, _) = doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = TrimTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 20.0));
t.pointer(&cx, &ev(PointerKind::Drag, 1.0, 1.0));
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Marquee(r)] if *r == Rect::new(0.0, 0.0, 1.0, 1.0)));
let up = t.pointer(&cx, &ev(PointerKind::Up, 1.0, 1.0));
assert_eq!(up, vec![Action::Exec(COMMAND.into(), json!({"rect": [0.0, 0.0, 1.0, 1.0]}))]);
// A drag that stays within a pixel or two is still a click.
t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &ev(PointerKind::Drag, 2.0, 0.0));
let up = t.pointer(&cx, &ev(PointerKind::Up, 1.0, 1.0));
assert!(matches!(up.as_slice(), [Action::Exec(_, v)] if v.get("at").is_some()), "{up:?}");
}
#[test]
fn an_alt_drag_trims_what_its_line_crosses() {
let (d, _) = doc_with_rect();
+7 -4
View File
@@ -10363,7 +10363,8 @@ Tools:
0.002) and `together` (Keep Shared Points Together).
- **Trim at Crossings Tool** (`trimAtCrossings`): removes stretches of paths between the places where they are crossed,
on any path that can be edited, selected or not. A click runs `addon.pathtools.trim` with `at`, for the path within 4
screen pixels of the pointer. A drag draws a rectangle and runs it with `rect` on release. An Alt-drag draws a
screen pixels of the pointer. A drag draws a rectangle and runs it with `rect` on release; once the pointer has
gone 3 screen pixels from the press, the gesture stays a drag even when it ends near the press. An Alt-drag draws a
freehand line and runs it with `points` on release; a line of more than 65,536 points is dropped. Nothing changes
while dragging, and Escape during a drag removes nothing. The tool has no options.
@@ -10458,8 +10459,9 @@ Commands:
([x, y, width, height]) removes every stretch the rectangle touches. `points` (2 to 100,000 points) removes every
stretch the line through them crosses. Exactly one of the three is given, with coordinates within ±10,000,000 pt.
Every path that can be edited takes part, selected or not, compound path members included. Hidden and locked paths,
guides and clipping paths are not trimmed and do not count as crossing; type, images and symbol instances take no
part. A path that ends on another marks a place on the other. Paths that lie along each other do not cross there.
guides and clipping paths are not trimmed and do not count as crossing, the members of a compound clipping path
included; type, images and symbol instances take no part. In isolation mode only paths inside the isolated object
are trimmed, and paths outside it still count as crossing. A path that ends on another marks a place on the other. Paths that lie along each other do not cross there.
Crossings are found on the paths' own outlines, without strokes, brushes or effects. A closed path that loses a
stretch becomes open. An open path that loses a middle stretch becomes two paths with its appearance, in its place in
the stacking order. A path that nothing crosses is removed whole, a compound path that loses its last member is
@@ -10467,7 +10469,8 @@ Commands:
pieces of a selected path are selected, and anchors and segments direct-selected on a trimmed path are deselected;
the rest of the selection stays as it was. One undo step, none when nothing is removed. Only a path that `at`,
`rect` or `points` reaches is searched for crossings. The command is refused when such a path has more than 10,000
segments, when more than 200,000 segments of other paths lie in its box, when the paths it trims make more than about
segments, when more than 200,000 segments of other paths lie in its box, when the number of paths `rect` or `points`
reaches times the number of paths that take part is more than 50,000,000, when the paths it trims make more than about
60,000,000 pairs of segments to compare in all, when the curves are too tangled to find where they cross, or when
checking `points` against the paths would take more than 60,000,000 comparisons. These rules are VectorCraft's own.
→ `{ids, removed}`: the paths left of those trimmed, and the stretches removed.