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
+2
View File
@@ -4,6 +4,7 @@
mod cleanup;
mod color;
mod pathtools;
mod pen;
use super::CommandSpec;
@@ -12,5 +13,6 @@ pub fn specs() -> Vec<CommandSpec> {
let mut v = cleanup::specs();
v.extend(color::specs());
v.extend(pathtools::specs());
v.extend(pen::specs());
v
}
+2 -2
View File
@@ -430,7 +430,7 @@ fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath
}
/// A smoothing `ratio` param (default [`pt::SMOOTH_RATIO`]).
fn ratio(p: &Value, cmd: &str) -> Result<f64> {
pub(super) fn ratio(p: &Value, cmd: &str) -> Result<f64> {
let r = number_or(p, "ratio", pt::SMOOTH_RATIO, cmd)?;
let (lo, hi) = pt::SMOOTH_RATIO_RANGE;
if !(lo..=hi).contains(&r) {
@@ -575,7 +575,7 @@ fn add_points(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "added": added }))
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn close(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.close";
let mode = match text_param(p, "mode", C)? {
None => pt::CloseMode::Handles,
+349
View File
@@ -0,0 +1,349 @@
//! Smart Pen (`addon.pen.*`), a built-in add-on: one tool that draws paths and edits their points,
//! handles and segments without switching tools, and a panel. The commands here are what the tool
//! and the panel do that the standard path commands don't: anchors added with a straight or curved
//! segment into them, segments switched between straight and curved, anchors removed with or
//! without refitting their neighbours, smooth/corner toggles, segments bent by a dragged point, and
//! the panel's next-segment choice and Close or Join. The tool, panel and submenu (Object ›
//! Plug-ins › Smart Pen) are the tools crate's and the UI's.
#[cfg(test)]
mod tests;
use std::collections::BTreeSet;
use serde_json::{Map, Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath, Vec2};
use vectorcraft_pathops::addons::{self as po, pathtools as pt, pen};
use vectorcraft_tools::addons::smart_pen;
use super::super::draw2::usize_opt;
use super::super::path::{compound_or_self, join, path_mut};
use super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param};
use crate::{EngineError, Result, Session};
/// The largest coordinate or move (pt) the commands take.
const LIMIT: f64 = 1e9;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.pen.append",
"Smart Pen",
[],
None,
"{id, subpath?: index (default: the last open subpath), x, y, in?: [x, y], out?: [x, y], segment?: \"straight\" (the segment into the new anchor is a line: the previous anchor's handle on it goes, and the new anchor's incoming one)|\"curved\" (the previous anchor gets a handle on it if it has none: in line with its other handle, or with a straight segment before it)} add an anchor at the end of an open subpath of path id (default: the handles as given, the previous anchor as it is), as one undo step; the new anchor is selected → {anchor}",
has_doc,
append
),
cmd!(
"addon.pen.close",
"Close Path",
[],
None,
"{id, subpath?: index (default: the last open subpath), segment?: \"straight\"|\"curved\" (default: the closing segment takes the handles its ends have)} close an open subpath of two anchors or more, as one undo step",
has_doc,
close
),
cmd!(
"addon.pen.segment",
"Straight or Curved Segment",
[],
None,
"{id, subpath?: index (0), segment: index, curved: bool} make a segment straight (its handles retracted) or curved (each end without a handle on it gets one, in line with its other handle or with a straight segment before it), as one undo step → {changed}",
has_doc,
segment
),
cmd!(
"addon.pen.removePoint",
"Remove Points, Keep Shape",
[],
None,
"{id, subpath?: index (0), anchor: index, plain?: bool (false: the handles beside it are refitted so the outline keeps its shape, and an open end takes its segment with it; true: it goes and the handles beside it stay as they are)} remove one anchor, as one undo step; a subpath keeps two anchors (three when closed)",
has_doc,
remove_point
),
cmd!(
"addon.pen.togglePoint",
"Convert Anchor Point",
[],
None,
"{id, subpath?: index (0), anchor: index, ratio?: 0.1..0.5 (0.3905)} a smooth anchor becomes a corner with its handles where they are; any other anchor is made smooth by the auto-smooth rule (a lone handle mirrored; without handles both at right angles to the bisector of the angle at it, ratio × the nearer neighbour's distance long). One undo step → {smooth}",
has_doc,
toggle_point
),
cmd!(
"addon.pen.reshape",
"Reshape Segment",
[],
None,
"{id, subpath?: index (0), segment: index, t?: 0..1 (where it was grabbed, 0.5; held to 0.05..0.95), dx, dy: pt, mode?: \"free\" (both handles move, the one nearer the grab more)|\"keepAngles\" (each handle keeps its direction)|\"symmetric\" (both handles at right angles to the chord, at one length) (\"free\")} bend a segment so the point grabbed moves by (dx, dy); missing handles grow out of their anchors and a smooth anchor stays smooth. One undo step",
has_doc,
reshape
),
cmd!(
"addon.pen.nextSegment",
"Next Segment",
["Object", "Plug-ins", "Smart Pen"],
None,
"{curved: bool} whether a click with the Smart Pen Tool adds a curved segment or a straight one (the tool's `next` option, kept with the preferences) → {next: \"straight\"|\"curved\"}",
always,
next_segment
),
cmd!(
"addon.pen.closeJoin",
"Close or Join",
["Object", "Plug-ins", "Smart Pen"],
None,
"{} with exactly two open path ends direct-selected, join them as path.join does; else close the selected open paths keeping their ends' handles, as addon.pathtools.close does. One undo step",
has_selection,
close_join
),
]
}
/// The `segment` param: straight (false) or curved (true), `None` when not given.
fn segment_kind(p: &Value, cmd: &str) -> Result<Option<bool>> {
match text_param(p, "segment", cmd)? {
None => Ok(None),
Some("straight") => Ok(Some(false)),
Some("curved") => Ok(Some(true)),
Some(_) => Err(bad(cmd, "`segment` must be \"straight\" or \"curved\"")),
}
}
/// An index param that must be given.
fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
usize_opt(p, key, cmd)?.ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
/// A true or false param that must be given.
fn switch_req(p: &Value, key: &str, cmd: &str) -> Result<bool> {
p.get(key).and_then(Value::as_bool).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false")))
}
/// Two number params that must be given, finite and within [`LIMIT`].
fn pair(p: &Value, (a, b): (&str, &str), cmd: &str) -> Result<(f64, f64)> {
let (x, y) = (number_or(p, a, f64::NAN, cmd)?, number_or(p, b, f64::NAN, cmd)?);
if !(x.abs() < LIMIT && y.abs() < LIMIT) {
return Err(bad(cmd, format!("give `{a}` and `{b}`")));
}
Ok((x, y))
}
/// The subpath a request names: its path (`id`, editable), its index (`subpath`; without one the
/// last open subpath with `open`, else the first) and its data.
fn subpath_of(s: &Session, p: &Value, cmd: &str, open: bool) -> Result<(NodeId, usize, SubPath)> {
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?;
let d = &s.doc()?.doc;
if !d.is_editable(id) {
return Err(bad(cmd, "the path is locked or hidden"));
}
let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?;
let si = match usize_opt(p, "subpath", cmd)? {
Some(si) => si,
None if open => path.subpaths.iter().rposition(|sp| !sp.closed).ok_or_else(|| bad(cmd, "the path has no open subpath"))?,
None => 0,
};
let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?;
Ok((id, si, sp.clone()))
}
/// What the selection becomes once a subpath is edited.
enum Pick {
/// As it was (no anchor moved to another index).
Keep,
/// The path (its compound path) selected whole.
Whole,
/// Only this anchor of the subpath, direct-selected.
Anchor(usize),
}
/// Put `sp` in place of subpath `si` of path `id`, as one undo step labelled `label`.
fn put(s: &mut Session, label: &str, id: NodeId, si: usize, sp: SubPath, pick: Pick) -> Result<()> {
s.edit(label, |d, sel| {
let slot = path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
*slot = sp;
let top = compound_or_self(d, id);
match pick {
Pick::Keep => {}
Pick::Whole => sel.set([top]),
Pick::Anchor(ai) => {
sel.set([top]);
sel.anchors.insert(id, BTreeSet::from([(si, ai)]));
}
}
Ok(())
})
}
/// An optional `[x, y]` param, finite and within [`LIMIT`].
fn point_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<Point>> {
match p.get(key).filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => finite_numbers::<2>(v)
.filter(|[x, y]| x.abs() < LIMIT && y.abs() < LIMIT)
.map(|[x, y]| Some(Point::new(x, y)))
.ok_or_else(|| bad(cmd, format!("`{key}` must be [x, y]"))),
}
}
fn append(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pen.append";
let (x, y) = pair(p, ("x", "y"), C)?;
let at = Point::new(x, y);
let (h_in, h_out) = (point_opt(p, "in", C)?.unwrap_or(at), point_opt(p, "out", C)?.unwrap_or(at));
let curved = segment_kind(p, C)?;
let (id, si, mut sp) = subpath_of(s, p, C, true)?;
if sp.closed {
return Err(bad(C, "the subpath is closed"));
}
sp.anchors.push(Anchor::with_handles(at, h_in, h_out));
let k = sp.anchors.len() - 1;
if let Some(prev) = k.checked_sub(1) {
match curved {
Some(true) => {
pen::leave_handle(&mut sp, prev, true, pt::SMOOTH_RATIO);
}
Some(false) => {
pen::straighten_segment(&mut sp, prev);
}
None => {}
}
}
put(s, "Smart Pen", id, si, sp, Pick::Anchor(k))?;
Ok(json!({ "anchor": k }))
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pen.close";
let curved = segment_kind(p, C)?;
let (id, si, mut sp) = subpath_of(s, p, C, true)?;
if sp.closed || sp.anchors.len() < 2 {
return Err(bad(C, "the subpath is closed or too short to close"));
}
sp.closed = true;
let last = sp.anchors.len() - 1;
match curved {
Some(true) => {
pen::curve_segment(&mut sp, last, pt::SMOOTH_RATIO);
}
Some(false) => {
pen::straighten_segment(&mut sp, last);
}
None => {}
}
put(s, "Close Path", id, si, sp, Pick::Whole)?;
Ok(json!({}))
}
fn segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pen.segment";
let seg = index_req(p, "segment", C)?;
let curved = switch_req(p, "curved", C)?;
let (id, si, mut sp) = subpath_of(s, p, C, false)?;
if seg >= sp.segment_count() {
return Err(bad(C, "no such segment"));
}
let changed = if curved { pen::curve_segment(&mut sp, seg, pt::SMOOTH_RATIO) } else { pen::straighten_segment(&mut sp, seg) };
if changed {
put(s, "Smart Pen", id, si, sp, Pick::Keep)?;
}
Ok(json!({ "changed": changed }))
}
fn remove_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pen.removePoint";
let ai = index_req(p, "anchor", C)?;
let plain = switch_or(p, "plain", false, C)?;
let (id, si, mut sp) = subpath_of(s, p, C, false)?;
let n = sp.anchors.len();
if ai >= n {
return Err(bad(C, "no such anchor"));
}
let end = !sp.closed && (ai == 0 || ai + 1 == n);
let removed = if plain {
pen::delete_anchor(&mut sp, ai)
} else if let Some((out, _)) = po::remove_anchor_keep_shape(&sp, ai) {
sp = out;
true
} else {
// An open end takes its segment with it.
end && n > 2 && vectorcraft_pathops::remove_anchor(&mut sp, ai)
};
if !removed {
return Err(bad(C, "the path is too short to remove a point from"));
}
let label = if plain { "Delete Anchor Point" } else { "Remove Points, Keep Shape" };
put(s, label, id, si, sp, Pick::Whole)?;
Ok(json!({}))
}
fn toggle_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pen.togglePoint";
let ai = index_req(p, "anchor", C)?;
let r = super::pathtools::ratio(p, C)?;
let (id, si, mut sp) = subpath_of(s, p, C, false)?;
if ai >= sp.anchors.len() {
return Err(bad(C, "no such anchor"));
}
let changed = pen::toggle_smooth(&mut sp, ai, r);
let smooth = sp.anchors.get(ai).is_some_and(|a| a.kind == AnchorKind::Smooth);
if changed {
put(s, "Convert Anchor Point", id, si, sp, Pick::Keep)?;
}
Ok(json!({ "smooth": smooth }))
}
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pen.reshape";
let seg = index_req(p, "segment", C)?;
let t = number_or(p, "t", 0.5, C)?;
if !(0.0..=1.0).contains(&t) {
return Err(bad(C, "`t` must be from 0 to 1"));
}
let (dx, dy) = pair(p, ("dx", "dy"), C)?;
let dv = Vec2::new(dx, dy);
let mode = match text_param(p, "mode", C)? {
None => po::ReshapeMode::Free,
Some(m) => po::ReshapeMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"free\", \"keepAngles\" or \"symmetric\""))?,
};
let (id, si, mut sp) = subpath_of(s, p, C, false)?;
if seg >= sp.segment_count() {
return Err(bad(C, "no such segment"));
}
if !po::reshape_segment(&mut sp, seg, t, dv, mode) {
return Err(bad(C, "the segment can't be bent that far"));
}
put(s, "Reshape Segment", id, si, sp, Pick::Keep)?;
Ok(json!({}))
}
fn next_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pen.nextSegment";
let curved = switch_req(p, "curved", C)?;
let next = if curved { "curved" } else { "straight" };
let values = Map::from_iter([("next".to_string(), json!(next))]);
s.set_tool_options(Some(smart_pen::ID), &values);
Ok(json!({ "next": next }))
}
fn close_join(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let d = &st.doc;
// The direct-selected anchors that are ends of open subpaths.
let mut picked = 0;
let mut ends = 0;
for (id, set) in &st.selection.anchors {
let path = d.node(*id).and_then(|n| n.path_data());
for &(si, ai) in set {
picked += 1;
let sp = path.and_then(|pd| pd.subpaths.get(si));
ends += usize::from(sp.is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len())));
}
}
if picked == 2 && ends == 2 {
return join(s, &json!({}));
}
super::pathtools::close(s, &json!({ "mode": "handles" }))
}
+287
View File
@@ -0,0 +1,287 @@
//! Smart Pen's commands: each one undo step, bad params refused, and the tool's gestures through
//! the session.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{AnchorKind, PathData, Point, Vec2};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use crate::{Session, ViewInfo};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap())
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Each of `bad` is refused and leaves the document as it was.
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
for p in bad {
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
}
assert_eq!(steps(s), n, "{cmd}");
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
}
fn parallel(a: Vec2, b: Vec2) -> bool {
a.cross(b).abs() < 1e-9 && a.dot(b) > 0.0
}
#[test]
fn append_adds_a_straight_or_curved_segment() {
let mut s = session();
let id = line(&mut s, &[(0.0, 100.0), (100.0, 100.0)]);
let n = steps(&s);
let r = s.execute("addon.pen.append", &json!({"id": id.0, "x": 150, "y": 150, "segment": "curved"})).unwrap();
assert_eq!(r["anchor"], json!(2));
assert_eq!(steps(&s), n + 1, "one undo step");
let sp = &path(&s, id).subpaths[0];
// The curve leaves (100, 100) along the straight segment before it.
let a = sp.anchors[1];
assert!(a.has_out() && !a.has_in() && parallel(a.h_out - a.p, Vec2::new(1.0, 0.0)), "{a:?}");
assert!(sp.segment_is_line(0) && !sp.segment_is_line(1));
// The new anchor is the one selected.
let sel = &s.doc().unwrap().selection;
assert_eq!(sel.anchors.get(&id), Some(&BTreeSet::from([(0, 2)])));
// Straight: the previous anchor's handle on it goes, and the new anchor's incoming one.
s.execute("addon.pen.append", &json!({"id": id.0, "subpath": 0, "x": 250, "y": 150, "in": [200, 100], "out": [300, 200], "segment": "straight"}))
.unwrap();
let sp = &path(&s, id).subpaths[0];
assert!(sp.segment_is_line(2) && sp.anchors[3].has_out() && !sp.anchors[3].has_in());
assert_eq!(sp.anchors[3].kind, AnchorKind::Corner);
// Without a segment, the handles as given.
s.execute("addon.pen.append", &json!({"id": id.0, "x": 350, "y": 150, "in": [330, 140], "out": [370, 160]})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[4].kind, AnchorKind::Smooth);
let closed = line(&mut s, &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
s.execute("path.close", &json!({"id": closed.0})).unwrap();
refuses(
&mut s,
"addon.pen.append",
&[
json!({}),
json!({"id": id.0}),
json!({"id": id.0, "x": 1, "y": f64::MAX}),
json!({"id": id.0, "x": 1, "y": 1, "segment": "wavy"}),
json!({"id": id.0, "x": 1, "y": 1, "in": [1]}),
json!({"id": id.0, "x": 1, "y": 1, "subpath": 3}),
json!({"id": closed.0, "x": 1, "y": 1}),
json!({"id": closed.0, "x": 1, "y": 1, "subpath": 0}),
json!({"id": 999_999, "x": 1, "y": 1}),
],
);
}
#[test]
fn close_makes_the_closing_segment_straight_or_curved() {
let mut s = session();
let id = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]);
let n = steps(&s);
s.execute("addon.pen.close", &json!({"id": id.0, "segment": "curved"})).unwrap();
let sp = &path(&s, id).subpaths[0];
assert!(sp.closed && !sp.segment_is_line(2));
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.pen.close", &json!({"id": id.0, "segment": "straight"})).unwrap();
assert!(path(&s, id).subpaths[0].segment_is_line(2));
let dot = line(&mut s, &[(5.0, 5.0)]);
refuses(&mut s, "addon.pen.close", &[json!({}), json!({"id": id.0}), json!({"id": dot.0}), json!({"id": id.0, "segment": 1})]);
}
#[test]
fn segment_switches_between_straight_and_curved() {
let mut s = session();
let id = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]);
let n = steps(&s);
let r = s.execute("addon.pen.segment", &json!({"id": id.0, "segment": 1, "curved": true})).unwrap();
assert_eq!(r["changed"], json!(true));
assert!(!path(&s, id).subpaths[0].segment_is_line(1));
s.execute("addon.pen.segment", &json!({"id": id.0, "segment": 1, "curved": false})).unwrap();
assert!(path(&s, id).subpaths[0].segment_is_line(1));
assert_eq!(steps(&s), n + 2);
// Already straight: no step.
let r = s.execute("addon.pen.segment", &json!({"id": id.0, "segment": 1, "curved": false})).unwrap();
assert_eq!((r["changed"].clone(), steps(&s)), (json!(false), n + 2));
refuses(
&mut s,
"addon.pen.segment",
&[
json!({"id": id.0, "segment": 1}),
json!({"id": id.0, "segment": 2, "curved": true}),
json!({"id": id.0, "curved": true}),
json!({"id": id.0, "segment": -1, "curved": true}),
],
);
}
#[test]
fn remove_point_keeps_the_shape_or_deletes_plainly() {
let mut s = session();
let e = NodeId(s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
let before = path(&s, e);
let n = steps(&s);
s.execute("addon.pen.removePoint", &json!({"id": e.0, "anchor": 1})).unwrap();
let after = path(&s, e);
assert_eq!(after.anchor_count(), 7);
assert_eq!(steps(&s), n + 1);
// The outline barely moved: its bounds are the circle's.
let (b0, b1) = (before.bounds().unwrap(), after.bounds().unwrap());
assert!((b0.width() - b1.width()).abs() < 2.0 && (b0.height() - b1.height()).abs() < 2.0, "{b0:?} {b1:?}");
// Plain: the handles beside it stay as they were.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.pen.removePoint", &json!({"id": e.0, "anchor": 1, "plain": true})).unwrap();
let plain = path(&s, e);
assert_eq!(
(plain.subpaths[0].anchors[0].h_out, plain.subpaths[0].anchors[1].h_in),
(before.subpaths[0].anchors[0].h_out, before.subpaths[0].anchors[2].h_in)
);
// An open end goes with its segment; two anchors stay.
let l = line(&mut s, &[(0.0, 0.0), (50.0, 0.0), (100.0, 0.0)]);
s.execute("addon.pen.removePoint", &json!({"id": l.0, "anchor": 2})).unwrap();
assert_eq!(path(&s, l).subpaths[0].anchors.len(), 2);
refuses(
&mut s,
"addon.pen.removePoint",
&[
json!({"id": l.0, "anchor": 0}),
json!({"id": l.0, "anchor": 0, "plain": true}),
json!({"id": l.0, "anchor": 5}),
json!({"id": l.0}),
json!({"id": l.0, "anchor": 1, "plain": 1}),
],
);
}
#[test]
fn toggle_point_and_reshape() {
let mut s = session();
let id = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]);
let n = steps(&s);
assert_eq!(s.execute("addon.pen.togglePoint", &json!({"id": id.0, "anchor": 1})).unwrap()["smooth"], json!(true));
let smooth = path(&s, id).subpaths[0].anchors[1];
assert_eq!(s.execute("addon.pen.togglePoint", &json!({"id": id.0, "anchor": 1})).unwrap()["smooth"], json!(false));
let corner = path(&s, id).subpaths[0].anchors[1];
assert_eq!((corner.h_in, corner.h_out), (smooth.h_in, smooth.h_out), "nothing moved");
assert_eq!(steps(&s), n + 2);
refuses(&mut s, "addon.pen.togglePoint", &[json!({"id": id.0, "anchor": 9}), json!({"id": id.0, "anchor": 1, "ratio": 2})]);
// The grabbed point follows the drag.
let l = line(&mut s, &[(0.0, 200.0), (100.0, 200.0)]);
for mode in ["free", "keepAngles", "symmetric"] {
s.execute("addon.pen.reshape", &json!({"id": l.0, "segment": 0, "t": 0.5, "dx": 0, "dy": -40, "mode": mode})).unwrap();
let p = kurbo::ParamCurve::eval(&path(&s, l).subpaths[0].segment(0), 0.5);
assert!(p.distance(Point::new(50.0, 160.0)) < 1e-6, "{mode}: {p:?}");
s.execute("edit.undo", &json!({})).unwrap();
}
refuses(
&mut s,
"addon.pen.reshape",
&[
json!({"id": l.0, "segment": 0}),
json!({"id": l.0, "segment": 1, "dx": 1, "dy": 1}),
json!({"id": l.0, "segment": 0, "dx": 1, "dy": 1, "t": 2}),
json!({"id": l.0, "segment": 0, "dx": 1e300, "dy": 1}),
json!({"id": l.0, "segment": 0, "dx": 1, "dy": 1, "mode": "twist"}),
],
);
}
#[test]
fn the_panel_commands_set_the_next_segment_and_close_or_join() {
let mut s = session();
assert_eq!(s.execute("addon.pen.nextSegment", &json!({"curved": true})).unwrap()["next"], json!("curved"));
assert_eq!(s.tool_options_of("smartPen")["next"], json!("curved"));
assert!(s.execute("addon.pen.nextSegment", &json!({})).is_err());
// Two open ends direct-selected: joined.
let a = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)]);
let b = line(&mut s, &[(110.0, 0.0), (200.0, 0.0)]);
s.select(|_, sel| {
sel.set([a, b]);
sel.anchors.insert(a, BTreeSet::from([(0, 1)]));
sel.anchors.insert(b, BTreeSet::from([(0, 0)]));
})
.unwrap();
let n = steps(&s);
s.execute("addon.pen.closeJoin", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1);
assert!(s.doc().unwrap().doc.node(b).is_none() || s.doc().unwrap().doc.node(a).is_none(), "one path is left");
// Otherwise the selected open paths close.
let c = line(&mut s, &[(0.0, 100.0), (100.0, 100.0), (100.0, 200.0)]);
s.execute("addon.pen.closeJoin", &json!({})).unwrap();
assert!(path(&s, c).subpaths[0].closed);
}
fn view() -> ViewInfo {
ViewInfo { smart_guides: false, ..ViewInfo::default() }
}
/// A gesture of the active tool, `m` held throughout: press at the first point, drag through the
/// others, release at the last.
fn gesture(s: &mut Session, pts: &[(f64, f64)], m: Mods) {
let mut kinds = vec![PointerKind::Down];
kinds.extend(pts.iter().skip(1).map(|_| PointerKind::Drag));
for (k, (x, y)) in kinds.into_iter().zip(pts.iter().copied()) {
s.pointer(&PointerEvent::new(k, x, y).with_mods(m), view()).unwrap();
}
let (x, y) = pts.last().copied().unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y).with_mods(m), view()).unwrap();
}
/// The one path of the session's document.
fn only_path(s: &Session) -> (NodeId, PathData) {
let mut found = vec![];
s.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
found.push((n.id, p.clone()));
}
});
assert_eq!(found.len(), 1, "{found:?}");
found.remove(0)
}
#[test]
fn the_tool_draws_a_path_one_step_per_anchor_and_closes_it() {
let mut s = session();
s.select_tool("smartPen", view()).unwrap();
let n = steps(&s);
gesture(&mut s, &[(50.0, 50.0)], Mods::default());
gesture(&mut s, &[(150.0, 50.0)], Mods::default());
s.execute("addon.pen.nextSegment", &json!({"curved": true})).unwrap();
gesture(&mut s, &[(150.0, 150.0)], Mods::default());
let (id, p) = only_path(&s);
let sp = &p.subpaths[0];
assert_eq!(sp.anchors.len(), 3);
assert!(sp.segment_is_line(0) && !sp.segment_is_line(1), "a straight segment, then a curved one leaving it tangent");
assert!(parallel(sp.anchors[1].h_out - sp.anchors[1].p, Vec2::new(1.0, 0.0)));
// A drag makes a smooth anchor.
gesture(&mut s, &[(50.0, 150.0), (30.0, 170.0)], Mods::default());
assert_eq!(path(&s, id).subpaths[0].anchors[3].kind, AnchorKind::Smooth);
// Tab between anchors: the last segment straight again.
s.tool_key(ToolKey::Tab, Mods::default(), view()).unwrap();
assert!(path(&s, id).subpaths[0].segment_is_line(2));
gesture(&mut s, &[(50.0, 50.0)], Mods::default());
assert!(path(&s, id).subpaths[0].closed);
assert_eq!(steps(&s), n + 6, "one step per anchor, the Tab and the close");
// Edits with the same tool: a drag on an anchor moves it, Alt-click removes one keeping the shape.
s.select(|_, sel| sel.set([id])).unwrap();
gesture(&mut s, &[(150.0, 50.0), (160.0, 40.0)], Mods::default());
assert_eq!(path(&s, id).subpaths[0].anchors[1].p, Point::new(160.0, 40.0));
gesture(&mut s, &[(160.0, 40.0)], Mods { alt: true, ..Mods::default() });
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
assert_eq!(steps(&s), n + 8);
}
+2 -2
View File
@@ -341,7 +341,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The compound path enclosing `id`, or `id` itself if it is not inside a compound path.
fn compound_or_self(d: &Document, id: NodeId) -> NodeId {
pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId {
d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id)
}
@@ -547,7 +547,7 @@ fn join_subpaths_in_node(d: &mut Document, id: NodeId, (sa, sb): (usize, usize),
}
/// Join open paths into a single path, or close an open path, preserving compound path structure.
fn join(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn join(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.join";
let join_kind = match p.get("to").and_then(Value::as_str) {
None => None,