Add-ons: Path Precision Tools — Corner Tool and Slide Anchor

Path Precision Tools gains two tools, kept out of the toolbars (All Tools
drawer and Object › Plug-ins › Path Precision Tools, no shortcuts):

- Corner Tool (cornerTool): editable corners on any path through the
  existing Live Corners. Hover shows an eligible anchor, a click cuts a
  corner with the tool's radius and kind, a drag sizes it (Shift: whole
  values), a 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; an Alt-marquee cuts every eligible anchor inside.
- Corners section in the Anchor Inspector: radius, kind, selected count,
  Apply to Selected Anchors.
- Slide Anchor Along Path (slideAnchor): a drag runs an anchor along its
  curve, the neighbouring segments refitted by the keep-shape kernel;
  Shift snaps where the tangent is a multiple of 45° from the constrain
  angle.
- Commands addon.pathtools.cornerSet, cornerRemove and slideAnchor, one
  undo step each, params validated and covered by the command sweep.
- Cursor stand-ins, catalog rows in every language, docs and tests.
This commit is contained in:
bflatastic
2026-10-31 04:21:26 +07:00
parent 0494f505d9
commit c9dc4fa857
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 {