Add-ons: Path Precision Tools — anchor eraser brush, extend path, measure, anchor inspector, crossings

A built-in add-on under Object › Plug-ins › Path Precision Tools:
- Anchor Eraser Brush: painting over selected paths removes the anchors
  under the brush that the outline can do without within a tolerance,
  refitting the handles beside them (Alt ignores the tolerance, Cmd/Ctrl
  deletes without refitting, pen pressure can scale the brush); one undo
  step per stroke.
- Extend Path tool: dragging an end of an open path carries it on
  straight, by stretching its last curve or along the circle it curves
  on, or shortens it back along itself.
- Measure & Inspect tool: drag to measure distance and angle; hover a
  path to read segment and path length, area, curvature, tangent and
  anchor angles and handles, in the document's units.
- Anchor Inspector panel: the selected anchor's position and handles as
  numbers, with Make Smooth (auto-smooth rule), Make Corner, Equalize
  Handles, Remove Points Keep Shape, Add Points and three ways to close.
- Add Points at Crossings and Split at Crossings.

The tools stay out of both toolbars as shipped; the All Tools drawer
lists them under the add-on's heading. Commands are addon.pathtools.*;
the existing commands, tools and menus are unchanged.
This commit is contained in:
bflatastic
2026-10-30 15:20:38 +07:00
parent 764552bc9a
commit dc7f147608
58 changed files with 4092 additions and 24 deletions
+2
View File
@@ -3,6 +3,7 @@
mod cleanup;
mod color;
mod pathtools;
use super::CommandSpec;
@@ -10,5 +11,6 @@ use super::CommandSpec;
pub fn specs() -> Vec<CommandSpec> {
let mut v = cleanup::specs();
v.extend(color::specs());
v.extend(pathtools::specs());
v
}
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session};
/// Simplify Anchors' tolerance by default, and the drift (pt) each step of it allows.
const TOLERANCE: f64 = 20.0;
const PT_PER_TOLERANCE: f64 = 0.02;
pub(super) const PT_PER_TOLERANCE: f64 = 0.02;
/// Rejoin Path Pieces' gap by default, and the widest (pt).
const GAP: f64 = 0.1;
const MAX_GAP: f64 = 6.0;
+676
View File
@@ -0,0 +1,676 @@
//! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke,
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits and Split / Add Points at Crossings. Its tools, panel and submenu (Object ›
//! Plug-ins › Path Precision Tools) are the tools crate's and the UI's.
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_pathops::addons::{self as po, pathtools as pt};
use super::super::draw2::{replace_with_pieces, usize_opt};
use super::super::pathops::Beside;
use super::super::{
CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param,
};
use super::cleanup::PT_PER_TOLERANCE;
use crate::{EngineError, Result, Session};
/// The Anchor Eraser Brush's tolerance by default (the scale Simplify Anchors (Keep Shape) uses).
const ERASE_TOLERANCE: f64 = 10.0;
/// Most points one brush stroke carries.
const MAX_STROKE_POINTS: usize = 100_000;
/// Most anchors one command edits at once.
const MAX_ANCHORS: usize = 2_000_000;
/// The narrowest a brush gets at the lightest pen pressure (pt).
const MIN_RADIUS: f64 = 0.05;
/// The widest brush (pt).
const MAX_DIAMETER: f64 = 2500.0;
/// The farthest an end is extended at once (pt).
const MAX_EXTEND: f64 = 1e6;
/// Most anchors Add Points puts on each segment.
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.pathtools.erase",
"Anchor Eraser Brush",
[],
None,
"{points: [[x, y] | [x, y, pressure 0..1]…] (the brush's path, at most 100000 points), diameter?: pt 0.1..2500 (20; a point's pressure scales it), tolerance?: 1..100 (10; the outline may move up to tolerance × 0.02 pt), ignoreTolerance?: bool (false: every anchor under the brush that can go goes, its neighbours refitted), plain?: bool (false: true deletes them without refitting the handles beside them), ids?: [id…] (default: the selection)} remove the anchors of the paths under the objects that the brush passes over and that can go while the outline stays within the tolerance, as one undo step; open ends stay → {ids, removed}",
has_selection,
erase
),
cmd!(
"addon.pathtools.extend",
"Extend Path",
[],
None,
"{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step → {id, length (pt)}",
has_doc,
extend
),
cmd!(
query "addon.pathtools.inspect",
"Measure & Inspect",
[],
None,
"{id, x, y, tolerance?: pt (3; how near an anchor counts as over it)} what the path reads at the point nearest (x, y): {subpath, closed, anchors, length, area (pt², an open subpath closed by a straight line), segment, t, point: [x, y], segmentLength, tangent (degrees counter-clockwise), radius (of curvature, null where straight), anchor?: {index, x, y, smooth, angle (at the anchor, null at an open end), in?: {length, angle}, out?: {length, angle}}}",
has_doc,
inspect
),
cmd!(
"addon.pathtools.setAnchor",
"Set Anchor",
[],
None,
"{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set → {anchors}",
has_selection,
set_anchor
),
cmd!(
"addon.pathtools.smooth",
"Make Smooth",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{ratio?: 0.1..0.5 (0.3905: a square's corners make a circle)} make the selected anchors smooth: a lone handle is mirrored; without handles, both stand at right angles to the bisector of the angle at the anchor, ratio × the nearer neighbour's distance long; two handles are put in line; an open end without handles aims at its neighbour's handle. One undo step → {anchors (changed)}",
has_selection,
smooth
),
cmd!(
"addon.pathtools.corner",
"Make Corner",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{retract?: bool (true: the handles go; false: they stay where they are and move apart)} make the selected anchors corners, as one undo step → {anchors (changed)}",
has_selection,
corner
),
cmd!(
"addon.pathtools.equalize",
"Equalize Handles",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{} give each selected anchor's two handles their average length (a missing one is made opposite the other), as one undo step → {anchors (changed)}",
has_selection,
equalize
),
cmd!(
"addon.pathtools.removeKeepShape",
"Remove Points, Keep Shape",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{} remove the direct-selected anchors, refitting the handles beside each so the outline keeps its shape as best it can, as one undo step; open ends stay → {removed}",
has_selection,
remove_keep_shape
),
cmd!(
"addon.pathtools.addPoints",
"Add Points to Segments…",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{count?: 1..100 (1)} add count anchors to each selected segment (a segment between two selected anchors, or every segment of a path selected whole), splitting it into pieces of equal length; the shape doesn't change. One undo step → {added}",
has_selection,
add_points
),
cmd!(
"addon.pathtools.close",
"Close Path",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{mode?: \"handles\" (keep the ends' handles)|\"straight\" (a straight closing segment)|\"smooth\" (the ends made smooth), ratio?: 0.1..0.5 (0.3905, for smooth)} close the open subpaths of the selected paths, as one undo step → {closed}",
has_selection,
close
),
cmd!(
"addon.pathtools.crossings",
"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)}",
has_selection,
crossings
),
]
}
/// The editable paths (not guides) under `roots`, compound path members included.
fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let editable = d.editable();
let mut out = vec![];
for n in roots.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: false, .. }) && editable(c.id) {
out.push(c.id);
}
});
}
out
}
/// Put `np` in path `id`'s place; its live shape goes (its anchors are edited).
fn set_path(d: &mut Document, id: NodeId, np: PathData) {
if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
*path = np;
*live = None;
}
}
/// Error unless the paths together have at most [`MAX_ANCHORS`] anchors.
fn check_size<'a>(paths: impl IntoIterator<Item = &'a PathData>, cmd: &str) -> Result<()> {
if paths.into_iter().map(PathData::anchor_count).sum::<usize>() > MAX_ANCHORS {
return Err(bad(cmd, "the paths have too many anchors to edit at once"));
}
Ok(())
}
// ---------- Anchor Eraser Brush ----------
/// The brush's points (with the radius each has), from `points`.
fn stroke(p: &Value, radius: f64, cmd: &str) -> Result<Vec<(Point, f64)>> {
let list = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points"))?;
if list.len() > MAX_STROKE_POINTS {
return Err(bad(cmd, "too many `points` (at most 100000)"));
}
list.iter()
.map(|v| {
let point = |[x, y]: [f64; 2], pressure: f64| (Point::new(x, y), (radius * pressure.clamp(0.0, 1.0)).max(MIN_RADIUS));
finite_numbers::<2>(v)
.map(|xy| point(xy, 1.0))
.or_else(|| finite_numbers::<3>(v).map(|[x, y, f]| point([x, y], f)))
.ok_or_else(|| bad(cmd, format!("bad point {v}: expected [x, y] or [x, y, pressure]")))
})
.collect()
}
/// Does the brush, sweeping through `pts`, cover `q`?
fn under_brush(pts: &[(Point, f64)], q: Point) -> bool {
let hit = |(a, ra): (Point, f64), (b, rb): (Point, f64)| {
let ab = b - a;
let len2 = ab.hypot2();
let t = if len2 > 0.0 { ((q - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
q.distance(a + ab * t) <= ra + (rb - ra) * t
};
match pts {
[only] => hit(*only, *only),
_ => pts.windows(2).any(|w| matches!(w, [a, b] if hit(*a, *b))),
}
}
/// The anchors of `sp` that go when the brush passes over those marked, removed without refitting
/// the handles beside them: open ends stay, and the subpath keeps two anchors (three closed).
fn delete_marked(sp: &SubPath, marked: &[bool]) -> SubPath {
let n = sp.anchors.len();
let min = if sp.closed { 3 } else { 2 };
let mut left = n.saturating_sub(min);
let mut out = sp.clone();
let keep: Vec<bool> = (0..n)
.map(|i| {
let end = !sp.closed && (i == 0 || i + 1 == n);
let go = !end && left > 0 && marked.get(i).copied().unwrap_or(false);
left -= usize::from(go);
!go
})
.collect();
let mut it = keep.iter();
out.anchors.retain(|_| it.next().copied().unwrap_or(true));
out
}
fn erase(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.erase";
let diameter = number_or(p, "diameter", 20.0, C)?;
if !(0.1..=MAX_DIAMETER).contains(&diameter) {
return Err(bad(C, "`diameter` must be from 0.1 to 2500 pt"));
}
let tolerance = number_or(p, "tolerance", ERASE_TOLERANCE, C)?;
if !(1.0..=100.0).contains(&tolerance) {
return Err(bad(C, "`tolerance` must be from 1 to 100"));
}
let (ignore, plain) = (switch_or(p, "ignoreTolerance", false, C)?, switch_or(p, "plain", false, C)?);
let pts = stroke(p, diameter / 2.0, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
let ids = paths_under(d, &roots);
let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect();
check_size(paths.iter().map(|(_, p)| *p), C)?;
let tol = if ignore { f64::INFINITY } else { tolerance * PT_PER_TOLERANCE };
let mut removed = 0;
let mut changed = vec![];
for (id, path) in paths {
let subs: Vec<SubPath> = path
.subpaths
.iter()
.map(|sp| {
let marked: Vec<bool> = sp.anchors.iter().map(|a| !pts.is_empty() && under_brush(&pts, a.p)).collect();
match marked.contains(&true) {
false => sp.clone(),
true if plain => delete_marked(sp, &marked),
true => po::simplify_anchors_keep_shape(sp, tol, &marked),
}
})
.collect();
let np = PathData::new(subs);
if np != *path {
removed += path.anchor_count().saturating_sub(np.anchor_count());
changed.push((id, np));
}
}
edit_whole(s, "Anchor Eraser Brush", changed)?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "removed": removed }))
}
/// Replace paths with the new data given, as one step (none when there is nothing to change); each
/// is selected whole, as its anchors' indexes changed.
fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) -> Result<()> {
if changed.is_empty() {
return Ok(());
}
s.edit(label, |d, sel| {
for (id, np) in changed {
sel.anchors.remove(&id);
sel.segments.remove(&id);
set_path(d, id, np);
}
Ok(())
})
}
// ---------- Extend Path ----------
/// The open subpath an `extend` request names: (path id, subpath index, its data).
fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?;
let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0);
let d = &s.doc()?.doc;
if !d.is_editable(id) {
return Err(bad(cmd, "the path is locked or hidden"));
}
let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?;
let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?;
if sp.closed || sp.anchors.len() < 2 {
return Err(bad(cmd, "the subpath has no ends to extend (it is closed or a single point)"));
}
Ok((id, si, sp.clone()))
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.extend";
let (id, si, sp) = open_subpath(s, p, C)?;
let at_start = match text_param(p, "end", C)? {
Some("start") => true,
Some("end") => false,
_ => return Err(bad(C, "`end` must be \"start\" or \"end\"")),
};
let mode = match text_param(p, "mode", C)? {
None => pt::ExtendMode::Straight,
Some(m) => pt::ExtendMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"straight\", \"bezier\" or \"arc\""))?,
};
let length = match (p.get("to").filter(|v| !v.is_null()), p.get("length").filter(|v| !v.is_null())) {
(Some(to), None) => {
let [x, y] = finite_numbers::<2>(to).ok_or_else(|| bad(C, "`to` must be [x, y]"))?;
pt::extend_length_to(&sp, at_start, Point::new(x, y), mode).ok_or_else(|| bad(C, "the path can't be extended"))?
}
(None, Some(_)) => number_or(p, "length", 0.0, C)?,
_ => return Err(bad(C, "give `length` or `to`")),
};
if !length.is_finite() || length.abs() > MAX_EXTEND {
return Err(bad(C, "`length` must be a number of at most 1000000 pt either way"));
}
let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?;
if new != sp {
s.edit("Extend Path", |d, sel| {
let n = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
let mut np = n;
if let Some(slot) = np.subpaths.get_mut(si) {
*slot = new;
}
sel.anchors.remove(&id);
sel.segments.remove(&id);
set_path(d, id, np);
Ok(())
})?;
}
Ok(json!({ "id": id.0, "length": length }))
}
// ---------- Measure & Inspect ----------
fn polar(h: Option<(f64, f64)>) -> Value {
h.map_or(Value::Null, |(length, angle)| json!({ "length": length, "angle": angle }))
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.inspect";
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?);
let tolerance = number_or(p, "tolerance", 3.0, C)?;
if !(x.is_finite() && y.is_finite()) || !(tolerance >= 0.0 && tolerance.is_finite()) {
return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)"));
}
let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?;
let i = pt::inspect(path, Point::new(x, y), tolerance).ok_or_else(|| bad(C, "the path has no segments to measure"))?;
let anchor = i.anchor.map(|a| {
json!({ "index": a.index, "x": a.p.x, "y": a.p.y, "smooth": a.smooth, "angle": a.angle, "in": polar(a.handle_in), "out": polar(a.handle_out) })
});
Ok(json!({
"subpath": i.subpath, "closed": i.closed, "anchors": i.anchors, "length": i.length, "area": i.area,
"segment": i.segment, "t": i.t, "point": [i.point.x, i.point.y], "segmentLength": i.segment_length,
"tangent": i.tangent, "radius": i.radius, "anchor": anchor,
}))
}
// ---------- Anchor Inspector ----------
/// The anchors the Anchor Inspector acts on, per path: each path's direct-selected anchors, or
/// every anchor of a path selected whole (with `whole`), keyed by subpath.
type Picked = Vec<(NodeId, BTreeMap<usize, BTreeSet<usize>>)>;
/// The selected anchors ([`Picked`]); `whole` counts the anchors of paths selected whole.
fn picked_anchors(s: &Session, whole: bool) -> Result<Picked> {
let st = s.doc()?;
let (d, sel) = (&st.doc, &st.selection);
let roots = super::super::edit::selected_roots(s)?;
let mut out = vec![];
for id in paths_under(d, &roots) {
let Some(path) = d.node(id).and_then(|n| n.path_data()) else { continue };
let refs: Vec<AnchorRef> = match sel.anchors.get(&id).filter(|a| !a.is_empty()) {
Some(set) => set.iter().copied().collect(),
None if whole => path.anchors().map(|(si, ai, _)| (si, ai)).collect(),
None => continue,
};
let mut by_sub: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
for (si, ai) in refs.into_iter().filter(|(si, ai)| path.subpaths.get(*si).is_some_and(|sp| *ai < sp.anchors.len())) {
by_sub.entry(si).or_default().insert(ai);
}
if !by_sub.is_empty() {
out.push((id, by_sub));
}
}
Ok(out)
}
/// Edit each selected anchor ([`picked_anchors`]) in place with `f` (true when it changed one), as
/// one step labelled `label`; the selection stays. → how many anchors changed.
fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath, usize) -> bool) -> Result<usize> {
let picked = picked_anchors(s, true)?;
if picked.is_empty() {
return Err(bad(cmd, "select paths or anchor points"));
}
let d = &s.doc()?.doc;
check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), cmd)?;
let mut count = 0;
let mut changed = vec![];
for (id, by_sub) in &picked {
let Some(mut np) = d.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
let before = count;
for (si, anchors) in by_sub {
let Some(sp) = np.subpaths.get_mut(*si) else { continue };
count += anchors.iter().filter(|&&i| f(sp, i)).count();
}
if count > before {
changed.push((*id, np));
}
}
if !changed.is_empty() {
s.edit(label, |d, _| {
for (id, np) in changed {
set_path(d, id, np);
}
Ok(())
})?;
}
Ok(count)
}
/// A smoothing `ratio` param (default [`pt::SMOOTH_RATIO`]).
fn ratio(p: &Value, cmd: &str) -> Result<f64> {
let r = number_or(p, "ratio", pt::SMOOTH_RATIO, cmd)?;
let (lo, hi) = pt::SMOOTH_RATIO_RANGE;
if !(lo..=hi).contains(&r) {
return Err(bad(cmd, "`ratio` must be from 0.1 to 0.5"));
}
Ok(r)
}
fn smooth(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.smooth";
let r = ratio(p, C)?;
let n = edit_anchors(s, "Make Smooth", C, |sp, i| pt::auto_smooth(sp, i, r))?;
Ok(json!({ "anchors": n }))
}
fn corner(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.corner";
let retract = switch_or(p, "retract", true, C)?;
let n = edit_anchors(s, "Make Corner", C, |sp, i| pt::make_corner(sp, i, retract))?;
Ok(json!({ "anchors": n }))
}
fn equalize(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.equalize";
let n = edit_anchors(s, "Equalize Handles", C, pt::equalize_handles)?;
Ok(json!({ "anchors": n }))
}
/// A number param that is given, finite, and (with `range`) inside it.
fn given(p: &Value, key: &str, range: Option<(f64, f64)>, cmd: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?;
match range {
Some((lo, hi)) if !(lo..=hi).contains(&x) => Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))),
_ => Ok(Some(x)),
}
}
}
}
fn set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.setAnchor";
const LIMIT: Option<(f64, f64)> = Some((-1e7, 1e7));
let (x, y) = (given(p, "x", LIMIT, C)?, given(p, "y", LIMIT, C)?);
let lengths = [given(p, "inLength", Some((0.0, 1e7)), C)?, given(p, "outLength", Some((0.0, 1e7)), C)?];
let angles = [given(p, "inAngle", LIMIT, C)?, given(p, "outAngle", LIMIT, C)?];
if [x, y].iter().chain(&lengths).chain(&angles).all(Option::is_none) {
return Err(bad(C, "give x, y, or a handle's length or angle"));
}
let n = edit_anchors(s, "Set Anchor", C, |sp, i| {
let Some(a) = sp.anchors.get_mut(i) else { return false };
let before = *a;
let to = Point::new(x.unwrap_or(a.p.x), y.unwrap_or(a.p.y));
a.translate(to - a.p);
for (out, (len, ang)) in [false, true].into_iter().zip(lengths.into_iter().zip(angles)) {
if len.is_none() && ang.is_none() {
continue;
}
let h = if out { a.h_out } else { a.h_in };
let (l0, a0) = pt::handle_polar(a.p, h).unwrap_or((0.0, if out { 0.0 } else { 180.0 }));
let (l, deg) = (len.unwrap_or(l0), ang.unwrap_or(a0));
// A length alone keeps a smooth anchor's other handle where it is; an angle turns it too.
let other = if out { a.h_in } else { a.h_out };
a.set_handle(out, pt::handle_at(a.p, l, deg), false);
if ang.is_none() {
if out { a.h_in = other } else { a.h_out = other }
}
}
*a != before
})?;
Ok(json!({ "anchors": n }))
}
fn remove_keep_shape(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.removeKeepShape";
let picked = picked_anchors(s, false)?;
if picked.is_empty() {
return Err(bad(C, "direct-select the anchor points to remove"));
}
let d = &s.doc()?.doc;
check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?;
let mut removed = 0;
let mut changed = vec![];
for (id, by_sub) in &picked {
let Some(mut np) = d.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
for (si, anchors) in by_sub {
let Some(sp) = np.subpaths.get_mut(*si) else { continue };
// From the last back, so the indexes left stay right.
for &i in anchors.iter().rev() {
if let Some((out, _)) = po::remove_anchor_keep_shape(sp, i) {
*sp = out;
removed += 1;
}
}
}
if np != *d.node(*id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(*id))? {
changed.push((*id, np));
}
}
edit_whole(s, "Remove Points, Keep Shape", changed)?;
Ok(json!({ "removed": removed }))
}
fn add_points(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.addPoints";
let count = number_or(p, "count", 1.0, C)?;
if !(1.0..=MAX_ADD as f64).contains(&count) || count.fract() != 0.0 {
return Err(bad(C, "`count` must be a whole number from 1 to 100"));
}
let picked = picked_anchors(s, true)?;
let st = s.doc()?;
let d = &st.doc;
check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?;
let mut added = 0;
let mut changed = vec![];
for (id, by_sub) in &picked {
let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue };
let picked_segments = st.selection.segments.get(id);
let mut np = path.clone();
for (si, sp) in np.subpaths.iter_mut().enumerate() {
let Some(anchors) = by_sub.get(&si) else { continue };
let n = sp.anchors.len();
// Picked segments, else those between two selected anchors.
let segs: BTreeSet<usize> = match picked_segments {
Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(),
None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n.max(1)))).collect(),
};
let (out, marks) = pt::add_points(sp, Some(&segs), count as usize);
added += marks.len();
*sp = out;
}
if np != *path {
changed.push((*id, np));
}
}
if added == 0 {
return Err(bad(C, "select segments, or paths, to add points to"));
}
edit_whole(s, "Add Points to Segments", changed)?;
Ok(json!({ "added": added }))
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.close";
let mode = match text_param(p, "mode", C)? {
None => pt::CloseMode::Handles,
Some(m) => pt::CloseMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"handles\", \"straight\" or \"smooth\""))?,
};
let r = ratio(p, C)?;
let roots = super::super::edit::selected_roots(s)?;
let d = &s.doc()?.doc;
let mut closed = 0;
let mut changed = vec![];
for id in paths_under(d, &roots) {
let Some(path) = d.node(id).and_then(|n| n.path_data()) else { continue };
let mut np = path.clone();
let n = np.subpaths.iter_mut().map(|sp| pt::close(sp, mode, r)).filter(|c| *c).count();
if n > 0 {
closed += n;
changed.push((id, np));
}
}
if changed.is_empty() {
return Err(bad(C, "select open paths to close"));
}
s.edit("Close Path", |d, _| {
for (id, np) in changed {
set_path(d, id, np);
}
Ok(())
})?;
Ok(json!({ "closed": closed }))
}
// ---------- Split / Add Points at Crossings ----------
fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.crossings";
let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?);
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
let ids = paths_under(d, &roots);
let paths: Vec<&PathData> = ids.iter().filter_map(|id| d.node(*id)?.path_data()).collect();
if paths.len() != ids.len() {
return Err(EngineError::Other("Add Points at Crossings: a path went missing".into()));
}
let places = pt::crossings(&paths, self_crossings)
.ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross (at most 10000 segments)"))?;
let mut count = 0;
// Each path that is crossed: its new subpaths, and the anchors at the crossings on each.
let mut changed: Vec<(NodeId, Vec<SubPath>, Vec<BTreeSet<usize>>)> = vec![];
for ((id, path), on) in ids.iter().zip(&paths).zip(places) {
if on.is_empty() {
continue;
}
let mut subs = vec![];
let mut marks = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
let (out, m) = pt::insert_at(sp, &here);
count += m.len();
subs.push(out);
marks.push(m);
}
changed.push((*id, subs, marks));
}
if changed.is_empty() {
return Ok(json!({ "ids": [], "crossings": 0 }));
}
let label = if cut { "Split at Crossings" } else { "Add Points at Crossings" };
let out = s.edit(label, |d, sel| {
let mut all = vec![];
let mut beside = Beside::replacing(d, &changed.iter().map(|(id, ..)| *id).collect::<Vec<_>>());
for (id, subs, marks) in changed {
sel.anchors.remove(&id);
sel.segments.remove(&id);
if !cut {
set_path(d, id, PathData::new(subs));
all.push(id);
continue;
}
let pieces: Vec<SubPath> = subs.iter().zip(&marks).flat_map(|(sp, m)| sp.cut_at(m)).collect();
// A path of one subpath becomes a path per piece; a compound shape's pieces stay its subpaths.
if subs.len() == 1 {
let made = replace_with_pieces(d, &mut beside, id, pieces.into_iter().map(PathData::single).collect())?;
for n in &made {
if !sel.contains(*n) {
sel.add(*n);
}
}
all.extend(made);
} else {
set_path(d, id, PathData::new(pieces));
all.push(id);
}
}
beside.finish(d)?;
Ok(all)
})?;
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>(), "crossings": count }))
}
@@ -0,0 +1,275 @@
//! Path Precision Tools' commands: each one undo step, bad params refused, and the tools' gestures
//! through the session.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{AnchorKind, PathData, Point};
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::{Session, ViewInfo};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
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();
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
}
/// A circle of 16 anchors about (100, 100), radius 50, selected.
fn circle(s: &mut Session) -> NodeId {
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
e
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
}
/// Direct-select anchors `anchors` (of subpath 0) of `id`.
fn select_anchors(s: &mut Session, id: NodeId, anchors: &[usize]) {
s.select(|_, sel| {
sel.set([id]);
sel.anchors.insert(id, anchors.iter().map(|a| (0, *a)).collect::<BTreeSet<_>>());
})
.unwrap();
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Each of `bad` is refused and leaves the document as it was.
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
for p in bad {
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
}
assert_eq!(steps(s), n, "{cmd}");
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
}
#[test]
fn the_brush_removes_anchors_under_it_keeping_the_shape() {
let mut s = session();
let e = circle(&mut s);
assert_eq!(path(&s, e).anchor_count(), 16);
let bounds = path(&s, e).bounds().unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "tolerance": 100})).unwrap();
let left = path(&s, e).anchor_count();
assert!(left < 16 && left > 8, "{left}");
assert_eq!(r["removed"], json!(16 - left));
assert_eq!(steps(&s), n + 1, "one undo step");
let b = path(&s, e).bounds().unwrap();
assert!((b.x1 - bounds.x1).abs() < 2.5 && (b.y0 - bounds.y0).abs() < 0.01, "{b:?} vs {bounds:?}");
// The anchors away from the brush stay: those on the left of the circle.
assert!(path(&s, e).anchors().filter(|(_, _, a)| a.p.x < 100.0).count() >= 7);
// A plain delete takes every anchor under it, whatever the shape.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "plain": true})).unwrap();
assert!(path(&s, e).anchors().all(|(_, _, a)| a.p.x < 121.0));
// Nothing under the brush: no step.
let n = steps(&s);
s.execute("addon.pathtools.erase", &json!({"points": [[390, 290]], "diameter": 5})).unwrap();
assert_eq!(steps(&s), n);
refuses(
&mut s,
"addon.pathtools.erase",
&[
json!({}),
json!({"points": "x"}),
json!({"points": [[1, "y"]]}),
json!({"points": [[100, 100]], "diameter": 0}),
json!({"points": [[100, 100]], "diameter": 3000}),
json!({"points": [[100, 100]], "tolerance": 0}),
json!({"points": [[100, 100]], "plain": "yes"}),
],
);
}
#[test]
fn the_brush_tool_is_one_undo_step_per_stroke() {
let mut s = session();
let e = circle(&mut s);
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("anchorEraserBrush", view).unwrap();
assert_eq!(s.tool_id(), "anchorEraserBrush");
s.set_tool_option("size", &json!(60));
s.set_tool_option("tolerance", &json!(100));
let n = steps(&s);
for (k, y) in
[(PointerKind::Down, 40.0), (PointerKind::Drag, 80.0), (PointerKind::Drag, 120.0), (PointerKind::Drag, 160.0), (PointerKind::Up, 160.0)]
{
s.pointer(&PointerEvent::new(k, 150.0, y), view).unwrap();
}
assert_eq!(steps(&s), n + 1);
assert!(path(&s, e).anchor_count() < 16);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, e).anchor_count(), 16);
}
#[test]
fn extend_lengthens_and_shortens_an_end() {
let mut s = session();
let l = line(&mut s, &[(0.0, 100.0), (100.0, 100.0)], false);
let n = steps(&s);
s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": 50})).unwrap();
assert_eq!(path(&s, l).subpaths[0].anchors.last().unwrap().p, Point::new(150.0, 100.0));
assert_eq!(steps(&s), n + 1);
s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "start", "to": [-30, 120], "mode": "bezier"})).unwrap();
assert!((path(&s, l).subpaths[0].anchors[0].p.x + 30.0).abs() < 1e-6);
let r = s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": -60})).unwrap();
assert_eq!(r["length"], json!(-60.0));
assert!((path(&s, l).length() - 120.0).abs() < 1e-3, "{}", path(&s, l).length());
assert_eq!(steps(&s), n + 3);
let sq = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
refuses(
&mut s,
"addon.pathtools.extend",
&[
json!({"id": l.0, "end": "end"}),
json!({"id": l.0, "end": "middle", "length": 5}),
json!({"id": l.0, "end": "end", "length": 5, "to": [1, 1]}),
json!({"id": l.0, "end": "end", "length": 5, "mode": "spiral"}),
json!({"id": l.0, "end": "end", "length": -1000}),
json!({"id": l.0, "end": "end", "length": 1e9}),
json!({"id": l.0, "end": "end", "to": [1]}),
json!({"id": l.0, "subpath": 3, "end": "end", "length": 5}),
json!({"id": sq.0, "end": "end", "length": 5}),
json!({"id": 99999, "end": "end", "length": 5}),
],
);
}
#[test]
fn inspect_reads_the_path_under_a_point() {
let mut s = session();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap());
let v = s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 99, "y": 1})).unwrap();
assert_eq!((v["closed"].clone(), v["anchors"].clone()), (json!(true), json!(4)));
assert!((v["length"].as_f64().unwrap() - 300.0).abs() < 1e-6 && (v["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6);
assert!((v["anchor"]["angle"].as_f64().unwrap() - 90.0).abs() < 1e-9);
assert!(v["radius"].is_null());
assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1})).is_err());
assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1, "y": 1, "tolerance": -1})).is_err());
}
#[test]
fn smooth_corner_and_equalize_edit_the_selected_anchors_in_one_step_each() {
let mut s = session();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
select(&mut s, &[r]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.smooth", &json!({})).unwrap()["anchors"], json!(4));
assert!(path(&s, r).anchors().all(|(_, _, a)| a.kind == AnchorKind::Smooth && (a.h_out - a.p).hypot() > 39.0));
assert_eq!(steps(&s), n + 1);
// Only the direct-selected anchor.
select_anchors(&mut s, r, &[0]);
s.execute("addon.pathtools.corner", &json!({})).unwrap();
let p = path(&s, r);
assert!(!p.subpaths[0].anchors[0].has_out() && p.subpaths[0].anchors[1].has_out());
assert_eq!(steps(&s), n + 2);
// Equalise: one handle made to match the other.
select_anchors(&mut s, r, &[1]);
s.execute("addon.pathtools.setAnchor", &json!({"outLength": 10})).unwrap();
let a = path(&s, r).subpaths[0].anchors[1];
assert!(((a.h_out - a.p).hypot() - 10.0).abs() < 1e-9 && (a.h_in - a.p).hypot() > 39.0);
s.execute("addon.pathtools.equalize", &json!({})).unwrap();
let a = path(&s, r).subpaths[0].anchors[1];
assert!(((a.h_out - a.p).hypot() - (a.h_in - a.p).hypot()).abs() < 1e-9);
// A selection with nothing to change makes no step.
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.equalize", &json!({})).unwrap()["anchors"], json!(0));
assert_eq!(steps(&s), n);
refuses(&mut s, "addon.pathtools.smooth", &[json!({"ratio": 0.9}), json!({"ratio": "x"})]);
refuses(&mut s, "addon.pathtools.corner", &[json!({"retract": 1})]);
}
#[test]
fn set_anchor_moves_anchors_and_sets_handles() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (50.0, 0.0), (100.0, 0.0)], false);
select_anchors(&mut s, l, &[1]);
let n = steps(&s);
s.execute("addon.pathtools.setAnchor", &json!({"x": 60, "y": 20, "outLength": 30, "outAngle": 90})).unwrap();
let a = path(&s, l).subpaths[0].anchors[1];
assert_eq!(a.p, Point::new(60.0, 20.0));
assert!(a.h_out.distance(Point::new(60.0, -10.0)) < 1e-9, "{a:?}");
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.setAnchor", &[json!({}), json!({"x": "1"}), json!({"inLength": -2}), json!({"y": f64::MAX})]);
}
#[test]
fn remove_keep_shape_takes_the_direct_selected_anchors() {
let mut s = session();
let e = circle(&mut s);
// Selected whole: nothing to say which anchors go.
refuses(&mut s, "addon.pathtools.removeKeepShape", &[json!({})]);
select_anchors(&mut s, e, &[1, 5]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.removeKeepShape", &json!({})).unwrap()["removed"], json!(2));
assert_eq!(path(&s, e).anchor_count(), 14);
assert_eq!(steps(&s), n + 1);
}
#[test]
fn add_points_splits_selected_segments_evenly() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({"count": 3})).unwrap()["added"], json!(3));
let xs: Vec<f64> = path(&s, l).anchors().map(|(_, _, a)| a.p.x).collect();
assert!(xs.iter().zip([0.0, 25.0, 50.0, 75.0, 100.0]).all(|(x, w)| (x - w).abs() < 1e-3), "{xs:?}");
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.addPoints", &[json!({"count": 0}), json!({"count": 101}), json!({"count": 1.5})]);
// An anchor alone selects no segment.
select_anchors(&mut s, l, &[1]);
refuses(&mut s, "addon.pathtools.addPoints", &[json!({})]);
}
#[test]
fn close_joins_open_ends() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (50.0, 80.0)], false);
select(&mut s, &[l]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.close", &json!({"mode": "smooth"})).unwrap()["closed"], json!(1));
assert!(path(&s, l).subpaths[0].closed);
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.close", &[json!({}), json!({"mode": "round"})]);
}
#[test]
fn crossings_add_anchors_or_cut_in_one_step() {
let mut s = session();
let a = line(&mut s, &[(0.0, 0.0), (100.0, 100.0)], false);
let b = line(&mut s, &[(0.0, 100.0), (100.0, 0.0)], false);
select(&mut s, &[a, b]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(2));
assert_eq!((path(&s, a).anchor_count(), path(&s, b).anchor_count()), (3, 3));
assert!(path(&s, a).subpaths[0].anchors[1].p.distance(Point::new(50.0, 50.0)) < 1e-6);
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 4, "{r}");
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
}
+4 -1
View File
@@ -4,11 +4,14 @@
//! its shape within a tolerance (shared by every add-on that takes anchors away).
//! - [`cleanup`]: Artwork Cleanup's path repairs (collinear, duplicate and near anchors, tiny
//! handles, nearly closed paths) and Rejoin Path Pieces.
//! - [`pathtools`]: Path Precision Tools' anchor edits, path readouts, extending open paths and
//! finding where paths cross.
pub mod cleanup;
pub mod keep_shape;
pub mod pathtools;
pub use cleanup::{
Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles,
};
pub use keep_shape::{SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_keep_shape};
pub use keep_shape::{SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_anchors_keep_shape, simplify_keep_shape};
+12
View File
@@ -113,6 +113,18 @@ pub fn simplify_keep_shape(path: &PathData, tolerance: f64, protect_corners: boo
PathData::new(path.subpaths.iter().map(|sp| Reducer::new(sp, protect_corners).run(tolerance)).collect())
}
/// [`simplify_keep_shape`] on subpath `sp` where only the anchors `may_go` marks (one flag per
/// anchor, by index) may go: what the Anchor Eraser Brush passes over. Open ends always stay. An
/// infinite tolerance removes every marked anchor that can go.
pub fn simplify_anchors_keep_shape(sp: &SubPath, tolerance: f64, may_go: &[bool]) -> SubPath {
if tolerance.is_nan() || tolerance <= 0.0 {
return sp.clone();
}
let mut r = Reducer::new(sp, false);
r.kept = (0..sp.anchors.len()).map(|i| !may_go.get(i).copied().unwrap_or(false)).collect();
r.run(tolerance)
}
/// One subpath being reduced: what is left of it, and where its anchors were in the original.
struct Reducer<'a> {
original: &'a SubPath,
+334
View File
@@ -0,0 +1,334 @@
//! Path Precision Tools' geometry: anchor edits for the Anchor Inspector (smooth by the auto-smooth
//! rule, equalise handles, add evenly spaced anchors, close), the readouts the Measure & Inspect
//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]) and the places
//! where paths cross ([`crossings`]).
mod crossings;
mod extend;
#[cfg(test)]
mod tests;
use std::collections::BTreeSet;
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Vec2};
use vectorcraft_geom::{AnchorKind, PathData, SubPath};
pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at};
pub use extend::{ExtendMode, extend_length_to, extend_open};
use crate::fit::{end_tangent, start_tangent};
/// The auto-smooth rule's handle length as a share of the nearer neighbour's distance: the share
/// that makes a square's four corners a circle.
pub const SMOOTH_RATIO: f64 = 0.3905;
/// The smallest and largest smoothing ratio.
pub const SMOOTH_RATIO_RANGE: (f64, f64) = (0.1, 0.5);
/// Most anchors [`add_points`] puts on one segment.
pub const MAX_ADDED_PER_SEGMENT: usize = 100;
/// Arc-length accuracy (pt) of the measurements here.
const ACCURACY: f64 = 1e-6;
/// Shorter than this (pt) is no handle and no distance.
const TINY: f64 = 1e-9;
fn unit(v: Vec2) -> Option<Vec2> {
let l = v.hypot();
(l > TINY && l.is_finite()).then(|| v / l)
}
/// The indices of anchor `i`'s neighbours in `sp` (`None` past an open end).
fn neighbours(sp: &SubPath, i: usize) -> (Option<usize>, Option<usize>) {
let n = sp.anchors.len();
let prev = if i > 0 { Some(i - 1) } else { (sp.closed && n > 1).then(|| n - 1) };
let next = if i + 1 < n { Some(i + 1) } else { (sp.closed && n > 1).then_some(0) };
(prev.filter(|&j| j != i), next.filter(|&j| j != i))
}
/// Make anchor `i` smooth by the auto-smooth rule: a lone handle is mirrored at its length; with
/// none, both handles stand at right angles to the bisector of the angle its neighbours make at
/// it, each `ratio` × the nearer neighbour's distance long; two handles are brought into line,
/// keeping their lengths. An open end without handles gets one aimed at its neighbour's facing
/// handle. False when nothing changes or there is no direction to take.
pub fn auto_smooth(sp: &mut SubPath, i: usize, ratio: f64) -> bool {
let (prev, next) = neighbours(sp, i);
let at = |j: Option<usize>| j.and_then(|j| sp.anchors.get(j)).copied();
let (prev, next) = (at(prev), at(next));
let Some(a) = sp.anchors.get_mut(i) else { return false };
let before = *a;
let p = a.p;
match (a.has_in(), a.has_out()) {
(true, true) => {
let (Some(o), Some(n)) = (unit(a.h_out - p), unit(p - a.h_in)) else { return false };
// Already in line: only the kind may change.
if o.cross(n).abs() <= 1e-12 && o.dot(n) > 0.0 {
a.kind = AnchorKind::Smooth;
return *a != before;
}
let Some(d) = unit(o + n) else { return false };
a.h_out = p + d * (a.h_out - p).hypot();
a.h_in = p - d * (a.h_in - p).hypot();
}
(true, false) if next.is_some() => a.h_out = p - (a.h_in - p),
(false, true) if prev.is_some() => a.h_in = p - (a.h_out - p),
(false, false) => match (prev, next) {
(Some(pr), Some(nx)) => {
let (dp, dn) = (pr.p.distance(p), nx.p.distance(p));
let (Some(up), Some(un)) = (unit(pr.p - p), unit(nx.p - p)) else { return false };
let Some(dir) = unit(un - up) else { return false };
let len = dp.min(dn) * ratio;
a.h_out = p + dir * len;
a.h_in = p - dir * len;
}
// An open end: aim at the neighbour's facing handle (or the neighbour itself).
(None, Some(nx)) => {
let Some(dir) = unit(nx.h_in - p) else { return false };
a.h_out = p + dir * (nx.p.distance(p) * ratio);
}
(Some(pr), None) => {
let Some(dir) = unit(pr.h_out - p) else { return false };
a.h_in = p + dir * (pr.p.distance(p) * ratio);
}
(None, None) => return false,
},
_ => return false,
}
a.kind = if a.has_in() && a.has_out() { AnchorKind::Smooth } else { AnchorKind::Corner };
*a != before
}
/// Make anchor `i` a corner: its handles retracted with `retract`, else left where they are.
/// False when nothing changes.
pub fn make_corner(sp: &mut SubPath, i: usize, retract: bool) -> bool {
let Some(a) = sp.anchors.get_mut(i) else { return false };
let before = *a;
if retract {
a.retract();
}
a.kind = AnchorKind::Corner;
*a != before
}
/// Give anchor `i`'s two handles the same length, their average, each keeping its direction; a
/// missing one (where there is a segment for it) is made opposite the other at its length. False
/// when there is nothing to equalise.
pub fn equalize_handles(sp: &mut SubPath, i: usize) -> bool {
let (prev, next) = neighbours(sp, i);
let Some(a) = sp.anchors.get_mut(i) else { return false };
let before = *a;
let p = a.p;
match (a.has_in(), a.has_out()) {
(true, true) => {
let len = ((a.h_in - p).hypot() + (a.h_out - p).hypot()) / 2.0;
let (Some(di), Some(dout)) = (unit(a.h_in - p), unit(a.h_out - p)) else { return false };
a.h_in = p + di * len;
a.h_out = p + dout * len;
}
(true, false) if next.is_some() => a.h_out = p - (a.h_in - p),
(false, true) if prev.is_some() => a.h_in = p - (a.h_out - p),
_ => return false,
}
*a != before
}
/// `sp` with `count` anchors added on each of `segments` (all of them when `None`), splitting each
/// into `count + 1` pieces of equal length; the shape doesn't change. → (the subpath, the indexes of
/// the anchors added).
pub fn add_points(sp: &SubPath, segments: Option<&BTreeSet<usize>>, count: usize) -> (SubPath, BTreeSet<usize>) {
let count = count.min(MAX_ADDED_PER_SEGMENT);
let mut hits = vec![];
for seg in (0..sp.segment_count()).filter(|s| segments.is_none_or(|set| set.contains(s))) {
let c = sp.segment(seg);
let len = c.arclen(ACCURACY);
if !(len > TINY && len.is_finite()) {
continue;
}
hits.extend((1..=count).map(|k| (seg, c.inv_arclen(len * k as f64 / (count + 1) as f64, ACCURACY))));
}
insert_at(sp, &hits)
}
/// How [`close`] joins an open subpath's ends.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CloseMode {
/// With the handles the ends already have.
Handles,
/// With a straight segment.
Straight,
/// With a segment that leaves both ends smoothly ([`auto_smooth`]).
Smooth,
}
impl CloseMode {
pub const ALL: [Self; 3] = [Self::Handles, Self::Straight, Self::Smooth];
pub fn id(self) -> &'static str {
match self {
Self::Handles => "handles",
Self::Straight => "straight",
Self::Smooth => "smooth",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id() == s)
}
}
/// Close open subpath `sp` (of two anchors or more) the way `mode` says. False when it is closed
/// already or too short.
pub fn close(sp: &mut SubPath, mode: CloseMode, ratio: f64) -> bool {
if sp.closed || sp.anchors.len() < 2 {
return false;
}
if mode == CloseMode::Straight {
if let Some(a) = sp.anchors.first_mut() {
a.h_in = a.p;
}
if let Some(a) = sp.anchors.last_mut() {
a.h_out = a.p;
}
}
sp.closed = true;
if mode == CloseMode::Smooth {
let last = sp.anchors.len() - 1;
auto_smooth(sp, 0, ratio);
auto_smooth(sp, last, ratio);
}
true
}
/// A handle's length (pt) and direction (degrees counter-clockwise on the page from the x axis);
/// `None` for no handle.
pub fn handle_polar(p: Point, h: Point) -> Option<(f64, f64)> {
let v = h - p;
let len = v.hypot();
(len > TINY).then(|| (len, angle_of(v)))
}
/// Direction of `v` in degrees counter-clockwise on the page (y points down), in (-180, 180].
pub fn angle_of(v: Vec2) -> f64 {
(-v.y).atan2(v.x).to_degrees() + 0.0
}
/// Where a handle `len` long at `deg` degrees (counter-clockwise on the page) from `p` ends.
pub fn handle_at(p: Point, len: f64, deg: f64) -> Point {
let (s, c) = deg.to_radians().sin_cos();
p + Vec2::new(c, -s) * len
}
/// What the Measure & Inspect tool reads off a path at a point near it.
#[derive(Clone, Debug, PartialEq)]
pub struct Inspection {
pub subpath: usize,
pub closed: bool,
pub anchors: usize,
/// The subpath's length (pt).
pub length: f64,
/// The area the subpath encloses (pt², an open one closed by a straight line).
pub area: f64,
pub segment: usize,
pub t: f64,
/// The point on the path nearest the pointer.
pub point: Point,
pub segment_length: f64,
/// The direction the path runs there, in degrees counter-clockwise on the page.
pub tangent: f64,
/// The radius of curvature there (pt); `None` where the path runs straight.
pub radius: Option<f64>,
/// The anchor near the pointer, when one is.
pub anchor: Option<AnchorInspection>,
}
/// An anchor's readouts.
#[derive(Clone, Debug, PartialEq)]
pub struct AnchorInspection {
pub index: usize,
pub p: Point,
pub smooth: bool,
/// The angle the path makes at the anchor (degrees: 90 at a square's corner, 180 where it runs
/// straight on); `None` at an open end.
pub angle: Option<f64>,
/// Each handle's (length, direction): `None` for no handle.
pub handle_in: Option<(f64, f64)>,
pub handle_out: Option<(f64, f64)>,
}
/// Radius of curvature of `c` at `t`: `None` where it runs straight or has no direction.
fn radius_at(c: &CubicBez, t: f64) -> Option<f64> {
use kurbo::ParamCurveDeriv;
let d1 = c.deriv();
let d = d1.eval(t).to_vec2();
let dd = d1.deriv().eval(t).to_vec2();
let speed = d.hypot();
let k = d.cross(dd) / (speed * speed * speed);
(speed > TINY && k.is_finite() && k.abs() > 1e-9).then(|| 1.0 / k.abs())
}
/// The direction `c` runs at `t` (unit), falling back to its chord's ends where the derivative
/// vanishes (a retracted handle).
fn tangent_at(c: &CubicBez, t: f64) -> Vec2 {
use kurbo::ParamCurveDeriv;
unit(c.deriv().eval(t).to_vec2()).unwrap_or_else(|| if t < 0.5 { start_tangent(c) } else { end_tangent(c) })
}
/// The readouts of anchor `i` of `sp`.
pub fn inspect_anchor(sp: &SubPath, i: usize) -> Option<AnchorInspection> {
let a = sp.anchors.get(i)?;
let (prev, next) = neighbours(sp, i);
let angle = match (prev, next) {
(Some(pr), Some(_)) if sp.segment_count() > 0 => {
let (into, out) = (end_tangent(&sp.segment(pr)), start_tangent(&sp.segment(i)));
Some((-into).dot(out).clamp(-1.0, 1.0).acos().to_degrees())
}
_ => None,
};
Some(AnchorInspection {
index: i,
p: a.p,
smooth: a.kind == AnchorKind::Smooth,
angle,
handle_in: handle_polar(a.p, a.h_in),
handle_out: handle_polar(a.p, a.h_out),
})
}
/// What `path` reads at `p`: its point nearest `p` and the readouts there, and the anchor within
/// `tol` of `p` if one is. `None` for a path with no segments or one with a non-finite coordinate.
pub fn inspect(path: &PathData, p: Point, tol: f64) -> Option<Inspection> {
let finite = |q: Point| q.x.is_finite() && q.y.is_finite();
if !finite(p) || !path.anchors().all(|(_, _, a)| finite(a.p) && finite(a.h_in) && finite(a.h_out)) {
return None;
}
let (si, seg, t, point, _) = path.nearest(p)?;
let sp = path.subpaths.get(si)?;
let c = sp.segment(seg);
let anchor = [(seg, c.p0), ((seg + 1) % sp.anchors.len(), c.p3)]
.into_iter()
.filter(|(_, q)| q.distance(p) <= tol)
.min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
.and_then(|(i, _)| inspect_anchor(sp, i));
Some(Inspection {
subpath: si,
closed: sp.closed,
anchors: sp.anchors.len(),
length: (0..sp.segment_count()).map(|s| sp.segment(s).arclen(ACCURACY)).sum(),
area: sp.signed_area().abs(),
segment: seg,
t,
point,
segment_length: c.arclen(ACCURACY),
tangent: angle_of(tangent_at(&c, t)),
radius: radius_at(&c, t),
anchor,
})
}
/// The point of `sp`'s segments nearest `p`: (segment, t, distance).
pub(crate) fn nearest_on(sp: &SubPath, p: Point) -> Option<(usize, f64, f64)> {
(0..sp.segment_count())
.map(|s| {
let n = sp.segment(s).nearest(p, ACCURACY);
(s, n.t, n.distance_sq.sqrt())
})
.min_by(|a, b| a.2.total_cmp(&b.2))
}
@@ -0,0 +1,192 @@
//! Where paths cross: [`crossings`] finds the places, [`insert_at`] puts anchors there (which
//! Split / Add Points at Crossings then keeps or cuts at).
use std::collections::{BTreeMap, BTreeSet};
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
/// Most segments [`crossings`] compares at once.
pub const MAX_CROSSING_SEGMENTS: usize = 10_000;
/// Most subdivision steps one [`crossings`] call takes.
const MAX_STEPS: usize = 4_000_000;
/// A piece of curve whose inner control points lie this close (pt) to its chord counts as straight.
const FLAT: f64 = 1e-4;
/// Deepest a curve is halved.
const MAX_DEPTH: u32 = 96;
/// Crossings closer than this (pt) are one.
const SAME: f64 = 1e-3;
/// A parameter this close to a segment's end is its anchor.
const END: f64 = 1e-6;
/// A place on a path: (subpath, segment, t).
pub type Place = (usize, usize, f64);
fn control_box(c: &CubicBez) -> Rect {
Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3)
}
fn flat(c: &CubicBez) -> bool {
let chord = c.p3 - c.p0;
let len = chord.hypot();
let off = |p: Point| if len > 1e-12 { chord.cross(p - c.p0).abs() / len } else { p.distance(c.p0) };
off(c.p1) <= FLAT && off(c.p2) <= FLAT
}
/// Where the chords of `a` and `b` cross: (s, u) along each, or `None` when they don't (parallel
/// ones never do).
fn chords_cross(a: &CubicBez, b: &CubicBez) -> Option<(f64, f64)> {
let (da, db) = (a.p3 - a.p0, b.p3 - b.p0);
let den = da.cross(db);
if den.abs() <= 1e-12 * da.hypot() * db.hypot() || den == 0.0 {
return None;
}
let w = b.p0 - a.p0;
let (s, u) = (w.cross(db) / den, w.cross(da) / den);
let within = |v: f64| (-1e-9..=1.0 + 1e-9).contains(&v);
(within(s) && within(u)).then(|| (s.clamp(0.0, 1.0), u.clamp(0.0, 1.0)))
}
/// Where segments `a` and `b` cross: (t on `a`, t on `b`) pairs, halving both while their control
/// boxes meet until each piece is straight. `None` once `budget` steps are spent.
fn segment_crossings(a: &CubicBez, b: &CubicBez, budget: &mut usize) -> Option<Vec<(f64, f64)>> {
let mut stack = vec![(0.0, 1.0, 0.0, 1.0, 0u32)];
let mut found: Vec<(f64, f64)> = vec![];
while let Some((a0, a1, b0, b1, depth)) = stack.pop() {
*budget = budget.checked_sub(1)?;
let (ca, cb) = (a.subsegment(a0..a1), b.subsegment(b0..b1));
if !control_box(&ca).inflate(SAME, SAME).overlaps(control_box(&cb)) {
continue;
}
if (flat(&ca) && flat(&cb)) || depth >= MAX_DEPTH {
if let Some((s, u)) = chords_cross(&ca, &cb) {
// The pieces are straight but their parameters needn't run evenly along them.
let x = ca.p0.lerp(ca.p3, s);
let (ta, tb) = (ca.nearest(x, 1e-9).t, cb.nearest(cb.p0.lerp(cb.p3, u), 1e-9).t);
found.push((a0 + ta * (a1 - a0), b0 + tb * (b1 - b0)));
}
continue;
}
// Halve the larger of the pieces still curved: two curves lying along each other then
// split along their length, not into every pair of pieces.
let size = |c: &CubicBez| if flat(c) { -1.0 } else { control_box(c).size().to_vec2().hypot() };
let d = depth + 1;
if size(&ca) >= size(&cb) {
let am = (a0 + a1) / 2.0;
stack.extend([(a0, am, b0, b1, d), (am, a1, b0, b1, d)]);
} else {
let bm = (b0 + b1) / 2.0;
stack.extend([(a0, a1, b0, bm, d), (a0, a1, bm, b1, d)]);
}
}
found.sort_by(|x, y| x.0.total_cmp(&y.0));
// The same crossing found by neighbouring pieces counts once.
found.dedup_by(|x, y| a.eval(x.0).distance(a.eval(y.0)) < SAME && b.eval(x.1).distance(b.eval(y.1)) < SAME);
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 `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.
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() {
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;
}
segs.push((k, si, s, c, control_box(&c)));
if segs.len() > MAX_CROSSING_SEGMENTS {
return None;
}
}
}
}
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() {
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))))
{
continue;
}
if !ra.inflate(SAME, SAME).overlaps(*rb) {
continue;
}
for (ta, tb) in segment_crossings(ca, cb, &mut budget)? {
out.get_mut(*ka)?.push((*sa, *ia, ta));
out.get_mut(*kb)?.push((*sb, *ib, tb));
}
}
}
for places in &mut out {
places.sort_by(|x, y| (x.0, x.1).cmp(&(y.0, y.1)).then(x.2.total_cmp(&y.2)));
places.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.
pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet<usize>) {
let (n, segs) = (sp.anchors.len(), sp.segment_count());
let mut inner: BTreeMap<usize, Vec<f64>> = BTreeMap::new();
let mut at_anchor = vec![];
for &(seg, t) in places.iter().filter(|(seg, t)| *seg < segs && t.is_finite()) {
match t {
_ if t <= END => at_anchor.push(seg),
_ if t >= 1.0 - END => at_anchor.push((seg + 1) % n),
_ => inner.entry(seg).or_default().push(t),
}
}
for ts in inner.values_mut() {
ts.sort_by(f64::total_cmp);
ts.dedup_by(|x, y| (*x - *y).abs() < END);
}
// Each split segment's pieces, cut from the original curve so their parameters stay true.
let mut anchors = sp.anchors.clone();
let mut added: BTreeMap<usize, Vec<Anchor>> = BTreeMap::new();
for (&seg, ts) in &inner {
let c = sp.segment(seg);
let bounds: Vec<f64> = std::iter::once(0.0).chain(ts.iter().copied()).chain([1.0]).collect();
let pieces: Vec<CubicBez> = bounds.windows(2).filter_map(|w| Some(c.subsegment(*w.first()?..*w.get(1)?))).collect();
if sp.segment_is_line(seg) {
added.insert(seg, pieces.iter().skip(1).map(|q| Anchor::corner(q.p0)).collect());
continue;
}
let made = pieces.windows(2).filter_map(|w| match w {
[a, b] => Some(Anchor { p: b.p0, h_in: a.p2, h_out: b.p1, kind: AnchorKind::Smooth }),
_ => None,
});
added.insert(seg, made.collect());
if let (Some(first), Some(a)) = (pieces.first(), anchors.get_mut(seg)) {
a.h_out = first.p1;
}
if let (Some(last), Some(a)) = (pieces.last(), anchors.get_mut((seg + 1) % n)) {
a.h_in = last.p2;
}
}
let mut out = SubPath::new(Vec::with_capacity(n + places.len()), sp.closed);
for (i, a) in anchors.into_iter().enumerate() {
out.anchors.push(a);
out.anchors.extend(added.remove(&i).unwrap_or_default());
}
let added_before = |j: usize| inner.range(..j).map(|(_, v)| v.len()).sum::<usize>();
let mut marks: BTreeSet<usize> = at_anchor.into_iter().map(|j| j + added_before(j)).collect();
for (&seg, ts) in &inner {
let base = seg + added_before(seg);
marks.extend((1..=ts.len()).map(|m| base + m));
}
(out, marks)
}
@@ -0,0 +1,220 @@
//! Lengthen an open path at one end, carrying on the way it runs there, or shorten it back along
//! itself: [`extend_open`], and [`extend_length_to`] for the length that brings the end nearest a
//! point (what a drag asks for).
use std::f64::consts::{FRAC_PI_2, PI};
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
use super::{ACCURACY, TINY, nearest_on};
use crate::fit::end_tangent;
/// How an end carries on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExtendMode {
/// In a straight line along the end's direction.
Straight,
/// By stretching the last segment's curve on past its end (no anchor added).
Bezier,
/// Along the circle that matches the end's curvature, at most half way round.
Arc,
}
impl ExtendMode {
pub const ALL: [Self; 3] = [Self::Straight, Self::Bezier, Self::Arc];
pub fn id(self) -> &'static str {
match self {
Self::Straight => "straight",
Self::Bezier => "bezier",
Self::Arc => "arc",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id() == s)
}
}
/// The least length (pt) shortening leaves of a path.
const MIN_LEFT: f64 = 1e-3;
/// An end straighter than this radius (pt) carries on in a straight line in [`ExtendMode::Arc`].
const MAX_RADIUS: f64 = 1e6;
/// The farthest (in its parameter) [`ExtendMode::Bezier`] stretches a segment.
const MAX_STRETCH: f64 = 1024.0;
fn finite(sp: &SubPath) -> bool {
let ok = |p: Point| p.x.is_finite() && p.y.is_finite();
sp.anchors.iter().all(|a| ok(a.p) && ok(a.h_in) && ok(a.h_out))
}
fn length(sp: &SubPath) -> f64 {
(0..sp.segment_count()).map(|s| sp.segment(s).arclen(ACCURACY)).sum()
}
/// The last segment of an open subpath of two anchors or more.
fn last_segment(sp: &SubPath) -> Option<CubicBez> {
let n = sp.segment_count();
(n > 0).then(|| sp.segment(n - 1))
}
/// Open subpath `sp` (two anchors or more) lengthened by `length` pt at its first (`at_start`) or
/// last anchor the way `mode` says, or shortened along itself by a negative `length`. `None` when it
/// is closed, too short, not finite, or shortening would leave nothing of it.
pub fn extend_open(sp: &SubPath, at_start: bool, length: f64, mode: ExtendMode) -> Option<SubPath> {
if sp.closed || sp.anchors.len() < 2 || !length.is_finite() || !finite(sp) {
return None;
}
let mut s = sp.clone();
if at_start {
s.reverse();
}
let mut out = match mode {
_ if length < 0.0 => trim_end(&s, -length)?,
_ if length == 0.0 => s,
ExtendMode::Straight => straight_end(s, length)?,
ExtendMode::Bezier => bezier_end(s, length)?,
ExtendMode::Arc => arc_end(s, length)?,
};
if at_start {
out.reverse();
}
Some(out)
}
/// The length (pt) [`extend_open`] takes to bring the end of `sp` nearest `to`: positive along the
/// way `mode` carries the end on, negative back along the path (never all of it). `None` when there
/// is no such end.
pub fn extend_length_to(sp: &SubPath, at_start: bool, to: Point, mode: ExtendMode) -> Option<f64> {
if !(to.x.is_finite() && to.y.is_finite()) {
return None;
}
let mut s = sp.clone();
if at_start {
s.reverse();
}
let total = length(&s);
let end = s.anchors.last()?.p;
// Long enough to reach past the pointer; the original stays a prefix of the result.
let probe = (to.distance(end) * 2.0).max(1.0);
let ext = extend_open(&s, false, probe, mode)?;
let (seg, t, _) = nearest_on(&ext, to)?;
let before: f64 = (0..seg).map(|k| ext.segment(k).arclen(ACCURACY)).sum();
let along = before + ext.segment(seg).subsegment(0.0..t).arclen(ACCURACY);
Some((along - total).max(MIN_LEFT - total))
}
/// `sp` with `cut` pt taken off its end.
fn trim_end(sp: &SubPath, cut: f64) -> Option<SubPath> {
if cut >= length(sp) - MIN_LEFT {
return None;
}
let mut s = sp.clone();
let mut left = cut;
while left > 0.0 {
let c = last_segment(&s)?;
let len = c.arclen(ACCURACY);
let line = s.segment_is_line(s.segment_count() - 1);
if left >= len {
s.anchors.pop();
left -= len;
if let Some(a) = s.anchors.last_mut() {
a.h_out = a.p;
a.kind = AnchorKind::Corner;
}
continue;
}
let part = c.subsegment(0.0..c.inv_arclen(len - left, ACCURACY));
let k = s.anchors.len();
if let Some(a) = s.anchors.get_mut(k - 2).filter(|_| !line) {
a.h_out = part.p1;
}
if let Some(a) = s.anchors.last_mut() {
*a = if line { Anchor::corner(part.p3) } else { Anchor { p: part.p3, h_in: part.p2, h_out: part.p3, kind: AnchorKind::Corner } };
}
break;
}
(s.anchors.len() >= 2).then_some(s)
}
/// `sp` with a straight segment `len` pt long added at its end, along its end's direction.
fn straight_end(mut s: SubPath, len: f64) -> Option<SubPath> {
let dir = end_tangent(&last_segment(&s)?);
let a = s.anchors.last_mut()?;
a.h_out = a.p;
let p = a.p + dir * len;
s.anchors.push(Anchor::corner(p));
Some(s)
}
/// `sp` with its last segment's curve stretched on by `len` pt (its end anchor moved, no anchor
/// added); a straight last segment just grows.
fn bezier_end(mut s: SubPath, len: f64) -> Option<SubPath> {
let c = last_segment(&s)?;
let k = s.anchors.len();
if s.segment_is_line(k - 2) {
let p = c.p3 + end_tangent(&c) * len;
*s.anchors.last_mut()? = Anchor::corner(p);
return Some(s);
}
let target = c.arclen(ACCURACY) + len;
let reach = |t: f64| c.subsegment(0.0..t).arclen(ACCURACY);
let mut hi = 2.0;
while reach(hi) < target && hi < MAX_STRETCH {
hi *= 2.0;
}
let mut lo = 1.0;
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
if reach(mid) < target { lo = mid } else { hi = mid }
}
let ext = c.subsegment(0.0..hi);
s.anchors.get_mut(k - 2)?.h_out = ext.p1;
*s.anchors.last_mut()? = Anchor { p: ext.p3, h_in: ext.p2, h_out: ext.p3, kind: AnchorKind::Corner };
Some(s)
}
/// The signed radius of the circle through three points (positive when the turn from `a` through
/// `b` to `c` is counter-clockwise in the x→y sense); `None` when they lie in a line.
fn signed_radius(a: Point, b: Point, c: Point) -> Option<f64> {
let cross = (b - a).cross(c - b);
let r = (b - a).hypot() * (c - b).hypot() * (c - a).hypot() / (2.0 * cross.abs());
(cross.abs() > TINY && r.is_finite()).then(|| r.copysign(cross))
}
/// `v` turned by `a` radians (x toward y).
fn rot(v: Vec2, a: f64) -> Vec2 {
let (s, c) = a.sin_cos();
Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
}
/// `sp` carried on by an arc `len` pt long (at most half a circle) of the circle that leaves its
/// end in its direction with the curvature it ends with; straight where it ends straight.
fn arc_end(mut s: SubPath, len: f64) -> Option<SubPath> {
let c = last_segment(&s)?;
let dir = end_tangent(&c);
let Some(r) = signed_radius(c.eval(0.96), c.eval(0.98), c.p3).filter(|r| r.abs() <= MAX_RADIUS) else { return straight_end(s, len) };
let radius = r.abs();
let turn = (len / radius).min(PI).copysign(r);
let normal: Vec2 = Vec2::new(-dir.y, dir.x) * r.signum();
let p = c.p3;
let centre = p + normal * radius;
let pieces = (turn.abs() / FRAC_PI_2).ceil().max(1.0) as usize;
let step = turn / pieces as f64;
let h = 4.0 / 3.0 * (step.abs() / 4.0).tan() * radius;
let at = |phi: f64| (centre + rot(p - centre, phi), rot(dir, phi));
let first = at(0.0);
let end = s.anchors.last_mut()?;
end.h_out = first.0 + first.1 * h;
if end.has_in() {
end.kind = AnchorKind::Smooth;
}
for j in 1..=pieces {
let (q, t) = at(step * j as f64);
let h_out = if j < pieces { q + t * h } else { q };
let kind = if j < pieces { AnchorKind::Smooth } else { AnchorKind::Corner };
s.anchors.push(Anchor { p: q, h_in: q - t * h, h_out, kind });
}
Some(s)
}
@@ -0,0 +1,296 @@
use std::collections::BTreeSet;
use kurbo::{ParamCurve, ParamCurveArclen, Point, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
use super::*;
use crate::addons::simplify_anchors_keep_shape;
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn line(pts: &[(f64, f64)], closed: bool) -> SubPath {
SubPath::polyline(&pts.iter().map(|&(x, y)| pt(x, y)).collect::<Vec<_>>(), closed)
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn length(sp: &SubPath) -> f64 {
(0..sp.segment_count()).map(|i| sp.segment(i).arclen(1e-6)).sum()
}
/// The farthest any sampled point of `sp` lies off the circle about `c` of radius `r`.
fn off_circle(sp: &SubPath, c: Point, r: f64) -> f64 {
(0..sp.segment_count())
.flat_map(|i| (0..=16).map(move |k| (i, k as f64 / 16.0)))
.map(|(i, t)| (sp.segment(i).eval(t).distance(c) - r).abs())
.fold(0.0, f64::max)
}
#[test]
fn auto_smooth_turns_a_square_into_a_circle() {
let mut sq = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
for i in 0..4 {
assert!(auto_smooth(&mut sq, i, SMOOTH_RATIO));
}
let a = sq.anchors[1];
assert_eq!(a.kind, AnchorKind::Smooth);
// Each handle is the ratio × the side long, at right angles to the corner's bisector.
assert!(near((a.h_out - a.p).hypot(), 39.05, 1e-9) && near((a.h_in - a.p).hypot(), 39.05, 1e-9));
assert!(near((a.h_out - a.p).dot(Vec2::new(-1.0, 1.0)), 0.0, 1e-9));
// The four corners make a circle through them, to within its cubic approximation.
assert!(off_circle(&sq, pt(50.0, 50.0), 50f64.hypot(50.0)) < 0.2, "{}", off_circle(&sq, pt(50.0, 50.0), 50f64.hypot(50.0)));
// A smooth anchor stays as it is.
assert!(!auto_smooth(&mut sq, 1, SMOOTH_RATIO));
}
#[test]
fn auto_smooth_mirrors_a_lone_handle_and_aims_open_ends() {
let mut sp = SubPath::new(
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(40.0, -10.0), pt(50.0, 0.0)), Anchor::corner(pt(100.0, 0.0))],
false,
);
assert!(auto_smooth(&mut sp, 1, SMOOTH_RATIO));
assert_eq!(sp.anchors[1].h_out, pt(60.0, 10.0));
// An open end with no handle aims at its neighbour's facing handle.
assert!(auto_smooth(&mut sp, 0, SMOOTH_RATIO));
let h = sp.anchors[0].h_out;
assert!(near(h.distance(pt(0.0, 0.0)), 50.0 * SMOOTH_RATIO, 1e-9));
assert!(near((h - pt(0.0, 0.0)).cross(pt(40.0, -10.0) - pt(0.0, 0.0)), 0.0, 1e-9), "{h:?}");
// Two handles out of line are brought into line, keeping their lengths.
let mut bent = SubPath::new(
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(40.0, 0.0), pt(50.0, 20.0)), Anchor::corner(pt(100.0, 0.0))],
false,
);
assert!(auto_smooth(&mut bent, 1, SMOOTH_RATIO));
let a = bent.anchors[1];
assert!(
near((a.h_in - a.p).cross(a.h_out - a.p), 0.0, 1e-9) && near((a.h_out - a.p).hypot(), 20.0, 1e-9) && near((a.h_in - a.p).hypot(), 10.0, 1e-9)
);
}
#[test]
fn equalise_averages_lengths_and_makes_a_missing_handle() {
let mut sp = SubPath::new(
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(40.0, 0.0), pt(80.0, 0.0)), Anchor::corner(pt(100.0, 0.0))],
false,
);
assert!(equalize_handles(&mut sp, 1));
assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (pt(30.0, 0.0), pt(70.0, 0.0)));
assert!(!equalize_handles(&mut sp, 1), "already equal");
let mut one = SubPath::new(
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(50.0, 0.0), pt(50.0, 20.0)), Anchor::corner(pt(100.0, 0.0))],
false,
);
assert!(equalize_handles(&mut one, 1));
assert_eq!(one.anchors[1].h_in, pt(50.0, -20.0));
assert!(!equalize_handles(&mut one, 0), "no handles to equalise");
}
#[test]
fn make_corner_retracts_or_keeps_handles() {
let a = Anchor::smooth(pt(50.0, 0.0), pt(60.0, 0.0));
let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), a, Anchor::corner(pt(100.0, 0.0))], false);
assert!(make_corner(&mut sp, 1, false));
assert_eq!((sp.anchors[1].kind, sp.anchors[1].h_out), (AnchorKind::Corner, pt(60.0, 0.0)));
assert!(make_corner(&mut sp, 1, true));
assert!(!sp.anchors[1].has_in() && !sp.anchors[1].has_out());
}
#[test]
fn add_points_splits_segments_into_equal_lengths() {
let sp = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 40.0)], false);
let (out, added) = add_points(&sp, Some(&BTreeSet::from([0])), 3);
let xs: Vec<f64> = out.anchors.iter().map(|a| a.p.x).collect();
for (x, want) in xs.iter().zip([0.0, 25.0, 50.0, 75.0, 100.0, 100.0]) {
assert!(near(*x, want, 1e-4), "{xs:?}");
}
assert_eq!((xs.len(), added), (6, BTreeSet::from([1, 2, 3])));
// On a curve the pieces have the same length and the shape stays.
let mut sq = 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 sq, i, SMOOTH_RATIO);
}
let (out, added) = add_points(&sq, None, 2);
assert_eq!((out.anchors.len(), added.len()), (12, 8));
let lens: Vec<f64> = (0..3).map(|i| out.segment(i).arclen(1e-6)).collect();
assert!(near(lens[0], lens[1], 1e-3) && near(lens[1], lens[2], 1e-3), "{lens:?}");
assert!(near(length(&out), length(&sq), 1e-4));
}
#[test]
fn close_joins_ends_three_ways() {
let open = SubPath::new(
vec![Anchor::with_handles(pt(0.0, 0.0), pt(-10.0, 10.0), pt(10.0, -10.0)), Anchor::corner(pt(100.0, 0.0)), Anchor::corner(pt(50.0, 80.0))],
false,
);
let mut kept = open.clone();
assert!(close(&mut kept, CloseMode::Handles, SMOOTH_RATIO));
assert!(kept.closed && kept.anchors[0].h_in == pt(-10.0, 10.0));
let mut straight = open.clone();
assert!(close(&mut straight, CloseMode::Straight, SMOOTH_RATIO));
assert!(straight.closed && straight.segment_is_line(2));
let mut smooth = open.clone();
assert!(close(&mut smooth, CloseMode::Smooth, SMOOTH_RATIO));
assert!(smooth.anchors[2].has_in() && smooth.anchors[2].has_out(), "the last anchor is made smooth");
assert!(!close(&mut smooth, CloseMode::Smooth, SMOOTH_RATIO), "closed already");
assert_eq!(CloseMode::parse("straight"), Some(CloseMode::Straight));
}
#[test]
fn extend_straight_lengthens_and_shortens_either_end() {
let sp = line(&[(0.0, 0.0), (100.0, 0.0)], false);
let end = extend_open(&sp, false, 50.0, ExtendMode::Straight).unwrap();
assert_eq!(end.anchors.last().unwrap().p, pt(150.0, 0.0));
let start = extend_open(&sp, true, 20.0, ExtendMode::Straight).unwrap();
assert_eq!(start.anchors.first().unwrap().p, pt(-20.0, 0.0));
let short = extend_open(&sp, false, -30.0, ExtendMode::Straight).unwrap();
assert_eq!(short.anchors.len(), 2);
assert!(near(short.anchors[1].p.x, 70.0, 1e-6));
// Shortening past an anchor drops it.
let bent = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 10.0)], false);
let cut = extend_open(&bent, false, -40.0, ExtendMode::Straight).unwrap();
assert_eq!(cut.anchors.len(), 2);
assert!(near(cut.anchors[1].p.x, 70.0, 1e-6));
// Nothing is left of a path shortened by its whole length; closed paths have no ends.
assert!(extend_open(&sp, false, -100.0, ExtendMode::Straight).is_none());
assert!(extend_open(&line(&[(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)], true), false, 5.0, ExtendMode::Straight).is_none());
assert!(extend_open(&sp, false, f64::NAN, ExtendMode::Arc).is_none());
}
/// A quarter of the circle of radius 100 about the origin, from (100, 0) to (0, 100).
fn quarter() -> SubPath {
let k = 4.0 / 3.0 * (std::f64::consts::FRAC_PI_8).tan() * 100.0;
SubPath::new(
vec![Anchor::with_handles(pt(100.0, 0.0), pt(100.0, 0.0), pt(100.0, k)), Anchor::with_handles(pt(0.0, 100.0), pt(k, 100.0), pt(0.0, 100.0))],
false,
)
}
#[test]
fn extend_arc_follows_the_circle_the_end_curves_along() {
let sp = quarter();
let len = std::f64::consts::FRAC_PI_2 * 100.0;
let out = extend_open(&sp, false, len, ExtendMode::Arc).unwrap();
// The circle matches the curvature the (cubic) quarter ends with, within a percent or so of
// the true circle's.
assert!(off_circle(&out, Point::ORIGIN, 100.0) < 3.0, "{}", off_circle(&out, Point::ORIGIN, 100.0));
let end = out.anchors.last().unwrap().p;
assert!(end.distance(pt(-100.0, 0.0)) < 5.0, "{end:?}");
assert!(near(length(&out), 2.0 * len, 0.2));
// At most half way round.
let long = extend_open(&sp, false, 10.0 * len, ExtendMode::Arc).unwrap();
assert!(near(length(&long) - len, std::f64::consts::PI * 100.0, 6.0), "{}", length(&long) - len);
// A straight end carries on straight.
let straight = extend_open(&line(&[(0.0, 0.0), (10.0, 0.0)], false), false, 5.0, ExtendMode::Arc).unwrap();
assert_eq!(straight.anchors.last().unwrap().p, pt(15.0, 0.0));
}
#[test]
fn extend_bezier_stretches_the_last_curve_without_new_anchors() {
let sp = quarter();
let before = length(&sp);
let out = extend_open(&sp, false, 30.0, ExtendMode::Bezier).unwrap();
assert_eq!(out.anchors.len(), 2);
assert!(near(length(&out), before + 30.0, 1e-3), "{}", length(&out));
let p = out.segment(0).eval(0.5);
assert!((p.to_vec2().hypot() - 100.0).abs() < 15.0);
}
#[test]
fn the_length_a_drag_asks_for() {
let sp = line(&[(0.0, 0.0), (100.0, 0.0)], false);
assert!(near(extend_length_to(&sp, false, pt(130.0, 10.0), ExtendMode::Straight).unwrap(), 30.0, 1e-6));
assert!(near(extend_length_to(&sp, false, pt(40.0, 5.0), ExtendMode::Straight).unwrap(), -60.0, 1e-6));
assert!(near(extend_length_to(&sp, true, pt(-25.0, 3.0), ExtendMode::Straight).unwrap(), 25.0, 1e-6));
// Never the whole path.
assert!(extend_length_to(&sp, false, pt(-50.0, 0.0), ExtendMode::Straight).unwrap() > -100.0);
let arc = extend_length_to(&quarter(), false, pt(-100.0, 0.0), ExtendMode::Arc).unwrap();
assert!(near(arc, std::f64::consts::FRAC_PI_2 * 100.0, 3.0), "{arc}");
assert!(extend_length_to(&sp, false, pt(f64::NAN, 0.0), ExtendMode::Straight).is_none());
}
#[test]
fn crossings_are_found_on_both_paths() {
let a = PathData::single(line(&[(0.0, 0.0), (100.0, 100.0)], false));
let b = PathData::single(line(&[(0.0, 100.0), (100.0, 0.0)], false));
let hits = crossings(&[&a, &b], false).unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].len(), 1);
assert!(near(hits[0][0].2, 0.5, 1e-6) && near(hits[1][0].2, 0.5, 1e-6));
// A circle crossed by a line: twice.
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 across = PathData::single(line(&[(-40.0, 50.0), (140.0, 50.0)], false));
let circle = PathData::single(circle);
let hits = crossings(&[&circle, &across], false).unwrap();
assert_eq!((hits[0].len(), hits[1].len()), (2, 2), "{hits:?}");
// A path doesn't cross itself where its segments meet, and only crosses itself when asked.
let bow = PathData::single(line(&[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false));
assert!(crossings(&[&bow], false).unwrap()[0].is_empty());
assert_eq!(crossings(&[&bow], true).unwrap()[0].len(), 2, "one crossing, on two of its segments");
// Two identical paths lying on each other don't tangle the search.
let sq = PathData::single(circle.subpaths[0].clone());
assert!(crossings(&[&sq, &circle], false).is_some());
// A path ending on another meets it there: at its end anchor, and part way along the other.
let t = PathData::single(line(&[(50.0, 50.0), (50.0, 150.0)], false));
let hits = crossings(&[&b, &t], false).unwrap();
assert_eq!((hits[0].len(), hits[1].len()), (1, 1), "{hits:?}");
assert!(near(hits[1][0].2, 0.0, 1e-6) && near(b.subpaths[0].segment(0).eval(hits[0][0].2).x, 50.0, 1e-6));
}
#[test]
fn insert_at_keeps_the_shape_and_names_the_new_anchors() {
let sp = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
let (out, marks) = insert_at(&sp, &[(1, 0.5), (0, 0.25), (0, 0.75), (1, 1.0), (7, 0.5)]);
let pts: Vec<Point> = out.anchors.iter().map(|a| a.p).collect();
let c = sp.segment(0);
let want = [pt(0.0, 0.0), c.eval(0.25), c.eval(0.75), pt(100.0, 0.0), pt(100.0, 50.0), pt(100.0, 100.0)];
assert_eq!(pts.len(), want.len());
assert!(pts.iter().zip(want).all(|(p, w)| p.distance(w) < 1e-9), "{pts:?}");
assert_eq!(marks, BTreeSet::from([1, 2, 4, 5]));
// The closing segment of a closed path.
let tri = line(&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true);
let (out, marks) = insert_at(&tri, &[(2, 0.5)]);
assert_eq!((out.anchors.len(), out.anchors[3].p, marks), (4, pt(0.0, 50.0), BTreeSet::from([3])));
}
#[test]
fn inspect_reads_lengths_area_angles_and_curvature() {
let rect = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], true));
let i = inspect(&rect, pt(50.0, 1.0), 3.0).unwrap();
assert_eq!((i.closed, i.anchors, i.segment), (true, 4, 0));
assert!(near(i.length, 300.0, 1e-6) && near(i.area, 5000.0, 1e-6) && near(i.segment_length, 100.0, 1e-6));
assert_eq!((i.radius, i.anchor.is_none()), (None, true));
let corner = inspect(&rect, pt(99.0, 1.0), 3.0).unwrap().anchor.unwrap();
assert_eq!(corner.index, 1);
assert!(near(corner.angle.unwrap(), 90.0, 1e-9));
let r = inspect(&PathData::single(quarter()), pt(71.0, 71.0), 1.0).unwrap();
assert!(near(r.radius.unwrap(), 100.0, 1.0), "{:?}", r.radius);
assert!(near(r.tangent, -135.0, 1.0), "{}", r.tangent);
let open = inspect(&PathData::single(quarter()), pt(100.0, 0.0), 1.0).unwrap().anchor.unwrap();
assert_eq!(open.angle, None, "an open end has no angle");
assert!(inspect(&PathData::default(), pt(0.0, 0.0), 1.0).is_none());
// Handle readouts: length and direction counter-clockwise on the page.
assert_eq!(handle_polar(pt(0.0, 0.0), pt(0.0, -10.0)), Some((10.0, 90.0)));
assert!(handle_at(pt(0.0, 0.0), 10.0, 90.0).distance(pt(0.0, -10.0)) < 1e-9);
}
#[test]
fn the_brush_kernel_removes_only_marked_anchors() {
let pts: Vec<(f64, f64)> = (0..=10).map(|i| (i as f64 * 10.0, 0.0)).collect();
let sp = line(&pts, false);
let marked: Vec<bool> = (0..=10).map(|i| (3..=5).contains(&i)).collect();
let out = simplify_anchors_keep_shape(&sp, 0.1, &marked);
let xs: Vec<f64> = out.anchors.iter().map(|a| a.p.x).collect();
assert_eq!(xs, [0.0, 10.0, 20.0, 60.0, 70.0, 80.0, 90.0, 100.0]);
// Off the line, a removal within tolerance only.
let bump = line(&[(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (30.0, 5.0), (40.0, 0.0)], false);
assert_eq!(simplify_anchors_keep_shape(&bump, 0.1, &[true; 5]).anchors.len(), 4, "only the anchor on the straight run goes");
assert_eq!(simplify_anchors_keep_shape(&bump, f64::INFINITY, &[true; 5]).anchors.len(), 2, "unless tolerance is ignored");
}
+53
View File
@@ -0,0 +1,53 @@
//! The tools of VectorCraft's built-in add-ons. They aren't in the toolbars as they ship: the All
//! Tools drawer lists them (under their add-on's heading) so they can be added, and each add-on's
//! submenu selects them. Like every tool they only emit commands (`addon.<name>.*`).
pub mod anchor_eraser;
pub mod extend_path;
pub mod measure_inspect;
use crate::{Tool, ToolInfo};
/// Path Precision Tools' tools, one flyout group.
pub const PATH_TOOLS: &[ToolInfo] = &[
ToolInfo { id: anchor_eraser::ID, label: "Anchor Eraser Brush", shortcut: None, icon: "dc-remove-brush" },
ToolInfo { id: extend_path::ID, label: "Extend Path Tool", shortcut: None, icon: "dc-arrow-extend" },
ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" },
];
/// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]).
pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS];
/// The heading the All Tools drawer lists tool `id` under: its add-on's name.
pub fn heading(id: &str) -> Option<&'static str> {
PATH_TOOLS.iter().any(|t| t.id == id).then_some("Path Precision Tools")
}
/// Create an add-on tool by id (None = not one of them).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()),
extend_path::ID => Box::new(extend_path::ExtendPathTool::default()),
measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_addon_tool_is_made_and_catalogued_but_not_in_the_toolbar() {
for t in GROUPS.iter().flat_map(|g| g.iter()) {
assert_eq!(create(t.id).map(|tool| tool.id()), Some(t.id));
assert_eq!(crate::tool_info(t.id).map(|i| i.label), Some(t.label));
assert!(crate::create(t.id).id() == t.id);
assert!(t.shortcut.is_none(), "no default shortcut");
assert!(!crate::TOOL_GROUPS.iter().any(|g| g.iter().any(|x| x.id == t.id)), "{} is not in the toolbar's groups", t.id);
assert_eq!(heading(t.id), Some("Path Precision Tools"));
}
assert!(crate::catalog::all_tools().any(|t| t.id == extend_path::ID));
assert!(create("pen").is_none() && heading("pen").is_none());
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Anchor Eraser Brush (Path Precision Tools): paint over selected paths and the anchors the round
//! brush passes over go, their neighbours' handles refitted, wherever the outline can do without
//! them within the tolerance (`addon.pathtools.erase`, previewed while painting, one undo step per
//! stroke). Alt removes every anchor under the brush that can go whatever the tolerance; Cmd/Ctrl
//! deletes them without refitting. `[` and `]` make the brush smaller and larger. With Pressure on,
//! a pen's pressure scales the brush.
use serde_json::{Value, json};
use vectorcraft_geom::Point;
use crate::{Action, Cursor, MAX_STROKE_SAMPLES, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, previews_at};
/// The tool's id.
pub const ID: &str = "anchorEraserBrush";
/// The brush's diameter on screen (px) by default, and its range.
pub const SIZE: f64 = 20.0;
pub const SIZE_RANGE: (f64, f64) = (1.0, 2500.0);
/// The tolerance by default and its range.
pub const TOLERANCE: f64 = 10.0;
pub const TOLERANCE_RANGE: (f64, f64) = (1.0, 100.0);
/// The undo step's name.
const LABEL: &str = "Anchor Eraser Brush";
/// The brush's outline colour.
const RED: [u8; 3] = [0xe0, 0x30, 0x30];
pub struct AnchorEraserTool {
/// Diameter on screen (px).
size: f64,
tolerance: f64,
pressure: bool,
/// The stroke under way: its points and their pen pressure.
points: Vec<(Point, f32)>,
/// The modifiers the stroke started with: Alt ignores the tolerance, Cmd/Ctrl skips refitting.
mods: Mods,
painting: bool,
/// Where the brush is.
at: Option<Point>,
}
impl Default for AnchorEraserTool {
fn default() -> Self {
Self { size: SIZE, tolerance: TOLERANCE, pressure: false, points: vec![], mods: Mods::default(), painting: false, at: None }
}
}
impl AnchorEraserTool {
/// `addon.pathtools.erase`'s params for the stroke so far.
fn params(&self, cx: &ToolContext) -> Value {
let points: Vec<Value> = self
.points
.iter()
.map(|(p, f)| if self.pressure && *f < 1.0 { json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0]) } else { json!([p.x, p.y]) })
.collect();
json!({
"points": points,
"diameter": cx.tol(self.size).clamp(0.1, SIZE_RANGE.1),
"tolerance": self.tolerance,
"ignoreTolerance": self.mods.alt,
"plain": self.mods.cmd,
})
}
}
impl Tool for AnchorEraserTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.painting
}
fn wants_samples(&self) -> bool {
true
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
self.at = Some(ev.pos);
match ev.kind {
// Nothing selected: nothing to erase from.
PointerKind::Down if !cx.selection.objects.is_empty() => {
self.points = vec![(ev.pos, ev.pressure)];
self.mods = ev.mods;
self.painting = true;
vec![Action::Begin(LABEL.into()), Action::Preview("addon.pathtools.erase".into(), self.params(cx))]
}
PointerKind::Drag if self.painting => {
if self.points.len() >= MAX_STROKE_SAMPLES {
return vec![];
}
self.points.push((ev.pos, ev.pressure));
if previews_at(self.points.len()) { vec![Action::Preview("addon.pathtools.erase".into(), self.params(cx))] } else { vec![] }
}
PointerKind::Up if self.painting => {
if self.points.len() < MAX_STROKE_SAMPLES && self.points.last().is_none_or(|(p, _)| *p != ev.pos) {
self.points.push((ev.pos, ev.pressure));
}
self.painting = false;
let out = vec![Action::Preview("addon.pathtools.erase".into(), self.params(cx)), Action::Commit];
self.points.clear();
out
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.painting => {
self.painting = false;
self.points.clear();
vec![Action::Cancel]
}
ToolKey::BracketLeft | ToolKey::BracketRight => {
let step = if key == ToolKey::BracketLeft { 1.0 / 1.25 } else { 1.25 };
self.size = (self.size * step).round().clamp(SIZE_RANGE.0, SIZE_RANGE.1);
vec![]
}
_ => vec![],
}
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::BracketLeft | ToolKey::BracketRight) || (self.painting && key == ToolKey::Escape)
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.at = None;
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(at) = self.at else { return vec![] };
let r = cx.tol(self.size / 2.0);
let circle = kurbo::Shape::to_path(&kurbo::Circle::new(at, r), 0.1 * r.max(1e-6));
vec![Overlay::Path { path: circle, color: RED, width: 1.0, dashed: false }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::AnchorEraser
}
fn options(&self) -> Value {
json!({ "size": self.size, "tolerance": self.tolerance, "pressure": self.pressure })
}
fn set_option(&mut self, key: &str, v: &Value) {
let num = |v: &Value, (lo, hi): (f64, f64)| v.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(lo, hi));
match key {
"size" => self.size = num(v, SIZE_RANGE).unwrap_or(self.size),
"tolerance" => self.tolerance = num(v, TOLERANCE_RANGE).unwrap_or(self.tolerance),
"pressure" => self.pressure = v.as_bool().unwrap_or(self.pressure),
_ => {}
}
}
}
#[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_stroke_previews_the_erase_and_commits_once() {
let (d, id) = doc_with_rect();
let mut sel = Selection::default();
sel.set([id]);
let p = paint();
let cx = cx(&d, &sel, &p);
let mut t = AnchorEraserTool::default();
let down = t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
assert_eq!(down.first(), Some(&Action::Begin(LABEL.into())));
assert!(t.busy());
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0));
let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") };
assert_eq!(cmd, "addon.pathtools.erase");
assert_eq!(params["points"], json!([[100.0, 100.0], [150.0, 100.0]]));
assert_eq!((params["diameter"].clone(), params["tolerance"].clone()), (json!(20.0), json!(10.0)));
let up = t.pointer(&cx, &ev(PointerKind::Up, 200.0, 100.0));
assert!(matches!(up.as_slice(), [Action::Preview(..), Action::Commit]), "{up:?}");
assert!(!t.busy());
// Alt ignores the tolerance; Cmd deletes without refitting; Escape cancels.
let alt = Mods { alt: true, cmd: true, ..Mods::default() };
let down = t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt));
let Some(Action::Preview(_, params)) = down.get(1) else { panic!("{down:?}") };
assert_eq!((params["ignoreTolerance"].clone(), params["plain"].clone()), (json!(true), json!(true)));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert!(t.pointer(&cx, &ev(PointerKind::Up, 100.0, 100.0)).is_empty());
}
#[test]
fn nothing_selected_paints_nothing() {
let (d, _) = doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = AnchorEraserTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 100.0)).is_empty());
assert!(!t.busy());
}
#[test]
fn options_size_the_brush_and_pressure_scales_it() {
let (d, id) = doc_with_rect();
let mut sel = Selection::default();
sel.set([id]);
let p = paint();
let mut cx = cx(&d, &sel, &p);
cx.zoom = 2.0;
let mut t = AnchorEraserTool::default();
t.set_option("size", &json!(40));
t.set_option("tolerance", &json!(500));
t.set_option("pressure", &json!(true));
assert_eq!(t.options(), json!({"size": 40.0, "tolerance": 100.0, "pressure": true}));
let mut down = ev(PointerKind::Down, 10.0, 10.0);
down.pressure = 0.5;
let a = t.pointer(&cx, &down);
let Some(Action::Preview(_, params)) = a.get(1) else { panic!("{a:?}") };
assert_eq!(params["diameter"], json!(20.0), "40 px at 200%");
assert_eq!(params["points"], json!([[10.0, 10.0, 0.5]]));
// The brush shows round the pointer, and `]` makes it larger.
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Path { .. }]));
t.key(&cx, ToolKey::BracketRight, Mods::default());
assert_eq!(t.options()["size"], json!(50.0));
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::AnchorEraser);
}
}
+282
View File
@@ -0,0 +1,282 @@
//! Extend Path tool (Path Precision Tools): drag an end of an open path (selected or not) to carry
//! the path on the way it runs there, or back along itself to shorten it
//! (`addon.pathtools.extend`, previewed while dragging, one undo step). Over an end, a dashed line
//! shows how it would go on. E cycles the mode (straight, the last curve stretched, an arc of the
//! circle the end curves along); Shift steps the length by the keyboard increment; back over the
//! end, the length snaps to none.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Point, SubPath};
use vectorcraft_pathops::addons::pathtools::{ExtendMode, extend_length_to, extend_open};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "extendPath";
/// The undo step's name.
const LABEL: &str = "Extend Path";
/// How far ahead (px) the hover preview shows an end going on.
const PREVIEW_PX: f64 = 60.0;
const GREY: [u8; 3] = [0x80, 0x80, 0x80];
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
/// An end of an open subpath.
#[derive(Clone, Debug, PartialEq)]
struct End {
id: NodeId,
subpath: usize,
start: bool,
p: Point,
/// The subpath as it was.
sp: SubPath,
}
#[derive(Default)]
pub struct ExtendPathTool {
mode: Mode,
hover: Option<End>,
/// The end being dragged and the length the drag asks for.
drag: Option<(End, f64)>,
}
/// [`ExtendMode`], with the default the tool starts with.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Mode(ExtendMode);
impl Default for Mode {
fn default() -> Self {
Mode(ExtendMode::Straight)
}
}
/// The end of an editable open path nearest `p` within the point tolerance.
fn end_at(cx: &ToolContext, p: Point) -> Option<End> {
let tol = cx.point_tol();
let mut best: Option<(f64, NodeId, usize, bool, Point)> = None;
cx.doc.walk(|n| {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
for (si, sp) in path.subpaths.iter().enumerate().filter(|(_, sp)| !sp.closed && sp.anchors.len() >= 2) {
for (start, a) in [(true, sp.anchors.first()), (false, sp.anchors.last())] {
let Some(a) = a else { continue };
let d = a.p.distance(p);
if d <= tol && best.is_none_or(|b| d < b.0) {
best = Some((d, n.id, si, start, a.p));
}
}
}
});
let (_, id, subpath, start, at) = best.filter(|b| cx.doc.is_editable(b.1))?;
let sp = cx.doc.node(id)?.path_data()?.subpaths.get(subpath)?.clone();
Some(End { id, subpath, start, p: at, sp })
}
impl ExtendPathTool {
/// The length a drag to `p` asks for: back over the end none, with Shift in steps of the
/// keyboard increment.
fn length_at(&self, cx: &ToolContext, end: &End, p: Point, mods: Mods) -> f64 {
if p.distance(end.p) <= cx.point_tol() {
return 0.0;
}
let len = extend_length_to(&end.sp, end.start, p, self.mode.0).unwrap_or(0.0);
let step = cx.keyboard_increment;
if mods.shift && step > 0.0 && step.is_finite() { (len / step).round() * step } else { len }
}
fn params(&self, end: &End, length: f64) -> Value {
json!({
"id": end.id.0,
"subpath": end.subpath,
"end": if end.start { "start" } else { "end" },
"length": length,
"mode": self.mode.0.id(),
})
}
}
/// The length of `sp` (pt).
fn length_of(sp: &SubPath) -> f64 {
(0..sp.segment_count()).map(|i| kurbo::ParamCurveArclen::arclen(&sp.segment(i), 1e-6)).sum()
}
impl Tool for ExtendPathTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.hover = end_at(cx, ev.pos);
vec![]
}
PointerKind::Down => {
let Some(end) = end_at(cx, ev.pos) else { return vec![] };
self.hover = None;
self.drag = Some((end, 0.0));
vec![Action::Begin(LABEL.into())]
}
PointerKind::Drag | PointerKind::Up => {
let Some((end, _)) = self.drag.take() else { return vec![] };
let length = self.length_at(cx, &end, ev.pos, ev.mods);
let preview = Action::Preview("addon.pathtools.extend".into(), self.params(&end, length));
if ev.kind == PointerKind::Drag {
self.drag = Some((end, length));
return vec![preview];
}
// Back where it started: nothing to keep.
if length == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] }
}
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.drag.is_some() => {
self.drag = None;
vec![Action::Cancel]
}
ToolKey::Letter('e') => {
let all = ExtendMode::ALL;
let i = all.iter().position(|m| *m == self.mode.0).unwrap_or(0);
self.mode = Mode(all.get((i + 1) % all.len()).copied().unwrap_or(ExtendMode::Straight));
vec![]
}
_ => vec![],
}
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
key == ToolKey::Letter('e') || (self.drag.is_some() && key == ToolKey::Escape)
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = None;
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let size = 7.0;
if let Some((end, length)) = &self.drag {
let total = length_of(&end.sp) + length;
return vec![
Overlay::Anchor { p: end.p, color: BLUE, filled: false, size },
Overlay::Measure {
p: end.p + kurbo::Vec2::new(cx.tol(12.0), cx.tol(12.0)),
text: format!("Length: {}\nΔ: {}", cx.len(total), cx.len(*length)),
},
];
}
let Some(end) = &self.hover else { return vec![] };
let mut out = vec![Overlay::Anchor { p: end.p, color: BLUE, filled: true, size }];
// How the end would go on.
if let Some(ext) = extend_open(&end.sp, end.start, cx.tol(PREVIEW_PX), self.mode.0) {
let (k, n) = (end.sp.anchors.len(), ext.anchors.len());
// From the old end on; a stretched last curve (no anchor added) shows whole.
let from = if n > k { k - 1 } else { n.saturating_sub(2) };
let tail = if end.start { ext.anchors.get(..n - from) } else { ext.anchors.get(from..) };
if let Some(tail) = tail {
let mut bp = vectorcraft_geom::BezPath::new();
SubPath::new(tail.to_vec(), false).to_bezpath_into(&mut bp);
out.push(Overlay::Path { path: bp, color: GREY, width: 1.0, dashed: true });
}
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::ExtendPath
}
fn options(&self) -> Value {
json!({ "mode": self.mode.0.id() })
}
fn set_option(&mut self, key: &str, v: &Value) {
if key == "mode"
&& let Some(m) = v.as_str().and_then(ExtendMode::parse)
{
self.mode = Mode(m);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Document, Node, Selection};
use vectorcraft_geom::PathData;
/// A document with an open line from (100, 100) to (200, 100).
fn doc_with_line() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let sp = SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0)], false);
d.insert(Some(l), 0, Node::path(id, PathData::single(sp), Appearance::default_art())).unwrap();
(d, id)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn dragging_an_end_previews_the_extension_and_commits() {
let (d, id) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = ExtendPathTool::default();
// Hovering the end shows it and how it would go on.
assert!(t.pointer(&cx, &ev(PointerKind::Move, 201.0, 101.0)).is_empty());
let ov = t.overlays(&cx);
assert!(matches!(ov.as_slice(), [Overlay::Anchor { filled: true, .. }, Overlay::Path { dashed: true, .. }]), "{ov:?}");
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 201.0, 101.0)), vec![Action::Begin(LABEL.into())]);
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 240.0, 105.0));
let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") };
assert_eq!(cmd, "addon.pathtools.extend");
assert_eq!((params["id"].clone(), params["end"].clone(), params["mode"].clone()), (json!(id.0), json!("end"), json!("straight")));
assert!((params["length"].as_f64().unwrap() - 40.0).abs() < 1e-6);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Length: 140"))));
let up = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 100.0));
assert!(matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if (p["length"].as_f64().unwrap() + 40.0).abs() < 1e-6), "{up:?}");
// The start end too, and Shift steps the length.
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
let mut shifted = ev(PointerKind::Drag, 87.4, 100.0);
shifted.mods.shift = true;
let drag = t.pointer(&cx, &shifted);
let Some(Action::Preview(_, params)) = drag.first() else { panic!("{drag:?}") };
assert_eq!((params["end"].clone(), params["length"].clone()), (json!("start"), json!(13.0)));
// Back on the end: nothing to keep.
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 100.0, 100.0)), vec![Action::Cancel]);
}
#[test]
fn away_from_ends_nothing_happens_and_e_cycles_the_mode() {
let (d, _) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = ExtendPathTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0)).is_empty(), "the middle of the path");
assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 100.0)).is_empty());
assert!(t.overlays(&cx).is_empty());
assert!(t.claims_key(&cx, ToolKey::Letter('e')));
for want in ["bezier", "arc", "straight"] {
t.key(&cx, ToolKey::Letter('e'), Mods::default());
assert_eq!(t.options()["mode"], json!(want));
}
t.set_option("mode", &json!("arc"));
t.set_option("mode", &json!("spiral"));
assert_eq!(t.options()["mode"], json!("arc"));
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::ExtendPath);
// A closed path has no ends.
let (d, _) = doc_with_rect();
let cx = crate::testutil::cx(&d, &sel, &p);
assert!(t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)).is_empty());
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Measure & Inspect tool (Path Precision Tools): drag between two points to read their distance,
//! angle and offsets (Shift: 45° steps), which stay shown until the next drag; hover a path to
//! read it there: open or closed and its anchor count, the segment's and the path's length, the
//! area it encloses, the radius of curvature and the direction it runs at the pointer, and over an
//! anchor its angle and handles. Readouts are in the document's units; nothing is drawn into the
//! document (`addon.pathtools.inspect` gives agents the same readouts).
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{NodeId, Unit};
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_pathops::addons::pathtools::{Inspection, inspect};
use crate::xform::measure;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "measureInspect";
const CYAN: [u8; 3] = [0x00, 0xa8, 0xff];
const ORANGE: [u8; 3] = [0xff, 0x8a, 0x00];
#[derive(Default)]
pub struct MeasureInspectTool {
line: Option<(Point, Point)>,
dragging: bool,
/// The line measured before the drag under way, which Escape puts back.
before: Option<(Point, Point)>,
/// The path under the pointer and what it reads there.
hover: Option<(NodeId, Inspection)>,
}
/// An area of `pt2` square points in `unit` (`12.5 mm²`).
fn area_label(unit: Unit, pt2: f64) -> String {
let side = unit.from_pt(1.0);
format!("{:.2} {}²", pt2 * side * side, unit.suffix())
}
/// The readouts of `i`, a line each.
fn readout(cx: &ToolContext, i: &Inspection) -> String {
let mut lines = vec![
format!("{} · {} anchors", if i.closed { "Closed path" } else { "Open path" }, i.anchors),
format!("Segment: {}", cx.len(i.segment_length)),
format!("Path: {}", cx.len(i.length)),
format!("Area: {}", area_label(cx.unit, i.area)),
format!("Tangent: {:.1}°", i.tangent),
match i.radius {
Some(r) => format!("Radius: {}", cx.len(r)),
None => "Radius: straight".into(),
},
];
if let Some(a) = &i.anchor {
let kind = if a.smooth { "smooth" } else { "corner" };
lines.push(match a.angle {
Some(deg) => format!("Anchor {} ({kind}) · Angle: {deg:.1}°", a.index + 1),
None => format!("Anchor {} ({kind}) · open end", a.index + 1),
});
for (name, h) in [("In", a.handle_in), ("Out", a.handle_out)] {
if let Some((len, deg)) = h {
lines.push(format!("{name}: {} at {deg:.1}°", cx.len(len)));
}
}
}
lines.join("\n")
}
impl MeasureInspectTool {
/// The path whose outline is under `p`, and its readouts there.
fn inspect_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Inspection)> {
// Outlines only: a fill under the pointer is not a path to read.
let hit = hit_test(cx.doc, p, HitOptions { outline: true, ..cx.hit_options() })?;
let path = cx.doc.node(hit.leaf)?.path_data()?;
let i = inspect(path, p, cx.point_tol())?;
(i.point.distance(p) <= cx.pick_tol() * 2.0).then_some((hit.leaf, i))
}
}
impl Tool for MeasureInspectTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.dragging
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => self.hover = Self::inspect_at(cx, ev.pos),
PointerKind::Down => {
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.before = self.line;
self.line = Some((p, p));
self.dragging = true;
self.hover = None;
}
PointerKind::Drag | PointerKind::Up if self.dragging => {
if let Some((a, _)) = self.line {
let b = if ev.mods.shift {
a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle)
} else {
crate::guides::snap_draw(cx, ev.pos, &[]).0
};
self.line = Some((a, b));
}
self.dragging = ev.kind == PointerKind::Drag;
}
_ => {}
}
vec![]
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.dragging {
self.dragging = false;
self.line = self.before;
}
vec![]
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = None;
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let off = Vec2::new(cx.tol(14.0), cx.tol(14.0));
if let Some((a, b)) = self.line.filter(|(a, b)| a != b) {
let (dist, ang, dx, dy) = measure(a, b);
out.push(Overlay::Line { a, b, color: CYAN, dashed: false });
out.push(Overlay::Measure { p: b + off, text: format!("D: {}\nAngle: {ang:.1}°\n{}", cx.len(dist), cx.offset_label(dx, dy)) });
}
if let Some((id, i)) = self.hover.as_ref().filter(|_| !self.dragging) {
if let Some(sp) =
cx.doc.node(*id).and_then(|n| n.path_data()).and_then(|p| p.subpaths.get(i.subpath)).filter(|sp| i.segment < sp.segment_count())
{
let c = sp.segment(i.segment);
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to(c.p0);
bp.curve_to(c.p1, c.p2, c.p3);
out.push(Overlay::Path { path: bp, color: ORANGE, width: 2.0, dashed: false });
}
let at = i.anchor.as_ref().map_or(i.point, |a| a.p);
out.push(Overlay::Anchor { p: at, color: ORANGE, filled: i.anchor.is_some(), size: 6.0 });
out.push(Overlay::Measure { p: at + off, text: readout(cx, i) });
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::MeasureInspect
}
/// The last measurement, as the Measure tool reports it.
fn options(&self) -> Value {
match self.line {
Some((a, b)) => {
let (d, ang, dx, dy) = measure(a, b);
json!({ "x": a.x, "y": a.y, "distance": d, "angle": ang, "dx": dx, "dy": dy })
}
None => Value::Null,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
#[test]
fn a_drag_measures_and_hovering_a_path_reads_it() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = MeasureInspectTool::default();
for (k, x, y) in [(PointerKind::Down, 10.0, 10.0), (PointerKind::Drag, 40.0, -30.0), (PointerKind::Up, 40.0, -30.0)] {
assert!(t.pointer(&cx, &PointerEvent::new(k, x, y)).is_empty(), "nothing goes into the document");
}
assert!((t.options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("D: 50"))));
// Hover the rectangle's top edge (100, 100)–(200, 100), then its corner.
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 150.0, 101.0));
let text = t
.overlays(&cx)
.into_iter()
.filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text) } else { None })
.find(|t| t.contains("Segment"))
.expect("the readout");
assert!(text.contains("Closed path · 4 anchors") && text.contains("Segment: 100") && text.contains("Path: 400"), "{text}");
assert!(text.contains("Area: 10000.00 pt²") && text.contains("Radius: straight"), "{text}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 199.0, 101.0));
let ov = t.overlays(&cx);
assert!(ov.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Angle: 90.0°"))), "{ov:?}");
// Away from every outline (inside the fill) there is nothing to read.
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 150.0, 150.0));
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Segment"))));
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::MeasureInspect);
}
#[test]
fn escape_puts_back_the_measurement_before() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = MeasureInspectTool::default();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 30.0, 0.0));
t.key(&cx, ToolKey::Escape, Mods::default());
assert!(!t.busy());
assert_eq!(t.options(), Value::Null);
assert_eq!(area_label(Unit::Points, 2.0), "2.00 pt²");
}
}
+2 -1
View File
@@ -137,8 +137,9 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")],
];
/// Every tool: the toolbar's, then the add-ons' ([`crate::addons::GROUPS`]).
pub fn all_tools() -> impl Iterator<Item = &'static ToolInfo> {
TOOL_GROUPS.iter().flat_map(|g| g.iter())
TOOL_GROUPS.iter().chain(crate::addons::GROUPS).flat_map(|g| g.iter())
}
pub fn tool_info(id: &str) -> Option<&'static ToolInfo> {
+14 -2
View File
@@ -171,6 +171,12 @@ pub enum Cursor {
MeshAdd,
/// Puppet Warp over the selection's mesh where a pin can go: a click adds one.
PuppetPin,
/// The Anchor Eraser Brush (Path Precision Tools add-on); the brush's circle is drawn round it.
AnchorEraser,
/// The Extend Path tool (Path Precision Tools add-on).
ExtendPath,
/// The Measure & Inspect tool (Path Precision Tools add-on).
MeasureInspect,
}
impl Cursor {
@@ -261,7 +267,10 @@ impl Cursor {
| Cursor::SymbolStyler
| Cursor::Scissors
| Cursor::MeshAdd
| Cursor::PuppetPin => Cursor::Crosshair,
| Cursor::PuppetPin
| Cursor::AnchorEraser
| Cursor::ExtendPath
| Cursor::MeasureInspect => Cursor::Crosshair,
c => c,
}
}
@@ -295,7 +304,10 @@ impl Cursor {
| Cursor::SymbolStainer
| Cursor::SymbolScreener
| Cursor::SymbolStyler
| Cursor::Scissors => Some(Cursor::Crosshair),
| Cursor::Scissors
| Cursor::AnchorEraser
| Cursor::ExtendPath
| Cursor::MeasureInspect => Some(Cursor::Crosshair),
Cursor::TypeVertical => Some(Cursor::Text),
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed),
+18
View File
@@ -672,6 +672,24 @@ group = "Drawing and editing tools"
shown_when = ["The Print Tiling tool: a drag moves the printable page area over the artboard (it snaps to the artboard's edges)."]
hotspot = "The page corner being placed."
[[cursor]]
id = "AnchorEraser"
group = "Drawing and editing tools"
shown_when = ["The Anchor Eraser Brush (Path Precision Tools add-on): a drag over selected paths removes the anchors under the brush that the outline can do without. The app draws the brush's size as a circle around the cursor."]
hotspot = "The brush centre."
[[cursor]]
id = "ExtendPath"
group = "Drawing and editing tools"
shown_when = ["The Extend Path tool (Path Precision Tools add-on): a drag from an end of an open path lengthens or shortens the path there."]
hotspot = "Exact: the end being dragged follows it."
[[cursor]]
id = "MeasureInspect"
group = "Drawing and editing tools"
shown_when = ["The Measure & Inspect tool (Path Precision Tools add-on): drag to measure distance and angle; hover a path to read its lengths, area, curvature and angles."]
hotspot = "Exact, because precision matters here (an open centre suits it)."
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
# The brush size is drawn by the app as a circle around the cursor.
+2
View File
@@ -7,6 +7,7 @@
//! by the control channel and MCP, and keeps tools testable without a UI.
#![forbid(unsafe_code)]
pub mod addons;
pub mod bbox;
pub mod builder;
pub mod catalog;
@@ -291,6 +292,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
.or_else(|| intertwine::create(other))
.or_else(|| objects_on_path::create(other))
.or_else(|| touchtype::create(other))
.or_else(|| addons::create(other))
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
}
}
+3
View File
@@ -66,6 +66,9 @@ const ROWS: &[Row] = &[
],
),
(&["artboard"], None, &["moveArt", "scaleArt"]),
// The add-ons' tools (`crate::addons`).
(&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]),
(&[crate::addons::extend_path::ID], None, &["mode"]),
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
(
&["flare"],
+7 -1
View File
@@ -3,10 +3,16 @@
pub mod cleanup;
pub mod color;
pub mod pathtools;
use crate::menus::Item;
/// The add-ons' submenus, then a separator: the head of Object › Plug-ins.
pub fn plugin_menu() -> Vec<Item> {
vec![Item::Sub("Artwork Cleanup", cleanup::menu()), Item::Sub("Color Effects", color::menu()), Item::Sep]
vec![
Item::Sub("Artwork Cleanup", cleanup::menu()),
Item::Sub("Color Effects", color::menu()),
Item::Sub("Path Precision Tools", pathtools::menu()),
Item::Sep,
]
}
+212
View File
@@ -0,0 +1,212 @@
//! Path Precision Tools in the interface: its submenu (Object › Plug-ins › Path Precision Tools),
//! which selects its tools, opens the Anchor Inspector and runs Split / Add Points at Crossings;
//! the Anchor Inspector panel (the selected anchor's position and handles as numbers, and the
//! add-on's anchor edits); and its tools' options dialog (a double-click on the tool). Everything
//! acts through `addon.pathtools.*` and `tool.setOption`.
use egui::Ui;
use serde_json::{Map, Value, json};
use vectorcraft_geom::Point;
use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect};
use crate::VectorcraftApp;
use crate::dialogs::DialogSpec;
use crate::menus::Item;
use crate::panels::{empty_state, pstate, set_pstate};
use crate::state::Dialog;
use crate::widgets::{self, dim_label};
/// The Anchor Inspector panel's id (`window.panel {panel: "anchorInspector"}`).
pub const ID: &str = "anchorInspector";
/// Where the panel keeps the count Add Points adds (panel state).
const COUNT: &str = "anchor-inspector-count";
/// The tools' options dialog.
pub const KIND: &str = "pathToolOptions";
/// The close modes, as `addon.pathtools.close` names them, and their labels.
const CLOSE_MODES: [(&str, &str); 3] = [("handles", "Keep Handles"), ("straight", "Straight"), ("smooth", "Smooth")];
const LABEL_W: f32 = 64.0;
/// Object › Plug-ins › Path Precision Tools.
pub fn menu() -> Vec<Item> {
let tool = |label, id: &str| Item::Cmd(label, "tool.select", json!({ "tool": id }));
vec![
tool("Anchor Eraser Brush", anchor_eraser::ID),
tool("Extend Path Tool", extend_path::ID),
tool("Measure & Inspect Tool", measure_inspect::ID),
Item::Sep,
Item::Cmd("Anchor Inspector", "window.panel", json!({ "panel": ID })),
Item::Sep,
Item::Cmd("Add Points at Crossings", "addon.pathtools.crossings", json!({})),
Item::Cmd("Split at Crossings", "addon.pathtools.crossings", json!({ "cut": true })),
]
}
/// The first direct-selected anchor (path, subpath, anchor) and how many are selected.
fn first_anchor(app: &VectorcraftApp) -> Option<(vectorcraft_geom::Anchor, usize)> {
let st = app.session.active()?;
let count = st.selection.anchors.values().map(|a| a.len()).sum();
let (id, set) = st.selection.anchors.iter().find(|(_, a)| !a.is_empty())?;
let (si, ai) = set.first()?;
let a = st.doc.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai)?;
Some((*a, count))
}
/// A handle's (length, direction in degrees counter-clockwise on the page); `None` for none.
fn polar(p: Point, h: Point) -> Option<(f64, f64)> {
let v = h - p;
(v.hypot() > 1e-9).then(|| (v.hypot(), (-v.y).atan2(v.x).to_degrees() + 0.0))
}
fn run(app: &mut VectorcraftApp, id: &str, p: Value) {
if let Err(e) = app.run(id, p) {
app.status(e);
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else {
empty_state(ui, "dc-anchor-smooth", tl!("No document"), tl!("Open a document to edit its anchor points."));
return;
};
let selected = !st.selection.is_empty();
let unit = app.session.general_unit();
let origin = app.ruler_origin();
let anchor = first_anchor(app);
let mut set: Option<Value> = None;
match anchor {
Some((_, n)) => dim_label(ui, &crate::i18n::fmt(tl!("Anchors selected: {count}"), &[("count", &n.to_string())])),
None => dim_label(ui, tl!("Direct-select anchor points to edit them.")),
};
ui.add_enabled_ui(anchor.is_some(), |ui| {
let a = anchor.map(|(a, _)| a);
egui::Grid::new("anchor-inspector-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
for (label, key, v) in [("X:", "x", a.map(|a| a.p.x - origin.x)), ("Y:", "y", a.map(|a| a.p.y - origin.y))] {
dim_label(ui, label);
if let Some(v) = widgets::num_field_named(ui, ("anchor-inspector", key), Some(tl!(label)), v, unit, 72.0) {
let o = if key == "x" { origin.x } else { origin.y };
set = Some(json!({ key: v + o }));
}
}
ui.end_row();
for (name, out) in [("In:", false), ("Out:", true)] {
let h = a.and_then(|a| polar(a.p, if out { a.h_out } else { a.h_in }));
let side = if out { "out" } else { "in" };
dim_label(ui, name);
let len = h.map(|h| h.0).or(a.map(|_| 0.0));
if let Some(v) = widgets::size_field_named(ui, ("anchor-inspector-len", side), Some(tl!("Length")), len, unit, 72.0) {
set = Some(json!({ format!("{side}Length"): v }));
}
dim_label(ui, "∠");
if let Some(v) = widgets::mixed_field_named(ui, ("anchor-inspector-angle", side), Some(tl!("Angle")), h.map(|h| h.1), "°", 1, 56.0) {
set = Some(json!({ format!("{side}Angle"): v }));
}
ui.end_row();
}
});
});
widgets::divider(ui);
let width = ui.available_width();
let mut action: Option<(&str, Value)> = None;
ui.add_enabled_ui(selected, |ui| {
for (label, id) in [
("Make Smooth", "addon.pathtools.smooth"),
("Make Corner", "addon.pathtools.corner"),
("Equalize Handles", "addon.pathtools.equalize"),
("Remove Points, Keep Shape", "addon.pathtools.removeKeepShape"),
] {
if widgets::flat_button(ui, tl!(label), width).clicked() {
action = Some((id, json!({})));
}
}
let mut count: f64 = pstate::<Option<f64>>(ui.ctx(), COUNT).unwrap_or(1.0);
ui.horizontal(|ui| {
if widgets::flat_button(ui, tl!("Add Points"), width - 70.0).clicked() {
action = Some(("addon.pathtools.addPoints", json!({ "count": count })));
}
if let Some(v) =
widgets::range_field_named(ui, "anchor-inspector-count", Some(tl!("Points per segment")), count, 1.0..=100.0, "", 0, 60.0)
{
count = v;
}
});
set_pstate(ui.ctx(), COUNT, Some(count));
widgets::label_row(ui, tl!("Close Path:"), LABEL_W + 16.0, |ui| {
for (mode, label) in CLOSE_MODES {
if widgets::flat_button_fit(ui, tl!(label), 40.0).clicked() {
action = Some(("addon.pathtools.close", json!({ "mode": mode })));
}
}
});
});
if let Some(p) = set {
run(app, "addon.pathtools.setAnchor", p);
}
if let Some((id, p)) = action {
run(app, id, p);
}
}
// ---------- the tools' options ----------
/// Tools whose options the dialog edits.
pub fn has_options(tool: &str) -> bool {
[anchor_eraser::ID, extend_path::ID].contains(&tool)
}
/// Open the options of add-on tool `tool` with its current values.
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<Value, String> {
let mut fields: Map<String, Value> = app.session.tool_options_of(tool).as_object().cloned().unwrap_or_default();
fields.insert("__tool".into(), json!(tool));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(json!({ "dialog": KIND }))
}
pub(crate) const OPTIONS_SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 280.0, ..DialogSpec::FORM };
fn heading(d: &Dialog) -> String {
let label = vectorcraft_tools::tool_info(&d.str("__tool")).map_or("", |t| t.label);
crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(label))])
}
fn body(_app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if d.str("__tool") == extend_path::ID {
let modes = [("straight", "Straight"), ("bezier", "Stretch Curve"), ("arc", "Arc")];
let labels: Vec<&str> = modes.iter().map(|(_, l)| *l).collect();
let current = modes.iter().find(|(m, _)| *m == d.str("mode")).map_or("Straight", |(_, l)| *l);
widgets::label_row(ui, tl!("Mode:"), LABEL_W, |ui| {
if let Some((m, _)) = widgets::dropdown(ui, "extend-path-mode", current, &labels, 140.0).and_then(|i| modes.get(i)) {
d.fields.insert("mode".into(), json!(m));
}
});
return false;
}
let rows = [
("size", "Size:", anchor_eraser::SIZE_RANGE, "px", anchor_eraser::SIZE),
("tolerance", "Tolerance:", anchor_eraser::TOLERANCE_RANGE, "", anchor_eraser::TOLERANCE),
];
for (key, label, (lo, hi), suffix, default) in rows {
let v = d.f64(key, default);
widgets::label_row(ui, tl!(label), LABEL_W, |ui| {
if let Some(x) = widgets::range_field(ui, ("anchor-eraser", key), v, lo..=hi, suffix, 0, 72.0) {
d.fields.insert(key.into(), json!(x));
}
});
}
if widgets::check(ui, "Pressure", d.bool("pressure"), true) {
let on = !d.bool("pressure");
d.fields.insert("pressure".into(), json!(on));
}
false
}
/// OK: set the tool's options.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let tool = d.str("__tool");
let values: Map<String, Value> = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
let r = app.run("tool.setOption", json!({ "tool": tool, "values": values }));
app.ui.dialog = None;
r
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,86 @@
use serde_json::json;
use vectorcraft_engine::Session;
use super::{ID, KIND, show};
use crate::tests_labels::painted_text;
use crate::{VectorcraftApp, menus};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app
}
#[test]
fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() {
let mut app = app();
let entries = menus::menu_entries(&app);
let under = |label: &str| entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"] && e.label == label);
for (label, tool) in
[("Anchor Eraser Brush", "anchorEraserBrush"), ("Extend Path Tool", "extendPath"), ("Measure & Inspect Tool", "measureInspect")]
{
let e = under(label).expect(label);
assert_eq!((e.command.as_deref(), &e.params), (Some("tool.select"), &json!({ "tool": tool })));
menus::invoke(&mut app, "tool.select", e.params.clone());
assert_eq!(app.session.tool_id(), tool);
}
for (label, cut) in [("Add Points at Crossings", false), ("Split at Crossings", true)] {
let e = under(label).expect(label);
assert_eq!(e.command.as_deref(), Some("addon.pathtools.crossings"));
assert_eq!(e.params.get("cut").and_then(|v| v.as_bool()).unwrap_or(false), cut);
}
// Artwork Cleanup's submenu and Install Plug-in… are still there.
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins", "Artwork Cleanup"]));
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins"] && e.label == "Install Plug-in…"));
let inspector = under("Anchor Inspector").expect("listed");
assert!(!crate::panel_docking::showing(&app.ui, ID));
menus::invoke(&mut app, "window.panel", inspector.params.clone());
assert!(crate::panel_docking::showing(&app.ui, ID), "the panel opens");
}
#[test]
fn the_inspector_shows_the_selected_anchor_and_its_buttons_edit() {
let mut app = app();
let r = app.run("path.create", json!({"anchors": [{"x": 10, "y": 10}, {"x": 110, "y": 10}, {"x": 110, "y": 110}], "closed": false})).unwrap();
let id = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap());
let text = painted_text(&mut app, show);
assert!(text.contains("Direct-select anchor points to edit them.") && text.contains("Make Smooth") && text.contains("Close Path:"), "{text}");
app.session
.select(|_, sel| {
sel.set([id]);
sel.anchors.insert(id, [(0, 1)].into_iter().collect());
})
.unwrap();
let text = painted_text(&mut app, show);
assert!(text.contains("Anchors selected: 1"), "{text}");
// The panel's buttons run the add-on's commands, each one undo step.
let steps = app.session.doc().unwrap().history.undo.len();
app.run("addon.pathtools.smooth", json!({})).unwrap();
let a = app.session.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[1];
assert!(a.has_in() && a.has_out());
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
}
#[test]
fn double_clicking_the_brush_opens_its_options_and_ok_sets_them() {
let mut app = app();
app.run("tool.options", json!({"tool": "anchorEraserBrush"})).unwrap();
let d = app.ui.dialog.as_mut().expect("the dialog");
assert_eq!(d.kind, KIND);
assert_eq!((d.f64("size", 0.0), d.f64("tolerance", 0.0), d.bool("pressure")), (20.0, 10.0, false));
let text = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert!(text.contains("Anchor Eraser Brush Options") && text.contains("Tolerance:") && text.contains("Pressure"), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("size".into(), json!(64));
d.fields.insert("pressure".into(), json!(true));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let o = app.session.tool_options_of("anchorEraserBrush");
assert_eq!((o["size"].as_f64(), o["pressure"].as_bool()), (Some(64.0), Some(true)));
// The Extend Path tool's mode; the Measure & Inspect tool has none.
app.run("tool.options", json!({"tool": "extendPath"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("arc"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.tool_options_of("extendPath")["mode"], json!("arc"));
assert!(app.run("tool.options", json!({"tool": "measureInspect"})).is_err());
}
+4 -1
View File
@@ -147,6 +147,9 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::Scissors
| Cursor::ScissorsNotAllowed
| Cursor::MeshAdd
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
| Cursor::PuppetPin
| Cursor::AnchorEraser
| Cursor::ExtendPath
| Cursor::MeasureInspect => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
}
}
+4 -1
View File
@@ -440,7 +440,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
#[cfg(test)]
const GLYPHS: [Cursor; 81] = [
const GLYPHS: [Cursor; 84] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -522,6 +522,9 @@ const GLYPHS: [Cursor; 81] = [
Cursor::Scissors,
Cursor::MeshAdd,
Cursor::PuppetPin,
Cursor::AnchorEraser,
Cursor::ExtendPath,
Cursor::MeasureInspect,
];
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
+1
View File
@@ -385,6 +385,7 @@ registry! {
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC,
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
+5 -2
View File
@@ -349,6 +349,9 @@ fn heading(id: &str) -> &'static str {
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
return cat;
}
if let Some(addon) = vectorcraft_tools::addons::heading(id) {
return addon;
}
match id {
_ if id.starts_with("symbol") => "Symbols",
_ if id.ends_with("Graph") => "Graph",
@@ -365,7 +368,7 @@ fn sections() -> &'static [(&'static str, Vec<&'static ToolInfo>)] {
static SECTIONS: OnceLock<Vec<(&'static str, Vec<&'static ToolInfo>)>> = OnceLock::new();
SECTIONS.get_or_init(|| {
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
for tool in vectorcraft_tools::catalog::all_tools() {
let h = heading(tool.id);
match out.iter_mut().find(|(c, _)| *c == h) {
Some((_, tools)) => tools.push(tool),
@@ -387,7 +390,7 @@ fn groups() -> &'static [Heading] {
static GROUPS: OnceLock<Vec<Heading>> = OnceLock::new();
GROUPS.get_or_init(|| {
let mut out: Vec<Heading> = sections().iter().map(|(cat, _)| (*cat, vec![])).collect();
for g in TOOL_GROUPS {
for g in TOOL_GROUPS.iter().chain(vectorcraft_tools::addons::GROUPS) {
let Some(first) = g.first() else { continue };
let count = |h: &str| g.iter().filter(|t| heading(t.id) == h).count();
let h = g.iter().map(|t| heading(t.id)).fold(heading(first.id), |best, h| if count(h) > count(best) { h } else { best });
+22 -2
View File
@@ -97,7 +97,7 @@ fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2
#[test]
fn the_drawer_frames_every_flyout_group_once() {
let listed: Vec<&str> = groups().iter().flat_map(|(_, gs)| gs.iter().flat_map(|g| g.iter().map(|t| t.id))).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
let all: Vec<&str> = vectorcraft_tools::catalog::all_tools().map(|t| t.id).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let under = |id: &str| groups().iter().find(|(_, gs)| gs.iter().any(|g| g.iter().any(|t| t.id == id))).map(|(c, _)| *c);
@@ -133,7 +133,7 @@ fn toolbar_show_turns_the_controls_under_the_tools_on_and_off() {
fn every_tool_is_listed_once_under_a_heading() {
let s = sections();
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
let all: Vec<&str> = vectorcraft_tools::catalog::all_tools().map(|t| t.id).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
@@ -372,3 +372,23 @@ fn drop_targets_follow_the_buttons_edges() {
assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After));
assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before));
}
/// The add-ons' tools are in neither toolbar as they ship: the drawer lists them under their
/// add-on's heading, and they can be added from there.
#[test]
fn addon_tools_are_in_the_drawer_not_the_toolbars() {
let mut app = app();
let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect"]);
for advanced in [false, true] {
if advanced {
app.run("window.toolbarAdvanced", json!({})).unwrap();
}
assert!(addon.iter().all(|id| slot_of(&app, id).is_none()), "advanced: {advanced}");
}
let (heading, tools) = sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == "extendPath")).expect("listed");
assert_eq!(*heading, "Path Precision Tools");
assert_eq!(tools.iter().map(|t| t.id).collect::<Vec<_>>(), addon);
app.run("toolbar.addTool", json!({"tool": "extendPath"})).unwrap();
assert!(slot_of(&app, "extendPath").is_some());
}
+33
View File
@@ -5654,3 +5654,36 @@ anchor Points: Точки:
@msg `{id}` is not a colour adjustment `{id}` не е цветова корекция
@msg missing `effect` липсва `effect`
@msg `params` must be an object `params` трябва да бъде обект
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Инструменти за прецизни пътища
Anchor Eraser Brush Четка за изтриване на опорни точки
Extend Path Tool Инструмент „Удължаване на път“
Measure & Inspect Tool Инструмент „Измерване и проверка“
Anchor Inspector Инспектор на опорни точки
Add Points at Crossings Добавяне на точки в пресичанията
Split at Crossings Разделяне в пресичанията
Extend Path Удължаване на път
Measure & Inspect Измерване и проверка
Set Anchor Задаване на опорна точка
Make Smooth Направи гладка
Make Corner Направи ъглова
Equalize Handles Изравняване на дръжките
Remove Points, Keep Shape Премахване на точки, запазване на формата
Add Points to Segments… Добавяне на точки към сегменти…
Add Points to Segments Добавяне на точки към сегменти
Open a document to edit its anchor points. Отворете документ, за да редактирате опорните му точки.
Anchors selected: {count} Избрани опорни точки: {count}
Direct-select anchor points to edit them. Изберете опорните точки директно, за да ги редактирате.
In: Вход:
Out: Изход:
Add Points Добавяне на точки
Points per segment Точки на сегмент
Close Path: Затваряне на пътя:
Keep Handles Запазване на дръжките
Straight Прав
Stretch Curve Разтягане на кривата
@msg direct-select the anchor points to remove изберете директно опорните точки за премахване
@msg select segments, or paths, to add points to изберете сегменти или пътища, към които да се добавят точки
@msg select open paths to close изберете отворени пътища за затваряне
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) пътищата са твърде много или твърде заплетени, за да се намерят пресичанията им (най-много 10000 сегмента)
@msg the paths have too many anchors to edit at once пътищата имат твърде много опорни точки, за да се редактират наведнъж
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5755,3 +5755,36 @@ anchor Points: Punts:
@msg `{id}` is not a colour adjustment `{id}` no és un ajust de color
@msg missing `effect` falta `effect`
@msg `params` must be an object `params` ha de ser un objecte
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Eines de precisió de traçats
Anchor Eraser Brush Pinzell esborrador de punts d’ancoratge
Extend Path Tool Eina d’allargar el traçat
Measure & Inspect Tool Eina de mesurar i inspeccionar
Anchor Inspector Inspector de punts d’ancoratge
Add Points at Crossings Afegeix punts als encreuaments
Split at Crossings Divideix als encreuaments
Extend Path Allarga el traçat
Measure & Inspect Mesura i inspecciona
Set Anchor Defineix el punt d’ancoratge
Make Smooth Fes-lo suau
Make Corner Fes-lo vèrtex
Equalize Handles Iguala les nanses
Remove Points, Keep Shape Elimina punts, mantén la forma
Add Points to Segments… Afegeix punts als segments…
Add Points to Segments Afegeix punts als segments
Open a document to edit its anchor points. Obriu un document per editar-ne els punts d’ancoratge.
Anchors selected: {count} Punts d’ancoratge seleccionats: {count}
Direct-select anchor points to edit them. Seleccioneu directament punts d’ancoratge per editar-los.
In: Entrada:
Out: Sortida:
Add Points Afegeix punts
Points per segment Punts per segment
Close Path: Tanca el traçat:
Keep Handles Mantén les nanses
Straight Recte
Stretch Curve Estira la corba
@msg direct-select the anchor points to remove seleccioneu directament els punts d’ancoratge que voleu eliminar
@msg select segments, or paths, to add points to seleccioneu segments, o traçats, als quals afegir punts
@msg select open paths to close seleccioneu traçats oberts per tancar-los
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) massa traçats, o massa enredats, per trobar-ne els encreuaments (10000 segments com a màxim)
@msg the paths have too many anchors to edit at once els traçats tenen massa punts d’ancoratge per editar-los alhora
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5764,3 +5764,36 @@ anchor Points: Body:
@msg `{id}` is not a colour adjustment `{id}` není úprava barev
@msg missing `effect` chybí `effect`
@msg `params` must be an object `params` musí být objekt
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Přesné nástroje pro cesty
Anchor Eraser Brush Štětec gumy kotevních bodů
Extend Path Tool Nástroj Prodloužit cestu
Measure & Inspect Tool Nástroj Měřit a zkoumat
Anchor Inspector Inspektor kotevních bodů
Add Points at Crossings Přidat body v křížení
Split at Crossings Rozdělit v křížení
Extend Path Prodloužit cestu
Measure & Inspect Měřit a zkoumat
Set Anchor Nastavit kotevní bod
Make Smooth Změnit na hladký
Make Corner Změnit na rohový
Equalize Handles Vyrovnat úchyty
Remove Points, Keep Shape Odstranit body, zachovat tvar
Add Points to Segments… Přidat body do segmentů…
Add Points to Segments Přidat body do segmentů
Open a document to edit its anchor points. Otevřete dokument a upravte jeho kotevní body.
Anchors selected: {count} Vybrané kotevní body: {count}
Direct-select anchor points to edit them. Kotevní body pro úpravy vyberte přímým výběrem.
In: Vstup:
Out: Výstup:
Add Points Přidat body
Points per segment Bodů na segment
Close Path: Uzavřít cestu:
Keep Handles Zachovat úchyty
Straight Přímý
Stretch Curve Natáhnout křivku
@msg direct-select the anchor points to remove přímým výběrem vyberte kotevní body k odstranění
@msg select segments, or paths, to add points to vyberte segmenty nebo cesty, do nichž přidat body
@msg select open paths to close vyberte otevřené cesty k uzavření
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) příliš mnoho nebo příliš spletených cest pro nalezení jejich křížení (nejvýše 10000 segmentů)
@msg the paths have too many anchors to edit at once cesty mají příliš mnoho kotevních bodů na úpravu najednou
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5669,3 +5669,36 @@ anchor Points: Punkter:
@msg `{id}` is not a colour adjustment `{id}` er ikke en farvejustering
@msg missing `effect` `effect` mangler
@msg `params` must be an object `params` skal være et objekt
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Præcisionsværktøjer til stier
Anchor Eraser Brush Ankerpunktviskepensel
Extend Path Tool Forlæng sti-værktøj
Measure & Inspect Tool Mål og undersøg-værktøj
Anchor Inspector Ankerpunktinspektør
Add Points at Crossings Tilføj punkter ved krydsninger
Split at Crossings Opdel ved krydsninger
Extend Path Forlæng sti
Measure & Inspect Mål og undersøg
Set Anchor Angiv ankerpunkt
Make Smooth Gør blødt
Make Corner Gør til hjørne
Equalize Handles Udlign håndtag
Remove Points, Keep Shape Fjern punkter, bevar form
Add Points to Segments… Tilføj punkter til segmenter…
Add Points to Segments Tilføj punkter til segmenter
Open a document to edit its anchor points. Åbn et dokument for at redigere dets ankerpunkter.
Anchors selected: {count} Valgte ankerpunkter: {count}
Direct-select anchor points to edit them. Vælg ankerpunkter direkte for at redigere dem.
In: Ind:
Out: Ud:
Add Points Tilføj punkter
Points per segment Punkter pr. segment
Close Path: Luk sti:
Keep Handles Bevar håndtag
Straight Lige
Stretch Curve Stræk kurve
@msg direct-select the anchor points to remove vælg de ankerpunkter, der skal fjernes, direkte
@msg select segments, or paths, to add points to vælg segmenter eller stier at tilføje punkter til
@msg select open paths to close vælg åbne stier at lukke
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) for mange eller for sammenfiltrede stier til at finde deres krydsninger (højst 10000 segmenter)
@msg the paths have too many anchors to edit at once stierne har for mange ankerpunkter til at blive redigeret på én gang
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34
View File
@@ -5346,3 +5346,37 @@ anchor Points: Punkte:
@msg `{id}` is not a colour adjustment `{id}` ist keine Farbkorrektur
@msg missing `effect` `effect` fehlt
@msg `params` must be an object `params` muss ein Objekt sein
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Pfad-Präzisionswerkzeuge
Anchor Eraser Brush Ankerpunkt-Radierpinsel
Extend Path Tool Pfad-verlängern-Werkzeug
Measure & Inspect Tool Messen-und-Prüfen-Werkzeug
Anchor Inspector Ankerpunkt-Inspektor
Add Points at Crossings Punkte an Kreuzungen hinzufügen
Split at Crossings An Kreuzungen teilen
Extend Path Pfad verlängern
Measure & Inspect Messen und prüfen
Set Anchor Ankerpunkt festlegen
Make Smooth Glätten
Make Corner Zu Eckpunkt machen
Equalize Handles Griffe angleichen
Remove Points, Keep Shape Punkte entfernen, Form erhalten
Add Points to Segments… Punkte zu Segmenten hinzufügen…
Add Points to Segments Punkte zu Segmenten hinzufügen
Open a document to edit its anchor points. Öffnen Sie ein Dokument, um seine Ankerpunkte zu bearbeiten.
Anchors selected: {count} Ausgewählte Ankerpunkte: {count}
Direct-select anchor points to edit them. Wählen Sie Ankerpunkte direkt aus, um sie zu bearbeiten.
Length Länge
In: Ein:
Out: Aus:
Add Points Punkte hinzufügen
Points per segment Punkte pro Segment
Close Path: Pfad schließen:
Keep Handles Griffe beibehalten
Straight Gerade
Stretch Curve Kurve strecken
@msg direct-select the anchor points to remove die zu entfernenden Ankerpunkte direkt auswählen
@msg select segments, or paths, to add points to Segmente oder Pfade auswählen, denen Punkte hinzugefügt werden sollen
@msg select open paths to close offene Pfade zum Schließen auswählen
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) zu viele oder zu verschlungene Pfade, um ihre Kreuzungen zu finden (höchstens 10000 Segmente)
@msg the paths have too many anchors to edit at once die Pfade haben zu viele Ankerpunkte, um sie auf einmal zu bearbeiten
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5716,3 +5716,36 @@ anchor Points: Σημεία:
@msg `{id}` is not a colour adjustment Το `{id}` δεν είναι διόρθωση χρώματος
@msg missing `effect` λείπει το `effect`
@msg `params` must be an object Το `params` πρέπει να είναι αντικείμενο
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Εργαλεία ακρίβειας διαδρομών
Anchor Eraser Brush Πινέλο διαγραφής σημείων αγκύρωσης (Anchor Eraser Brush)
Extend Path Tool Επέκταση διαδρομής (Extend Path Tool)
Measure & Inspect Tool Μέτρηση και επιθεώρηση (Measure & Inspect Tool)
Anchor Inspector Επιθεωρητής σημείων αγκύρωσης
Add Points at Crossings Προσθήκη σημείων στις διασταυρώσεις
Split at Crossings Διαχωρισμός στις διασταυρώσεις
Extend Path Επέκταση διαδρομής
Measure & Inspect Μέτρηση και επιθεώρηση
Set Anchor Ορισμός σημείου αγκύρωσης
Make Smooth Μετατροπή σε ομαλό
Make Corner Μετατροπή σε γωνία
Equalize Handles Εξίσωση λαβών
Remove Points, Keep Shape Αφαίρεση σημείων, διατήρηση σχήματος
Add Points to Segments… Προσθήκη σημείων σε τμήματα…
Add Points to Segments Προσθήκη σημείων σε τμήματα
Open a document to edit its anchor points. Ανοίξτε ένα έγγραφο για να επεξεργαστείτε τα σημεία αγκύρωσής του.
Anchors selected: {count} Επιλεγμένα σημεία αγκύρωσης: {count}
Direct-select anchor points to edit them. Επιλέξτε απευθείας σημεία αγκύρωσης για να τα επεξεργαστείτε.
In: Είσοδος:
Out: Έξοδος:
Add Points Προσθήκη σημείων
Points per segment Σημεία ανά τμήμα
Close Path: Κλείσιμο διαδρομής:
Keep Handles Διατήρηση λαβών
Straight Ευθύ
Stretch Curve Τέντωμα καμπύλης
@msg direct-select the anchor points to remove επιλέξτε απευθείας τα σημεία αγκύρωσης προς αφαίρεση
@msg select segments, or paths, to add points to επιλέξτε τμήματα ή διαδρομές στα οποία θα προστεθούν σημεία
@msg select open paths to close επιλέξτε ανοιχτές διαδρομές για κλείσιμο
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) οι διαδρομές είναι πάρα πολλές ή πολύ μπλεγμένες για να βρεθούν οι διασταυρώσεις τους (έως 10000 τμήματα)
@msg the paths have too many anchors to edit at once οι διαδρομές έχουν πάρα πολλά σημεία αγκύρωσης για επεξεργασία με μία φορά
Can't render this file because it contains an unexpected character in line 164 and column 5.
+34
View File
@@ -5706,3 +5706,37 @@ anchor Points: Puntos:
@msg `{id}` is not a colour adjustment `{id}` no es un ajuste de color
@msg missing `effect` falta `effect`
@msg `params` must be an object `params` debe ser un objeto
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Herramientas de precisión de trazados
Anchor Eraser Brush Pincel borrador de puntos de ancla
Extend Path Tool Herramienta Prolongar trazado
Measure & Inspect Tool Herramienta Medir e inspeccionar
Anchor Inspector Inspector de puntos de ancla
Add Points at Crossings Agregar puntos en los cruces
Split at Crossings Dividir en los cruces
Extend Path Prolongar trazado
Measure & Inspect Medir e inspeccionar
Set Anchor Definir punto de ancla
Make Smooth Suavizar punto
Make Corner Convertir en vértice
Equalize Handles Igualar manejadores
Remove Points, Keep Shape Quitar puntos, mantener forma
Add Points to Segments… Agregar puntos a segmentos…
Add Points to Segments Agregar puntos a segmentos
Open a document to edit its anchor points. Abra un documento para editar sus puntos de ancla.
Anchors selected: {count} Puntos de ancla seleccionados: {count}
Direct-select anchor points to edit them. Seleccione directamente puntos de ancla para editarlos.
Length Longitud
In: Entrada:
Out: Salida:
Add Points Agregar puntos
Points per segment Puntos por segmento
Close Path: Cerrar trazado:
Keep Handles Mantener manejadores
Straight Recto
Stretch Curve Estirar curva
@msg direct-select the anchor points to remove seleccione directamente los puntos de ancla que quitar
@msg select segments, or paths, to add points to seleccione segmentos, o trazados, a los que agregar puntos
@msg select open paths to close seleccione trazados abiertos que cerrar
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) hay demasiados trazados, o están demasiado enredados, para hallar sus cruces (10000 segmentos como máximo)
@msg the paths have too many anchors to edit at once los trazados tienen demasiados puntos de ancla para editarlos a la vez
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5795,3 +5795,36 @@ anchor Points: Pisteet:
@msg `{id}` is not a colour adjustment `{id}` ei ole värisäätö
@msg missing `effect` `effect` puuttuu
@msg `params` must be an object `params` on oltava objekti
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Polkujen tarkkuustyökalut
Anchor Eraser Brush Ankkuripisteiden pyyhintäsivellin
Extend Path Tool Polun jatkamistyökalu
Measure & Inspect Tool Mittaus- ja tarkastelutyökalu
Anchor Inspector Ankkuripisteiden tarkastelu
Add Points at Crossings Lisää pisteet risteyskohtiin
Split at Crossings Jaa risteyskohdista
Extend Path Jatka polkua
Measure & Inspect Mittaa ja tarkastele
Set Anchor Aseta ankkuripiste
Make Smooth Tee tasaiseksi
Make Corner Tee kulmapisteeksi
Equalize Handles Tasaa kahvat
Remove Points, Keep Shape Poista pisteet, säilytä muoto
Add Points to Segments… Lisää pisteitä osiin…
Add Points to Segments Lisää pisteitä osiin
Open a document to edit its anchor points. Avaa asiakirja muokataksesi sen ankkuripisteitä.
Anchors selected: {count} Valittuja ankkuripisteitä: {count}
Direct-select anchor points to edit them. Valitse ankkuripisteet suoraan muokataksesi niitä.
In: Sisään:
Out: Ulos:
Add Points Lisää pisteitä
Points per segment Pisteitä osaa kohden
Close Path: Sulje polku:
Keep Handles Säilytä kahvat
Straight Suora
Stretch Curve Venytä käyrää
@msg direct-select the anchor points to remove valitse poistettavat ankkuripisteet suoraan
@msg select segments, or paths, to add points to valitse osat tai polut, joihin pisteitä lisätään
@msg select open paths to close valitse suljettavat avoimet polut
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) liian monta tai liian sotkuista polkua niiden risteyskohtien löytämiseen (enintään 10000 osaa)
@msg the paths have too many anchors to edit at once poluissa on liian monta ankkuripistettä muokattavaksi kerralla
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5763,3 +5763,36 @@ anchor Points: Points :
@msg `{id}` is not a colour adjustment `{id}` n'est pas un réglage de couleur
@msg missing `effect` `effect` manquant
@msg `params` must be an object `params` doit être un objet
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Outils de précision des tracés
Anchor Eraser Brush Pinceau effaceur de points d’ancrage
Extend Path Tool Outil Prolonger le tracé
Measure & Inspect Tool Outil Mesurer et inspecter
Anchor Inspector Inspecteur de points d’ancrage
Add Points at Crossings Ajouter des points aux croisements
Split at Crossings Scinder aux croisements
Extend Path Prolonger le tracé
Measure & Inspect Mesurer et inspecter
Set Anchor Définir le point d’ancrage
Make Smooth Rendre lisse
Make Corner Rendre angulaire
Equalize Handles Égaliser les poignées
Remove Points, Keep Shape Supprimer des points, conserver la forme
Add Points to Segments… Ajouter des points aux segments…
Add Points to Segments Ajouter des points aux segments
Open a document to edit its anchor points. Ouvrez un document pour modifier ses points d’ancrage.
Anchors selected: {count} Points d’ancrage sélectionnés : {count}
Direct-select anchor points to edit them. Sélectionnez directement des points d’ancrage pour les modifier.
In: Entrée :
Out: Sortie :
Add Points Ajouter des points
Points per segment Points par segment
Close Path: Fermer le tracé :
Keep Handles Conserver les poignées
Straight Droit
Stretch Curve Étirer la courbe
@msg direct-select the anchor points to remove sélectionnez directement les points d’ancrage à supprimer
@msg select segments, or paths, to add points to sélectionnez des segments, ou des tracés, auxquels ajouter des points
@msg select open paths to close sélectionnez des tracés ouverts à fermer
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) trop de tracés, ou trop enchevêtrés, pour trouver leurs croisements (10000 segments au plus)
@msg the paths have too many anchors to edit at once les tracés ont trop de points d’ancrage pour être modifiés en une fois
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34
View File
@@ -5750,3 +5750,37 @@ anchor Points: Pontok:
@msg `{id}` is not a colour adjustment `{id}` nem színkorrekció
@msg missing `effect` hiányzik: `effect`
@msg `params` must be an object `params`: objektum kell
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Útvonal-precíziós eszközök
Anchor Eraser Brush Horgonypont-radírecset
Extend Path Tool Útvonal meghosszabbítása eszköz
Measure & Inspect Tool Mérés és vizsgálat eszköz
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
Extend Path Útvonal meghosszabbítása
Measure & Inspect Mérés és vizsgálat
Set Anchor Horgonypont beállítása
Make Smooth Simává alakítás
Make Corner Sarokká alakítás
Equalize Handles Fogantyúk kiegyenlítése
Remove Points, Keep Shape Pontok eltávolítása, alak megtartása
Add Points to Segments… Pontok hozzáadása a szakaszokhoz…
Add Points to Segments Pontok hozzáadása a szakaszokhoz
Open a document to edit its anchor points. Nyisson meg egy dokumentumot a horgonypontjai szerkesztéséhez.
Anchors selected: {count} Kijelölt horgonypontok: {count}
Direct-select anchor points to edit them. A szerkesztéshez jelölje ki közvetlenül a horgonypontokat.
Length Hossz
In: Be:
Out: Ki:
Add Points Pontok hozzáadása
Points per segment Pont szakaszonként
Close Path: Útvonal lezárása:
Keep Handles Fogantyúk megtartása
Straight Egyenes
Stretch Curve Görbe nyújtása
@msg direct-select the anchor points to remove jelölje ki közvetlenül az eltávolítandó horgonypontokat
@msg select segments, or paths, to add points to jelöljön ki szakaszokat vagy útvonalakat, amelyekhez pontokat ad
@msg select open paths to close jelöljön ki lezárandó nyitott útvonalakat
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) túl sok vagy túl összegabalyodott útvonal a kereszteződések megtalálásához (legfeljebb 10000 szakasz)
@msg the paths have too many anchors to edit at once az útvonalaknak túl sok horgonypontjuk van az egyszerre szerkesztéshez
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5669,3 +5669,36 @@ anchor Points: Titik:
@msg `{id}` is not a colour adjustment `{id}` bukan penyesuaian warna
@msg missing `effect` `effect` tidak ada
@msg `params` must be an object `params` harus berupa objek
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Alat presisi jalur
Anchor Eraser Brush Kuas penghapus titik jangkar
Extend Path Tool Alat perpanjang jalur
Measure & Inspect Tool Alat ukur dan periksa
Anchor Inspector Pemeriksa titik jangkar
Add Points at Crossings Tambahkan titik di persilangan
Split at Crossings Pisahkan di persilangan
Extend Path Perpanjang jalur
Measure & Inspect Ukur dan periksa
Set Anchor Atur titik jangkar
Make Smooth Jadikan halus
Make Corner Jadikan sudut
Equalize Handles Samakan pegangan
Remove Points, Keep Shape Hapus titik, pertahankan bentuk
Add Points to Segments… Tambahkan titik ke segmen…
Add Points to Segments Tambahkan titik ke segmen
Open a document to edit its anchor points. Buka dokumen untuk menyunting titik jangkarnya.
Anchors selected: {count} Titik jangkar terpilih: {count}
Direct-select anchor points to edit them. Pilih langsung titik jangkar untuk menyuntingnya.
In: Masuk:
Out: Keluar:
Add Points Tambahkan titik
Points per segment Titik per segmen
Close Path: Tutup jalur:
Keep Handles Pertahankan pegangan
Straight Lurus
Stretch Curve Regangkan kurva
@msg direct-select the anchor points to remove pilih langsung titik jangkar yang akan dihapus
@msg select segments, or paths, to add points to pilih segmen, atau jalur, untuk ditambahi titik
@msg select open paths to close pilih jalur terbuka untuk ditutup
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) jalur terlalu banyak atau terlalu kusut untuk menemukan persilangannya (paling banyak 10000 segmen)
@msg the paths have too many anchors to edit at once jalur memiliki terlalu banyak titik jangkar untuk disunting sekaligus
Can't render this file because it contains an unexpected character in line 2169 and column 5.
+34
View File
@@ -5380,3 +5380,37 @@ anchor Points: Punti:
@msg `{id}` is not a colour adjustment `{id}` non è una regolazione del colore
@msg missing `effect` manca `effect`
@msg `params` must be an object `params` deve essere un oggetto
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Strumenti di precisione per tracciati
Anchor Eraser Brush Pennello gomma punti di ancoraggio
Extend Path Tool Strumento Estendi tracciato
Measure & Inspect Tool Strumento Misura e ispeziona
Anchor Inspector Ispettore punti di ancoraggio
Add Points at Crossings Aggiungi punti agli incroci
Split at Crossings Dividi agli incroci
Extend Path Estendi tracciato
Measure & Inspect Misura e ispeziona
Set Anchor Imposta punto di ancoraggio
Make Smooth Rendi arrotondato
Make Corner Rendi angolo
Equalize Handles Uguaglia maniglie
Remove Points, Keep Shape Rimuovi punti, mantieni forma
Add Points to Segments… Aggiungi punti ai segmenti…
Add Points to Segments Aggiungi punti ai segmenti
Open a document to edit its anchor points. Apri un documento per modificarne i punti di ancoraggio.
Anchors selected: {count} Punti di ancoraggio selezionati: {count}
Direct-select anchor points to edit them. Seleziona direttamente i punti di ancoraggio per modificarli.
Length Lunghezza
In: Entrata:
Out: Uscita:
Add Points Aggiungi punti
Points per segment Punti per segmento
Close Path: Chiudi tracciato:
Keep Handles Mantieni maniglie
Straight Diritto
Stretch Curve Allunga curva
@msg direct-select the anchor points to remove seleziona direttamente i punti di ancoraggio da rimuovere
@msg select segments, or paths, to add points to seleziona segmenti, o tracciati, a cui aggiungere punti
@msg select open paths to close seleziona tracciati aperti da chiudere
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) tracciati troppi o troppo intricati per trovarne gli incroci (al massimo 10000 segmenti)
@msg the paths have too many anchors to edit at once i tracciati hanno troppi punti di ancoraggio da modificare in una volta
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34
View File
@@ -5449,3 +5449,37 @@ anchor Points: ポイント:
@msg `{id}` is not a colour adjustment `{id}` はカラー補正ではありません
@msg missing `effect` `effect` が指定されていません
@msg `params` must be an object `params` にはオブジェクトを指定してください
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools パス精密ツール
Anchor Eraser Brush アンカーポイント消去ブラシ
Extend Path Tool パス延長ツール
Measure & Inspect Tool 計測・検査ツール
Anchor Inspector アンカーポイントインスペクター
Add Points at Crossings 交差点にポイントを追加
Split at Crossings 交差点で分割
Extend Path パスを延長
Measure & Inspect 計測・検査
Set Anchor アンカーポイントを設定
Make Smooth スムーズにする
Make Corner コーナーにする
Equalize Handles ハンドルの長さを揃える
Remove Points, Keep Shape ポイントを削除(形状を維持)
Add Points to Segments… セグメントにポイントを追加…
Add Points to Segments セグメントにポイントを追加
Open a document to edit its anchor points. アンカーポイントを編集するにはドキュメントを開いてください。
Anchors selected: {count} 選択中のアンカーポイント:{count}
Direct-select anchor points to edit them. 編集するアンカーポイントをダイレクト選択してください。
Length 長さ
In: イン:
Out: アウト:
Add Points ポイントを追加
Points per segment セグメントあたりのポイント数
Close Path: パスを閉じる:
Keep Handles ハンドルを維持
Straight 直線
Stretch Curve カーブを延長
@msg direct-select the anchor points to remove 削除するアンカーポイントをダイレクト選択してください
@msg select segments, or paths, to add points to ポイントを追加するセグメントまたはパスを選択してください
@msg select open paths to close 閉じるオープンパスを選択してください
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) パスが多すぎるか複雑すぎて交差点を見つけられません(最大 10000 セグメント)
@msg the paths have too many anchors to edit at once パスのアンカーポイントが多すぎて一度に編集できません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34
View File
@@ -5753,3 +5753,37 @@ anchor Points: წერტილები:
@msg `{id}` is not a colour adjustment `{id}` ფერის კორექცია არ არის
@msg missing `effect` აკლია `effect`
@msg `params` must be an object `params` უნდა იყოს ობიექტი
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools ტრაექტორიების ზუსტი ხელსაწყოები
Anchor Eraser Brush საყრდენი წერტილების საშლელი ფუნჯი
Extend Path Tool ტრაექტორიის გაგრძელების ხელსაწყო
Measure & Inspect Tool გაზომვისა და შემოწმების ხელსაწყო
Anchor Inspector საყრდენი წერტილების ინსპექტორი
Add Points at Crossings წერტილების დამატება გადაკვეთებზე
Split at Crossings დაყოფა გადაკვეთებზე
Extend Path ტრაექტორიის გაგრძელება
Measure & Inspect გაზომვა და შემოწმება
Set Anchor საყრდენი წერტილის დაყენება
Make Smooth გლუვად ქცევა
Make Corner კუთხოვნად ქცევა
Equalize Handles სახელურების გათანაბრება
Remove Points, Keep Shape წერტილების მოცილება ფორმის შენარჩუნებით
Add Points to Segments… წერტილების დამატება სეგმენტებში…
Add Points to Segments წერტილების დამატება სეგმენტებში
Open a document to edit its anchor points. გახსენით დოკუმენტი მისი საყრდენი წერტილების დასარედაქტირებლად.
Anchors selected: {count} მონიშნული საყრდენი წერტილები: {count}
Direct-select anchor points to edit them. დასარედაქტირებლად საყრდენი წერტილები პირდაპირ მონიშნეთ.
Length სიგრძე
In: შესასვლელი:
Out: გამოსასვლელი:
Add Points წერტილების დამატება
Points per segment წერტილები სეგმენტზე
Close Path: ტრაექტორიის შეკვრა:
Keep Handles სახელურების შენარჩუნება
Straight სწორი
Stretch Curve მრუდის გაჭიმვა
@msg direct-select the anchor points to remove პირდაპირ მონიშნეთ მოსაცილებელი საყრდენი წერტილები
@msg select segments, or paths, to add points to მონიშნეთ სეგმენტები ან ტრაექტორიები წერტილების დასამატებლად
@msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) ტრაექტორიები ზედმეტად ბევრი ან ჩახლართულია გადაკვეთების საპოვნელად (მაქსიმუმ 10000 სეგმენტი)
@msg the paths have too many anchors to edit at once ტრაექტორიებს ზედმეტად ბევრი საყრდენი წერტილი აქვს ერთბაშად დასარედაქტირებლად
Can't render this file because it is too large.
+34
View File
@@ -5343,3 +5343,37 @@ anchor Points: 포인트:
@msg `{id}` is not a colour adjustment `{id}`은(는) 색상 조정이 아닙니다
@msg missing `effect` `effect`이 없습니다
@msg `params` must be an object `params`는 개체여야 합니다
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools 패스 정밀 도구
Anchor Eraser Brush 앵커 포인트 지우개 브러시
Extend Path Tool 패스 연장 도구
Measure & Inspect Tool 측정 및 검사 도구
Anchor Inspector 앵커 포인트 검사기
Add Points at Crossings 교차점에 포인트 추가
Split at Crossings 교차점에서 분할
Extend Path 패스 연장
Measure & Inspect 측정 및 검사
Set Anchor 앵커 포인트 설정
Make Smooth 매끄럽게 만들기
Make Corner 모서리로 만들기
Equalize Handles 핸들 길이 맞추기
Remove Points, Keep Shape 포인트 제거(모양 유지)
Add Points to Segments… 세그먼트에 포인트 추가…
Add Points to Segments 세그먼트에 포인트 추가
Open a document to edit its anchor points. 앵커 포인트를 편집하려면 문서를 여세요.
Anchors selected: {count} 선택한 앵커 포인트: {count}
Direct-select anchor points to edit them. 편집할 앵커 포인트를 직접 선택하세요.
Length 길이
In: 들어옴:
Out: 나감:
Add Points 포인트 추가
Points per segment 세그먼트당 포인트
Close Path: 패스 닫기:
Keep Handles 핸들 유지
Straight 직선
Stretch Curve 곡선 늘이기
@msg direct-select the anchor points to remove 제거할 앵커 포인트를 직접 선택하세요
@msg select segments, or paths, to add points to 포인트를 추가할 세그먼트나 패스를 선택하세요
@msg select open paths to close 닫을 열린 패스를 선택하세요
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 패스가 너무 많거나 너무 얽혀 교차점을 찾을 수 없습니다(최대 10000개 세그먼트)
@msg the paths have too many anchors to edit at once 패스의 앵커 포인트가 너무 많아 한 번에 편집할 수 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5713,3 +5713,36 @@ anchor Points: Punten:
@msg `{id}` is not a colour adjustment `{id}` is geen kleuraanpassing
@msg missing `effect` `effect` ontbreekt
@msg `params` must be an object `params` moet een object zijn
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Precisiegereedschappen voor paden
Anchor Eraser Brush Ankerpunt-gumpenseel
Extend Path Tool Pad verlengen-gereedschap
Measure & Inspect Tool Meten en inspecteren-gereedschap
Anchor Inspector Ankerpuntinspecteur
Add Points at Crossings Punten toevoegen bij kruisingen
Split at Crossings Splitsen bij kruisingen
Extend Path Pad verlengen
Measure & Inspect Meten en inspecteren
Set Anchor Ankerpunt instellen
Make Smooth Vloeiend maken
Make Corner Hoekpunt maken
Equalize Handles Grepen gelijkmaken
Remove Points, Keep Shape Punten verwijderen, vorm behouden
Add Points to Segments… Punten aan segmenten toevoegen…
Add Points to Segments Punten aan segmenten toevoegen
Open a document to edit its anchor points. Open een document om de ankerpunten ervan te bewerken.
Anchors selected: {count} Geselecteerde ankerpunten: {count}
Direct-select anchor points to edit them. Selecteer ankerpunten direct om ze te bewerken.
In: In:
Out: Uit:
Add Points Punten toevoegen
Points per segment Punten per segment
Close Path: Pad sluiten:
Keep Handles Grepen behouden
Straight Recht
Stretch Curve Curve uitrekken
@msg direct-select the anchor points to remove selecteer de te verwijderen ankerpunten direct
@msg select segments, or paths, to add points to selecteer segmenten, of paden, om punten aan toe te voegen
@msg select open paths to close selecteer open paden om te sluiten
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) te veel of te verwarde paden om hun kruisingen te vinden (hoogstens 10000 segmenten)
@msg the paths have too many anchors to edit at once de paden hebben te veel ankerpunten om in één keer te bewerken
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34
View File
@@ -5618,3 +5618,37 @@ anchor Points: Punkty:
@msg `{id}` is not a colour adjustment `{id}` nie jest korektą koloru
@msg missing `effect` brak `effect`
@msg `params` must be an object `params` musi być obiektem
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Precyzyjne narzędzia ścieżek
Anchor Eraser Brush Pędzel gumki punktów kontrolnych
Extend Path Tool Narzędzie Przedłuż ścieżkę
Measure & Inspect Tool Narzędzie Mierz i badaj
Anchor Inspector Inspektor punktów kontrolnych
Add Points at Crossings Dodaj punkty na skrzyżowaniach
Split at Crossings Podziel na skrzyżowaniach
Extend Path Przedłuż ścieżkę
Measure & Inspect Mierz i badaj
Set Anchor Ustaw punkt kontrolny
Make Smooth Zmień na gładki
Make Corner Zmień na narożny
Equalize Handles Wyrównaj uchwyty
Remove Points, Keep Shape Usuń punkty, zachowaj kształt
Add Points to Segments… Dodaj punkty do segmentów…
Add Points to Segments Dodaj punkty do segmentów
Open a document to edit its anchor points. Otwórz dokument, aby edytować jego punkty kontrolne.
Anchors selected: {count} Zaznaczone punkty kontrolne: {count}
Direct-select anchor points to edit them. Zaznacz bezpośrednio punkty kontrolne, aby je edytować.
Length Długość
In: Wejście:
Out: Wyjście:
Add Points Dodaj punkty
Points per segment Punkty na segment
Close Path: Zamknij ścieżkę:
Keep Handles Zachowaj uchwyty
Straight Prosty
Stretch Curve Rozciągnij krzywą
@msg direct-select the anchor points to remove zaznacz bezpośrednio punkty kontrolne do usunięcia
@msg select segments, or paths, to add points to zaznacz segmenty lub ścieżki, do których dodać punkty
@msg select open paths to close zaznacz otwarte ścieżki do zamknięcia
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) za dużo ścieżek lub są zbyt splątane, by znaleźć ich skrzyżowania (najwyżej 10000 segmentów)
@msg the paths have too many anchors to edit at once ścieżki mają za dużo punktów kontrolnych, by edytować je naraz
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34
View File
@@ -5681,3 +5681,37 @@ anchor Points: Pontos:
@msg `{id}` is not a colour adjustment `{id}` não é um ajuste de cor
@msg missing `effect` falta `effect`
@msg `params` must be an object `params` deve ser um objeto
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Ferramentas de Precisão de Caminhos
Anchor Eraser Brush Pincel Apagador de Pontos de Ancoragem
Extend Path Tool Ferramenta Estender Caminho
Measure & Inspect Tool Ferramenta Medir e Inspecionar
Anchor Inspector Inspetor de Pontos de Ancoragem
Add Points at Crossings Adicionar Pontos nos Cruzamentos
Split at Crossings Dividir nos Cruzamentos
Extend Path Estender Caminho
Measure & Inspect Medir e Inspecionar
Set Anchor Definir Ponto de Ancoragem
Make Smooth Tornar Suave
Make Corner Tornar Canto
Equalize Handles Igualar Alças
Remove Points, Keep Shape Remover Pontos, Manter Forma
Add Points to Segments… Adicionar Pontos aos Segmentos…
Add Points to Segments Adicionar Pontos aos Segmentos
Open a document to edit its anchor points. Abra um documento para editar seus pontos de ancoragem.
Anchors selected: {count} Pontos de ancoragem selecionados: {count}
Direct-select anchor points to edit them. Selecione diretamente pontos de ancoragem para editá-los.
Length Comprimento
In: Entrada:
Out: Saída:
Add Points Adicionar Pontos
Points per segment Pontos por segmento
Close Path: Fechar Caminho:
Keep Handles Manter Alças
Straight Reto
Stretch Curve Esticar Curva
@msg direct-select the anchor points to remove selecione diretamente os pontos de ancoragem a remover
@msg select segments, or paths, to add points to selecione segmentos, ou caminhos, aos quais adicionar pontos
@msg select open paths to close selecione caminhos abertos para fechar
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) caminhos demais, ou emaranhados demais, para encontrar seus cruzamentos (no máximo 10000 segmentos)
@msg the paths have too many anchors to edit at once os caminhos têm pontos de ancoragem demais para editar de uma vez
Can't render this file because it contains an unexpected character in line 6 and column 47.
+33
View File
@@ -5742,3 +5742,36 @@ anchor Points: Точки:
@msg `{id}` is not a colour adjustment `{id}` не является цветокоррекцией
@msg missing `effect` отсутствует `effect`
@msg `params` must be an object `params` должно быть объектом
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Инструменты точной правки контуров
Anchor Eraser Brush Кисть-ластик опорных точек
Extend Path Tool Инструмент «Продление контура»
Measure & Inspect Tool Инструмент «Измерение и анализ»
Anchor Inspector Инспектор опорных точек
Add Points at Crossings Добавить точки на пересечениях
Split at Crossings Разрезать на пересечениях
Extend Path Продлить контур
Measure & Inspect Измерение и анализ
Set Anchor Задать опорную точку
Make Smooth Сделать гладкой
Make Corner Сделать угловой
Equalize Handles Выровнять длину маркеров
Remove Points, Keep Shape Убрать точки, сохранить форму
Add Points to Segments… Добавить точки в сегменты…
Add Points to Segments Добавить точки в сегменты
Open a document to edit its anchor points. Откройте документ, чтобы править его опорные точки.
Anchors selected: {count} Выбрано опорных точек: {count}
Direct-select anchor points to edit them. Выделите опорные точки напрямую, чтобы править их.
In: Вход:
Out: Выход:
Add Points Добавить точки
Points per segment Точек на сегмент
Close Path: Замкнуть контур:
Keep Handles Сохранить маркеры
Straight Прямой
Stretch Curve Растянуть кривую
@msg direct-select the anchor points to remove выделите напрямую опорные точки, которые нужно убрать
@msg select segments, or paths, to add points to выделите сегменты или контуры, в которые нужно добавить точки
@msg select open paths to close выделите незамкнутые контуры, чтобы замкнуть их
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контуров слишком много или они слишком запутаны, чтобы найти их пересечения (не более 10000 сегментов)
@msg the paths have too many anchors to edit at once у контуров слишком много опорных точек, чтобы править их за раз
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -5788,3 +5788,36 @@ anchor Points: Punkter:
@msg `{id}` is not a colour adjustment `{id}` är ingen färgjustering
@msg missing `effect` `effect` saknas
@msg `params` must be an object `params` måste vara ett objekt
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Precisionsverktyg för banor
Anchor Eraser Brush Ankarpunktsraderpensel
Extend Path Tool Förläng bana-verktyget
Measure & Inspect Tool Mät och granska-verktyget
Anchor Inspector Ankarpunktsgranskare
Add Points at Crossings Lägg till punkter vid korsningar
Split at Crossings Dela vid korsningar
Extend Path Förläng bana
Measure & Inspect Mät och granska
Set Anchor Ange ankarpunkt
Make Smooth Gör mjuk
Make Corner Gör till hörn
Equalize Handles Utjämna handtag
Remove Points, Keep Shape Ta bort punkter, behåll formen
Add Points to Segments… Lägg till punkter i segment…
Add Points to Segments Lägg till punkter i segment
Open a document to edit its anchor points. Öppna ett dokument för att redigera dess ankarpunkter.
Anchors selected: {count} Valda ankarpunkter: {count}
Direct-select anchor points to edit them. Direktmarkera ankarpunkter för att redigera dem.
In: In:
Out: Ut:
Add Points Lägg till punkter
Points per segment Punkter per segment
Close Path: Stäng bana:
Keep Handles Behåll handtag
Straight Rak
Stretch Curve Sträck kurvan
@msg direct-select the anchor points to remove direktmarkera ankarpunkterna som ska tas bort
@msg select segments, or paths, to add points to markera segment, eller banor, att lägga till punkter i
@msg select open paths to close markera öppna banor att stänga
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) för många eller för trassliga banor för att hitta deras korsningar (högst 10000 segment)
@msg the paths have too many anchors to edit at once banorna har för många ankarpunkter för att redigeras på en gång
Can't render this file because it contains an unexpected character in line 5 and column 25.
+33
View File
@@ -5722,3 +5722,36 @@ anchor Points: Noktalar:
@msg `{id}` is not a colour adjustment `{id}` bir renk ayarı değil
@msg missing `effect` "isim" eksik
@msg `params` must be an object "gradyan" bir nesne olmalıdır
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Yol Hassasiyet Araçları
Anchor Eraser Brush Bağlantı Noktası Silgi Fırçası
Extend Path Tool Yolu Uzat Aracı
Measure & Inspect Tool Ölç ve İncele Aracı
Anchor Inspector Bağlantı Noktası Denetçisi
Add Points at Crossings Kesişimlere Nokta Ekle
Split at Crossings Kesişimlerden Böl
Extend Path Yolu Uzat
Measure & Inspect Ölç ve İncele
Set Anchor Bağlantı Noktasını Ayarla
Make Smooth Yumuşak Yap
Make Corner Köşe Yap
Equalize Handles Kolları Eşitle
Remove Points, Keep Shape Noktaları Kaldır, Şekli Koru
Add Points to Segments… Bölümlere Nokta Ekle…
Add Points to Segments Bölümlere Nokta Ekle
Open a document to edit its anchor points. Bağlantı noktalarını düzenlemek için bir belge açın.
Anchors selected: {count} Seçili bağlantı noktaları: {count}
Direct-select anchor points to edit them. Düzenlemek için bağlantı noktalarını doğrudan seçin.
In: Giriş:
Out: Çıkış:
Add Points Nokta Ekle
Points per segment Bölüm başına nokta
Close Path: Yolu Kapat:
Keep Handles Kolları Koru
Straight Düz
Stretch Curve Eğriyi Uzat
@msg direct-select the anchor points to remove kaldırılacak bağlantı noktalarını doğrudan seçin
@msg select segments, or paths, to add points to nokta eklenecek bölümleri veya yolları seçin
@msg select open paths to close kapatılacak açık yolları seçin
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) kesişimlerini bulmak için yollar çok fazla veya çok karışık (en fazla 10000 bölüm)
@msg the paths have too many anchors to edit at once yolların aynı anda düzenlenemeyecek kadar çok bağlantı noktası var
Can't render this file because it contains an unexpected character in line 5 and column 25.
+34
View File
@@ -5360,3 +5360,37 @@ anchor Points: Точки:
@msg `{id}` is not a colour adjustment `{id}` не є корекцією кольору
@msg missing `effect` відсутня `effect`
@msg `params` must be an object `params` має бути об’єктом
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Інструменти точного редагування контурів
Anchor Eraser Brush Пензель-гумка опорних точок
Extend Path Tool Інструмент «Подовження контуру»
Measure & Inspect Tool Інструмент «Вимірювання й аналіз»
Anchor Inspector Інспектор опорних точок
Add Points at Crossings Додати точки на перетинах
Split at Crossings Розрізати на перетинах
Extend Path Подовжити контур
Measure & Inspect Вимірювання й аналіз
Set Anchor Задати опорну точку
Make Smooth Зробити гладкою
Make Corner Зробити кутовою
Equalize Handles Вирівняти довжину маркерів
Remove Points, Keep Shape Забрати точки, зберегти форму
Add Points to Segments… Додати точки до сегментів…
Add Points to Segments Додати точки до сегментів
Open a document to edit its anchor points. Відкрийте документ, щоб редагувати його опорні точки.
Anchors selected: {count} Вибрано опорних точок: {count}
Direct-select anchor points to edit them. Виділіть опорні точки напряму, щоб редагувати їх.
Length Довжина
In: Вхід:
Out: Вихід:
Add Points Додати точки
Points per segment Точок на сегмент
Close Path: Замкнути контур:
Keep Handles Зберегти маркери
Straight Прямий
Stretch Curve Розтягнути криву
@msg direct-select the anchor points to remove виділіть напряму опорні точки, які треба забрати
@msg select segments, or paths, to add points to виділіть сегменти або контури, до яких треба додати точки
@msg select open paths to close виділіть незамкнені контури, щоб замкнути їх
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контурів забагато або вони надто заплутані, щоб знайти їхні перетини (не більше 10000 сегментів)
@msg the paths have too many anchors to edit at once у контурів забагато опорних точок, щоб редагувати їх за раз
Can't render this file because it contains an unexpected character in line 178 and column 5.
+34
View File
@@ -5362,3 +5362,37 @@ anchor Points: Điểm:
@msg `{id}` is not a colour adjustment `{id}` không phải là điều chỉnh màu
@msg missing `effect` thiếu `effect`
@msg `params` must be an object `params` phải là một đối tượng
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools Công cụ chính xác cho đường dẫn
Anchor Eraser Brush Cọ tẩy điểm neo
Extend Path Tool Công cụ Kéo dài đường dẫn
Measure & Inspect Tool Công cụ Đo và kiểm tra
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
Extend Path Kéo dài đường dẫn
Measure & Inspect Đo và kiểm tra
Set Anchor Đặt điểm neo
Make Smooth Làm mượt
Make Corner Chuyển thành góc
Equalize Handles Cân bằng tay nắm
Remove Points, Keep Shape Gỡ điểm, giữ hình dạng
Add Points to Segments… Thêm điểm vào đoạn…
Add Points to Segments Thêm điểm vào đoạn
Open a document to edit its anchor points. Mở một tài liệu để sửa các điểm neo của nó.
Anchors selected: {count} Điểm neo đã chọn: {count}
Direct-select anchor points to edit them. Chọn trực tiếp các điểm neo để sửa chúng.
Length Độ dài
In: Vào:
Out: Ra:
Add Points Thêm điểm
Points per segment Số điểm mỗi đoạn
Close Path: Đóng đường dẫn:
Keep Handles Giữ tay nắm
Straight Thẳng
Stretch Curve Kéo giãn đường cong
@msg direct-select the anchor points to remove chọn trực tiếp các điểm neo cần gỡ
@msg select segments, or paths, to add points to chọn các đoạn hoặc đường dẫn để thêm điểm
@msg select open paths to close chọn các đường dẫn mở để đóng
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) quá nhiều đường dẫn hoặc chúng quá rối để tìm chỗ giao nhau (tối đa 10000 đoạn)
@msg the paths have too many anchors to edit at once các đường dẫn có quá nhiều điểm neo để sửa cùng lúc
Can't render this file because it contains an unexpected character in line 200 and column 5.
+34
View File
@@ -5603,3 +5603,37 @@ anchor Points: 点:
@msg `{id}` is not a colour adjustment `{id}` 不是颜色调整
@msg missing `effect` 缺少 `effect`
@msg `params` must be an object `params` 必须是对象
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools 路径精确工具
Anchor Eraser Brush 锚点橡皮擦画笔
Extend Path Tool 延长路径工具
Measure & Inspect Tool 测量与检查工具
Anchor Inspector 锚点检查器
Add Points at Crossings 在交叉处添加锚点
Split at Crossings 在交叉处分割
Extend Path 延长路径
Measure & Inspect 测量与检查
Set Anchor 设置锚点
Make Smooth 转为平滑
Make Corner 转为尖角
Equalize Handles 等长手柄
Remove Points, Keep Shape 删除锚点并保持形状
Add Points to Segments… 在线段中添加锚点…
Add Points to Segments 在线段中添加锚点
Open a document to edit its anchor points. 打开文档以编辑其锚点。
Anchors selected: {count} 已选锚点:{count}
Direct-select anchor points to edit them. 直接选择锚点以编辑它们。
Length 长度
In: 入:
Out: 出:
Add Points 添加锚点
Points per segment 每段锚点数
Close Path: 闭合路径:
Keep Handles 保留手柄
Straight 直线
Stretch Curve 拉伸曲线
@msg direct-select the anchor points to remove 请直接选择要删除的锚点
@msg select segments, or paths, to add points to 请选择要添加锚点的线段或路径
@msg select open paths to close 请选择要闭合的开放路径
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路径太多或太复杂,无法找到交叉处(最多 10000 段)
@msg the paths have too many anchors to edit at once 路径的锚点太多,无法一次编辑
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34
View File
@@ -5399,3 +5399,37 @@ anchor Points: 點:
@msg `{id}` is not a colour adjustment `{id}` 不是顏色調整
@msg missing `effect` 未提供 `effect`
@msg `params` must be an object `params` 必須是物件
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
Path Precision Tools 路徑精確工具
Anchor Eraser Brush 錨點橡皮擦筆刷
Extend Path Tool 延長路徑工具
Measure & Inspect Tool 測量與檢查工具
Anchor Inspector 錨點檢查器
Add Points at Crossings 在交叉處增加錨點
Split at Crossings 在交叉處分割
Extend Path 延長路徑
Measure & Inspect 測量與檢查
Set Anchor 設定錨點
Make Smooth 轉為平滑
Make Corner 轉為角點
Equalize Handles 等長控制把手
Remove Points, Keep Shape 刪除錨點並保持形狀
Add Points to Segments… 在線段中增加錨點…
Add Points to Segments 在線段中增加錨點
Open a document to edit its anchor points. 開啟文件以編輯其錨點。
Anchors selected: {count} 已選取錨點:{count}
Direct-select anchor points to edit them. 直接選取錨點以編輯它們。
Length 長度
In: 入:
Out: 出:
Add Points 增加錨點
Points per segment 每段錨點數
Close Path: 封閉路徑:
Keep Handles 保留控制把手
Straight 直線
Stretch Curve 延展曲線
@msg direct-select the anchor points to remove 請直接選取要刪除的錨點
@msg select segments, or paths, to add points to 請選取要增加錨點的線段或路徑
@msg select open paths to close 請選取要封閉的開放路徑
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路徑太多或太複雜,無法找到交叉處(最多 10000 段)
@msg the paths have too many anchors to edit at once 路徑的錨點太多,無法一次編輯
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -181,6 +181,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
css_properties::ID => css_properties::show(app, ui),
crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui),
crate::addons::color::ID => crate::addons::color::show(app, ui),
crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui),
id if library_panel::parse(id).is_some() => library_panel::show(app, ui, id),
_ => {
dim_label(ui, tl!("This panel is on the roadmap (see the parity plan)."));
+1
View File
@@ -199,6 +199,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
(crate::panels::variables::ID, "Variables", "chart-column"),
(crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"),
(crate::addons::color::ID, "Color Effects", "sun"),
(crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"),
];
/// Groups of icon panels separated by dividers in the collapsed column.
+2
View File
@@ -389,6 +389,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::menus::invoke(app, id, json!({}));
Ok(json!({ "dialog": "command" }))
}
// The add-ons' tools.
_ if crate::addons::pathtools::has_options(tool) => crate::addons::pathtools::open_options(app, tool),
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
_ => Err(format!("the {tool} tool has no options")),
}
+58 -8
View File
@@ -5709,30 +5709,80 @@ topmost piece.
{"name":"run_command","arguments":{"command":"addon.cleanup.simplify","params":{"tolerance":40,"protectCorners":true}}}
```
## Color Effects add-on
## Path Precision Tools add-on
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"}`) and in three tools that the toolbars don't show until they are added from the
All Tools drawer (or chosen with `tool.select`): `anchorEraserBrush`, `extendPath` and `measureInspect`. Each edit is one
undo step, and an edit that changes nothing makes no step.
Tools:
- **Anchor Eraser Brush** (`anchorEraserBrush`): a drag over selected paths runs `addon.pathtools.erase` with the
stroke. Options (`tool.setOption`, or a double-click on the tool): `size` (screen pixels, 1 to 2500, 20), `tolerance`
(1 to 100, 10) and `pressure` (a pen's pressure scales the brush). Alt removes every anchor under the brush that can
go whatever the tolerance; Cmd/Ctrl deletes them without refitting; `[` and `]` resize the brush.
- **Extend Path tool** (`extendPath`): a drag from an end of an open path runs `addon.pathtools.extend` with the length
the pointer asks for: forward carries the path on, back along it shortens it. Option `mode`: `straight` (default),
`bezier` or `arc`; E cycles it. Shift steps the length by the keyboard increment.
- **Measure & Inspect tool** (`measureInspect`): a drag measures distance, angle and offsets, as the Measure tool does
(`tool.options` reports the last one); hovering a path reads it, as `addon.pathtools.inspect` does. Nothing goes into
the document.
Commands:
- `addon.pathtools.erase {points, diameter?, tolerance?, ignoreTolerance?, plain?, ids?}` removes the anchors of the
selected paths that a round brush of `diameter` pt (20 by default), swept through `points` (`[x, y]`, or `[x, y,
pressure]` to scale it), passes over, wherever the outline stays within tolerance × 0.02 pt of where it was (the
scale `addon.cleanup.simplify` uses), refitting the handles beside each. Open ends stay. → `{ids, removed}`.
- `addon.pathtools.extend {id, subpath?, end, length | to, mode?}` lengthens an open path at `end` (`"start"` or
`"end"`) by `length` pt, or shortens it by a negative length (never all of it). With `to: [x, y]` instead, the length
is the one that brings the end nearest that point. `mode`: `straight` (along the end's direction), `bezier` (the last
segment's curve stretched on, no anchor added) or `arc` (along the circle the end curves on, at most half way round;
a straight end goes on straight).
- `addon.pathtools.inspect {id, x, y, tolerance?}` (a query) reads the path at its point nearest `(x, y)`: open or
closed, anchor count, the subpath's length and enclosed area (an open one closed by a straight line), the segment's
length, the direction the path runs there and its radius of curvature (`null` where straight), and, within
`tolerance` (3 pt) of an anchor, that anchor's angle (90 at a square's corner, 180 where the path runs straight on;
`null` at an open end) and its handles' lengths and directions.
- The Anchor Inspector's edits act on the direct-selected anchors, or on every anchor of a path selected whole:
`addon.pathtools.setAnchor {x?, y?, inLength?, inAngle?, outLength?, outAngle?}` (page coordinates; angles in
degrees counter-clockwise; a length of 0 retracts), `addon.pathtools.smooth {ratio?}`, `addon.pathtools.corner
{retract?}` and `addon.pathtools.equalize {}`. Make Smooth mirrors a lone handle; an anchor without handles gets two
at right angles to the bisector of the angle its neighbours make at it, each `ratio` (0.1 to 0.5, 0.3905 by default:
the ratio that makes a square's corners a circle) × the nearer neighbour's distance long; an open end aims its handle
at its neighbour's. Equalize gives both handles their average length and makes a missing one opposite the other.
- `addon.pathtools.removeKeepShape {}` removes the direct-selected anchors, refitting the handles beside each.
- `addon.pathtools.addPoints {count?}` splits each selected segment (between two selected anchors, or every segment of
a path selected whole) into `count + 1` (1 to 100) pieces of equal length.
- `addon.pathtools.close {mode?, ratio?}` closes the selected open paths keeping the ends' handles (`handles`), with a
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.
```json
{"name":"run_command","arguments":{"command":"addon.pathtools.extend","params":{"id":12,"end":"end","length":40,"mode":"arc"}}}
{"name":"run_command","arguments":{"command":"addon.pathtools.erase","params":{"points":[[100,40],[100,160]],"diameter":30}}}
{"name":"run_command","arguments":{"command":"addon.pathtools.crossings","params":{"cut":true}}}
```
## Color Effects add-on
Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is
`addon.color.filter`, and in the desktop app it also lives in Object › Plug-ins › Color Effects and in its panel
(`window.panel {panel: "colorEffects"}`).
Its live effects go on through `effect.apply` like any other and recolour what the object paints, in stack order with
the other colour adjustments (Effect › Color Adjustments); each colour keeps its colour model:
| Effect | Parameters | Does |
|---|---|---|
| `addon.color.exposure` (Exposure…) | `exposure` stops −20…20 (0), `offset` −0.5…0.5 (0), `gamma` 0.1…10 (1) | Scales the light in linear light (each stop doubles or halves it, mostly in the highlights), adds the offset (shadows and midtones) and applies the gamma |
| `addon.color.desaturate` (Desaturate) | none | Turns every colour to the grey of its lightness |
| `addon.color.invert` (Invert Colors) | none | Turns every colour to its RGB inverse |
`addon.color.filter {effect, params?, fill?, stroke?, includeImages?, includePatterns?, ids?}` applies any colour
adjustment, the add-on's or one of Effect › Color Adjustments', to the colours themselves instead of as a live effect:
fills, strokes, type, gradient meshes, embedded images and pattern tiles, as `edit.colors.invert` reaches them, as one
undo step. Missing `params` take the effect's dialog defaults; the objects' own effects stay as they are.
```json
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"addon.color.exposure","params":{"exposure":1.5}}}}
{"name":"run_command","arguments":{"command":"addon.color.filter","params":{"effect":"adjust.hueSaturation","params":{"hue":120}}}}
```
The panel picks an adjustment (Brightness/Contrast, Hue/Saturation, Levels, Temperature/Tint, Exposure, Desaturate or
Invert Colors), sets its values, and then Add as Effect (`effect.apply`) or Apply as Filter (`addon.color.filter`).
+15 -3
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1216,8 +1216,20 @@ Show, Fix Selected and Fix All (each one undo step), Deep Ungroup and Rejoin Pat
alone; Fix All as one step; scopes; rejoin within the gap only) and `addons::cleanup::tests` in `vectorcraft-ui-egui`
(the submenu opens the panel; Check lists what it finds). Commands: `docs/mcp.md` › Artwork Cleanup add-on.
## Color Effects add-on (2026-10-30)
## Path Precision Tools add-on (2026-10-30)
A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Path Precision Tools,
its Anchor Inspector panel and three tools kept out of the shipped toolbars (listed in the All Tools drawer) run
`addon.pathtools.*`: an Anchor Eraser Brush on the shared keep-shape kernel, an Extend Path tool (straight, stretched
curve or arc; shortening back along the path), a Measure & Inspect tool with hover readouts (lengths, area, curvature,
angles), the Anchor Inspector's numeric anchor and handle fields with Make Smooth (auto-smooth rule), Make Corner,
Equalize Handles, Remove Points Keep Shape, Add Points and three ways to close, and Add Points at / Split at Crossings.
Kernels in `vectorcraft_pathops::addons::pathtools`. Tests: `addons::pathtools` in `vectorcraft-pathops`,
`vectorcraft-tools` and `vectorcraft-ui-egui`, `cmd::addons::pathtools::tests` in `vectorcraft-engine` (each command one
undo step, bad params refused, the brush's stroke one step) and `edit_toolbar::tests::addon_tools_are_in_the_drawer_not_the_toolbars`.
Commands: `docs/mcp.md` › Path Precision Tools add-on.
## Color Effects add-on (2026-10-30)
A VectorCraft addition, not an Illustrator feature, so no area score moves. Effect › Color Effects adds live Exposure
(stops of linear light, offset and gamma), Desaturate and Invert Colors as colour adjustments evaluated with those of
Effect › Color Adjustments (`vectorcraft_effects::addons::color`), and `addon.color.filter` applies any colour adjustment
@@ -1226,11 +1238,11 @@ Tests: `addons::color` in `vectorcraft-effects` (exposure in stops, desaturate k
identity), `cmd::addons::color::tests` in `vectorcraft-engine` (drawn live without changing the colours; a filter as one
undo step, with any adjustment and its params; bad requests refused) and `addons::color::tests` in
`vectorcraft-ui-egui` (the menus and the panel). Commands: `docs/mcp.md` › Color Effects add-on.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Path Precision Tools add-on (a VectorCraft addition): evidence added; scores unchanged. |
| 2026-10-30 | minor | Color Effects, a built-in add-on beyond Illustrator; scores and estimates unchanged |
| 2026-10-30 | minor | Artwork Cleanup add-on (a VectorCraft addition): evidence added; scores unchanged. |
| 2026-10-30 | minor | Hardware: a second finger cancels the tool press the first finger began, and a finger landing while another is down presses nothing, so a pinch only navigates (#4198); evidence added; scores unchanged (estimated). |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects add-on: its effects, panel and Object › Plug-ins submenu; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools add-on: its tools, Anchor Inspector panel and Object › Plug-ins submenu; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -321,6 +321,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned |
| Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 |
| Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), 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 runs Add Points at / Split at Crossings; 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` and their tests; not an Illustrator feature, so it is not scored |
| Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored |
| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored |
@@ -363,6 +364,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Path Precision Tools add-on (a VectorCraft addition): its tools, Anchor Inspector panel and Object › Plug-ins › Path Precision Tools; scores and estimates unchanged |
| 2026-10-30 | minor | Color Effects add-on: its effects, panel and Object › Plug-ins submenu; scores and estimates unchanged |
| 2026-10-30 | minor | Artwork Cleanup add-on (a VectorCraft addition): its panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged |
| 2026-10-30 | minor | Spinners: a held arrow repeats after a pause (#4267); scores and estimates unchanged |