diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 8741abdfd..ca6625540 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -4,6 +4,7 @@ mod cleanup; mod color; mod pathtools; +mod pen; use super::CommandSpec; @@ -12,5 +13,6 @@ pub fn specs() -> Vec { let mut v = cleanup::specs(); v.extend(color::specs()); v.extend(pathtools::specs()); + v.extend(pen::specs()); v } diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 1020c46a7..6c5ef24c5 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -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 { +pub(super) fn ratio(p: &Value, cmd: &str) -> Result { 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 { Ok(json!({ "added": added })) } -fn close(s: &mut Session, p: &Value) -> Result { +pub(super) fn close(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.close"; let mode = match text_param(p, "mode", C)? { None => pt::CloseMode::Handles, diff --git a/crates/engine/src/cmd/addons/pen.rs b/crates/engine/src/cmd/addons/pen.rs new file mode 100644 index 000000000..35d0ea5b5 --- /dev/null +++ b/crates/engine/src/cmd/addons/pen.rs @@ -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 { + 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> { + 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_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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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" })) +} diff --git a/crates/engine/src/cmd/addons/pen/tests.rs b/crates/engine/src/cmd/addons/pen/tests.rs new file mode 100644 index 000000000..1bb9f3345 --- /dev/null +++ b/crates/engine/src/cmd/addons/pen/tests.rs @@ -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 = 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); +} diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 24757213b..5450f3d3c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -341,7 +341,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { } /// 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 { +pub(super) fn join(s: &mut Session, p: &Value) -> Result { const C: &str = "path.join"; let join_kind = match p.get("to").and_then(Value::as_str) { None => None, diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 1881fafc4..4bc82c439 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -510,6 +510,22 @@ fn structured_junk() { ("shapeBuilder.regions", json!({"ids": [2, 3], "gap": "x"})), ("shapeBuilder.regions", json!({"gap": true})), ("shapeBuilder.regions", json!({"gap": 12, "ids": [u64::MAX]})), + // Smart Pen add-on. + ("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})), + ("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})), + ("addon.pen.append", json!({"id": 2, "x": 9.9e8, "y": -9.9e8, "in": [-9.9e8, 9.9e8], "out": [9.9e8, 9.9e8], "segment": "curved"})), + ("addon.pen.close", json!({"id": 2, "subpath": u64::MAX, "segment": "curved"})), + ("addon.pen.close", json!({"id": 3, "segment": "straight"})), + ("addon.pen.segment", json!({"id": 2, "segment": u64::MAX, "curved": true})), + ("addon.pen.segment", json!({"id": 2, "subpath": 0, "segment": 0, "curved": "yes"})), + ("addon.pen.removePoint", json!({"id": 2, "anchor": u64::MAX, "plain": true})), + ("addon.pen.removePoint", json!({"id": 2, "anchor": 0})), + ("addon.pen.togglePoint", json!({"id": 2, "anchor": 0, "ratio": 1e308})), + ("addon.pen.togglePoint", json!({"id": 2, "subpath": 0, "anchor": 1})), + ("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": 0.0, "dx": 9.9e8, "dy": -9.9e8, "mode": "keepAngles"})), + ("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})), + ("addon.pen.nextSegment", json!({"curved": "yes"})), + ("addon.pen.closeJoin", json!({"ids": [u64::MAX]})), ]; let mut failures = vec![]; for fx in Fixture::ALL { diff --git a/crates/pathops/src/addons.rs b/crates/pathops/src/addons.rs index b60e4a33a..bb821df06 100644 --- a/crates/pathops/src/addons.rs +++ b/crates/pathops/src/addons.rs @@ -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}; diff --git a/crates/pathops/src/addons/pathtools.rs b/crates/pathops/src/addons/pathtools.rs index c7444fab6..7ab280946 100644 --- a/crates/pathops/src/addons/pathtools.rs +++ b/crates/pathops/src/addons/pathtools.rs @@ -37,7 +37,7 @@ fn unit(v: Vec2) -> Option { } /// The indices of anchor `i`'s neighbours in `sp` (`None` past an open end). -fn neighbours(sp: &SubPath, i: usize) -> (Option, Option) { +pub(crate) fn neighbours(sp: &SubPath, i: usize) -> (Option, Option) { 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) }; diff --git a/crates/pathops/src/addons/pen.rs b/crates/pathops/src/addons/pen.rs new file mode 100644 index 000000000..57db044c3 --- /dev/null +++ b/crates/pathops/src/addons/pen.rs @@ -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 { + 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)); + } +} diff --git a/crates/pathops/src/addons/segment_reshape.rs b/crates/pathops/src/addons/segment_reshape.rs new file mode 100644 index 000000000..41acf4419 --- /dev/null +++ b/crates/pathops/src/addons/segment_reshape.rs @@ -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::ALL.into_iter().find(|m| m.id() == s) + } +} + +fn unit(v: Vec2) -> Option { + 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); + } +} diff --git a/crates/tools/src/addons.rs b/crates/tools/src/addons.rs index b34cfb1f5..8c5c9909b 100644 --- a/crates/tools/src/addons.rs +++ b/crates/tools/src/addons.rs @@ -5,6 +5,7 @@ pub mod anchor_eraser; pub mod extend_path; pub mod measure_inspect; +pub mod smart_pen; use crate::{Tool, ToolInfo}; @@ -15,12 +16,19 @@ pub const PATH_TOOLS: &[ToolInfo] = &[ ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" }, ]; +/// Smart Pen's tool, a flyout group of its own. +pub const SMART_PEN: &[ToolInfo] = &[ToolInfo { id: smart_pen::ID, label: "Smart Pen Tool", shortcut: None, icon: "spline-pointer" }]; + +/// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]), each with the +/// heading the All Tools drawer lists it under (its add-on's name). +const HEADED: &[(&[ToolInfo], &str)] = &[(PATH_TOOLS, "Path Precision Tools"), (SMART_PEN, "Smart Pen")]; + /// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]). -pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS]; +pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS, SMART_PEN]; /// The heading the All Tools drawer lists tool `id` under: its add-on's name. pub fn heading(id: &str) -> Option<&'static str> { - PATH_TOOLS.iter().any(|t| t.id == id).then_some("Path Precision Tools") + HEADED.iter().find(|(g, _)| g.iter().any(|t| t.id == id)).map(|(_, h)| *h) } /// Create an add-on tool by id (None = not one of them). @@ -29,6 +37,7 @@ pub fn create(id: &str) -> Option> { anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()), extend_path::ID => Box::new(extend_path::ExtendPathTool::default()), measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()), + smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()), _ => return None, }) } @@ -45,8 +54,10 @@ mod tests { assert!(crate::create(t.id).id() == t.id); assert!(t.shortcut.is_none(), "no default shortcut"); assert!(!crate::TOOL_GROUPS.iter().any(|g| g.iter().any(|x| x.id == t.id)), "{} is not in the toolbar's groups", t.id); - assert_eq!(heading(t.id), Some("Path Precision Tools")); } + assert!(PATH_TOOLS.iter().all(|t| heading(t.id) == Some("Path Precision Tools"))); + assert_eq!(heading(smart_pen::ID), Some("Smart Pen")); + assert_eq!(GROUPS.len(), HEADED.len()); assert!(crate::catalog::all_tools().any(|t| t.id == extend_path::ID)); assert!(create("pen").is_none() && heading("pen").is_none()); } diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs new file mode 100644 index 000000000..b4cc0ea05 --- /dev/null +++ b/crates/tools/src/addons/smart_pen.rs @@ -0,0 +1,553 @@ +//! Smart Pen Tool (Smart Pen add-on): one tool that draws paths and edits them without switching +//! tools or holding a key for it. +//! +//! Drawing: a click adds the next anchor with a straight or a curved segment into it, as the +//! `next` option says (the Smart Pen panel's choice; a curve after a straight segment leaves it +//! tangent); a drag makes a smooth anchor (Alt held breaks the outgoing handle off, Shift keeps it +//! to 45° steps, Space moves the anchor); Tab switches the segment into the anchor being placed, or +//! the last one drawn, between straight and curved. A rubber band shows the next segment. A click +//! on the first anchor closes the path, on an end of another open path joins the two, and away +//! from paths, with no path being drawn, starts a new one. Enter or Esc ends the path. +//! +//! Editing the shown paths' points: a click selects a point (an end of an open path is then drawn +//! on from), Shift-click adds it to the selection or takes it out; a drag moves it (Shift: 45°); +//! Alt-drag pulls out its outgoing handle; Alt-click removes it keeping the shape, Shift+Alt-click +//! deletes it leaving the handles beside it; a double-click toggles it between smooth and corner. +//! A selected point's handle drags (Shift: 45°, Alt: keeps its angle); a double-click on it toggles +//! its point. A segment drags to reshape it (Shift: handles square to its chord, Alt: handles keep +//! their angles), Alt-click adds a point on it; a click on a path that isn't selected selects it. +//! +//! Each gesture is one undo step (`addon.pen.*`, and `path.*` where the standard command does it). + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::{Anchor, BezPath, Point, SubPath, Vec2, constrain_angle_from}; +use vectorcraft_pathops::addons::pathtools::SMOOTH_RATIO; +use vectorcraft_pathops::addons::pen::{leave_handle, straighten_segment}; + +use crate::direct::hit_handle; +use crate::draw2::{anchor_in, hit_segment, segment_in, shown_paths}; +use crate::guides::{DrawSnap, Leave}; +use crate::pen::{active_path, continue_from, open_end_at}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// The tool's id. +pub const ID: &str = "smartPen"; +/// Screen pixels the pointer must move after a press before it drags instead of clicking. +const DRAG_THRESHOLD: f64 = 3.0; + +#[derive(Default)] +pub struct SmartPenTool { + /// A click adds a curved segment (else a straight one): the `next` option. + curved: bool, + /// A path is being drawn... + drawing: bool, + /// ...this subpath of this path, once the engine made it. + path: Option<(NodeId, usize)>, + /// The press under way. + press: Option, + hover: Option, + /// The pointer at the last press or drag (Space moves an anchor being placed by its moves). + last: Point, + snap: DrawSnap, +} + +/// A press: where it went down, what it is on and whether it has become a drag (and started its +/// undo step). +struct Press { + at: Point, + mods: Mods, + dragged: bool, + begun: bool, + what: Target, +} + +/// An anchor being placed at `at`, its handles `h_in` and `h_out`, on `path` (none: the first +/// anchor of a new path); `seg` is the segment into it (straight, curved, or as its handles make +/// it), `tabbed` once Tab chose it. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Place { + at: Point, + h_in: Point, + h_out: Point, + path: Option<(NodeId, usize)>, + seg: Option, + tabbed: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Target { + Place(Place), + /// Closing the path being drawn on its first anchor, at `first`. + Close { + id: NodeId, + si: usize, + first: Point, + }, + /// An anchor of a shown path at `p`; `n` anchors in its subpath, `closed` or not. + Point { + id: NodeId, + si: usize, + ai: usize, + p: Point, + n: usize, + closed: bool, + }, + /// A handle of a selected anchor at `anchor`, the handle at `h`. + Handle { + id: NodeId, + si: usize, + ai: usize, + out: bool, + anchor: Point, + h: Point, + }, + /// A segment, grabbed at `t`. + Segment { + id: NodeId, + si: usize, + seg: usize, + t: f64, + }, +} + +/// What lies under the pointer, in the order a press takes it. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Hit { + /// The first anchor of the path being drawn: a click closes it. + Close(NodeId, usize, Point), + /// A handle of a selected anchor: (path, subpath, anchor, outgoing). + Handle(NodeId, usize, usize, bool), + /// An anchor of a shown path. + Anchor(NodeId, usize, usize), + /// An end of another open path, while one is drawn: a click joins the two. + Join(NodeId, usize, bool), + /// A segment: (path, subpath, segment, t). + Segment(NodeId, usize, usize, f64), + /// An end of an open path, while none is drawn: a click draws on from it. + Continue(NodeId, usize, bool), + Nothing, +} + +fn xy(p: Point) -> Value { + json!([p.x, p.y]) +} + +/// Is `id` selected as the path drawn on: itself, or the compound path it is in? +fn selected_alone(cx: &ToolContext, id: NodeId) -> bool { + let sel = cx.selection.objects.as_slice(); + sel == [id] || cx.doc.parent_of(id).is_some_and(|p| sel == [p] && cx.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) +} + +impl SmartPenTool { + /// The open subpath being drawn: (path, subpath, its data), while it is selected and editable. + fn active<'a>(&self, cx: &'a ToolContext) -> Option<(NodeId, usize, &'a SubPath)> { + let (id, si) = self.path.filter(|_| self.drawing)?; + if !selected_alone(cx, id) || !cx.doc.is_editable(id) { + return None; + } + let sp = cx.doc.node(id)?.path_data()?.subpaths.get(si)?; + (!sp.closed && !sp.anchors.is_empty()).then_some((id, si, sp)) + } + + /// The path a press began is the selected one by the next event. + fn sync(&mut self, cx: &ToolContext) { + if self.drawing + && self.path.is_none() + && let Some((id, si, ..)) = active_path(cx) + { + self.path = Some((id, si)); + } + } + + fn stop(&mut self) { + (self.drawing, self.path) = (false, None); + } + + /// What a press at `p` is on. + fn hit(&self, cx: &ToolContext, p: Point) -> Hit { + let tol = cx.tol(5.0).max(cx.point_tol()); + let point = cx.point_tol(); + let active = self.active(cx); + if let Some((id, si, sp)) = active + && sp.anchors.len() > 1 + && let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) + && first.p.distance(p) <= tol + && first.p.distance(p) <= last.p.distance(p) + { + return Hit::Close(id, si, first.p); + } + if let Some((id, si, ai, out)) = hit_handle(cx, p, point) { + return Hit::Handle(id, si, ai, out); + } + let shown = shown_paths(cx); + if let Some((id, si, ai)) = anchor_in(cx, shown.iter().copied(), p, point) { + return Hit::Anchor(id, si, ai); + } + if let Some((id, si, _)) = active + && let Some((other, osi, first)) = open_end_at(cx, p, tol, Some((id, si))) + { + return Hit::Join(other, osi, first); + } + if active.is_none() + && let Some((id, si, first)) = open_end_at(cx, p, tol, None) + { + return Hit::Continue(id, si, first); + } + // Drawing, only the shown paths' segments take a press; else any path's. + let seg = if active.is_some() { segment_in(cx, shown, p, cx.pick_tol()) } else { hit_segment(cx, p, cx.pick_tol()) }; + seg.map_or(Hit::Nothing, |(id, si, seg, t)| Hit::Segment(id, si, seg, t)) + } + + /// The command that previews the anchor being placed: with its handles once `dragged`. + fn place_preview(&self, pl: &Place, dragged: bool) -> Action { + let Place { at, h_in, h_out, path, seg, tabbed } = *pl; + let Some((id, si)) = path else { + let mut a = json!({"x": at.x, "y": at.y}); + if dragged { + (a["in"], a["out"]) = (xy(h_in), xy(h_out)); + } + return Action::Preview("path.create".into(), json!({"anchors": [a]})); + }; + let mut p = json!({"id": id.0, "subpath": si, "x": at.x, "y": at.y}); + if dragged { + (p["in"], p["out"]) = (xy(h_in), xy(h_out)); + } + // A click adds the segment the option asks for; a drag shapes it with its handles. + let seg = if tabbed || dragged { seg } else { Some(self.curved) }; + if let Some(curved) = seg { + p["segment"] = json!(if curved { "curved" } else { "straight" }); + } + Action::Preview("addon.pen.append".into(), p) + } + + /// Where the drag puts handle `h` of the anchor at `anchor`: Shift turns it in 45° steps; with + /// `keep` it keeps the direction of `was` and only its length follows. + fn handle_to(cx: &ToolContext, anchor: Point, pos: Point, was: Point, shift: bool, keep: bool) -> Point { + let v = pos - anchor; + let dir = was - anchor; + if keep && dir.hypot() > 1e-9 { + let u = dir / dir.hypot(); + return anchor + u * v.dot(u).max(0.0); + } + if shift { anchor + constrain_angle_from(v, 45.0, cx.constrain_angle) } else { pos } + } + + fn press_down(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Vec { + let press = |what| Press { at: ev.pos, mods: ev.mods, dragged: false, begun: false, what }; + match self.hit(cx, ev.pos) { + Hit::Close(id, si, first) => { + self.press = Some(Press { begun: true, ..press(Target::Close { id, si, first }) }); + let seg = if self.curved { "curved" } else { "straight" }; + vec![ + Action::Begin("Close Path".into()), + Action::Preview("addon.pen.close".into(), json!({"id": id.0, "subpath": si, "segment": seg})), + ] + } + Hit::Handle(id, si, ai, out) => { + let Some(a) = cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai).copied()) else { + return vec![]; + }; + self.press = Some(press(Target::Handle { id, si, ai, out, anchor: a.p, h: if out { a.h_out } else { a.h_in } })); + vec![] + } + Hit::Anchor(id, si, ai) => { + let Some(sp) = cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)) else { return vec![] }; + let Some(a) = sp.anchors.get(ai) else { return vec![] }; + self.press = Some(press(Target::Point { id, si, ai, p: a.p, n: sp.anchors.len(), closed: sp.closed })); + if ev.mods.alt { + // Alt: a click removes it, a drag pulls out a handle (decided on the way). + return vec![]; + } + let mode = if ev.mods.shift { "toggle" } else { "set" }; + vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": mode}))] + } + Hit::Join(other, osi, first) => { + let Some((id, si, _)) = self.active(cx) else { return vec![] }; + self.stop(); + let end = if first { "first" } else { "last" }; + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end], "subpaths": [si, osi]}))] + } + Hit::Segment(id, si, seg, t) => { + self.press = Some(press(Target::Segment { id, si, seg, t })); + vec![] + } + Hit::Continue(id, si, first) => { + (self.drawing, self.path) = (true, Some((id, si))); + continue_from(cx, id, si, first) + } + Hit::Nothing => { + let path = self.active(cx).map(|(id, si, _)| (id, si)); + if path.is_none() { + // A new path: the engine makes it, and the next event picks it up. + (self.drawing, self.path) = (true, None); + } + let pl = Place { at: p, h_in: p, h_out: p, path, seg: self.curved.then_some(true), tabbed: false }; + self.press = Some(Press { begun: true, ..press(Target::Place(pl)) }); + vec![Action::Begin("Smart Pen".into()), self.place_preview(&pl, false)] + } + } + } + + fn press_drag(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let Some(mut pr) = self.press.take() else { return vec![] }; + if !pr.dragged && ev.pos.distance(pr.at) < cx.tol(DRAG_THRESHOLD) { + self.last = ev.pos; + self.press = Some(pr); + return vec![]; + } + let first = !pr.dragged; + pr.dragged = true; + let (shift, alt) = (ev.mods.shift, ev.mods.alt); + let (label, act) = match pr.what { + Target::Place(mut pl) => { + if ev.mods.space { + let d = ev.pos - self.last; + pl.at += d; + pl.h_in += d; + } + pl.h_out = Self::handle_to(cx, pl.at, ev.pos, pl.at, shift, false); + // Alt keeps the incoming handle where it is: only the outgoing one follows. + if !alt { + pl.h_in = pl.at - (pl.h_out - pl.at); + } + pr.what = Target::Place(pl); + ("", self.place_preview(&pl, true)) + } + Target::Close { id, si, first } => { + let out = Self::handle_to(cx, first, ev.pos, first, shift, false); + let h_in = first - (out - first); + ("", Action::Preview("path.close".into(), json!({"id": id.0, "subpath": si, "in": xy(h_in), "independent": alt}))) + } + Target::Point { id, si, ai, p, .. } if pr.mods.alt => { + let h = Self::handle_to(cx, p, ev.pos, p, shift, false); + let params = json!({"id": id.0, "subpath": si, "anchor": ai, "which": "out", "x": h.x, "y": h.y}); + ("Move Direction Handle", Action::Preview("path.setHandle".into(), params)) + } + Target::Point { id, si, ai, p, .. } => { + let to = if shift { p + constrain_angle_from(ev.pos - pr.at, 45.0, cx.constrain_angle) } else { p + (ev.pos - pr.at) }; + ( + "Move Anchor Point", + Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "x": to.x, "y": to.y})), + ) + } + Target::Handle { id, si, ai, out, anchor, h } => { + let to = Self::handle_to(cx, anchor, h + (ev.pos - pr.at), h, shift, alt); + let which = if out { "out" } else { "in" }; + let params = json!({"id": id.0, "subpath": si, "anchor": ai, "which": which, "x": to.x, "y": to.y}); + ("Move Direction Handle", Action::Preview("path.setHandle".into(), params)) + } + Target::Segment { id, si, seg, t } => { + let d = ev.pos - pr.at; + let mode = if shift { + "symmetric" + } else if alt { + "keepAngles" + } else { + "free" + }; + let params = json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": d.x, "dy": d.y, "mode": mode}); + ("Reshape Segment", Action::Preview("addon.pen.reshape".into(), params)) + } + }; + self.last = ev.pos; + let mut out = vec![]; + if first && !pr.begun { + pr.begun = true; + out.push(Action::Begin(label.into())); + } + out.push(act); + self.press = Some(pr); + out + } + + fn press_up(&mut self, cx: &ToolContext) -> Vec { + self.snap.clear(); + let Some(pr) = self.press.take() else { return vec![] }; + if pr.begun { + if matches!(pr.what, Target::Close { .. }) { + self.stop(); + } + return vec![Action::Commit]; + } + match pr.what { + Target::Point { id, si, ai, .. } if pr.mods.alt => { + vec![Action::Exec("addon.pen.removePoint".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "plain": pr.mods.shift}))] + } + // A click on an end of an open path draws on from it; on any other point it ends the + // path being drawn. + Target::Point { id, si, ai, n, closed, .. } => { + if closed || (ai != 0 && ai + 1 != n) || pr.mods.shift { + self.stop(); + return vec![]; + } + (self.drawing, self.path) = (true, Some((id, si))); + if ai + 1 == n { + return vec![]; + } + let reverse = Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si})); + vec![reverse, Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, n - 1]], "mode": "set"}))] + } + Target::Segment { id, si, seg, t } if pr.mods.alt => { + vec![Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))] + } + Target::Segment { id, .. } if !cx.selection.contains(id) && self.active(cx).is_none() => { + let top = cx.doc.parent_of(id).filter(|p| cx.doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); + vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))] + } + _ => vec![], + } + } + + /// Tab: the segment into the anchor being placed, or else the last one drawn, switches + /// between straight and curved. + fn tab(&mut self, cx: &ToolContext) -> Vec { + if let Some(pr) = &mut self.press + && let Target::Place(pl) = &mut pr.what + { + pl.seg = Some(pl.seg == Some(false)); + pl.tabbed = true; + let (pl, dragged) = (*pl, pr.dragged); + return vec![self.place_preview(&pl, dragged)]; + } + let Some((id, si, sp)) = self.active(cx) else { return vec![] }; + let Some(seg) = sp.segment_count().checked_sub(1) else { return vec![] }; + vec![Action::Exec("addon.pen.segment".into(), json!({"id": id.0, "subpath": si, "segment": seg, "curved": sp.segment_is_line(seg)}))] + } + + /// The next segment as a click at `h` would add it. + fn rubber_band(&self, sp: &SubPath, h: Point) -> Option { + let mut sp = sp.clone(); + sp.anchors.push(Anchor::corner(h)); + let prev = sp.anchors.len().checked_sub(2)?; + if self.curved { + leave_handle(&mut sp, prev, true, SMOOTH_RATIO); + } else { + straighten_segment(&mut sp, prev); + } + let tail = sp.anchors.get(prev..)?; + let mut bp = BezPath::new(); + SubPath::new(tail.to_vec(), false).to_bezpath_into(&mut bp); + Some(bp) + } +} + +impl Tool for SmartPenTool { + fn id(&self) -> &'static str { + ID + } + + fn busy(&self) -> bool { + self.press.is_some() + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.sync(cx); + let active = self.active(cx); + if self.drawing && active.is_none() && ev.kind == PointerKind::Down { + self.stop(); + } + // Anchors placed snap to Smart Guides; Shift keeps them to 45° steps from the last one. + let drawn = active.map(|a| a.0); + let from = active.and_then(|(.., sp)| sp.anchors.last()).map(|a| { + let leave = Leave::segment(cx, a.p, ev.mods.shift); + if a.has_out() { leave.without_contact() } else { leave } + }); + match ev.kind { + PointerKind::Move => { + self.hover = Some(self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref())); + vec![] + } + PointerKind::Down => { + self.last = ev.pos; + let p = self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()); + self.press_down(cx, ev, p) + } + PointerKind::Drag => self.press_drag(cx, ev), + PointerKind::Up => self.press_up(cx), + PointerKind::DoubleClick => match self.hit(cx, ev.pos) { + Hit::Handle(id, si, ai, _) | Hit::Anchor(id, si, ai) => { + vec![Action::Exec("addon.pen.togglePoint".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))] + } + _ => vec![], + }, + } + } + + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + self.sync(cx); + match key { + ToolKey::Tab => self.tab(cx), + ToolKey::Enter | ToolKey::Escape => self.deactivate(cx), + _ => vec![], + } + } + + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + key == ToolKey::Tab && (self.press.is_some() || self.active(cx).is_some()) + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.stop(); + self.snap.clear(); + match self.press.take() { + Some(pr) if pr.begun => vec![Action::Commit], + _ => vec![], + } + } + + fn hover_ended(&mut self, _cx: &ToolContext) { + self.hover = None; + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut o = vec![]; + if cx.pen_rubber_band + && self.press.is_none() + && let (Some((id, _, sp)), Some(h)) = (self.active(cx), self.hover) + && let Some(path) = self.rubber_band(sp, h) + { + o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); + if let Some(last) = sp.anchors.last().map(|a| a.p) + && cx.measurement_labels + && last.distance(h) > 1e-9 + { + o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(last, h) }); + } + } + o.extend_from_slice(self.snap.guides()); + o + } + + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + match self.hit(cx, p) { + Hit::Close(..) => Cursor::PenClose, + Hit::Handle(..) => Cursor::AnchorPoint, + Hit::Anchor(..) if m.alt => Cursor::PenDelete, + Hit::Anchor(..) => Cursor::DirectAnchorSelected, + Hit::Join(..) => Cursor::PenJoin, + Hit::Segment(..) if m.alt => Cursor::PenAdd, + Hit::Segment(..) => Cursor::ReshapeSegment, + Hit::Continue(..) => Cursor::PenContinue, + Hit::Nothing if self.active(cx).is_some() => Cursor::PenDrawing, + Hit::Nothing => Cursor::SmartPen, + } + } + + fn options(&self) -> Value { + json!({ "next": if self.curved { "curved" } else { "straight" } }) + } + + fn set_option(&mut self, key: &str, v: &Value) { + if key == "next" { + match v.as_str() { + Some("curved") => self.curved = true, + Some("straight") => self.curved = false, + _ => {} + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/tools/src/addons/smart_pen/tests.rs b/crates/tools/src/addons/smart_pen/tests.rs new file mode 100644 index 000000000..df093ac31 --- /dev/null +++ b/crates/tools/src/addons/smart_pen/tests.rs @@ -0,0 +1,310 @@ +use super::*; +use crate::testutil::{cx, paint}; +use vectorcraft_doc::{Appearance, Document, Node, Selection}; +use vectorcraft_geom::{PathData, SubPath}; + +const NO: Mods = Mods { shift: false, alt: false, cmd: false, ctrl: false, space: false }; +const ALT: Mods = Mods { alt: true, ..NO }; +const SHIFT: Mods = Mods { shift: true, ..NO }; + +/// A document with the open path `pts` (selected): the document, the path and the selection. +fn doc(sp: SubPath) -> (Document, NodeId, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, PathData::single(sp), Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + (d, id, s) +} + +fn line() -> SubPath { + SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0)], false) +} + +fn ev(kind: PointerKind, x: f64, y: f64, m: Mods) -> PointerEvent { + PointerEvent::new(kind, x, y).with_mods(m) +} + +/// A context without Smart Guides, so points land where they are pressed. +fn plain<'a>(d: &'a Document, s: &'a Selection, p: &'a crate::PaintDefaults) -> ToolContext<'a> { + ToolContext { smart_guides: false, snap_to_point: false, ..cx(d, s, p) } +} + +/// A click: press and release at (x, y). +fn click(t: &mut SmartPenTool, cx: &ToolContext, x: f64, y: f64, m: Mods) -> Vec { + let mut out = t.pointer(cx, &ev(PointerKind::Down, x, y, m)); + out.extend(t.pointer(cx, &ev(PointerKind::Up, x, y, m))); + out +} + +/// A tool drawing on from the last anchor of the selected path. +fn drawing(cx: &ToolContext, end: Point) -> SmartPenTool { + let mut t = SmartPenTool::default(); + click(&mut t, cx, end.x, end.y, NO); + assert!(t.active(cx).is_some(), "the end clicked is drawn on from"); + t +} + +/// The last preview among `acts`: its command and params. +fn preview(acts: &[Action]) -> (String, Value) { + match acts.iter().rev().find(|a| matches!(a, Action::Preview(..))) { + Some(Action::Preview(c, p)) => (c.clone(), p.clone()), + _ => panic!("no preview in {acts:?}"), + } +} + +#[test] +fn a_click_away_from_paths_starts_a_new_path() { + let (d, s, p) = (Document::new(500.0, 500.0), Selection::default(), paint()); + let cx = plain(&d, &s, &p); + let mut t = SmartPenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), NO), Cursor::SmartPen); + let down = t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0, NO)); + assert_eq!(down.first(), Some(&Action::Begin("Smart Pen".into()))); + assert_eq!(preview(&down), ("path.create".into(), json!({"anchors": [{"x": 50.0, "y": 50.0}]}))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0, NO)), vec![Action::Commit]); + assert!(t.drawing && !t.busy()); +} + +#[test] +fn clicks_add_straight_or_curved_segments_as_the_option_says() { + let (d, _, s) = doc(line()); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = drawing(&cx, Point::new(200.0, 100.0)); + assert_eq!(t.cursor(&cx, Point::new(300.0, 200.0), NO), Cursor::PenDrawing); + let acts = click(&mut t, &cx, 300.0, 200.0, NO); + assert_eq!(acts.first(), Some(&Action::Begin("Smart Pen".into()))); + let (c, params) = preview(&acts); + assert_eq!((c.as_str(), params["segment"].as_str(), params["x"].as_f64()), ("addon.pen.append", Some("straight"), Some(300.0))); + assert!(params.get("in").is_none(), "a click gives the anchor no handles"); + assert_eq!(acts.last(), Some(&Action::Commit)); + t.set_option("next", &json!("curved")); + assert_eq!(t.options(), json!({"next": "curved"})); + let (_, params) = preview(&click(&mut t, &cx, 300.0, 200.0, NO)); + assert_eq!(params["segment"], json!("curved")); + t.set_option("next", &json!("wavy")); + assert_eq!(t.options(), json!({"next": "curved"}), "an unknown value changes nothing"); +} + +#[test] +fn a_drag_makes_a_smooth_point_and_alt_breaks_the_outgoing_handle() { + let (d, _, s) = doc(line()); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = drawing(&cx, Point::new(200.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0, NO)); + // Inside the drag threshold nothing happens yet. + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 301.0, 200.0, NO)).is_empty()); + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 210.0, NO)); + let (c, params) = preview(&acts); + assert_eq!(c, "addon.pen.append"); + assert_eq!((params["out"].clone(), params["in"].clone()), (json!([340.0, 210.0]), json!([260.0, 190.0]))); + assert!(params.get("segment").is_none(), "a drag shapes the segment with its handles"); + // Alt: the incoming handle stays, only the outgoing one follows. + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0, ALT)); + let (_, params) = preview(&acts); + assert_eq!((params["out"].clone(), params["in"].clone()), (json!([300.0, 250.0]), json!([260.0, 190.0]))); + // Shift: the handle in 45° steps. + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 205.0, SHIFT)); + let (_, params) = preview(&acts); + let out = params["out"].as_array().unwrap(); + assert!((out[1].as_f64().unwrap() - 200.0).abs() < 1e-9, "{out:?}"); + // Space moves the anchor with the pointer. + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 350.0, 215.0, Mods { space: true, ..NO })); + let (_, params) = preview(&acts); + assert_eq!((params["x"].as_f64(), params["y"].as_f64()), (Some(310.0), Some(210.0))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 350.0, 215.0, NO)), vec![Action::Commit]); +} + +#[test] +fn tab_switches_the_segment_being_placed_and_the_last_one_drawn() { + let (d, id, s) = doc(line()); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = drawing(&cx, Point::new(200.0, 100.0)); + assert!(t.claims_key(&cx, ToolKey::Tab)); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0, NO)); + t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 210.0, NO)); + for want in ["straight", "curved", "straight"] { + let acts = t.key(&cx, ToolKey::Tab, NO); + assert_eq!(preview(&acts).1["segment"], json!(want)); + } + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 340.0, 210.0, NO)), vec![Action::Commit]); + // Between anchors, Tab switches the last segment drawn: the line here becomes curved. + assert_eq!( + t.key(&cx, ToolKey::Tab, NO), + vec![Action::Exec("addon.pen.segment".into(), json!({"id": id.0, "subpath": 0, "segment": 0, "curved": true}))] + ); + // Not drawing, Tab is not the tool's. + let mut idle = SmartPenTool::default(); + assert!(!idle.claims_key(&cx, ToolKey::Tab) && idle.key(&cx, ToolKey::Tab, NO).is_empty()); +} + +#[test] +fn a_press_on_the_first_anchor_closes_the_path() { + let (d, id, s) = doc(SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], false)); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = drawing(&cx, Point::new(200.0, 200.0)); + assert_eq!(t.cursor(&cx, Point::new(101.0, 100.0), NO), Cursor::PenClose); + let down = t.pointer(&cx, &ev(PointerKind::Down, 101.0, 100.0, NO)); + assert_eq!( + down, + vec![Action::Begin("Close Path".into()), Action::Preview("addon.pen.close".into(), json!({"id": id.0, "subpath": 0, "segment": "straight"}))] + ); + // A drag shapes the closing curve. + let (c, params) = preview(&t.pointer(&cx, &ev(PointerKind::Drag, 130.0, 100.0, NO))); + assert_eq!((c.as_str(), params["in"].clone()), ("path.close", json!([70.0, 100.0]))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 130.0, 100.0, NO)), vec![Action::Commit]); + assert!(!t.drawing, "the path is finished"); +} + +#[test] +fn point_edits_without_switching_tools() { + let (d, id, s) = doc(SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)], false)); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = SmartPenTool::default(); + let select = |mode: &str| Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 1]], "mode": mode})); + assert_eq!(t.cursor(&cx, Point::new(200.0, 100.0), NO), Cursor::DirectAnchorSelected); + assert_eq!(t.cursor(&cx, Point::new(200.0, 100.0), ALT), Cursor::PenDelete); + // A click selects the point (an inner one: nothing is drawn on from it); Shift toggles it. + assert_eq!(click(&mut t, &cx, 200.0, 100.0, NO), vec![select("set")]); + assert!(!t.drawing); + assert_eq!(click(&mut t, &cx, 200.0, 100.0, SHIFT), vec![select("toggle")]); + // Alt-click removes it keeping the shape; Shift+Alt-click deletes it plainly. + let remove = |plain: bool| vec![Action::Exec("addon.pen.removePoint".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "plain": plain}))]; + assert_eq!(click(&mut t, &cx, 200.0, 100.0, ALT), remove(false)); + assert_eq!(click(&mut t, &cx, 200.0, 100.0, Mods { shift: true, ..ALT }), remove(true)); + // A drag moves it (Shift: 45° steps from where it was). + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0, NO)); + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 230.0, 104.0, SHIFT)); + assert_eq!(acts.first(), Some(&Action::Begin("Move Anchor Point".into()))); + let (c, params) = preview(&acts); + assert_eq!((c.as_str(), params["anchor"].as_u64(), params["y"].as_f64()), ("path.moveAnchor", Some(1), Some(100.0))); + assert!((params["x"].as_f64().unwrap() - 230.0).abs() < 0.5, "{params}"); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 230.0, 104.0, NO)), vec![Action::Commit]); + // Alt-drag pulls out the outgoing handle. + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0, ALT)); + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 120.0, ALT)); + assert_eq!(acts.first(), Some(&Action::Begin("Move Direction Handle".into()))); + assert_eq!(preview(&acts), ("path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 220.0, "y": 120.0}))); + t.pointer(&cx, &ev(PointerKind::Up, 220.0, 120.0, ALT)); + // A double-click toggles smooth and corner. + assert_eq!( + t.pointer(&cx, &ev(PointerKind::DoubleClick, 200.0, 100.0, NO)), + vec![Action::Exec("addon.pen.togglePoint".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))] + ); + // A click on the first anchor of the open path draws on from it, the path reversed. + let acts = click(&mut t, &cx, 100.0, 100.0, NO); + assert_eq!(acts.get(1), Some(&Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": 0})))); + assert_eq!(acts.last(), Some(&Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})))); + assert!(t.drawing); +} + +#[test] +fn handles_drag_and_keep_their_angle_with_alt() { + let sp = SubPath::new( + vec![ + Anchor::corner(Point::new(100.0, 100.0)), + Anchor::smooth(Point::new(200.0, 100.0), Point::new(240.0, 100.0)), + Anchor::corner(Point::new(300.0, 200.0)), + ], + false, + ); + let (d, id, mut s) = doc(sp); + s.anchors.insert(id, [(0, 1)].into_iter().collect()); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = SmartPenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(240.0, 100.0), NO), Cursor::AnchorPoint); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 240.0, 100.0, NO)).is_empty()); + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 130.0, ALT)); + assert_eq!(acts.first(), Some(&Action::Begin("Move Direction Handle".into()))); + // Alt keeps the handle's direction: only its length follows the pointer. + assert_eq!(preview(&acts), ("path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 250.0, "y": 100.0}))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 250.0, 130.0, NO)), vec![Action::Commit]); + // A click without a drag changes nothing; a double-click toggles its point. + assert!(click(&mut t, &cx, 160.0, 100.0, NO).is_empty()); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 100.0, NO)), + vec![Action::Exec("addon.pen.togglePoint".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))] + ); +} + +#[test] +fn segments_reshape_by_dragging_and_alt_click_adds_a_point() { + let (d, id, s) = doc(line()); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = SmartPenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(150.0, 100.0), NO), Cursor::ReshapeSegment); + assert_eq!(t.cursor(&cx, Point::new(150.0, 100.0), ALT), Cursor::PenAdd); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0, NO)).is_empty()); + for (m, mode) in [(NO, "free"), (ALT, "keepAngles"), (SHIFT, "symmetric")] { + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 130.0, m)); + let (c, params) = preview(&acts); + assert_eq!( + (c.as_str(), params["mode"].as_str(), params["dy"].as_f64(), params["segment"].as_u64()), + ("addon.pen.reshape", Some(mode), Some(30.0), Some(0)) + ); + assert!((params["t"].as_f64().unwrap() - 0.5).abs() < 1e-6); + } + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 130.0, NO)), vec![Action::Commit]); + let acts = click(&mut t, &cx, 125.0, 100.0, ALT); + let [Action::Exec(c, params)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((c.as_str(), params["id"].as_u64(), params["segment"].as_u64()), ("path.insertAnchor", Some(id.0), Some(0))); + assert!(click(&mut t, &cx, 150.0, 100.0, NO).is_empty(), "a click on a selected path's segment does nothing"); + // A click on a path that isn't selected selects it. + let none = Selection::default(); + let cx = plain(&d, &none, &p); + assert_eq!(click(&mut t, &cx, 150.0, 100.0, NO), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); +} + +#[test] +fn ends_of_other_open_paths_continue_or_join() { + let (mut d, id, s) = doc(line()); + let l = d.layers[0].id; + let other = d.alloc_id(); + let sp = SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + d.insert(Some(l), 1, Node::path(other, PathData::single(sp), Appearance::default_art())).unwrap(); + let p = paint(); + // Not drawing, nothing selected: a click on an end draws on from it. + let none = Selection::default(); + let cx = plain(&d, &none, &p); + let mut t = SmartPenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(200.0, 300.0), NO), Cursor::PenContinue); + assert_eq!(click(&mut t, &cx, 200.0, 300.0, NO), vec![Action::Exec("select.set".into(), json!({"ids": [other.0]}))]); + assert!(t.drawing); + // Drawing the selected path, a press on the other's end joins the two. + let cx = plain(&d, &s, &p); + let mut t = drawing(&cx, Point::new(200.0, 100.0)); + assert_eq!(t.cursor(&cx, Point::new(100.0, 300.0), NO), Cursor::PenJoin); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Down, 100.0, 300.0, NO)), + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"], "subpaths": [0, 0]}))] + ); + assert!(!t.drawing); +} + +#[test] +fn the_rubber_band_shows_the_next_segment_and_ending_the_path() { + let (d, _, s) = doc(line()); + let p = paint(); + let cx = plain(&d, &s, &p); + let mut t = drawing(&cx, Point::new(200.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Move, 300.0, 200.0, NO)); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. }))); + // Curved, the band leaves the last anchor along the straight segment before it. + t.set_option("next", &json!("curved")); + let band = t.overlays(&cx).into_iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(path) } else { None }).unwrap(); + assert!( + band.elements().iter().any(|e| matches!(e, vectorcraft_geom::PathEl::CurveTo(p1, ..) if (p1.y - 100.0).abs() < 1e-9 && p1.x > 200.0)), + "{band:?}" + ); + assert!(t.key(&cx, ToolKey::Escape, NO).is_empty()); + assert!(t.active(&cx).is_none()); + assert!(t.overlays(&cx).is_empty()); +} diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index 4ec7c0753..ac18d6beb 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -164,7 +164,7 @@ pub fn is_selection_tool(id: &str) -> bool { /// The tools that show a selected path's anchors and handles as Direct Selection does, and drag /// them: a Cmd drag with one of them is a Direct Selection drag until a selection tool is chosen. pub fn edits_anchors(id: &str) -> bool { - matches!(id, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature") + matches!(id, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature" | crate::addons::smart_pen::ID) } #[cfg(test)] diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 1b5097e0b..f7974e82d 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -177,6 +177,9 @@ pub enum Cursor { ExtendPath, /// The Measure & Inspect tool (Path Precision Tools add-on). MeasureInspect, + /// The Smart Pen Tool (Smart Pen add-on) away from paths, where a click starts a new path (over + /// a path or while drawing it shows the Pen's and Direct Selection's cursors). + SmartPen, } impl Cursor { @@ -270,7 +273,8 @@ impl Cursor { | Cursor::PuppetPin | Cursor::AnchorEraser | Cursor::ExtendPath - | Cursor::MeasureInspect => Cursor::Crosshair, + | Cursor::MeasureInspect + | Cursor::SmartPen => Cursor::Crosshair, c => c, } } @@ -310,6 +314,7 @@ impl Cursor { | Cursor::MeasureInspect => Some(Cursor::Crosshair), Cursor::TypeVertical => Some(Cursor::Text), Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd), + Cursor::SmartPen => Some(Cursor::Pen), Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed), _ => None, } diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 30fd82c75..5304377b3 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -666,6 +666,12 @@ group = "Drawing and editing tools" shown_when = ["The Measure tool: drag to measure distance and angle."] hotspot = "Exact, because precision matters here (an open centre suits it)." +[[cursor]] +id = "SmartPen" +group = "Drawing and editing tools" +shown_when = ["The Smart Pen Tool (Smart Pen add-on) away from paths, where a click starts a new path. Over a path, or while a path is drawn, the tool shows the Pen's and Direct Selection's cursors for what a click there does."] +hotspot = "The nib's tip." + [[cursor]] id = "PrintTiling" group = "Drawing and editing tools" diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 9acefa895..a2e9d3881 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -80,7 +80,7 @@ struct Place { /// The open path the pen is extending: the single selected open path, or the single open path of a /// single selected compound path. -fn active_path(cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { +pub(crate) fn active_path(cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { if cx.selection.objects.len() != 1 { return None; } @@ -135,7 +135,7 @@ fn endpoint_on(cx: &ToolContext, id: NodeId, p: Point, tol: f64, except: Option< /// An end of an open path (any editable path, including inside groups and compound paths, not `except`) /// under `p`, within `tol`: the path the Pen continues or joins to, and whether it's the path's first anchor. -fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<(NodeId, usize, bool)> { +pub(crate) fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<(NodeId, usize, bool)> { let mut best: Option<((NodeId, usize, bool), f64)> = None; // 1. Shown paths (anchors visible because object, group, or compound path is selected). for id in shown_paths(cx) { @@ -352,22 +352,7 @@ impl Tool for PenTool { // Continue any other open path from the end clicked, selecting it. if let Some((id, si, at_first)) = open_end_at(cx, at, tol, None) { (self.drawing, self.path, self.subpath) = (true, Some(id), Some(si)); - let top = cx - .doc - .parent_of(id) - .filter(|p| matches!(cx.doc.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))) - .unwrap_or(id); - let mut out = vec![]; - if cx.selection.objects.as_slice() != [top] { - out.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); - } - if at_first { - // Name the path: the `select.set` above may have just put its compound - // parent in the selection, and a reverse addressed through the - // selection would flip this subpath of every member of it. - out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))); - } - return out; + return continue_from(cx, id, si, at_first); } } if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) { @@ -594,6 +579,24 @@ impl PenTool { } } +/// Go on drawing subpath `si` of open path `id` from its last anchor, or from its first +/// (`at_first`, the subpath reversed so that it is the last): the path (its compound path) is +/// selected. +pub(crate) fn continue_from(cx: &ToolContext, id: NodeId, si: usize, at_first: bool) -> Vec { + let top = cx.doc.parent_of(id).filter(|p| matches!(cx.doc.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id); + let mut out = vec![]; + if cx.selection.objects.as_slice() != [top] { + out.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); + } + if at_first { + // Name the path: the `select.set` above may have just put its compound parent in the + // selection, and a reverse addressed through the selection would flip this subpath of + // every member of it. + out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))); + } + out +} + /// Auto Add/Delete: what a click at `p` does to a selected path. On one of its anchors it deletes /// it; else, where one of its segments passes within `tol`, it adds an anchor. None when the /// preference turns it off, Shift is held, or no selected path is there. diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index f27f56330..2d13db548 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -69,6 +69,7 @@ const ROWS: &[Row] = &[ // The add-ons' tools (`crate::addons`). (&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]), (&[crate::addons::extend_path::ID], None, &["mode"]), + (&[crate::addons::smart_pen::ID], None, &["next"]), // Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes). ( &["flare"], diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index 850ab5976..36106b72b 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -4,6 +4,7 @@ pub mod cleanup; pub mod color; pub mod pathtools; +pub mod pen; use crate::menus::Item; @@ -13,6 +14,7 @@ pub fn plugin_menu() -> Vec { Item::Sub("Artwork Cleanup", cleanup::menu()), Item::Sub("Color Effects", color::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), + Item::Sub("Smart Pen", pen::menu()), Item::Sep, ] } diff --git a/crates/ui-egui/src/addons/pen.rs b/crates/ui-egui/src/addons/pen.rs new file mode 100644 index 000000000..9e1a2e3d3 --- /dev/null +++ b/crates/ui-egui/src/addons/pen.rs @@ -0,0 +1,75 @@ +//! Smart Pen in the interface: its submenu (Object › Plug-ins › Smart Pen), which selects the +//! Smart Pen Tool and opens the Smart Pen panel; and the panel: whether a click with the tool adds +//! a straight or a curved segment, and the point and path edits it shares with the tool. Everything +//! acts through `addon.pen.*` and the `addon.pathtools.*` anchor edits. + +use egui::Ui; +use serde_json::{Value, json}; +use vectorcraft_tools::addons::smart_pen; + +use crate::VectorcraftApp; +use crate::menus::Item; +use crate::panels::empty_state; +use crate::widgets::{self, dim_label}; + +/// The Smart Pen panel's id (`window.panel {panel: "smartPen"}`). +pub const ID: &str = "smartPen"; + +/// Object › Plug-ins › Smart Pen. +pub fn menu() -> Vec { + vec![ + Item::Cmd("Smart Pen Tool", "tool.select", json!({ "tool": smart_pen::ID })), + Item::Cmd("Smart Pen Panel", "window.panel", json!({ "panel": ID })), + ] +} + +/// The panel's point and path edits: (label, command, params). +fn edits() -> [(&'static str, &'static str, Value); 5] { + [ + (tl!("Remove Points, Keep Shape"), "addon.pathtools.removeKeepShape", json!({})), + (tl!("Make Smooth"), "addon.pathtools.smooth", json!({})), + // Nothing moves: the handles stay where they are. + (tl!("Make Corner"), "addon.pathtools.corner", json!({ "retract": false })), + (tl!("Retract Handles"), "addon.pathtools.corner", json!({ "retract": true })), + (tl!("Close or Join"), "addon.pen.closeJoin", json!({})), + ] +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { + empty_state(ui, "spline-pointer", tl!("No document"), tl!("Open a document to draw with the Smart Pen.")); + return; + }; + let selected = !st.selection.is_empty(); + let curved = app.session.tool_options_of(smart_pen::ID)["next"] == json!("curved"); + let mut action: Option<(&str, Value)> = None; + // The label above its buttons, so a long translation stays within the dock's width. + widgets::subheader(ui, tl!("Next Segment")); + ui.horizontal(|ui| { + for (icon, tip, on) in [("dc-line", tl!("Straight"), false), ("spline", tl!("Curved"), true)] { + if widgets::icon_button(ui, icon, tip, curved == on, 24.0).clicked() { + action = Some(("addon.pen.nextSegment", json!({ "curved": on }))); + } + } + }); + widgets::divider(ui); + if !selected { + dim_label(ui, tl!("Direct-select anchor points to edit them.")); + } + let width = ui.available_width(); + ui.add_enabled_ui(selected, |ui| { + for (label, id, p) in edits() { + if widgets::flat_button(ui, label, width).clicked() { + action = Some((id, p)); + } + } + }); + if let Some((id, p)) = action + && let Err(e) = app.run(id, p) + { + app.status(e); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/pen/tests.rs b/crates/ui-egui/src/addons/pen/tests.rs new file mode 100644 index 000000000..db0983b79 --- /dev/null +++ b/crates/ui-egui/src/addons/pen/tests.rs @@ -0,0 +1,57 @@ +use serde_json::json; +use vectorcraft_engine::Session; + +use super::{ID, show}; +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app +} + +#[test] +fn the_plugins_submenu_selects_the_tool_and_opens_the_panel() { + let mut app = app(); + let entries = menus::menu_entries(&app); + let under = |label: &str| entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Smart Pen"] && e.label == label); + let tool = under("Smart Pen Tool").expect("listed"); + assert_eq!((tool.command.as_deref(), &tool.params), (Some("tool.select"), &json!({ "tool": "smartPen" }))); + menus::invoke(&mut app, "tool.select", tool.params.clone()); + assert_eq!(app.session.tool_id(), "smartPen"); + let panel = under("Smart Pen Panel").expect("listed"); + assert!(!crate::panel_docking::showing(&app.ui, ID)); + menus::invoke(&mut app, "window.panel", panel.params.clone()); + assert!(crate::panel_docking::showing(&app.ui, ID), "the panel opens"); + // The other add-ons' submenus are still there, and the tool has no shortcut. + assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"])); + assert!(vectorcraft_tools::tool_info("smartPen").is_some_and(|t| t.shortcut.is_none())); +} + +#[test] +fn the_panel_sets_the_next_segment_and_its_buttons_edit() { + let mut app = app(); + let text = painted_text(&mut app, show); + for label in [ + "Next Segment", + "Direct-select anchor points to edit them.", + "Remove Points, Keep Shape", + "Make Smooth", + "Make Corner", + "Retract Handles", + "Close or Join", + ] { + assert!(text.contains(label), "{label}: {text}"); + } + assert_eq!(app.session.tool_options_of("smartPen")["next"], json!("straight")); + app.run("addon.pen.nextSegment", json!({"curved": true})).unwrap(); + assert_eq!(app.session.tool_options_of("smartPen")["next"], json!("curved")); + // Close or Join closes an open path selected whole, as one undo step. + let r = app.run("path.create", json!({"anchors": [{"x": 10, "y": 10}, {"x": 110, "y": 10}, {"x": 110, "y": 110}], "closed": false})).unwrap(); + let id = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap()); + let steps = app.session.doc().unwrap().history.undo.len(); + app.run("addon.pen.closeJoin", json!({})).unwrap(); + assert!(app.session.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].closed); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1); +} diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 6987dcb6d..84c7bea8f 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -150,6 +150,7 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { | Cursor::PuppetPin | Cursor::AnchorEraser | Cursor::ExtendPath - | Cursor::MeasureInspect => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), + | Cursor::MeasureInspect + | Cursor::SmartPen => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), } } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 5461767bd..df9008210 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -440,7 +440,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { /// Every cursor with a glyph: its own, drawn here, or the one it borrows. #[cfg(test)] -const GLYPHS: [Cursor; 84] = [ +const GLYPHS: [Cursor; 85] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -525,6 +525,7 @@ const GLYPHS: [Cursor; 84] = [ Cursor::AnchorEraser, Cursor::ExtendPath, Cursor::MeasureInspect, + Cursor::SmartPen, ]; /// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index 4184e4e55..d4bc5e25a 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -379,16 +379,20 @@ fn drop_targets_follow_the_buttons_edges() { fn addon_tools_are_in_the_drawer_not_the_toolbars() { let mut app = app(); let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); - assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect"]); + assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect", "smartPen"]); for advanced in [false, true] { if advanced { app.run("window.toolbarAdvanced", json!({})).unwrap(); } assert!(addon.iter().all(|id| slot_of(&app, id).is_none()), "advanced: {advanced}"); } - let (heading, tools) = sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == "extendPath")).expect("listed"); - assert_eq!(*heading, "Path Precision Tools"); - assert_eq!(tools.iter().map(|t| t.id).collect::>(), addon); - app.run("toolbar.addTool", json!({"tool": "extendPath"})).unwrap(); - assert!(slot_of(&app, "extendPath").is_some()); + let listed = |id: &str| { + sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == id)).map(|(h, tools)| (*h, tools.iter().map(|t| t.id).collect::>())) + }; + assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..3].to_vec()))); + assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"]))); + for tool in ["extendPath", "smartPen"] { + app.run("toolbar.addTool", json!({"tool": tool})).unwrap(); + assert!(slot_of(&app, tool).is_some(), "{tool}"); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 82ab44160..09d7063dc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5687,3 +5687,25 @@ anchor Points: Точки: @msg select open paths to close изберете отворени пътища за затваряне @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) пътищата са твърде много или твърде заплетени, за да се намерят пресичанията им (най-много 10000 сегмента) @msg the paths have too many anchors to edit at once пътищата имат твърде много опорни точки, за да се редактират наведнъж +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Интелигентно перо + Smart Pen Tool Инструмент „Интелигентно перо“ + Smart Pen Panel Панел „Интелигентно перо“ + Next Segment Следващ сегмент + Curved Крив + Retract Handles Прибиране на дръжките + Close or Join Затваряне или свързване + Open a document to draw with the Smart Pen. Отворете документ, за да рисувате с интелигентното перо. + Straight or Curved Segment Прав или крив сегмент +@msg the path is locked or hidden пътят е заключен или скрит +@msg `id` is not a path `id` не е път +@msg the path has no open subpath пътят няма отворен подпът +@msg the subpath is closed подпътят е затворен +@msg the subpath is closed or too short to close подпътят е затворен или е твърде къс, за да се затвори +@msg the path is too short to remove a point from пътят е твърде къс, за да се премахне точка от него +@msg the segment can't be bent that far сегментът не може да се огъне толкова +@msg give `{a}` and `{b}` задайте `{a}` и `{b}` +@msg `t` must be from 0 to 1 `t` трябва да е от 0 до 1 +@msg `segment` must be "straight" or "curved" `segment` трябва да е "straight" или "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` трябва да е "free", "keepAngles" или "symmetric" +@msg `{key}` must be [x, y] `{key}` трябва да е [x, y] diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f25027869..c6cfb2d9f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5788,3 +5788,25 @@ anchor Points: Punts: @msg select open paths to close seleccioneu traçats oberts per tancar-los @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) massa traçats, o massa enredats, per trobar-ne els encreuaments (10000 segments com a màxim) @msg the paths have too many anchors to edit at once els traçats tenen massa punts d’ancoratge per editar-los alhora +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Ploma intel·ligent + Smart Pen Tool Eina de ploma intel·ligent + Smart Pen Panel Tauler de ploma intel·ligent + Next Segment Segment següent + Curved Corbat + Retract Handles Retrau les nanses + Close or Join Tanca o uneix + Open a document to draw with the Smart Pen. Obriu un document per dibuixar amb la ploma intel·ligent. + Straight or Curved Segment Segment recte o corbat +@msg the path is locked or hidden el traçat està bloquejat o ocult +@msg `id` is not a path `id` no és un traçat +@msg the path has no open subpath el traçat no té cap subtraçat obert +@msg the subpath is closed el subtraçat és tancat +@msg the subpath is closed or too short to close el subtraçat és tancat o massa curt per tancar-lo +@msg the path is too short to remove a point from el traçat és massa curt per treure’n un punt +@msg the segment can't be bent that far el segment no es pot corbar tant +@msg give `{a}` and `{b}` indiqueu `{a}` i `{b}` +@msg `t` must be from 0 to 1 `t` ha de ser de 0 a 1 +@msg `segment` must be "straight" or "curved" `segment` ha de ser "straight" o "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` ha de ser "free", "keepAngles" o "symmetric" +@msg `{key}` must be [x, y] `{key}` ha de ser [x, y] diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 894d77f6e..6282e305c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5797,3 +5797,25 @@ anchor Points: Body: @msg select open paths to close vyberte otevřené cesty k uzavření @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) příliš mnoho nebo příliš spletených cest pro nalezení jejich křížení (nejvýše 10000 segmentů) @msg the paths have too many anchors to edit at once cesty mají příliš mnoho kotevních bodů na úpravu najednou +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Chytré pero + Smart Pen Tool Nástroj Chytré pero + Smart Pen Panel Panel Chytré pero + Next Segment Další segment + Curved Zakřivený + Retract Handles Zasunout úchyty + Close or Join Uzavřít nebo spojit + Open a document to draw with the Smart Pen. Otevřete dokument a kreslete chytrým perem. + Straight or Curved Segment Přímý nebo zakřivený segment +@msg the path is locked or hidden cesta je zamčená nebo skrytá +@msg `id` is not a path `id` není cesta +@msg the path has no open subpath cesta nemá žádnou otevřenou dílčí cestu +@msg the subpath is closed dílčí cesta je uzavřená +@msg the subpath is closed or too short to close dílčí cesta je uzavřená nebo příliš krátká k uzavření +@msg the path is too short to remove a point from cesta je příliš krátká na odebrání bodu +@msg the segment can't be bent that far segment nelze ohnout tak daleko +@msg give `{a}` and `{b}` zadejte `{a}` a `{b}` +@msg `t` must be from 0 to 1 `t` musí být od 0 do 1 +@msg `segment` must be "straight" or "curved" `segment` musí být "straight" nebo "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` musí být "free", "keepAngles" nebo "symmetric" +@msg `{key}` must be [x, y] `{key}` musí být [x, y] diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bac02b2ad..b969e9572 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5702,3 +5702,25 @@ anchor Points: Punkter: @msg select open paths to close vælg åbne stier at lukke @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) for mange eller for sammenfiltrede stier til at finde deres krydsninger (højst 10000 segmenter) @msg the paths have too many anchors to edit at once stierne har for mange ankerpunkter til at blive redigeret på én gang +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Smart pen + Smart Pen Tool Smart pen-værktøj + Smart Pen Panel Smart pen-panel + Next Segment Næste segment + Curved Buet + Retract Handles Træk håndtag ind + Close or Join Luk eller forbind + Open a document to draw with the Smart Pen. Åbn et dokument for at tegne med smart pen. + Straight or Curved Segment Lige eller buet segment +@msg the path is locked or hidden stien er låst eller skjult +@msg `id` is not a path `id` er ikke en sti +@msg the path has no open subpath stien har ingen åben delsti +@msg the subpath is closed delstien er lukket +@msg the subpath is closed or too short to close delstien er lukket eller for kort til at lukke +@msg the path is too short to remove a point from stien er for kort til at fjerne et punkt +@msg the segment can't be bent that far segmentet kan ikke bøjes så langt +@msg give `{a}` and `{b}` angiv `{a}` og `{b}` +@msg `t` must be from 0 to 1 `t` skal være fra 0 til 1 +@msg `segment` must be "straight" or "curved" `segment` skal være "straight" eller "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` skal være "free", "keepAngles" eller "symmetric" +@msg `{key}` must be [x, y] `{key}` skal være [x, y] diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6adaa3f3a..ac48208a1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5380,3 +5380,25 @@ anchor Points: Punkte: @msg select open paths to close offene Pfade zum Schließen auswählen @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) zu viele oder zu verschlungene Pfade, um ihre Kreuzungen zu finden (höchstens 10000 Segmente) @msg the paths have too many anchors to edit at once die Pfade haben zu viele Ankerpunkte, um sie auf einmal zu bearbeiten +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Intelligenter Zeichenstift + Smart Pen Tool Intelligenter-Zeichenstift-Werkzeug + Smart Pen Panel Bedienfeld „Intelligenter Zeichenstift“ + Next Segment Nächstes Segment + Curved Gebogen + Retract Handles Griffe einziehen + Close or Join Schließen oder zusammenfügen + Open a document to draw with the Smart Pen. Öffnen Sie ein Dokument, um mit dem intelligenten Zeichenstift zu zeichnen. + Straight or Curved Segment Gerades oder gebogenes Segment +@msg the path is locked or hidden der Pfad ist gesperrt oder ausgeblendet +@msg `id` is not a path `id` ist kein Pfad +@msg the path has no open subpath der Pfad hat keinen offenen Teilpfad +@msg the subpath is closed der Teilpfad ist geschlossen +@msg the subpath is closed or too short to close der Teilpfad ist geschlossen oder zu kurz zum Schließen +@msg the path is too short to remove a point from der Pfad ist zu kurz, um einen Punkt zu entfernen +@msg the segment can't be bent that far das Segment lässt sich nicht so weit biegen +@msg give `{a}` and `{b}` geben Sie `{a}` und `{b}` an +@msg `t` must be from 0 to 1 `t` muss zwischen 0 und 1 liegen +@msg `segment` must be "straight" or "curved" `segment` muss "straight" oder "curved" sein +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` muss "free", "keepAngles" oder "symmetric" sein +@msg `{key}` must be [x, y] `{key}` muss [x, y] sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d30ffaa19..0a18dae9d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5749,3 +5749,25 @@ anchor Points: Σημεία: @msg select open paths to close επιλέξτε ανοιχτές διαδρομές για κλείσιμο @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) οι διαδρομές είναι πάρα πολλές ή πολύ μπλεγμένες για να βρεθούν οι διασταυρώσεις τους (έως 10000 τμήματα) @msg the paths have too many anchors to edit at once οι διαδρομές έχουν πάρα πολλά σημεία αγκύρωσης για επεξεργασία με μία φορά +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Έξυπνη πένα + Smart Pen Tool Έξυπνη πένα (Smart Pen Tool) + Smart Pen Panel Πίνακας έξυπνης πένας + Next Segment Επόμενο τμήμα + Curved Καμπύλο + Retract Handles Σύμπτυξη λαβών + Close or Join Κλείσιμο ή ένωση + Open a document to draw with the Smart Pen. Ανοίξτε ένα έγγραφο για να σχεδιάσετε με την έξυπνη πένα. + Straight or Curved Segment Ευθύ ή καμπύλο τμήμα +@msg the path is locked or hidden η διαδρομή είναι κλειδωμένη ή κρυφή +@msg `id` is not a path Το `id` δεν είναι διαδρομή +@msg the path has no open subpath η διαδρομή δεν έχει ανοιχτή υποδιαδρομή +@msg the subpath is closed η υποδιαδρομή είναι κλειστή +@msg the subpath is closed or too short to close η υποδιαδρομή είναι κλειστή ή πολύ μικρή για να κλείσει +@msg the path is too short to remove a point from η διαδρομή είναι πολύ μικρή για να αφαιρεθεί σημείο +@msg the segment can't be bent that far το τμήμα δεν μπορεί να καμφθεί τόσο +@msg give `{a}` and `{b}` δώστε `{a}` και `{b}` +@msg `t` must be from 0 to 1 Το `t` πρέπει να είναι από 0 έως 1 +@msg `segment` must be "straight" or "curved" Το `segment` πρέπει να είναι "straight" ή "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" Το `mode` πρέπει να είναι "free", "keepAngles" ή "symmetric" +@msg `{key}` must be [x, y] Το `{key}` πρέπει να είναι [x, y] diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0fbc7da18..1d4c1f10b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5740,3 +5740,25 @@ anchor Points: Puntos: @msg select open paths to close seleccione trazados abiertos que cerrar @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) hay demasiados trazados, o están demasiado enredados, para hallar sus cruces (10000 segmentos como máximo) @msg the paths have too many anchors to edit at once los trazados tienen demasiados puntos de ancla para editarlos a la vez +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Pluma inteligente + Smart Pen Tool Herramienta Pluma inteligente + Smart Pen Panel Panel Pluma inteligente + Next Segment Segmento siguiente + Curved Curvo + Retract Handles Retraer manejadores + Close or Join Cerrar o unir + Open a document to draw with the Smart Pen. Abra un documento para dibujar con la pluma inteligente. + Straight or Curved Segment Segmento recto o curvo +@msg the path is locked or hidden el trazado está bloqueado u oculto +@msg `id` is not a path `id` no es un trazado +@msg the path has no open subpath el trazado no tiene ningún subtrazado abierto +@msg the subpath is closed el subtrazado está cerrado +@msg the subpath is closed or too short to close el subtrazado está cerrado o es demasiado corto para cerrarlo +@msg the path is too short to remove a point from el trazado es demasiado corto para quitarle un punto +@msg the segment can't be bent that far el segmento no se puede curvar tanto +@msg give `{a}` and `{b}` indique `{a}` y `{b}` +@msg `t` must be from 0 to 1 `t` debe estar entre 0 y 1 +@msg `segment` must be "straight" or "curved" `segment` debe ser "straight" o "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` debe ser "free", "keepAngles" o "symmetric" +@msg `{key}` must be [x, y] `{key}` debe ser [x, y] diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 2bd47a6ef..4bf2f6cea 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5828,3 +5828,25 @@ anchor Points: Pisteet: @msg select open paths to close valitse suljettavat avoimet polut @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) liian monta tai liian sotkuista polkua niiden risteyskohtien löytämiseen (enintään 10000 osaa) @msg the paths have too many anchors to edit at once poluissa on liian monta ankkuripistettä muokattavaksi kerralla +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Älykynä + Smart Pen Tool Älykynätyökalu + Smart Pen Panel Älykynäpaneeli + Next Segment Seuraava segmentti + Curved Kaareva + Retract Handles Vedä kahvat sisään + Close or Join Sulje tai liitä + Open a document to draw with the Smart Pen. Avaa asiakirja piirtääksesi älykynällä. + Straight or Curved Segment Suora tai kaareva segmentti +@msg the path is locked or hidden polku on lukittu tai piilotettu +@msg `id` is not a path `id` ei ole polku +@msg the path has no open subpath polulla ei ole avointa alipolkua +@msg the subpath is closed alipolku on suljettu +@msg the subpath is closed or too short to close alipolku on suljettu tai liian lyhyt suljettavaksi +@msg the path is too short to remove a point from polku on liian lyhyt pisteen poistamiseen +@msg the segment can't be bent that far segmenttiä ei voi taivuttaa niin paljon +@msg give `{a}` and `{b}` anna `{a}` ja `{b}` +@msg `t` must be from 0 to 1 `t` on oltava 0–1 +@msg `segment` must be "straight" or "curved" `segment` on oltava "straight" tai "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` on oltava "free", "keepAngles" tai "symmetric" +@msg `{key}` must be [x, y] `{key}` on oltava [x, y] diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ae3f1b86a..eb00097f6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5796,3 +5796,25 @@ anchor Points: Points : @msg select open paths to close sélectionnez des tracés ouverts à fermer @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) trop de tracés, ou trop enchevêtrés, pour trouver leurs croisements (10000 segments au plus) @msg the paths have too many anchors to edit at once les tracés ont trop de points d’ancrage pour être modifiés en une fois +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Plume intelligente + Smart Pen Tool Outil Plume intelligente + Smart Pen Panel Panneau Plume intelligente + Next Segment Segment suivant + Curved Courbe + Retract Handles Rétracter les poignées + Close or Join Fermer ou joindre + Open a document to draw with the Smart Pen. Ouvrez un document pour dessiner avec la plume intelligente. + Straight or Curved Segment Segment droit ou courbe +@msg the path is locked or hidden le tracé est verrouillé ou masqué +@msg `id` is not a path `id` n’est pas un tracé +@msg the path has no open subpath le tracé n’a aucun sous-tracé ouvert +@msg the subpath is closed le sous-tracé est fermé +@msg the subpath is closed or too short to close le sous-tracé est fermé ou trop court pour être fermé +@msg the path is too short to remove a point from le tracé est trop court pour en retirer un point +@msg the segment can't be bent that far le segment ne peut pas être courbé à ce point +@msg give `{a}` and `{b}` indiquez `{a}` et `{b}` +@msg `t` must be from 0 to 1 `t` doit être compris entre 0 et 1 +@msg `segment` must be "straight" or "curved" `segment` doit être "straight" ou "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` doit être "free", "keepAngles" ou "symmetric" +@msg `{key}` must be [x, y] `{key}` doit être [x, y] diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 55614e708..f176caa3d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5784,3 +5784,25 @@ anchor Points: Pontok: @msg select open paths to close jelöljön ki lezárandó nyitott útvonalakat @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) túl sok vagy túl összegabalyodott útvonal a kereszteződések megtalálásához (legfeljebb 10000 szakasz) @msg the paths have too many anchors to edit at once az útvonalaknak túl sok horgonypontjuk van az egyszerre szerkesztéshez +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Okostoll + Smart Pen Tool Okostoll eszköz + Smart Pen Panel Okostoll panel + Next Segment Következő szakasz + Curved Ívelt + Retract Handles Fogantyúk behúzása + Close or Join Lezárás vagy összekapcsolás + Open a document to draw with the Smart Pen. Nyisson meg egy dokumentumot az okostollal való rajzoláshoz. + Straight or Curved Segment Egyenes vagy ívelt szakasz +@msg the path is locked or hidden az útvonal zárolt vagy rejtett +@msg `id` is not a path `id`: nem útvonal +@msg the path has no open subpath az útvonalnak nincs nyitott részútvonala +@msg the subpath is closed a részútvonal zárt +@msg the subpath is closed or too short to close a részútvonal zárt vagy túl rövid a lezáráshoz +@msg the path is too short to remove a point from az útvonal túl rövid egy pont eltávolításához +@msg the segment can't be bent that far a szakasz nem hajlítható ennyire +@msg give `{a}` and `{b}` adja meg: `{a}` és `{b}` +@msg `t` must be from 0 to 1 `t`: 0 és 1 között kell +@msg `segment` must be "straight" or "curved" `segment`: "straight" vagy "curved" kell +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode`: "free", "keepAngles" vagy "symmetric" kell +@msg `{key}` must be [x, y] `{key}`: [x, y] kell diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 99db664b5..f44849f6a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5702,3 +5702,25 @@ anchor Points: Titik: @msg select open paths to close pilih jalur terbuka untuk ditutup @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) jalur terlalu banyak atau terlalu kusut untuk menemukan persilangannya (paling banyak 10000 segmen) @msg the paths have too many anchors to edit at once jalur memiliki terlalu banyak titik jangkar untuk disunting sekaligus +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Pena pintar + Smart Pen Tool Alat pena pintar + Smart Pen Panel Panel pena pintar + Next Segment Segmen berikutnya + Curved Lengkung + Retract Handles Tarik masuk pegangan + Close or Join Tutup atau sambungkan + Open a document to draw with the Smart Pen. Buka dokumen untuk menggambar dengan pena pintar. + Straight or Curved Segment Segmen lurus atau lengkung +@msg the path is locked or hidden jalur terkunci atau tersembunyi +@msg `id` is not a path `id` bukan jalur +@msg the path has no open subpath jalur tidak punya subjalur terbuka +@msg the subpath is closed subjalur sudah tertutup +@msg the subpath is closed or too short to close subjalur sudah tertutup atau terlalu pendek untuk ditutup +@msg the path is too short to remove a point from jalur terlalu pendek untuk dihapus titiknya +@msg the segment can't be bent that far segmen tidak dapat dibengkokkan sejauh itu +@msg give `{a}` and `{b}` berikan `{a}` dan `{b}` +@msg `t` must be from 0 to 1 `t` harus antara 0 dan 1 +@msg `segment` must be "straight" or "curved" `segment` harus "straight" atau "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` harus "free", "keepAngles" atau "symmetric" +@msg `{key}` must be [x, y] `{key}` harus [x, y] diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eb91330e1..663b1bdbc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5414,3 +5414,25 @@ anchor Points: Punti: @msg select open paths to close seleziona tracciati aperti da chiudere @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) tracciati troppi o troppo intricati per trovarne gli incroci (al massimo 10000 segmenti) @msg the paths have too many anchors to edit at once i tracciati hanno troppi punti di ancoraggio da modificare in una volta +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Penna intelligente + Smart Pen Tool Strumento Penna intelligente + Smart Pen Panel Pannello Penna intelligente + Next Segment Segmento successivo + Curved Curvo + Retract Handles Ritira maniglie + Close or Join Chiudi o unisci + Open a document to draw with the Smart Pen. Apri un documento per disegnare con la penna intelligente. + Straight or Curved Segment Segmento diritto o curvo +@msg the path is locked or hidden il tracciato è bloccato o nascosto +@msg `id` is not a path `id` non è un tracciato +@msg the path has no open subpath il tracciato non ha sottotracciati aperti +@msg the subpath is closed il sottotracciato è chiuso +@msg the subpath is closed or too short to close il sottotracciato è chiuso o troppo corto per chiuderlo +@msg the path is too short to remove a point from il tracciato è troppo corto per rimuoverne un punto +@msg the segment can't be bent that far il segmento non si può curvare così tanto +@msg give `{a}` and `{b}` indica `{a}` e `{b}` +@msg `t` must be from 0 to 1 `t` deve essere compreso tra 0 e 1 +@msg `segment` must be "straight" or "curved" `segment` deve essere "straight" o "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` deve essere "free", "keepAngles" o "symmetric" +@msg `{key}` must be [x, y] `{key}` deve essere [x, y] diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b9d63a54a..0c645b935 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5483,3 +5483,25 @@ anchor Points: ポイント: @msg select open paths to close 閉じるオープンパスを選択してください @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) パスが多すぎるか複雑すぎて交差点を見つけられません(最大 10000 セグメント) @msg the paths have too many anchors to edit at once パスのアンカーポイントが多すぎて一度に編集できません +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen スマートペン + Smart Pen Tool スマートペンツール + Smart Pen Panel スマートペンパネル + Next Segment 次のセグメント + Curved 曲線 + Retract Handles ハンドルを収める + Close or Join 閉じるまたは連結 + Open a document to draw with the Smart Pen. スマートペンで描くにはドキュメントを開いてください。 + Straight or Curved Segment 直線または曲線セグメント +@msg the path is locked or hidden パスがロックされているか非表示です +@msg `id` is not a path `id` はパスではありません +@msg the path has no open subpath パスに開いたサブパスがありません +@msg the subpath is closed サブパスは閉じています +@msg the subpath is closed or too short to close サブパスが閉じているか、短すぎて閉じられません +@msg the path is too short to remove a point from パスが短すぎてポイントを削除できません +@msg the segment can't be bent that far セグメントをそこまで曲げることはできません +@msg give `{a}` and `{b}` `{a}` と `{b}` を指定してください +@msg `t` must be from 0 to 1 `t` には 0 から 1 を指定してください +@msg `segment` must be "straight" or "curved" `segment` には "straight" または "curved" を指定してください +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` には "free"、"keepAngles" または "symmetric" を指定してください +@msg `{key}` must be [x, y] `{key}` には [x, y] を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index ebddec3e9..4558fd897 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5787,3 +5787,25 @@ anchor Points: წერტილები: @msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) ტრაექტორიები ზედმეტად ბევრი ან ჩახლართულია გადაკვეთების საპოვნელად (მაქსიმუმ 10000 სეგმენტი) @msg the paths have too many anchors to edit at once ტრაექტორიებს ზედმეტად ბევრი საყრდენი წერტილი აქვს ერთბაშად დასარედაქტირებლად +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen ჭკვიანი კალამი + Smart Pen Tool ჭკვიანი კალმის ხელსაწყო + Smart Pen Panel ჭკვიანი კალმის პანელი + Next Segment შემდეგი სეგმენტი + Curved მრუდი + Retract Handles სახელურების შეკეცვა + Close or Join შეკვრა ან შეერთება + Open a document to draw with the Smart Pen. გახსენით დოკუმენტი ჭკვიანი კალმით სახატავად. + Straight or Curved Segment სწორი ან მრუდი სეგმენტი +@msg the path is locked or hidden ტრაექტორია ჩაკეტილი ან დამალულია +@msg `id` is not a path `id` არ არის ტრაექტორია +@msg the path has no open subpath ტრაექტორიას არ აქვს ღია ქვეტრაექტორია +@msg the subpath is closed ქვეტრაექტორია შეკრულია +@msg the subpath is closed or too short to close ქვეტრაექტორია შეკრულია ან შესაკრავად ძალიან მოკლეა +@msg the path is too short to remove a point from ტრაექტორია წერტილის მოსაშორებლად ძალიან მოკლეა +@msg the segment can't be bent that far სეგმენტის ამდენად მოხრა შეუძლებელია +@msg give `{a}` and `{b}` მიუთითეთ `{a}` და `{b}` +@msg `t` must be from 0 to 1 `t` უნდა იყოს 0-დან 1-მდე +@msg `segment` must be "straight" or "curved" `segment` უნდა იყოს "straight" ან "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` უნდა იყოს "free", "keepAngles" ან "symmetric" +@msg `{key}` must be [x, y] `{key}` უნდა იყოს [x, y] diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 348baf15c..5b40aeddf 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5377,3 +5377,25 @@ anchor Points: 포인트: @msg select open paths to close 닫을 열린 패스를 선택하세요 @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 패스가 너무 많거나 너무 얽혀 교차점을 찾을 수 없습니다(최대 10000개 세그먼트) @msg the paths have too many anchors to edit at once 패스의 앵커 포인트가 너무 많아 한 번에 편집할 수 없습니다 +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen 스마트 펜 + Smart Pen Tool 스마트 펜 도구 + Smart Pen Panel 스마트 펜 패널 + Next Segment 다음 세그먼트 + Curved 곡선 + Retract Handles 핸들 접기 + Close or Join 닫기 또는 결합 + Open a document to draw with the Smart Pen. 스마트 펜으로 그리려면 문서를 여세요. + Straight or Curved Segment 직선 또는 곡선 세그먼트 +@msg the path is locked or hidden 패스가 잠겨 있거나 숨겨져 있습니다 +@msg `id` is not a path `id`은(는) 패스가 아닙니다 +@msg the path has no open subpath 패스에 열린 하위 패스가 없습니다 +@msg the subpath is closed 하위 패스가 닫혀 있습니다 +@msg the subpath is closed or too short to close 하위 패스가 닫혀 있거나 너무 짧아 닫을 수 없습니다 +@msg the path is too short to remove a point from 패스가 너무 짧아 포인트를 제거할 수 없습니다 +@msg the segment can't be bent that far 세그먼트를 그만큼 구부릴 수 없습니다 +@msg give `{a}` and `{b}` `{a}`와(과) `{b}`을(를) 지정하세요 +@msg `t` must be from 0 to 1 `t`는 0에서 1 사이여야 합니다 +@msg `segment` must be "straight" or "curved" `segment`는 "straight" 또는 "curved"여야 합니다 +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode`는 "free", "keepAngles" 또는 "symmetric"이어야 합니다 +@msg `{key}` must be [x, y] `{key}`은(는) [x, y]여야 합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 956e2edd2..b04195e78 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5746,3 +5746,25 @@ anchor Points: Punten: @msg select open paths to close selecteer open paden om te sluiten @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) te veel of te verwarde paden om hun kruisingen te vinden (hoogstens 10000 segmenten) @msg the paths have too many anchors to edit at once de paden hebben te veel ankerpunten om in één keer te bewerken +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Slimme pen + Smart Pen Tool Slimme pen-gereedschap + Smart Pen Panel Slimme pen-paneel + Next Segment Volgend segment + Curved Gebogen + Retract Handles Grepen intrekken + Close or Join Sluiten of verbinden + Open a document to draw with the Smart Pen. Open een document om met de slimme pen te tekenen. + Straight or Curved Segment Recht of gebogen segment +@msg the path is locked or hidden het pad is vergrendeld of verborgen +@msg `id` is not a path `id` is geen pad +@msg the path has no open subpath het pad heeft geen open subpad +@msg the subpath is closed het subpad is gesloten +@msg the subpath is closed or too short to close het subpad is gesloten of te kort om te sluiten +@msg the path is too short to remove a point from het pad is te kort om er een punt uit te verwijderen +@msg the segment can't be bent that far het segment kan niet zo ver worden gebogen +@msg give `{a}` and `{b}` geef `{a}` en `{b}` op +@msg `t` must be from 0 to 1 `t` moet tussen 0 en 1 liggen +@msg `segment` must be "straight" or "curved" `segment` moet "straight" of "curved" zijn +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` moet "free", "keepAngles" of "symmetric" zijn +@msg `{key}` must be [x, y] `{key}` moet [x, y] zijn diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9292990c4..44eebf262 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5652,3 +5652,25 @@ anchor Points: Punkty: @msg select open paths to close zaznacz otwarte ścieżki do zamknięcia @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) za dużo ścieżek lub są zbyt splątane, by znaleźć ich skrzyżowania (najwyżej 10000 segmentów) @msg the paths have too many anchors to edit at once ścieżki mają za dużo punktów kontrolnych, by edytować je naraz +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Inteligentne pióro + Smart Pen Tool Narzędzie Inteligentne pióro + Smart Pen Panel Panel Inteligentne pióro + Next Segment Następny segment + Curved Zakrzywiony + Retract Handles Schowaj uchwyty + Close or Join Zamknij lub połącz + Open a document to draw with the Smart Pen. Otwórz dokument, aby rysować inteligentnym piórem. + Straight or Curved Segment Segment prosty lub zakrzywiony +@msg the path is locked or hidden ścieżka jest zablokowana lub ukryta +@msg `id` is not a path `id` nie jest ścieżką +@msg the path has no open subpath ścieżka nie ma otwartej ścieżki podrzędnej +@msg the subpath is closed ścieżka podrzędna jest zamknięta +@msg the subpath is closed or too short to close ścieżka podrzędna jest zamknięta lub za krótka, by ją zamknąć +@msg the path is too short to remove a point from ścieżka jest za krótka, by usunąć z niej punkt +@msg the segment can't be bent that far segmentu nie da się tak bardzo wygiąć +@msg give `{a}` and `{b}` podaj `{a}` i `{b}` +@msg `t` must be from 0 to 1 `t` musi mieć wartość od 0 do 1 +@msg `segment` must be "straight" or "curved" `segment` musi mieć wartość "straight" lub "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` musi mieć wartość "free", "keepAngles" lub "symmetric" +@msg `{key}` must be [x, y] `{key}` musi mieć postać [x, y] diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b850a0796..2e49999cf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5715,3 +5715,25 @@ anchor Points: Pontos: @msg select open paths to close selecione caminhos abertos para fechar @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) caminhos demais, ou emaranhados demais, para encontrar seus cruzamentos (no máximo 10000 segmentos) @msg the paths have too many anchors to edit at once os caminhos têm pontos de ancoragem demais para editar de uma vez +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Caneta Inteligente + Smart Pen Tool Ferramenta Caneta Inteligente + Smart Pen Panel Painel Caneta Inteligente + Next Segment Próximo Segmento + Curved Curvo + Retract Handles Recolher Alças + Close or Join Fechar ou Unir + Open a document to draw with the Smart Pen. Abra um documento para desenhar com a caneta inteligente. + Straight or Curved Segment Segmento Reto ou Curvo +@msg the path is locked or hidden o caminho está bloqueado ou oculto +@msg `id` is not a path `id` não é um caminho +@msg the path has no open subpath o caminho não tem subcaminho aberto +@msg the subpath is closed o subcaminho está fechado +@msg the subpath is closed or too short to close o subcaminho está fechado ou é curto demais para fechar +@msg the path is too short to remove a point from o caminho é curto demais para remover um ponto +@msg the segment can't be bent that far o segmento não pode ser curvado tanto +@msg give `{a}` and `{b}` informe `{a}` e `{b}` +@msg `t` must be from 0 to 1 `t` deve estar entre 0 e 1 +@msg `segment` must be "straight" or "curved" `segment` deve ser "straight" ou "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` deve ser "free", "keepAngles" ou "symmetric" +@msg `{key}` must be [x, y] `{key}` deve ser [x, y] diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cf222a0af..63ed5fd13 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5775,3 +5775,25 @@ anchor Points: Точки: @msg select open paths to close выделите незамкнутые контуры, чтобы замкнуть их @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контуров слишком много или они слишком запутаны, чтобы найти их пересечения (не более 10000 сегментов) @msg the paths have too many anchors to edit at once у контуров слишком много опорных точек, чтобы править их за раз +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Умное перо + Smart Pen Tool Инструмент «Умное перо» + Smart Pen Panel Панель «Умное перо» + Next Segment Следующий сегмент + Curved Кривой + Retract Handles Убрать маркеры + Close or Join Замкнуть или соединить + Open a document to draw with the Smart Pen. Откройте документ, чтобы рисовать умным пером. + Straight or Curved Segment Прямой или кривой сегмент +@msg the path is locked or hidden контур заблокирован или скрыт +@msg `id` is not a path `id` не является контуром +@msg the path has no open subpath у контура нет незамкнутого вложенного контура +@msg the subpath is closed вложенный контур замкнут +@msg the subpath is closed or too short to close вложенный контур замкнут или слишком короток, чтобы его замкнуть +@msg the path is too short to remove a point from контур слишком короток, чтобы удалить из него точку +@msg the segment can't be bent that far сегмент нельзя изогнуть настолько +@msg give `{a}` and `{b}` укажите `{a}` и `{b}` +@msg `t` must be from 0 to 1 `t` должно быть от 0 до 1 +@msg `segment` must be "straight" or "curved" `segment` должно быть "straight" или "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` должно быть "free", "keepAngles" или "symmetric" +@msg `{key}` must be [x, y] `{key}` должно быть [x, y] diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 965c0f8d6..7cf4031cc 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5821,3 +5821,25 @@ anchor Points: Punkter: @msg select open paths to close markera öppna banor att stänga @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) för många eller för trassliga banor för att hitta deras korsningar (högst 10000 segment) @msg the paths have too many anchors to edit at once banorna har för många ankarpunkter för att redigeras på en gång +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Smart penna + Smart Pen Tool Smart penna-verktyget + Smart Pen Panel Smart penna-panelen + Next Segment Nästa segment + Curved Böjd + Retract Handles Dra in handtag + Close or Join Stäng eller foga samman + Open a document to draw with the Smart Pen. Öppna ett dokument för att rita med smart penna. + Straight or Curved Segment Rakt eller böjt segment +@msg the path is locked or hidden banan är låst eller dold +@msg `id` is not a path `id` är inte en bana +@msg the path has no open subpath banan har ingen öppen delbana +@msg the subpath is closed delbanan är stängd +@msg the subpath is closed or too short to close delbanan är stängd eller för kort för att stänga +@msg the path is too short to remove a point from banan är för kort för att ta bort en punkt +@msg the segment can't be bent that far segmentet kan inte böjas så långt +@msg give `{a}` and `{b}` ange `{a}` och `{b}` +@msg `t` must be from 0 to 1 `t` måste vara från 0 till 1 +@msg `segment` must be "straight" or "curved" `segment` måste vara "straight" eller "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` måste vara "free", "keepAngles" eller "symmetric" +@msg `{key}` must be [x, y] `{key}` måste vara [x, y] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ee0fcd140..70c0dc82d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5755,3 +5755,25 @@ anchor Points: Noktalar: @msg select open paths to close kapatılacak açık yolları seçin @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) kesişimlerini bulmak için yollar çok fazla veya çok karışık (en fazla 10000 bölüm) @msg the paths have too many anchors to edit at once yolların aynı anda düzenlenemeyecek kadar çok bağlantı noktası var +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Akıllı Kalem + Smart Pen Tool Akıllı Kalem Aracı + Smart Pen Panel Akıllı Kalem Paneli + Next Segment Sonraki Segment + Curved Eğri + Retract Handles Kolları Geri Çek + Close or Join Kapat veya Birleştir + Open a document to draw with the Smart Pen. Akıllı kalemle çizmek için bir belge açın. + Straight or Curved Segment Düz veya Eğri Segment +@msg the path is locked or hidden yol kilitli veya gizli +@msg `id` is not a path `id` bir yol değil +@msg the path has no open subpath yolun açık alt yolu yok +@msg the subpath is closed alt yol kapalı +@msg the subpath is closed or too short to close alt yol kapalı veya kapatmak için çok kısa +@msg the path is too short to remove a point from yol, bir nokta kaldırmak için çok kısa +@msg the segment can't be bent that far segment bu kadar bükülemez +@msg give `{a}` and `{b}` `{a}` ve `{b}` değerlerini verin +@msg `t` must be from 0 to 1 `t` 0 ile 1 arasında olmalıdır +@msg `segment` must be "straight" or "curved" `segment` "straight" veya "curved" olmalıdır +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` "free", "keepAngles" veya "symmetric" olmalıdır +@msg `{key}` must be [x, y] `{key}` [x, y] olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a9264ddd2..a84207b27 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5394,3 +5394,25 @@ anchor Points: Точки: @msg select open paths to close виділіть незамкнені контури, щоб замкнути їх @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контурів забагато або вони надто заплутані, щоб знайти їхні перетини (не більше 10000 сегментів) @msg the paths have too many anchors to edit at once у контурів забагато опорних точок, щоб редагувати їх за раз +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Розумне перо + Smart Pen Tool Інструмент «Розумне перо» + Smart Pen Panel Панель «Розумне перо» + Next Segment Наступний сегмент + Curved Кривий + Retract Handles Прибрати маркери + Close or Join Замкнути або з’єднати + Open a document to draw with the Smart Pen. Відкрийте документ, щоб малювати розумним пером. + Straight or Curved Segment Прямий або кривий сегмент +@msg the path is locked or hidden контур заблоковано або приховано +@msg `id` is not a path `id` не є контуром +@msg the path has no open subpath контур не має незамкненого вкладеного контуру +@msg the subpath is closed вкладений контур замкнено +@msg the subpath is closed or too short to close вкладений контур замкнено або він закороткий, щоб його замкнути +@msg the path is too short to remove a point from контур закороткий, щоб видалити з нього точку +@msg the segment can't be bent that far сегмент не можна вигнути настільки +@msg give `{a}` and `{b}` укажіть `{a}` і `{b}` +@msg `t` must be from 0 to 1 `t` має бути від 0 до 1 +@msg `segment` must be "straight" or "curved" `segment` має бути "straight" або "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` має бути "free", "keepAngles" або "symmetric" +@msg `{key}` must be [x, y] `{key}` має бути [x, y] diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d471be738..6189e8a7a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5396,3 +5396,25 @@ anchor Points: Điểm: @msg select open paths to close chọn các đường dẫn mở để đóng @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) quá nhiều đường dẫn hoặc chúng quá rối để tìm chỗ giao nhau (tối đa 10000 đoạn) @msg the paths have too many anchors to edit at once các đường dẫn có quá nhiều điểm neo để sửa cùng lúc +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen Bút thông minh + Smart Pen Tool Công cụ Bút thông minh + Smart Pen Panel Bảng Bút thông minh + Next Segment Đoạn tiếp theo + Curved Cong + Retract Handles Thu tay nắm + Close or Join Đóng hoặc nối + Open a document to draw with the Smart Pen. Mở một tài liệu để vẽ bằng bút thông minh. + Straight or Curved Segment Đoạn thẳng hoặc cong +@msg the path is locked or hidden đường dẫn bị khóa hoặc bị ẩn +@msg `id` is not a path `id` không phải là đường dẫn +@msg the path has no open subpath đường dẫn không có nhánh con mở +@msg the subpath is closed nhánh con đã đóng +@msg the subpath is closed or too short to close nhánh con đã đóng hoặc quá ngắn để đóng +@msg the path is too short to remove a point from đường dẫn quá ngắn để xóa một điểm +@msg the segment can't be bent that far không thể uốn đoạn xa đến vậy +@msg give `{a}` and `{b}` hãy cho `{a}` và `{b}` +@msg `t` must be from 0 to 1 `t` phải từ 0 đến 1 +@msg `segment` must be "straight" or "curved" `segment` phải là "straight" hoặc "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` phải là "free", "keepAngles" hoặc "symmetric" +@msg `{key}` must be [x, y] `{key}` phải là [x, y] diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dd7d0037a..048b8bf5e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5637,3 +5637,25 @@ anchor Points: 点: @msg select open paths to close 请选择要闭合的开放路径 @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路径太多或太复杂,无法找到交叉处(最多 10000 段) @msg the paths have too many anchors to edit at once 路径的锚点太多,无法一次编辑 +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen 智能钢笔 + Smart Pen Tool 智能钢笔工具 + Smart Pen Panel 智能钢笔面板 + Next Segment 下一线段 + Curved 曲线 + Retract Handles 收回手柄 + Close or Join 闭合或连接 + Open a document to draw with the Smart Pen. 打开文档以使用智能钢笔绘制。 + Straight or Curved Segment 直线或曲线线段 +@msg the path is locked or hidden 路径已锁定或隐藏 +@msg `id` is not a path `id` 不是路径 +@msg the path has no open subpath 路径没有开放的子路径 +@msg the subpath is closed 子路径已闭合 +@msg the subpath is closed or too short to close 子路径已闭合或太短,无法闭合 +@msg the path is too short to remove a point from 路径太短,无法删除锚点 +@msg the segment can't be bent that far 线段无法弯曲到这个程度 +@msg give `{a}` and `{b}` 请提供 `{a}` 和 `{b}` +@msg `t` must be from 0 to 1 `t` 必须在 0 到 1 之间 +@msg `segment` must be "straight" or "curved" `segment` 必须是 "straight" 或 "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` 必须是 "free"、"keepAngles" 或 "symmetric" +@msg `{key}` must be [x, y] `{key}` 必须是 [x, y] diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0847e3b6c..f076ef95e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5433,3 +5433,25 @@ anchor Points: 點: @msg select open paths to close 請選取要封閉的開放路徑 @msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路徑太多或太複雜,無法找到交叉處(最多 10000 段) @msg the paths have too many anchors to edit at once 路徑的錨點太多,無法一次編輯 +# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages. + Smart Pen 智慧筆型 + Smart Pen Tool 智慧筆型工具 + Smart Pen Panel 智慧筆型面板 + Next Segment 下一個線段 + Curved 曲線 + Retract Handles 收回控制把手 + Close or Join 封閉或連接 + Open a document to draw with the Smart Pen. 開啟文件以使用智慧筆型繪製。 + Straight or Curved Segment 直線或曲線線段 +@msg the path is locked or hidden 路徑已鎖定或隱藏 +@msg `id` is not a path `id` 不是路徑 +@msg the path has no open subpath 路徑沒有開放的子路徑 +@msg the subpath is closed 子路徑已封閉 +@msg the subpath is closed or too short to close 子路徑已封閉或太短,無法封閉 +@msg the path is too short to remove a point from 路徑太短,無法移除錨點 +@msg the segment can't be bent that far 線段無法彎曲到這個程度 +@msg give `{a}` and `{b}` 請提供 `{a}` 和 `{b}` +@msg `t` must be from 0 to 1 `t` 必須介於 0 到 1 +@msg `segment` must be "straight" or "curved" `segment` 必須是 "straight" 或 "curved" +@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` 必須是 "free"、"keepAngles" 或 "symmetric" +@msg `{key}` must be [x, y] `{key}` 必須是 [x, y] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 3760594c8..513a1bcae 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -182,6 +182,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui), crate::addons::color::ID => crate::addons::color::show(app, ui), crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui), + crate::addons::pen::ID => crate::addons::pen::show(app, ui), id if library_panel::parse(id).is_some() => library_panel::show(app, ui, id), _ => { dim_label(ui, tl!("This panel is on the roadmap (see the parity plan).")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9616f7903..76d6b3967 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -541,6 +541,10 @@ fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) for d in digits { let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view); } + // Tab, when the tool takes it (the Smart Pen Tool placing an anchor). + if app.session.tool_claims_key(ToolKey::Tab, view) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) { + let _ = app.session.tool_key(ToolKey::Tab, Mods::default(), view); + } // The tool gets each letter it takes (E while dragging a selection marquee) once per press, // without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin // letters is the key at that position on a US layout. @@ -592,6 +596,8 @@ fn claimed_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewIn (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), + // The Smart Pen Tool's Tab while it draws a path (the key otherwise hides the panels). + (Key::Tab, ToolKey::Tab), ] { if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) { let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e41af01cd..c3e856ba4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -200,6 +200,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), (crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"), + (crate::addons::pen::ID, "Smart Pen", "spline-pointer"), ]; /// Groups of icon panels separated by dividers in the collapsed column. diff --git a/docs/mcp.md b/docs/mcp.md index a04a7abbd..f603b6317 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5767,6 +5767,66 @@ Commands: {"name":"run_command","arguments":{"command":"addon.pathtools.erase","params":{"points":[[100,40],[100,160]],"diameter":30}}} {"name":"run_command","arguments":{"command":"addon.pathtools.crossings","params":{"cut":true}}} ``` + +## Smart Pen add-on + +Smart Pen is a built-in add-on: one tool that draws paths and edits their points, handles and segments without +switching tools. Its commands are `addon.pen.*`; in the desktop app it lives in Object › Plug-ins › Smart Pen, in its +panel (`window.panel {panel: "smartPen"}`) and in the Smart Pen Tool (`smartPen`), which the toolbars don't show until +it is added from the All Tools drawer (or chosen with `tool.select`); it has no shortcut. Each gesture and each command is +one undo step; an edit that changes nothing makes no step. The standard Pen tool is unchanged. + +The tool (`smartPen`), option `next`: `"straight"` (default) or `"curved"`, the segment a click adds: + +- Drawing: a click adds the next anchor with a straight segment into it (the previous anchor's handle on it retracted) + or a curved one (the previous anchor gets a handle if it has none: in line with its other handle, or, after a straight + segment, along it, so the curve leaves tangent). A drag makes a smooth anchor; Alt breaks the outgoing handle off, + Shift keeps it to 45° steps, Space moves the anchor. Tab switches the segment into the anchor being placed, or the + last segment drawn, between straight and curved. A rubber band shows the next segment. A press on the first anchor + closes the path; on an end of another open path it joins the two (`path.join`); with no path being drawn, a click on + an end of an open path draws on from it. Enter or Esc ends the path. +- Points of the shown (selected) paths: a click selects one (`select.anchors`; Shift adds or removes it), and on an end + of an open path draws on from there; a drag moves it (`path.moveAnchor`, Shift: 45°); Alt-drag pulls out its outgoing + handle (`path.setHandle`); Alt-click removes it keeping the shape and Shift+Alt-click deletes it with the handles + beside it left as they are (`addon.pen.removePoint`); a double-click toggles it between smooth and corner + (`addon.pen.togglePoint`). +- Handles of selected anchors drag (`path.setHandle`; Shift: 45°, Alt: the handle keeps its angle); a double-click on + one toggles its anchor. +- Segments drag to reshape (`addon.pen.reshape`; Shift: `symmetric`, Alt: `keepAngles`); Alt-click adds an anchor + (`path.insertAnchor`); a click on a path that isn't selected selects it. + +Commands: + +- `addon.pen.append {id, subpath?, x, y, in?, out?, segment?}` adds an anchor at the end of an open subpath (by default + the last open one) and selects it. `segment`: `"straight"` (the previous anchor's handle on the new segment and the + new anchor's incoming handle retracted) or `"curved"` (the previous anchor gets a handle on it as a click does); + without it the anchor takes the handles given and the previous anchor stays as it is. +- `addon.pen.close {id, subpath?, segment?}` closes an open subpath of two anchors or more, the closing segment + straight, curved or (without `segment`) with the handles its ends have. +- `addon.pen.segment {id, subpath?, segment, curved}` makes one segment straight or curved → `{changed}`. +- `addon.pen.removePoint {id, subpath?, anchor, plain?}` removes one anchor: the handles beside it are refitted so the + outline keeps its shape (an open end takes its segment with it), or with `plain` left as they are. A subpath keeps two + anchors (three when closed). +- `addon.pen.togglePoint {id, subpath?, anchor, ratio?}`: a smooth anchor becomes a corner with its handles where they + are; any other is made smooth by the auto-smooth rule (`ratio` 0.1 to 0.5, 0.3905) → `{smooth}`. +- `addon.pen.reshape {id, subpath?, segment, t?, dx, dy, mode?}` bends a segment so its point at `t` (0 to 1, held to + 0.05…0.95) moves by `(dx, dy)`: `free` (default; both handles move, the one nearer the grab more), `keepAngles` (each + handle only changes length; a segment whose two directions are parallel bends freely) or `symmetric` (both handles + at right angles to the chord at one length). Missing handles grow out of their anchors; a smooth anchor stays smooth. +- `addon.pen.nextSegment {curved}` sets the tool's `next` option → `{next}`. +- `addon.pen.closeJoin {}`: with exactly two open path ends direct-selected, joins them (as `path.join`); otherwise + closes the selected open paths keeping their ends' handles (as `addon.pathtools.close`). + +The panel sets the next segment and runs Remove Points Keep Shape (`addon.pathtools.removeKeepShape`), Make Smooth +(`addon.pathtools.smooth`), Make Corner (`addon.pathtools.corner {retract: false}`: nothing moves), Retract Handles +(`addon.pathtools.corner {retract: true}`) and Close or Join (`addon.pen.closeJoin`). + +```json +{"name":"run_command","arguments":{"command":"addon.pen.nextSegment","params":{"curved":true}}} +{"name":"run_command","arguments":{"command":"addon.pen.append","params":{"id":12,"x":200,"y":150,"segment":"curved"}}} +{"name":"run_command","arguments":{"command":"addon.pen.reshape","params":{"id":12,"segment":0,"t":0.3,"dx":0,"dy":-25}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is `addon.color.filter`, and in the desktop app it also lives in Object › Plug-ins › Color Effects and in its panel diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a42e2b21c..1f50e3735 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Smart Pen, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1229,6 +1229,21 @@ Kernels in `vectorcraft_pathops::addons::pathtools`. Tests: `addons::pathtools` undo step, bad params refused, the brush's stroke one step) and `edit_toolbar::tests::addon_tools_are_in_the_drawer_not_the_toolbars`. Commands: `docs/mcp.md` › Path Precision Tools add-on. +## Smart Pen add-on (2026-10-30) + +A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Smart Pen selects the +Smart Pen Tool (kept out of the shipped toolbars, listed in the All Tools drawer, no shortcut) and opens its panel. The +tool draws with a straight or curved next segment (a curve after a straight segment leaves it tangent), smooth drags +with Alt, Shift and Space, Tab to switch a segment, a rubber band, closing, joining and continuing open ends; and edits +without switching tools: point select, move, Alt-click remove keeping shape, Shift+Alt-click plain delete, double-click +smooth/corner, handle drags (Shift 45°, Alt keeps the angle), segment drags (free, keep angles, symmetric) and Alt-click +insert. The panel sets the next segment and runs Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles and +Close or Join. Kernels: `vectorcraft_pathops::addons::pen` and the shared `addons::segment_reshape`. Tests: +`addons::pen` and `addons::segment_reshape` in `vectorcraft-pathops`, `addons::smart_pen` in `vectorcraft-tools`, +`cmd::addons::pen::tests` in `vectorcraft-engine` (each command one undo step, bad params refused, the tool's gestures +through the session) and `addons::pen::tests` in `vectorcraft-ui-egui` (the submenu, the panel). Commands: `docs/mcp.md` +› Smart Pen add-on. + ## Color Effects add-on (2026-10-30) A VectorCraft addition, not an Illustrator feature, so no area score moves. Effect › Color Effects adds live Exposure (stops of linear light, offset and gamma), Desaturate and Invert Colors as colour adjustments evaluated with those of @@ -1242,6 +1257,7 @@ undo step, with any adjustment and its params; bad requests refused) and `addons | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Smart Pen add-on (a VectorCraft addition): evidence added; scores unchanged. | | 2026-10-30 | minor | Path Precision Tools add-on (a VectorCraft addition): evidence added; scores unchanged. | | 2026-10-30 | minor | Color Effects, a built-in add-on beyond Illustrator; scores and estimates unchanged | | 2026-10-30 | minor | Artwork Cleanup add-on (a VectorCraft addition): evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 103cf2c48..05876d803 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools add-on: its tools, Anchor Inspector panel and Object › Plug-ins submenu; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Smart Pen add-on: its tool, panel and Object › Plug-ins submenu; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -321,6 +321,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, ); arcs, spirals, flares and Pen paths are not turned | | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | +| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | | Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored | | Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored | @@ -364,6 +365,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Smart Pen add-on (a VectorCraft addition): its tool, panel and Object › Plug-ins › Smart Pen; scores and estimates unchanged | | 2026-10-30 | minor | Path Precision Tools add-on (a VectorCraft addition): its tools, Anchor Inspector panel and Object › Plug-ins › Path Precision Tools; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: its effects, panel and Object › Plug-ins submenu; scores and estimates unchanged | | 2026-10-30 | minor | Artwork Cleanup add-on (a VectorCraft addition): its panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged |