Add-ons: Smart Pen — draw and edit paths with one tool, panel

A built-in add-on under Object › Plug-ins › Smart Pen:
- The Smart Pen Tool draws and edits Bézier paths without switching
  tools: a click adds the next segment straight or curved (a panel
  toggle; Tab flips the segment being placed or the last one), a drag
  makes a smooth point (Alt breaks the handle, Shift snaps to 45°, Space
  moves the point), a curve after a straight segment joins it tangent.
- On existing paths it selects and moves points, removes a point keeping
  the shape (Alt-click), toggles smooth/corner (double-click), drags
  handles, reshapes a segment by dragging it and inserts a point on it
  (Alt-click); it closes, continues and joins open paths.
- The Smart Pen panel: next segment straight or curved, remove points
  keeping shape, make smooth, make corner, retract handles, close or
  join.

The tool stays out of both toolbars as shipped; the All Tools drawer
lists it under the add-on's heading. Commands are addon.pen.*; the
existing Pen tool, commands and menus are unchanged.
This commit is contained in:
bflatastic
2026-10-30 16:02:27 +07:00
parent e85e149384
commit 1ca3db1b51
55 changed files with 2767 additions and 38 deletions
+6
View File
@@ -6,12 +6,18 @@
//! handles, nearly closed paths) and Rejoin Path Pieces.
//! - [`pathtools`]: Path Precision Tools' anchor edits, path readouts, extending open paths and
//! finding where paths cross.
//! - [`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.
pub mod cleanup;
pub mod keep_shape;
pub mod pathtools;
pub mod pen;
pub mod segment_reshape;
pub use cleanup::{
Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles,
};
pub use keep_shape::{SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_anchors_keep_shape, simplify_keep_shape};
pub use segment_reshape::{ReshapeMode, reshape_segment};
+1 -1
View File
@@ -37,7 +37,7 @@ fn unit(v: Vec2) -> Option<Vec2> {
}
/// The indices of anchor `i`'s neighbours in `sp` (`None` past an open end).
fn neighbours(sp: &SubPath, i: usize) -> (Option<usize>, Option<usize>) {
pub(crate) fn neighbours(sp: &SubPath, i: usize) -> (Option<usize>, Option<usize>) {
let n = sp.anchors.len();
let prev = if i > 0 { Some(i - 1) } else { (sp.closed && n > 1).then(|| n - 1) };
let next = if i + 1 < n { Some(i + 1) } else { (sp.closed && n > 1).then_some(0) };
+196
View File
@@ -0,0 +1,196 @@
//! Smart Pen geometry: making a segment straight or curved (the curve leaving its ends tangent to
//! what comes before them), toggling an anchor between smooth and corner, and deleting an anchor
//! without refitting the handles beside it.
use kurbo::Vec2;
use vectorcraft_geom::{AnchorKind, SubPath};
use super::pathtools::{auto_smooth, make_corner, neighbours};
fn unit(v: Vec2) -> Option<Vec2> {
let l = v.hypot();
(l > 1e-9 && l.is_finite()).then(|| v / l)
}
/// Give anchor `i` the handle facing its next anchor (`out`) or its previous one, when it has none,
/// so the segment between them curves; `ratio` × the distance to that anchor long. The handle is
/// in line with the anchor's other handle (the anchor becomes smooth); else, after a straight
/// segment, in line with that segment (a straight-to-curved joint, the curve leaving tangent);
/// else the anchor is smoothed by the auto-smooth rule. False when nothing changes.
pub fn leave_handle(sp: &mut SubPath, i: usize, out: bool, ratio: f64) -> bool {
let (prev, next) = neighbours(sp, i);
let (Some(facing), back) = (if out { (next, prev) } else { (prev, next) }) else { return false };
let at = |j: usize| sp.anchors.get(j).copied();
let (Some(a), Some(f)) = (at(i), at(facing)) else { return false };
if (out && a.has_out()) || (!out && a.has_in()) {
return false;
}
let len = a.p.distance(f.p) * ratio;
let opposite = if out { a.h_in } else { a.h_out };
// The back segment is straight when the back anchor's handle toward `i` is retracted too.
let straight_back = back.and_then(at).filter(|b| if out { !b.has_out() } else { !b.has_in() });
let (dir, kind) = match (unit(a.p - opposite), straight_back) {
(Some(d), _) => (d, AnchorKind::Smooth),
(None, Some(b)) => match unit(a.p - b.p) {
Some(d) => (d, a.kind),
None => return false,
},
(None, None) => return auto_smooth(sp, i, ratio),
};
let Some(a) = sp.anchors.get_mut(i) else { return false };
let h = a.p + dir * len;
if out {
a.h_out = h;
} else {
a.h_in = h;
}
a.kind = kind;
true
}
/// Make segment `seg` curved: each of its ends without a handle on it gets one ([`leave_handle`]).
/// False when nothing changes.
pub fn curve_segment(sp: &mut SubPath, seg: usize, ratio: f64) -> bool {
let n = sp.anchors.len();
if seg >= sp.segment_count() {
return false;
}
let from = leave_handle(sp, seg % n, true, ratio);
let to = leave_handle(sp, (seg + 1) % n, false, ratio);
from || to
}
/// Make segment `seg` straight: the handles its two ends have on it are retracted, and an end left
/// with one handle is a corner. False when it was straight already.
pub fn straighten_segment(sp: &mut SubPath, seg: usize) -> bool {
let n = sp.anchors.len();
if seg >= sp.segment_count() || sp.segment_is_line(seg) {
return false;
}
if let Some(a) = sp.anchors.get_mut(seg % n) {
a.h_out = a.p;
a.kind = AnchorKind::Corner;
}
if let Some(b) = sp.anchors.get_mut((seg + 1) % n) {
b.h_in = b.p;
b.kind = AnchorKind::Corner;
}
true
}
/// Toggle anchor `i` between smooth and corner: a smooth anchor becomes a corner with its handles
/// where they are (nothing moves); any other is smoothed by the auto-smooth rule at `ratio`.
/// False when nothing changes.
pub fn toggle_smooth(sp: &mut SubPath, i: usize, ratio: f64) -> bool {
let Some(a) = sp.anchors.get(i) else { return false };
if a.kind == AnchorKind::Smooth && a.has_in() && a.has_out() { make_corner(sp, i, false) } else { auto_smooth(sp, i, ratio) }
}
/// Delete anchor `i`, leaving the handles of its neighbours as they are (the segment between them
/// takes their handles as they stand). False, leaving `sp` alone, when there is no such anchor or
/// the subpath would keep fewer than two anchors (three when closed).
pub fn delete_anchor(sp: &mut SubPath, i: usize) -> bool {
let min = if sp.closed { 3 } else { 2 };
if i >= sp.anchors.len() || sp.anchors.len() <= min {
return false;
}
sp.anchors.remove(i);
if !sp.closed {
// A new end has nothing beyond it.
if let Some(a) = sp.anchors.first_mut() {
a.h_in = a.p;
}
if let Some(a) = sp.anchors.last_mut() {
a.h_out = a.p;
}
}
true
}
#[cfg(test)]
mod tests {
use kurbo::Point;
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
use super::*;
use crate::addons::pathtools::SMOOTH_RATIO;
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn parallel(a: Vec2, b: Vec2) -> bool {
a.cross(b).abs() < 1e-9 && a.dot(b) > 0.0
}
#[test]
fn a_curve_after_a_straight_segment_leaves_tangent_to_it() {
// (0,0) → (100,0) straight, then a curve on to (150,50).
let mut sp = SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0), pt(150.0, 50.0)], false);
assert!(leave_handle(&mut sp, 1, true, SMOOTH_RATIO));
let a = sp.anchors[1];
assert!(parallel(a.h_out - a.p, Vec2::new(1.0, 0.0)), "{:?}", a.h_out);
assert!(((a.h_out - a.p).hypot() - 50f64.hypot(50.0) * SMOOTH_RATIO).abs() < 1e-9);
assert!(!a.has_in(), "the straight segment stays straight");
assert!(sp.segment_is_line(0) && !sp.segment_is_line(1));
assert!(!leave_handle(&mut sp, 1, true, SMOOTH_RATIO), "it has one already");
}
#[test]
fn a_lone_handle_is_continued_and_corners_between_curves_are_smoothed() {
let mut sp = SubPath::new(
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(30.0, -10.0), pt(50.0, 0.0)), Anchor::corner(pt(100.0, 0.0))],
false,
);
assert!(leave_handle(&mut sp, 1, true, SMOOTH_RATIO));
let a = sp.anchors[1];
assert_eq!(a.kind, AnchorKind::Smooth);
assert!(parallel(a.h_out - a.p, a.p - a.h_in));
// A corner without handles after a curved segment: smoothed by the auto-smooth rule.
let mut sp = SubPath::new(
vec![Anchor::with_handles(pt(0.0, 0.0), pt(0.0, 0.0), pt(20.0, -30.0)), Anchor::corner(pt(50.0, 0.0)), Anchor::corner(pt(100.0, 0.0))],
false,
);
assert!(leave_handle(&mut sp, 1, true, SMOOTH_RATIO));
assert!(sp.anchors[1].has_in() && sp.anchors[1].has_out() && sp.anchors[1].kind == AnchorKind::Smooth);
}
#[test]
fn segments_toggle_between_straight_and_curved() {
let mut sp = SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)], false);
assert!(curve_segment(&mut sp, 1, SMOOTH_RATIO));
assert!(!sp.segment_is_line(1) && sp.segment_is_line(0) && sp.segment_is_line(2));
assert!(!curve_segment(&mut sp, 1, SMOOTH_RATIO), "curved already");
assert!(straighten_segment(&mut sp, 1));
assert!(sp.segment_is_line(1));
assert!(!straighten_segment(&mut sp, 1));
assert!(!curve_segment(&mut sp, 3, SMOOTH_RATIO) && !straighten_segment(&mut sp, 3), "no such segment");
}
#[test]
fn toggling_a_point_moves_nothing_one_way_and_smooths_the_other() {
let mut sp = SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 100.0)], false);
assert!(toggle_smooth(&mut sp, 1, SMOOTH_RATIO));
let smooth = sp.anchors[1];
assert_eq!(smooth.kind, AnchorKind::Smooth);
assert!(toggle_smooth(&mut sp, 1, SMOOTH_RATIO));
let corner = sp.anchors[1];
assert_eq!((corner.kind, corner.h_in, corner.h_out), (AnchorKind::Corner, smooth.h_in, smooth.h_out));
assert!(!toggle_smooth(&mut sp, 9, SMOOTH_RATIO));
}
#[test]
fn delete_keeps_the_neighbours_handles() {
let mut sp = SubPath::new(
vec![Anchor::smooth(pt(0.0, 0.0), pt(10.0, 10.0)), Anchor::corner(pt(50.0, 0.0)), Anchor::smooth(pt(100.0, 0.0), pt(110.0, -10.0))],
false,
);
assert!(delete_anchor(&mut sp, 1));
assert_eq!(sp.anchors.len(), 2);
assert_eq!((sp.anchors[0].h_out, sp.anchors[1].h_in), (pt(10.0, 10.0), pt(90.0, 10.0)));
assert_eq!((sp.anchors[0].h_in, sp.anchors[1].h_out), (pt(0.0, 0.0), pt(100.0, 0.0)), "the ends have nothing beyond them");
assert!(!delete_anchor(&mut sp, 0), "two anchors stay");
let mut tri = SubPath::polyline(&[pt(0.0, 0.0), pt(1.0, 0.0), pt(0.0, 1.0)], true);
assert!(!delete_anchor(&mut tri, 1) && !delete_anchor(&mut tri, 7));
}
}
@@ -0,0 +1,208 @@
//! Reshaping a segment by dragging a point on it, shared by the add-on tools that bend segments
//! directly. The grabbed point follows the pointer; how the two handles share the move depends on
//! where the segment was grabbed (the nearer end's handle moves more) and on the [`ReshapeMode`].
//! A missing handle grows out of its anchor, and a smooth anchor's other handle turns with the one
//! moved, so the path stays smooth there.
use kurbo::Vec2;
use vectorcraft_geom::SubPath;
/// The range the grab position `t` is held to: right at an end, the handle beside it would have
/// to grow without bound to move the point.
pub const T_RANGE: (f64, f64) = (0.05, 0.95);
/// How [`reshape_segment`] moves the handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReshapeMode {
/// Both handles move in the direction of the drag, the one nearer the grab more.
Free,
/// Each handle keeps its direction and only changes length (a missing one points along the
/// segment's chord). A segment whose two directions are parallel can't bend that way, and is
/// reshaped [`ReshapeMode::Free`].
KeepAngles,
/// Both handles stand at right angles to the chord, on one side and at one length.
Symmetric,
}
impl ReshapeMode {
pub const ALL: [Self; 3] = [Self::Free, Self::KeepAngles, Self::Symmetric];
pub fn id(self) -> &'static str {
match self {
Self::Free => "free",
Self::KeepAngles => "keepAngles",
Self::Symmetric => "symmetric",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id() == s)
}
}
fn unit(v: Vec2) -> Option<Vec2> {
let l = v.hypot();
(l > 1e-9 && l.is_finite()).then(|| v / l)
}
/// Bend segment `seg` of `sp` so its point at `t` (held to [`T_RANGE`]) moves by `dv`, the way
/// `mode` says. False, leaving `sp` as it was, when there is no such segment, an input isn't
/// finite or the result wouldn't be.
pub fn reshape_segment(sp: &mut SubPath, seg: usize, t: f64, dv: Vec2, mode: ReshapeMode) -> bool {
if seg >= sp.segment_count() || !t.is_finite() || !dv.x.is_finite() || !dv.y.is_finite() {
return false;
}
let t = t.clamp(T_RANGE.0, T_RANGE.1);
let mut out = sp.clone();
let bent = match mode {
ReshapeMode::Symmetric => out.bend_segment_perpendicular(seg, t, dv),
ReshapeMode::KeepAngles => keep_angles(&mut out, seg, t, dv) || free(&mut out, seg, t, dv),
ReshapeMode::Free => free(&mut out, seg, t, dv),
};
let finite = out.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite());
if !bent || !finite {
return false;
}
*sp = out;
true
}
/// The weights the segment's two inner control points have at `t`.
fn weights(t: f64) -> (f64, f64) {
let s = 1.0 - t;
(3.0 * s * s * t, 3.0 * s * t * t)
}
/// Move the two handles by shares of `dv` weighted by the grab position: the share each gets is in
/// proportion to how near its end the grab is, scaled so the point at `t` lands exactly `dv` away
/// (the smallest move of the two that does it).
fn free(sp: &mut SubPath, seg: usize, t: f64, dv: Vec2) -> bool {
let n = sp.anchors.len();
let (w1, w2) = weights(t);
let (a, b) = (1.0 - t, t);
let k = 1.0 / (w1 * a + w2 * b);
if let Some(p) = sp.anchors.get_mut(seg % n) {
p.set_handle(true, p.h_out + dv * (k * a), false);
}
if let Some(q) = sp.anchors.get_mut((seg + 1) % n) {
q.set_handle(false, q.h_in + dv * (k * b), false);
}
true
}
/// Lengthen or shorten the two handles along their own directions so the point at `t` moves by
/// `dv`; a handle that would turn back on itself is retracted. False when the directions are
/// parallel (no such move exists).
fn keep_angles(sp: &mut SubPath, seg: usize, t: f64, dv: Vec2) -> bool {
let n = sp.anchors.len();
let c = sp.segment(seg);
let (Some(u1), Some(u2)) = (unit(c.p1 - c.p0).or_else(|| unit(c.p3 - c.p0)), unit(c.p2 - c.p3).or_else(|| unit(c.p0 - c.p3))) else {
return false;
};
let det = u1.cross(u2);
if det.abs() < 1e-6 {
return false;
}
let (w1, w2) = weights(t);
// Cramer's rule for x·u1 + y·u2 = dv, then each handle's change of length.
let (x, y) = (dv.cross(u2) / det, u1.cross(dv) / det);
let len1 = ((c.p1 - c.p0).dot(u1) + x / w1).max(0.0);
let len2 = ((c.p2 - c.p3).dot(u2) + y / w2).max(0.0);
if let Some(p) = sp.anchors.get_mut(seg % n) {
p.set_handle(true, c.p0 + u1 * len1, false);
}
if let Some(q) = sp.anchors.get_mut((seg + 1) % n) {
q.set_handle(false, c.p3 + u2 * len2, false);
}
true
}
#[cfg(test)]
mod tests {
use kurbo::{ParamCurve, Point, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
use super::*;
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn line() -> SubPath {
SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false)
}
#[test]
fn the_grabbed_point_follows_the_drag_and_the_nearer_handle_moves_more() {
for t in [0.2, 0.5, 0.8] {
let mut sp = line();
let before = sp.segment(0).eval(t);
assert!(reshape_segment(&mut sp, 0, t, Vec2::new(0.0, 30.0), ReshapeMode::Free));
let after = sp.segment(0).eval(t);
assert!((after - before - Vec2::new(0.0, 30.0)).hypot() < 1e-9, "t {t}: {after:?}");
// The handles grew out of the anchors, the one nearer the grab the longer.
let (h1, h2) = ((sp.anchors[0].h_out - sp.anchors[0].p).hypot(), (sp.anchors[1].h_in - sp.anchors[1].p).hypot());
assert!(h1 > 0.0 && h2 > 0.0);
assert_eq!(h1 > h2, t < 0.5, "t {t}: {h1} {h2}");
}
}
#[test]
fn a_smooth_neighbour_stays_smooth() {
let mut sp =
SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), Anchor::smooth(pt(100.0, 0.0), pt(130.0, 0.0)), Anchor::corner(pt(200.0, 0.0))], false);
assert!(reshape_segment(&mut sp, 0, 0.7, Vec2::new(0.0, -20.0), ReshapeMode::Free));
let a = sp.anchors[1];
assert_eq!(a.kind, AnchorKind::Smooth);
assert!((a.h_out - a.p).cross(a.p - a.h_in).abs() < 1e-9, "the handles stay in line");
assert!(((a.h_out - a.p).hypot() - 30.0).abs() < 1e-9, "the other handle keeps its length");
}
#[test]
fn keep_angles_only_changes_lengths() {
let mut sp = SubPath::new(
vec![
Anchor::with_handles(pt(0.0, 0.0), pt(0.0, 0.0), pt(20.0, -20.0)),
Anchor::with_handles(pt(100.0, 0.0), pt(80.0, -20.0), pt(100.0, 0.0)),
],
false,
);
let before = sp.segment(0).eval(0.5);
assert!(reshape_segment(&mut sp, 0, 0.5, Vec2::new(0.0, -10.0), ReshapeMode::KeepAngles));
let (a, b) = (sp.anchors[0], sp.anchors[1]);
assert!((a.h_out - a.p).cross(Vec2::new(1.0, -1.0)).abs() < 1e-9 && (b.h_in - b.p).cross(Vec2::new(-1.0, -1.0)).abs() < 1e-9);
assert!((sp.segment(0).eval(0.5) - before - Vec2::new(0.0, -10.0)).hypot() < 1e-9);
// A straight segment has parallel directions: it bends freely instead.
let mut sp = line();
assert!(reshape_segment(&mut sp, 0, 0.5, Vec2::new(0.0, 10.0), ReshapeMode::KeepAngles));
assert!((sp.segment(0).eval(0.5) - pt(50.0, 10.0)).hypot() < 1e-9);
}
#[test]
fn symmetric_makes_the_handles_square_to_the_chord() {
let mut sp = line();
assert!(reshape_segment(&mut sp, 0, 0.3, Vec2::new(5.0, 20.0), ReshapeMode::Symmetric));
let (a, b) = (sp.anchors[0], sp.anchors[1]);
assert!((a.h_out.x - a.p.x).abs() < 1e-9 && (b.h_in.x - b.p.x).abs() < 1e-9);
assert!(((a.h_out - a.p).hypot() - (b.h_in - b.p).hypot()).abs() < 1e-9);
}
#[test]
fn bad_input_changes_nothing() {
let mut sp = line();
for (seg, t, dv) in [
(1, 0.5, Vec2::new(0.0, 1.0)),
(0, f64::NAN, Vec2::new(0.0, 1.0)),
(0, 0.5, Vec2::new(f64::INFINITY, 0.0)),
(0, 0.05, Vec2::new(1e308, 1e308)),
] {
assert!(!reshape_segment(&mut sp, seg, t, dv, ReshapeMode::Free), "{seg} {t} {dv:?}");
assert_eq!(sp, line());
}
assert!(!reshape_segment(&mut SubPath::default(), 0, 0.5, Vec2::new(1.0, 1.0), ReshapeMode::KeepAngles));
// A grab at an end is held inside the range, so the handles stay finite.
assert!(reshape_segment(&mut sp, 0, 0.0, Vec2::new(0.0, 1.0), ReshapeMode::Free));
assert_eq!(ReshapeMode::parse("keepAngles"), Some(ReshapeMode::KeepAngles));
assert_eq!(ReshapeMode::parse("sideways"), None);
}
}