mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user