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
+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]));
}