Merge pull request #4758 from storytold/feat/addon-corner-tool

Add-ons: Path Precision Tools — Corner Tool and Slide Anchor
This commit is contained in:
BFlat
2026-10-31 04:38:11 +07:00
committed by GitHub
55 changed files with 1939 additions and 47 deletions
+54 -20
View File
@@ -1,9 +1,11 @@
//! 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 and the Parametric Shape Tool's shapes ([`shape`]).
//! 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`]).
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
mod corners;
mod shape;
#[cfg(test)]
mod tests;
@@ -139,8 +141,18 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
crossings
),
cmd!(
"addon.pathtools.slideAnchor",
"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)}",
has_doc,
slide_anchor
),
];
v.extend(shape::specs());
v.extend(corners::specs());
v
}
@@ -288,8 +300,8 @@ fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) ->
// ---------- Extend Path ----------
/// The open subpath an `extend` request names: (path id, subpath index, its data).
fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
/// The subpath a request names: (path id, subpath index, its data).
fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?;
let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0);
let d = &s.doc()?.doc;
@@ -298,15 +310,33 @@ fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, Sub
}
let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?;
let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?;
if sp.closed || sp.anchors.len() < 2 {
return Err(bad(cmd, "the subpath has no ends to extend (it is closed or a single point)"));
}
Ok((id, si, sp.clone()))
}
/// 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.
fn set_subpath(s: &mut Session, label: &str, (id, si, old): (NodeId, usize, SubPath), new: SubPath) -> Result<()> {
if new == old {
return Ok(());
}
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);
Ok(())
})
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.extend";
let (id, si, sp) = open_subpath(s, p, C)?;
let (id, si, sp) = named_subpath(s, p, C)?;
if sp.closed || sp.anchors.len() < 2 {
return Err(bad(C, "the subpath has no ends to extend (it is closed or a single point)"));
}
let at_start = match text_param(p, "end", C)? {
Some("start") => true,
Some("end") => false,
@@ -328,22 +358,26 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`length` must be a number of at most 1000000 pt either way"));
}
let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?;
if new != sp {
s.edit("Extend Path", |d, sel| {
let n = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
let mut np = n;
if let Some(slot) = np.subpaths.get_mut(si) {
*slot = new;
}
sel.anchors.remove(&id);
sel.segments.remove(&id);
set_path(d, id, np);
Ok(())
})?;
}
set_subpath(s, "Extend Path", (id, si, sp), new)?;
Ok(json!({ "id": id.0, "length": length }))
}
// ---------- Slide Anchor Along Path ----------
fn slide_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.slideAnchor";
let (id, si, sp) = named_subpath(s, p, C)?;
let anchor = usize_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "give `anchor`, the index of the anchor to slide"))?;
let t = number_or(p, "t", f64::NAN, C)?;
if t.is_nan() || t.abs() > pt::MAX_SLIDE {
return Err(bad(C, "`t` must be a number from -0.98 to 0.98"));
}
let (new, strayed) =
pt::slide_anchor(&sp, anchor, t).ok_or_else(|| bad(C, "that anchor can't slide: an end of an open path has nowhere to go"))?;
set_subpath(s, "Slide Anchor Along Path", (id, si, sp), new)?;
Ok(json!({ "id": id.0, "strayed": strayed }))
}
// ---------- Measure & Inspect ----------
fn polar(h: Option<(f64, f64)>) -> Value {
@@ -0,0 +1,144 @@
//! The Corner Tool's commands: the app's Live Corners ([`LiveCorners`]) cut, resized and removed on
//! chosen anchors, each edit one undo step.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{LiveCorners, NodeId, NodeKind};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_tools::addons::corner_tool::MAX_RADIUS;
use super::super::super::object::recut;
use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, number_or};
use super::{check_size, paths_under};
use crate::{Result, Session};
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.pathtools.cornerSet",
"Set Corners",
[],
None,
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}",
has_doc,
corner_set
),
cmd!(
"addon.pathtools.cornerRemove",
"Remove Corners",
[],
None,
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors)} make those live corners sharp again, as one undo step → {ids, corners (how many)}",
has_doc,
corner_remove
),
]
}
/// The `anchors` of `p`.
fn anchors_param(p: &Value, cmd: &str) -> Result<Option<BTreeSet<usize>>> {
let Some(v) = p.get("anchors").filter(|v| !v.is_null()) else { return Ok(None) };
let list = v.as_array().ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {v}")))?;
list.iter()
.map(|k| {
k.as_u64()
.and_then(|k| usize::try_from(k).ok())
.ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {k}")))
})
.collect::<Result<_>>()
.map(Some)
}
/// The corners a request names: each path with the corners (anchor indices of its uncut outline).
fn named_corners(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
let shared = anchors_param(p, cmd)?;
// Each path with its own `anchors`, or sharing the request's.
let mut own: Vec<Option<BTreeSet<usize>>> = vec![];
let ids: Vec<NodeId> = match p.get("items").filter(|v| !v.is_null()) {
None => paths_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, cmd)?),
Some(Value::Array(items)) => {
let mut ids = vec![];
for it in items {
ids.push(checked_id(s, it.get("id").unwrap_or(&Value::Null), cmd)?);
own.push(anchors_param(it, cmd)?);
}
ids
}
Some(v) => return Err(bad(cmd, format!("`items` must be a list of {{id, anchors?}}, not {v}"))),
};
let st = s.doc()?;
let d = &st.doc;
check_size(ids.iter().filter_map(|id| d.node(*id)?.path_data()), cmd)?;
let editable = d.editable();
let mut out = vec![];
for (i, id) in ids.iter().enumerate() {
let Some(lc) = d.node(*id).filter(|_| editable(*id)).and_then(LiveCorners::of) else { continue };
let corners = match own.get(i).and_then(Option::as_ref).or(shared.as_ref()) {
Some(given) => {
let n = lc.base.anchor_count();
if let Some(past) = given.iter().find(|k| **k >= n) {
return Err(bad(cmd, format!("object {id} has {n} anchors, so no anchor {past}")));
}
given.iter().copied().filter(|k| lc.corner(*k).is_some()).collect()
}
None => st.selection.partial(*id).filter(|a| !a.is_empty()).map_or_else(|| lc.all(), |a| lc.corners_of(a)),
};
if !corners.is_empty() {
out.push((*id, corners));
}
}
if out.is_empty() {
return Err(bad(cmd, "choose anchors where two sides meet at an angle: only those take a corner"));
}
Ok(out)
}
/// Cut `radius` and `kind` on `corners` as one step (none when they have them already).
fn edit_corners(s: &mut Session, label: &str, corners: Vec<(NodeId, BTreeSet<usize>)>, radius: f64, kind: Option<CornerKind>) -> Result<Value> {
let d = &s.doc()?.doc;
let same = corners.iter().all(|(id, picked)| {
d.node(*id)
.and_then(LiveCorners::of)
.is_some_and(|c| picked.iter().all(|k| (c.drawn_radius(*k) - radius).abs() < 1e-9 && kind.is_none_or(|kd| c.kind(*k) == kd)))
});
let out = json!({
"ids": corners.iter().map(|(id, _)| id.0).collect::<Vec<_>>(),
"corners": corners.iter().map(|(_, c)| c.len()).sum::<usize>(),
});
if !same {
s.edit(label, |d, sel| {
for (id, picked) in corners {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
// A cut changes the anchors, which is all a segment selection goes by.
sel.segments.remove(&id);
recut(path, live, sel.anchors.get_mut(&id), &picked, Some(radius), kind);
}
Ok(())
})?;
}
Ok(out)
}
fn corner_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.cornerSet";
let radius = number_or(p, "radius", f64::NAN, C)?;
if !(0.0..=MAX_RADIUS).contains(&radius) {
return Err(bad(C, "`radius` must be from 0 to 1000000 pt"));
}
let kind = p
.get("kind")
.filter(|v| !v.is_null())
.map(|v| {
serde_json::from_value::<CornerKind>(v.clone())
.map_err(|_| bad(C, format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
})
.transpose()?;
let corners = named_corners(s, p, C)?;
edit_corners(s, "Set Corners", corners, radius, kind)
}
fn corner_remove(s: &mut Session, p: &Value) -> Result<Value> {
let corners = named_corners(s, p, "addon.pathtools.cornerRemove")?;
edit_corners(s, "Remove Corners", corners, 0.0, Some(CornerKind::Round))
}
+219 -1
View File
@@ -4,7 +4,8 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_doc::{LiveCorners, LiveShape, NodeId};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{AnchorKind, PathData, Point};
use vectorcraft_tools::{PointerEvent, PointerKind};
@@ -456,3 +457,220 @@ fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0));
}
// ---------- Corner Tool ----------
/// A square (100, 100)–(300, 300), plain and selected: anchors from the top left, clockwise.
fn square(s: &mut Session) -> NodeId {
let id = line(s, &[(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)], true);
select(s, &[id]);
id
}
fn live(s: &Session, id: NodeId) -> Option<LiveShape> {
s.doc().unwrap().doc.node(id).unwrap().live_shape().cloned()
}
fn drawn(s: &Session, id: NodeId, corner: usize) -> f64 {
LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap().drawn_radius(corner)
}
#[test]
fn corner_set_cuts_live_corners_as_one_undo_step() {
let mut s = session();
let id = square(&mut s);
let plain = path(&s, id);
let n = steps(&s);
// The selected path: all four.
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap();
assert_eq!(r, json!({"ids": [id.0], "corners": 4}));
assert_eq!((steps(&s), path(&s, id).anchor_count()), (n + 1, 8));
let lc = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).map(|c| (c.drawn_radius(3), c.kind(3))).unwrap();
assert_eq!(lc, (20.0, CornerKind::Chamfer));
// The same again changes nothing: no step.
s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap();
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((path(&s, id), live(&s, id)), (plain, None));
}
#[test]
fn corner_set_goes_by_anchors_the_selection_and_items_and_fits() {
let mut s = session();
let id = square(&mut s);
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
// Named anchors, of each path by `items`, at once; an open end takes none.
let items = json!([{"id": id.0, "anchors": [1]}, {"id": other.0, "anchors": [0, 1]}]);
let n = steps(&s);
let r = s.execute("addon.pathtools.cornerSet", &json!({"items": items, "radius": 5})).unwrap();
assert_eq!((r["corners"].clone(), steps(&s)), (json!(2), n + 1));
assert_eq!((drawn(&s, id, 1), drawn(&s, id, 0), drawn(&s, other, 1)), (5.0, 0.0, 5.0));
// `ids` with `anchors`, keeping the kind each has.
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [1], "radius": 9})).unwrap();
assert_eq!(drawn(&s, id, 1), 9.0);
// Direct-selected anchors: only their corners, which stay selected as they gain anchors.
select_anchors(&mut s, id, &[0]);
s.execute("addon.pathtools.cornerSet", &json!({"radius": 7})).unwrap();
assert_eq!((drawn(&s, id, 0), drawn(&s, id, 2)), (7.0, 0.0));
let sel = s.doc().unwrap().selection.anchors[&id].clone();
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
assert_eq!(c.corners_of(&sel), BTreeSet::from([0]));
// A radius that doesn't fit is drawn at what does.
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [2], "radius": 5000})).unwrap();
assert!(drawn(&s, id, 2) <= 200.0 - 7.0 + 1e-9, "{}", drawn(&s, id, 2));
}
#[test]
fn corner_remove_makes_corners_sharp_and_a_path_plain_again() {
let mut s = session();
let id = square(&mut s);
let plain = path(&s, id);
s.execute("addon.pathtools.cornerSet", &json!({"anchors": [0, 1], "radius": 10, "kind": "invertedRound"})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "anchors": [0]}]})).unwrap();
assert_eq!((r["corners"].clone(), steps(&s), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), n + 1, 0.0, 10.0));
s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap();
assert_eq!((steps(&s), path(&s, id), live(&s, id)), (n + 2, plain, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(drawn(&s, id, 1), 10.0);
}
#[test]
fn corner_commands_refuse_bad_params() {
let mut s = session();
let id = square(&mut s);
let smooth = circle(&mut s);
let i = id.0;
refuses(
&mut s,
"addon.pathtools.cornerSet",
&[
json!({"ids": [i]}),
json!({"ids": [i], "radius": -1}),
json!({"ids": [i], "radius": 1e308}),
json!({"ids": [i], "radius": "big"}),
json!({"ids": [i], "radius": 5, "kind": "spiky"}),
json!({"ids": [i], "anchors": "all", "radius": 5}),
json!({"ids": [i], "anchors": [-1], "radius": 5}),
json!({"ids": [i], "anchors": [9], "radius": 5}),
json!({"ids": [u64::MAX], "radius": 5}),
json!({"items": 3, "radius": 5}),
json!({"items": [{"anchors": [0]}], "radius": 5}),
json!({"ids": [smooth.0], "radius": 5}),
],
);
refuses(&mut s, "addon.pathtools.cornerRemove", &[json!({"anchors": [9], "ids": [i]}), json!({"ids": [smooth.0]})]);
// Locked: left alone.
s.execute("object.lock", &json!({"ids": [i]})).ok();
if !s.doc().unwrap().doc.is_editable(id) {
refuses(&mut s, "addon.pathtools.cornerSet", &[json!({"items": [{"id": i}], "radius": 5})]);
}
}
#[test]
fn the_corner_tool_clicks_drags_and_removes_in_one_step_each() {
let mut s = session();
let id = square(&mut s);
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("cornerTool", view).unwrap();
s.set_tool_option("radius", &json!(15));
let n = steps(&s);
let press = |s: &mut Session, k, x, y| s.pointer(&PointerEvent::new(k, x, y), view).unwrap();
// A click on the top right anchor.
press(&mut s, PointerKind::Down, 300.0, 100.0);
press(&mut s, PointerKind::Up, 300.0, 100.0);
assert_eq!((steps(&s), drawn(&s, id, 1)), (n + 1, 15.0));
// A drag from the bottom right anchor, previewed, one step.
press(&mut s, PointerKind::Down, 300.0, 300.0);
press(&mut s, PointerKind::Drag, 280.0, 280.0);
assert_eq!(drawn(&s, id, 2), 20.0, "the preview shows");
press(&mut s, PointerKind::Drag, 270.0, 270.0);
press(&mut s, PointerKind::Up, 270.0, 270.0);
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 2, 30.0));
// A drag on the marker resizes; a double-click on it removes.
press(&mut s, PointerKind::Down, 270.0, 270.0);
press(&mut s, PointerKind::Drag, 260.0, 260.0);
press(&mut s, PointerKind::Up, 260.0, 260.0);
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 3, 40.0));
press(&mut s, PointerKind::DoubleClick, 260.0, 260.0);
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 4, 0.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(drawn(&s, id, 2), 40.0);
// A click on a marker selects the corner and loads its values.
press(&mut s, PointerKind::Down, 285.0, 115.0);
press(&mut s, PointerKind::Up, 285.0, 115.0);
assert_eq!(steps(&s), n + 3, "selecting is no edit");
assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(2));
// The bottom right one, resized by its marker, loads its radius.
press(&mut s, PointerKind::Down, 260.0, 260.0);
press(&mut s, PointerKind::Up, 260.0, 260.0);
assert_eq!(s.tool_options_of("cornerTool")["radius"], json!(40.0));
// An Alt-marquee over the left side: both anchors, in one step.
let alt = vectorcraft_tools::Mods { alt: true, ..Default::default() };
for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 350.0), (PointerKind::Up, 150.0, 350.0)] {
s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), view).unwrap();
}
assert_eq!((steps(&s), drawn(&s, id, 0), drawn(&s, id, 3)), (n + 4, 40.0, 40.0));
}
// ---------- Slide Anchor Along Path ----------
#[test]
fn slide_anchor_moves_it_along_the_path_as_one_undo_step() {
let mut s = session();
let e = circle(&mut s);
let before = path(&s, e);
let area = kurbo::Shape::area(&before.to_bezpath()).abs();
let want = before.subpaths[0].segment(3);
let n = steps(&s);
let r = s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0.5})).unwrap();
assert!(r["strayed"].as_f64().unwrap() < 0.05, "{r}");
let after = path(&s, e);
assert_eq!((steps(&s), after.anchor_count()), (n + 1, 16));
assert!(after.subpaths[0].anchors[3].p.distance(kurbo::ParamCurve::eval(&want, 0.5)) < 1e-9);
assert!((kurbo::Shape::area(&after.to_bezpath()).abs() / area - 1.0).abs() < 1e-3);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, e), before);
// No move, no step.
s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0})).unwrap();
assert_eq!(steps(&s), n);
}
#[test]
fn slide_anchor_refuses_bad_params() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
let i = l.0;
refuses(
&mut s,
"addon.pathtools.slideAnchor",
&[
json!({"id": i, "anchor": 0, "t": 0.5}),
json!({"id": i, "anchor": 2, "t": -0.5}),
json!({"id": i, "anchor": 9, "t": 0.5}),
json!({"id": i, "anchor": 1, "t": 1.5}),
json!({"id": i, "anchor": 1}),
json!({"id": i, "t": 0.5}),
json!({"id": i, "subpath": 4, "anchor": 1, "t": 0.5}),
json!({"id": u64::MAX, "anchor": 1, "t": 0.5}),
],
);
}
#[test]
fn the_slide_tool_previews_and_commits_one_step() {
let mut s = session();
let l = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false);
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("slideAnchor", view).unwrap();
let n = steps(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 100.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 120.0), view).unwrap();
let at = |s: &Session| path(s, l).subpaths[0].anchors[1].p;
assert!(at(&s).distance(Point::new(250.0, 100.0)) < 1e-3, "the preview shows");
s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), view).unwrap();
assert!(steps(&s) == n + 1 && at(&s).distance(Point::new(280.0, 100.0)) < 1e-3);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(at(&s), Point::new(200.0, 100.0));
}
+1
View File
@@ -35,4 +35,5 @@ pub(crate) use arrange::group_nodes;
pub(crate) use bounds::bounds_rewrite;
pub use commands::specs;
pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip};
pub(super) use shape::recut;
pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects};
+24 -9
View File
@@ -1,3 +1,6 @@
use vectorcraft_doc::AnchorRef;
use vectorcraft_geom::PathData;
use super::spans::Span;
use super::{
BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches,
@@ -249,17 +252,29 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
Some(k) => k.clone(),
None => c.picked(partial.as_deref()),
};
let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec());
set_corners(path, live, &picked, radius, kind);
// Direct-Selected corners stay selected as they gain or lose anchors.
if let (Some(anchors), Some(selected)) = (partial, selected)
&& let Some(c) = LiveCorners::new(path, live.as_deref())
&& c.sources() != layout
{
*anchors = c.anchors_of(&selected);
}
recut(path, live, partial, &picked, radius, kind);
}
Ok(())
})?;
ok()
}
/// [`set_corners`] with the Direct-Selected corners (`partial`, the path's selected anchors)
/// staying selected as they gain or lose anchors.
pub(in crate::cmd) fn recut(
path: &mut PathData,
live: &mut Option<Box<LiveShape>>,
partial: Option<&mut BTreeSet<AnchorRef>>,
picked: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
let before = LiveCorners::new(path, live.as_deref()).map(|c| (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()));
set_corners(path, live, picked, radius, kind);
if let (Some(anchors), Some((Some(selected), layout))) = (partial, before)
&& let Some(c) = LiveCorners::new(path, live.as_deref())
&& c.sources() != layout
{
*anchors = c.anchors_of(&selected);
}
}
+13
View File
@@ -543,6 +543,19 @@ fn structured_junk() {
("addon.pathtools.shape.set", json!({"id": 2, "center": [1, 2, 3], "radius": "big"})),
("addon.pathtools.shape.convert", json!({"ids": [0, 1, 2, 3, u64::MAX]})),
("addon.pathtools.shape.convert", json!({"ids": "all"})),
// Path Precision Tools: the Corner Tool and Slide Anchor Along Path.
("addon.pathtools.cornerSet", json!({"ids": [2], "radius": 1e6, "kind": "chamfer"})),
("addon.pathtools.cornerSet", json!({"ids": [2, 3], "anchors": [0, 1, u64::MAX], "radius": 5})),
("addon.pathtools.cornerSet", json!({"items": [{"id": 2, "anchors": [0]}, {"id": u64::MAX}], "radius": 1e-300})),
("addon.pathtools.cornerSet", json!({"items": [1, null, {"anchors": "x"}], "radius": 1})),
("addon.pathtools.cornerSet", json!({"ids": [2], "radius": 1e308, "kind": "spiky"})),
("addon.pathtools.cornerSet", json!({"items": "all", "radius": f64::NAN, "anchors": [-1, 1.5, "x"]})),
("addon.pathtools.cornerRemove", json!({"ids": [0, 1, 2, 3, u64::MAX]})),
("addon.pathtools.cornerRemove", json!({"items": [{"id": 2, "anchors": [u64::MAX]}]})),
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.98})),
("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"})),
// 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 -1
View File
@@ -5,7 +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 and the Parametric Shape Tool's re-editable shapes.
//! finding where paths cross, sliding anchors along their path 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.
+5 -2
View File
@@ -1,11 +1,13 @@
//! Path Precision Tools' geometry: anchor edits for the Anchor Inspector (smooth by the auto-smooth
//! rule, equalise handles, add evenly spaced anchors, close), the readouts the Measure & Inspect
//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]) and the places
//! where paths cross ([`crossings`]), and the Parametric Shape Tool's shapes ([`shapes`]).
//! 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`]).
mod crossings;
mod extend;
pub mod shapes;
mod slide;
#[cfg(test)]
mod tests;
@@ -16,6 +18,7 @@ use vectorcraft_geom::{AnchorKind, PathData, SubPath};
pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at};
pub use extend::{ExtendMode, extend_length_to, extend_open};
pub use slide::{MAX_SLIDE, slide_anchor, slide_stops, slide_t_at};
use crate::fit::{end_tangent, start_tangent};
@@ -0,0 +1,107 @@
//! Sliding an anchor along its path: the anchor moves to a point on one of its two segments and
//! the segments beside it are refitted, so the outline stays where it was. It is a new anchor at
//! that point (an exact split) with the old one removed by the shared keep-shape refit.
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point};
use vectorcraft_geom::SubPath;
use super::{ACCURACY, TINY, insert_at, neighbours};
use crate::addons::keep_shape::remove_anchor_keep_shape;
use crate::fit::is_straight;
/// How far along a neighbouring segment an anchor can slide: stopping short of the next anchor
/// keeps a segment between them.
pub const MAX_SLIDE: f64 = 0.98;
/// Steps of the search for the places on a curve where the tangent is at a snapping angle.
const STOP_SAMPLES: usize = 64;
/// Bisection steps that pin one of those places down.
const STOP_STEPS: usize = 40;
/// A stop this close to the anchor is the anchor itself.
const MIN_STOP: f64 = 1e-6;
/// The indices of the anchors beside anchor `i`; `None` for an end of an open subpath.
fn both_neighbours(sp: &SubPath, i: usize) -> Option<(usize, usize)> {
if i >= sp.anchors.len() {
return None;
}
match neighbours(sp, i) {
(Some(prev), Some(next)) => Some((prev, next)),
_ => None,
}
}
/// Anchor `index` of `sp` moved along the path by `t` → (the new subpath, how far its outline
/// strays from the old one, in points). `t` says where: a positive one is the curve parameter on
/// the segment after the anchor, a negative one the parameter back from the end of the segment
/// before it (`-t` from 1). The subpath keeps its anchor count and the anchor its index. `None` when the anchor can't slide: an end of
/// an open subpath, out of range, `t` not a number or past [`MAX_SLIDE`], non-finite coordinates,
/// or a closed subpath of two anchors.
pub fn slide_anchor(sp: &SubPath, index: usize, t: f64) -> Option<(SubPath, f64)> {
if t.is_nan() || t.abs() > MAX_SLIDE {
return None;
}
let (prev, _) = both_neighbours(sp, index)?;
let forward = t > 0.0;
let (split, _) = if forward { insert_at(sp, &[(index, t)]) } else { insert_at(sp, &[(prev, 1.0 + t)]) };
// Too small a move to split a segment: the anchor stays.
if split.anchors.len() == sp.anchors.len() {
return Some((sp.clone(), 0.0));
}
// Backward the new anchor comes just before the old one, or last on a closed subpath's first.
let old = if !forward && index > 0 { index + 1 } else { index };
let (mut out, strayed) = remove_anchor_keep_shape(&split, old)?;
if !forward && index == 0 {
out.anchors.rotate_right(1);
}
Some((out, strayed))
}
/// The slide (see [`slide_anchor`]) that brings anchor `index` nearest to `p`, held to
/// [`MAX_SLIDE`]; `None` when the anchor can't slide.
pub fn slide_t_at(sp: &SubPath, index: usize, p: Point) -> Option<f64> {
let (prev, _) = both_neighbours(sp, index)?;
let (before, after) = (sp.segment(prev).nearest(p, ACCURACY), sp.segment(index).nearest(p, ACCURACY));
let t = if before.distance_sq < after.distance_sq { before.t - 1.0 } else { after.t };
Some(t.clamp(-MAX_SLIDE, MAX_SLIDE))
}
/// The slides of anchor `index` (see [`slide_anchor`]) that land where the path's tangent is at a
/// multiple of 45° from `base` degrees, in order.
pub fn slide_stops(sp: &SubPath, index: usize, base: f64) -> Vec<f64> {
let Some((prev, _)) = both_neighbours(sp, index) else { return vec![] };
let before = tangent_stops(&sp.segment(prev), base).into_iter().map(|t| t - 1.0);
before.chain(tangent_stops(&sp.segment(index), base)).filter(|t| (MIN_STOP..=MAX_SLIDE).contains(&t.abs())).collect()
}
/// The parameters of `c` inside 0..1 where its tangent is at a multiple of 45° from `base`
/// degrees, in order. A straight segment has none: its tangent never reaches one or never leaves.
fn tangent_stops(c: &CubicBez, base: f64) -> Vec<f64> {
if is_straight(c, TINY) {
return vec![];
}
let d = c.deriv();
let base = base.to_radians();
// Zero at each multiple, and it doesn't jump where the angle wraps round or the curve reverses.
let f = |t: f64| {
let v = d.eval(t).to_vec2();
(v.hypot() > TINY).then(|| ((v.y.atan2(v.x) - base) * 4.0).sin())
};
let mut out = vec![];
for k in 0..STOP_SAMPLES {
let (mut a, mut b) = (k as f64 / STOP_SAMPLES as f64, (k + 1) as f64 / STOP_SAMPLES as f64);
let (Some(fa), Some(fb)) = (f(a), f(b)) else { continue };
if (fa < 0.0) == (fb < 0.0) {
continue;
}
for _ in 0..STOP_STEPS {
let m = (a + b) / 2.0;
match f(m) {
Some(fm) if (fm < 0.0) == (fa < 0.0) => a = m,
_ => b = m,
}
}
out.push(b);
}
out
}
@@ -294,3 +294,68 @@ fn the_brush_kernel_removes_only_marked_anchors() {
assert_eq!(simplify_anchors_keep_shape(&bump, 0.1, &[true; 5]).anchors.len(), 4, "only the anchor on the straight run goes");
assert_eq!(simplify_anchors_keep_shape(&bump, f64::INFINITY, &[true; 5]).anchors.len(), 2, "unless tolerance is ignored");
}
/// A circle about (0, 0) of radius 100 with `n` smooth anchors, each arc a cubic.
fn circle(n: usize) -> SubPath {
let step = std::f64::consts::TAU / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan() * 100.0;
let anchors = (0..n).map(|i| {
let a = i as f64 * step;
let (p, tan) = (pt(a.cos() * 100.0, a.sin() * 100.0), Vec2::new(-a.sin(), a.cos()));
Anchor { p, h_in: p - tan * k, h_out: p + tan * k, kind: AnchorKind::Smooth }
});
SubPath::new(anchors.collect(), true)
}
#[test]
fn a_slid_anchor_keeps_the_shape_its_count_and_its_index() {
let sp = circle(8);
for (index, t) in [(2, 0.5), (2, -0.5), (0, -0.25), (0, 0.25), (7, 0.9), (7, -0.9)] {
let (out, strayed) = slide_anchor(&sp, index, t).unwrap();
let seg = if t > 0.0 { sp.segment(index) } else { sp.segment((index + 7) % 8) };
let want = seg.eval(if t > 0.0 { t } else { 1.0 + t });
assert_eq!(out.anchors.len(), 8, "{index} {t}");
assert!(out.anchors[index].p.distance(want) < 1e-9, "{index} {t}: the anchor is where it was sent");
assert!(strayed < 0.05 && off_circle(&out, pt(0.0, 0.0), 100.0) < 0.05, "{index} {t}: {strayed}");
// The other anchors stay.
assert!((0..8).filter(|i| *i != index).all(|i| out.anchors[i].p == sp.anchors[i].p), "{index} {t}");
}
}
#[test]
fn sliding_along_straight_runs_stays_straight_and_ends_do_not_slide() {
let sp = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (200.0, 100.0)], false);
let (out, strayed) = slide_anchor(&sp, 1, 0.5).unwrap();
assert_eq!(out.anchors[1].p, pt(150.0, 0.0));
assert!(strayed < 1e-9 && out.segment_is_line(0) && out.segment_is_line(1));
assert_eq!(slide_anchor(&sp, 1, 0.0).unwrap().0, sp, "no move");
assert_eq!(slide_anchor(&sp, 1, 1e-9).unwrap().0, sp, "too small a move");
for (i, t) in [(0, 0.5), (3, -0.5), (9, 0.5), (1, 0.99), (1, -1.0), (1, f64::NAN), (1, f64::INFINITY)] {
assert!(slide_anchor(&sp, i, t).is_none(), "{i} {t}");
}
let mut bad = sp.clone();
bad.anchors[1].h_out = pt(f64::NAN, 0.0);
assert!(slide_anchor(&bad, 1, 0.5).is_none());
}
#[test]
fn the_slide_a_pointer_asks_for_and_where_the_tangent_stops() {
let sp = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false);
assert!(near(slide_t_at(&sp, 1, pt(150.0, 30.0)).unwrap(), 0.5, 1e-6));
let back = slide_t_at(&sp, 1, pt(75.0, -30.0)).unwrap();
assert!(back < 0.0 && sp.segment(0).eval(1.0 + back).distance(pt(75.0, 0.0)) < 1e-3, "{back}");
assert!(near(slide_t_at(&sp, 1, pt(900.0, 0.0)).unwrap(), MAX_SLIDE, 1e-9), "held short of the next anchor");
assert!(slide_t_at(&sp, 0, pt(50.0, 0.0)).is_none());
assert!(slide_stops(&sp, 1, 0.0).is_empty(), "a straight run has no tangent to reach");
let slanted = line(&[(0.0, 0.0), (100.0, 100.0), (250.0, 250.0)], false);
assert!(slide_stops(&slanted, 1, 0.0).is_empty(), "nor one that lies on a multiple of 45°");
// Round a circle of 4 anchors the tangent is at a multiple of 45° at each anchor and half way.
let round = circle(4);
let stops = slide_stops(&round, 0, 0.0);
assert_eq!(stops.len(), 2, "{stops:?}");
assert!(near(stops[0], -0.5, 1e-6) && near(stops[1], 0.5, 1e-6), "{stops:?}");
// From a constrain angle of 22.5°, between those.
let stops = slide_stops(&round, 0, 22.5);
assert_eq!(stops.len(), 4, "{stops:?}");
assert!(stops.windows(2).all(|w| w[0] < w[1]));
}
+6
View File
@@ -3,9 +3,11 @@
//! submenu selects them. Like every tool they only emit commands (`addon.<name>.*`).
pub mod anchor_eraser;
pub mod corner_tool;
pub mod extend_path;
pub mod measure_inspect;
pub mod parametric_shape;
pub mod slide_anchor;
pub mod smart_pen;
use crate::{Tool, ToolInfo};
@@ -16,6 +18,8 @@ pub const PATH_TOOLS: &[ToolInfo] = &[
ToolInfo { id: extend_path::ID, label: "Extend Path Tool", shortcut: None, icon: "dc-arrow-extend" },
ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" },
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" },
];
/// Smart Pen's tool, a flyout group of its own.
@@ -37,9 +41,11 @@ pub fn heading(id: &str) -> Option<&'static str> {
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()),
corner_tool::ID => Box::new(corner_tool::CornerTool::default()),
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()),
slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()),
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
_ => return None,
})
+387
View File
@@ -0,0 +1,387 @@
//! Corner Tool (Path Precision Tools): editable corners on any path, selected or not, through the
//! app's Live Corners (`addon.pathtools.cornerSet` / `cornerRemove`). Over an anchor that can take
//! a corner, the anchor shows; a click cuts one with the tool's radius and kind, a drag sizes it
//! (Shift: whole values of the ruler's unit). A cut corner shows a marker at the centre of its cut:
//! a click selects the corner and loads its radius and kind into the tool, a drag resizes it (to 0
//! it goes), a double-click removes it. A corner can't grow past what fits. Alt-marquee cuts
//! corners on every anchor inside; a plain marquee selects them, and a click away deselects.
use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, LiveCorners, NodeId, NodeKind};
use vectorcraft_geom::corners::Corner;
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, Point, Rect};
use crate::builder::HIGHLIGHT_RED;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "cornerTool";
/// The radius a click cuts by default (pt).
pub const RADIUS: f64 = 10.0;
/// The largest radius asked for (pt).
pub const MAX_RADIUS: f64 = 1e6;
/// The undo step of a drag.
const LABEL: &str = "Corner Radius";
const SET: &str = "addon.pathtools.cornerSet";
const REMOVE: &str = "addon.pathtools.cornerRemove";
/// How far (px) the pointer travels before a press is a drag.
const DRAG_PX: f64 = 3.0;
/// Most markers drawn at once.
const MAX_MARKERS: usize = 2000;
const MARKER_PX: f32 = 7.0;
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
/// A corner the tool can act on.
#[derive(Clone, Debug, PartialEq)]
struct Spot {
id: NodeId,
corner: Corner,
/// Maps the corner's space into the document.
xf: Affine,
/// The radius it is drawn with: 0 for a sharp corner.
drawn: f64,
kind: CornerKind,
}
impl Spot {
fn of(lc: &LiveCorners, id: NodeId, corner: &Corner) -> Self {
Self { id, corner: *corner, xf: lc.xf, drawn: lc.drawn_radius(corner.index), kind: lc.kind(corner.index) }
}
/// Where it shows: a cut corner's marker, the centre of a round cut, or a sharp corner's anchor.
fn at(&self) -> Point {
self.xf * self.corner.on_bisector(self.drawn / self.corner.sin())
}
/// The params naming it.
fn items(&self) -> Value {
json!([{ "id": self.id.0, "anchors": [self.corner.index] }])
}
}
/// Every cut corner of the editable paths.
fn cut_corners(doc: &Document) -> Vec<Spot> {
let editable = doc.editable();
let mut out = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { live: Some(_), guide: false, .. })
&& editable(n.id)
&& let Some(lc) = LiveCorners::of(n)
{
out.extend(lc.corners.iter().filter(|c| lc.drawn_radius(c.index) > 0.0).map(|c| Spot::of(&lc, n.id, c)));
}
});
out
}
/// The sharp corner of an editable path nearest `p` within the point tolerance.
fn sharp_corner_at(cx: &ToolContext, p: Point) -> Option<Spot> {
let (tol, editable) = (cx.point_tol(), cx.doc.editable());
let mut best: Option<(f64, Spot)> = None;
cx.doc.walk(|n| {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
if !path.anchors().any(|(_, _, a)| a.p.distance(p) <= tol) || !editable(n.id) {
return;
}
let Some(lc) = LiveCorners::of(n) else { return };
for c in lc.corners.iter().filter(|c| lc.drawn_radius(c.index) <= 0.0) {
let s = Spot::of(&lc, n.id, c);
let d = s.at().distance(p);
if d <= tol && best.as_ref().is_none_or(|b| d < b.0) {
best = Some((d, s));
}
}
});
best.map(|(_, s)| s)
}
/// The corners of the editable paths inside `r`, by path: anchor indices of each uncut outline.
fn corners_inside(cx: &ToolContext, r: Rect) -> BTreeMap<NodeId, Vec<usize>> {
let editable = cx.doc.editable();
let mut out = BTreeMap::new();
cx.doc.walk(|n| {
if !matches!(n.kind, NodeKind::Path { guide: false, .. }) || !editable(n.id) {
return;
}
let Some(lc) = LiveCorners::of(n) else { return };
let inside: Vec<usize> = lc.corners.iter().filter(|c| r.contains(lc.xf * c.at)).map(|c| c.index).collect();
if !inside.is_empty() {
out.insert(n.id, inside);
}
});
out
}
/// The largest radius `spot` draws with the corners around it as they are.
fn max_radius(doc: &Document, spot: &Spot) -> f64 {
let Some(lc) = doc.node(spot.id).and_then(LiveCorners::of) else { return 0.0 };
let radii: Vec<f64> = (0..lc.base.anchor_count()).map(|k| lc.drawn_radius(k)).collect();
spot.corner.max_shared_radius(&lc.corners, &radii, |k| k == spot.corner.index)
}
/// `select.anchors` params selecting `corners` of path `id`.
fn select_params(cx: &ToolContext, id: NodeId, corners: &[usize], mode: &str) -> Option<Value> {
let lc = cx.doc.node(id).and_then(LiveCorners::of)?;
let anchors: Vec<_> = lc.anchors_of(&corners.iter().copied().collect()).into_iter().collect();
Some(json!({ "id": id.0, "anchors": anchors, "mode": mode }))
}
/// A document revision and the cut corners it has.
type Marks = ((u64, u64), Arc<Vec<Spot>>);
#[derive(Clone, Debug, Default)]
enum State {
#[default]
Idle,
/// Pressed on a corner: a drag sizes it.
Corner {
spot: Box<Spot>,
start: Point,
/// The largest radius that fits.
max: f64,
/// The drag has left the click.
began: bool,
/// The radius the drag asks for.
radius: f64,
/// Where the pointer is.
at: Point,
},
/// `cut`: an Alt-marquee, which cuts corners; else it selects them.
Marquee { start: Point, cur: Point, cut: bool, toggle: bool },
}
pub struct CornerTool {
radius: f64,
kind: CornerKind,
state: State,
hover: Option<Spot>,
/// The cut corners of the document as it was at a revision, for hovering and the markers.
cache: Cell<Option<Marks>>,
}
impl Default for CornerTool {
fn default() -> Self {
Self { radius: RADIUS, kind: CornerKind::Round, state: State::Idle, hover: None, cache: Cell::new(None) }
}
}
impl CornerTool {
fn markers(&self, cx: &ToolContext) -> Arc<Vec<Spot>> {
let kept = self.cache.take().filter(|(rev, _)| *rev == cx.revision);
let list = kept.map_or_else(|| Arc::new(cut_corners(cx.doc)), |(_, list)| list);
self.cache.set(Some((cx.revision, list.clone())));
list
}
/// The corner at `p`: a cut corner's marker, else a sharp corner's anchor.
fn spot_at(&self, cx: &ToolContext, p: Point) -> Option<Spot> {
let (marks, tol) = (self.markers(cx), cx.point_tol());
let dist = |s: &&Spot| s.at().distance(p);
let mark = marks.iter().filter(|s| dist(s) <= tol).min_by(|a, b| dist(a).total_cmp(&dist(b)));
mark.cloned().or_else(|| sharp_corner_at(cx, p))
}
/// The command a drag of `spot` to `radius` stands for.
fn sized(&self, spot: &Spot, radius: f64) -> Action {
if radius > 0.0 {
let kind = if spot.drawn > 0.0 { spot.kind } else { self.kind };
Action::Preview(SET.into(), json!({ "items": spot.items(), "radius": radius, "kind": kind }))
} else {
Action::Preview(REMOVE.into(), json!({ "items": spot.items() }))
}
}
fn drag_corner(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
let State::Corner { spot, start, max, began, radius, at } = &mut self.state else { return vec![] };
*at = p;
let mut out = vec![];
if !*began {
if p.distance(*start) < cx.tol(DRAG_PX) {
return out;
}
*began = true;
out.push(Action::Begin(LABEL.into()));
}
let inv = spot.xf.inverse();
let change = spot.corner.radius_change(inv * p - inv * *start);
// From a sharp corner a drag either way sizes it; a cut one grows inward and shrinks back.
let asked = if spot.drawn > 0.0 { spot.drawn + change } else { change.abs() };
let asked = if mods.shift { cx.unit.to_pt(cx.unit.from_pt(asked).round()) } else { asked };
*radius = asked.min(*max).max(0.0);
let (spot, r) = (spot.clone(), *radius);
out.push(self.sized(&spot, r));
out
}
/// A press on a corner let go without a drag.
fn click_corner(&self, cx: &ToolContext, spot: &Spot, mods: Mods) -> Vec<Action> {
if spot.drawn <= 0.0 {
return vec![Action::Exec(SET.into(), json!({ "items": spot.items(), "radius": self.radius, "kind": self.kind }))];
}
let mode = if mods.shift { "toggle" } else { "set" };
select_params(cx, spot.id, &[spot.corner.index], mode).map_or_else(Vec::new, |p| vec![Action::Exec("select.anchors".into(), p)])
}
fn finish_marquee(&self, cx: &ToolContext, r: Rect, cut: bool, toggle: bool) -> Vec<Action> {
let inside = corners_inside(cx, r);
if cut {
if inside.is_empty() {
return vec![];
}
let items: Vec<Value> = inside.iter().map(|(id, corners)| json!({ "id": id.0, "anchors": corners })).collect();
return vec![Action::Exec(SET.into(), json!({ "items": items, "radius": self.radius, "kind": self.kind }))];
}
if inside.is_empty() && !toggle {
return vec![Action::Exec("select.none".into(), json!({}))];
}
let modes = std::iter::once(if toggle { "toggle" } else { "set" }).chain(std::iter::repeat(if toggle { "toggle" } else { "add" }));
inside
.iter()
.zip(modes)
.filter_map(|((id, corners), mode)| Some(Action::Exec("select.anchors".into(), select_params(cx, *id, corners, mode)?)))
.collect()
}
}
impl Tool for CornerTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
!matches!(self.state, State::Idle)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = self.spot_at(cx, p);
vec![]
}
PointerKind::Down => {
self.hover = None;
let spot = if ev.mods.alt { None } else { self.spot_at(cx, p) };
self.state = match spot {
Some(spot) => {
// A cut corner's values are the tool's from now on, as the panel shows.
if spot.drawn > 0.0 {
(self.radius, self.kind) = (spot.drawn, spot.kind);
}
let max = max_radius(cx.doc, &spot);
State::Corner { radius: spot.drawn, spot: Box::new(spot), start: p, max, began: false, at: p }
}
None => State::Marquee { start: p, cur: p, cut: ev.mods.alt, toggle: ev.mods.shift },
};
vec![]
}
PointerKind::Drag => {
if let State::Marquee { cur, .. } = &mut self.state {
*cur = p;
return vec![];
}
self.drag_corner(cx, p, ev.mods)
}
PointerKind::Up => match std::mem::take(&mut self.state) {
State::Idle => vec![],
State::Marquee { start, cut, toggle, .. } => self.finish_marquee(cx, Rect::from_points(start, p), cut, toggle),
state @ State::Corner { began: true, .. } => {
self.state = state;
let mut out = self.drag_corner(cx, p, ev.mods);
if let State::Corner { spot, radius, .. } = std::mem::take(&mut self.state) {
// Back where it started: nothing to keep.
if spot.drawn <= 0.0 && radius <= 0.0 {
return vec![Action::Cancel];
}
}
out.push(Action::Commit);
out
}
State::Corner { spot, .. } => self.click_corner(cx, &spot, ev.mods),
},
PointerKind::DoubleClick => {
self.state = State::Idle;
match self.spot_at(cx, p).filter(|s| s.drawn > 0.0) {
Some(s) => vec![Action::Exec(REMOVE.into(), json!({ "items": s.items() }))],
None => vec![],
}
}
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key != ToolKey::Escape {
return vec![];
}
match std::mem::take(&mut self.state) {
State::Corner { began: true, .. } => vec![Action::Cancel],
_ => vec![],
}
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
self.busy() && key == ToolKey::Escape
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = None;
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let ring = |p, color, filled| Overlay::CurvePoint { p, color, filled, size: MARKER_PX };
match &self.state {
State::Marquee { start, cur, .. } => return vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
State::Corner { began: true, radius, max, at, .. } => {
let mut out = vec![];
if cx.measurement_labels {
out.push(Overlay::Measure { p: *at, text: format!("Radius: {}", cx.len(*radius)) });
}
// At the limit of what fits.
if *max > 0.0 && *radius >= *max {
out.push(ring(*at, HIGHLIGHT_RED, false));
}
return out;
}
_ => {}
}
let mut out: Vec<Overlay> = self.markers(cx).iter().take(MAX_MARKERS).map(|s| ring(s.at(), BLUE, false)).collect();
match &self.hover {
Some(s) if s.drawn > 0.0 => out.push(ring(s.at(), BLUE, true)),
Some(s) => out.push(Overlay::Anchor { p: s.at(), color: BLUE, filled: true, size: MARKER_PX }),
None => {}
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::CornerTool
}
fn options(&self) -> Value {
json!({ "radius": self.radius, "kind": self.kind })
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"radius" => {
if let Some(r) = v.as_f64().filter(|r| (0.0..=MAX_RADIUS).contains(r)) {
self.radius = r;
}
}
"kind" => {
if let Ok(k) = serde_json::from_value(v.clone()) {
self.kind = k;
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,161 @@
use vectorcraft_doc::corners::set_corners;
use vectorcraft_doc::{Appearance, Node, Selection};
use vectorcraft_geom::{PathData, SubPath};
use super::*;
use crate::testutil::*;
/// A square (100, 100)–(300, 300), plain: anchors from the top left, clockwise.
fn doc_with_square() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)].map(|(x, y)| Point::new(x, y));
d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, true)), Appearance::default_art())).unwrap();
(d, id)
}
/// [`doc_with_square`] with a chamfer of radius 20 on corner 1, whose marker is at (280, 120).
fn doc_with_corner() -> (Document, NodeId) {
let (mut d, id) = doc_with_square();
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!() };
set_corners(path, live, &[1].into(), Some(20.0), Some(CornerKind::Chamfer));
(d, id)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
fn with(mut e: PointerEvent, f: impl Fn(&mut Mods)) -> PointerEvent {
f(&mut e.mods);
e
}
fn radius_of(a: Option<&Action>) -> f64 {
let Some(Action::Preview(_, p)) = a else { panic!("{a:?}") };
p["radius"].as_f64().unwrap()
}
#[test]
fn hover_shows_the_anchor_and_a_click_cuts_with_the_tools_values() {
let (d, id) = doc_with_square();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
t.set_option("radius", &json!(15));
t.set_option("kind", &json!("invertedRound"));
assert!(t.pointer(&cx, &ev(PointerKind::Move, 301.0, 101.0)).is_empty());
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }]), "{:?}", t.overlays(&cx));
t.pointer(&cx, &ev(PointerKind::Move, 200.0, 200.0));
assert!(t.overlays(&cx).is_empty(), "nothing there");
assert!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0)).is_empty());
assert!(t.busy());
let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0));
assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [1]}], "radius": 15.0, "kind": "invertedRound"}))]);
assert!(!t.busy());
}
#[test]
fn a_drag_from_an_anchor_sizes_the_corner_and_stops_at_what_fits() {
let (d, id) = doc_with_square();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
assert_eq!(drag.first(), Some(&Action::Begin(LABEL.into())));
assert!((radius_of(drag.get(1)) - 20.0).abs() < 1e-9, "{drag:?}");
let Some(Action::Preview(cmd, params)) = drag.get(1) else { panic!() };
assert_eq!((cmd.as_str(), params["items"][0]["id"].clone(), params["kind"].clone()), (SET, json!(id.0), json!("round")));
// Shift: whole values.
let shifted = t.pointer(&cx, &with(ev(PointerKind::Drag, 276.6, 123.4), |m| m.shift = true));
assert_eq!(radius_of(shifted.first()), 23.0);
// Past what fits: as far as the sharp corners next to it, marked at the limit.
let far = t.pointer(&cx, &ev(PointerKind::Drag, 0.0, 400.0));
assert!((radius_of(far.first()) - 200.0).abs() < 1e-9, "{far:?}");
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { color, .. } if *color == HIGHLIGHT_RED)));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Radius: 200"))));
let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0));
assert!(matches!(up.as_slice(), [Action::Preview(..), Action::Commit]), "{up:?}");
// Back on the anchor: nothing to keep. Escape cancels.
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0)), vec![Action::Cancel]);
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
assert!(t.claims_key(&cx, ToolKey::Escape));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert!(!t.busy());
}
#[test]
fn a_cut_corners_marker_selects_loads_resizes_and_removes() {
let (d, id) = doc_with_corner();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::CurvePoint { filled: false, .. }]), "the marker shows");
t.pointer(&cx, &ev(PointerKind::Move, 281.0, 119.0));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { filled: true, .. })), "hovered");
// A click selects the corner's anchors and loads its values.
t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0));
let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0));
let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") };
assert_eq!((cmd.as_str(), params["id"].clone(), params["mode"].clone()), ("select.anchors", json!(id.0), json!("set")));
assert_eq!(params["anchors"], json!([[0, 1], [0, 2]]));
assert_eq!(t.options(), json!({"radius": 20.0, "kind": "chamfer"}));
// A drag resizes it, its kind kept; to nothing it goes.
t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0));
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 270.0, 130.0));
assert!((radius_of(drag.get(1)) - 30.0).abs() < 1e-9, "{drag:?}");
assert!(matches!(drag.get(1), Some(Action::Preview(_, p)) if p["kind"] == json!("chamfer")));
let gone = t.pointer(&cx, &ev(PointerKind::Up, 310.0, 90.0));
assert!(matches!(gone.as_slice(), [Action::Preview(c, _), Action::Commit] if c == REMOVE), "{gone:?}");
// A double-click removes it.
let dbl = t.pointer(&cx, &ev(PointerKind::DoubleClick, 280.0, 120.0));
assert_eq!(dbl, vec![Action::Exec(REMOVE.into(), json!({"items": [{"id": id.0, "anchors": [1]}]}))]);
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 300.0, 300.0)).is_empty(), "a sharp corner has nothing to remove");
}
#[test]
fn an_alt_marquee_cuts_every_corner_inside_and_a_plain_one_selects() {
let (d, id) = doc_with_corner();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
// Alt over an anchor starts a marquee too; the chamfered corner counts, the far side doesn't.
let alt = |e| with(e, |m| m.alt = true);
t.pointer(&cx, &alt(ev(PointerKind::Down, 50.0, 50.0)));
t.pointer(&cx, &alt(ev(PointerKind::Drag, 350.0, 200.0)));
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Marquee(_)]));
let up = t.pointer(&cx, &alt(ev(PointerKind::Up, 350.0, 200.0)));
assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [0, 1]}], "radius": RADIUS, "kind": "round"}))]);
t.pointer(&cx, &alt(ev(PointerKind::Down, 400.0, 400.0)));
assert!(t.pointer(&cx, &alt(ev(PointerKind::Up, 450.0, 450.0))).is_empty(), "nothing inside");
// Without Alt the corners inside are selected; a click away deselects.
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
let up = t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0));
let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") };
assert_eq!((cmd.as_str(), params["anchors"].clone(), params["mode"].clone()), ("select.anchors", json!([[0, 0]]), json!("set")));
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
#[test]
fn locked_paths_are_left_alone_and_options_are_checked() {
let (mut d, id) = doc_with_corner();
d.node_mut(id).unwrap().locked = true;
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
assert!(t.overlays(&cx).is_empty());
t.pointer(&cx, &ev(PointerKind::Move, 100.0, 100.0));
assert!(t.overlays(&cx).is_empty());
for bad in [json!(-1), json!(1e308), json!("x")] {
t.set_option("radius", &bad);
}
t.set_option("kind", &json!("spiky"));
assert_eq!(t.options(), json!({"radius": RADIUS, "kind": "round"}));
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::CornerTool);
}
+231
View File
@@ -0,0 +1,231 @@
//! Slide Anchor Along Path (Path Precision Tools): drag an anchor, selected or not, and it runs
//! along the curve it sits on, the two segments beside it refitted so the outline stays
//! (`addon.pathtools.slideAnchor`, previewed while dragging, one undo step). 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.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Point, SubPath};
use vectorcraft_pathops::addons::pathtools::{slide_stops, slide_t_at};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "slideAnchor";
/// The undo step's name.
const LABEL: &str = "Slide Anchor Along Path";
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
const SIZE: f32 = 7.0;
/// An anchor that can slide.
#[derive(Clone, Debug, PartialEq)]
struct Grip {
id: NodeId,
subpath: usize,
anchor: usize,
p: Point,
/// The subpath as it was.
sp: SubPath,
}
#[derive(Default)]
pub struct SlideAnchorTool {
hover: Option<Grip>,
/// The anchor being dragged and how far along.
drag: Option<(Grip, f64)>,
}
/// The anchor of an editable path nearest `p` within the point tolerance that can slide.
fn grip_at(cx: &ToolContext, p: Point) -> Option<Grip> {
let (tol, editable) = (cx.point_tol(), cx.doc.editable());
let mut best: Option<(f64, NodeId, usize, usize, Point)> = None;
cx.doc.walk(|n| {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
for (si, ai, a) in path.anchors() {
let d = a.p.distance(p);
let open_end = path.subpaths.get(si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()));
if d <= tol && !open_end && best.is_none_or(|b| d < b.0) && editable(n.id) {
best = Some((d, n.id, si, ai, a.p));
}
}
});
let (_, id, subpath, anchor, at) = best?;
let sp = cx.doc.node(id)?.path_data()?.subpaths.get(subpath)?.clone();
Some(Grip { id, subpath, anchor, p: at, sp })
}
/// How far along a drag to `p` takes `g`: with Shift, to the nearest place where the tangent is at
/// a multiple of 45° from the constrain angle.
fn slide_to(cx: &ToolContext, g: &Grip, p: Point, mods: Mods) -> f64 {
let t = slide_t_at(&g.sp, g.anchor, p).unwrap_or(0.0);
let stops = if mods.shift { slide_stops(&g.sp, g.anchor, cx.constrain_angle) } else { vec![] };
stops.into_iter().min_by(|a, b| (a - t).abs().total_cmp(&(b - t).abs())).unwrap_or(t)
}
impl Tool for SlideAnchorTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.hover = grip_at(cx, ev.pos);
vec![]
}
PointerKind::Down => {
let Some(g) = grip_at(cx, ev.pos) else { return vec![] };
self.hover = None;
self.drag = Some((g, 0.0));
vec![Action::Begin(LABEL.into())]
}
PointerKind::Drag | PointerKind::Up => {
let Some((g, _)) = self.drag.take() else { return vec![] };
let t = slide_to(cx, &g, ev.pos, ev.mods);
let params = json!({ "id": g.id.0, "subpath": g.subpath, "anchor": g.anchor, "t": t });
let preview = Action::Preview("addon.pathtools.slideAnchor".into(), params);
if ev.kind == PointerKind::Drag {
self.drag = Some((g, t));
return vec![preview];
}
// Back where it started: nothing to keep.
if t == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] }
}
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.drag.take().is_some() { vec![Action::Cancel] } else { vec![] }
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
self.drag.is_some() && key == ToolKey::Escape
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = None;
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let anchor = |p, filled| Overlay::Anchor { p, color: BLUE, filled, size: SIZE };
match (&self.drag, &self.hover) {
(Some((g, _)), _) => vec![anchor(g.p, false)],
(None, Some(g)) => vec![anchor(g.p, true)],
(None, None) => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::SlideAnchor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Document, Node, Selection};
use vectorcraft_geom::PathData;
/// A document with an open zigzag (100, 100), (200, 100), (300, 100) and a circle-ish curve.
fn doc_with_line() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)];
d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, false)), Appearance::default_art())).unwrap();
(d, id)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn dragging_an_anchor_previews_the_slide_and_commits() {
let (d, id) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Move, 201.0, 101.0)).is_empty());
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }]));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 201.0, 101.0)), vec![Action::Begin(LABEL.into())]);
assert!(t.busy());
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 130.0));
let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") };
assert_eq!(cmd, "addon.pathtools.slideAnchor");
assert_eq!((params["id"].clone(), params["subpath"].clone(), params["anchor"].clone()), (json!(id.0), json!(0), json!(1)));
assert!((params["t"].as_f64().unwrap() - 0.5).abs() < 1e-6);
// The other way along the path.
let up = t.pointer(&cx, &ev(PointerKind::Up, 175.0, 90.0));
assert!(matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if p["t"].as_f64().unwrap() < 0.0), "{up:?}");
assert!(!t.busy());
}
#[test]
fn back_where_it_started_nothing_is_kept_and_escape_cancels() {
let (d, _) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 100.0)), vec![Action::Cancel]);
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0));
t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 100.0));
assert!(t.claims_key(&cx, ToolKey::Escape));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert!(!t.busy() && !t.claims_key(&cx, ToolKey::Escape));
}
#[test]
fn ends_and_empty_canvas_do_nothing() {
let (d, _) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
for (x, y) in [(100.0, 100.0), (300.0, 100.0), (150.0, 300.0)] {
assert!(t.pointer(&cx, &ev(PointerKind::Down, x, y)).is_empty(), "({x}, {y})");
assert!(t.pointer(&cx, &ev(PointerKind::Up, x, y)).is_empty());
}
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::SlideAnchor);
}
#[test]
fn shift_snaps_to_where_the_tangent_is_at_45_degrees() {
// A smooth anchor on a curve turning from east to south through south-east.
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let a = |x: f64, y: f64, h: (f64, f64), o: (f64, f64)| vectorcraft_geom::Anchor {
p: Point::new(x, y),
h_in: Point::new(h.0, h.1),
h_out: Point::new(o.0, o.1),
kind: vectorcraft_geom::AnchorKind::Smooth,
};
let sp = SubPath::new(
vec![
a(0.0, 100.0, (0.0, 100.0), (0.0, 100.0)),
a(100.0, 100.0, (100.0, 100.0), (155.0, 100.0)),
a(200.0, 200.0, (200.0, 145.0), (200.0, 200.0)),
],
false,
);
d.insert(Some(l), 0, Node::path(id, PathData::single(sp.clone()), Appearance::default_art())).unwrap();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
let mut shifted = ev(PointerKind::Drag, 140.0, 105.0);
shifted.mods.shift = true;
let drag = t.pointer(&cx, &shifted);
let Some(Action::Preview(_, params)) = drag.first() else { panic!("{drag:?}") };
let at = params["t"].as_f64().unwrap();
assert!((at - 0.5).abs() < 1e-6, "half way round a symmetric quarter turn: {at}");
}
}
+11 -1
View File
@@ -180,6 +180,12 @@ pub enum Cursor {
/// The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a shape out from its
/// centre; a selected shape's handles edit it.
ParametricShape,
/// The Corner Tool (Path Precision Tools add-on): a click or drag on an anchor cuts a corner;
/// a corner's marker resizes it.
CornerTool,
/// The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag runs an anchor along
/// the path.
SlideAnchor,
/// 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,
@@ -278,6 +284,8 @@ impl Cursor {
| Cursor::ExtendPath
| Cursor::MeasureInspect
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor
| Cursor::SmartPen => Cursor::Crosshair,
c => c,
}
@@ -316,7 +324,9 @@ impl Cursor {
| Cursor::AnchorEraser
| Cursor::ExtendPath
| Cursor::MeasureInspect
| Cursor::ParametricShape => Some(Cursor::Crosshair),
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor => Some(Cursor::Crosshair),
Cursor::TypeVertical => Some(Cursor::Text),
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
Cursor::SmartPen => Some(Cursor::Pen),
+12
View File
@@ -702,6 +702,18 @@ group = "Drawing and editing tools"
shown_when = ["The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a re-editable shape out from its centre; a click selects one and shows its handles."]
hotspot = "Exact: the centre of the shape being drawn."
[[cursor]]
id = "CornerTool"
group = "Drawing and editing tools"
shown_when = ["The Corner Tool (Path Precision Tools add-on): a click or drag on an anchor where two sides meet cuts a corner there; a drag on a corner's marker resizes it; an Alt-drag cuts corners on every anchor inside the marquee."]
hotspot = "Exact: the anchor or marker being picked."
[[cursor]]
id = "SlideAnchor"
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."
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
# The brush size is drawn by the app as a circle around the cursor.
+1
View File
@@ -69,6 +69,7 @@ const ROWS: &[Row] = &[
// The add-ons' tools (`crate::addons`).
(&[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::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"]),
+9 -4
View File
@@ -1,16 +1,17 @@
//! 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, and the add-on's anchor edits); 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 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`.
pub mod corner;
pub mod shape;
use egui::Ui;
use serde_json::{Map, Value, json};
use vectorcraft_geom::Point;
use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect, parametric_shape};
use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect, parametric_shape, slide_anchor};
use crate::VectorcraftApp;
use crate::dialogs::DialogSpec;
@@ -37,6 +38,8 @@ pub fn menu() -> Vec<Item> {
tool("Extend Path Tool", extend_path::ID),
tool("Measure & Inspect Tool", measure_inspect::ID),
tool("Parametric Shape Tool", parametric_shape::ID),
Item::Cmd("Corner Tool", corner::COMMAND, json!({})),
tool("Slide Anchor Along Path", slide_anchor::ID),
Item::Sep,
Item::Cmd("Anchor Inspector", "window.panel", json!({ "panel": ID })),
Item::Cmd("Parametric Shape", "window.panel", json!({ "panel": shape::ID })),
@@ -154,6 +157,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if let Some((id, p)) = action {
run(app, id, p);
}
widgets::divider(ui);
corner::section(app, ui);
}
// ---------- the tools' options ----------
@@ -0,0 +1,64 @@
//! The Anchor Inspector panel's Corners section: the Corner Tool's radius and kind, how many corners
//! the selection holds, and Apply (`addon.pathtools.cornerSet`).
use egui::Ui;
use serde_json::{Value, json};
use vectorcraft_doc::LiveCorners;
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_tools::addons::corner_tool::{ID, MAX_RADIUS, RADIUS};
use super::{LABEL_W, run};
use crate::VectorcraftApp;
use crate::dialogs::corners::kind_label;
use crate::widgets::{self, dim_label};
/// The menu's command: the tool, with the panel its values are in.
pub const COMMAND: &str = "addon.pathtools.cornerTool";
const FIELD_W: f32 = 96.0;
/// Object › Plug-ins › Path Precision Tools › Corner Tool.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
app.select_tool(ID);
app.run("window.panel", json!({ "panel": super::ID }))
}
/// How many corners Apply would edit: the selected paths' direct-selected ones, or all of a path
/// selected whole.
fn selected_corners(app: &VectorcraftApp) -> usize {
let Some(st) = app.session.active() else { return 0 };
let of = |id| {
let lc = st.doc.node(id).and_then(LiveCorners::of)?;
Some(st.selection.partial(id).filter(|a| !a.is_empty()).map_or(lc.corners.len(), |a| lc.corners_of(a).len()))
};
st.selection.objects.iter().filter_map(|id| of(*id)).sum()
}
pub(super) fn section(app: &mut VectorcraftApp, ui: &mut Ui) {
let options = app.session.tool_options_of(ID);
let radius = options["radius"].as_f64().unwrap_or(RADIUS);
let kind = serde_json::from_value::<CornerKind>(options["kind"].clone()).unwrap_or_default();
let count = selected_corners(app);
let unit = app.session.general_unit();
widgets::subheader(ui, "Corners");
dim_label(ui, &crate::i18n::fmt(tl!("Corners selected: {count}"), &[("count", &count.to_string())]));
let mut set: Option<Value> = None;
widgets::label_row(ui, tl!("Radius:"), LABEL_W, |ui| {
if let Some(v) = widgets::size_field(ui, "corner-tool-radius", Some(radius), unit, FIELD_W) {
set = Some(json!({ "radius": v.clamp(0.0, MAX_RADIUS) }));
}
});
widgets::label_row(ui, tl!("Corner:"), LABEL_W, |ui| {
let labels = CornerKind::ALL.map(kind_label);
if let Some(k) = widgets::dropdown(ui, "corner-tool-kind", kind_label(kind), &labels, FIELD_W).and_then(|i| CornerKind::ALL.get(i)) {
set = Some(json!({ "kind": k }));
}
});
let width = ui.available_width();
let apply = ui.add_enabled_ui(count > 0, |ui| widgets::flat_button(ui, tl!("Apply to Selected Anchors"), width).clicked()).inner;
if let Some(values) = set {
run(app, "tool.setOption", json!({ "tool": ID, "values": values }));
}
if apply {
run(app, "addon.pathtools.cornerSet", json!({ "radius": radius, "kind": kind }));
}
}
@@ -21,6 +21,7 @@ fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() {
("Extend Path Tool", "extendPath"),
("Measure & Inspect Tool", "measureInspect"),
("Parametric Shape Tool", "parametricShape"),
("Slide Anchor Along Path", "slideAnchor"),
] {
let e = under(label).expect(label);
assert_eq!((e.command.as_deref(), &e.params), (Some("tool.select"), &json!({ "tool": tool })));
@@ -113,3 +114,50 @@ fn the_parametric_shape_panel_opens_from_the_submenu_and_shows_the_shape() {
assert!(text.contains("Speech Bubble") && text.contains("Tail:") && text.contains("Tip:") && !text.contains("With none selected"), "{text}");
assert!(!text.contains("Count:"), "a speech bubble has no count: {text}");
}
#[test]
fn the_corner_tool_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 == "Corner 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(), "cornerTool");
assert!(crate::panel_docking::showing(&app.ui, ID), "the panel opens");
// Neither toolbar shows it, the All Tools list does.
assert!(
vectorcraft_tools::catalog::all_tools().any(|t| t.id == "cornerTool")
&& vectorcraft_tools::catalog::all_tools().any(|t| t.id == "slideAnchor")
);
assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "cornerTool" || t.id == "slideAnchor"));
}
#[test]
fn the_corner_section_shows_the_tools_values_and_applies_them_to_the_selection() {
let mut app = app();
let r = app
.run("path.create", json!({"anchors": [{"x": 10, "y": 10}, {"x": 110, "y": 10}, {"x": 110, "y": 110}, {"x": 10, "y": 110}], "closed": true}))
.unwrap();
let id = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap());
let text = painted_text(&mut app, show);
assert!(
text.contains("Corners selected: 4") && text.contains("Radius:") && text.contains("Round") && text.contains("Apply to Selected Anchors"),
"{text}"
);
app.run("tool.setOption", json!({"tool": "cornerTool", "values": {"radius": 12, "kind": "chamfer"}})).unwrap();
assert!(painted_text(&mut app, show).contains("Chamfer"));
app.session
.select(|_, sel| {
sel.set([id]);
sel.anchors.insert(id, [(0, 1), (0, 2)].into_iter().collect());
})
.unwrap();
assert!(painted_text(&mut app, show).contains("Corners selected: 2"));
// What Apply runs: the tool's values on the selected corners, one undo step.
let steps = app.session.doc().unwrap().history.undo.len();
let options = app.session.tool_options_of("cornerTool");
app.run("addon.pathtools.cornerSet", json!({"radius": options["radius"], "kind": options["kind"]})).unwrap();
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 6);
assert!(painted_text(&mut app, show).contains("Corners selected: 2"), "still selected");
}
+2
View File
@@ -152,6 +152,8 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::ExtendPath
| Cursor::MeasureInspect
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor
| Cursor::SmartPen => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
}
}
+3 -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; 86] = [
const GLYPHS: [Cursor; 88] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -526,6 +526,8 @@ const GLYPHS: [Cursor; 86] = [
Cursor::ExtendPath,
Cursor::MeasureInspect,
Cursor::ParametricShape,
Cursor::CornerTool,
Cursor::SlideAnchor,
Cursor::SmartPen,
];
+2 -2
View File
@@ -379,7 +379,7 @@ 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", "smartPen"]);
assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "cornerTool", "slideAnchor", "smartPen"]);
for advanced in [false, true] {
if advanced {
app.run("window.toolbarAdvanced", json!({})).unwrap();
@@ -389,7 +389,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[..4].to_vec())));
assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..6].to_vec())));
assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"])));
for tool in ["extendPath", "smartPen"] {
app.run("toolbar.addTool", json!({"tool": tool})).unwrap();
+12
View File
@@ -5772,6 +5772,18 @@ anchor Points: Точки:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` или `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Корекциите на мастилата за печат работят върху CMYK документи: първо изберете Файл › Цветен режим на документа › CMYK цвят
@msg `{key}` must be 0..{max} `{key}` трябва да е 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Инструмент „Ъгли“
Slide Anchor Along Path Плъзгане на опорна точка по пътя
Set Corners Задаване на ъгли
Remove Corners Премахване на ъгли
Corners selected: {count} Избрани ъгли: {count}
Apply to Selected Anchors Прилагане към избраните опорни точки
@msg `radius` must be from 0 to 1000000 pt `radius` трябва да е от 0 до 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` трябва да е число от -0.98 до 0.98
@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 тази опорна точка не може да се плъзга: краят на отворен път няма накъде да отиде
# 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
@@ -5852,3 +5852,15 @@ swatch Yellow Green Žuto-zelena
Position: Položaj:
Font Size: Veličina fonta:
shape Open Otvoren
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Alat za uglove
Slide Anchor Along Path Klizanje sidra po putanji
Set Corners Postavi uglove
Remove Corners Ukloni uglove
Corners selected: {count} Odabrani uglovi: {count}
Apply to Selected Anchors Primijeni na odabrana sidra
@msg `radius` must be from 0 to 1000000 pt `radius` mora biti od 0 do 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` mora biti broj od -0.98 do 0.98
@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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5873,6 +5873,18 @@ anchor Points: Punts:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indiqueu `inkLimit`, `removeWhiteOverprint` o `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Les correccions de tinta per a impressió funcionen en documents CMYK: trieu primer Fitxer › Mode de color del document › Color CMYK
@msg `{key}` must be 0..{max} `{key}` ha de ser 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Eina de cantonades
Slide Anchor Along Path Llisca l’ancoratge pel traçat
Set Corners Defineix les cantonades
Remove Corners Elimina les cantonades
Corners selected: {count} Cantonades seleccionades: {count}
Apply to Selected Anchors Aplica als punts d’ancoratge seleccionats
@msg `radius` must be from 0 to 1000000 pt `radius` ha de ser de 0 a 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` ha de ser un nombre de -0.98 a 0.98
@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
# 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.
+12
View File
@@ -5882,6 +5882,18 @@ anchor Points: Body:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` zadejte `inkLimit`, `removeWhiteOverprint` nebo `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Opravy tiskových barev fungují v dokumentech CMYK: nejprve zvolte Soubor › Barevný režim dokumentu › Barvy CMYK
@msg `{key}` must be 0..{max} `{key}` musí být 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Nástroj Rohy
Slide Anchor Along Path Posunout kotevní bod po cestě
Set Corners Nastavit rohy
Remove Corners Odstranit rohy
Corners selected: {count} Vybrané rohy: {count}
Apply to Selected Anchors Použít na vybrané kotevní body
@msg `radius` must be from 0 to 1000000 pt `radius` musí být od 0 do 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` musí být číslo od -0.98 do 0.98
@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
# 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.
+12
View File
@@ -5787,6 +5787,18 @@ anchor Points: Punkter:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` angiv `inkLimit`, `removeWhiteOverprint` eller `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Trykfarverettelser virker på CMYK-dokumenter: vælg først Filer › Dokumentets farvetilstand › CMYK-farve
@msg `{key}` must be 0..{max} `{key}` skal være 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Hjørneværktøj
Slide Anchor Along Path Skub ankerpunkt langs sti
Set Corners Angiv hjørner
Remove Corners Fjern hjørner
Corners selected: {count} Valgte hjørner: {count}
Apply to Selected Anchors Anvend på valgte ankerpunkter
@msg `radius` must be from 0 to 1000000 pt `radius` skal være fra 0 til 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` skal være et tal fra -0.98 til 0.98
@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
# 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.
+12
View File
@@ -5467,6 +5467,18 @@ anchor Points: Punkte:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` geben Sie `inkLimit`, `removeWhiteOverprint` oder `overprintBlack` an
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Druckfarbkorrekturen wirken in CMYK-Dokumenten: Wählen Sie zuerst Datei › Dokumentfarbmodus › CMYK-Farbe
@msg `{key}` must be 0..{max} `{key}` muss 0..{max} sein
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Ecken-Werkzeug
Slide Anchor Along Path Ankerpunkt entlang des Pfads verschieben
Set Corners Ecken festlegen
Remove Corners Ecken entfernen
Corners selected: {count} Ausgewählte Ecken: {count}
Apply to Selected Anchors Auf ausgewählte Ankerpunkte anwenden
@msg `radius` must be from 0 to 1000000 pt `radius` muss von 0 bis 1000000 pt sein
@msg `t` must be a number from -0.98 to 0.98 `t` muss eine Zahl von -0.98 bis 0.98 sein
@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
# 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.
+12
View File
@@ -5834,6 +5834,18 @@ anchor Points: Σημεία:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` δώστε `inkLimit`, `removeWhiteOverprint` ή `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Οι διορθώσεις μελανιών εκτύπωσης ισχύουν σε έγγραφα CMYK: επιλέξτε πρώτα Αρχείο › Λειτουργία χρώματος εγγράφου › Χρώμα CMYK
@msg `{key}` must be 0..{max} το `{key}` πρέπει να είναι 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Γωνίες (Corner Tool)
Slide Anchor Along Path Ολίσθηση σημείου αγκύρωσης κατά μήκος διαδρομής
Set Corners Ορισμός γωνιών
Remove Corners Αφαίρεση γωνιών
Corners selected: {count} Επιλεγμένες γωνίες: {count}
Apply to Selected Anchors Εφαρμογή στα επιλεγμένα σημεία αγκύρωσης
@msg `radius` must be from 0 to 1000000 pt το `radius` πρέπει να είναι από 0 έως 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 το `t` πρέπει να είναι αριθμός από -0.98 έως 0.98
@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 αυτό το σημείο αγκύρωσης δεν μπορεί να ολισθήσει: το άκρο ανοιχτής διαδρομής δεν έχει πού να πάει
# 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.
+12
View File
@@ -5825,6 +5825,18 @@ anchor Points: Puntos:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indique `inkLimit`, `removeWhiteOverprint` u `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Las correcciones de tinta para impresión funcionan en documentos CMYK: elija primero Archivo › Modo de color del documento › Color CMYK
@msg `{key}` must be 0..{max} `{key}` debe ser 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Herramienta Esquinas
Slide Anchor Along Path Deslizar punto de ancla por el trazado
Set Corners Definir esquinas
Remove Corners Quitar esquinas
Corners selected: {count} Esquinas seleccionadas: {count}
Apply to Selected Anchors Aplicar a los puntos de ancla seleccionados
@msg `radius` must be from 0 to 1000000 pt `radius` debe ser de 0 a 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` debe ser un número de -0.98 a 0.98
@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
# 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.
+12
View File
@@ -5913,6 +5913,18 @@ anchor Points: Pisteet:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` anna `inkLimit`, `removeWhiteOverprint` tai `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Painovärien korjaukset toimivat CMYK-asiakirjoissa: valitse ensin Tiedosto › Asiakirjan väritila › CMYK-väri
@msg `{key}` must be 0..{max} `{key}` on oltava 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Kulmatyökalu
Slide Anchor Along Path Liu’uta ankkuripistettä polkua pitkin
Set Corners Aseta kulmat
Remove Corners Poista kulmat
Corners selected: {count} Valittuja kulmia: {count}
Apply to Selected Anchors Käytä valittuihin ankkuripisteisiin
@msg `radius` must be from 0 to 1000000 pt `radius` on oltava 0–1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` on oltava luku väliltä -0.98–0.98
@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ä
# 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.
+12
View File
@@ -5881,6 +5881,18 @@ anchor Points: Points :
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indiquez `inkLimit`, `removeWhiteOverprint` ou `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Les corrections d’encres pour l’impression s’appliquent aux documents CMJN : choisissez d’abord Fichier › Mode colorimétrique du document › Couleur CMJN
@msg `{key}` must be 0..{max} `{key}` doit être compris entre 0 et {max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Outil Angles
Slide Anchor Along Path Faire glisser le point d’ancrage le long du tracé
Set Corners Définir les angles
Remove Corners Supprimer les angles
Corners selected: {count} Angles sélectionnés : {count}
Apply to Selected Anchors Appliquer aux points d’ancrage sélectionnés
@msg `radius` must be from 0 to 1000000 pt `radius` doit être compris entre 0 et 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` doit être un nombre compris entre -0.98 et 0.98
@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
# 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.
+12
View File
@@ -5869,6 +5869,18 @@ anchor Points: Pontok:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` adja meg: `inkLimit`, `removeWhiteOverprint` vagy `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first A nyomdai festékjavítások CMYK-dokumentumokon működnek: előbb válassza a Fájl › Dokumentum színmódja › CMYK-szín parancsot
@msg `{key}` must be 0..{max} `{key}` értéke 0..{max} lehet
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Sarok eszköz
Slide Anchor Along Path Horgonypont csúsztatása az útvonal mentén
Set Corners Sarkok beállítása
Remove Corners Sarkok eltávolítása
Corners selected: {count} Kijelölt sarkok: {count}
Apply to Selected Anchors Alkalmazás a kijelölt horgonypontokra
@msg `radius` must be from 0 to 1000000 pt a `radius` értéke 0 és 1000000 pt között lehet
@msg `t` must be a number from -0.98 to 0.98 a `t` értéke -0.98 és 0.98 közötti szám lehet
@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
# 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.
+12
View File
@@ -5787,6 +5787,18 @@ anchor Points: Titik:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` berikan `inkLimit`, `removeWhiteOverprint`, atau `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Perbaikan Tinta Cetak bekerja pada dokumen CMYK: pilih dulu Berkas › Mode warna dokumen › Warna CMYK
@msg `{key}` must be 0..{max} `{key}` harus 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Alat sudut
Slide Anchor Along Path Geser titik jangkar sepanjang jalur
Set Corners Atur sudut
Remove Corners Hapus sudut
Corners selected: {count} Sudut terpilih: {count}
Apply to Selected Anchors Terapkan ke titik jangkar terpilih
@msg `radius` must be from 0 to 1000000 pt `radius` harus dari 0 sampai 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` harus berupa angka dari -0.98 sampai 0.98
@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
# 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.
+12
View File
@@ -5501,6 +5501,18 @@ anchor Points: Punti:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indica `inkLimit`, `removeWhiteOverprint` o `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Le correzioni inchiostri di stampa funzionano nei documenti CMYK: scegli prima File › Metodo colore documento › Colore CMYK
@msg `{key}` must be 0..{max} `{key}` deve essere 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Strumento Angoli
Slide Anchor Along Path Fai scorrere il punto di ancoraggio lungo il tracciato
Set Corners Imposta angoli
Remove Corners Rimuovi angoli
Corners selected: {count} Angoli selezionati: {count}
Apply to Selected Anchors Applica ai punti di ancoraggio selezionati
@msg `radius` must be from 0 to 1000000 pt `radius` deve essere da 0 a 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` deve essere un numero da -0.98 a 0.98
@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
# 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.
+12
View File
@@ -5570,6 +5570,18 @@ anchor Points: ポイント:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`、`removeWhiteOverprint` または `overprintBlack` を指定してください
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷インキの修正は CMYK ドキュメントで使用できます。先にファイル › ドキュメントのカラーモード › CMYK カラーを選択してください
@msg `{key}` must be 0..{max} `{key}` は 0..{max} にしてください
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool コーナーツール
Slide Anchor Along Path パスに沿ってアンカーポイントをスライド
Set Corners コーナーを設定
Remove Corners コーナーを削除
Corners selected: {count} 選択中のコーナー:{count}
Apply to Selected Anchors 選択中のアンカーポイントに適用
@msg `radius` must be from 0 to 1000000 pt `radius` は 0 から 1000000 pt にしてください
@msg `t` must be a number from -0.98 to 0.98 `t` は -0.98 から 0.98 の数値にしてください
@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 そのアンカーポイントはスライドできません:オープンパスの端点には移動先がありません
# 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
@@ -5872,6 +5872,18 @@ anchor Points: წერტილები:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` მიუთითეთ `inkLimit`, `removeWhiteOverprint` ან `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first საბეჭდი მელნის შესწორებები მუშაობს CMYK დოკუმენტებზე: ჯერ აირჩიეთ ფაილი › დოკუმენტის ფერის რეჟიმი › CMYK ფერი
@msg `{key}` must be 0..{max} `{key}` უნდა იყოს 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool კუთხეების ხელსაწყო
Slide Anchor Along Path საყრდენი წერტილის სრიალი ტრაექტორიაზე
Set Corners კუთხეების დაყენება
Remove Corners კუთხეების მოცილება
Corners selected: {count} მონიშნული კუთხეები: {count}
Apply to Selected Anchors მონიშნულ საყრდენ წერტილებზე გამოყენება
@msg `radius` must be from 0 to 1000000 pt `radius` უნდა იყოს 0-დან 1000000 pt-მდე
@msg `t` must be a number from -0.98 to 0.98 `t` უნდა იყოს რიცხვი -0.98-დან 0.98-მდე
@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 ეს საყრდენი წერტილი ვერ ისრიალებს: ღია ტრაექტორიის ბოლოს წასასვლელი არ აქვს
# 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.
+12
View File
@@ -5464,6 +5464,18 @@ anchor Points: 포인트:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` 또는 `overprintBlack`을 지정하십시오
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 인쇄 잉크 수정은 CMYK 문서에서 작동합니다. 먼저 파일 › 문서 색상 모드 › CMYK 색상을 선택하십시오
@msg `{key}` must be 0..{max} `{key}`은(는) 0..{max}이어야 합니다
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool 모퉁이 도구
Slide Anchor Along Path 패스를 따라 앵커 포인트 밀기
Set Corners 모퉁이 설정
Remove Corners 모퉁이 제거
Corners selected: {count} 선택한 모퉁이: {count}
Apply to Selected Anchors 선택한 앵커 포인트에 적용
@msg `radius` must be from 0 to 1000000 pt `radius`은(는) 0에서 1000000 pt 사이여야 합니다
@msg `t` must be a number from -0.98 to 0.98 `t`은(는) -0.98에서 0.98 사이의 숫자여야 합니다
@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 이 앵커 포인트는 밀 수 없습니다. 열린 패스의 끝은 갈 곳이 없습니다
# 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
@@ -5831,6 +5831,18 @@ anchor Points: Punten:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` geef `inkLimit`, `removeWhiteOverprint` of `overprintBlack` op
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Drukinktcorrecties werken in CMYK-documenten: kies eerst Bestand › Documentkleurmodus › CMYK-kleur
@msg `{key}` must be 0..{max} `{key}` moet 0..{max} zijn
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Hoekengereedschap
Slide Anchor Along Path Ankerpunt langs pad schuiven
Set Corners Hoeken instellen
Remove Corners Hoeken verwijderen
Corners selected: {count} Geselecteerde hoeken: {count}
Apply to Selected Anchors Toepassen op geselecteerde ankerpunten
@msg `radius` must be from 0 to 1000000 pt `radius` moet van 0 tot 1000000 pt zijn
@msg `t` must be a number from -0.98 to 0.98 `t` moet een getal van -0.98 tot 0.98 zijn
@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
# 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.
+12
View File
@@ -5737,6 +5737,18 @@ anchor Points: Punkty:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` podaj `inkLimit`, `removeWhiteOverprint` lub `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Poprawki farb drukarskich działają w dokumentach CMYK: najpierw wybierz Plik › Tryb koloru dokumentu › Kolor CMYK
@msg `{key}` must be 0..{max} `{key}` musi wynosić 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Narzędzie Narożniki
Slide Anchor Along Path Przesuń punkt kontrolny wzdłuż ścieżki
Set Corners Ustaw narożniki
Remove Corners Usuń narożniki
Corners selected: {count} Zaznaczone narożniki: {count}
Apply to Selected Anchors Zastosuj do zaznaczonych punktów kontrolnych
@msg `radius` must be from 0 to 1000000 pt `radius` musi wynosić od 0 do 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` musi być liczbą od -0.98 do 0.98
@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ść
# 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.
+12
View File
@@ -5800,6 +5800,18 @@ anchor Points: Pontos:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` informe `inkLimit`, `removeWhiteOverprint` ou `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first As Correções de Tinta para Impressão funcionam em documentos CMYK: escolha antes Arquivo › Modo de Cor do Documento › Cor CMYK
@msg `{key}` must be 0..{max} `{key}` deve ser 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Ferramenta Cantos
Slide Anchor Along Path Deslizar Ponto de Ancoragem pelo Caminho
Set Corners Definir Cantos
Remove Corners Remover Cantos
Corners selected: {count} Cantos selecionados: {count}
Apply to Selected Anchors Aplicar aos Pontos de Ancoragem Selecionados
@msg `radius` must be from 0 to 1000000 pt `radius` deve ser de 0 a 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` deve ser um número de -0.98 a 0.98
@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
# 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.
+12
View File
@@ -5860,6 +5860,18 @@ anchor Points: Точки:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` или `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Исправления печатных красок работают в документах CMYK: сначала выберите Файл › Цветовой режим документа › Цвет CMYK
@msg `{key}` must be 0..{max} `{key}` должен быть 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Инструмент «Углы»
Slide Anchor Along Path Сдвинуть опорную точку вдоль контура
Set Corners Задать углы
Remove Corners Убрать углы
Corners selected: {count} Выбрано углов: {count}
Apply to Selected Anchors Применить к выбранным опорным точкам
@msg `radius` must be from 0 to 1000000 pt `radius` должен быть от 0 до 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` должен быть числом от -0.98 до 0.98
@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 эту опорную точку нельзя сдвинуть: концу открытого контура некуда двигаться
# 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
@@ -5938,6 +5938,18 @@ shape Open Öppen
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` ange `inkLimit`, `removeWhiteOverprint` eller `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Tryckfärgskorrigeringar fungerar i CMYK-dokument: välj först Fil › Dokumentets färgläge › CMYK-färg
@msg `{key}` must be 0..{max} `{key}` måste vara 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Hörnverktyget
Slide Anchor Along Path Skjut ankarpunkt längs bana
Set Corners Ange hörn
Remove Corners Ta bort hörn
Corners selected: {count} Valda hörn: {count}
Apply to Selected Anchors Tillämpa på valda ankarpunkter
@msg `radius` must be from 0 to 1000000 pt `radius` måste vara från 0 till 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` måste vara ett tal från -0.98 till 0.98
@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
# 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.
+12
View File
@@ -5840,6 +5840,18 @@ anchor Points: Noktalar:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` veya `overprintBlack` verin
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Baskı Mürekkebi Düzeltmeleri CMYK belgelerinde çalışır: önce Dosya › Belge Renk Modu › CMYK Rengi'ni seçin
@msg `{key}` must be 0..{max} `{key}` 0..{max} olmalıdır
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Köşe Aracı
Slide Anchor Along Path Bağlantı Noktasını Yol Boyunca Kaydır
Set Corners Köşeleri Ayarla
Remove Corners Köşeleri Kaldır
Corners selected: {count} Seçili köşeler: {count}
Apply to Selected Anchors Seçili Bağlantı Noktalarına Uygula
@msg `radius` must be from 0 to 1000000 pt `radius` 0 ile 1000000 pt arasında olmalıdır
@msg `t` must be a number from -0.98 to 0.98 `t` -0.98 ile 0.98 arasında bir sayı olmalıdır
@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
# 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.
+12
View File
@@ -5481,6 +5481,18 @@ anchor Points: Точки:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` або `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Виправлення друкарських фарб працюють у документах CMYK: спершу виберіть Файл › Колірний режим документа › Колір CMYK
@msg `{key}` must be 0..{max} `{key}` має бути 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Інструмент «Кути»
Slide Anchor Along Path Зсунути опорну точку вздовж контуру
Set Corners Задати кути
Remove Corners Забрати кути
Corners selected: {count} Вибрано кутів: {count}
Apply to Selected Anchors Застосувати до вибраних опорних точок
@msg `radius` must be from 0 to 1000000 pt `radius` має бути від 0 до 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` має бути числом від -0.98 до 0.98
@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 цю опорну точку не можна зсунути: кінцю відкритого контуру нікуди рухатися
# 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.
+12
View File
@@ -5483,6 +5483,18 @@ anchor Points: Điểm:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` hãy cho `inkLimit`, `removeWhiteOverprint` hoặc `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Sửa mực in hoạt động trên tài liệu CMYK: trước hết chọn Tệp › Chế độ màu tài liệu › Màu CMYK
@msg `{key}` must be 0..{max} `{key}` phải là 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Công cụ Góc
Slide Anchor Along Path Trượt điểm neo dọc đường dẫn
Set Corners Đặt góc
Remove Corners Gỡ góc
Corners selected: {count} Góc đã chọn: {count}
Apply to Selected Anchors Áp dụng cho các điểm neo đã chọn
@msg `radius` must be from 0 to 1000000 pt `radius` phải từ 0 đến 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` phải là số từ -0.98 đến 0.98
@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
# 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.
+12
View File
@@ -5724,6 +5724,18 @@ anchor Points: 点:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` 请指定 `inkLimit`、`removeWhiteOverprint` 或 `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷油墨修正适用于 CMYK 文档:请先选择 文件 › 文档颜色模式 › CMYK 颜色
@msg `{key}` must be 0..{max} `{key}` 必须为 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool 转角工具
Slide Anchor Along Path 沿路径滑动锚点
Set Corners 设置转角
Remove Corners 移除转角
Corners selected: {count} 已选转角:{count}
Apply to Selected Anchors 应用到所选锚点
@msg `radius` must be from 0 to 1000000 pt `radius` 必须为 0 到 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` 必须是 -0.98 到 0.98 之间的数字
@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 该锚点无法滑动:开放路径的端点无处可去
# 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
@@ -5520,6 +5520,18 @@ anchor Points: 點:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` 請指定 `inkLimit`、`removeWhiteOverprint` 或 `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷油墨修正適用於 CMYK 文件:請先選擇 檔案 › 文件色彩模式 › CMYK 色彩
@msg `{key}` must be 0..{max} `{key}` 必須為 0..{max}
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool 轉角工具
Slide Anchor Along Path 沿路徑滑動錨點
Set Corners 設定轉角
Remove Corners 移除轉角
Corners selected: {count} 已選取轉角:{count}
Apply to Selected Anchors 套用至所選錨點
@msg `radius` must be from 0 to 1000000 pt `radius` 必須為 0 到 1000000 pt
@msg `t` must be a number from -0.98 to 0.98 `t` 必須是 -0.98 到 0.98 之間的數字
@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 該錨點無法滑動:開放路徑的端點無處可去
# 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
@@ -527,6 +527,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
Some(t) => Err(format!("unknown tool `{t}`")),
None => Err("missing `tool`".into()),
},
crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::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
@@ -1,5 +1,11 @@
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
(
crate::addons::pathtools::corner::COMMAND,
"Corner Tool",
"",
"{} choose the Corner Tool and show the Anchor Inspector panel with its radius and kind",
),
(
"app.language",
"Interface Language",
+2
View File
@@ -1226,6 +1226,8 @@ 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),
_ => egui::vec2(260.0, 360.0),
}
}
+29 -3
View File
@@ -5789,9 +5789,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 four tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with
`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect` and `parametricShape`. None has a shortcut. Each edit
is one undo step, and an edit that changes nothing makes no step.
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`.
None has a shortcut. Each edit is one undo step, and an edit that changes nothing makes no step.
Tools:
@@ -5814,6 +5814,19 @@ Tools:
gear's tooth height, an arrow's head, a cross's bar, a heart's notch, a bubble's tail tip); each drag runs
`addon.pathtools.shape.set`. A double-click on the canvas places a shape with the last settings. Options
(`tool.setOption`, or the panel with no shape selected) are the next shape's numbers, as the commands take them.
- **Corner Tool** (`cornerTool`, or the UI command `addon.pathtools.cornerTool`, which also shows the Anchor Inspector
panel with its Corners section): edits the app's Live Corners on any path, selected or not. Over an anchor where two
sides meet at an angle the anchor shows; a click runs `addon.pathtools.cornerSet` with the tool's `radius` (pt, 10)
and `kind` (`round`, `invertedRound` or `chamfer`), and a drag from it sizes the corner (Shift: whole values of the
ruler's unit; no larger than fits). A cut corner shows a marker at the centre of its cut: a click selects the corner
and loads its radius and kind into the tool, a drag resizes it (to 0 it goes), a double-click removes it. An Alt-drag
cuts corners on every anchor in the marquee, replacing those there; a plain marquee selects the corners inside (Shift
toggles), and a click away deselects. The panel section sets `radius` and `kind`, counts the selected corners and has
Apply to Selected Anchors.
- **Slide Anchor Along Path** (`slideAnchor`): a drag on an anchor runs `addon.pathtools.slideAnchor`, previewed while
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.
Commands:
@@ -5846,6 +5859,19 @@ Commands:
- `addon.pathtools.crossings {cut?, selfCrossings?, ids?}` adds anchors where the selected paths cross one another (and
where each crosses itself, with `selfCrossings`), or with `cut` also cuts them there: a path of one subpath becomes a
path per piece. Paths that meet at a shared anchor don't cross there; at most 10,000 segments at once.
- `addon.pathtools.cornerSet {ids?, anchors?, items?, radius, kind?}` cuts live corners, the ones Direct Selection's
corner widgets and the Corners dialog edit, as one undo step. `ids` (default: the selection) are the paths and
`anchors` the anchor indices of each path's outline with no corners cut; without `anchors`, a path's direct-selected
anchors, or all of them. `items: [{id, anchors?}…]` gives each path its own instead. Only anchors where two sides meet
at an angle take a corner: smooth anchors and open ends are skipped, and it is an error if none is left. `radius` is
0 to 1,000,000 pt, drawn no larger than fits; `kind` is `round`, `invertedRound` or `chamfer` (default: each corner's
own). → `{ids, corners}`.
- `addon.pathtools.cornerRemove {ids?, anchors?, items?}` makes those corners sharp again, as one undo step; a plain path
with none left is plain again. → `{ids, corners}`.
- `addon.pathtools.slideAnchor {id, subpath?, anchor, t}` moves an anchor along its path, the segments beside it refitted
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.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);
+15 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Artwork Cleanup add-on: text tools; 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: Corner Tool and Slide Anchor Along Path; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1263,6 +1263,19 @@ stored data), `cmd::addons::pathtools::tests` in `vectorcraft-engine` (each comm
anchor edits and transforms, the SVG export filter, the tool's gestures), `addons::pathtools::tests` in
`vectorcraft-ui-egui` (submenu and panel) and `command_sweep`.
The add-on's Corner Tool (2026-10-31) edits the Live Corners every path already keeps (no second corner store): hover
shows an anchor that can take a corner, a click cuts one with the tool's radius and kind (round, inverted, chamfer), a
drag sizes it (Shift: whole values), a cut corner's marker selects it and loads its values, resizes it, and removes it
at 0 or on a double-click, a corner never grows past what fits, and an Alt-marquee cuts every eligible anchor inside. A
Corners section in the Anchor Inspector sets the radius and kind, counts the selected corners and applies to them.
Slide Anchor Along Path drags an anchor along its curve, the two segments beside it refitted by the keep-shape kernel
(Shift: where the tangent is a multiple of 45°). Commands `addon.pathtools.cornerSet`, `cornerRemove` and `slideAnchor`,
each one undo step; `vectorcraft_pathops::addons::pathtools::slide_anchor`. Tests: `addons::pathtools` in
`vectorcraft-pathops` (the outline stays within 0.05 pt on a circle, straight runs, ends, stops), `addons::corner_tool`
and `addons::slide_anchor` in `vectorcraft-tools`, `cmd::addons::pathtools::tests` in `vectorcraft-engine` (each command
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`.
## 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
@@ -1311,6 +1324,7 @@ add-on.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores 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. |
| 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): text tools; evidence added; scores unchanged. |
| 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Artwork Cleanup add-on: text tools; 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: Corner Tool and Slide Anchor Along Path; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -326,7 +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 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 |
| 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); an Alt-drag away from paths selects the points inside a marquee | 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 three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored |
| Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored |
@@ -372,6 +372,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged |
| 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged |