Files
vectorcraft/crates/pathops/src/addons/pathtools/extend.rs
T
bflatastic dc7f147608 Add-ons: Path Precision Tools — anchor eraser brush, extend path, measure, anchor inspector, crossings
A built-in add-on under Object › Plug-ins › Path Precision Tools:
- Anchor Eraser Brush: painting over selected paths removes the anchors
  under the brush that the outline can do without within a tolerance,
  refitting the handles beside them (Alt ignores the tolerance, Cmd/Ctrl
  deletes without refitting, pen pressure can scale the brush); one undo
  step per stroke.
- Extend Path tool: dragging an end of an open path carries it on
  straight, by stretching its last curve or along the circle it curves
  on, or shortens it back along itself.
- Measure & Inspect tool: drag to measure distance and angle; hover a
  path to read segment and path length, area, curvature, tangent and
  anchor angles and handles, in the document's units.
- Anchor Inspector panel: the selected anchor's position and handles as
  numbers, with Make Smooth (auto-smooth rule), Make Corner, Equalize
  Handles, Remove Points Keep Shape, Add Points and three ways to close.
- Add Points at Crossings and Split at Crossings.

The tools stay out of both toolbars as shipped; the All Tools drawer
lists them under the add-on's heading. Commands are addon.pathtools.*;
the existing commands, tools and menus are unchanged.
2026-10-30 15:20:38 +07:00

221 lines
8.1 KiB
Rust

