mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user