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

This commit is contained in:
aiongg
2026-10-13 00:06:15 +00:00
parent 575839cc4f
commit 1cf1477183
3 changed files with 55 additions and 12 deletions
+16 -2
View File
@@ -600,7 +600,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
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();
+24 -6
View File
@@ -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()
+15 -4
View File
@@ -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;