//! Lengthen an open path at one end, carrying on the way it runs there, or shorten it back along
//! itself: [`extend_open`], and [`extend_length_to`] for the length that brings the end nearest a
//! point (what a drag asks for).
use std::f64::consts::{FRAC_PI_2, PI};
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
use super::{ACCURACY, TINY, nearest_on};
use crate::fit::end_tangent;
/// How an end carries on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExtendMode {
/// In a straight line along the end's direction.
Straight,
/// By stretching the last segment's curve on past its end (no anchor added).
Bezier,
/// Along the circle that matches the end's curvature, at most half way round.
Arc,
}
impl ExtendMode {
pub const ALL: [Self; 3] = [Self::Straight, Self::Bezier, Self::Arc];
pub fn id(self) -> &'static str {
match self {
Self::Straight => "straight",
Self::Bezier => "bezier",
Self::Arc => "arc",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id() == s)
}
}
/// The least length (pt) shortening leaves of a path.
const MIN_LEFT: f64 = 1e-3;
/// An end straighter than this radius (pt) carries on in a straight line in [`ExtendMode::Arc`].
const MAX_RADIUS: f64 = 1e6;
/// The farthest (in its parameter) [`ExtendMode::Bezier`] stretches a segment.
const MAX_STRETCH: f64 = 1024.0;
fn finite(sp: &SubPath) -> bool {
let ok = |p: Point| p.x.is_finite() && p.y.is_finite();
sp.anchors.iter().all(|a| ok(a.p) && ok(a.h_in) && ok(a.h_out))
}
fn length(sp: &SubPath) -> f64 {
(0..sp.segment_count()).map(|s| sp.segment(s).arclen(ACCURACY)).sum()
}
/// The last segment of an open subpath of two anchors or more.
fn last_segment(sp: &SubPath) -> Option<CubicBez> {
let n = sp.segment_count();
(n > 0).then(|| sp.segment(n - 1))
}
/// Open subpath `sp` (two anchors or more) lengthened by `length` pt at its first (`at_start`) or
/// last anchor the way `mode` says, or shortened along itself by a negative `length`. `None` when it
/// is closed, too short, not finite, or shortening would leave nothing of it.
pub fn extend_open(sp: &SubPath, at_start: bool, length: f64, mode: ExtendMode) -> Option<SubPath> {
if sp.closed || sp.anchors.len() < 2 || !length.is_finite() || !finite(sp) {
return None;
}
let mut s = sp.clone();
if at_start {
s.reverse();
}
let mut out = match mode {
_ if length < 0.0 => trim_end(&s, -length)?,
_ if length == 0.0 => s,
ExtendMode::Straight => straight_end(s, length)?,
ExtendMode::Bezier => bezier_end(s, length)?,
ExtendMode::Arc => arc_end(s, length)?,
};
if at_start {
out.reverse();
}
Some(out)
}
/// The length (pt) [`extend_open`] takes to bring the end of `sp` nearest `to`: positive along the
/// way `mode` carries the end on, negative back along the path (never all of it). `None` when there
/// is no such end.
pub fn extend_length_to(sp: &SubPath, at_start: bool, to: Point, mode: ExtendMode) -> Option<f64> {
if !(to.x.is_finite() && to.y.is_finite()) {
return None;
}
let mut s = sp.clone();
if at_start {
s.reverse();
}
let total = length(&s);
let end = s.anchors.last()?.p;
// Long enough to reach past the pointer; the original stays a prefix of the result.
let probe = (to.distance(end) * 2.0).max(1.0);
let ext = extend_open(&s, false, probe, mode)?;
let (seg, t, _) = nearest_on(&ext, to)?;
let before: f64 = (0..seg).map(|k| ext.segment(k).arclen(ACCURACY)).sum();
let along = before + ext.segment(seg).subsegment(0.0..t).arclen(ACCURACY);
Some((along - total).max(MIN_LEFT - total))
}
/// `sp` with `cut` pt taken off its end.
fn trim_end(sp: &SubPath, cut: f64) -> Option<SubPath> {
if cut >= length(sp) - MIN_LEFT {
return None;
}
let mut s = sp.clone();
let mut left = cut;
while left > 0.0 {
let c = last_segment(&s)?;
let len = c.arclen(ACCURACY);
let line = s.segment_is_line(s.segment_count() - 1);
if left >= len {
s.anchors.pop();
left -= len;
if let Some(a) = s.anchors.last_mut() {
a.h_out = a.p;
a.kind = AnchorKind::Corner;
}
continue;
}
let part = c.subsegment(0.0..c.inv_arclen(len - left, ACCURACY));
let k = s.anchors.len();
if let Some(a) = s.anchors.get_mut(k - 2).filter(|_| !line) {
a.h_out = part.p1;
}
if let Some(a) = s.anchors.last_mut() {
*a = if line { Anchor::corner(part.p3) } else { Anchor { p: part.p3, h_in: part.p2, h_out: part.p3, kind: AnchorKind::Corner } };
}
break;
}
(s.anchors.len() >= 2).then_some(s)
}
/// `sp` with a straight segment `len` pt long added at its end, along its end's direction.
fn straight_end(mut s: SubPath, len: f64) -> Option<SubPath> {
let dir = end_tangent(&last_segment(&s)?);
let a = s.anchors.last_mut()?;
a.h_out = a.p;
let p = a.p + dir * len;
s.anchors.push(Anchor::corner(p));
Some(s)
}
/// `sp` with its last segment's curve stretched on by `len` pt (its end anchor moved, no anchor
/// added); a straight last segment just grows.
fn bezier_end(mut s: SubPath, len: f64) -> Option<SubPath> {
let c = last_segment(&s)?;
let k = s.anchors.len();
if s.segment_is_line(k - 2) {
let p = c.p3 + end_tangent(&c) * len;
*s.anchors.last_mut()? = Anchor::corner(p);
return Some(s);
}
let target = c.arclen(ACCURACY) + len;
let reach = |t: f64| c.subsegment(0.0..t).arclen(ACCURACY);
let mut hi = 2.0;
while reach(hi) < target && hi < MAX_STRETCH {
hi *= 2.0;
}
let mut lo = 1.0;
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
if reach(mid) < target { lo = mid } else { hi = mid }
}
let ext = c.subsegment(0.0..hi);
s.anchors.get_mut(k - 2)?.h_out = ext.p1;
*s.anchors.last_mut()? = Anchor { p: ext.p3, h_in: ext.p2, h_out: ext.p3, kind: AnchorKind::Corner };
Some(s)
}
/// The signed radius of the circle through three points (positive when the turn from `a` through
/// `b` to `c` is counter-clockwise in the x→y sense); `None` when they lie in a line.
fn signed_radius(a: Point, b: Point, c: Point) -> Option<f64> {
let cross = (b - a).cross(c - b);
let r = (b - a).hypot() * (c - b).hypot() * (c - a).hypot() / (2.0 * cross.abs());
(cross.abs() > TINY && r.is_finite()).then(|| r.copysign(cross))
}
/// `v` turned by `a` radians (x toward y).
fn rot(v: Vec2, a: f64) -> Vec2 {
let (s, c) = a.sin_cos();
Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
}
/// `sp` carried on by an arc `len` pt long (at most half a circle) of the circle that leaves its
/// end in its direction with the curvature it ends with; straight where it ends straight.
fn arc_end(mut s: SubPath, len: f64) -> Option<SubPath> {
let c = last_segment(&s)?;
let dir = end_tangent(&c);
let Some(r) = signed_radius(c.eval(0.96), c.eval(0.98), c.p3).filter(|r| r.abs() <= MAX_RADIUS) else { return straight_end(s, len) };
let radius = r.abs();
let turn = (len / radius).min(PI).copysign(r);
let normal: Vec2 = Vec2::new(-dir.y, dir.x) * r.signum();
let p = c.p3;
let centre = p + normal * radius;
let pieces = (turn.abs() / FRAC_PI_2).ceil().max(1.0) as usize;
let step = turn / pieces as f64;
let h = 4.0 / 3.0 * (step.abs() / 4.0).tan() * radius;
let at = |phi: f64| (centre + rot(p - centre, phi), rot(dir, phi));
let first = at(0.0);
let end = s.anchors.last_mut()?;
end.h_out = first.0 + first.1 * h;
if end.has_in() {
end.kind = AnchorKind::Smooth;
}
for j in 1..=pieces {
let (q, t) = at(step * j as f64);
let h_out = if j < pieces { q + t * h } else { q };
let kind = if j < pieces { AnchorKind::Smooth } else { AnchorKind::Corner };
s.anchors.push(Anchor { p: q, h_in: q - t * h, h_out, kind });
}
Some(s)
}