Merge pull request #4778 from storytold/feat/addon-shared-edges

Add-ons: Path Precision Tools — Shared Edge Merge
This commit is contained in:
BFlat
2026-10-31 07:32:42 +07:00
committed by GitHub
53 changed files with 1953 additions and 28 deletions
+17 -9
View File
@@ -1,7 +1,8 @@
//! 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, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]) and the Parametric Shape Tool's shapes ([`shape`]).
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), and the Shared Edge Merge Tool's
//! merges with Keep Shared Points Together ([`shared_edge`]).
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
@@ -9,6 +10,7 @@ mod corners;
#[cfg(test)]
mod effect_tests;
mod shape;
mod shared_edge;
#[cfg(test)]
mod tests;
@@ -57,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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)}",
"{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; with Keep Shared Points Together on, anchors coincident with an end that moves go with it → {id, length (pt)}",
has_doc,
extend
),
@@ -75,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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}",
"{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; with Keep Shared Points Together on, anchors coincident with one that moves go with it → {anchors}",
has_selection,
set_anchor
),
@@ -147,13 +149,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Slide Anchor Along Path",
[],
None,
"{id, subpath?: index (0), anchor: index, t: -0.98..0.98 (where to: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before)} move an anchor along its path, the segments beside it refitted so the outline stays, as one undo step; ends of open paths don't slide → {id, strayed (pt)}",
"{id, subpath?: index (0), anchor: index, t: -0.98..0.98 (where to: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before)} move an anchor along its path, the segments beside it refitted so the outline stays, as one undo step; ends of open paths don't slide; with Keep Shared Points Together on, anchors coincident with it go with it → {id, strayed (pt)}",
has_doc,
slide_anchor
),
];
v.extend(shape::specs());
v.extend(corners::specs());
v.extend(shared_edge::specs());
v
}
@@ -315,19 +318,22 @@ fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, Su
}
/// Put `new` in place of the subpath `old` (from [`named_subpath`]) as one step labelled `label`
/// (none when it is the same); the path's direct selection goes.
/// (none when it is the same), with what Keep Shared Points Together moves too; the path's direct
/// selection goes.
fn set_subpath(s: &mut Session, label: &str, (id, si, old): (NodeId, usize, SubPath), new: SubPath) -> Result<()> {
if new == old {
return Ok(());
}
let mut np = s.doc()?.doc.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
if let Some(slot) = np.subpaths.get_mut(si) {
*slot = new;
}
let shared = shared_edge::shared_points(s, [(id, &np)])?;
s.edit(label, |d, sel| {
let mut np = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
if let Some(slot) = np.subpaths.get_mut(si) {
*slot = new;
}
sel.anchors.remove(&id);
sel.segments.remove(&id);
set_path(d, id, np);
shared_edge::move_shared(d, shared);
Ok(())
})
}
@@ -458,10 +464,12 @@ fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath
}
}
if !changed.is_empty() {
let shared = shared_edge::shared_points(s, changed.iter().map(|(id, np)| (*id, np)))?;
s.edit(label, |d, _| {
for (id, np) in changed {
set_path(d, id, np);
}
shared_edge::move_shared(d, shared);
Ok(())
})?;
}
@@ -0,0 +1,164 @@
//! The Shared Edge Merge Tool's command and Keep Shared Points Together: with that option on, the
//! add-on's own commands that move an anchor (Slide Anchor Along Path, Extend Path, Set Anchor) take
//! the anchors coincident with it along.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value, json};
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, Vec2};
use vectorcraft_pathops::addons::pathtools as pt;
use vectorcraft_tools::addons::shared_edge::{DIAMETER, DIAMETER_RANGE, ID};
use super::super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, number_or, switch_or, text_param};
use super::{edit_whole, set_path};
use crate::{Result, Session};
#[cfg(test)]
mod tests;
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.pathtools.mergeEdge",
"Merge Shared Edge",
[],
None,
"{a, b: two path ids (they needn't be selected), at: [x, y] (where), diameter?: pt 0.25..200 (20; how near `at` the edge is looked for), tolerance?: pt 0..3 (0.002; how near two anchors count as coincident), follow?: \"average\" (half way between the two)|\"a\"|\"b\" (keep that path's outline), ends?: bool (false: true makes an end even where the paths could merge)} where the paths touch at two coincident anchors, one each side of `at`, replace the stretches between them with one shared edge; stretches much longer one than the other (by over 10%) or leaving an end at over 120° aren't merged. Otherwise make an end there: two near anchors meet, and an anchor only one path has there is matched by a new one on the other. Each path stays closed or open; one undo step → {a, b, made: \"edge\"|\"end\"|\"nothing\" (merged already)}",
has_doc,
merge_edge
),
cmd!(
"addon.pathtools.movePointsTogether",
"Keep Shared Points Together",
[],
None,
"{enabled?: bool (default: the opposite of now)} Keep Shared Points Together: whether anchors coincident (within the Shared Edge Merge Tool's `tolerance`) with one that Slide Anchor Along Path, Extend Path or Set Anchor moves move with it, on any editable path (the `together` option of tool `sharedEdge`, kept with the preferences; off by default) → {enabled}",
always,
move_points_together
),
]
}
/// A path a merge may edit.
fn merge_path<'a>(d: &'a Document, id: NodeId, cmd: &str) -> Result<&'a PathData> {
if !d.is_editable(id) {
return Err(bad(cmd, "the path is locked or hidden"));
}
match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Path { path, guide: false, .. }) => Ok(path),
_ => Err(bad(cmd, "`a` and `b` must be two different paths")),
}
}
fn merge_edge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.mergeEdge";
let a = checked_id(s, p.get("a").unwrap_or(&Value::Null), C)?;
let b = checked_id(s, p.get("b").unwrap_or(&Value::Null), C)?;
if a == b {
return Err(bad(C, "`a` and `b` must be two different paths"));
}
let [x, y] = p.get("at").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
let at = Point::new(x, y);
let diameter = number_or(p, "diameter", DIAMETER, C)?;
if !(DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(&diameter) {
return Err(bad(C, "`diameter` must be from 0.25 to 200 pt"));
}
let tolerance = number_or(p, "tolerance", pt::COINCIDENCE, C)?;
if !(0.0..=pt::MAX_COINCIDENCE).contains(&tolerance) {
return Err(bad(C, "`tolerance` must be from 0 to 3 pt"));
}
let follow = match text_param(p, "follow", C)? {
None => pt::Follow::Average,
Some(f) => pt::Follow::parse(f).ok_or_else(|| bad(C, "`follow` must be \"average\", \"a\" or \"b\""))?,
};
let ends = switch_or(p, "ends", false, C)?;
let d = &s.doc()?.doc;
let (pa, pb) = (merge_path(d, a, C)?, merge_path(d, b, C)?);
let radius = diameter / 2.0;
let edge = if ends { None } else { pt::stretches_at(pa, pb, at, radius, tolerance) };
let plan = edge.or_else(|| pt::ends_at(pa, pb, at, radius)).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?;
let (na, nb) = pt::apply_merge(pa, pb, &plan, follow).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?;
let changed: Vec<(NodeId, PathData)> =
[(a, pa, na), (b, pb, nb)].into_iter().filter(|(_, old, new)| *old != new).map(|(id, _, new)| (id, new)).collect();
let made = match plan {
_ if changed.is_empty() => "nothing",
pt::MergePlan::Edge { .. } => "edge",
pt::MergePlan::Ends { .. } => "end",
};
edit_whole(s, "Merge Shared Edge", changed)?;
Ok(json!({ "a": a.0, "b": b.0, "made": made }))
}
fn move_points_together(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.movePointsTogether";
let now = s.tool_options_of(ID)["together"].as_bool().unwrap_or(false);
let enabled = switch_or(p, "enabled", !now, C)?;
s.set_tool_options(Some(ID), &Map::from_iter([("together".to_string(), json!(enabled))]));
Ok(json!({ "enabled": enabled }))
}
/// Anchors to move along with others: each with how far.
pub(super) type SharedPoints = Vec<(NodeId, AnchorRef, Vec2)>;
/// With Keep Shared Points Together on, the anchors of editable paths coincident with an anchor
/// that `changed` (paths in their new form) moves, and not moving themselves. Subpaths whose
/// anchors `changed` adds or removes move none: their anchors stay put.
pub(super) fn shared_points<'a>(s: &Session, changed: impl IntoIterator<Item = (NodeId, &'a PathData)>) -> Result<SharedPoints> {
let options = s.tool_options_of(ID);
if options["together"].as_bool() != Some(true) {
return Ok(vec![]);
}
let tol = options["tolerance"].as_f64().unwrap_or(pt::COINCIDENCE);
let d = &s.doc()?.doc;
let mut moved: BTreeSet<(NodeId, AnchorRef)> = BTreeSet::new();
let mut from: Vec<(Point, Vec2)> = vec![];
for (id, new) in changed {
let Some(old) = d.node(id).and_then(|n| n.path_data()) else { continue };
for (si, (o, n)) in old.subpaths.iter().zip(&new.subpaths).enumerate().filter(|(_, (o, n))| o.anchors.len() == n.anchors.len()) {
for (ai, (x, y)) in o.anchors.iter().zip(&n.anchors).enumerate().filter(|(_, (x, y))| x.p != y.p) {
moved.insert((id, (si, ai)));
from.push((x.p, y.p - x.p));
}
}
}
let mut out = vec![];
if from.is_empty() {
return Ok(out);
}
from.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
let editable = d.editable();
d.walk(|n| {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
if !editable(n.id) {
return;
}
for (si, ai, a) in path.anchors() {
let start = from.partition_point(|(p, _)| p.x < a.p.x - tol);
let mut near = from.iter().skip(start).take_while(|(p, _)| p.x <= a.p.x + tol);
if let Some((_, by)) = near.find(|(p, _)| p.distance(a.p) <= tol)
&& !moved.contains(&(n.id, (si, ai)))
{
out.push((n.id, (si, ai), *by));
}
}
});
Ok(out)
}
/// Move the anchors [`shared_points`] found; what they are on stops being a live shape.
pub(super) fn move_shared(d: &mut Document, shared: SharedPoints) {
let mut by_path: BTreeMap<NodeId, Vec<(AnchorRef, Vec2)>> = BTreeMap::new();
for (id, at, by) in shared {
by_path.entry(id).or_default().push((at, by));
}
for (id, anchors) in by_path {
let Some(mut np) = d.node(id).and_then(|n| n.path_data()).cloned() else { continue };
for ((si, ai), by) in anchors {
if let Some(a) = np.anchor_mut(si, ai) {
a.translate(by);
}
}
set_path(d, id, np);
}
}
@@ -0,0 +1,204 @@
//! The Shared Edge Merge Tool's command and Keep Shared Points Together: one undo step each, bad
//! params refused, the tool's click through the session, and only the add-on's commands affected.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{PathData, Point};
use vectorcraft_tools::{Mods, 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 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();
NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap())
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn points(s: &Session, id: NodeId) -> Vec<Point> {
path(s, id).anchors().map(|(_, _, a)| a.p).collect()
}
/// Two closed shapes with a bent edge each between (100, 100) and (200, 100).
fn shapes(s: &mut Session) -> (NodeId, NodeId) {
let a = line(s, &[(100.0, 100.0), (150.0, 104.0), (200.0, 100.0), (200.0, 50.0), (100.0, 50.0)], true);
let b = line(s, &[(200.0, 100.0), (150.0, 96.0), (100.0, 100.0), (100.0, 150.0), (200.0, 150.0)], true);
(a, b)
}
#[test]
fn merge_edge_replaces_both_stretches_as_one_undo_step() {
let mut s = session();
let (a, b) = shapes(&mut s);
s.execute("select.none", &json!({})).unwrap();
let (pa, pb) = (path(&s, a), path(&s, b));
let n = steps(&s);
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap();
assert_eq!(r, json!({"a": a.0, "b": b.0, "made": "edge"}));
assert_eq!(steps(&s), n + 1);
assert!(path(&s, a).subpaths[0].closed && path(&s, b).subpaths[0].closed);
assert_eq!(points(&s, a)[..3], [Point::new(100.0, 100.0), Point::new(150.0, 100.0), Point::new(200.0, 100.0)]);
assert_eq!(points(&s, b)[..3], [Point::new(200.0, 100.0), Point::new(150.0, 100.0), Point::new(100.0, 100.0)]);
// Nothing left to do: no step.
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap();
assert_eq!(r["made"], json!("nothing"));
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((path(&s, a), path(&s, b)), (pa, pb));
}
#[test]
fn merge_edge_follows_the_path_asked_and_makes_ends() {
let mut s = session();
let (a, b) = shapes(&mut s);
let pa = path(&s, a);
s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100], "follow": "a"})).unwrap();
assert_eq!(path(&s, a), pa);
assert_eq!(points(&s, b)[1], Point::new(150.0, 104.0));
// Ends asked for: the near anchors of two lines meet, one undo step.
let c = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)], false);
let d = line(&mut s, &[(54.0, 200.0), (90.0, 240.0)], false);
let n = steps(&s);
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": c.0, "b": d.0, "at": [52, 200], "ends": true})).unwrap();
assert_eq!((r["made"].clone(), steps(&s)), (json!("end"), n + 1));
assert_eq!((points(&s, c)[1], points(&s, d)[0]), (Point::new(52.0, 200.0), Point::new(52.0, 200.0)));
}
#[test]
fn merge_edge_refuses_bad_params_and_locked_paths() {
let mut s = session();
let (a, b) = shapes(&mut s);
let (i, j) = (a.0, b.0);
let n = steps(&s);
for p in [
json!({}),
json!({"a": i, "b": i, "at": [125, 100]}),
json!({"a": i, "b": j}),
json!({"a": i, "b": j, "at": [1]}),
json!({"a": i, "b": j, "at": [1, "x"]}),
json!({"a": i, "b": j, "at": [125, 100], "diameter": 0.1}),
json!({"a": i, "b": j, "at": [125, 100], "diameter": 201}),
json!({"a": i, "b": j, "at": [125, 100], "tolerance": 3.5}),
json!({"a": i, "b": j, "at": [125, 100], "tolerance": -1}),
json!({"a": i, "b": j, "at": [125, 100], "follow": "up"}),
json!({"a": i, "b": j, "at": [125, 100], "ends": 1}),
json!({"a": i, "b": u64::MAX, "at": [125, 100]}),
json!({"a": i, "b": j, "at": [390, 290]}),
] {
assert!(s.execute("addon.pathtools.mergeEdge", &p).is_err(), "{p}");
}
assert_eq!(steps(&s), n);
s.execute("select.set", &json!({"ids": [i]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
assert!(s.execute("addon.pathtools.mergeEdge", &json!({"a": i, "b": j, "at": [125, 100]})).is_err());
}
#[test]
fn the_tool_merges_unselected_paths_on_a_click_with_shift_following_the_upper() {
let mut s = session();
let (lower, upper) = shapes(&mut s);
s.execute("select.none", &json!({})).unwrap();
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("sharedEdge", view).unwrap();
let keep = path(&s, upper);
let n = steps(&s);
let shifted = |kind| {
let mut e = PointerEvent::new(kind, 125.0, 100.0);
e.mods = Mods { shift: true, ..Mods::default() };
e
};
s.pointer(&shifted(PointerKind::Down), view).unwrap();
s.pointer(&shifted(PointerKind::Up), view).unwrap();
assert_eq!(steps(&s), n + 1);
assert_eq!(path(&s, upper), keep);
assert_eq!(points(&s, lower)[1], Point::new(150.0, 96.0));
}
#[test]
fn keep_shared_points_together_is_off_by_default_and_a_preference_of_the_tool() {
let mut s = session();
assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(false));
assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(), json!({"enabled": true}));
assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(true), "kept with another tool active");
assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({})).unwrap(), json!({"enabled": false}));
assert!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": 1})).is_err());
}
#[test]
fn a_slide_takes_coincident_anchors_along_in_one_step_when_the_option_is_on() {
let mut s = session();
let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false);
let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false);
let far = line(&mut s, &[(200.0, 101.0), (250.0, 200.0)], false);
let slide = json!({"id": a.0, "anchor": 1, "t": 0.5});
let n = steps(&s);
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0), "off: it stays");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap();
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
assert_eq!(steps(&s), n + 1);
let moved = points(&s, a)[1];
assert!(moved.distance(Point::new(250.0, 100.0)) < 1e-6, "{moved:?}");
assert!(points(&s, b)[0].distance(moved) < 1e-9);
assert_eq!(points(&s, b)[1], Point::new(200.0, 200.0));
assert_eq!(points(&s, far)[0], Point::new(200.0, 101.0), "not coincident");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0));
// A wider coincidence reaches it, and a locked path stays.
s.set_tool_options(Some("sharedEdge"), &serde_json::Map::from_iter([("tolerance".to_string(), json!(2))]));
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
assert!(points(&s, far)[0].distance(Point::new(250.0, 101.0)) < 1e-6);
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0));
}
#[test]
fn set_anchor_and_extend_take_coincident_anchors_along_but_built_in_moves_do_not() {
let mut s = session();
let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0)], false);
let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false);
s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap();
// Set Anchor on one selected anchor.
s.select(|_, sel| {
sel.set([a]);
sel.anchors.insert(a, [(0, 1)].into_iter().collect());
})
.unwrap();
let n = steps(&s);
s.execute("addon.pathtools.setAnchor", &json!({"x": 220, "y": 90})).unwrap();
assert_eq!(steps(&s), n + 1);
assert_eq!(points(&s, b), [Point::new(220.0, 90.0), Point::new(200.0, 200.0)]);
// An edit that moves no anchor moves none.
s.execute("addon.pathtools.corner", &json!({})).unwrap();
assert_eq!(points(&s, b)[0], Point::new(220.0, 90.0));
// Extend Path moves an end only when it adds no anchor.
s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10, "mode": "bezier"})).unwrap();
let end = points(&s, a)[1];
assert!(points(&s, b)[0].distance(end) < 1e-9);
let end = points(&s, b)[0];
s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10})).unwrap();
assert_eq!(points(&s, a).len(), 3);
assert_eq!(points(&s, b)[0], end, "the end it left stays with what shares it");
// The app's own moves are untouched by the option.
s.select(|_, sel| {
sel.set([a]);
sel.anchors.insert(a, [(0, 1)].into_iter().collect());
})
.unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 5})).unwrap();
assert_eq!(points(&s, b)[0], end);
}
+11
View File
@@ -568,6 +568,17 @@ fn structured_junk() {
("addon.pathtools.slideAnchor", json!({"id": 2, "subpath": u64::MAX, "anchor": 0, "t": -0.5})),
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": u64::MAX, "t": 1e308})),
("addon.pathtools.slideAnchor", json!({"id": u64::MAX, "anchor": "x", "t": "y"})),
// Path Precision Tools: the Shared Edge Merge Tool and Keep Shared Points Together.
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [0, 0]})),
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [10, 10], "diameter": 200, "tolerance": 3, "ends": true})),
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 2, "at": [0, 0]})),
("addon.pathtools.mergeEdge", json!({"a": 2, "b": u64::MAX, "at": [1e308, -1e308]})),
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [1, 2, 3], "diameter": 1e308, "tolerance": -1, "follow": "up"})),
("addon.pathtools.mergeEdge", json!({"a": "x", "b": null, "at": "y", "follow": 3, "ends": "z"})),
("addon.pathtools.movePointsTogether", json!({"enabled": "maybe"})),
("addon.pathtools.movePointsTogether", json!({"enabled": true})),
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})),
("addon.pathtools.movePointsTogether", json!({"enabled": false})),
// Smart Pen add-on.
("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})),
("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})),
+2 -2
View File
@@ -5,8 +5,8 @@
//! - [`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,
//! finding where paths cross, sliding anchors along their path and the Parametric Shape Tool's
//! re-editable shapes.
//! finding where paths cross, sliding anchors along their path, merging touching edges and the
//! Parametric Shape Tool's re-editable shapes.
//! - [`segment_reshape`]: bending a segment by dragging a point on it (shared by the tools that
//! reshape segments directly).
//! - [`pen`]: the Smart Pen's straight and curved segments and its point edits.
+6 -2
View File
@@ -2,12 +2,13 @@
//! rule, equalise handles, add evenly spaced anchors, close), what its live path effects add to them
//! ([`add_anchors`], [`smooth_anchors`], [`split_at_anchors`]), the readouts the Measure & Inspect
//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]), the places
//! where paths cross ([`crossings`]), anchors slid along their path ([`slide`]) and the Parametric
//! Shape Tool's shapes ([`shapes`]).
//! where paths cross ([`crossings`]), anchors slid along their path ([`slide`]), touching edges
//! merged ([`shared_edge`]) and the Parametric Shape Tool's shapes ([`shapes`]).
mod crossings;
mod extend;
pub mod shapes;
mod shared_edge;
mod slide;
#[cfg(test)]
mod tests;
@@ -19,6 +20,9 @@ use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at};
pub use extend::{ExtendMode, extend_length_to, extend_open};
pub use shared_edge::{
COINCIDENCE, EndSide, Follow, MAX_COINCIDENCE, MergePlan, MergePreview, Stretch, apply_merge, ends_at, merge_preview, stretches_at,
};
pub use slide::{MAX_SLIDE, slide_anchor, slide_stops, slide_t_at};
use crate::fit::{end_tangent, start_tangent};
@@ -22,7 +22,7 @@ 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 {
pub(super) fn control_box(c: &CubicBez) -> Rect {
Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3)
}
@@ -44,7 +44,7 @@ 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 {
pub(super) 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))
}
@@ -0,0 +1,489 @@
//! Merging two touching edges: where two paths meet at two pairs of coincident anchors, the
//! stretches of path between those anchors become one shared edge ([`stretches_at`]); where the
//! anchors aren't there yet, near ones are brought together and a missing one is added
//! ([`ends_at`]). [`merge_preview`] says what a plan would do and [`apply_merge`] does it. The
//! Shared Edge Merge Tool and `addon.pathtools.mergeEdge` drive both from the same plan.
use std::collections::BTreeMap;
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
use super::crossings::control_box;
use super::extend::finite;
use super::{ACCURACY, TINY, insert_at, unit};
use crate::fit::{end_tangent, is_straight, start_tangent};
#[cfg(test)]
mod tests;
/// How near two anchors count as one, by default and at most (pt).
pub const COINCIDENCE: f64 = 0.002;
pub const MAX_COINCIDENCE: f64 = 3.0;
/// Stretches differing in length by more than this ratio aren't the same edge.
const MAX_LENGTH_RATIO: f64 = 1.1;
/// The cosine of the widest angle (120°) stretches may make at an end: past it they run apart.
const MIN_END_COS: f64 = -0.5;
/// Paths with more anchors than this aren't searched.
const MAX_PATH_ANCHORS: usize = 200_000;
/// Most segments a stretch runs.
const MAX_STRETCH: usize = 5_000;
/// Most segments under the brush looked at on each path.
const MAX_NEAR: usize = 16;
/// Anchors of the two stretches closer than this share of the length are one anchor of the result.
const SAME_PLACE: f64 = 0.01;
/// How close (pt along a segment) a cut has to be to the segment's end to be that end.
const AT_END: f64 = 1e-6;
/// The cosine of the widest bend (5°) the average's joints are straightened out of.
const MIN_JOINT_COS: f64 = 0.9962;
/// Control points this close (pt) are the same edge.
const SAME_EDGE: f64 = 1e-6;
/// Which outline a merge keeps.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Follow {
/// Half way between the two.
Average,
/// The first path's: it doesn't change.
First,
/// The second path's.
Second,
}
impl Follow {
pub const ALL: [Self; 3] = [Self::Average, Self::First, Self::Second];
pub fn id(self) -> &'static str {
match self {
Self::Average => "average",
Self::First => "a",
Self::Second => "b",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|f| f.id() == s)
}
}
/// `segments` segments of a subpath from anchor `start` forward; `reversed` when the merge runs
/// along them backward.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Stretch {
pub subpath: usize,
pub start: usize,
pub segments: usize,
pub reversed: bool,
}
/// One path's side of making an end.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum EndSide {
/// The anchor it has there.
Anchor { subpath: usize, anchor: usize },
/// It has none: one goes on this segment.
Insert { subpath: usize, segment: usize, t: f64 },
}
/// What a click can do.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MergePlan {
/// Replace these two stretches, running the same way, with one edge.
Edge { a: Stretch, b: Stretch },
/// Bring the two sides to one point.
Ends { a: EndSide, b: EndSide },
}
/// What a plan would do.
#[derive(Clone, Debug, PartialEq)]
pub enum MergePreview {
/// The two stretches and the edge replacing them; `done` when they are that edge already.
Edge { a: Vec<CubicBez>, b: Vec<CubicBez>, merged: Vec<CubicBez>, done: bool },
/// Where the two sides are and where they meet.
Ends { a: Point, b: Point, to: Point },
}
fn searchable(a: &PathData, b: &PathData, at: Point) -> bool {
let fits = |p: &PathData| p.anchor_count() <= MAX_PATH_ANCHORS && p.subpaths.iter().all(finite);
at.x.is_finite() && at.y.is_finite() && fits(a) && fits(b)
}
impl Stretch {
/// Its segments in the order the merge runs along them.
fn chain(&self, path: &PathData) -> Option<Vec<CubicBez>> {
let sp = path.subpaths.get(self.subpath)?;
let count = sp.segment_count();
if count == 0 || self.segments == 0 || self.segments > count {
return None;
}
let forward: Vec<CubicBez> = (0..self.segments).map(|k| sp.segment((self.start + k) % count)).collect();
Some(if self.reversed { backward(&forward) } else { forward })
}
}
/// `chain` run the other way.
fn backward(chain: &[CubicBez]) -> Vec<CubicBez> {
chain.iter().rev().map(|c| CubicBez::new(c.p3, c.p2, c.p1, c.p0)).collect()
}
fn length(chain: &[CubicBez]) -> f64 {
chain.iter().map(|c| c.arclen(ACCURACY)).sum()
}
/// A subpath's anchors in order of x, to find the ones at a point quickly.
struct ByX<'a> {
sp: &'a SubPath,
order: Vec<(f64, usize)>,
}
impl<'a> ByX<'a> {
fn new(sp: &'a SubPath) -> Self {
let mut order: Vec<(f64, usize)> = sp.anchors.iter().enumerate().map(|(i, a)| (a.p.x, i)).collect();
order.sort_by(|x, y| x.0.total_cmp(&y.0));
Self { sp, order }
}
/// Its anchor nearest `p`, within `tol`.
fn partner(&self, p: Point, tol: f64) -> Option<usize> {
let from = self.order.partition_point(|(x, _)| *x < p.x - tol);
let within = self.order.iter().skip(from).take_while(|(x, _)| *x <= p.x + tol);
let near = within.filter_map(|(_, i)| Some((self.sp.anchors.get(*i)?.p.distance(p), *i))).filter(|(d, _)| *d <= tol);
near.min_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, i)| i)
}
}
/// The segments of `path` within `radius` of `at`, nearest first: (distance, subpath, segment).
fn segments_near(path: &PathData, at: Point, radius: f64) -> Vec<(f64, usize, usize)> {
let mut out = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
for s in 0..sp.segment_count() {
let c = sp.segment(s);
if !control_box(&c).inflate(radius, radius).contains(at) {
continue;
}
let d = c.nearest(at, ACCURACY).distance_sq.sqrt();
if d <= radius {
out.push((d, si, s));
}
}
}
out.sort_by(|x, y| x.0.total_cmp(&y.0));
out.truncate(MAX_NEAR);
out
}
/// The stretch of `sa` around segment `seg` between the nearest anchors each way that `b` has a
/// coincident one for, and the stretch of `b` between those that holds its segment `seg_b`.
fn matching((si_a, sa): (usize, &SubPath), seg: usize, (si_b, by_x): (usize, &ByX), seg_b: usize, tol: f64) -> Option<(Stretch, Stretch)> {
let (n, m) = (sa.anchors.len(), by_x.sp.anchors.len());
let limit = sa.segment_count().min(MAX_STRETCH);
let partner = |i: usize| by_x.partner(sa.anchors.get(i)?.p, tol);
let back = (0..limit).take_while(|k| sa.closed || *k <= seg).find_map(|k| Some((k, partner((seg + n - k) % n)?)));
let ahead = (0..limit).take_while(|k| sa.closed || seg + 1 + k < n).find_map(|k| Some((k, partner((seg + 1 + k) % n)?)));
let ((back, b0), (ahead, b1)) = (back?, ahead?);
let segments = back + ahead + 1;
// Round to the same anchor, or to the same one of the other path: there is no stretch between.
if b0 == b1 || (sa.closed && segments >= n) {
return None;
}
let first = Stretch { subpath: si_a, start: (seg + n - back) % n, segments, reversed: false };
let run = |from: usize, to: usize| (to + m - from) % m;
let holds = |from: usize, to: usize| (seg_b + m - from) % m < run(from, to);
let second = if (by_x.sp.closed || b0 < b1) && holds(b0, b1) {
Stretch { subpath: si_b, start: b0, segments: run(b0, b1), reversed: false }
} else if (by_x.sp.closed || b1 < b0) && holds(b1, b0) {
Stretch { subpath: si_b, start: b1, segments: run(b1, b0), reversed: true }
} else {
return None;
};
Some((first, second))
}
/// Could these two run along one another: much the same length, not leaving their ends in
/// opposite directions?
fn alike(a: &[CubicBez], b: &[CubicBez]) -> bool {
let (la, lb) = (length(a), length(b));
let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = ((a.first(), a.last()), (b.first(), b.last())) else { return false };
la.min(lb) > TINY
&& la.max(lb) <= la.min(lb) * MAX_LENGTH_RATIO
&& start_tangent(a0).dot(start_tangent(b0)) >= MIN_END_COS
&& end_tangent(a1).dot(end_tangent(b1)) >= MIN_END_COS
}
/// The two stretches of `a` and `b` that can merge within `radius` of `at`, each pair of ends
/// coincident within `tol`: the pair nearest `at`. `None` when there is none.
pub fn stretches_at(a: &PathData, b: &PathData, at: Point, radius: f64, tol: f64) -> Option<MergePlan> {
if !searchable(a, b, at) {
return None;
}
let (near_a, near_b) = (segments_near(a, at, radius), segments_near(b, at, radius));
let index: BTreeMap<usize, ByX> = near_b.iter().filter_map(|&(_, si, _)| Some((si, ByX::new(b.subpaths.get(si)?)))).collect();
let mut pairs: Vec<_> = near_a.iter().flat_map(|&(da, sa, ga)| near_b.iter().map(move |&(db, sb, gb)| (da + db, (sa, ga), (sb, gb)))).collect();
pairs.sort_by(|x, y| x.0.total_cmp(&y.0));
pairs.into_iter().find_map(|(_, (sa, ga), (sb, gb))| {
let (x, y) = matching((sa, a.subpaths.get(sa)?), ga, (sb, index.get(&sb)?), gb, tol)?;
alike(&x.chain(a)?, &y.chain(b)?).then_some(MergePlan::Edge { a: x, b: y })
})
}
/// The anchor of `path` nearest `at`, within `radius`: (subpath, anchor, where).
fn anchor_near(path: &PathData, at: Point, radius: f64) -> Option<(usize, usize, Point)> {
let near = path.anchors().map(|(si, ai, a)| (a.p.distance(at), si, ai, a.p)).filter(|x| x.0 <= radius);
near.min_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, si, ai, p)| (si, ai, p))
}
/// Where `path` takes an anchor to match one at `p`, if the place is within `radius` of `at`.
fn matching_place(path: &PathData, p: Point, at: Point, radius: f64) -> Option<EndSide> {
let (subpath, segment, t, q, _) = path.nearest(p)?;
(q.distance(at) <= radius).then_some(EndSide::Insert { subpath, segment, t })
}
/// What making an end within `radius` of `at` takes: the two paths' nearest anchors there, or the
/// anchor one has and the place on the other for its match. `None` when neither has an anchor
/// there, or they are together already.
pub fn ends_at(a: &PathData, b: &PathData, at: Point, radius: f64) -> Option<MergePlan> {
if !searchable(a, b, at) {
return None;
}
let anchor = |(subpath, anchor, _): (usize, usize, Point)| EndSide::Anchor { subpath, anchor };
match (anchor_near(a, at, radius), anchor_near(b, at, radius)) {
(Some(x), Some(y)) => (x.2.distance(y.2) > TINY).then(|| MergePlan::Ends { a: anchor(x), b: anchor(y) }),
(Some(x), None) => Some(MergePlan::Ends { a: anchor(x), b: matching_place(b, x.2, at, radius)? }),
(None, Some(y)) => Some(MergePlan::Ends { a: matching_place(a, y.2, at, radius)?, b: anchor(y) }),
(None, None) => None,
}
}
/// Where the anchors between `chain`'s segments fall, as shares of its length.
fn fractions(chain: &[CubicBez]) -> Vec<f64> {
let total = length(chain);
let mut run = 0.0;
let inner = chain.iter().take(chain.len().saturating_sub(1));
inner
.map(|c| {
run += c.arclen(ACCURACY);
run / total
})
.collect()
}
/// The places (share of the length on each stretch) both stretches get an anchor: each one's own
/// anchors, where two nearly meet the pair of them, and otherwise the same share on the other.
fn shared_cuts(fa: &[f64], fb: &[f64]) -> Vec<(f64, f64)> {
let (mut i, mut j) = (0, 0);
let mut out = vec![];
loop {
match (fa.get(i), fb.get(j)) {
(Some(&x), Some(&y)) if (x - y).abs() <= SAME_PLACE => {
out.push((x, y));
i += 1;
j += 1;
}
(Some(&x), Some(&y)) if x < y => {
out.push((x, x));
i += 1;
}
(Some(&x), None) => {
out.push((x, x));
i += 1;
}
(_, Some(&y)) => {
out.push((y, y));
j += 1;
}
(None, None) => return out,
}
}
}
/// `chain` with a joint at each of the shares of its length `at`; those at a joint already add none.
fn cut(chain: &[CubicBez], mut at: Vec<f64>) -> Vec<CubicBez> {
at.sort_by(f64::total_cmp);
let total = length(chain);
let mut next = at.into_iter().peekable();
let mut run = 0.0;
let mut out = vec![];
for c in chain {
let len = c.arclen(ACCURACY);
let mut ts = vec![0.0];
while let Some(along) = next.peek().map(|f| f * total - run).filter(|d| *d < len - AT_END) {
next.next();
if along > AT_END {
ts.push(c.inv_arclen(along, ACCURACY));
}
}
ts.push(1.0);
out.extend(ts.windows(2).filter_map(|w| Some(c.subsegment(*w.first()?..*w.get(1)?))));
run += len;
}
out
}
/// The curve half way between `a` and `b`; a straight one has its handles on its anchors.
fn halfway(a: &CubicBez, b: &CubicBez) -> CubicBez {
let c = CubicBez::new(a.p0.midpoint(b.p0), a.p1.midpoint(b.p1), a.p2.midpoint(b.p2), a.p3.midpoint(b.p3));
if is_straight(&c, SAME_EDGE) { CubicBez::new(c.p0, c.p0, c.p3, c.p3) } else { c }
}
/// Bring the handles at `chain`'s joints in line where they all but are: averaging leaves a small
/// bend at what was a smooth anchor on both stretches.
fn align_joints(chain: &mut [CubicBez]) {
for k in 1..chain.len() {
let (before, after) = chain.split_at_mut(k);
let (Some(x), Some(y)) = (before.last_mut(), after.first_mut()) else { continue };
let (Some(i), Some(o)) = (unit(x.p3 - x.p2), unit(y.p1 - y.p0)) else { continue };
if i.dot(o) < MIN_JOINT_COS {
continue;
}
let Some(dir) = unit(i + o) else { continue };
x.p2 = x.p3 - dir * (x.p3 - x.p2).hypot();
y.p1 = y.p0 + dir * (y.p1 - y.p0).hypot();
}
}
/// The edge replacing stretches `a` and `b`, which run the same way. `None` when their anchors
/// can't be matched up.
fn merged_edge(a: &[CubicBez], b: &[CubicBez], follow: Follow) -> Option<Vec<CubicBez>> {
match follow {
Follow::First => return Some(a.to_vec()),
Follow::Second => return Some(b.to_vec()),
Follow::Average => {}
}
// A segment of no length has nothing to average.
let solid = |c: &[CubicBez]| -> Vec<CubicBez> { c.iter().filter(|s| s.arclen(ACCURACY) > TINY).copied().collect() };
let (a, b) = (solid(a), solid(b));
let cuts = shared_cuts(&fractions(&a), &fractions(&b));
let (pa, pb) = (cut(&a, cuts.iter().map(|c| c.0).collect()), cut(&b, cuts.iter().map(|c| c.1).collect()));
if pa.is_empty() || pa.len() != pb.len() {
return None;
}
let mut out: Vec<CubicBez> = pa.iter().zip(&pb).map(|(x, y)| halfway(x, y)).collect();
align_joints(&mut out);
Some(out)
}
fn in_line(a: &Anchor) -> bool {
match (unit(a.h_out - a.p), unit(a.p - a.h_in)) {
(Some(o), Some(i)) => o.cross(i).abs() <= 1e-6 && o.dot(i) > 0.0,
_ => false,
}
}
/// `sp` with `st` replaced by `chain`, which runs the way the merge does: the anchors inside are
/// `chain`'s joints and the two at the ends move to `chain`'s ends, their outer handles with them.
/// An end whose handles no longer line up turns into a corner.
fn splice(sp: &SubPath, st: Stretch, chain: &[CubicBez]) -> Option<SubPath> {
let own = backward(chain);
let chain = if st.reversed { own.as_slice() } else { chain };
let (first, last) = (chain.first()?, chain.last()?);
let mut out = sp.anchors.clone();
let start = st.start.min(out.len());
out.rotate_left(start);
let settle = |a: &mut Anchor| {
if a.kind == AnchorKind::Smooth && !in_line(a) {
a.kind = AnchorKind::Corner;
}
};
let a = out.first_mut()?;
a.translate(first.p0 - a.p);
a.h_out = first.p1;
settle(a);
let b = out.get_mut(st.segments)?;
b.translate(last.p3 - b.p);
b.h_in = last.p2;
settle(b);
let joints = chain.windows(2).filter_map(|w| match w {
[x, y] => {
let mut a = Anchor { p: y.p0, h_in: x.p2, h_out: y.p1, kind: AnchorKind::Corner };
if in_line(&a) {
a.kind = AnchorKind::Smooth;
}
Some(a)
}
_ => None,
});
out.splice(1..st.segments, joints);
// Back in its place; a stretch over the start of a closed subpath only shifts where it starts.
let start = start.min(out.len());
out.rotate_right(start);
Some(SubPath::new(out, sp.closed))
}
/// Where a side of an end is now.
fn side_at(path: &PathData, side: EndSide) -> Option<Point> {
match side {
EndSide::Anchor { subpath, anchor } => Some(path.subpaths.get(subpath)?.anchors.get(anchor)?.p),
EndSide::Insert { subpath, segment, t } => {
let sp = path.subpaths.get(subpath)?;
(segment < sp.segment_count()).then(|| sp.segment(segment).eval(t.clamp(0.0, 1.0)))
}
}
}
/// Where the two sides of an end meet: on the path followed; with neither, half way between two
/// anchors, or at the anchor when the other side is a place on a path.
fn meeting_point((a, sa): (Point, EndSide), (b, sb): (Point, EndSide), follow: Follow) -> Point {
match (follow, sa, sb) {
(Follow::First, ..) | (Follow::Average, EndSide::Anchor { .. }, EndSide::Insert { .. }) => a,
(Follow::Second, ..) | (Follow::Average, EndSide::Insert { .. }, EndSide::Anchor { .. }) => b,
_ => a.midpoint(b),
}
}
/// `path` with `side`'s anchor (added if it lacks one) at `to`.
fn put_side(path: &PathData, side: EndSide, to: Point) -> Option<PathData> {
let mut out = path.clone();
let anchor = match side {
EndSide::Anchor { subpath, anchor } => out.anchor_mut(subpath, anchor)?,
EndSide::Insert { subpath, segment, t } => {
let sp = out.subpaths.get_mut(subpath)?;
let (new, added) = insert_at(sp, &[(segment, t)]);
*sp = new;
sp.anchors.get_mut(*added.first()?)?
}
};
anchor.translate(to - anchor.p);
Some(out)
}
fn same_edge(x: &[CubicBez], y: &[CubicBez]) -> bool {
let near = |p: Point, q: Point| p.distance(q) <= SAME_EDGE;
x.len() == y.len() && x.iter().zip(y).all(|(c, d)| near(c.p0, d.p0) && near(c.p1, d.p1) && near(c.p2, d.p2) && near(c.p3, d.p3))
}
/// What `plan` would do to `a` and `b`, following `follow`.
pub fn merge_preview(a: &PathData, b: &PathData, plan: &MergePlan, follow: Follow) -> Option<MergePreview> {
match *plan {
MergePlan::Edge { a: sa, b: sb } => {
let (ca, cb) = (sa.chain(a)?, sb.chain(b)?);
let merged = merged_edge(&ca, &cb, follow)?;
let done = same_edge(&merged, &ca) && same_edge(&merged, &cb);
Some(MergePreview::Edge { a: ca, b: cb, merged, done })
}
MergePlan::Ends { a: ea, b: eb } => {
let (pa, pb) = (side_at(a, ea)?, side_at(b, eb)?);
Some(MergePreview::Ends { a: pa, b: pb, to: meeting_point((pa, ea), (pb, eb), follow) })
}
}
}
/// `a` and `b` as `plan` leaves them, following `follow`: each subpath stays closed or open, and
/// the stretch of the path followed doesn't change. `None` when the plan doesn't fit them.
pub fn apply_merge(a: &PathData, b: &PathData, plan: &MergePlan, follow: Follow) -> Option<(PathData, PathData)> {
match (*plan, merge_preview(a, b, plan, follow)?) {
(MergePlan::Edge { a: sa, b: sb }, MergePreview::Edge { merged, .. }) => {
let replaced = |path: &PathData, st: Stretch, keep: Follow| {
let mut out = path.clone();
if follow != keep {
let sp = out.subpaths.get_mut(st.subpath)?;
*sp = splice(sp, st, &merged)?;
}
Some(out)
};
Some((replaced(a, sa, Follow::First)?, replaced(b, sb, Follow::Second)?))
}
(MergePlan::Ends { a: ea, b: eb }, MergePreview::Ends { to, .. }) => Some((put_side(a, ea, to)?, put_side(b, eb, to)?)),
_ => None,
}
}
@@ -0,0 +1,270 @@
use kurbo::{ParamCurve, Point};
use vectorcraft_geom::{Anchor, PathData, SubPath};
use super::*;
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
/// A closed shape above y = 0 whose lower edge runs right to left from (100, 0) to (0, 0), curved
/// down by `bulge` control-point height.
fn above(bulge: f64) -> SubPath {
SubPath::new(
vec![
Anchor::corner(pt(0.0, -50.0)),
Anchor::corner(pt(100.0, -50.0)),
Anchor::with_handles(pt(100.0, 0.0), pt(100.0, 0.0), pt(70.0, bulge)),
Anchor::with_handles(pt(0.0, 0.0), pt(30.0, bulge), pt(0.0, 0.0)),
],
true,
)
}
/// A closed shape below y = 0 whose upper edge runs left to right, curved up by `bulge`.
fn below(bulge: f64) -> SubPath {
SubPath::new(
vec![
Anchor::with_handles(pt(0.0, 0.0), pt(0.0, 0.0), pt(30.0, -bulge)),
Anchor::with_handles(pt(100.0, 0.0), pt(70.0, -bulge), pt(100.0, 0.0)),
Anchor::corner(pt(100.0, 50.0)),
Anchor::corner(pt(0.0, 50.0)),
],
true,
)
}
fn single(sp: SubPath) -> PathData {
PathData::single(sp)
}
/// The plan at `at` with a 20 pt brush.
fn plan(a: &PathData, b: &PathData, at: Point) -> MergePlan {
stretches_at(a, b, at, 10.0, COINCIDENCE).expect("a stretch")
}
/// The outline of `chain` run one way, sampled.
fn samples(chain: &[CubicBez]) -> Vec<Point> {
let n = chain.len() * 16;
let at = |k: usize| {
let f = k as f64 / n as f64 * chain.len() as f64;
let i = (f.floor() as usize).min(chain.len() - 1);
chain[i].eval(f - i as f64)
};
(0..=n).map(at).collect()
}
/// The edge as `path` now has it between anchors `from` (inclusive) and `to`, along the path.
fn edge(path: &PathData, from: usize, segments: usize) -> Vec<CubicBez> {
let sp = &path.subpaths[0];
(0..segments).map(|k| sp.segment((from + k) % sp.segment_count())).collect()
}
/// The farthest apart two chains are, sampled alike.
fn apart(x: &[CubicBez], y: &[CubicBez]) -> f64 {
samples(x).iter().zip(samples(y)).map(|(p, q)| p.distance(q)).fold(0.0, f64::max)
}
#[test]
fn the_merged_edge_lies_half_way_and_both_paths_keep_their_topology() {
let (a, b) = (single(above(6.0)), single(below(2.0)));
let plan = plan(&a, &b, pt(50.0, 0.0));
assert_eq!(
plan,
MergePlan::Edge {
a: Stretch { subpath: 0, start: 2, segments: 1, reversed: false },
b: Stretch { subpath: 0, start: 0, segments: 1, reversed: true },
}
);
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
assert!(na.subpaths[0].closed && nb.subpaths[0].closed);
assert_eq!((na.anchor_count(), nb.anchor_count()), (4, 4));
// Everything else is where it was.
assert_eq!((na.subpaths[0].anchors[0], na.subpaths[0].anchors[1]), (a.subpaths[0].anchors[0], a.subpaths[0].anchors[1]));
assert_eq!((nb.subpaths[0].anchors[2], nb.subpaths[0].anchors[3]), (b.subpaths[0].anchors[2], b.subpaths[0].anchors[3]));
// One edge, run opposite ways by the two paths, between the two it replaced.
let (ea, eb) = (edge(&na, 2, 1), edge(&nb, 0, 1));
assert!(apart(&ea, &backward(&eb)) < 1e-9);
let (upper, lower) = (a.subpaths[0].segment(2), b.subpaths[0].segment(0));
for k in 0..=16 {
let t = k as f64 / 16.0;
let (p, u, l) = (ea[0].eval(t), upper.eval(t), lower.eval(1.0 - t));
assert!(p.distance(u.midpoint(l)) < 1e-9, "{p:?} is half way between {u:?} and {l:?}");
assert!(l.y - 1e-9 <= p.y && p.y <= u.y + 1e-9);
}
// Merged: there is nothing left to do, and it says so.
let again = stretches_at(&na, &nb, pt(50.0, 0.0), 10.0, COINCIDENCE).unwrap();
assert!(matches!(merge_preview(&na, &nb, &again, Follow::Average), Some(MergePreview::Edge { done: true, .. })));
let (ma, mb) = apply_merge(&na, &nb, &again, Follow::Average).unwrap();
assert!(apart(&edge(&ma, 2, 1), &edge(&na, 2, 1)) < 1e-9 && apart(&edge(&mb, 0, 1), &edge(&nb, 0, 1)) < 1e-9);
}
#[test]
fn following_a_path_keeps_it_and_the_other_takes_its_edge() {
let (a, b) = (single(above(6.0)), single(below(2.0)));
let plan = plan(&a, &b, pt(50.0, 0.0));
let (na, nb) = apply_merge(&a, &b, &plan, Follow::First).unwrap();
assert_eq!(na, a);
assert!(apart(&edge(&nb, 0, 1), &backward(&edge(&a, 2, 1))) < 1e-9);
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Second).unwrap();
assert_eq!(nb, b);
assert!(apart(&edge(&na, 2, 1), &backward(&edge(&b, 0, 1))) < 1e-9);
}
#[test]
fn stretches_of_different_anchor_counts_get_the_same_anchors() {
let (with_two, _) = insert_at(&above(6.0), &[(2, 0.3)]);
assert_eq!(with_two.anchors.len(), 5);
let (a, b) = (single(with_two), single(below(2.0)));
let plan = plan(&a, &b, pt(50.0, 0.0));
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
assert_eq!((na.anchor_count(), nb.anchor_count()), (5, 5));
let (ea, eb) = (edge(&na, 2, 2), edge(&nb, 0, 2));
assert!(apart(&ea, &backward(&eb)) < 1e-9);
assert_eq!(ea[0].p3, eb[0].p3);
// The anchors were smooth on the one and a point of a curve on the other: the joint stays smooth.
assert_eq!(na.subpaths[0].anchors[3].kind, AnchorKind::Smooth);
// Anchors at nearly the same place stand for one.
let (b2, _) = insert_at(&below(2.0), &[(0, 0.7)]);
let (a2, b2) = (a.clone(), single(b2));
let (na, nb) = apply_merge(&a2, &b2, &stretches_at(&a2, &b2, pt(50.0, 0.0), 10.0, COINCIDENCE).unwrap(), Follow::Average).unwrap();
assert_eq!((na.anchor_count(), nb.anchor_count()), (5, 5), "one anchor inside, not two");
}
#[test]
fn open_paths_stay_open_and_keep_what_lies_beyond_the_stretch() {
let a = single(SubPath::polyline(&[pt(-50.0, 0.0), pt(0.0, 0.0), pt(50.0, 4.0), pt(100.0, 0.0), pt(150.0, 40.0)], false));
let b = single(SubPath::polyline(&[pt(100.0, 0.0), pt(50.0, -4.0), pt(0.0, 0.0), pt(-20.0, 60.0)], false));
let plan = plan(&a, &b, pt(25.0, 0.0));
assert_eq!(
plan,
MergePlan::Edge {
a: Stretch { subpath: 0, start: 1, segments: 2, reversed: false },
b: Stretch { subpath: 0, start: 0, segments: 2, reversed: true },
}
);
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
assert!(!na.subpaths[0].closed && !nb.subpaths[0].closed);
let (pa, pb) = (&na.subpaths[0].anchors, &nb.subpaths[0].anchors);
assert_eq!(pa.iter().map(|x| x.p).collect::<Vec<_>>(), [pt(-50.0, 0.0), pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0), pt(150.0, 40.0)]);
assert_eq!(pb.iter().map(|x| x.p).collect::<Vec<_>>(), [pt(100.0, 0.0), pt(50.0, 0.0), pt(0.0, 0.0), pt(-20.0, 60.0)]);
}
#[test]
fn a_stretch_over_the_start_of_a_closed_path_stays_closed_and_whole() {
// The edge along y = 0 runs from anchor 3 over anchor 0, which has no partner, to anchor 1.
let a = single(SubPath::polyline(&[pt(50.0, 1.0), pt(100.0, 0.0), pt(100.0, -50.0), pt(0.0, 0.0)], true));
let b = single(SubPath::polyline(&[pt(100.0, 0.0), pt(0.0, 0.0), pt(0.0, 50.0), pt(100.0, 50.0)], true));
let plan = plan(&a, &b, pt(25.0, 0.5));
let MergePlan::Edge { a: sa, .. } = plan else { panic!("{plan:?}") };
assert_eq!((sa.start, sa.segments), (3, 2));
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
assert!(na.subpaths[0].closed && nb.subpaths[0].closed);
assert_eq!((na.anchor_count(), nb.anchor_count()), (4, 5));
// Every anchor of the new outlines is where one was, or on the merged edge at y 0.5.
let at = |path: &PathData, p: Point| path.anchors().any(|(_, _, x)| x.p.distance(p) < 1e-4);
assert!(at(&na, pt(100.0, -50.0)) && at(&nb, pt(0.0, 50.0)) && at(&nb, pt(100.0, 50.0)));
assert!(at(&na, pt(50.0, 0.5)) && at(&nb, pt(50.0, 0.5)), "{na:?}");
}
#[test]
fn the_stretch_under_the_brush_is_the_one_along_the_shared_edge_not_the_long_way_round() {
let square = |x: f64| single(SubPath::polyline(&[pt(x, 0.0), pt(x + 100.0, 0.0), pt(x + 100.0, 100.0), pt(x, 100.0)], true));
let (a, b) = (square(0.0), square(100.0));
// Above the corner, each path's top edge is nearest: their long ways round face each other.
let MergePlan::Edge { a: sa, b: sb } = plan(&a, &b, pt(99.0, -2.0)) else { panic!() };
assert_eq!((sa.start, sa.segments), (1, 1));
assert_eq!((sb.start, sb.segments), (3, 1));
}
#[test]
fn stretches_too_unlike_or_without_two_coincident_ends_are_left_alone() {
let (a, b) = (single(above(6.0)), single(below(2.0)));
assert_eq!(stretches_at(&a, &b, pt(50.0, 40.0), 10.0, COINCIDENCE), None, "the brush doesn't touch both");
// One coincident anchor only.
let c = single(SubPath::polyline(&[pt(0.0, 0.0), pt(101.0, 0.0)], false));
assert_eq!(stretches_at(&a, &c, pt(50.0, 0.0), 10.0, COINCIDENCE), None);
// Coincident within a wider tolerance.
assert!(stretches_at(&a, &c, pt(50.0, 2.0), 10.0, 1.5).is_some());
// One side twice as long as the other, both ends coincident.
let hump = single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 5.0), pt(50.0, 60.0), pt(100.0, 0.0)], false));
let flat = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false));
assert_eq!(stretches_at(&hump, &flat, pt(25.0, 1.0), 10.0, COINCIDENCE), None);
// Past the limits, or not finite.
let mut bad = a.clone();
bad.subpaths[0].anchors[0].p.x = f64::NAN;
assert_eq!(stretches_at(&bad, &b, pt(50.0, 0.0), 10.0, COINCIDENCE), None);
assert_eq!(ends_at(&bad, &b, pt(50.0, 0.0), 10.0), None);
assert_eq!(stretches_at(&a, &b, pt(f64::NAN, 0.0), 10.0, COINCIDENCE), None);
assert_eq!(stretches_at(&PathData::default(), &b, pt(50.0, 0.0), 10.0, COINCIDENCE), None);
assert_eq!(ends_at(&PathData::default(), &PathData::default(), pt(50.0, 0.0), 10.0), None);
}
#[test]
fn near_anchors_meet_half_way_or_on_the_path_followed() {
let a = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false));
let b = single(SubPath::polyline(&[pt(104.0, 2.0), pt(200.0, 0.0)], false));
let plan = ends_at(&a, &b, pt(102.0, 1.0), 10.0).unwrap();
let ends = |f| {
let (na, nb) = apply_merge(&a, &b, &plan, f).unwrap();
(na.subpaths[0].anchors[1].p, nb.subpaths[0].anchors[0].p)
};
assert_eq!(ends(Follow::Average), (pt(102.0, 1.0), pt(102.0, 1.0)));
assert_eq!(ends(Follow::First), (pt(100.0, 0.0), pt(100.0, 0.0)));
assert_eq!(ends(Follow::Second), (pt(104.0, 2.0), pt(104.0, 2.0)));
assert_eq!(merge_preview(&a, &b, &plan, Follow::Average), Some(MergePreview::Ends { a: pt(100.0, 0.0), b: pt(104.0, 2.0), to: pt(102.0, 1.0) }));
// Together already: nothing to do.
let c = single(SubPath::polyline(&[pt(100.0, 0.0), pt(100.0, 90.0)], false));
assert_eq!(ends_at(&a, &c, pt(100.0, 1.0), 10.0), None);
// Nothing there.
assert_eq!(ends_at(&a, &b, pt(50.0, 1.0), 10.0), None);
}
#[test]
fn an_anchor_one_path_lacks_is_added_to_match() {
let a = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 2.0), pt(100.0, 90.0)], false));
let b = single(SubPath::polyline(&[pt(0.0, 0.0), pt(200.0, 0.0)], false));
let plan = ends_at(&a, &b, pt(100.0, 1.0), 10.0).unwrap();
assert!(matches!(plan, MergePlan::Ends { a: EndSide::Anchor { anchor: 1, .. }, b: EndSide::Insert { .. } }), "{plan:?}");
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
assert_eq!(na, a, "the anchor stays");
assert_eq!(nb.anchor_count(), 3);
assert!(nb.subpaths[0].anchors[1].p.distance(pt(100.0, 2.0)) < 1e-9);
// Following the path that lacks it, the anchor comes to it.
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Second).unwrap();
assert!(na.subpaths[0].anchors[1].p.distance(pt(100.0, 0.0)) < 1e-6);
assert!(nb.subpaths[0].anchors[1].p.distance(na.subpaths[0].anchors[1].p) < 1e-9);
// The same the other way round.
let swapped = ends_at(&b, &a, pt(100.0, 1.0), 10.0).unwrap();
let (nb, na) = apply_merge(&b, &a, &swapped, Follow::Average).unwrap();
assert_eq!((na.anchor_count(), nb.anchor_count()), (3, 3));
// With both paths' ends made, the stretch between can merge.
let c = single(SubPath::polyline(&[pt(100.0, 2.0), pt(100.0, 90.0)], false));
let d = single(SubPath::polyline(&[pt(95.0, 2.0), pt(95.0, 90.0)], false));
assert_eq!(stretches_at(&c, &d, pt(97.0, 50.0), 10.0, COINCIDENCE), None);
let first = ends_at(&c, &d, pt(97.0, 3.0), 10.0).unwrap();
let (c, d) = apply_merge(&c, &d, &first, Follow::Average).unwrap();
let second = ends_at(&c, &d, pt(97.0, 89.0), 10.0).unwrap();
let (c, d) = apply_merge(&c, &d, &second, Follow::Average).unwrap();
assert!(stretches_at(&c, &d, pt(97.5, 50.0), 10.0, COINCIDENCE).is_some());
}
#[test]
fn a_smooth_end_whose_handles_part_turns_into_a_corner() {
let a = single(SubPath::new(
vec![Anchor::smooth(pt(0.0, 0.0), pt(30.0, 10.0)), Anchor::with_handles(pt(100.0, 0.0), pt(70.0, 10.0), pt(100.0, 0.0))],
true,
));
let b = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false));
let plan = plan(&a, &b, pt(50.0, 3.0));
let (na, _) = apply_merge(&a, &b, &plan, Follow::Second).unwrap();
assert_eq!(na.subpaths[0].anchors[0].kind, AnchorKind::Corner);
}
#[test]
fn follow_names_round_trip() {
for f in Follow::ALL {
assert_eq!(Follow::parse(f.id()), Some(f));
}
assert_eq!(Follow::parse("up"), None);
}
+3
View File
@@ -7,6 +7,7 @@ pub mod corner_tool;
pub mod extend_path;
pub mod measure_inspect;
pub mod parametric_shape;
pub mod shared_edge;
pub mod slide_anchor;
pub mod smart_pen;
@@ -20,6 +21,7 @@ pub const PATH_TOOLS: &[ToolInfo] = &[
ToolInfo { id: parametric_shape::ID, label: "Parametric Shape Tool", shortcut: None, icon: "hexagon" },
ToolInfo { id: corner_tool::ID, label: "Corner Tool", shortcut: None, icon: "dc-rounded-rect" },
ToolInfo { id: slide_anchor::ID, label: "Slide Anchor Along Path", shortcut: None, icon: "dc-anchor" },
ToolInfo { id: shared_edge::ID, label: "Shared Edge Merge Tool", shortcut: None, icon: "dc-join" },
];
/// Smart Pen's tool, a flyout group of its own.
@@ -45,6 +47,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
extend_path::ID => Box::new(extend_path::ExtendPathTool::default()),
measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()),
parametric_shape::ID => Box::new(parametric_shape::ParametricShapeTool::default()),
shared_edge::ID => Box::new(shared_edge::SharedEdgeTool::default()),
slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()),
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
_ => return None,
+349
View File
@@ -0,0 +1,349 @@
//! Shared Edge Merge Tool (Path Precision Tools): where two paths, selected or not, touch at two
//! coincident anchors, hovering with the round brush shows the two stretches between them (dashed)
//! and the edge merging them (solid, green when they are merged already), and a click replaces both
//! with that edge, half way between them (`addon.pathtools.mergeEdge`, one undo step). Shift follows
//! the upper path, Alt the lower one. Where the anchors aren't there yet, a click makes one end:
//! near anchors meet half way, and an anchor one path lacks is added to match. Shift+Alt asks for
//! an end inside a stretch that could merge.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use vectorcraft_pathops::addons::pathtools::{Follow, MergePlan, MergePreview, ends_at, merge_preview, stretches_at};
use crate::builder::HIGHLIGHT_RED;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub use vectorcraft_pathops::addons::pathtools::{COINCIDENCE, MAX_COINCIDENCE};
/// The tool's id.
pub const ID: &str = "sharedEdge";
/// The brush's diameter by default and its range (pt).
pub const DIAMETER: f64 = 20.0;
pub const DIAMETER_RANGE: (f64, f64) = (0.25, 200.0);
/// Most paths under the brush compared with one another.
const MAX_PATHS: usize = 6;
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
const GREEN: [u8; 3] = [0x2e, 0xb8, 0x6a];
const GREY: [u8; 3] = [0x80, 0x80, 0x80];
const EDGE_PX: f32 = 2.0;
const ANCHOR_PX: f32 = 7.0;
/// What the brush is over.
#[derive(Clone, Debug, PartialEq)]
struct Target {
/// The upper path.
a: NodeId,
/// The lower path.
b: NodeId,
at: Point,
follow: Follow,
/// Shift+Alt: an end, even where the stretch could merge.
ends: bool,
preview: MergePreview,
/// The document it was found in.
revision: (u64, u64),
}
pub struct SharedEdgeTool {
diameter: f64,
tolerance: f64,
together: bool,
at: Option<Point>,
target: Option<Target>,
pressed: bool,
}
impl Default for SharedEdgeTool {
fn default() -> Self {
Self { diameter: DIAMETER, tolerance: COINCIDENCE, together: false, at: None, target: None, pressed: false }
}
}
/// The editable paths the brush touches, nearest first: (paint order, id).
fn paths_under(cx: &ToolContext, at: Point, radius: f64) -> Vec<(usize, NodeId)> {
let editable = cx.doc.editable();
let mut order = 0;
let mut out: Vec<(f64, usize, NodeId)> = vec![];
cx.doc.walk(|n| {
order += 1;
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
if path.control_bounds().is_none_or(|r| !r.inflate(radius, radius).contains(at)) {
return;
}
let d = path.nearest(at).map_or(f64::INFINITY, |(.., q, _)| q.distance(at));
if d <= radius && editable(n.id) {
out.push((d, order, n.id));
}
});
out.sort_by(|x, y| x.0.total_cmp(&y.0));
out.truncate(MAX_PATHS);
out.into_iter().map(|(_, order, id)| (order, id)).collect()
}
impl SharedEdgeTool {
/// What a click at `at` with `mods` would do, on the nearest paths that it can.
fn look(&self, cx: &ToolContext, at: Point, mods: Mods) -> Option<Target> {
let radius = self.diameter / 2.0;
let path = |id: NodeId| cx.doc.node(id).and_then(|n| n.path_data());
let under = paths_under(cx, at, radius);
let mut pairs: Vec<(NodeId, NodeId)> = vec![];
for (i, x) in under.iter().enumerate() {
pairs.extend(under.iter().skip(i + 1).map(|y| if x.0 > y.0 { (x.1, y.1) } else { (y.1, x.1) }));
}
let find = |f: &dyn Fn(NodeId, NodeId) -> Option<MergePlan>| pairs.iter().find_map(|&(a, b)| Some((a, b, f(a, b)?)));
let ends = mods.shift && mods.alt;
let edge = if ends { None } else { find(&|a, b| stretches_at(path(a)?, path(b)?, at, radius, self.tolerance)) };
let (a, b, plan) = edge.or_else(|| find(&|a, b| ends_at(path(a)?, path(b)?, at, radius)))?;
let follow = match (mods.shift, mods.alt) {
(true, false) => Follow::First,
(false, true) => Follow::Second,
_ => Follow::Average,
};
let preview = merge_preview(path(a)?, path(b)?, &plan, follow)?;
Some(Target { a, b, at, follow, ends, preview, revision: cx.revision })
}
fn params(&self, t: &Target) -> Value {
json!({
"a": t.a.0,
"b": t.b.0,
"at": [t.at.x, t.at.y],
"diameter": self.diameter,
"tolerance": self.tolerance,
"follow": t.follow.id(),
"ends": t.ends,
})
}
}
fn outline(chain: &[kurbo::CubicBez]) -> BezPath {
let mut path = BezPath::new();
if let Some(c) = chain.first() {
path.move_to(c.p0);
}
for c in chain {
path.curve_to(c.p1, c.p2, c.p3);
}
path
}
impl Tool for SharedEdgeTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.pressed
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
self.at = Some(ev.pos);
self.target = self.look(cx, ev.pos, ev.mods);
match ev.kind {
PointerKind::Down => self.pressed = true,
PointerKind::Up if std::mem::take(&mut self.pressed) => {
let target = self.target.take();
let todo = target.filter(|t| !matches!(t.preview, MergePreview::Edge { done: true, .. }));
return todo.map_or_else(Vec::new, |t| vec![Action::Exec("addon.pathtools.mergeEdge".into(), self.params(&t))]);
}
_ => {}
}
vec![]
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape {
self.pressed = false;
}
vec![]
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
self.pressed && key == ToolKey::Escape
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.at = None;
self.target = None;
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(at) = self.at else { return vec![] };
let r = self.diameter / 2.0;
let ring = kurbo::Shape::to_path(&kurbo::Circle::new(at, r), 0.1 * r);
let mut out = vec![Overlay::Path { path: ring, color: GREY, width: 1.0, dashed: false }];
let curve = |chain: &[kurbo::CubicBez], color, dashed| Overlay::Path { path: outline(chain), color, width: EDGE_PX, dashed };
let anchor = |p, color, filled| Overlay::Anchor { p, color, filled, size: ANCHOR_PX };
match self.target.as_ref().filter(|t| t.revision == cx.revision).map(|t| &t.preview) {
Some(MergePreview::Edge { merged, done: true, .. }) => out.push(curve(merged, GREEN, false)),
Some(MergePreview::Edge { a, b, merged, .. }) => {
out.extend([curve(a, BLUE, true), curve(b, BLUE, true), curve(merged, HIGHLIGHT_RED, false)])
}
Some(MergePreview::Ends { a, b, to }) => out.extend([anchor(*a, BLUE, false), anchor(*b, BLUE, false), anchor(*to, HIGHLIGHT_RED, true)]),
None => {}
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::SharedEdge
}
fn options(&self) -> Value {
json!({ "diameter": self.diameter, "tolerance": self.tolerance, "together": self.together })
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"diameter" => {
if let Some(d) = v.as_f64().filter(|d| (DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(d)) {
self.diameter = d;
}
}
"tolerance" => {
if let Some(t) = v.as_f64().filter(|t| (0.0..=MAX_COINCIDENCE).contains(t)) {
self.tolerance = t;
}
}
"together" => {
if let Some(on) = v.as_bool() {
self.together = on;
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Document, Node, Selection};
use vectorcraft_geom::{PathData, SubPath};
/// A lower and an upper square sharing the edge x = 100 from y = 0 to y = 100; `gap` pushes the
/// upper one right. → (the document, the lower one, the upper one).
fn doc_with_squares(gap: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let (lower, upper) = (d.alloc_id(), d.alloc_id());
let square = |x: f64| [(x, 0.0), (x + 100.0, 0.0), (x + 100.0, 100.0), (x, 100.0)].map(|(x, y)| Point::new(x, y));
for (i, (id, x)) in [(lower, 0.0), (upper, 100.0 + gap)].into_iter().enumerate() {
d.insert(Some(l), i, Node::path(id, PathData::single(SubPath::polyline(&square(x), true)), Appearance::default_art())).unwrap();
}
(d, lower, upper)
}
fn ev(kind: PointerKind, x: f64, y: f64, shift: bool, alt: bool) -> PointerEvent {
let mut e = PointerEvent::new(kind, x, y);
(e.mods.shift, e.mods.alt) = (shift, alt);
e
}
/// The params of a click at (x, y).
fn click(t: &mut SharedEdgeTool, cx: &ToolContext, x: f64, y: f64, shift: bool, alt: bool) -> Option<Value> {
t.pointer(cx, &ev(PointerKind::Down, x, y, shift, alt));
match t.pointer(cx, &ev(PointerKind::Up, x, y, shift, alt)).as_slice() {
[Action::Exec(cmd, p)] => {
assert_eq!(cmd, "addon.pathtools.mergeEdge");
Some(p.clone())
}
[] => None,
other => panic!("{other:?}"),
}
}
#[test]
fn an_edge_merged_already_shows_green_and_a_click_leaves_it() {
let (d, ..) = doc_with_squares(0.0);
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SharedEdgeTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Move, 100.0, 50.0, false, false)).is_empty());
// They are one edge already: it shows green and a click has nothing to do.
let shows = t.overlays(&cx);
assert!(matches!(shows.as_slice(), [Overlay::Path { color: GREY, .. }, Overlay::Path { color: GREEN, dashed: false, .. }]), "{shows:?}");
assert_eq!(click(&mut t, &cx, 100.0, 50.0, false, false), None);
assert!(!t.busy());
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::SharedEdge);
}
#[test]
fn a_click_averages_the_stretches_and_shift_and_alt_follow_a_path() {
// The stretches differ, their ends coincident within the tolerance set.
let (d, lower, upper) = doc_with_squares(1.0);
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SharedEdgeTool::default();
t.set_option("tolerance", &json!(3));
t.pointer(&cx, &ev(PointerKind::Move, 100.5, 50.0, false, false));
let shows = t.overlays(&cx);
assert!(
matches!(
shows.as_slice(),
[
_,
Overlay::Path { color: BLUE, dashed: true, .. },
Overlay::Path { color: BLUE, dashed: true, .. },
Overlay::Path { dashed: false, .. }
]
),
"{shows:?}"
);
let plain = click(&mut t, &cx, 100.5, 50.0, false, false).expect("a merge");
assert_eq!((plain["a"].clone(), plain["b"].clone(), plain["follow"].clone()), (json!(upper.0), json!(lower.0), json!("average")));
assert_eq!((plain["at"].clone(), plain["ends"].clone(), plain["tolerance"].clone()), (json!([100.5, 50.0]), json!(false), json!(3.0)));
assert_eq!(click(&mut t, &cx, 100.5, 50.0, true, false).expect("a merge")["follow"], json!("a"));
assert_eq!(click(&mut t, &cx, 100.5, 50.0, false, true).expect("a merge")["follow"], json!("b"));
// Shift+Alt: an end, inside the stretch.
let end = click(&mut t, &cx, 100.5, 2.0, true, true).expect("an end");
assert_eq!((end["ends"].clone(), end["follow"].clone()), (json!(true), json!("average")));
}
#[test]
fn near_anchors_make_an_end_and_nothing_near_does_nothing() {
let (d, ..) = doc_with_squares(5.0);
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SharedEdgeTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 102.0, 1.0, false, false));
let shows = t.overlays(&cx);
assert!(matches!(shows.as_slice(), [_, Overlay::Anchor { .. }, Overlay::Anchor { .. }, Overlay::Anchor { filled: true, .. }]), "{shows:?}");
assert_eq!(click(&mut t, &cx, 102.0, 1.0, false, false).expect("an end")["ends"], json!(false));
// One path alone, and empty canvas.
assert_eq!(click(&mut t, &cx, 0.0, 50.0, false, false), None);
assert_eq!(click(&mut t, &cx, 300.0, 300.0, false, false), None);
}
#[test]
fn locked_paths_are_left_alone_and_escape_drops_a_press() {
let (mut d, _, upper) = doc_with_squares(5.0);
d.node_mut(upper).unwrap().locked = true;
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SharedEdgeTool::default();
assert_eq!(click(&mut t, &cx, 102.0, 1.0, false, false), None);
t.pointer(&cx, &ev(PointerKind::Down, 102.0, 1.0, false, false));
assert!(t.busy() && t.claims_key(&cx, ToolKey::Escape));
assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty());
assert!(!t.busy());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 102.0, 1.0, false, false)).is_empty());
}
#[test]
fn options_stay_in_range() {
let mut t = SharedEdgeTool::default();
for (k, v) in
[("diameter", json!(1000)), ("diameter", json!(0.1)), ("tolerance", json!(4)), ("tolerance", json!("x")), ("together", json!(1))]
{
t.set_option(k, &v);
}
assert_eq!(t.options(), json!({ "diameter": DIAMETER, "tolerance": COINCIDENCE, "together": false }));
t.set_option("diameter", &json!(40));
t.set_option("tolerance", &json!(0));
t.set_option("together", &json!(true));
assert_eq!(t.options(), json!({ "diameter": 40.0, "tolerance": 0.0, "together": true }));
}
}
+6 -1
View File
@@ -186,6 +186,9 @@ pub enum Cursor {
/// The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag runs an anchor along
/// the path.
SlideAnchor,
/// The Shared Edge Merge Tool (Path Precision Tools add-on): a click merges the two paths'
/// touching edges under the brush, or makes an end there.
SharedEdge,
/// The Smart Pen Tool (Smart Pen add-on) away from paths, where a click starts a new path (over
/// a path or while drawing it shows the Pen's and Direct Selection's cursors).
SmartPen,
@@ -286,6 +289,7 @@ impl Cursor {
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor
| Cursor::SharedEdge
| Cursor::SmartPen => Cursor::Crosshair,
c => c,
}
@@ -326,7 +330,8 @@ impl Cursor {
| Cursor::MeasureInspect
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor => Some(Cursor::Crosshair),
| Cursor::SlideAnchor
| Cursor::SharedEdge => Some(Cursor::Crosshair),
Cursor::TypeVertical => Some(Cursor::Text),
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
Cursor::SmartPen => Some(Cursor::Pen),
+6
View File
@@ -714,6 +714,12 @@ group = "Drawing and editing tools"
shown_when = ["The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag moves an anchor along the curve it sits on."]
hotspot = "Exact: the anchor being dragged follows it along the path."
[[cursor]]
id = "SharedEdge"
group = "Drawing and editing tools"
shown_when = ["The Shared Edge Merge Tool (Path Precision Tools add-on): a click merges the touching edges of two paths under the round brush, or brings their anchors together there."]
hotspot = "Exact: the centre of the brush, drawn by the app as a circle around the cursor."
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
# The brush size is drawn by the app as a circle around the cursor.
+1
View File
@@ -70,6 +70,7 @@ const ROWS: &[Row] = &[
(&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]),
(&[crate::addons::extend_path::ID], None, &["mode"]),
(&[crate::addons::corner_tool::ID], None, &["radius", "kind"]),
(&[crate::addons::shared_edge::ID], None, &["diameter", "tolerance", "together"]),
(&[crate::addons::smart_pen::ID], None, &["next"]),
// The kind first: a new kind resets the values after it.
(&[crate::addons::parametric_shape::ID], None, &["kind", "count", "a", "b", "radius", "width", "height", "rotation"]),
+8 -3
View File
@@ -1,12 +1,14 @@
//! Path Precision Tools in the interface: its submenu (Object › Plug-ins › Path Precision Tools),
//! which selects its tools, opens its panels and runs Split / Add Points at Crossings and Convert
//! to Parametric Shape; the Anchor Inspector panel (the selected anchor's position and handles as
//! numbers, the add-on's anchor edits and the Corner Tool's values, [`corner`]); the Parametric
//! Shape panel ([`shape`]); and its tools' options dialog (a double-click on the tool). Everything
//! acts through `addon.pathtools.*` and `tool.setOption`.
//! numbers, the add-on's anchor edits, the Corner Tool's values, [`corner`], and the Shared Edge
//! Merge Tool's, [`shared_edge`]); the Parametric Shape panel ([`shape`]); and its tools' options
//! dialog (a double-click on the tool). Everything acts through `addon.pathtools.*` and
//! `tool.setOption`.
pub mod corner;
pub mod shape;
pub mod shared_edge;
use egui::Ui;
use serde_json::{Map, Value, json};
@@ -54,6 +56,7 @@ pub fn menu() -> Vec<Item> {
tool("Parametric Shape Tool", parametric_shape::ID),
Item::Cmd("Corner Tool", corner::COMMAND, json!({})),
tool("Slide Anchor Along Path", slide_anchor::ID),
Item::Cmd("Shared Edge Merge Tool", shared_edge::COMMAND, json!({})),
Item::Sep,
Item::Cmd("Anchor Inspector", "window.panel", json!({ "panel": ID })),
Item::Cmd("Parametric Shape", "window.panel", json!({ "panel": shape::ID })),
@@ -173,6 +176,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
widgets::divider(ui);
corner::section(app, ui);
widgets::divider(ui);
shared_edge::section(app, ui);
}
// ---------- the tools' options ----------
@@ -0,0 +1,46 @@
//! The Anchor Inspector panel's Shared Edges section: the Shared Edge Merge Tool's brush and
//! coincidence, and Keep Shared Points Together.
use egui::Ui;
use serde_json::{Value, json};
use vectorcraft_tools::addons::shared_edge::{COINCIDENCE, DIAMETER, DIAMETER_RANGE, ID, MAX_COINCIDENCE};
use super::{LABEL_W, run};
use crate::VectorcraftApp;
use crate::widgets;
/// The menu's command: the tool, with the panel its values are in.
pub const COMMAND: &str = "addon.pathtools.sharedEdgeTool";
const FIELD_W: f32 = 96.0;
/// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
app.select_tool(ID);
app.run("window.panel", json!({ "panel": super::ID }))
}
pub(super) fn section(app: &mut VectorcraftApp, ui: &mut Ui) {
let options = app.session.tool_options_of(ID);
let diameter = options["diameter"].as_f64().unwrap_or(DIAMETER);
let tolerance = options["tolerance"].as_f64().unwrap_or(COINCIDENCE);
let together = options["together"].as_bool().unwrap_or(false);
widgets::subheader(ui, "Shared Edges");
let mut set: Option<Value> = None;
widgets::label_row(ui, tl!("Diameter:"), LABEL_W, |ui| {
if let Some(v) = widgets::range_field(ui, "shared-edge-diameter", diameter, DIAMETER_RANGE.0..=DIAMETER_RANGE.1, "pt", 2, FIELD_W) {
set = Some(json!({ "diameter": v }));
}
});
widgets::label_row(ui, tl!("Coincidence:"), LABEL_W, |ui| {
if let Some(v) = widgets::range_field(ui, "shared-edge-tolerance", tolerance, 0.0..=MAX_COINCIDENCE, "pt", 3, FIELD_W) {
set = Some(json!({ "tolerance": v }));
}
});
let toggled = widgets::check(ui, "Keep Shared Points Together", together, true);
if let Some(values) = set {
run(app, "tool.setOption", json!({ "tool": ID, "values": values }));
}
if toggled {
run(app, "addon.pathtools.movePointsTogether", json!({ "enabled": !together }));
}
}
@@ -132,6 +132,26 @@ fn the_corner_tool_item_selects_the_tool_and_opens_its_panel_section() {
assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "cornerTool" || t.id == "slideAnchor"));
}
#[test]
fn the_shared_edge_item_selects_the_tool_and_opens_its_panel_section() {
let mut app = app();
let entries = menus::menu_entries(&app);
let e = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"] && e.label == "Shared Edge Merge Tool").expect("listed");
assert!(!crate::panel_docking::showing(&app.ui, ID));
menus::invoke(&mut app, e.command.as_deref().unwrap(), e.params.clone());
assert_eq!(app.session.tool_id(), "sharedEdge");
assert!(crate::panel_docking::showing(&app.ui, ID), "the panel opens");
// Neither toolbar shows it and it has no shortcut; the All Tools list does.
let tool = vectorcraft_tools::catalog::all_tools().find(|t| t.id == "sharedEdge").expect("in All Tools");
assert!(tool.shortcut.is_none());
assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "sharedEdge"));
// The panel's section shows the tool's values and its option acts.
let text = painted_text(&mut app, show);
assert!(text.contains("Shared Edges") && text.contains("Coincidence:") && text.contains("Keep Shared Points Together"), "{text}");
app.run("addon.pathtools.movePointsTogether", json!({ "enabled": true })).unwrap();
assert_eq!(app.session.tool_options_of("sharedEdge")["together"], json!(true));
}
#[test]
fn the_corner_section_shows_the_tools_values_and_applies_them_to_the_selection() {
let mut app = app();
+1
View File
@@ -154,6 +154,7 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor
| Cursor::SharedEdge
| Cursor::SmartPen => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
}
}
+2 -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; 88] = [
const GLYPHS: [Cursor; 89] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -528,6 +528,7 @@ const GLYPHS: [Cursor; 88] = [
Cursor::ParametricShape,
Cursor::CornerTool,
Cursor::SlideAnchor,
Cursor::SharedEdge,
Cursor::SmartPen,
];
+5 -2
View File
@@ -379,7 +379,10 @@ fn drop_targets_follow_the_buttons_edges() {
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", "parametricShape", "cornerTool", "slideAnchor", "smartPen"]);
assert_eq!(
addon,
["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "cornerTool", "slideAnchor", "sharedEdge", "smartPen"]
);
for advanced in [false, true] {
if advanced {
app.run("window.toolbarAdvanced", json!({})).unwrap();
@@ -389,7 +392,7 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() {
let listed = |id: &str| {
sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == id)).map(|(h, tools)| (*h, tools.iter().map(|t| t.id).collect::<Vec<_>>()))
};
assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..6].to_vec())));
assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..7].to_vec())));
assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"])));
for tool in ["extendPath", "smartPen"] {
app.run("toolbar.addTool", json!({"tool": tool})).unwrap();
+11
View File
@@ -5784,6 +5784,17 @@ anchor Points: Точки:
@msg choose anchors where two sides meet at an angle: only those take a corner изберете опорни точки, където две страни се срещат под ъгъл: само те могат да имат ъгъл
@msg give `anchor`, the index of the anchor to slide посочете `anchor`, индекса на опорната точка за плъзгане
@msg that anchor can't slide: an end of an open path has nowhere to go тази опорна точка не може да се плъзга: краят на отворен път няма накъде да отиде
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Инструмент „Сливане на общ ръб“
Merge Shared Edge Сливане на общ ръб
Keep Shared Points Together Общите точки да остават заедно
Shared Edges Общи ръбове
Coincidence: Съвпадение:
@msg `a` and `b` must be two different paths `a` и `b` трябва да са два различни пътя
@msg `diameter` must be from 0.25 to 200 pt `diameter` трябва да е от 0.25 до 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` трябва да е от 0 до 3 pt
@msg `follow` must be "average", "a" or "b" `follow` трябва да е "average", "a" или "b"
@msg the paths have nothing to merge there пътищата нямат какво да се слеят тук
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Разделяне на текст
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5906,3 +5906,15 @@ shape Open Otvoren
@msg choose anchors where two sides meet at an angle: only those take a corner odaberite sidra gdje se dvije stranice sastaju pod uglom: samo ona primaju ugao
@msg give `anchor`, the index of the anchor to slide navedite `anchor`, indeks sidra koje klizi
@msg that anchor can't slide: an end of an open path has nowhere to go to sidro ne može kliziti: kraj otvorene putanje nema kuda
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Alat za spajanje zajedničke ivice
Merge Shared Edge Spoji zajedničku ivicu
Keep Shared Points Together Drži zajedničke tačke zajedno
Shared Edges Zajedničke ivice
Coincidence: Podudarnost:
@msg `a` and `b` must be two different paths `a` i `b` moraju biti dvije različite putanje
@msg `diameter` must be from 0.25 to 200 pt `diameter` mora biti od 0.25 do 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` mora biti od 0 do 3 pt
@msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b"
@msg the paths have nothing to merge there putanje tu nemaju šta spojiti
@msg the path is locked or hidden putanja je zaključana ili skrivena
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5885,6 +5885,17 @@ anchor Points: Punts:
@msg choose anchors where two sides meet at an angle: only those take a corner trieu punts d’ancoratge on dos costats es troben formant un angle: només aquests admeten una cantonada
@msg give `anchor`, the index of the anchor to slide indiqueu `anchor`, l’índex del punt d’ancoratge que voleu lliscar
@msg that anchor can't slide: an end of an open path has nowhere to go aquest punt d’ancoratge no pot lliscar: l’extrem d’un traçat obert no té on anar
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Eina de fusió de vores compartides
Merge Shared Edge Fusiona la vora compartida
Keep Shared Points Together Mantén junts els punts compartits
Shared Edges Vores compartides
Coincidence: Coincidència:
@msg `a` and `b` must be two different paths `a` i `b` han de ser dos traçats diferents
@msg `diameter` must be from 0.25 to 200 pt `diameter` ha de ser de 0.25 a 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` ha de ser de 0 a 3 pt
@msg `follow` must be "average", "a" or "b" `follow` ha de ser "average", "a" o "b"
@msg the paths have nothing to merge there els traçats no tenen res a fusionar aquí
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Divideix el text
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5894,6 +5894,17 @@ anchor Points: Body:
@msg choose anchors where two sides meet at an angle: only those take a corner vyberte kotevní body, kde se dvě strany setkávají pod úhlem: jen ty mohou mít roh
@msg give `anchor`, the index of the anchor to slide zadejte `anchor`, index kotevního bodu k posunutí
@msg that anchor can't slide: an end of an open path has nowhere to go tento kotevní bod nelze posunout: konec otevřené cesty nemá kam jít
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Nástroj Sloučení sdílené hrany
Merge Shared Edge Sloučit sdílenou hranu
Keep Shared Points Together Držet sdílené body pohromadě
Shared Edges Sdílené hrany
Coincidence: Shoda:
@msg `a` and `b` must be two different paths `a` a `b` musí být dvě různé cesty
@msg `diameter` must be from 0.25 to 200 pt `diameter` musí být od 0.25 do 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` musí být od 0 do 3 pt
@msg `follow` must be "average", "a" or "b" `follow` musí být "average", "a" nebo "b"
@msg the paths have nothing to merge there cesty zde nemají co sloučit
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Rozdělit text
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5799,6 +5799,17 @@ anchor Points: Punkter:
@msg choose anchors where two sides meet at an angle: only those take a corner vælg ankerpunkter, hvor to sider mødes i en vinkel: kun de kan få et hjørne
@msg give `anchor`, the index of the anchor to slide angiv `anchor`, indekset for det ankerpunkt, der skal skubbes
@msg that anchor can't slide: an end of an open path has nowhere to go det ankerpunkt kan ikke skubbes: enden af en åben sti har ingen steder at gå hen
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Værktøj til fletning af fælles kant
Merge Shared Edge Flet fælles kant
Keep Shared Points Together Hold fælles punkter sammen
Shared Edges Fælles kanter
Coincidence: Sammenfald:
@msg `a` and `b` must be two different paths `a` og `b` skal være to forskellige stier
@msg `diameter` must be from 0.25 to 200 pt `diameter` skal være fra 0.25 til 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` skal være fra 0 til 3 pt
@msg `follow` must be "average", "a" or "b" `follow` skal være "average", "a" eller "b"
@msg the paths have nothing to merge there stierne har intet at flette her
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Opdel tekst
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5479,6 +5479,17 @@ anchor Points: Punkte:
@msg choose anchors where two sides meet at an angle: only those take a corner Ankerpunkte wählen, an denen zwei Seiten im Winkel aufeinandertreffen: nur diese können eine Ecke bekommen
@msg give `anchor`, the index of the anchor to slide `anchor` angeben, den Index des zu verschiebenden Ankerpunkts
@msg that anchor can't slide: an end of an open path has nowhere to go dieser Ankerpunkt lässt sich nicht verschieben: das Ende eines offenen Pfads kann nirgendwohin
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Gemeinsame-Kante-Werkzeug
Merge Shared Edge Gemeinsame Kante zusammenfügen
Keep Shared Points Together Gemeinsame Punkte zusammenhalten
Shared Edges Gemeinsame Kanten
Coincidence: Übereinstimmung:
@msg `a` and `b` must be two different paths `a` und `b` müssen zwei verschiedene Pfade sein
@msg `diameter` must be from 0.25 to 200 pt `diameter` muss von 0.25 bis 200 pt sein
@msg `tolerance` must be from 0 to 3 pt `tolerance` muss von 0 bis 3 pt sein
@msg `follow` must be "average", "a" or "b" `follow` muss "average", "a" oder "b" sein
@msg the paths have nothing to merge there die Pfade haben hier nichts zusammenzufügen
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Text aufteilen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5846,6 +5846,17 @@ anchor Points: Σημεία:
@msg choose anchors where two sides meet at an angle: only those take a corner επιλέξτε σημεία αγκύρωσης όπου δύο πλευρές συναντώνται υπό γωνία: μόνο αυτά δέχονται γωνία
@msg give `anchor`, the index of the anchor to slide δώστε το `anchor`, τον δείκτη του σημείου αγκύρωσης προς ολίσθηση
@msg that anchor can't slide: an end of an open path has nowhere to go αυτό το σημείο αγκύρωσης δεν μπορεί να ολισθήσει: το άκρο ανοιχτής διαδρομής δεν έχει πού να πάει
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Εργαλείο συγχώνευσης κοινής ακμής
Merge Shared Edge Συγχώνευση κοινής ακμής
Keep Shared Points Together Διατήρηση κοινών σημείων μαζί
Shared Edges Κοινές ακμές
Coincidence: Σύμπτωση:
@msg `a` and `b` must be two different paths Τα `a` και `b` πρέπει να είναι δύο διαφορετικές διαδρομές
@msg `diameter` must be from 0.25 to 200 pt Το `diameter` πρέπει να είναι από 0.25 έως 200 pt
@msg `tolerance` must be from 0 to 3 pt Το `tolerance` πρέπει να είναι από 0 έως 3 pt
@msg `follow` must be "average", "a" or "b" Το `follow` πρέπει να είναι "average", "a" ή "b"
@msg the paths have nothing to merge there οι διαδρομές δεν έχουν κάτι να συγχωνευθεί εδώ
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Διαίρεση κειμένου
Can't render this file because it contains an unexpected character in line 164 and column 5.
+11
View File
@@ -5837,6 +5837,17 @@ anchor Points: Puntos:
@msg choose anchors where two sides meet at an angle: only those take a corner elija puntos de ancla donde dos lados se encuentren en ángulo: solo esos admiten una esquina
@msg give `anchor`, the index of the anchor to slide indique `anchor`, el índice del punto de ancla que deslizar
@msg that anchor can't slide: an end of an open path has nowhere to go ese punto de ancla no se puede deslizar: el extremo de un trazado abierto no tiene adónde ir
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Herramienta Combinar borde compartido
Merge Shared Edge Combinar borde compartido
Keep Shared Points Together Mantener juntos los puntos compartidos
Shared Edges Bordes compartidos
Coincidence: Coincidencia:
@msg `a` and `b` must be two different paths `a` y `b` deben ser dos trazados distintos
@msg `diameter` must be from 0.25 to 200 pt `diameter` debe ser de 0.25 a 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` debe ser de 0 a 3 pt
@msg `follow` must be "average", "a" or "b" `follow` debe ser "average", "a" o "b"
@msg the paths have nothing to merge there los trazados no tienen nada que combinar aquí
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Dividir texto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5925,6 +5925,17 @@ anchor Points: Pisteet:
@msg choose anchors where two sides meet at an angle: only those take a corner valitse ankkuripisteet, joissa kaksi sivua kohtaa kulmassa: vain niihin voi tehdä kulman
@msg give `anchor`, the index of the anchor to slide anna `anchor`, liu’utettavan ankkuripisteen indeksi
@msg that anchor can't slide: an end of an open path has nowhere to go tätä ankkuripistettä ei voi liu’uttaa: avoimen polun päällä ei ole minne mennä
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Yhteisen reunan yhdistämistyökalu
Merge Shared Edge Yhdistä yhteinen reuna
Keep Shared Points Together Pidä yhteiset pisteet yhdessä
Shared Edges Yhteiset reunat
Coincidence: Yhtenevyys:
@msg `a` and `b` must be two different paths `a` ja `b` on oltava kaksi eri polkua
@msg `diameter` must be from 0.25 to 200 pt `diameter` on oltava 0.25–200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` on oltava 0–3 pt
@msg `follow` must be "average", "a" or "b" `follow` on oltava "average", "a" tai "b"
@msg the paths have nothing to merge there poluilla ei ole tässä mitään yhdistettävää
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Jaa teksti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5893,6 +5893,17 @@ anchor Points: Points :
@msg choose anchors where two sides meet at an angle: only those take a corner choisissez des points d’ancrage où deux côtés se rencontrent en formant un angle : seuls ceux-ci acceptent un angle
@msg give `anchor`, the index of the anchor to slide indiquez `anchor`, l’indice du point d’ancrage à faire glisser
@msg that anchor can't slide: an end of an open path has nowhere to go ce point d’ancrage ne peut pas glisser : l’extrémité d’un tracé ouvert n’a nulle part où aller
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Outil Fusion d’arête partagée
Merge Shared Edge Fusionner l’arête partagée
Keep Shared Points Together Garder ensemble les points partagés
Shared Edges Arêtes partagées
Coincidence: Coïncidence :
@msg `a` and `b` must be two different paths `a` et `b` doivent être deux tracés différents
@msg `diameter` must be from 0.25 to 200 pt `diameter` doit être de 0.25 à 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` doit être de 0 à 3 pt
@msg `follow` must be "average", "a" or "b" `follow` doit valoir "average", "a" ou "b"
@msg the paths have nothing to merge there les tracés n’ont rien à fusionner ici
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Diviser le texte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5881,6 +5881,17 @@ anchor Points: Pontok:
@msg choose anchors where two sides meet at an angle: only those take a corner válasszon olyan horgonypontokat, ahol két oldal szögben találkozik: csak ezek kaphatnak sarkot
@msg give `anchor`, the index of the anchor to slide adja meg az `anchor` értékét, a csúsztatandó horgonypont indexét
@msg that anchor can't slide: an end of an open path has nowhere to go ez a horgonypont nem csúsztatható: a nyitott útvonal végének nincs hová mennie
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Közös él egyesítése eszköz
Merge Shared Edge Közös él egyesítése
Keep Shared Points Together Közös pontok együtt tartása
Shared Edges Közös élek
Coincidence: Egybeesés:
@msg `a` and `b` must be two different paths `a` és `b` két különböző útvonal kell legyen
@msg `diameter` must be from 0.25 to 200 pt `diameter`: 0.25 és 200 pt között kell
@msg `tolerance` must be from 0 to 3 pt `tolerance`: 0 és 3 pt között kell
@msg `follow` must be "average", "a" or "b" `follow`: "average", "a" vagy "b" kell
@msg the paths have nothing to merge there az útvonalaknak itt nincs mit egyesíteni
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Szöveg felosztása
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5799,6 +5799,17 @@ anchor Points: Titik:
@msg choose anchors where two sides meet at an angle: only those take a corner pilih titik jangkar tempat dua sisi bertemu membentuk sudut: hanya titik itu yang dapat diberi sudut
@msg give `anchor`, the index of the anchor to slide berikan `anchor`, indeks titik jangkar yang akan digeser
@msg that anchor can't slide: an end of an open path has nowhere to go titik jangkar itu tidak dapat digeser: ujung jalur terbuka tidak punya arah tujuan
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Alat gabung tepi bersama
Merge Shared Edge Gabungkan tepi bersama
Keep Shared Points Together Jaga titik bersama tetap menyatu
Shared Edges Tepi bersama
Coincidence: Kesamaan posisi:
@msg `a` and `b` must be two different paths `a` dan `b` harus dua jalur yang berbeda
@msg `diameter` must be from 0.25 to 200 pt `diameter` harus dari 0.25 hingga 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` harus dari 0 hingga 3 pt
@msg `follow` must be "average", "a" or "b" `follow` harus "average", "a" atau "b"
@msg the paths have nothing to merge there tidak ada yang dapat digabungkan dari jalur di sini
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Pisahkan teks
Can't render this file because it contains an unexpected character in line 2169 and column 5.
+11
View File
@@ -5513,6 +5513,17 @@ anchor Points: Punti:
@msg choose anchors where two sides meet at an angle: only those take a corner scegli punti di ancoraggio in cui due lati si incontrano ad angolo: solo quelli accettano un angolo
@msg give `anchor`, the index of the anchor to slide indica `anchor`, l’indice del punto di ancoraggio da far scorrere
@msg that anchor can't slide: an end of an open path has nowhere to go quel punto di ancoraggio non può scorrere: l’estremità di un tracciato aperto non ha dove andare
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Strumento Fondi bordo condiviso
Merge Shared Edge Fondi bordo condiviso
Keep Shared Points Together Mantieni uniti i punti condivisi
Shared Edges Bordi condivisi
Coincidence: Coincidenza:
@msg `a` and `b` must be two different paths `a` e `b` devono essere due tracciati diversi
@msg `diameter` must be from 0.25 to 200 pt `diameter` deve essere da 0.25 a 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` deve essere da 0 a 3 pt
@msg `follow` must be "average", "a" or "b" `follow` deve essere "average", "a" o "b"
@msg the paths have nothing to merge there i tracciati non hanno nulla da fondere qui
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Dividi testo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5582,6 +5582,17 @@ anchor Points: ポイント:
@msg choose anchors where two sides meet at an angle: only those take a corner 2 つの辺が角度をなして交わるアンカーポイントを選択してください:コーナーを作れるのはそこだけです
@msg give `anchor`, the index of the anchor to slide スライドするアンカーポイントの番号 `anchor` を指定してください
@msg that anchor can't slide: an end of an open path has nowhere to go そのアンカーポイントはスライドできません:オープンパスの端点には移動先がありません
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool 共有エッジ結合ツール
Merge Shared Edge 共有エッジを結合
Keep Shared Points Together 共有ポイントをまとめて移動
Shared Edges 共有エッジ
Coincidence: 一致の許容値:
@msg `a` and `b` must be two different paths `a` と `b` は異なる 2 つのパスにしてください
@msg `diameter` must be from 0.25 to 200 pt `diameter` は 0.25〜200 pt にしてください
@msg `tolerance` must be from 0 to 3 pt `tolerance` は 0〜3 pt にしてください
@msg `follow` must be "average", "a" or "b" `follow` は "average"、"a"、"b" のいずれかにしてください
@msg the paths have nothing to merge there ここには結合できるものがありません
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text テキストを分割
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5884,6 +5884,17 @@ anchor Points: წერტილები:
@msg choose anchors where two sides meet at an angle: only those take a corner აირჩიეთ საყრდენი წერტილები, სადაც ორი გვერდი კუთხით ხვდება ერთმანეთს: მხოლოდ მათ შეუძლიათ კუთხის მიღება
@msg give `anchor`, the index of the anchor to slide მიუთითეთ `anchor`, სასრიალო საყრდენი წერტილის ინდექსი
@msg that anchor can't slide: an end of an open path has nowhere to go ეს საყრდენი წერტილი ვერ ისრიალებს: ღია ტრაექტორიის ბოლოს წასასვლელი არ აქვს
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool საერთო წიბოს შერწყმის ხელსაწყო
Merge Shared Edge საერთო წიბოს შერწყმა
Keep Shared Points Together საერთო წერტილების ერთად შენარჩუნება
Shared Edges საერთო წიბოები
Coincidence: დამთხვევა:
@msg `a` and `b` must be two different paths `a` და `b` ორი სხვადასხვა ტრაექტორია უნდა იყოს
@msg `diameter` must be from 0.25 to 200 pt `diameter` უნდა იყოს 0.25-დან 200 pt-მდე
@msg `tolerance` must be from 0 to 3 pt `tolerance` უნდა იყოს 0-დან 3 pt-მდე
@msg `follow` must be "average", "a" or "b" `follow` უნდა იყოს "average", "a" ან "b"
@msg the paths have nothing to merge there ტრაექტორიებს აქ შესარწყმელი არაფერი აქვთ
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text ტექსტის დაყოფა
Can't render this file because it is too large.
+11
View File
@@ -5476,6 +5476,17 @@ anchor Points: 포인트:
@msg choose anchors where two sides meet at an angle: only those take a corner 두 변이 각을 이루며 만나는 앵커 포인트를 선택하세요. 그런 포인트만 모퉁이를 만들 수 있습니다
@msg give `anchor`, the index of the anchor to slide 밀 앵커 포인트의 인덱스 `anchor`를 지정하세요
@msg that anchor can't slide: an end of an open path has nowhere to go 이 앵커 포인트는 밀 수 없습니다. 열린 패스의 끝은 갈 곳이 없습니다
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool 공유 가장자리 병합 도구
Merge Shared Edge 공유 가장자리 병합
Keep Shared Points Together 공유 포인트 함께 유지
Shared Edges 공유 가장자리
Coincidence: 일치 허용치:
@msg `a` and `b` must be two different paths `a`와 `b`는 서로 다른 두 패스여야 합니다
@msg `diameter` must be from 0.25 to 200 pt `diameter`는 0.25~200 pt여야 합니다
@msg `tolerance` must be from 0 to 3 pt `tolerance`는 0~3 pt여야 합니다
@msg `follow` must be "average", "a" or "b" `follow`는 "average", "a" 또는 "b"여야 합니다
@msg the paths have nothing to merge there 여기에는 병합할 패스 부분이 없습니다
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text 텍스트 나누기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5843,6 +5843,17 @@ anchor Points: Punten:
@msg choose anchors where two sides meet at an angle: only those take a corner kies ankerpunten waar twee zijden elkaar in een hoek ontmoeten: alleen die kunnen een hoek krijgen
@msg give `anchor`, the index of the anchor to slide geef `anchor` op, de index van het ankerpunt dat moet schuiven
@msg that anchor can't slide: an end of an open path has nowhere to go dat ankerpunt kan niet schuiven: het einde van een open pad kan nergens heen
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Gereedschap Gedeelde rand samenvoegen
Merge Shared Edge Gedeelde rand samenvoegen
Keep Shared Points Together Gedeelde punten bij elkaar houden
Shared Edges Gedeelde randen
Coincidence: Samenvallen:
@msg `a` and `b` must be two different paths `a` en `b` moeten twee verschillende paden zijn
@msg `diameter` must be from 0.25 to 200 pt `diameter` moet van 0.25 tot 200 pt zijn
@msg `tolerance` must be from 0 to 3 pt `tolerance` moet van 0 tot 3 pt zijn
@msg `follow` must be "average", "a" or "b" `follow` moet "average", "a" of "b" zijn
@msg the paths have nothing to merge there de paden hebben hier niets om samen te voegen
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Tekst splitsen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5749,6 +5749,17 @@ anchor Points: Punkty:
@msg choose anchors where two sides meet at an angle: only those take a corner wybierz punkty kontrolne, w których dwa boki spotykają się pod kątem: tylko one mogą mieć narożnik
@msg give `anchor`, the index of the anchor to slide podaj `anchor`, indeks punktu kontrolnego do przesunięcia
@msg that anchor can't slide: an end of an open path has nowhere to go tego punktu kontrolnego nie można przesunąć: koniec otwartej ścieżki nie ma dokąd pójść
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Narzędzie Scal wspólną krawędź
Merge Shared Edge Scal wspólną krawędź
Keep Shared Points Together Trzymaj wspólne punkty razem
Shared Edges Wspólne krawędzie
Coincidence: Zbieżność:
@msg `a` and `b` must be two different paths `a` i `b` muszą być dwiema różnymi ścieżkami
@msg `diameter` must be from 0.25 to 200 pt `diameter` musi mieć od 0.25 do 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` musi mieć od 0 do 3 pt
@msg `follow` must be "average", "a" or "b" `follow` musi mieć wartość "average", "a" lub "b"
@msg the paths have nothing to merge there ścieżki nie mają tu nic do scalenia
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Podziel tekst
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5812,6 +5812,17 @@ anchor Points: Pontos:
@msg choose anchors where two sides meet at an angle: only those take a corner escolha pontos de ancoragem onde dois lados se encontram em ângulo: só esses aceitam um canto
@msg give `anchor`, the index of the anchor to slide informe `anchor`, o índice do ponto de ancoragem a deslizar
@msg that anchor can't slide: an end of an open path has nowhere to go esse ponto de ancoragem não pode deslizar: a extremidade de um caminho aberto não tem para onde ir
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Ferramenta Mesclar Borda Compartilhada
Merge Shared Edge Mesclar Borda Compartilhada
Keep Shared Points Together Manter Pontos Compartilhados Juntos
Shared Edges Bordas Compartilhadas
Coincidence: Coincidência:
@msg `a` and `b` must be two different paths `a` e `b` devem ser dois caminhos diferentes
@msg `diameter` must be from 0.25 to 200 pt `diameter` deve ser de 0.25 a 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` deve ser de 0 a 3 pt
@msg `follow` must be "average", "a" or "b" `follow` deve ser "average", "a" ou "b"
@msg the paths have nothing to merge there os caminhos não têm nada a mesclar aqui
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Dividir Texto
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11
View File
@@ -5872,6 +5872,17 @@ anchor Points: Точки:
@msg choose anchors where two sides meet at an angle: only those take a corner выберите опорные точки, где две стороны сходятся под углом: только они принимают угол
@msg give `anchor`, the index of the anchor to slide укажите `anchor`, индекс опорной точки для сдвига
@msg that anchor can't slide: an end of an open path has nowhere to go эту опорную точку нельзя сдвинуть: концу открытого контура некуда двигаться
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Инструмент «Слияние общего края»
Merge Shared Edge Объединить общий край
Keep Shared Points Together Держать общие точки вместе
Shared Edges Общие края
Coincidence: Совпадение:
@msg `a` and `b` must be two different paths `a` и `b` должны быть двумя разными контурами
@msg `diameter` must be from 0.25 to 200 pt `diameter` должно быть от 0.25 до 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` должно быть от 0 до 3 pt
@msg `follow` must be "average", "a" or "b" `follow` должно быть "average", "a" или "b"
@msg the paths have nothing to merge there здесь у контуров нечего объединять
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Разбить текст
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5950,6 +5950,17 @@ shape Open Öppen
@msg choose anchors where two sides meet at an angle: only those take a corner välj ankarpunkter där två sidor möts i en vinkel: bara de kan få ett hörn
@msg give `anchor`, the index of the anchor to slide ange `anchor`, indexet för ankarpunkten som ska skjutas
@msg that anchor can't slide: an end of an open path has nowhere to go den ankarpunkten kan inte skjutas: änden av en öppen bana har ingenstans att ta vägen
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Verktyg för sammanfogning av gemensam kant
Merge Shared Edge Sammanfoga gemensam kant
Keep Shared Points Together Håll ihop gemensamma punkter
Shared Edges Gemensamma kanter
Coincidence: Sammanfall:
@msg `a` and `b` must be two different paths `a` och `b` måste vara två olika banor
@msg `diameter` must be from 0.25 to 200 pt `diameter` måste vara från 0.25 till 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` måste vara från 0 till 3 pt
@msg `follow` must be "average", "a" or "b" `follow` måste vara "average", "a" eller "b"
@msg the paths have nothing to merge there banorna har inget att sammanfoga här
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Dela upp text
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -5852,6 +5852,17 @@ anchor Points: Noktalar:
@msg choose anchors where two sides meet at an angle: only those take a corner iki kenarın açıyla birleştiği bağlantı noktalarını seçin: yalnızca onlara köşe uygulanır
@msg give `anchor`, the index of the anchor to slide kaydırılacak bağlantı noktasının dizini `anchor` değerini verin
@msg that anchor can't slide: an end of an open path has nowhere to go bu bağlantı noktası kaydırılamaz: açık yolun ucunun gidecek yeri yok
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Ortak Kenar Birleştirme Aracı
Merge Shared Edge Ortak Kenarı Birleştir
Keep Shared Points Together Ortak Noktaları Bir Arada Tut
Shared Edges Ortak Kenarlar
Coincidence: Çakışma:
@msg `a` and `b` must be two different paths `a` ve `b` iki farklı yol olmalıdır
@msg `diameter` must be from 0.25 to 200 pt `diameter` 0.25 ile 200 pt arasında olmalıdır
@msg `tolerance` must be from 0 to 3 pt `tolerance` 0 ile 3 pt arasında olmalıdır
@msg `follow` must be "average", "a" or "b" `follow` "average", "a" veya "b" olmalıdır
@msg the paths have nothing to merge there yolların burada birleştirilecek bir kısmı yok
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Metni Böl
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -5493,6 +5493,17 @@ anchor Points: Точки:
@msg choose anchors where two sides meet at an angle: only those take a corner виберіть опорні точки, де дві сторони сходяться під кутом: лише вони приймають кут
@msg give `anchor`, the index of the anchor to slide вкажіть `anchor`, індекс опорної точки для зсуву
@msg that anchor can't slide: an end of an open path has nowhere to go цю опорну точку не можна зсунути: кінцю відкритого контуру нікуди рухатися
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Інструмент «Об’єднання спільного краю»
Merge Shared Edge Об’єднати спільний край
Keep Shared Points Together Тримати спільні точки разом
Shared Edges Спільні краї
Coincidence: Збіг:
@msg `a` and `b` must be two different paths `a` і `b` мають бути двома різними контурами
@msg `diameter` must be from 0.25 to 200 pt `diameter` має бути від 0.25 до 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` має бути від 0 до 3 pt
@msg `follow` must be "average", "a" or "b" `follow` має бути "average", "a" або "b"
@msg the paths have nothing to merge there тут у контурів немає чого об’єднувати
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Розбити текст
Can't render this file because it contains an unexpected character in line 178 and column 5.
+11
View File
@@ -5495,6 +5495,17 @@ anchor Points: Điểm:
@msg choose anchors where two sides meet at an angle: only those take a corner hãy chọn các điểm neo nơi hai cạnh gặp nhau thành góc: chỉ những điểm đó mới nhận góc
@msg give `anchor`, the index of the anchor to slide hãy cho biết `anchor`, chỉ số của điểm neo cần trượt
@msg that anchor can't slide: an end of an open path has nowhere to go không thể trượt điểm neo đó: đầu của đường dẫn mở không có chỗ để đi
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool Công cụ Hợp nhất cạnh chung
Merge Shared Edge Hợp nhất cạnh chung
Keep Shared Points Together Giữ các điểm chung đi cùng nhau
Shared Edges Cạnh chung
Coincidence: Độ trùng khớp:
@msg `a` and `b` must be two different paths `a` và `b` phải là hai đường dẫn khác nhau
@msg `diameter` must be from 0.25 to 200 pt `diameter` phải từ 0.25 đến 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` phải từ 0 đến 3 pt
@msg `follow` must be "average", "a" or "b" `follow` phải là "average", "a" hoặc "b"
@msg the paths have nothing to merge there các đường dẫn không có gì để hợp nhất ở đây
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text Tách văn bản
Can't render this file because it contains an unexpected character in line 200 and column 5.
+11
View File
@@ -5736,6 +5736,17 @@ anchor Points: 点:
@msg choose anchors where two sides meet at an angle: only those take a corner 请选择两条边成角相交的锚点:只有这些锚点才能设置转角
@msg give `anchor`, the index of the anchor to slide 请提供 `anchor`,即要滑动的锚点索引
@msg that anchor can't slide: an end of an open path has nowhere to go 该锚点无法滑动:开放路径的端点无处可去
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool 共享边合并工具
Merge Shared Edge 合并共享边
Keep Shared Points Together 保持共享点在一起
Shared Edges 共享边
Coincidence: 重合容差:
@msg `a` and `b` must be two different paths `a` 和 `b` 必须是两条不同的路径
@msg `diameter` must be from 0.25 to 200 pt `diameter` 必须为 0.25 到 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` 必须为 0 到 3 pt
@msg `follow` must be "average", "a" or "b" `follow` 必须是 "average"、"a" 或 "b"
@msg the paths have nothing to merge there 这里没有可合并的路径部分
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text 拆分文本
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5532,6 +5532,17 @@ anchor Points: 點:
@msg choose anchors where two sides meet at an angle: only those take a corner 請選取兩邊成角相交的錨點:只有這些錨點才能設定轉角
@msg give `anchor`, the index of the anchor to slide 請提供 `anchor`,即要滑動的錨點索引
@msg that anchor can't slide: an end of an open path has nowhere to go 該錨點無法滑動:開放路徑的端點無處可去
# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together.
Shared Edge Merge Tool 共用邊合併工具
Merge Shared Edge 合併共用邊
Keep Shared Points Together 保持共用點在一起
Shared Edges 共用邊
Coincidence: 重合容差:
@msg `a` and `b` must be two different paths `a` 和 `b` 必須是兩條不同的路徑
@msg `diameter` must be from 0.25 to 200 pt `diameter` 必須為 0.25 到 200 pt
@msg `tolerance` must be from 0 to 3 pt `tolerance` 必須為 0 到 3 pt
@msg `follow` must be "average", "a" or "b" `follow` 必須是 "average"、"a" 或 "b"
@msg the paths have nothing to merge there 這裡沒有可合併的路徑部分
# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages.
Break Text 分割文字
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -528,6 +528,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
None => Err("missing `tool`".into()),
},
crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::open(app),
crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app),
"tool.setOption" => app.session.set_tool_option_cmd(p),
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
+6
View File
@@ -6,6 +6,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} choose the Corner Tool and show the Anchor Inspector panel with its radius and kind",
),
(
crate::addons::pathtools::shared_edge::COMMAND,
"Shared Edge Merge Tool",
"",
"{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together",
),
(
"app.language",
"Interface Language",
+3 -2
View File
@@ -1226,8 +1226,9 @@ const DOCK_LABELS: &[(&str, &str)] = &[
fn floating_size(panel: &str) -> egui::Vec2 {
match panel {
"properties" | "layers" | "libraries" => egui::vec2(300.0, 420.0),
// Tall enough to show its Corners section, last in it, which the Corner Tool's menu item opens.
crate::addons::pathtools::ID => egui::vec2(260.0, 540.0),
// Tall enough to show its Corners and Shared Edges sections, last in it, which the Corner
// Tool's and Shared Edge Merge Tool's menu items open.
crate::addons::pathtools::ID => egui::vec2(260.0, 660.0),
_ => egui::vec2(260.0, 360.0),
}
}
+25 -2
View File
@@ -5795,8 +5795,9 @@ type and evening out horizontal scales are not there yet.
Path Precision Tools is a built-in add-on for exact anchor and path work: its commands are `addon.pathtools.*`, and in
the desktop app it lives in Object › Plug-ins › Path Precision Tools, in its Anchor Inspector panel
(`window.panel {panel: "anchorInspector"}`), in its Parametric Shape panel (`window.panel {panel: "parametricShape"}`)
and in six tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with
`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect`, `parametricShape`, `cornerTool` and `slideAnchor`.
and in seven tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with
`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect`, `parametricShape`, `cornerTool`, `slideAnchor` and
`sharedEdge`.
None has a shortcut. Each edit is one undo step, and an edit that changes nothing makes no step.
Tools:
@@ -5833,6 +5834,16 @@ Tools:
dragging: the anchor runs along the curve it sits on, as far as just short of its neighbours, and the two segments
beside it are refitted. Shift snaps to where the path's tangent is at a multiple of 45° from the Constrain Angle. Ends
of open paths don't slide.
- **Shared Edge Merge Tool** (`sharedEdge`, or the UI command `addon.pathtools.sharedEdgeTool`, which also shows the
Anchor Inspector panel with its Shared Edges section): works on any two unlocked paths under its round brush, selected
or not. Where they touch at two coincident anchors, hovering shows the two stretches between those anchors dashed and
the edge replacing them solid (green when they are one edge already); a click runs `addon.pathtools.mergeEdge`, half
way between the two. Shift keeps the upper path's outline, Alt the lower's. Where the anchors aren't there yet a click
makes an end: two near anchors meet half way (or, with Shift or Alt, on the path kept), and an anchor only one path has
there gets a match on the other. Shift+Alt makes an end even inside a stretch that could merge. Stretches over 10%
different in length, or at over 120° at an end, are left alone. Options (`tool.setOption`, or the panel, kept with the
preferences): `diameter` (0.25 to 200 pt, 20), `tolerance` (how near two anchors count as coincident, 0 to 3 pt,
0.002) and `together` (Keep Shared Points Together).
Commands:
@@ -5878,6 +5889,18 @@ Commands:
so the outline stays (the anchor count and index stay). `t`, -0.98 to 0.98, says where: positive, a curve parameter on
the segment after the anchor; negative, back from the end of the one before. An anchor at a sharp corner loses it, as
the corner isn't on the way. → `{id, strayed}`, how far the outline moved in points.
- `addon.pathtools.mergeEdge {a, b, at, diameter?, tolerance?, follow?, ends?}` looks within `diameter` (0.25 to 200 pt,
default 20) of `at` ([x, y]) on paths `a` and `b`, two editable paths: where they touch at two anchors coincident within
`tolerance` (0 to 3 pt, default 0.002), one each way along them, it replaces the stretches between with one edge;
otherwise it makes an end there, and `ends: true` asks for that first. `follow` says which outline stays: `average`
(default; half way between), `a` or `b`. Each path stays closed or open, and anchors either stretch has inside are on
the merged edge. One undo step. → `{a, b, made}`, with `made` `edge`, `end`, or `nothing` when they are merged already
(no undo step); it is an error if there is nothing to merge there.
- `addon.pathtools.movePointsTogether {enabled?}` sets Keep Shared Points Together (without `enabled`, turns it the other
way). While it is on, anchors of any editable path coincident, within the Shared Edge Merge Tool's `tolerance`, with
one that `addon.pathtools.slideAnchor`, `extend` or `setAnchor` moves go with it, in the same undo step; an Extend Path
that adds an anchor leaves the old end, and what shares it, where they are. Nothing else in the app changes. It is the
`together` option of tool `sharedEdge`, off by default. → `{enabled}`.
- `addon.pathtools.shape.create {center, kind?, width?, height?, rotation?, count?, a?, b?, radius?}` draws a parametric
shape with the current paint, selected. `kind`: `rectangle`, `ellipse`, `polygon` (default), `star`, `ring`, `arrow`,
`cross`, `heart`, `gear` or `bubble` (a speech bubble). `width` and `height` are 0.01 to 16,384 pt (100);
+16 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (supersampled raster exports avoid redundant compositing for opaque knockout fills; Path Precision Tools add-on: Corner Tool and Slide Anchor Along Path; 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-31 · **Change:** minor (Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1289,6 +1289,20 @@ and `addons::slide_anchor` in `vectorcraft-tools`, `cmd::addons::pathtools::test
one undo step, bad params refused, the tools' gestures), `addons::pathtools::tests` in `vectorcraft-ui-egui` (submenu,
panel section), `edit_toolbar::tests` and `command_sweep`.
The add-on's Shared Edge Merge Tool (2026-10-31) works on any two editable paths under its round brush. Where they touch
at two coincident anchors (tolerance 0 to 3 pt), hovering shows both stretches and the merged edge, and a click replaces
both with it, half way between them, or following the upper (Shift) or lower (Alt) path; anchors either stretch has inside
are matched on the other, both paths stay closed or open, and it is one undo step. Near anchors meet on a click and an
anchor one path lacks is added to match. Keep Shared Points Together, an option in the Anchor Inspector, makes Slide Anchor
Along Path, Extend Path and Set Anchor take coincident anchors along; nothing else in the app changes. Commands
`addon.pathtools.mergeEdge` and `movePointsTogether`; `vectorcraft_pathops::addons::pathtools::apply_merge`. Not yet:
growing to adjacent stretches, preview preferences, cycling three candidates, pairs at crossings without anchors, the
tool's Alt-click cut and delete, and shared segments reshaping together. Tests: `addons::pathtools::shared_edge` in
`vectorcraft-pathops` (the merged edge lies half way, topology, unequal anchors, stretches over a closed path's start,
ends), `addons::shared_edge` in `vectorcraft-tools`, `cmd::addons::pathtools::shared_edge::tests` in `vectorcraft-engine`
(one undo step, bad params refused, the tool's click, the option), `addons::pathtools::tests` in `vectorcraft-ui-egui`
(submenu, panel section), `edit_toolbar::tests` and `command_sweep`.
## Smart Pen add-on (2026-10-30)
A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Smart Pen selects the
@@ -1338,6 +1352,7 @@ add-on.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. |
| 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; evidence added; scores unchanged. |
| 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores unchanged. |
| 2026-10-30 | patch | Performance: opaque solid-fill knockout paths avoid redundant erase/add compositing in hard-edge frames, including supersampled raster export; analytical antialiasing keeps its existing rounding; scores and estimates unchanged |
| 2026-10-30 | minor | File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; evidence added; scores unchanged. |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Path Precision Tools add-on: live Path Effects; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -326,6 +326,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| 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 |
| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not an Illustrator feature, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its six tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored |
@@ -374,6 +375,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; scores and estimates unchanged |
| 2026-10-31 | minor | Path Precision Tools add-on: live Path Effects in Effect › Path Effects; scores and estimates unchanged |
| 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool (editable corners, panel section) and Slide Anchor Along Path; scores and estimates unchanged |
| 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): its text tools in the panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged |