Path Precision Tools: Trim at Crossings removes the stretch of a path between crossings

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.
This commit is contained in:
dotCooCoo
2026-11-13 09:24:53 -08:00
parent 60fd4bd644
commit 3a336fd32f
44 changed files with 1645 additions and 31 deletions
+5 -3
View File
@@ -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<CommandSpec> {
"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<CommandSpec> {
v.extend(shape::specs());
v.extend(corners::specs());
v.extend(shared_edge::specs());
v.extend(trim::specs());
v
}
@@ -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<CommandSpec> {
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<Point>, Rect),
}
fn pick(p: &Value) -> Result<Pick> {
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::<Result<Vec<Point>>>()?;
let bounds = pts.iter().fold(None, |r: Option<Rect>, 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<Part<'_>> {
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<bool> {
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<bool> {
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<pt::Place>,
budget: &mut usize,
work: &mut pt::Work,
) -> Result<Option<(Vec<SubPath>, 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<bool> = 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::<Result<_>>()?,
};
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<Value> {
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<pt::Place>)> = match &pick {
Pick::At(at, tolerance) => {
let mut best: Option<(usize, pt::Place, f64)> = None;
for (i, (_, path, b)) in parts.iter().enumerate() {
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<PathData>)> = 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<NodeId> = plan.iter().map(|(id, _)| *id).collect();
// The compound paths that may lose their last member.
let mut compounds: Vec<NodeId> = 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::<Vec<_>>(), "removed": removed }))
}
@@ -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<Value> = 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<NodeId> {
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<NodeId> {
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<Point> = 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<Value> = (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);
}
@@ -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",
+8
View File
@@ -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})),
+6 -4
View File
@@ -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};
+182 -17
View File
@@ -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<V
Some(found)
}
/// Do segments `i` and `j` of `sp` meet at an anchor they share?
fn adjacent(sp: &SubPath, i: usize, j: usize) -> 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<usize> {
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<Point> {
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<Vec<(f64, f64)>> {
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<Vec<Vec<Place>>> {
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<Vec<Vec<Pl
}
}
}
let with_length: Vec<Vec<Vec<usize>>> = paths.iter().map(|p| p.subpaths.iter().map(with_length_from).collect()).collect();
let mut out: Vec<Vec<Place>> = 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<Vec<Vec<Pl
Some(out)
}
/// Most segments of other paths [`crossings_on`] compares one path with.
pub const MAX_CROSSED_BY_SEGMENTS: usize = 200_000;
/// Most pairs of segments [`crossings_on`] looks at.
const MAX_PAIRS: usize = 60_000_000;
/// The work [`crossings_on_within`] may still do: pairs of segments to look at and subdivision steps.
/// One value shared by several calls bounds them all together.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Work {
pairs: usize,
steps: usize,
}
impl Default for Work {
/// The work one [`crossings_on`] call may do.
fn default() -> 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<Vec<Place>> {
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<Vec<Place>> {
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<Vec<usize>> = path.subpaths.iter().map(with_length_from).collect();
let mut out: Vec<Place> = 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.
@@ -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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>());
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::<Vec<_>>(), [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::<Vec<_>>();
// 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<_>>(), [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<_>>(), [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<_>>(),
[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<Point> = 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::<Vec<_>>(), [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<Point> = 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());
}
+151
View File
@@ -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<Span> {
let n = sp.segment_count();
if n == 0 {
return vec![];
}
let nf = n as f64;
let mut at: Vec<f64> = 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<f64> = 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<usize> {
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<Anchor> = 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<Vec<SubPath>> {
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<Span> = 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)
}
+3
View File
@@ -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<Box<dyn Tool>> {
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()),
+213
View File
@@ -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<Point>,
end: Point,
/// An Alt-drag's freehand line (empty for a rectangle).
line: Vec<Point>,
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<Action> {
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<Action> {
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<Overlay> {
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());
}
}
+2 -1
View File
@@ -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> {
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 })),
@@ -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 })));
+2 -1
View File
@@ -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::<Vec<_>>()))
};
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])));
+2
View File
@@ -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 Задаване на опорна точка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 Ορισμός σημείου αγκύρωσης
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 स्प्लिट फ्रीफॉर्म लाइन
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2422 and column 5.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 アンカーポイントを設定
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 საყრდენი წერტილის დაყენება
Can't render this file because it is too large.
+2
View File
@@ -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 앵커 포인트 설정
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 Задать опорную точку
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -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 Задати опорну точку
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -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 设置锚点
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 設定錨點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+31 -4
View File
@@ -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);
+4
View File
@@ -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 `<name>_YYYY-MM-DD_HH-MM.<ext>` (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.
+2 -1
View File
@@ -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 |