From 1cf1477183bde2e120a94f8bb1e87c31dbaf23b3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 00:06:15 +0000 Subject: [PATCH] Integration (#630): the Pen continuing from a path's first anchor adds anchors before it (path.appendAnchor at: start) instead of reversing the path, so arrowheads stay on their ends and a press then Esc leaves no undo step --- crates/engine/src/cmd/object.rs | 18 ++++++++++++++++-- crates/tools/src/pen.rs | 30 ++++++++++++++++++++++++------ crates/tools/src/tests.rs | 19 +++++++++++++++---- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 29aa8dc09..39441ede5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -600,7 +600,7 @@ pub fn specs() -> Vec { "Add Anchor", [], None, - "{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor", + "{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last), at?: end|start (default end)} — after the subpath's last anchor, or before its first with `at: start`", has_doc, path_append ), @@ -2714,6 +2714,12 @@ fn path_append(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; let subpath = p.get("subpath").and_then(Value::as_u64); + let at_start = match p.get("at") { + None => false, + Some(v) if v.as_str() == Some("end") => false, + Some(v) if v.as_str() == Some("start") => true, + Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")), + }; s.edit(|d, _| { let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); @@ -2727,7 +2733,11 @@ fn path_append(s: &mut Session, p: &Value) -> Result { if sp.closed { return Err(bad("path.appendAnchor", "the subpath is closed")); } - sp.anchors.push(a.transform(inv)); + if at_start { + sp.anchors.insert(0, a.transform(inv)); + } else { + sp.anchors.push(a.transform(inv)); + } if it.shape == Shape::GraphicLine { it.shape = Shape::Path; } @@ -4383,6 +4393,10 @@ mod path_edit_tests { s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap(); assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0)); assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err()); + // `at: start` goes before the first anchor (the Pen continuing from a path's start). + s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [50, 60]}, "at": "start"})).unwrap(); + assert_eq!((anchors(&s, id)[0].p, anchors(&s, id).len()), (Point::new(50.0, 60.0), 5)); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}, "at": "middle"})).is_err()); // Direction lines on the last anchor: collinear and opposite make it smooth. s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap(); let last = *anchors(&s, id).last().unwrap(); diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index e7df8a958..3b2c1dd36 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -30,6 +30,9 @@ pub struct PenTool { editing_end: bool, /// The press on the end point has been dragged. end_moved: bool, + /// Continuing from the subpath's first anchor: `anchors` run in drawing order, the reverse + /// of the path's, and new anchors go before its first one. + from_start: bool, } /// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last @@ -116,6 +119,7 @@ impl PenTool { self.subpath = None; self.editing_end = false; self.end_moved = false; + self.from_start = false; vec![] } @@ -130,7 +134,8 @@ impl PenTool { let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) }; (xf * a.p, xf * i, xf * o) }; - // From the first anchor the path is reversed first, so the new anchors follow on. + // From the first anchor the anchors run backwards (handles swapped); new ones are added + // before the first, so the path keeps its direction (and its arrowheads their ends). let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) }; self.path = Some(id.0); self.subpath = Some(si); @@ -139,7 +144,13 @@ impl PenTool { self.editing_end = true; self.end_moved = false; self.dragging = true; - if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] } + self.from_start = !at_end; + vec![] + } + + /// Anchor `a` (drawing order) in the path's order. + fn path_anchor(&self, a: &(Point, Point, Point)) -> (Point, Point, Point) { + if self.from_start { (a.0, a.2, a.1) } else { *a } } /// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas. @@ -161,10 +172,13 @@ impl PenTool { vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))] } Some(id) => { - let mut p = json!({"id": id, "anchor": anchor_json(last)}); + let mut p = json!({"id": id, "anchor": anchor_json(&self.path_anchor(last))}); if let Some(si) = self.subpath { p["subpath"] = json!(si); } + if self.from_start { + p["at"] = json!("start"); + } vec![Action::Exec("path.appendAnchor".into(), p)] } None => vec![], @@ -248,13 +262,17 @@ impl Tool for PenTool { self.dragging = false; // The continued path's end point: a drag set its direction lines. if std::mem::take(&mut self.editing_end) { - let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] }; - if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 { + let (Some(id), Some(si), Some(&(p, drawn_in, drawn_out))) = (self.path, self.subpath, self.anchors.last()) else { + return vec![]; + }; + if !std::mem::take(&mut self.end_moved) || (drawn_out - p).hypot() < 1e-9 { return vec![]; } + let (_, h_in, h_out) = self.path_anchor(&(p, drawn_in, drawn_out)); + let anchor = if self.from_start { "first" } else { "last" }; return vec![Action::Exec( "path.setAnchor".into(), - json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}), + json!({"id": id, "subpath": si, "anchor": anchor, "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}), )]; } self.commit_anchor() diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index a181a0e6b..7bef4a0ca 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1559,19 +1559,30 @@ fn pen_continues_an_open_path_from_either_end_point() { let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]); assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); - // From the first anchor, the path is reversed first; a drag there sets its direction lines. + // From the first anchor the path keeps its direction (arrowheads stay on their ends, and a + // press then Esc leaves nothing to undo): a drag there sets the first anchor's direction + // lines, and new anchors go before it. let mut t = create("pen"); - let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); - assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))).is_empty()); t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0))); let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0))); assert_eq!( a, vec![Action::Exec( "path.setAnchor".into(), - serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]}) + serde_json::json!({"id": id.0, "subpath": 0, "anchor": "first", "in": [80.0, 140.0], "out": [120.0, 60.0]}) )] ); + t.pointer(&cx, &ev(PointerKind::Down, at(20.0, 200.0))); + t.pointer(&cx, &ev(PointerKind::Drag, at(40.0, 220.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(40.0, 220.0))); + let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["at"].as_str(), p["subpath"].as_u64()), ("path.appendAnchor", Some("start"), Some(0))); + assert_eq!(p["anchor"], serde_json::json!({"p": [20.0, 200.0], "in": [40.0, 220.0], "out": [0.0, 180.0]})); + let mut t = create("pen"); + assert!(t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).iter().all(|a| !matches!(a, Action::Exec(c, _) if c.starts_with("path.")))); // Closed paths and locked paths aren't continued. let mut d2 = d.clone(); d2.item_mut(id).unwrap().locked = true;