From 3a336fd32f3c499225f402de798687b35885073b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:23:56 -0800 Subject: [PATCH] Path Precision Tools: Trim at Crossings removes the stretch of a path between crossings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Trim at Crossings Tool removes stretches of paths between the places where they are crossed by another path or by themselves. A click removes the stretch under the pointer, a drag removes every stretch its rectangle touches, and an Alt-drag removes every stretch its freehand line crosses. It works on every path that can be edited, selected or not, and each gesture is one undo step. - `addon.pathtools.trim {at, tolerance?}`, `{rect}` or `{points}` is the command behind the tool. 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 that nothing crosses is removed whole. The stretches that stay get no new anchors. - Hidden and locked paths, guides and clipping paths are not trimmed and do not count as crossing. Only the paths the gesture reaches are searched for crossings, and one work limit covers every path the command trims. - `crossings_on` and `crossings_on_within` in `vectorcraft-pathops` find the crossings on one path and compare it only with the segments in its box. - The new tool shares the crossing search with Add Points at Crossings and Split at Crossings, so those change too: two segments of a path that meet at an anchor, directly or across segments with no length, now count as crossing where they cross again away from that anchor, and do not count as crossing at the anchor itself. With Self Crossings, a curved segment that loops over itself counts as crossing itself. Before, both crossings were missed. - The tool is an add-on tool: Object › Plug-ins › Path Precision Tools and the All Tools drawer list it, it has no shortcut, and neither shipped toolbar shows it. Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md. --- crates/engine/src/cmd/addons/pathtools.rs | 8 +- .../engine/src/cmd/addons/pathtools/trim.rs | 292 +++++++++++ .../src/cmd/addons/pathtools/trim/tests.rs | 460 ++++++++++++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/tests/command_sweep.rs | 8 + crates/pathops/src/addons/pathtools.rs | 10 +- .../pathops/src/addons/pathtools/crossings.rs | 199 +++++++- crates/pathops/src/addons/pathtools/tests.rs | 228 +++++++++ crates/pathops/src/addons/pathtools/trim.rs | 151 ++++++ crates/tools/src/addons.rs | 3 + crates/tools/src/addons/trim.rs | 213 ++++++++ crates/ui-egui/src/addons/pathtools.rs | 3 +- crates/ui-egui/src/addons/pathtools/tests.rs | 1 + crates/ui-egui/src/edit_toolbar/tests.rs | 3 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/mcp.md | 35 +- docs/target-app-parity.md | 4 + docs/ui-parity.md | 3 +- 44 files changed, 1645 insertions(+), 31 deletions(-) create mode 100644 crates/engine/src/cmd/addons/pathtools/trim.rs create mode 100644 crates/engine/src/cmd/addons/pathtools/trim/tests.rs create mode 100644 crates/pathops/src/addons/pathtools/trim.rs create mode 100644 crates/tools/src/addons/trim.rs diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 9ddcc43559..86f9c3aef6 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -2,8 +2,8 @@ //! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's //! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners //! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's -//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes -//! into Paths. +//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes, Expand Dashes +//! into Paths and Trim at Crossings ([`trim`]). //! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's //! and the UI's. @@ -14,6 +14,7 @@ mod shape; mod shared_edge; #[cfg(test)] mod tests; +mod trim; use std::collections::{BTreeMap, BTreeSet}; @@ -143,7 +144,7 @@ pub fn specs() -> Vec { "Add Points at Crossings", ["Object", "Plug-ins", "Path Precision Tools"], None, - "{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}", + "{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too, a curved segment that loops over itself included), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}", has_selection, crossings ), @@ -178,6 +179,7 @@ pub fn specs() -> Vec { v.extend(shape::specs()); v.extend(corners::specs()); v.extend(shared_edge::specs()); + v.extend(trim::specs()); v } diff --git a/crates/engine/src/cmd/addons/pathtools/trim.rs b/crates/engine/src/cmd/addons/pathtools/trim.rs new file mode 100644 index 0000000000..757a1ef489 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/trim.rs @@ -0,0 +1,292 @@ +//! Trim at Crossings: remove the stretch of a path between the places where other paths, or the +//! path itself, cross it. The command takes the stretch at a point, every stretch a rectangle +//! touches, or every stretch a line through some points crosses. It works on every path that can +//! be edited, selected or not. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId, NodeKind}; +use vectorcraft_geom::kurbo::{Line, PathSeg}; +use vectorcraft_geom::{PathData, Point, Rect, SubPath, hit}; +use vectorcraft_pathops::addons::pathtools as pt; + +use super::super::super::draw2::replace_with_pieces; +use super::super::super::pathops::Beside; +use super::super::super::{CommandSpec, bad, cmd, finite_numbers, has_doc, number_or}; +use crate::{Result, Session}; + +#[cfg(test)] +mod tests; + +/// The undo step's name. +const LABEL: &str = "Trim at Crossings"; +/// How near `at` a path counts as under it by default (pt), and the most. +const TOLERANCE: f64 = 3.0; +const MAX_TOLERANCE: f64 = 100.0; +/// Most points of `points`. +const MAX_POINTS: usize = 100_000; +/// The farthest coordinate the command takes. +const MAX_COORD: f64 = 1e7; + +pub(super) fn specs() -> Vec { + vec![cmd!( + "addon.pathtools.trim", + "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)}", + has_doc, + trim + )] +} + +/// What picks the stretches to remove. +enum Pick { + /// The stretch of the nearest path within the tolerance of the point. + At(Point, f64), + /// Every stretch the rectangle touches. + Rect(Rect), + /// Every stretch the line through the points crosses. + Line(Vec, Rect), +} + +fn pick(p: &Value) -> Result { + const C: &str = "addon.pathtools.trim"; + let given = |key: &str| p.get(key).filter(|v| !v.is_null()); + let within = |v: &[f64]| v.iter().all(|x| x.abs() <= MAX_COORD); + match (given("at"), given("rect"), given("points")) { + (Some(at), None, None) => { + let [x, y] = finite_numbers(at).filter(|v| within(v)).ok_or_else(|| bad(C, "`at` must be a point [x, y]"))?; + let tolerance = number_or(p, "tolerance", TOLERANCE, C)?; + if !(0.0..=MAX_TOLERANCE).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 0 to 100 pt")); + } + Ok(Pick::At(Point::new(x, y), tolerance)) + } + (None, Some(rect), None) => { + let [x, y, w, h] = finite_numbers(rect).filter(|v| within(v)).ok_or_else(|| bad(C, "`rect` must be [x, y, width, height]"))?; + Ok(Pick::Rect(Rect::new(x, y, x + w, y + h).abs())) + } + (None, None, Some(points)) => { + let list = points.as_array().ok_or_else(|| bad(C, "`points` must be a list of [x, y] points"))?; + if !(2..=MAX_POINTS).contains(&list.len()) { + return Err(bad(C, "`points` must hold from 2 to 100000 points")); + } + let pts = list + .iter() + .map(|v| { + finite_numbers(v) + .filter(|xy| within(xy)) + .map(|[x, y]| Point::new(x, y)) + .ok_or_else(|| bad(C, format!("bad point {v}: expected [x, y]"))) + }) + .collect::>>()?; + let bounds = pts.iter().fold(None, |r: Option, q| Some(r.map_or(Rect::from_points(*q, *q), |r| r.union_pt(*q)))); + Ok(Pick::Line(pts, bounds.unwrap_or_default())) + } + _ => Err(bad(C, "give exactly one of `at`, `rect` and `points`")), + } +} + +/// A path that takes part: its id, geometry and control-point box. +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. +fn parts(d: &Document) -> Vec> { + let editable = d.editable(); + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind + && let Some(b) = path.control_bounds() + && editable(n.id) + { + out.push((n.id, path, b)); + } + }); + out +} + +/// Most box tests one command makes between a line's pieces and the paths' segments. +const MAX_LINE_TESTS: usize = 60_000_000; + +/// Does the line through `pts` cross `sp`? Each piece of the line compared costs one of `budget`; +/// `None` once it is spent. +fn crossed_by_line(sp: &SubPath, pts: &[Point], budget: &mut usize) -> Option { + let segs: Vec<_> = (0..sp.segment_count()) + .map(|i| { + let c = sp.segment(i); + (c, Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3).inflate(1e-9, 1e-9)) + }) + .collect(); + let Some(reach) = segs.iter().map(|s| s.1).reduce(|a, b| a.union(b)) else { return Some(false) }; + for w in pts.windows(2) { + let [a, b] = w else { continue }; + let piece = Rect::from_points(*a, *b); + *budget = budget.checked_sub(1)?; + if !reach.overlaps(piece) { + continue; + } + for (c, r) in &segs { + *budget = budget.checked_sub(1)?; + if r.overlaps(piece) && !PathSeg::Cubic(*c).intersect_line(Line::new(*a, *b)).is_empty() { + return Some(true); + } + } + } + Some(false) +} + +/// Does the rectangle or line of `pick` touch the outline of `piece`? Never for `Pick::At`. +fn touches(piece: &SubPath, pick: &Pick, budget: &mut usize) -> Result { + match pick { + Pick::At(..) => Ok(false), + Pick::Rect(r) => Ok(hit::intersects_rect(&PathData::single(piece.clone()), *r)), + Pick::Line(pts, _) => crossed_by_line(piece, pts, budget) + .ok_or_else(|| bad("addon.pathtools.trim", "the line is too long, or the paths it passes too many, to check")), + } +} + +/// `path` without the stretches `pick` takes (`on`: the place of `Pick::At` on it): its subpaths +/// then, and how many stretches went. `None` when none does. A rectangle or a line that touches no +/// part of `path` leaves it out before its crossings are looked for. +fn trimmed( + path: &PathData, + others: &[&PathData], + pick: &Pick, + on: Option, + budget: &mut usize, + work: &mut pt::Work, +) -> Result, usize)>> { + const C: &str = "addon.pathtools.trim"; + let reached = match pick { + Pick::At(..) => true, + Pick::Rect(r) => hit::intersects_rect(path, *r), + Pick::Line(..) => { + let mut any = false; + for sp in &path.subpaths { + if touches(sp, pick, budget)? { + any = true; + break; + } + } + any + } + }; + if !reached { + return Ok(None); + } + let places = + pt::crossings_on_within(path, others, work).ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross"))?; + let mut subs = vec![]; + let mut removed = 0; + for (si, sp) in path.subpaths.iter().enumerate() { + // The places come in subpath order: this subpath's are one run of them. + let run = places.partition_point(|p| p.0 < si)..places.partition_point(|p| p.0 <= si); + let here: Vec<(usize, f64)> = places.get(run).unwrap_or_default().iter().map(|&(_, seg, t)| (seg, t)).collect(); + let spans = pt::spans(sp, &here); + let marks: Vec = match pick { + Pick::At(..) => { + let at = on.filter(|(s, ..)| *s == si).and_then(|(_, seg, t)| pt::span_at(sp, &spans, seg, t)); + (0..spans.len()).map(|i| Some(i) == at).collect() + } + _ => spans.iter().map(|s| touches(&pt::slice(sp, *s), pick, budget)).collect::>()?, + }; + match pt::without_spans(sp, &spans, &marks) { + Some(pieces) => { + removed += marks.iter().filter(|m| **m).count(); + subs.extend(pieces); + } + None => subs.push(sp.clone()), + } + } + Ok((removed > 0).then_some((subs, removed))) +} + +fn trim(s: &mut Session, p: &Value) -> Result { + let pick = pick(p)?; + let d = &s.doc()?.doc; + let parts = parts(d); + // The paths to trim, each with the place `at` falls on. + let targets: Vec<(usize, Option)> = match &pick { + Pick::At(at, tolerance) => { + let mut best: Option<(usize, pt::Place, f64)> = None; + for (i, (_, path, b)) in parts.iter().enumerate() { + let b = b.inflate(*tolerance, *tolerance); + if !(b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1) { + continue; + } + // Of paths equally near, the one on top. + if let Some((si, seg, t, _, dist)) = path.nearest(*at) + && dist <= *tolerance + && best.is_none_or(|b| dist <= b.2) + { + best = Some((i, (si, seg, t), dist)); + } + } + 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(), + }; + let mut plan: Vec<(NodeId, Vec)> = vec![]; + let mut removed = 0; + let mut budget = MAX_LINE_TESTS; + // One budget of crossing work for every path the command trims. + let mut work = pt::Work::default(); + for (i, on) in targets { + let Some((id, path, b)) = parts.get(i) else { continue }; + let reach = b.inflate(1e-3, 1e-3); + let others: Vec<&PathData> = + parts.iter().enumerate().filter(|(j, (_, _, ob))| *j != i && reach.overlaps(*ob)).map(|(_, (_, o, _))| *o).collect(); + let Some((subs, n)) = trimmed(path, &others, &pick, on, &mut budget, &mut work)? else { continue }; + removed += n; + // A path of one subpath becomes a path per piece; the pieces of one with more stay its subpaths. + let pieces = match (path.subpaths.len(), subs.is_empty()) { + (_, true) => vec![], + (1, false) => subs.into_iter().map(PathData::single).collect(), + _ => vec![PathData::new(subs)], + }; + plan.push((*id, pieces)); + } + if plan.is_empty() { + return Ok(json!({ "ids": [], "removed": 0 })); + } + let ids = s.edit(LABEL, |d, sel| { + let trimmed: Vec = plan.iter().map(|(id, _)| *id).collect(); + // The compound paths that may lose their last member. + let mut compounds: Vec = trimmed + .iter() + .filter_map(|id| d.parent_of(*id)) + .filter(|c| matches!(d.node(*c).map(|n| &n.kind), Some(NodeKind::Compound { .. }))) + .collect(); + compounds.dedup(); + let mut beside = Beside::replacing(d, &trimmed); + let mut all = vec![]; + for (id, pieces) in plan { + let selected = sel.contains(id); + sel.anchors.remove(&id); + sel.segments.remove(&id); + let made = replace_with_pieces(d, &mut beside, id, pieces)?; + // The pieces of a selected path are selected; what was not selected stays so. + if made.is_empty() { + sel.remove(id); + } else if selected { + for n in &made { + if !sel.contains(*n) { + sel.add(*n); + } + } + } + all.extend(made); + } + beside.finish(d)?; + for c in compounds { + if d.node(c).is_some_and(|n| n.children().is_some_and(|ch| ch.is_empty())) { + d.remove(c)?; + sel.remove(c); + } + } + Ok(all) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "removed": removed })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/trim/tests.rs b/crates/engine/src/cmd/addons/pathtools/trim/tests.rs new file mode 100644 index 0000000000..d6e0d305ac --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/trim/tests.rs @@ -0,0 +1,460 @@ +//! Trim at Crossings: the stretch at a point, the stretches a rectangle touches or a line crosses, +//! one undo step, and what takes no part. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point}; + +use crate::Session; + +const TRIM: &str = "addon.pathtools.trim"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + id +} + +/// A line across at y = 100 from x = 0 to 300, and two uprights across it at x = 100 and x = 200. +fn across_and_uprights(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let h = line(s, &[(0.0, 100.0), (300.0, 100.0)], false); + let v1 = line(s, &[(100.0, 50.0), (100.0, 150.0)], false); + let v2 = line(s, &[(200.0, 50.0), (200.0, 150.0)], false); + (h, v1, v2) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn exists(s: &Session, id: NodeId) -> bool { + s.doc().unwrap().doc.node(id).is_some() +} + +/// The first and last anchor of the path's only subpath. +fn ends(s: &Session, id: NodeId) -> (Point, Point) { + let p = path(s, id); + assert_eq!(p.subpaths.len(), 1); + (p.subpaths[0].anchors.first().unwrap().p, p.subpaths[0].anchors.last().unwrap().p) +} + +fn near(p: Point, x: f64, y: f64) -> bool { + p.distance(Point::new(x, y)) < 1e-6 +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +/// The ids of the first layer's objects, bottom to top. +fn stack(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +fn set_flags(s: &mut Session, id: NodeId, locked: bool, visible: bool) { + s.edit("flags", |d, _| { + let n = d.node_mut(id).unwrap(); + n.locked = locked; + n.visible = visible; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn a_point_between_two_crossings_removes_that_stretch_and_splits_the_path() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let look = s.doc().unwrap().doc.node(h).unwrap().appearance.clone(); + let (before, n) = (stack(&s), steps(&s)); + let r = s.execute(TRIM, &json!({"at": [150, 101]})).unwrap(); + assert_eq!(r["removed"], json!(1)); + let made = ids(&r); + assert_eq!(made.len(), 2, "{r}"); + assert_eq!(made[0], h, "the first piece keeps the id"); + let (a, b) = (ends(&s, made[0]), ends(&s, made[1])); + assert!(near(a.0, 0.0, 100.0) && near(a.1, 100.0, 100.0), "{a:?}"); + assert!(near(b.0, 200.0, 100.0) && near(b.1, 300.0, 100.0), "{b:?}"); + // Both pieces look as the path did and stand where it stood, under the uprights. + assert!(made.iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().appearance == look)); + assert_eq!(stack(&s), [made[0], made[1], v1, v2]); + assert_eq!(before, [h, v1, v2]); + // The uprights are as they were, nothing got selected, and it is one undo step. + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + assert!(s.doc().unwrap().selection.is_empty()); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stack(&s), before); + assert!(near(ends(&s, h).1, 300.0, 100.0)); +} + +#[test] +fn an_end_stretch_runs_to_the_open_end_and_the_rest_stays_one_path() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + let r = s.execute(TRIM, &json!({"at": [40, 100]})).unwrap(); + assert_eq!((ids(&r), r["removed"].clone()), (vec![h], json!(1))); + let e = ends(&s, h); + assert!(near(e.0, 100.0, 100.0) && near(e.1, 300.0, 100.0), "{e:?}"); + // No anchor is added at the crossing the rest runs through. + assert_eq!(path(&s, h).anchor_count(), 2); + // A point on no path within the tolerance removes nothing and makes no step. + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110]})).unwrap(), json!({"ids": [], "removed": 0})); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 0})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 12})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 200.0, 100.0)); +} + +#[test] +fn a_closed_path_that_loses_a_stretch_opens() { + let mut s = session(); + let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + line(&mut s, &[(50.0, 150.0), (250.0, 150.0)], false); + // The top half of the square's outline, between the two places the line crosses it. + let r = s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert_eq!((ids(&r), r["removed"].clone()), (vec![sq], json!(1))); + let p = path(&s, sq); + assert_eq!(p.subpaths.len(), 1); + assert!(!p.subpaths[0].closed); + let pts: Vec = p.subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts.len(), 4, "{pts:?}"); + assert!(pts.iter().all(|q| q.y >= 150.0 - 1e-6), "only the lower half is left: {pts:?}"); + assert!(pts.iter().any(|q| near(*q, 100.0, 200.0)) && pts.iter().any(|q| near(*q, 200.0, 200.0))); + // It is a plain path now. + assert!(matches!(&s.doc().unwrap().doc.node(sq).unwrap().kind, NodeKind::Path { live: None, .. })); +} + +#[test] +fn a_rectangle_removes_every_stretch_it_touches_as_one_step() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false); + let n = steps(&s); + // Over the middle of both lines across, touching neither upright. + let r = s.execute(TRIM, &json!({"rect": [140, 90, 20, 40]})).unwrap(); + assert_eq!(r["removed"], json!(2)); + assert_eq!(ids(&r).len(), 4); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0)); + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(ends(&s, h).1, 300.0, 100.0) && near(ends(&s, h2).1, 300.0, 120.0)); + // A rectangle given from its far corner is the same one; one over empty space removes nothing. + assert_eq!(s.execute(TRIM, &json!({"rect": [160, 130, -20, -40]})).unwrap()["removed"], json!(2)); + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"rect": [300, 200, 50, 50]})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + // An upright's stretches: above the upper line, between the two, below the lower. A rectangle + // over its top end takes the first only. + assert_eq!(s.execute(TRIM, &json!({"rect": [95, 45, 10, 10]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v1).1, 100.0, 150.0)); +} + +#[test] +fn a_line_through_points_removes_the_stretches_it_crosses() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let n = steps(&s); + // Down through the middle of the line across, then right through the lower end of the second upright. + let r = s.execute(TRIM, &json!({"points": [[150, 80], [150, 130], [250, 130]]})).unwrap(); + assert_eq!(r["removed"], json!(2)); + assert_eq!(steps(&s), n + 1); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + assert!(near(ends(&s, v2).0, 200.0, 50.0) && near(ends(&s, v2).1, 200.0, 100.0)); + assert_eq!(path(&s, v1).anchor_count(), 2); +} + +#[test] +fn locked_and_hidden_paths_are_not_trimmed_and_do_not_count_as_crossing() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + set_flags(&mut s, v1, true, true); + set_flags(&mut s, v2, false, false); + let n = steps(&s); + // On the locked upright, and a rectangle over the hidden one's top end: nothing goes. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(s.execute(TRIM, &json!({"rect": [195, 45, 10, 10]})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + // Neither marks a place on the line across: with nothing else crossing it, it goes whole. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, h) && exists(&s, v1) && exists(&s, v2)); + // A guide doesn't count either. + s.execute("edit.undo", &json!({})).unwrap(); + set_flags(&mut s, v1, false, true); + s.execute("select.set", &json!({"ids": [v1.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert!(!exists(&s, h)); +} + +#[test] +fn a_path_of_two_subpaths_loses_the_stretch_on_the_subpath_under_the_point() { + let mut s = session(); + // Lines of different lengths, so the places on one fall at other parameters than on the other. + let line_at = |x0: f64, x1: f64, y: f64| vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y), Point::new(x1, y)], false); + let two = s + .edit("two", |d, _| { + let layer = d.layers[0].id; + let id = d.alloc_id(); + let both = PathData::new(vec![line_at(0.0, 300.0, 100.0), line_at(50.0, 250.0, 120.0)]); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, both, Default::default()))?; + Ok(id) + }) + .unwrap(); + line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false); + line(&mut s, &[(200.0, 50.0), (200.0, 150.0)], false); + // The middle of the lower subpath goes; the upper one stays whole, both in the one path. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 120]})).unwrap()["removed"], json!(1)); + let p = path(&s, two); + let spans: Vec<(Point, Point)> = p.subpaths.iter().map(|sp| (sp.anchors[0].p, sp.anchors.last().unwrap().p)).collect(); + assert_eq!(spans.len(), 3, "{spans:?}"); + assert!(near(spans[0].0, 0.0, 100.0) && near(spans[0].1, 300.0, 100.0), "{spans:?}"); + assert!(near(spans[1].0, 50.0, 120.0) && near(spans[1].1, 100.0, 120.0), "{spans:?}"); + assert!(near(spans[2].0, 200.0, 120.0) && near(spans[2].1, 250.0, 120.0), "{spans:?}"); +} + +#[test] +fn a_clipping_path_is_not_trimmed_and_does_not_count_as_crossing() { + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + s.edit("clip", |d, _| { + if let NodeKind::Path { clipping, .. } = &mut d.node_mut(v1).unwrap().kind { + *clipping = true; + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(path(&s, v1).anchor_count(), 2); + // The 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)); + // Type over the rest of it takes no part either: nothing crosses the rest, and it goes whole. + let t = NodeId(s.execute("text.create", &json!({"x": 220, "y": 115, "text": "HHHH", "size": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"rect": [215, 70, 70, 50]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, h) && exists(&s, t)); +} + +#[test] +fn a_path_too_large_to_search_is_refused_only_when_touched() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + // A border of 10,001 segments round all of them, more than a path to trim may have. + let mut border: Vec<(f64, f64)> = (0..=10_001).map(|i| (-500.0 + i as f64 * 0.2, -500.0)).collect(); + border.push((-500.0, 1500.0)); + let b = line(&mut s, &border, true); + // A rectangle and a line over the middle of the line across reach inside the border's box only. + assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"points": [[150, 80], [150, 120]]})).unwrap()["removed"], json!(1)); + // A rectangle on the border is refused, and changes nothing. + let n = steps(&s); + let err = s.execute(TRIM, &json!({"rect": [-510, 0, 20, 20]})).unwrap_err().to_string(); + assert!(err.contains("too many or too tangled"), "{err}"); + assert_eq!((steps(&s), path(&s, b).anchor_count()), (n, 10_003)); +} + +#[test] +fn a_path_nothing_crosses_goes_whole_open_or_closed() { + let mut s = session(); + let open = line(&mut s, &[(0.0, 10.0), (50.0, 10.0), (50.0, 40.0)], false); + let closed = line(&mut s, &[(100.0, 10.0), (150.0, 10.0), (150.0, 40.0)], true); + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"at": [25, 10]})).unwrap(), json!({"ids": [], "removed": 1})); + assert_eq!(s.execute(TRIM, &json!({"at": [125, 10]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, open) && !exists(&s, closed)); + assert_eq!(steps(&s), n + 2); +} + +#[test] +fn a_path_that_ends_on_another_marks_a_place_and_paths_along_each_other_do_not() { + let mut s = session(); + let h = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false); + let stub = line(&mut s, &[(150.0, 100.0), (150.0, 160.0)], false); + // The stub ends on the line: the line's stretch stops there, and the stub is one stretch. + s.execute(TRIM, &json!({"at": [50, 100]})).unwrap(); + assert!(near(ends(&s, h).0, 150.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 130]})).unwrap()["removed"], json!(1)); + assert!(!exists(&s, stub)); + // A second line along the first doesn't mark it: the lower one goes whole. + let along = line(&mut s, &[(200.0, 100.0), (260.0, 100.0)], false); + s.execute(TRIM, &json!({"rect": [270, 90, 10, 20]})).unwrap(); + assert!(!exists(&s, h) && exists(&s, along)); +} + +#[test] +fn a_self_crossing_path_loses_its_loop() { + let mut s = session(); + // A bow: its first and third segments cross at (50, 50). + let bow = line(&mut s, &[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false); + let r = s.execute(TRIM, &json!({"at": [100, 50]})).unwrap(); + assert_eq!(r["removed"], json!(1)); + let made = ids(&r); + assert_eq!(made.len(), 2); + assert_eq!(made[0], bow); + assert!(near(ends(&s, made[0]).1, 50.0, 50.0) && near(ends(&s, made[1]).0, 50.0, 50.0)); +} + +#[test] +fn compound_path_members_are_trimmed_and_an_emptied_compound_path_goes() { + let mut s = session(); + let a = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false); + let b = line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let members = |s: &Session| s.doc().unwrap().doc.node(compound).map(|n| n.children().unwrap().len()); + assert_eq!(members(&s), Some(2)); + // The members cross each other: a middle stretch splits one member in the compound path. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 75]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), Some(2)); + assert_eq!(s.execute(TRIM, &json!({"at": [100, 125]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), Some(1)); + assert_eq!(s.execute(TRIM, &json!({"at": [200, 100]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), None, "the emptied compound path is removed"); + assert!(stack(&s).is_empty()); +} + +#[test] +fn the_selection_stays_as_it_was_and_the_pieces_of_a_selected_path_are_selected() { + let mut s = session(); + let (_, v1, _) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [v1.0]})).unwrap(); + s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [v1], "the trimmed path was not selected"); + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [h.0]})).unwrap(); + let made = ids(&s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()); + assert_eq!(made.len(), 2); + let sel = &s.doc().unwrap().selection; + assert!(made.iter().all(|id| sel.contains(*id)), "{:?}", sel.objects); + // The second piece now ends on an upright: nothing crosses it, and it goes whole. + s.execute("select.set", &json!({"ids": [made[1].0]})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 100]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(s.doc().unwrap().selection.is_empty(), "a removed path leaves the selection"); +} + +#[test] +fn many_paths_far_from_the_trimmed_one_cost_no_crossing_search() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + // 3,000 short lines far below, each crossing its neighbor. + s.edit("many", |d, _| { + let layer = d.layers[0].id; + for i in 0..3000 { + let x = (i % 100) as f64 * 3.0; + let y = 1000.0 + (i / 100) as f64 * 3.0; + let sp = vectorcraft_geom::SubPath::polyline( + &[Point::new(x, y), Point::new(x + 5.0, y + 5.0), Point::new(x + 5.0, y), Point::new(x, y + 5.0)], + 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(); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + // A rectangle over all of them takes every stretch it touches. + let r = s.execute(TRIM, &json!({"rect": [-10, 990, 400, 200]})).unwrap(); + assert!(r["removed"].as_u64().unwrap() >= 3000, "{}", r["removed"]); + assert_eq!(stack(&s).len(), 4, "the line across in two pieces and the uprights"); +} + +#[test] +fn the_tool_trims_on_release_as_one_step_and_escape_drops_a_drag() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false); + let view = crate::ViewInfo { smart_guides: false, ..crate::ViewInfo::default() }; + s.select_tool("trimAtCrossings", view).unwrap(); + assert_eq!(s.tool_id(), "trimAtCrossings"); + let n = steps(&s); + // A drag over the middle of both lines across: nothing changes until the release. + s.pointer(&PointerEvent::new(PointerKind::Down, 140.0, 90.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 130.0), view).unwrap(); + assert!(near(ends(&s, h).1, 300.0, 100.0)); + s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 130.0), view).unwrap(); + assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0)); + assert_eq!(steps(&s), n + 1, "one undo step for the drag"); + // Escape during a drag: the release removes nothing. + s.pointer(&PointerEvent::new(PointerKind::Down, 90.0, 40.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), view).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), view).unwrap(); + assert_eq!(steps(&s), n + 1); + assert!(near(ends(&s, v1).0, 100.0, 50.0)); + // A click on the upright's top stretch removes it. + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 70.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 70.0), view).unwrap(); + assert!(near(ends(&s, v1).0, 100.0, 100.0)); + assert_eq!(steps(&s), n + 2); +} + +#[test] +fn a_line_too_long_to_check_against_the_paths_is_refused() { + let mut s = session(); + // A zigzag of 1,000 segments, and a line of 100,000 points inside its box that crosses none. + let zigzag: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64 * 0.3, (i % 2) as f64 * 200.0)).collect(); + let z = line(&mut s, &zigzag, false); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + let points: Vec = (0..100_000).map(|i| json!([150.0 + (i % 2) as f64 * 0.01, 150.0])).collect(); + let err = s.execute(TRIM, &json!({ "points": points })).unwrap_err().to_string(); + assert!(err.contains("too long"), "{err}"); + assert_eq!(steps(&s), n); + assert!(*s.doc().unwrap().doc == *before); + // A short line there is checked, and removes nothing. + assert_eq!(s.execute(TRIM, &json!({"points": [[150.0, 150.0], [150.01, 150.0]]})).unwrap()["removed"], json!(0)); + assert!(exists(&s, z)); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let mut s = session(); + across_and_uprights(&mut s); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + for bad in [ + json!({}), + json!({"at": null}), + json!({"at": [150]}), + json!({"at": [150, "y"]}), + json!({"at": [150, 1e308]}), + json!({"at": [150, 100], "tolerance": -1}), + json!({"at": [150, 100], "tolerance": 101}), + json!({"at": [150, 100], "tolerance": "3"}), + json!({"at": [150, 100], "rect": [140, 90, 20, 40]}), + json!({"rect": [140, 90, 20]}), + json!({"rect": "all"}), + json!({"rect": [0, 0, 1e9, 5]}), + json!({"points": [[150, 80]]}), + json!({"points": [[150, 80], [150]]}), + json!({"points": "x"}), + json!({"points": [[150, 80], [150, 130]], "at": [1, 2]}), + ] { + assert!(s.execute(TRIM, &bad).is_err(), "{bad}"); + } + assert_eq!(steps(&s), n); + assert!(*s.doc().unwrap().doc == *before); +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 8071c1df3d..8af5ef07ea 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -58,6 +58,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.pathtools.shape.set", "addon.pathtools.slideAnchor", "addon.pathtools.smooth", + "addon.pathtools.trim", "addon.pen.append", "addon.pen.close", "addon.pen.closeJoin", diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 6577754de0..13803e03aa 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -847,6 +847,14 @@ fn structured_junk() { ("addon.pathtools.movePointsTogether", json!({"enabled": true})), ("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})), ("addon.pathtools.movePointsTogether", json!({"enabled": false})), + // Path Precision Tools: Trim at Crossings. + ("addon.pathtools.trim", json!({"at": [1e308, -1e308], "tolerance": 1e308})), + ("addon.pathtools.trim", json!({"at": ["x", null], "rect": [0, 0, f64::NAN, 1], "points": "all"})), + ("addon.pathtools.trim", json!({"rect": [-1e7, -1e7, 2e7, 2e7]})), + ("addon.pathtools.trim", json!({"rect": [0, 0, -1e308, 1e308]})), + ("addon.pathtools.trim", json!({"points": [[0, 0], [1e7, 1e7], [-1e7, 5], [0, 0]]})), + ("addon.pathtools.trim", json!({"points": [[0, 0], [1, "y"], null]})), + ("addon.pathtools.trim", json!({"at": [50, 50], "tolerance": 100})), // Arrange Tools add-on. ("addon.arrange.grid", json!({"columns": u64::MAX, "gapX": 1e6, "gapY": -1e6, "centerOnArtboard": true})), ("addon.arrange.grid", json!({"layout": "row", "gapX": f64::MAX, "columns": -1})), diff --git a/crates/pathops/src/addons/pathtools.rs b/crates/pathops/src/addons/pathtools.rs index 1de342ffc8..a20a4a5b36 100644 --- a/crates/pathops/src/addons/pathtools.rs +++ b/crates/pathops/src/addons/pathtools.rs @@ -2,9 +2,9 @@ //! rule, equalise handles, add evenly spaced anchors, close), what its live path effects add to them //! ([`add_anchors`], [`smooth_anchors`], [`split_at_anchors`]), the readouts the Measure & Inspect //! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]), the places -//! where paths cross ([`crossings`]), anchors slid along their path ([`slide`]), touching edges -//! merged ([`shared_edge`]), paths divided into dashes ([`divide_dashes`]) and the Parametric Shape -//! Tool's shapes ([`shapes`]). +//! where paths cross ([`crossings`]), the stretches between them ([`spans`]), anchors slid along +//! their path ([`slide`]), touching edges merged ([`shared_edge`]), paths divided into dashes +//! ([`divide_dashes`]) and the Parametric Shape Tool's shapes ([`shapes`]). mod crossings; mod dashes; @@ -14,19 +14,21 @@ mod shared_edge; mod slide; #[cfg(test)] mod tests; +mod trim; use std::collections::BTreeSet; use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Vec2}; use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; -pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at}; +pub use crossings::{MAX_CROSSED_BY_SEGMENTS, MAX_CROSSING_SEGMENTS, Place, Work, crossings, crossings_on, crossings_on_within, insert_at}; pub use dashes::{DashSpec, Piece, divide_dashes}; pub use extend::{ExtendMode, extend_length_to, extend_open}; pub use shared_edge::{ COINCIDENCE, EndSide, Follow, MAX_COINCIDENCE, MergePlan, MergePreview, Stretch, apply_merge, ends_at, merge_preview, stretches_at, }; pub use slide::{MAX_SLIDE, slide_anchor, slide_stops, slide_t_at}; +pub use trim::{Span, slice, span_at, spans, without_spans}; use crate::fit::{end_tangent, start_tangent}; diff --git a/crates/pathops/src/addons/pathtools/crossings.rs b/crates/pathops/src/addons/pathtools/crossings.rs index d216a2f4b3..c20e559d96 100644 --- a/crates/pathops/src/addons/pathtools/crossings.rs +++ b/crates/pathops/src/addons/pathtools/crossings.rs @@ -1,9 +1,10 @@ //! Where paths cross: [`crossings`] finds the places, [`insert_at`] puts anchors there (which -//! Split / Add Points at Crossings then keeps or cuts at). +//! Split / Add Points at Crossings then keeps or cuts at), and [`crossings_on`] finds the places on +//! one path (which Trim at Crossings removes stretches between). use std::collections::{BTreeMap, BTreeSet}; -use kurbo::{CubicBez, ParamCurve, ParamCurveNearest, Point, Rect}; +use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point, Rect}; use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; /// Most segments [`crossings`] compares at once. @@ -85,16 +86,82 @@ fn segment_crossings(a: &CubicBez, b: &CubicBez, budget: &mut usize) -> Option bool { - let n = sp.segment_count(); - i.abs_diff(j) == 1 || (sp.closed && n > 1 && i.abs_diff(j) == n - 1) +/// Where segment `c` crosses itself, as its two parameters in order: a cubic loops over itself at +/// most once. `None` when it doesn't, or only at a cusp or an end. +fn self_crossing(c: &CubicBez) -> Option<(f64, f64)> { + // In powers of t, c(t) = a t³ + b t² + k t + p0. Two parameters s and t meet where + // a (s² + st + t²) + b (s + t) + k = 0: with u = s + t and v = st, crossing that with `a` gives u, + // and dotting it with `a` gives v; s and t are the roots of z² - u z + v. + let (p0, p1, p2, p3) = (c.p0.to_vec2(), c.p1.to_vec2(), c.p2.to_vec2(), c.p3.to_vec2()); + let a = p3 - p2 * 3.0 + p1 * 3.0 - p0; + let b = (p2 - p1 * 2.0 + p0) * 3.0; + let k = (p1 - p0) * 3.0; + let (ab, aa) = (a.cross(b), a.dot(a)); + // A curve with no loop can leave these near zero, however long and thin a loop is: the checks + // at the end drop what that gives. + if ab == 0.0 || aa == 0.0 { + return None; + } + let u = -a.cross(k) / ab; + let v = u * u + (b * u + k).dot(a) / aa; + let disc = u * u - 4.0 * v; + if !disc.is_finite() || disc <= 1e-12 { + return None; + } + let (s, t) = ((u - disc.sqrt()) / 2.0, (u + disc.sqrt()) / 2.0); + let inside = |x: f64| END < x && x < 1.0 - END; + // The two places are one point, and the curve passes it in two directions (a straight curve + // that doubles back on itself meets itself along its length, not at a crossing). + let (ds, dt) = (c.deriv().eval(s).to_vec2(), c.deriv().eval(t).to_vec2()); + let across = ds.cross(dt).abs() > 1e-15 * ds.hypot() * dt.hypot(); + (inside(s) && inside(t) && across && c.eval(s).distance(c.eval(t)) < SAME).then_some((s, t)) } -/// Where `paths` cross one another (and, with `self_crossings`, where each crosses itself): for -/// each path, the places on it, in order. Segments that meet at an anchor they share don't count as -/// crossing there. `None` past [`MAX_CROSSING_SEGMENTS`] segments or when the curves are too tangled -/// to sort out, or when a coordinate isn't finite. +/// For each segment of `sp`, and one past the last, the first segment at or after it that has a +/// length (the segment count when none has): segments with none lie at one point, between anchors +/// that coincide. +fn with_length_from(sp: &SubPath) -> Vec { + let n = sp.segment_count(); + let mut out = vec![n; n + 1]; + for i in (0..n).rev() { + let c = sp.segment(i); + let next = if control_box(&c).size().to_vec2().hypot() < SAME { out.get(i + 1).copied().unwrap_or(n) } else { i }; + if let Some(o) = out.get_mut(i) { + *o = next; + } + } + out +} + +/// The anchors segments `i` and `j` of `sp` share (`with_length` from [`with_length_from`]): none, +/// one, or both ends for the two segments of a closed subpath of two. Segments with no length between +/// them leave them meeting at that point. +fn shared_anchors(sp: &SubPath, with_length: &[usize], i: usize, j: usize) -> Vec { + let (n, lo, hi) = (sp.segment_count(), i.min(j), i.max(j)); + let from = |k: usize| with_length.get(k).copied().unwrap_or(n); + let mut out = vec![]; + if hi > lo && from(lo + 1) >= hi { + out.extend([sp.segment(lo).p3, sp.segment(hi).p0]); + } + // Round the first anchor of a closed subpath. + if sp.closed && n > 1 && hi > lo && from(hi + 1) >= n && from(0) >= lo { + out.extend([sp.segment(hi).p3, sp.segment(lo).p0]); + } + out +} + +/// Where segments `a` and `b` cross, without the places at the anchors in `shared`: two segments that +/// meet at an anchor don't cross there, but they can cross again elsewhere. +fn crossings_apart(a: &CubicBez, b: &CubicBez, shared: &[Point], budget: &mut usize) -> Option> { + let mut found = segment_crossings(a, b, budget)?; + found.retain(|(ta, _)| !shared.iter().any(|p| a.eval(*ta).distance(*p) < SAME)); + Some(found) +} + +/// Where `paths` cross one another (and, with `self_crossings`, where each crosses itself, a segment +/// that loops over itself included): for each path, the places on it, in order. Segments that meet +/// at an anchor they share don't count as crossing there. `None` past [`MAX_CROSSING_SEGMENTS`] +/// segments or when the curves are too tangled to sort out, or when a coordinate isn't finite. pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option>> { let mut segs: Vec<(usize, usize, usize, CubicBez, Rect)> = vec![]; for (k, path) in paths.iter().enumerate() { @@ -111,19 +178,22 @@ pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option>> = paths.iter().map(|p| p.subpaths.iter().map(with_length_from).collect()).collect(); let mut out: Vec> = vec![vec![]; paths.len()]; let mut budget = MAX_STEPS; for (i, (ka, sa, ia, ca, ra)) in segs.iter().enumerate() { + if self_crossings && let Some((s, t)) = self_crossing(ca) { + out.get_mut(*ka)?.extend([(*sa, *ia, s), (*sa, *ia, t)]); + } for (kb, sb, ib, cb, rb) in segs.iter().skip(i + 1) { - if ka == kb - && (!self_crossings || (sa == sb && paths.get(*ka).and_then(|p| p.subpaths.get(*sa)).is_some_and(|sp| adjacent(sp, *ia, *ib)))) - { + if (ka == kb && !self_crossings) || !ra.inflate(SAME, SAME).overlaps(*rb) { continue; } - if !ra.inflate(SAME, SAME).overlaps(*rb) { - continue; - } - for (ta, tb) in segment_crossings(ca, cb, &mut budget)? { + let shared = match (paths.get(*ka).and_then(|p| p.subpaths.get(*sa)), with_length.get(*ka).and_then(|w| w.get(*sa))) { + (Some(sp), Some(w)) if ka == kb && sa == sb => shared_anchors(sp, w, *ia, *ib), + _ => vec![], + }; + for (ta, tb) in crossings_apart(ca, cb, &shared, &mut budget)? { out.get_mut(*ka)?.push((*sa, *ia, ta)); out.get_mut(*kb)?.push((*sb, *ib, tb)); } @@ -136,6 +206,101 @@ pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option Self { + Self { pairs: MAX_PAIRS, steps: MAX_STEPS } + } +} + +/// Where `others` cross `path`, and where `path` crosses itself (a segment that loops over itself +/// included): the places on `path`, in order, within the work of [`Work::default`]. +pub fn crossings_on(path: &PathData, others: &[&PathData]) -> Option> { + crossings_on_within(path, others, &mut Work::default()) +} + +/// [`crossings_on`], drawing on `work`. Only the segments of `others` whose control boxes meet the +/// box of `path` are compared with it, so the paths far from it cost one box test per segment. +/// Segments of `path` that meet at an anchor they share don't count as crossing there, and a +/// segment of `others` with a coordinate that isn't finite is left out. `None` when `path` has more +/// than [`MAX_CROSSING_SEGMENTS`] segments or a coordinate that isn't finite, when more than +/// [`MAX_CROSSED_BY_SEGMENTS`] segments of `others` lie in its box, when the pairs of segments to +/// look at are more than `work` has left, or when the curves are too tangled to sort out in the +/// steps it has left. +pub fn crossings_on_within(path: &PathData, others: &[&PathData], work: &mut Work) -> Option> { + let mut own: Vec<(usize, usize, CubicBez, Rect)> = vec![]; + for (si, sp) in path.subpaths.iter().enumerate() { + for s in 0..sp.segment_count() { + let c = sp.segment(s); + if ![c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()) { + return None; + } + own.push((si, s, c, control_box(&c))); + if own.len() > MAX_CROSSING_SEGMENTS { + return None; + } + } + } + let Some(reach) = own.iter().map(|o| o.3).reduce(|a, b| a.union(b)).map(|r| r.inflate(SAME, SAME)) else { return Some(vec![]) }; + let mut near: Vec<(CubicBez, Rect)> = vec![]; + for sp in others.iter().flat_map(|p| &p.subpaths) { + for s in 0..sp.segment_count() { + let c = sp.segment(s); + let r = control_box(&c); + if [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()) && reach.overlaps(r) { + near.push((c, r)); + if near.len() > MAX_CROSSED_BY_SEGMENTS { + return None; + } + } + } + } + work.pairs = work.pairs.checked_sub(own.len().saturating_mul(near.len() + own.len() / 2))?; + let with_length: Vec> = path.subpaths.iter().map(with_length_from).collect(); + let mut out: Vec = vec![]; + let budget = &mut work.steps; + for (i, (sa, ia, ca, ra)) in own.iter().enumerate() { + let ra = ra.inflate(SAME, SAME); + if let Some((s, t)) = self_crossing(ca) { + out.extend([(*sa, *ia, s), (*sa, *ia, t)]); + } + for (cb, rb) in &near { + if ra.overlaps(*rb) { + out.extend(segment_crossings(ca, cb, budget)?.into_iter().map(|(ta, _)| (*sa, *ia, ta))); + } + } + for (sb, ib, cb, rb) in own.iter().skip(i + 1) { + if !ra.overlaps(*rb) { + continue; + } + let shared = match (path.subpaths.get(*sa), with_length.get(*sa)) { + (Some(sp), Some(w)) if sa == sb => shared_anchors(sp, w, *ia, *ib), + _ => vec![], + }; + for (ta, tb) in crossings_apart(ca, cb, &shared, budget)? { + out.push((*sa, *ia, ta)); + out.push((*sb, *ib, tb)); + } + } + } + out.sort_by(|x, y| (x.0, x.1).cmp(&(y.0, y.1)).then(x.2.total_cmp(&y.2))); + out.dedup_by(|x, y| (x.0, x.1) == (y.0, y.1) && (x.2 - y.2).abs() < END); + Some(out) +} + /// `sp` with an anchor at each of `places` (segment, t), the shape unchanged → (the subpath, the /// indexes of the anchors at those places: the new ones, and existing ones where a place falls on /// an anchor). Places off the subpath are left out. diff --git a/crates/pathops/src/addons/pathtools/tests.rs b/crates/pathops/src/addons/pathtools/tests.rs index 121fe6653c..936dfc323e 100644 --- a/crates/pathops/src/addons/pathtools/tests.rs +++ b/crates/pathops/src/addons/pathtools/tests.rs @@ -434,3 +434,231 @@ fn splitting_reads_an_anchors_kind_from_its_handles() { assert_eq!(split_at_anchors(&sq, true, false).len(), 3); assert!(split_at_anchors(&sq, true, false).iter().all(|p| !p.closed)); } + +#[test] +fn crossings_on_finds_one_paths_places_among_many() { + // A horizontal line crossed by two uprights, with a self-crossing bow far away. + let across = PathData::single(line(&[(0.0, 50.0), (300.0, 50.0)], false)); + let up1 = PathData::single(line(&[(100.0, 0.0), (100.0, 100.0)], false)); + let up2 = PathData::single(line(&[(200.0, 0.0), (200.0, 100.0)], false)); + let far = PathData::single(line(&[(1000.0, 0.0), (1100.0, 100.0), (1100.0, 0.0), (1000.0, 100.0)], false)); + let places = crossings_on(&across, &[&up1, &up2, &far]).unwrap(); + assert_eq!(places.len(), 2, "{places:?}"); + let x = |p: &Place| across.subpaths[p.0].segment(p.1).eval(p.2).x; + assert!(near(x(&places[0]), 100.0, 1e-6) && near(x(&places[1]), 200.0, 1e-6)); + // A path's crossings with itself count, once on each of the two segments. + let own = crossings_on(&far, &[&across]).unwrap(); + assert_eq!(own.iter().map(|p| p.1).collect::>(), [0, 2], "{own:?}"); + // A path that ends on another marks the other there, and its own end. + let stub = PathData::single(line(&[(150.0, 50.0), (150.0, 90.0)], false)); + assert_eq!(crossings_on(&across, &[&stub]).unwrap().len(), 1); + assert!(near(crossings_on(&stub, &[&across]).unwrap()[0].2, 0.0, 1e-6)); + // Lines lying along each other don't cross, and a path with a coordinate that isn't finite is refused. + let along = PathData::single(line(&[(50.0, 50.0), (250.0, 50.0)], false)); + assert!(crossings_on(&across, &[&along]).unwrap().is_empty()); + let broken = PathData::single(line(&[(0.0, f64::NAN), (10.0, 10.0)], false)); + assert!(crossings_on(&broken, &[&across]).is_none()); + assert_eq!(crossings_on(&across, &[&broken, &up1]).unwrap().len(), 1, "a broken path beside it is left out"); + assert!(crossings_on(&PathData::default(), &[&across]).unwrap().is_empty()); +} + +#[test] +fn segments_that_share_an_anchor_still_cross_elsewhere() { + // A straight segment, then a curve from its end that swings back across it near x = 17.4. + let hook = SubPath::new( + vec![Anchor::corner(pt(0.0, 0.0)), Anchor { h_out: pt(-50.0, 50.0), ..Anchor::corner(pt(100.0, 0.0)) }, Anchor::corner(pt(50.0, -50.0))], + false, + ); + let path = PathData::single(hook.clone()); + let places = crossings_on(&path, &[]).unwrap(); + assert_eq!(places.iter().map(|p| p.1).collect::>(), [0, 1], "{places:?}"); + let x = hook.segment(0).eval(places[0].2).x; + assert!(near(x, 17.4, 0.01), "{x}"); + assert!(hook.segment(1).eval(places[1].2).distance(pt(x, 0.0)) < 1e-3); + // Split at Crossings finds it too, and neither counts the anchor the segments share. + assert_eq!(crossings(&[&path], true).unwrap()[0], places); + // The two segments of a closed subpath share both ends: a lens has no crossing. + let lens = SubPath::new( + vec![ + Anchor { h_out: pt(30.0, -40.0), h_in: pt(30.0, 40.0), ..Anchor::corner(pt(0.0, 0.0)) }, + Anchor { h_in: pt(70.0, -40.0), h_out: pt(70.0, 40.0), ..Anchor::corner(pt(100.0, 0.0)) }, + ], + true, + ); + assert!(crossings_on(&PathData::single(lens.clone()), &[]).unwrap().is_empty()); + assert!(crossings(&[&PathData::single(lens)], true).unwrap()[0].is_empty()); +} + +#[test] +fn segments_with_no_length_between_them_still_share_their_anchor() { + // An open path with its middle anchor doubled, and a closed one whose last anchor sits on its + // first: the segments either side of the one with no length meet there and don't cross. + let doubled = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false)); + let ring = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)], true)); + for p in [&doubled, &ring] { + assert!(crossings_on(p, &[]).unwrap().is_empty(), "{:?}", crossings_on(p, &[])); + assert!(crossings(&[p], true).unwrap()[0].is_empty(), "{:?}", crossings(&[p], true)); + } + // A segment that crosses them elsewhere still does. + let bow = PathData::single(line(&[(0.0, 0.0), (100.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false)); + assert_eq!(crossings_on(&bow, &[]).unwrap().iter().map(|p| p.1).collect::>(), [0, 3]); +} + +#[test] +fn one_work_budget_bounds_several_calls_together() { + // 1,000 segments in a straight row: 500,000 pairs a call, so 120 calls spend 60,000,000. + let row: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64, 0.0)).collect(); + let path = PathData::single(line(&row, false)); + let mut work = Work::default(); + let calls = (0..200).take_while(|_| crossings_on_within(&path, &[], &mut work).is_some()).count(); + assert_eq!(calls, 120); + assert!(crossings_on(&path, &[]).is_some(), "a call of its own has its own budget"); +} + +#[test] +fn a_segment_that_loops_over_itself_crosses_itself() { + // One cubic that meets itself at t = 0.25 and t = 0.75. + let looped = PathData::single(SubPath::new( + vec![Anchor { h_out: pt(13.0 / 9.0, 1.0), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(0.0, 1.0), ..Anchor::corner(pt(1.0, 0.0)) }], + false, + )); + let places = crossings_on(&looped, &[]).unwrap(); + assert_eq!(places.len(), 2, "{places:?}"); + assert!(near(places[0].2, 0.25, 1e-9) && near(places[1].2, 0.75, 1e-9), "{places:?}"); + assert_eq!(crossings(&[&looped], true).unwrap()[0], places); + assert!(crossings(&[&looped], false).unwrap()[0].is_empty()); + // Its two visits to that point divide it into three stretches, and the loop can go alone. + let sp = &looped.subpaths[0]; + let three = spans(sp, &places.iter().map(|p| (p.1, p.2)).collect::>()); + assert_eq!(three.len(), 3, "{three:?}"); + let left = without_spans(sp, &three, &[false, true, false]).unwrap(); + assert_eq!(left.len(), 2); + let x = sp.segment(0).eval(0.25); + assert_eq!((left[0].anchors[0].p, left[1].anchors.last().unwrap().p), (pt(0.0, 0.0), pt(1.0, 0.0))); + assert!(left[0].anchors.last().unwrap().p.distance(x) < 1e-3 && left[1].anchors[0].p.distance(x) < 1e-3); + // The same loop squashed to a 10⁻¹² of its height. + let flat = PathData::single(SubPath::new( + vec![ + Anchor { h_out: pt(13.0 / 9.0, 1e-12), ..Anchor::corner(pt(0.0, 0.0)) }, + Anchor { h_in: pt(0.0, 1e-12), ..Anchor::corner(pt(1.0, 0.0)) }, + ], + false, + )); + assert_eq!(crossings_on(&flat, &[]).unwrap().iter().map(|p| (p.2 * 4.0).round()).collect::>(), [1.0, 3.0]); + // An arch, an S and a cusp don't cross themselves. + for (h1, h2) in [((0.0, 1.0), (1.0, 1.0)), ((0.3, 1.0), (0.7, -1.0)), ((1.0, 1.0), (0.0, 1.0))] { + let c = PathData::single(SubPath::new( + vec![Anchor { h_out: pt(h1.0, h1.1), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(h2.0, h2.1), ..Anchor::corner(pt(1.0, 0.0)) }], + false, + )); + assert!(crossings_on(&c, &[]).unwrap().is_empty(), "{h1:?} {h2:?}"); + } + // Nor does a straight curve that doubles back along itself, at a slant. + let back = PathData::single(SubPath::new( + vec![Anchor { h_out: pt(1.6, 1.2), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(-0.8, -0.6), ..Anchor::corner(pt(0.8, 0.6)) }], + false, + )); + assert!(crossings_on(&back, &[]).unwrap().is_empty()); +} + +#[test] +fn spans_divide_a_subpath_at_its_places() { + let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + assert_eq!(spans(&open, &[]), [(0.0, 2.0)]); + assert_eq!(spans(&open, &[(1, 0.5), (0, 0.25)]), [(0.0, 0.25), (0.25, 1.5), (1.5, 2.0)]); + // Places at an open end, off the subpath, or twice at one point, add no stretch. + assert_eq!(spans(&open, &[(0, 0.0), (1, 1.0), (7, 0.5), (0, 0.5), (0, 0.5 + 1e-9), (0, f64::NAN)]), [(0.0, 0.5), (0.5, 2.0)]); + // A place at an anchor is the same from either segment. + assert_eq!(spans(&open, &[(0, 1.0), (1, 0.0)]), [(0.0, 1.0), (1.0, 2.0)]); + let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true); + assert_eq!(spans(&ring, &[]), [(0.0, 4.0)]); + assert_eq!(spans(&ring, &[(1, 0.5)]), [(1.5, 5.5)], "one place: from it round to it again"); + assert_eq!(spans(&ring, &[(3, 0.5), (0, 0.5)]), [(0.5, 3.5), (3.5, 4.5)]); + // The closing segment's end is the first anchor. + assert_eq!(spans(&ring, &[(3, 1.0), (0, 0.0), (2, 0.0)]), [(0.0, 2.0), (2.0, 4.0)]); + assert!(spans(&SubPath::new(vec![Anchor::corner(pt(1.0, 1.0))], false), &[(0, 0.5)]).is_empty()); + // Which stretch a place is on: the one that starts there, the last one at an open end. + let three = spans(&open, &[(1, 0.5), (0, 0.25)]); + assert_eq!(span_at(&open, &three, 0, 0.1), Some(0)); + assert_eq!(span_at(&open, &three, 0, 0.25), Some(1)); + assert_eq!(span_at(&open, &three, 1, 1.0), Some(2)); + assert_eq!(span_at(&open, &three, 0, f64::NAN), None); + let two = spans(&ring, &[(3, 0.5), (0, 0.5)]); + assert_eq!((span_at(&ring, &two, 0, 0.25), span_at(&ring, &two, 3, 0.75), span_at(&ring, &two, 2, 0.0)), (Some(1), Some(1), Some(0))); +} + +#[test] +fn a_subpath_without_some_stretches_keeps_the_rest_as_it_was() { + // An open line of three segments, crossed twice on its middle one: the middle stretch goes. + let open = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0)], false); + let three = spans(&open, &[(1, 0.25), (1, 0.75)]); + let left = without_spans(&open, &three, &[false, true, false]).unwrap(); + let xs = |sp: &SubPath| sp.anchors.iter().map(|a| a.p.x).collect::>(); + // A straight segment's parameter doesn't run evenly along it: the cuts are where it puts them. + let (q1, q3) = (open.segment(1).eval(0.25).x, open.segment(1).eval(0.75).x); + assert!(100.0 < q1 && q1 < q3 && q3 < 200.0); + assert_eq!(left.iter().map(xs).collect::>(), [vec![0.0, 100.0, q1], vec![q3, 200.0, 300.0]]); + assert!(left.iter().all(|p| !p.closed && p.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p)), "straight pieces have no handles"); + // Nothing marked: unchanged. All marked: nothing left. An end stretch: one piece. + assert!(without_spans(&open, &three, &[false, false, false]).is_none()); + assert!(without_spans(&open, &three, &[]).is_none()); + assert_eq!(without_spans(&open, &three, &[true, true, true]), Some(vec![])); + assert_eq!(without_spans(&open, &three, &[true, false, false]).unwrap().iter().map(xs).collect::>(), [vec![q1, 200.0, 300.0]]); + // Stretches left side by side stay one piece with no anchor where they meet. + let four = spans(&open, &[(0, 0.5), (1, 0.5), (2, 0.5)]); + assert_eq!( + without_spans(&open, &four, &[false, false, false, true]).unwrap().iter().map(xs).collect::>(), + [vec![0.0, 100.0, 200.0, 250.0]] + ); + // A closed square that loses a stretch opens, and its first anchor stays inside the piece. + let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true); + let two = spans(&ring, &[(1, 0.5), (2, 0.5)]); + let open_ring = without_spans(&ring, &two, &[true, false]).unwrap(); + assert_eq!(open_ring.len(), 1); + let pts: Vec = open_ring[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts, [pt(50.0, 100.0), pt(0.0, 100.0), pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 50.0)]); + assert!(!open_ring[0].closed); + // The same when the removed stretch is the one through the first anchor. + let through = without_spans(&ring, &two, &[false, true]).unwrap(); + assert_eq!(through[0].anchors.iter().map(|a| a.p).collect::>(), [pt(100.0, 50.0), pt(100.0, 100.0), pt(50.0, 100.0)]); + // Three places: the two stretches left on both sides of the first anchor are one piece through it. + let three = spans(&ring, &[(0, 0.5), (1, 0.5), (2, 0.5)]); + assert_eq!(three, [(0.5, 1.5), (1.5, 2.5), (2.5, 4.5)]); + let around = without_spans(&ring, &three, &[false, true, false]).unwrap(); + assert_eq!(around.len(), 1, "{around:?}"); + let pts: Vec = around[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts, [pt(50.0, 100.0), pt(0.0, 100.0), pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 50.0)]); + // A closed subpath with one place loses all of itself. + assert_eq!(without_spans(&ring, &spans(&ring, &[(0, 0.5)]), &[true]), Some(vec![])); +} + +#[test] +fn a_slice_of_a_curve_keeps_its_shape_and_the_anchors_it_runs_through() { + let mut circle = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true); + for i in 0..4 { + auto_smooth(&mut circle, i, SMOOTH_RATIO); + } + let piece = slice(&circle, (0.5, 2.5)); + assert_eq!(piece.anchors.len(), 4); + assert!(!piece.closed); + // Its ends are corners with the handle off the piece retracted; the anchors between stay smooth, and + // the whole segment between them keeps its handles. + assert_eq!((piece.anchors[0].kind, piece.anchors[3].kind), (AnchorKind::Corner, AnchorKind::Corner)); + assert_eq!((piece.anchors[0].h_in, piece.anchors[3].h_out), (piece.anchors[0].p, piece.anchors[3].p)); + assert_eq!((piece.anchors[1].p, piece.anchors[2].p), (circle.anchors[1].p, circle.anchors[2].p)); + assert_eq!((piece.anchors[1].h_out, piece.anchors[2].h_in), (circle.anchors[1].h_out, circle.anchors[2].h_in)); + assert_eq!((piece.anchors[1].kind, piece.anchors[2].kind), (AnchorKind::Smooth, AnchorKind::Smooth)); + // Every point of the piece is on the circle's outline. + for i in 0..piece.segment_count() { + for k in 0..=8 { + let p = piece.segment(i).eval(k as f64 / 8.0); + let d = PathData::single(circle.clone()).nearest(p).unwrap().4; + assert!(d < 1e-6, "{p:?} is {d} off"); + } + } + assert!(near(length(&piece), length(&circle) / 2.0, 1e-3)); + // No length, backwards or not finite: nothing. + assert!(slice(&circle, (1.0, 1.0)).anchors.is_empty()); + assert!(slice(&circle, (2.0, 1.0)).anchors.is_empty()); + assert!(slice(&circle, (f64::NAN, 1.0)).anchors.is_empty()); +} diff --git a/crates/pathops/src/addons/pathtools/trim.rs b/crates/pathops/src/addons/pathtools/trim.rs new file mode 100644 index 0000000000..18a2a5640b --- /dev/null +++ b/crates/pathops/src/addons/pathtools/trim.rs @@ -0,0 +1,151 @@ +//! The stretches of a subpath between the places it is crossed ([`spans`]), the stretch a place +//! lies on ([`span_at`]), a stretch as a subpath of its own ([`slice`]) and the subpath with some +//! of its stretches removed ([`without_spans`]). + +use kurbo::{ParamCurve, Point}; +use vectorcraft_geom::{Anchor, AnchorKind, SubPath}; + +/// A parameter this close to a segment's end is its anchor. +const END: f64 = 1e-6; +/// Places closer than this (pt) are one. +const SAME: f64 = 1e-3; + +/// A stretch of a subpath: where it starts and ends, each a segment's index plus the parameter on +/// that segment. On a closed subpath the end is past the segment count where the stretch runs +/// through the first anchor. +pub type Span = (f64, f64); + +/// The point at position `u` (a segment's index plus the parameter on it) of `sp`, which has +/// segments. A closed subpath's positions wrap round. +fn point_at(sp: &SubPath, u: f64) -> Point { + let n = sp.segment_count(); + let u = if sp.closed { u.rem_euclid(n as f64) } else { u.clamp(0.0, n as f64) }; + let seg = (u.floor() as usize).min(n.saturating_sub(1)); + sp.segment(seg).eval(u - seg as f64) +} + +/// The stretches `places` (segment, t) divide `sp` into, in the subpath's order. An open subpath +/// starts and ends a stretch at its ends; a closed one with no place is one stretch, the whole of +/// it, and so is a closed one with one place, from that place round to it again. Places the subpath +/// runs between without moving 0.001 pt away are one (a loop's two visits to a point are two), so +/// are a place and an open end, and places off the subpath are left out. +pub fn spans(sp: &SubPath, places: &[(usize, f64)]) -> Vec { + let n = sp.segment_count(); + if n == 0 { + return vec![]; + } + let nf = n as f64; + let mut at: Vec = places + .iter() + .filter(|(seg, t)| *seg < n && t.is_finite()) + .map(|&(seg, t)| { + let t = if t <= END { + 0.0 + } else if t >= 1.0 - END { + 1.0 + } else { + t + }; + let u = seg as f64 + t; + if sp.closed && u >= nf { u - nf } else { u } + }) + .collect(); + at.sort_by(f64::total_cmp); + // Two positions are one place when the subpath stays at the same point between them; a loop + // that comes back to a point visits it twice. + let same = |a: f64, b: f64| { + let p = point_at(sp, a); + (b - a).abs() < 1.0 && [0.25, 0.5, 0.75, 1.0].iter().all(|f| point_at(sp, a + (b - a) * f).distance(p) < SAME) + }; + at.dedup_by(|later, earlier| same(*earlier, *later)); + if !sp.closed { + at.retain(|u| !same(0.0, *u) && !same(*u, nf)); + let bounds: Vec = std::iter::once(0.0).chain(at).chain([nf]).collect(); + return bounds.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect(); + } + // The last place and the first meet round the first anchor. + while let (Some(&first), Some(&last)) = (at.first(), at.last()) + && at.len() > 1 + && same(last - nf, first) + { + at.pop(); + } + let Some(&first) = at.first() else { return vec![(0.0, nf)] }; + at.push(first + nf); + at.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() +} + +/// Which of `stretches` of `sp` the place (`seg`, `t`) lies on. A place where two stretches meet +/// is on the one that starts there. +pub fn span_at(sp: &SubPath, stretches: &[Span], seg: usize, t: f64) -> Option { + let nf = sp.segment_count() as f64; + if !t.is_finite() { + return None; + } + let u = (seg as f64 + t.clamp(0.0, 1.0)).min(nf); + let on = |&(from, to): &Span| (from <= u && u < to) || (sp.closed && from <= u + nf && u + nf < to); + stretches.iter().position(on).or_else(|| (!sp.closed && u >= nf).then(|| stretches.len().checked_sub(1)).flatten()) +} + +/// The stretch `(from, to)` of `sp` as an open subpath of its own: the anchors it runs through keep +/// their kind and handles, and its ends are corners. Empty when the stretch has no length. +pub fn slice(sp: &SubPath, (from, to): Span) -> SubPath { + let n = sp.segment_count(); + let mut anchors: Vec = vec![]; + if n == 0 || !(from.is_finite() && to.is_finite()) || from < 0.0 || to <= from { + return SubPath::new(anchors, false); + } + let to = to.min(from + n as f64); + let mut i = from.floor() as usize; + while (i as f64) < to { + let (t0, t1) = ((from - i as f64).max(0.0), (to - i as f64).min(1.0)); + let seg = i % n; + i += 1; + if t1 <= t0 { + continue; + } + let whole = sp.segment(seg); + let c = if t0 <= 0.0 && t1 >= 1.0 { whole } else { whole.subsegment(t0..t1) }; + let (h1, h2) = if sp.segment_is_line(seg) { (c.p0, c.p3) } else { (c.p1, c.p2) }; + match anchors.last_mut() { + Some(a) => a.h_out = h1, + None => anchors.push(Anchor { p: c.p0, h_in: c.p0, h_out: h1, kind: AnchorKind::Corner }), + } + // An anchor the stretch runs through keeps its kind. + let through = t1 >= 1.0 && (i as f64) < to; + let kind = if through { sp.anchors.get((seg + 1) % sp.anchors.len()).map_or(AnchorKind::Corner, |a| a.kind) } else { AnchorKind::Corner }; + anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind }); + } + SubPath::new(anchors, false) +} + +/// `sp` without the stretches of `stretches` that `remove` marks: the pieces left, each an open +/// subpath, in the subpath's order. Stretches left side by side stay one piece, so no anchor is +/// added where a stretch that stays meets another. `None` when no stretch is marked, and no piece +/// when all are. +pub fn without_spans(sp: &SubPath, stretches: &[Span], remove: &[bool]) -> Option> { + let n = stretches.len(); + let gone = |i: usize| remove.get(i).copied().unwrap_or(false); + let first_gone = (0..n).find(|&i| gone(i))?; + let nf = sp.segment_count() as f64; + // A closed subpath is walked from its first removed stretch, so the stretches left on both + // sides of its first anchor are one piece. + let start = if sp.closed { first_gone } else { 0 }; + let mut out = vec![]; + let mut run: Option = None; + for k in 0..n { + let i = (start + k) % n; + let Some(&(from, to)) = stretches.get(i).filter(|_| !gone(i)) else { + out.extend(run.take().map(|r| slice(sp, r))); + continue; + }; + run = Some(match run { + // Past the first anchor of a closed subpath the positions start again. + Some((a, b)) => (a, if to < b { to + nf } else { to }), + None => (from, to), + }); + } + out.extend(run.map(|r| slice(sp, r))); + out.retain(|p| p.anchors.len() >= 2); + Some(out) +} diff --git a/crates/tools/src/addons.rs b/crates/tools/src/addons.rs index e88721b04e..c226a3c4d9 100644 --- a/crates/tools/src/addons.rs +++ b/crates/tools/src/addons.rs @@ -13,6 +13,7 @@ pub mod parametric_shape; pub mod shared_edge; pub mod slide_anchor; pub mod smart_pen; +pub mod trim; use crate::{Tool, ToolInfo}; @@ -25,6 +26,7 @@ pub const PATH_TOOLS: &[ToolInfo] = &[ ToolInfo { id: corner_tool::ID, label: "Corner Tool", shortcut: None, icon: "dc-rounded-rect" }, ToolInfo { id: slide_anchor::ID, label: "Slide Anchor Along Path", shortcut: None, icon: "dc-anchor" }, ToolInfo { id: shared_edge::ID, label: "Shared Edge Merge Tool", shortcut: None, icon: "dc-join" }, + ToolInfo { id: trim::ID, label: "Trim at Crossings Tool", shortcut: None, icon: "dc-pf-trim" }, ]; /// Smart Pen's tool, a flyout group of its own. @@ -60,6 +62,7 @@ pub fn create(id: &str) -> Option> { shared_edge::ID => Box::new(shared_edge::SharedEdgeTool::default()), slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()), smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()), + trim::ID => Box::new(trim::TrimTool::default()), enclosing_lasso::ID => Box::new(enclosing_lasso::EnclosingLassoTool::default()), contour::ID => Box::new(contour::ContourTool::default()), box3d::ID => Box::new(box3d::Box3dTool::default()), diff --git a/crates/tools/src/addons/trim.rs b/crates/tools/src/addons/trim.rs new file mode 100644 index 0000000000..610af9d47e --- /dev/null +++ b/crates/tools/src/addons/trim.rs @@ -0,0 +1,213 @@ +//! Trim at Crossings Tool (Path Precision Tools): a click removes the stretch of the path under the +//! pointer between the places where other paths, or the path itself, cross it; a drag draws a +//! rectangle and removes every stretch it touches; an Alt-drag draws a freehand line and removes +//! every stretch it crosses. Each runs `addon.pathtools.trim` on release, as one undo step, on +//! every path that can be edited, selected or not. Escape during a drag removes nothing. + +use serde_json::json; +use vectorcraft_geom::{Point, Rect}; + +use crate::draw2::FEEDBACK; +use crate::xform::polygon; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// The tool's id. +pub const ID: &str = "trimAtCrossings"; +/// How near the pointer a path counts as under it (screen pixels). +pub const REACH: f64 = 4.0; +/// A press that moves less than this many screen pixels is a click. +const MIN_DRAG_PX: f64 = 3.0; +/// The most points a freehand line keeps (one per screen pixel dragged). A longer one is dropped. +pub(crate) const MAX_POINTS: usize = 65_536; +/// The command the tool runs. +const COMMAND: &str = "addon.pathtools.trim"; + +#[derive(Default)] +pub struct TrimTool { + /// Where the press started, while the button is down. + start: Option, + end: Point, + /// An Alt-drag's freehand line (empty for a rectangle). + line: Vec, + freehand: bool, +} + +impl TrimTool { + fn reset(&mut self) { + self.start = None; + self.line.clear(); + self.freehand = false; + } +} + +impl Tool for TrimTool { + fn id(&self) -> &'static str { + ID + } + + fn busy(&self) -> bool { + self.start.is_some() + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + if !ev.pos.is_finite() { + if ev.kind == PointerKind::Up { + self.reset(); + } + return vec![]; + } + match ev.kind { + PointerKind::Down => { + self.start = Some(ev.pos); + self.end = ev.pos; + self.freehand = ev.mods.alt; + self.line = if self.freehand { vec![ev.pos] } else { vec![] }; + vec![] + } + PointerKind::Drag if self.start.is_some() => { + self.end = ev.pos; + 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 { + self.reset(); + return vec![]; + } + self.line.push(ev.pos); + } + vec![] + } + PointerKind::Up => { + let Some(start) = self.start else { return vec![] }; + let mut line = std::mem::take(&mut self.line); + let freehand = self.freehand; + 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) { + return vec![Action::Exec(COMMAND.into(), json!({"at": [start.x, start.y], "tolerance": cx.tol(REACH).min(100.0)}))]; + } + if !freehand { + let r = Rect::from_points(start, ev.pos); + return vec![Action::Exec(COMMAND.into(), json!({"rect": [r.x0, r.y0, r.width(), r.height()]}))]; + } + if line.last().is_some_and(|p| *p != ev.pos) { + line.push(ev.pos); + } + let points: Vec<_> = line.iter().map(|p| json!([p.x, p.y])).collect(); + if points.len() < 2 { vec![] } else { vec![Action::Exec(COMMAND.into(), json!({ "points": points }))] } + } + _ => vec![], + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape { + self.reset(); + } + vec![] + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.start.is_some() && key == ToolKey::Escape + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(start) = self.start else { return vec![] }; + if self.freehand { + return if self.line.len() < 2 { + vec![] + } else { + 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))] } + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Crosshair + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use vectorcraft_doc::Selection; + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + + #[test] + fn a_click_trims_at_the_point_with_nothing_selected() { + let (d, _) = doc_with_rect(); + let (sel, p) = (Selection::default(), paint()); + let mut cx = cx(&d, &sel, &p); + cx.zoom = 2.0; + let mut t = TrimTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)).is_empty()); + assert!(t.busy() && t.overlays(&cx).is_empty()); + let up = t.pointer(&cx, &ev(PointerKind::Up, 100.5, 100.0)); + assert_eq!(up, vec![Action::Exec(COMMAND.into(), json!({"at": [100.0, 100.0], "tolerance": 2.0}))], "4 px at 200%"); + assert!(!t.busy()); + assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::Crosshair); + } + + #[test] + fn a_drag_trims_what_its_rectangle_touches_and_escape_drops_it() { + 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, 160.0, 130.0)); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 140.0, 90.0)).is_empty(), "nothing runs while dragging"); + assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Marquee(r)] if *r == Rect::new(140.0, 90.0, 160.0, 130.0))); + let up = t.pointer(&cx, &ev(PointerKind::Up, 140.0, 90.0)); + assert_eq!(up, vec![Action::Exec(COMMAND.into(), json!({"rect": [140.0, 90.0, 20.0, 40.0]}))]); + // Escape during a drag: nothing runs on release, and the key is the tool's only then. + assert!(!t.claims_key(&cx, ToolKey::Escape)); + t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0)); + assert!(t.claims_key(&cx, ToolKey::Escape)); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy() && t.overlays(&cx).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 60.0, 60.0)).is_empty()); + } + + #[test] + fn an_alt_drag_trims_what_its_line_crosses() { + let (d, _) = doc_with_rect(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let mut t = TrimTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0).with_mods(alt)); + t.pointer(&cx, &ev(PointerKind::Drag, 10.2, 10.0).with_mods(alt)); + t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 30.0)); + assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Path { .. }])); + // Alt at the press decides; points less than a pixel apart are one. + let up = t.pointer(&cx, &ev(PointerKind::Up, 80.0, 30.0)); + assert_eq!(up, vec![Action::Exec(COMMAND.into(), json!({"points": [[10.0, 10.0], [40.0, 30.0], [80.0, 30.0]]}))]); + // An Alt-click is a click. + t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0).with_mods(alt)); + let up = t.pointer(&cx, &ev(PointerKind::Up, 10.0, 10.0).with_mods(alt)); + assert!(matches!(up.as_slice(), [Action::Exec(_, v)] if v.get("at").is_some()), "{up:?}"); + // So is one that moves a pixel or two, and one that goes away and comes back is a line. + t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0).with_mods(alt)); + t.pointer(&cx, &ev(PointerKind::Drag, 12.0, 10.0).with_mods(alt)); + let up = t.pointer(&cx, &ev(PointerKind::Up, 12.0, 11.0).with_mods(alt)); + assert_eq!(up, vec![Action::Exec(COMMAND.into(), json!({"at": [10.0, 10.0], "tolerance": 4.0}))]); + t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0).with_mods(alt)); + t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0).with_mods(alt)); + let up = t.pointer(&cx, &ev(PointerKind::Up, 10.0, 11.0).with_mods(alt)); + assert_eq!(up, vec![Action::Exec(COMMAND.into(), json!({"points": [[10.0, 10.0], [40.0, 10.0], [10.0, 11.0]]}))]); + // A line past the cap is dropped rather than cut short. + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + for i in 1..=MAX_POINTS { + t.pointer(&cx, &ev(PointerKind::Drag, i as f64 * 2.0, 0.0)); + } + assert!(!t.busy()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 0.0, 0.0)).is_empty()); + } +} diff --git a/crates/ui-egui/src/addons/pathtools.rs b/crates/ui-egui/src/addons/pathtools.rs index f59984ccdc..bfbcf0d5b3 100644 --- a/crates/ui-egui/src/addons/pathtools.rs +++ b/crates/ui-egui/src/addons/pathtools.rs @@ -15,7 +15,7 @@ pub mod shared_edge; use egui::Ui; use serde_json::{Map, Value, json}; use vectorcraft_geom::Point; -use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect, parametric_shape, slide_anchor}; +use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect, parametric_shape, slide_anchor, trim}; use crate::VectorcraftApp; use crate::dialogs::DialogSpec; @@ -60,6 +60,7 @@ pub fn menu() -> Vec { Item::Cmd("Set Corners…", set_corners::COMMAND, Value::Null), tool("Slide Anchor Along Path", slide_anchor::ID), Item::Cmd("Shared Edge Merge Tool", shared_edge::COMMAND, json!({})), + tool("Trim at Crossings Tool", trim::ID), Item::Sep, Item::Cmd("Anchor Inspector", "window.panel", json!({ "panel": ID })), Item::Cmd("Parametric Shape", "window.panel", json!({ "panel": shape::ID })), diff --git a/crates/ui-egui/src/addons/pathtools/tests.rs b/crates/ui-egui/src/addons/pathtools/tests.rs index 9ecf12062d..db4b39b6fc 100644 --- a/crates/ui-egui/src/addons/pathtools/tests.rs +++ b/crates/ui-egui/src/addons/pathtools/tests.rs @@ -22,6 +22,7 @@ fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() { ("Measure & Inspect Tool", "measureInspect"), ("Parametric Shape Tool", "parametricShape"), ("Slide Anchor Along Path", "slideAnchor"), + ("Trim at Crossings Tool", "trimAtCrossings"), ] { let e = under(label).expect(label); assert_eq!((e.command.as_deref(), &e.params), (Some("tool.select"), &json!({ "tool": tool }))); diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index cac6338418..c38cde007e 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -648,6 +648,7 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() { "cornerTool", "slideAnchor", "sharedEdge", + "trimAtCrossings", "smartPen", enclosing, contour, @@ -663,7 +664,7 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() { let listed = |id: &str| { sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == id)).map(|(h, tools)| (*h, tools.iter().map(|t| t.id).collect::>())) }; - assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..7].to_vec()))); + assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..8].to_vec()))); assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"]))); assert_eq!(listed(enclosing), Some(("Enclosing Lasso", vec![enclosing]))); assert_eq!(listed(contour), Some(("Contour", vec![contour]))); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 708ffe9db6..3f7df0402b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6276,6 +6276,8 @@ anchor Points: Точки: Anchor Inspector Инспектор на опорни точки Add Points at Crossings Добавяне на точки в пресичанията Split at Crossings Разделяне в пресичанията + Trim at Crossings Tool Инструмент „Подрязване в пресичанията“ + Trim at Crossings Подрязване в пресичанията Extend Path Удължаване на път Measure & Inspect Измерване и проверка Set Anchor Задаване на опорна точка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8f5ea73487..0e7be00ba2 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6326,6 +6326,8 @@ anchor Points: Tačke: Anchor Inspector Pregled sidara Add Points at Crossings Dodaj tačke na presjecima Split at Crossings Razdvoji na presjecima + Trim at Crossings Tool Alat obreži na presjecima + Trim at Crossings Obreži na presjecima Extend Path Produži putanju Measure & Inspect Mjeri i pregledaj Set Anchor Postavi sidro diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5516c9662f..bfff5d051f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6799,6 +6799,8 @@ anchor Points: Punts: Anchor Inspector Inspector de punts d’ancoratge Add Points at Crossings Afegeix punts als encreuaments Split at Crossings Divideix als encreuaments + Trim at Crossings Tool Eina de retallar als encreuaments + Trim at Crossings Retalla als encreuaments Extend Path Allarga el traçat Measure & Inspect Mesura i inspecciona Set Anchor Defineix el punt d’ancoratge diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d03d192a3a..661f9dcb9a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6830,6 +6830,8 @@ anchor Points: Body: Anchor Inspector Inspektor kotevních bodů Add Points at Crossings Přidat body v křížení Split at Crossings Rozdělit v křížení + Trim at Crossings Tool Nástroj Zastřihnout v křížení + Trim at Crossings Zastřihnout v křížení Extend Path Prodloužit cestu Measure & Inspect Měřit a zkoumat Set Anchor Nastavit kotevní bod diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00c85aaeb6..0309b70b30 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6292,6 +6292,8 @@ anchor Points: Punkter: Anchor Inspector Ankerpunktinspektør Add Points at Crossings Tilføj punkter ved krydsninger Split at Crossings Opdel ved krydsninger + Trim at Crossings Tool Værktøj til trimning ved krydsninger + Trim at Crossings Trim ved krydsninger Extend Path Forlæng sti Measure & Inspect Mål og undersøg Set Anchor Angiv ankerpunkt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 47337855aa..403abfc386 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6422,6 +6422,8 @@ anchor Points: Punkte: Anchor Inspector Ankerpunkt-Inspektor Add Points at Crossings Punkte an Kreuzungen hinzufügen Split at Crossings An Kreuzungen teilen + Trim at Crossings Tool An-Kreuzungen-kürzen-Werkzeug + Trim at Crossings An Kreuzungen kürzen Extend Path Pfad verlängern Measure & Inspect Messen und prüfen Set Anchor Ankerpunkt festlegen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c319729a71..592502086b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6782,6 +6782,8 @@ anchor Points: Σημεία: Anchor Inspector Επιθεωρητής σημείων αγκύρωσης Add Points at Crossings Προσθήκη σημείων στις διασταυρώσεις Split at Crossings Διαχωρισμός στις διασταυρώσεις + Trim at Crossings Tool Εργαλείο περικοπής στις διασταυρώσεις + Trim at Crossings Περικοπή στις διασταυρώσεις Extend Path Επέκταση διαδρομής Measure & Inspect Μέτρηση και επιθεώρηση Set Anchor Ορισμός σημείου αγκύρωσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b203292a4..1e65009c70 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6772,6 +6772,8 @@ anchor Points: Puntos: Anchor Inspector Inspector de puntos de ancla Add Points at Crossings Agregar puntos en los cruces Split at Crossings Dividir en los cruces + Trim at Crossings Tool Herramienta Recortar en los cruces + Trim at Crossings Recortar en los cruces Extend Path Prolongar trazado Measure & Inspect Medir e inspeccionar Set Anchor Definir punto de ancla diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b2c6f48d54..fd53076ae2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6860,6 +6860,8 @@ anchor Points: Pisteet: Anchor Inspector Ankkuripisteiden tarkastelu Add Points at Crossings Lisää pisteet risteyskohtiin Split at Crossings Jaa risteyskohdista + Trim at Crossings Tool Risteyskohdista katkaisun työkalu + Trim at Crossings Katkaise risteyskohdista Extend Path Jatka polkua Measure & Inspect Mittaa ja tarkastele Set Anchor Aseta ankkuripiste diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e84f344060..c42f1ffe45 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6825,6 +6825,8 @@ anchor Points: Points : Anchor Inspector Inspecteur de points d’ancrage Add Points at Crossings Ajouter des points aux croisements Split at Crossings Scinder aux croisements + Trim at Crossings Tool Outil Rogner aux croisements + Trim at Crossings Rogner aux croisements Extend Path Prolonger le tracé Measure & Inspect Mesurer et inspecter Set Anchor Définir le point d’ancrage diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 017bc03750..b22b78fb4d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4743,6 +4743,8 @@ antiAlias Sharp शार्प Split at Anchors एंकर पर विभाजित Split at Anchors… एंकर पर विभाजित… Split at Crossings क्रॉसिंग में विभाजित + Trim at Crossings Tool क्रॉसिंग पर ट्रिम टूल + Trim at Crossings क्रॉसिंग पर ट्रिम करें Split at Cursor कर्सर में विभाजित Split Complementary स्प्लिट कॉम्प्लेमेंटरी Split Freeform Line स्प्लिट फ्रीफॉर्म लाइन diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index effb31e5d8..21fc2bc2a8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6813,6 +6813,8 @@ anchor Points: Pontok: Anchor Inspector Horgonypont-vizsgáló Add Points at Crossings Pontok hozzáadása a kereszteződésekben Split at Crossings Felosztás a kereszteződésekben + Trim at Crossings Tool Levágás a kereszteződésekben eszköz + Trim at Crossings Levágás a kereszteződésekben Extend Path Útvonal meghosszabbítása Measure & Inspect Mérés és vizsgálat Set Anchor Horgonypont beállítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 912c6954eb..295d5742c7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6292,6 +6292,8 @@ anchor Points: Titik: Anchor Inspector Pemeriksa titik jangkar Add Points at Crossings Tambahkan titik di persilangan Split at Crossings Pisahkan di persilangan + Trim at Crossings Tool Alat pangkas di persilangan + Trim at Crossings Pangkas di persilangan Extend Path Perpanjang jalur Measure & Inspect Ukur dan periksa Set Anchor Atur titik jangkar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d6c7e2186..39566a65a2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6456,6 +6456,8 @@ anchor Points: Punti: Anchor Inspector Ispettore punti di ancoraggio Add Points at Crossings Aggiungi punti agli incroci Split at Crossings Dividi agli incroci + Trim at Crossings Tool Strumento Rifila agli incroci + Trim at Crossings Rifila agli incroci Extend Path Estendi tracciato Measure & Inspect Misura e ispeziona Set Anchor Imposta punto di ancoraggio diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 17444d894e..6f4b2b465d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6521,6 +6521,8 @@ anchor Points: ポイント: Anchor Inspector アンカーポイントインスペクター Add Points at Crossings 交差点にポイントを追加 Split at Crossings 交差点で分割 + Trim at Crossings Tool 交差点でトリムツール + Trim at Crossings 交差点でトリム Extend Path パスを延長 Measure & Inspect 計測・検査 Set Anchor アンカーポイントを設定 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be8a847cb2..ab460bb5b0 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6854,6 +6854,8 @@ anchor Points: წერტილები: Anchor Inspector საყრდენი წერტილების ინსპექტორი Add Points at Crossings წერტილების დამატება გადაკვეთებზე Split at Crossings დაყოფა გადაკვეთებზე + Trim at Crossings Tool გადაკვეთებზე წაკვეთის ხელსაწყო + Trim at Crossings წაკვეთა გადაკვეთებზე Extend Path ტრაექტორიის გაგრძელება Measure & Inspect გაზომვა და შემოწმება Set Anchor საყრდენი წერტილის დაყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 991daba6d0..b1d7d4d3a3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6417,6 +6417,8 @@ anchor Points: 포인트: Anchor Inspector 앵커 포인트 검사기 Add Points at Crossings 교차점에 포인트 추가 Split at Crossings 교차점에서 분할 + Trim at Crossings Tool 교차점에서 트림 도구 + Trim at Crossings 교차점에서 트림 Extend Path 패스 연장 Measure & Inspect 측정 및 검사 Set Anchor 앵커 포인트 설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b8ca33858f..f23ea1dcd7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6905,6 +6905,8 @@ anchor Points: Punten: Anchor Inspector Ankerpuntinspecteur Add Points at Crossings Punten toevoegen bij kruisingen Split at Crossings Splitsen bij kruisingen + Trim at Crossings Tool Afsnijden bij kruisingen-gereedschap + Trim at Crossings Afsnijden bij kruisingen Extend Path Pad verlengen Measure & Inspect Meten en inspecteren Set Anchor Ankerpunt instellen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eb5afe87cc..bb590f62b7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6685,6 +6685,8 @@ anchor Points: Punkty: Anchor Inspector Inspektor punktów kontrolnych Add Points at Crossings Dodaj punkty na skrzyżowaniach Split at Crossings Podziel na skrzyżowaniach + Trim at Crossings Tool Narzędzie Przytnij na skrzyżowaniach + Trim at Crossings Przytnij na skrzyżowaniach Extend Path Przedłuż ścieżkę Measure & Inspect Mierz i badaj Set Anchor Ustaw punkt kontrolny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 80615d6370..55e93281b7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6745,6 +6745,8 @@ anchor Points: Pontos: Anchor Inspector Inspetor de Pontos de Ancoragem Add Points at Crossings Adicionar Pontos nos Cruzamentos Split at Crossings Dividir nos Cruzamentos + Trim at Crossings Tool Ferramenta Aparar nos Cruzamentos + Trim at Crossings Aparar nos Cruzamentos Extend Path Estender Caminho Measure & Inspect Medir e Inspecionar Set Anchor Definir Ponto de Ancoragem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c830e69b37..8528c59b00 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6807,6 +6807,8 @@ anchor Points: Точки: Anchor Inspector Инспектор опорных точек Add Points at Crossings Добавить точки на пересечениях Split at Crossings Разрезать на пересечениях + Trim at Crossings Tool Инструмент «Подрезка на пересечениях» + Trim at Crossings Подрезать на пересечениях Extend Path Продлить контур Measure & Inspect Измерение и анализ Set Anchor Задать опорную точку diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d4304b97bd..57fa395290 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6811,6 +6811,8 @@ anchor Points: Punkter: Anchor Inspector Ankarpunktsgranskare Add Points at Crossings Lägg till punkter vid korsningar Split at Crossings Dela vid korsningar + Trim at Crossings Tool Trimma vid korsningar-verktyget + Trim at Crossings Trimma vid korsningar Extend Path Förläng bana Measure & Inspect Mät och granska Set Anchor Ange ankarpunkt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5fc694ec2..a9e82551e6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6786,6 +6786,8 @@ anchor Points: Noktalar: Anchor Inspector Bağlantı Noktası Denetçisi Add Points at Crossings Kesişimlere Nokta Ekle Split at Crossings Kesişimlerden Böl + Trim at Crossings Tool Kesişimlerden Kırpma Aracı + Trim at Crossings Kesişimlerden Kırp Extend Path Yolu Uzat Measure & Inspect Ölç ve İncele Set Anchor Bağlantı Noktasını Ayarla diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5a36512db0..3ce556ec82 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6436,6 +6436,8 @@ anchor Points: Точки: Anchor Inspector Інспектор опорних точок Add Points at Crossings Додати точки на перетинах Split at Crossings Розрізати на перетинах + Trim at Crossings Tool Інструмент «Підрізання на перетинах» + Trim at Crossings Підрізати на перетинах Extend Path Подовжити контур Measure & Inspect Вимірювання й аналіз Set Anchor Задати опорну точку diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 839f5c7405..7b8fbf1448 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6437,6 +6437,8 @@ anchor Points: Điểm: Anchor Inspector Trình kiểm tra điểm neo Add Points at Crossings Thêm điểm tại chỗ giao nhau Split at Crossings Tách tại chỗ giao nhau + Trim at Crossings Tool Công cụ Cắt xén tại chỗ giao nhau + Trim at Crossings Cắt xén tại chỗ giao nhau Extend Path Kéo dài đường dẫn Measure & Inspect Đo và kiểm tra Set Anchor Đặt điểm neo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b0d6001e32..d5abcb3148 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6668,6 +6668,8 @@ anchor Points: 点: Anchor Inspector 锚点检查器 Add Points at Crossings 在交叉处添加锚点 Split at Crossings 在交叉处分割 + Trim at Crossings Tool 在交叉处修剪工具 + Trim at Crossings 在交叉处修剪 Extend Path 延长路径 Measure & Inspect 测量与检查 Set Anchor 设置锚点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c24ae37609..4ad325f2d3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6474,6 +6474,8 @@ anchor Points: 點: Anchor Inspector 錨點檢查器 Add Points at Crossings 在交叉處增加錨點 Split at Crossings 在交叉處分割 + Trim at Crossings Tool 在交叉處修剪工具 + Trim at Crossings 在交叉處修剪 Extend Path 延長路徑 Measure & Inspect 測量與檢查 Set Anchor 設定錨點 diff --git a/docs/mcp.md b/docs/mcp.md index 59bb3c78ad..50cbf6a927 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10310,9 +10310,9 @@ type and evening out horizontal scales are not there yet. Path Precision Tools is a built-in add-on for exact anchor and path work: its commands are `addon.pathtools.*`, and in the desktop app it lives in Object › Plug-ins › Path Precision Tools, in its Anchor Inspector panel (`window.panel {panel: "anchorInspector"}`), in its Parametric Shape panel (`window.panel {panel: "parametricShape"}`) -and in seven tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with -`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect`, `parametricShape`, `cornerTool`, `slideAnchor` and -`sharedEdge`. +and in eight tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with +`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect`, `parametricShape`, `cornerTool`, `slideAnchor`, +`sharedEdge` and `trimAtCrossings`. None has a shortcut. Each edit is one undo step, and an edit that changes nothing makes no step. Tools: @@ -10361,6 +10361,11 @@ Tools: different in length, or at over 120° at an end, are left alone. Options (`tool.setOption`, or the panel, kept with the preferences): `diameter` (0.25 to 200 pt, 20), `tolerance` (how near two anchors count as coincident, 0 to 3 pt, 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 + 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. Commands: @@ -10392,7 +10397,9 @@ Commands: straight segment (`straight`) or with both ends made smooth (`smooth`). - `addon.pathtools.crossings {cut?, selfCrossings?, ids?}` adds anchors where the selected paths cross one another (and where each crosses itself, with `selfCrossings`), or with `cut` also cuts them there: a path of one subpath becomes a - path per piece. Paths that meet at a shared anchor don't cross there; at most 10,000 segments at once. + path per piece. Segments that meet at an anchor they share, or with only segments of no length between them, don't + cross there, and they cross where they meet again elsewhere. With `selfCrossings`, a curved segment that loops over + itself crosses itself. At most 10,000 segments at once. - `addon.pathtools.divideDashes {dash?, gap?, fit?, removeGaps?}` cuts the selected paths into separate open paths along the curve, each `dash` long (0.01 to 1,000,000 pt, 10 by default) with `gap` between them (0 to 1,000,000 pt, 0 by default: the pieces touch), every piece keeping the path's appearance, as one undo step. The gap pieces are left @@ -10444,6 +10451,26 @@ Commands: one that `addon.pathtools.slideAnchor`, `extend` or `setAnchor` moves go with it, in the same undo step; an Extend Path that adds an anchor leaves the old end, and what shares it, where they are. Nothing else in the app changes. It is the `together` option of tool `sharedEdge`, off by default. → `{enabled}`. +- `addon.pathtools.trim {at, tolerance?}`, `{rect}` or `{points}` removes stretches of paths. A stretch runs 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` ([x, y]) removes the stretch at that point of the nearest path within `tolerance` (0 to 100 pt, default 3; of + paths equally near, the one on top), and a point where two stretches meet is on the one that starts there. `rect` + ([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. + 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 + removed, and a live shape becomes a plain path. No anchor is added where a stretch that stays meets another. The + 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 + 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. - `addon.pathtools.shape.create {center, kind?, width?, height?, rotation?, count?, a?, b?, radius?}` draws a parametric shape with the current paint, selected. `kind`: `rectangle`, `ellipse`, `polygon` (default), `star`, `ring`, `arrow`, `cross`, `heart`, `gear` or `bubble` (a speech bubble). `width` and `height` are 0.01 to 16,384 pt (100); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..4415effe65 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2110,6 +2110,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Expand Dashes into Paths turns the dashed stroke of each selected path and compound path into separate open paths, one per dash, for cutters and plotters that need the centre lines rather than Outline Stroke's filled outlines. The dashes are laid out by the renderer's own dash code, so they match what the canvas draws: the dash pattern, its offset and Align Dashes to Corners and Path Ends, a dash running on through the start of a closed path, a dash all the way round staying closed, and under a round or projecting cap each dot (a dash of no length) as a path on its point. Each dash keeps the path's appearance with the top stroke's dashes and arrowheads off; the path's fills stay on it, below its dashes; a compound path's dashes are plain paths. One undo step; the dashes are selected; at most 100000 (more, or a pattern too fine to draw, is refused). Tests: `expand_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (the stroke kept with dashes off, the offset, one undo step, the fill kept below, dashes centred on corners when aligned, dots under round and butt caps, compound paths, refusals and the piece cap), `the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. +## Path Precision Tools add-on: Trim at Crossings (2026-11-13) + +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Trim at Crossings Tool (kept out of the shipped toolbars, listed in the All Tools drawer, no shortcut) removes stretches of paths between the places where they are crossed by another path or by themselves, on any path that can be edited, selected or not. A click removes the stretch under the pointer, a drag removes every stretch its rectangle touches, and an Alt-drag removes every stretch its freehand line crosses; each is one undo step, and Escape during a drag removes nothing. 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, and the stretches that stay get no new anchors. Hidden and locked paths, guides and clipping paths are not trimmed and do not count as crossing. Add Points at / Split at Crossings share the crossing search: segments that meet at an anchor now count where they cross again elsewhere, and with Self Crossings a curved segment that loops over itself crosses itself. Command `addon.pathtools.trim`; `vectorcraft_pathops::addons::pathtools::{crossings_on, spans, without_spans}`. Not yet: a highlight of the stretch under the pointer before the click, and crossings with type, images and symbol instances. Tests: `crossings_on_*`, `segments_*`, `a_segment_that_loops_over_itself_crosses_itself`, `one_work_budget_*`, `spans_divide_*`, `a_subpath_without_*` and `a_slice_*` in `addons::pathtools::tests` in `vectorcraft-pathops`, `addons::trim` in `vectorcraft-tools` (click, rectangle, freehand line, Escape), `cmd::addons::pathtools::trim::tests` in `vectorcraft-engine` (a middle and an end stretch, a closed path opening, a rectangle and a line as one undo step, locked and hidden paths, guides and clipping paths, paths nothing crosses, compound path members, the selection, 3,000 other paths, a path too large to search left alone until touched, bad params refused), `the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector` in `vectorcraft-ui-egui`, `edit_toolbar::tests` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. + ## Document Tools add-on: Save a Timestamped Copy (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Document Tools › Save a Timestamped Copy writes a copy of the document named `_YYYY-MM-DD_HH-MM.` (the date and time in UTC, through chrono; `-2`, `-3`… added when that name is taken) next to the document, or into a folder picked (the folder picker) when it was never saved, in the document's own format or another save format. It writes through File › Save a Copy's save, so the document keeps its path, title and modified state, no undo step is made, and the write stays inside the automation roots in force. It is off on the web, which has no clock. Tests: `cmd::addons::document::tests` in `vectorcraft-engine` (the name and place, numbered names, the document unchanged and its content in the copy, an unsaved document needing a folder, other formats, bad params refused, automation roots), `addons::document::tests` in `vectorcraft-ui-egui` (the submenu, beside the document, the folder picked or cancelled) and `command_sweep`. Commands: `docs/mcp.md` › Document Tools add-on. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d640d3b5d2..3fc7506392 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -465,9 +465,10 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › Batch Trace Folder asks for a folder of images, then a destination folder, traces each image with a preset (optionally expanded) and saves it there as `.vectorcraft`, `.svg`, `.pdf`, `.ai` or `.eps` under its own name (taken names numbered, nothing replaced), closing each image again; the status bar counts the images traced and those that couldn't be; off on the web | done | #4921; `addon.document.batchTraceFolder`, `crates/engine/src/cmd/addons/document/batch_trace.rs`, `crates/ui-egui/src/addons/document.rs` and their tests; not a feature of the reference app | | Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › New Symbols from Layers makes a symbol of each layer's art named after the layer (the highlighted layers when two or more are, else all); Convert Layers to Symbols also replaces each layer's art with an instance in place; empty layers skipped, one undo step | done | #4927; `addon.document.symbolsFromLayers`, `crates/engine/src/cmd/addons/document.rs`, `crates/ui-egui/src/addons/document.rs` and their tests; not a feature of the reference app, so it is not scored | | Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not a feature of the reference app, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its eight tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool, Trim at Crossings Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools › Divide into Dashes… (Dash and Gap in any unit, Fit to Corners and Ends, Remove Gaps; Preview) cuts the selected paths, or only their direct-selected segments, into open pieces along the curve, keeping their appearance, one undo step | done | #4950; `addon.pathtools.divideDashes`, `crates/pathops/src/addons/pathtools/dashes.rs`, `crates/ui-egui/src/addons/pathtools/dashes.rs` and their tests; not a feature of the reference app, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools › Set Corners… (also a button in the Anchor Inspector's Corners section: Radius in any unit, Corner kind, Anchors: All, Odd-Numbered or Even-Numbered, counted from each path's first anchor; Angle: from … to …°, only the corners whose sides meet at an angle in that range; Preview) cuts corners on the chosen anchors of every selected path, one undo step | done | #5546, #5545; `addon.pathtools.cornerSet` (`every`, `minAngle`, `maxAngle`), `crates/ui-egui/src/addons/pathtools/set_corners.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools › Trim at Crossings Tool (in the All Tools drawer under the add-on's heading, in neither toolbar as shipped, no shortcut) removes stretches of paths between the places where they are crossed, on any editable path, selected or not: a click removes the stretch under the pointer, a drag every stretch its rectangle touches, an Alt-drag every stretch its freehand line crosses; one undo step each; Escape during a drag removes nothing | done | `addon.pathtools.trim` (in `crates/engine/src/cmd/addons/pathtools/trim.rs`), `crates/tools/src/addons/trim.rs` and their tests; not a feature of the reference app, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools › Expand Dashes into Paths turns each selected path's dashed stroke into separate open paths, one per dash, laid out as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off; one undo step | done | #4922; `addon.pathtools.expandDashes` (in `crates/engine/src/cmd/addons/pathtools.rs`, using the renderer's dash layout) and its tests; not a feature of the reference app, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not a feature of the reference app, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not a feature of the reference app, so it is not scored |