D: Add/Delete Anchor Point and Convert Direction Point tools (path.addAnchor/deleteAnchor/convertAnchor)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 10:22:27 -04:00
parent 66ab252044
commit 349525bae8
9 changed files with 378 additions and 6 deletions

View File

@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.

View File

@ -0,0 +1,220 @@
//! Anchor-point editing (the Pen tool group): Add Anchor Point, Delete Anchor Point and Convert
//! Direction Point. Positions are in spread coordinates; anchors are `(subpath, index)`.
use designcraft_doc::{Document, ItemId, Shape};
use designcraft_geom::{Affine, Anchor, AnchorKind, Point};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, point_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"path.addAnchor",
"Add Anchor Point",
[],
None,
"{id, at: [x,y] (spread coords; the nearest point on the path), tolerance?: pt (default 4)} → {subpath, anchor}",
has_doc,
add_anchor
),
cmd!(
"path.deleteAnchor",
"Delete Anchor Point",
[],
None,
"{id, subpath, anchor} — removes the point (and the object when no path is left)",
has_doc,
delete_anchor
),
cmd!(
"path.convertAnchor",
"Convert Direction Point",
[],
None,
"{id, subpath, anchor, to?: [x,y] (spread coords: drag out symmetric handles to there), corner?: bool} — without `to`, a smooth point becomes a corner and a corner gets smooth handles",
has_doc,
convert_anchor
),
]
}
/// Spread → item-space transform of `id`.
fn to_inner(d: &Document, id: ItemId) -> Option<Affine> {
let loc = d.find(id)?;
Some((d.parent_xf(&loc) * d.item_at(&loc)?.xf).inverse())
}
fn anchor_param(p: &Value, cmd: &str) -> Result<(ItemId, usize, usize)> {
let id = super::id_param(p, "id").ok_or_else(|| bad(cmd, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing anchor"))? as usize;
Ok((id, si, ai))
}
/// Editing points makes a drawn rectangle/ellipse/polygon an ordinary path.
fn as_path(shape: &mut Shape) {
if matches!(shape, Shape::Rectangle | Shape::Oval | Shape::Polygon) {
*shape = Shape::Path;
}
}
fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?;
let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?;
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);
s.edit(|d, _| {
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let q = inv * at;
let (si, seg, t, _, dist) = it.path.nearest(q).ok_or_else(|| bad("path.addAnchor", "the object has no path"))?;
// Tolerance is in spread units; the item space may be scaled.
let scale = inv.determinant().abs().sqrt().max(1e-9);
if dist > tol * scale {
return Err(bad("path.addAnchor", "not on the path"));
}
let t = t.clamp(1e-4, 1.0 - 1e-4);
let ai = it.path.subpaths[si].insert_anchor(seg, t);
as_path(&mut it.shape);
Ok(json!({"subpath": si, "anchor": ai}))
})
}
fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?;
s.edit(|d, sel| {
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?;
if ai >= sp.anchors.len() {
return Err(bad("path.deleteAnchor", "no such anchor"));
}
sp.anchors.remove(ai);
// A subpath needs two points (three to stay closed).
if sp.anchors.len() < 2 {
it.path.subpaths.remove(si);
} else if sp.closed && sp.anchors.len() < 3 {
sp.closed = false;
}
as_path(&mut it.shape);
if it.path.subpaths.is_empty() {
d.remove_item(id)?;
sel.items.retain(|i| *i != id);
return Ok(json!({"deleted": true}));
}
Ok(json!({"deleted": false}))
})
}
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let (id, si, ai) = anchor_param(p, "path.convertAnchor")?;
let to = point_param(p, "to");
let corner = p.get("corner").and_then(Value::as_bool);
s.edit(|d, _| {
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?;
let n = sp.anchors.len();
if ai >= n {
return Err(bad("path.convertAnchor", "no such anchor"));
}
let closed = sp.closed;
let a = sp.anchors[ai];
let has_handles = a.has_in() || a.has_out();
let new = match (to, corner) {
(Some(t), _) => Anchor::smooth(a.p, inv * t),
(None, Some(true)) => Anchor::corner(a.p),
(None, Some(false)) => smooth_auto(&sp.anchors, ai, closed),
(None, None) if has_handles => Anchor::corner(a.p),
(None, None) => smooth_auto(&sp.anchors, ai, closed),
};
sp.anchors[ai] = new;
as_path(&mut it.shape);
Ok(json!({"kind": if new.kind == AnchorKind::Smooth { "smooth" } else { "corner" }}))
})
}
/// Smooth handles for a corner: parallel to the line through its neighbours, a third of the way
/// to each.
fn smooth_auto(anchors: &[Anchor], i: usize, closed: bool) -> Anchor {
let n = anchors.len();
let p = anchors[i].p;
let prev = if i > 0 {
Some(anchors[i - 1].p)
} else if closed {
Some(anchors[n - 1].p)
} else {
None
};
let next = if i + 1 < n {
Some(anchors[i + 1].p)
} else if closed {
Some(anchors[0].p)
} else {
None
};
let (a, b) = (prev.unwrap_or(p), next.unwrap_or(p));
let dir = b - a;
let len = dir.hypot();
if len < 1e-9 {
return anchors[i];
}
let u = dir / len;
let (lin, lout) = ((p - a).hypot() / 3.0, (b - p).hypot() / 3.0);
let lin = if prev.is_some() { lin } else { lout };
let lout = if next.is_some() { lout } else { lin };
Anchor { p, h_in: p - u * lin, h_out: p + u * lout, kind: AnchorKind::Smooth }
}
/// The anchor of item `id` nearest to `at` (spread coords), within `tol`.
pub fn anchor_near(d: &Document, id: ItemId, at: Point, tol: f64) -> Option<(usize, usize)> {
let loc = d.find(id)?;
let m = d.parent_xf(&loc) * d.item_at(&loc)?.xf;
d.item(id)?
.path
.anchors()
.map(|(si, ai, a)| (si, ai, ((m * a.p) - at).hypot()))
.filter(|x| x.2 <= tol)
.min_by(|a, b| a.2.total_cmp(&b.2))
.map(|x| (x.0, x.1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_convert_delete_anchor_points() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = ItemId(s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap());
let count = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().path.anchor_count();
assert_eq!(count(&s), 4);
assert!(s.execute("path.addAnchor", &json!({"id": id.0, "at": [150, 150]})).is_err(), "not on the path");
let r = s.execute("path.addAnchor", &json!({"id": id.0, "at": [150, 101]})).unwrap();
assert_eq!(count(&s), 5);
let ai = r["anchor"].as_u64().unwrap() as usize;
let d = s.doc().unwrap().doc.clone();
let it = d.item(id).unwrap();
assert_eq!(it.shape, Shape::Path);
assert!((it.path.subpaths[0].anchors[ai].p.y - 100.0).abs() < 1e-6);
assert_eq!(anchor_near(&d, id, Point::new(150.5, 100.5), 3.0), Some((0, ai)));
// Drag out handles, then click again to make it a corner.
s.execute("path.convertAnchor", &json!({"id": id.0, "anchor": ai, "to": [170, 90]})).unwrap();
let a = s.doc().unwrap().doc.item(id).unwrap().path.subpaths[0].anchors[ai];
assert_eq!(a.kind, AnchorKind::Smooth);
assert!((a.h_out - Point::new(170.0, 90.0)).hypot() < 1e-9 && (a.h_in - Point::new(130.0, 110.0)).hypot() < 1e-9);
assert_eq!(s.execute("path.convertAnchor", &json!({"id": id.0, "anchor": ai})).unwrap()["kind"], "corner");
assert_eq!(s.execute("path.convertAnchor", &json!({"id": id.0, "anchor": 0})).unwrap()["kind"], "smooth");
s.execute("path.deleteAnchor", &json!({"id": id.0, "anchor": ai})).unwrap();
assert_eq!(count(&s), 4);
// Deleting down to one point removes the object.
for _ in 0..2 {
s.execute("path.deleteAnchor", &json!({"id": id.0, "anchor": 0})).unwrap();
}
assert!(!s.doc().unwrap().doc.item(id).unwrap().path.is_closed(), "two points can't stay closed");
let r = s.execute("path.deleteAnchor", &json!({"id": id.0, "anchor": 0})).unwrap();
assert_eq!(r["deleted"], true);
assert!(s.doc().unwrap().doc.item(id).is_none());
}
}

View File

@ -1,6 +1,7 @@
//! The command registry. Ids follow InDesign's menu structure.
mod anchored;
pub mod anchors;
mod datamerge;
mod edit;
mod export;
@ -124,6 +125,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(export::specs());
v.extend(edit::specs());
v.extend(object::specs());
v.extend(anchors::specs());
v.extend(text::specs());
v.extend(style::specs());
v.extend(table::specs());

102
crates/tools/src/anchors.rs Normal file
View File

@ -0,0 +1,102 @@
//! Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (Shift+C): they act on
//! the selected paths, or the path under the pointer when nothing is selected.
use designcraft_doc::ItemId;
use designcraft_geom::Point;
use serde_json::json;
use crate::select::anchor_at_in;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Add,
Delete,
Convert,
}
pub struct AnchorTool {
kind: Kind,
/// Convert: the anchor pressed on (id, subpath, anchor), the press point, whether it dragged.
press: Option<(u64, usize, usize, Point, bool)>,
}
impl AnchorTool {
pub fn new(kind: Kind) -> Self {
Self { kind, press: None }
}
}
/// The paths the tool works on.
fn targets(cx: &ToolContext, p: Point) -> Vec<ItemId> {
if cx.selection.items.is_empty() { cx.hit(p).map(|(_, id)| vec![id]).unwrap_or_default() } else { cx.selection.items.clone() }
}
impl Tool for AnchorTool {
fn id(&self) -> &'static str {
match self.kind {
Kind::Add => "addAnchor",
Kind::Delete => "deleteAnchor",
Kind::Convert => "convertDirection",
}
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match (self.kind, ev.kind) {
(Kind::Add, PointerKind::Down) => {
let Some((_, sp)) = cx.layout.spread_at(p) else { return vec![] };
// The first target whose outline passes near the pointer.
for id in targets(cx, p) {
let Some(it) = cx.doc.item(id) else { continue };
let Some(xf) = cx.item_canvas_xf(id) else { continue };
let inner = (xf * it.xf).inverse() * p;
let scale = (xf * it.xf).inverse().determinant().abs().sqrt();
if it.path.nearest(inner).is_some_and(|n| n.4 <= cx.tol(5.0) * scale) {
return vec![Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)}))];
}
}
vec![]
}
(Kind::Delete, PointerKind::Down) => match anchor_at_in(cx, &targets(cx, p), p) {
Some((id, si, ai, None)) => vec![Action::Exec("path.deleteAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))],
_ => vec![],
},
(Kind::Convert, PointerKind::Down) => {
if let Some((id, si, ai, None)) = anchor_at_in(cx, &targets(cx, p), p) {
self.press = Some((id, si, ai, p, false));
}
vec![]
}
(Kind::Convert, PointerKind::Drag) => {
let Some((id, si, ai, start, dragged)) = self.press else { return vec![] };
if !dragged && (p - start).hypot() < cx.tol(3.0) {
return vec![];
}
let Some((_, sp)) = cx.layout.spread_at(p) else { return vec![] };
let mut out = vec![];
if !dragged {
out.push(Action::Begin("Convert Direction Point".into()));
self.press = Some((id, si, ai, start, true));
}
out.push(Action::Preview("path.convertAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai, "to": [sp.x, sp.y]})));
out
}
(Kind::Convert, PointerKind::Up) => match self.press.take() {
Some((_, _, _, _, true)) => vec![Action::Commit],
// A click toggles smooth ↔ corner.
Some((id, si, ai, _, false)) => vec![Action::Exec("path.convertAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))],
None => vec![],
},
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Pen
}
fn busy(&self) -> bool {
self.press.is_some_and(|p| p.4)
}
}

View File

@ -8,6 +8,7 @@
//! Pointer positions are **canvas** coordinates (see [`layout::CanvasLayout`]).
#![forbid(unsafe_code)]
mod anchors;
pub mod catalog;
mod frame;
mod gradient;
@ -259,6 +260,9 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"type" => Box::new(text::TypeTool::default()),
"pen" => Box::new(pen::PenTool::default()),
"gradientSwatch" => Box::new(gradient::GradientTool::default()),
"addAnchor" => Box::new(anchors::AnchorTool::new(anchors::Kind::Add)),
"deleteAnchor" => Box::new(anchors::AnchorTool::new(anchors::Kind::Delete)),
"convertDirection" => Box::new(anchors::AnchorTool::new(anchors::Kind::Convert)),
"rotate" => Box::new(xform::XformTool::new("rotate")),
"scale" => Box::new(xform::XformTool::new("scale")),
"shear" => Box::new(xform::XformTool::new("shear")),

View File

@ -23,8 +23,13 @@ enum Drag {
/// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle).
pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> {
anchor_at_in(cx, &cx.selection.items, p)
}
/// [`anchor_at`] over the given items.
pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> {
let tol = cx.tol(5.0);
for id in &cx.selection.items {
for id in ids {
let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue };
let m = xf * it.xf;
for (si, sp) in it.path.subpaths.iter().enumerate() {

View File

@ -104,3 +104,41 @@ fn gradient_tool_drag_sets_the_vector() {
}
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 220.0 + off.x, 153.0 + off.y)), vec![Action::Commit]);
}
#[test]
fn anchor_tools_emit_path_commands() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "", ParaFormat::default()).unwrap();
let (s, c, l) = (Selection::items(vec![fid]), Cache::new(), CanvasLayout::new(&d, false));
let cx = ctx(&d, &s, &c, &l);
let off = l.offset(SpreadRef::Doc(0));
let at = |x: f64, y: f64| (x + off.x, y + off.y);
let ev = |k, (x, y): (f64, f64)| PointerEvent::new(k, x, y);
let mut add = create("addAnchor");
match &add.pointer(&cx, &ev(PointerKind::Down, at(150.0, 101.0)))[..] {
[Action::Exec(cmd, p)] => {
assert_eq!(cmd, "path.addAnchor");
assert_eq!(p["at"], serde_json::json!([150.0, 101.0]));
}
other => panic!("{other:?}"),
}
assert!(add.pointer(&cx, &ev(PointerKind::Down, at(150.0, 150.0))).is_empty(), "inside, away from the edge");
let mut del = create("deleteAnchor");
assert_eq!(
del.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))),
vec![Action::Exec("path.deleteAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 1}))]
);
// Convert: click toggles, drag pulls out handles.
let mut conv = create("convertDirection");
conv.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0)));
assert_eq!(
conv.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))),
vec![Action::Exec("path.convertAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 0}))]
);
conv.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0)));
let a = conv.pointer(&cx, &ev(PointerKind::Drag, at(120.0, 90.0)));
assert_eq!(a[0], Action::Begin("Convert Direction Point".into()));
assert!(matches!(&a[1], Action::Preview(c, p) if c == "path.convertAnchor" && p["to"] == serde_json::json!([120.0, 90.0])));
assert_eq!(conv.pointer(&cx, &ev(PointerKind::Up, at(120.0, 90.0))), vec![Action::Commit]);
}

