mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 05:36:46 -06:00
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:
co-authored by
Claude Opus 5.5
parent
66ab252044
commit
349525bae8
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user