mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user