View File

@ -656,7 +656,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
painter.rect_stroke(gb, 0.0, Stroke::new(1.0, Color32::from_rgb(196, 111, 43)), StrokeKind::Middle);
}
}
let direct = matches!(app.session.tool_id(), "directSelection" | "pen");
let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection");
if direct {
for id in &sel.items {
let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };

View File

@ -9,7 +9,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 93 | 9 | 0 | 96% |
| P0 | 102 | 94 | 8 | 0 | 96% |
| P1 | 97 | 33 | 23 | 41 | 46% |
| P2 | 76 | 8 | 7 | 61 | 15% |
@ -18,7 +18,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Application shell & workspace | 65% |
| Documents, pages, spreads | 71% |
| Layers | 74% |
| Frames, shapes & paths | 74% |
| Frames, shapes & paths | 76% |
| Transform | 78% |
| Fill, stroke, colour | 69% |
| Effects & transparency | 50% |
@ -35,7 +35,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Undo, history, saving | 100% |
| Accessibility | 20% |
Open P0 items: Dockable panels, Preferences, Pen, add/delete anchor, convert direction, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance.
Open P0 items: Dockable panels, Preferences, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance.
## Rows
@ -98,7 +98,7 @@ Open P0 items: Dockable panels, Preferences, Pen, add/delete anchor, convert dir
| Frames, shapes & paths | Rectangle/Ellipse/Polygon shapes | P0 | D |
| Frames, shapes & paths | Polygon settings | P0 | D |
| Frames, shapes & paths | Line tool | P0 | D |
| Frames, shapes & paths | Pen, add/delete anchor, convert direction | P0 | P |
| Frames, shapes & paths | Pen, add/delete anchor, convert direction | P0 | D |
| Frames, shapes & paths | Pencil, Smooth, Erase | P1 | M |
| Frames, shapes & paths | Scissors | P1 | M |
| Frames, shapes & paths | Content type | P0 | D |