D: Smooth and Erase tools (path.smooth, path.erase)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 20:39:23 -04:00
parent b10a06f7b8
commit 4e874efbb0
7 changed files with 333 additions and 4 deletions

View File

@ -41,7 +41,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase are still to come.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.

View File

@ -37,9 +37,78 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
convert_anchor
),
cmd!(
"path.erase",
"Erase",
[],
None,
"{id, points: [[x,y], …] (spread coords: the Erase tool's drag), tolerance?: pt (default 4)} — removes the stretch of path the drag ran along",
has_doc,
|s, p| erase_or_smooth(s, p, false)
),
cmd!(
"path.smooth",
"Smooth",
[],
None,
"{id, points: [[x,y], …] (spread coords), tolerance?: pt (default 4)} — re-fits the stretch the drag ran along with fewer, smoother anchors",
has_doc,
|s, p| erase_or_smooth(s, p, true)
),
]
}
/// Erase / Smooth: the stretch of one subpath touched by the drag.
fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
let cmd = if smooth { "path.smooth" } else { "path.erase" };
let id = super::id_param(p, "id").ok_or_else(|| bad(cmd, "missing id"))?;
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);
let pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
s.edit(|d, _| {
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let scale = inv.determinant().abs().sqrt().max(1e-9);
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
// Where the drag touched the path, per subpath.
let mut hits: Vec<(usize, usize, f64)> = Vec::new();
for q in &pts {
if let Some((si, seg, t, _, dist)) = it.path.nearest(inv * *q)
&& dist <= tol * scale
{
hits.push((si, seg, t));
}
}
let Some(si) =
(0..it.path.subpaths.len()).max_by_key(|i| hits.iter().filter(|h| h.0 == *i).count()).filter(|i| hits.iter().any(|h| h.0 == *i))
else {
return Err(bad(cmd, "the drag didn't touch the path"));
};
let on: Vec<(usize, f64)> = hits.iter().filter(|h| h.0 == si).map(|h| (h.1, h.2)).collect();
let k = |a: &(usize, f64)| a.0 as f64 + a.1;
let a = *on.iter().min_by(|x, y| k(x).total_cmp(&k(y))).expect("hits");
let b = *on.iter().max_by(|x, y| k(x).total_cmp(&k(y))).expect("hits");
let sp = it.path.subpaths[si].clone();
if smooth {
it.path.subpaths[si] = designcraft_geom::edit_path::smooth(&sp, a, b, tol * scale);
} else {
let parts = designcraft_geom::edit_path::erase(&sp, a, b);
it.path.subpaths.remove(si);
for (k, part) in parts.into_iter().enumerate() {
it.path.subpaths.insert(si + k, part);
}
}
as_path(&mut it.shape);
if it.path.subpaths.is_empty() {
d.remove_item(id)?;
return Ok(json!({"deleted": true}));
}
Ok(json!({"subpaths": it.path.subpaths.len()}))
})
}
/// Spread → item-space transform of `id`.
fn to_inner(d: &Document, id: ItemId) -> Option<Affine> {
let loc = d.find(id)?;
@ -183,6 +252,23 @@ pub fn anchor_near(d: &Document, id: ItemId, at: Point, tol: f64) -> Option<(usi
mod tests {
use super::*;
#[test]
fn erase_and_smooth_along_a_drag() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let pts: Vec<Value> = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect();
let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap();
let n = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
let drag: Vec<Value> = (0..=12).map(|i| json!([150.0 + i as f64 * 20.0, 303.0])).collect();
s.execute("path.smooth", &json!({"id": id, "points": drag})).unwrap();
let after = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
assert!(after < n, "{after} < {n}");
let drag: Vec<Value> = (0..=5).map(|i| json!([200.0 + i as f64 * 20.0, 303.0])).collect();
let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap();
assert_eq!(r["subpaths"], 2, "a gap in the middle");
assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err());
}
#[test]
fn add_convert_delete_anchor_points() {
let mut s = Session::new();

View File

@ -0,0 +1,171 @@
//! Path surgery for the Erase and Smooth tools: cut a subpath at curve parameters, remove the
//! span between two places, re-fit a stretch more smoothly.
use kurbo::{ParamCurve, Point};
use crate::path::{Anchor, AnchorKind, SubPath};
/// A place on a subpath: segment index and parameter in it.
pub type At = (usize, f64);
fn key(a: At) -> f64 {
a.0 as f64 + a.1
}
/// Anchors of an open walk around `sp` (a closed subpath gets its first anchor again at the end).
fn walk(sp: &SubPath) -> Vec<Anchor> {
let mut v = sp.anchors.clone();
if sp.closed && !v.is_empty() {
v.push(v[0]);
}
v
}
/// The part of the walk `w` before `at`.
fn prefix(sp: &SubPath, w: &[Anchor], at: At) -> Vec<Anchor> {
let (seg, t) = at;
let c = sp.segment(seg);
let left = c.subsegment(0.0..t);
let mut out: Vec<Anchor> = w[..=seg].to_vec();
if let Some(l) = out.last_mut() {
l.h_out = left.p1;
}
out.push(Anchor::with_handles(left.p3, left.p2, left.p3));
out
}
/// The part of the walk `w` after `at`.
fn suffix(sp: &SubPath, w: &[Anchor], at: At) -> Vec<Anchor> {
let (seg, t) = at;
let c = sp.segment(seg);
let right = c.subsegment(t..1.0);
let mut out = vec![Anchor::with_handles(right.p0, right.p0, right.p1)];
let mut rest: Vec<Anchor> = w[seg + 1..].to_vec();
if let Some(f) = rest.first_mut() {
f.h_in = right.p2;
}
out.extend(rest);
out
}
fn tidy(mut v: Vec<Anchor>) -> Vec<Anchor> {
for a in &mut v {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
v.dedup_by(|b, a| {
(a.p - b.p).hypot() < 1e-6 && {
a.h_out = b.h_out;
true
}
});
v
}
/// Remove the stretch of `sp` between `a` and `b` (either order). An open path leaves up to two
/// pieces; a closed one opens into one.
pub fn erase(sp: &SubPath, a: At, b: At) -> Vec<SubPath> {
let (a, b) = if key(a) <= key(b) { (a, b) } else { (b, a) };
let w = walk(sp);
if sp.closed {
// From b round to a, through the start.
let mut tail = suffix(sp, &w, b);
let head = prefix(sp, &w, a);
if let (Some(last), Some(first)) = (tail.last_mut(), head.first()) {
last.h_out = first.h_out;
}
tail.extend(head.into_iter().skip(1));
let t = tidy(tail);
return if t.len() >= 2 { vec![SubPath::new(t, false)] } else { vec![] };
}
[tidy(prefix(sp, &w, a)), tidy(suffix(sp, &w, b))]
.into_iter()
.filter(|p| p.len() >= 2 && length(p) > 1e-3)
.map(|p| SubPath::new(p, false))
.collect()
}
fn length(a: &[Anchor]) -> f64 {
a.windows(2).map(|w| (w[1].p - w[0].p).hypot()).sum()
}
/// Re-fit the stretch between `a` and `b` (either order) through fewer, smoother anchors.
pub fn smooth(sp: &SubPath, a: At, b: At, tol: f64) -> SubPath {
let (a, b) = if key(a) <= key(b) { (a, b) } else { (b, a) };
// Samples along the stretch.
let mut pts: Vec<Point> = Vec::new();
let steps = 64;
let (k0, k1) = (key(a), key(b));
for i in 0..=steps {
let k = k0 + (k1 - k0) * i as f64 / steps as f64;
let seg = (k.floor() as usize).min(sp.segment_count().saturating_sub(1));
let t = (k - seg as f64).clamp(0.0, 1.0);
pts.push(sp.segment(seg).eval(t));
}
let mut mid = crate::freehand::fit(&pts, tol.max(0.5) * 2.0, false);
if mid.len() < 2 {
return sp.clone();
}
let w = walk(sp);
let mut head = prefix(sp, &w, a);
let tail = suffix(sp, &w, b);
// The fitted ends meet the untouched parts smoothly.
if let Some(h) = head.last_mut() {
h.h_out = mid[0].h_out;
}
head.pop();
let n = mid.len();
mid[n - 1].h_out = tail[0].h_out;
head.extend(mid);
head.extend(tail.into_iter().skip(1));
let mut out = tidy(head);
if sp.closed {
// The walk repeated the first anchor at the end.
if out.len() > 2 && (out[0].p - out[out.len() - 1].p).hypot() < 1e-6 {
let last = out.pop().expect("len");
out[0].h_in = last.h_in;
}
}
for x in &mut out {
if x.has_in() && x.has_out() && x.kind == AnchorKind::Corner {
*x = Anchor::with_handles(x.p, x.h_in, x.h_out);
}
}
SubPath::new(out, sp.closed)
}
#[cfg(test)]
mod tests {
use super::*;
fn line(n: usize) -> SubPath {
SubPath::polyline(&(0..=n).map(|i| Point::new(i as f64 * 10.0, 0.0)).collect::<Vec<_>>(), false)
}
#[test]
fn erase_middle_of_open_and_closed_paths() {
let sp = line(10);
let parts = erase(&sp, (3, 0.5), (6, 0.5));
assert_eq!(parts.len(), 2);
assert!((parts[0].anchors.last().unwrap().p.x - 35.0).abs() < 1e-9);
assert!((parts[1].anchors[0].p.x - 65.0).abs() < 1e-9);
assert!((parts[1].anchors.last().unwrap().p.x - 100.0).abs() < 1e-9);
// Erasing the very end leaves one piece.
assert_eq!(erase(&sp, (8, 0.0), (9, 1.0)).len(), 1);
let sq = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
let open = erase(&sq, (0, 0.25), (0, 0.75));
assert_eq!(open.len(), 1);
assert!(!open[0].closed);
// Parameters are the segment's own (a straight cubic isn't linear in t).
assert!((open[0].anchors[0].p - sq.segment(0).eval(0.75)).hypot() < 1e-9);
assert!((open[0].anchors.last().unwrap().p - sq.segment(0).eval(0.25)).hypot() < 1e-9);
}
#[test]
fn smooth_reduces_a_zigzag() {
let zig = SubPath::polyline(&(0..=20).map(|i| Point::new(i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 3.0 })).collect::<Vec<_>>(), false);
let s = smooth(&zig, (2, 0.0), (18, 0.0), 4.0);
assert!(s.anchors.len() < zig.anchors.len(), "{}", s.anchors.len());
assert!((s.anchors[0].p - zig.anchors[0].p).hypot() < 1e-9);
assert!((s.anchors.last().unwrap().p - zig.anchors.last().unwrap().p).hypot() < 1e-9);
}
}

View File

@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
pub mod corners;
pub mod edit_path;
pub mod freehand;
pub mod hit;
pub mod path;

View File

@ -262,6 +262,8 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"pen" => Box::new(pen::PenTool::default()),
"gradientSwatch" => Box::new(gradient::GradientTool::default()),
"pencil" => Box::new(pencil::PencilTool::default()),
"smooth" => Box::new(pencil::PathDragTool::new(false)),
"erase" => Box::new(pencil::PathDragTool::new(true)),
"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)),

View File

@ -71,3 +71,72 @@ impl Tool for PencilTool {
self.stroke.is_some()
}
}
/// Smooth and Erase tools (Pencil group): drag along a selected path.
pub struct PathDragTool {
erase: bool,
/// Target path, canvas offset of its spread, and the drag in spread coordinates.
drag: Option<(u64, designcraft_geom::Vec2, Vec<Point>)>,
}
impl PathDragTool {
pub fn new(erase: bool) -> Self {
Self { erase, drag: None }
}
}
impl Tool for PathDragTool {
fn id(&self) -> &'static str {
if self.erase { "erase" } else { "smooth" }
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
// The selected path, or the one under the pointer.
let id = cx.selection.items.first().copied().or_else(|| cx.hit(ev.pos).map(|h| h.1));
let Some(id) = id else { return vec![] };
let Some(loc) = cx.doc.find(id) else { return vec![] };
let off = cx.layout.offset(loc.spread);
self.drag = Some((id.0, off, vec![ev.pos - off]));
vec![]
}
PointerKind::Drag => {
if let Some((_, off, pts)) = &mut self.drag {
pts.push(ev.pos - *off);
}
vec![]
}
PointerKind::Up => {
let Some((id, off, mut pts)) = self.drag.take() else { return vec![] };
pts.push(ev.pos - off);
let points: Vec<_> = pts.iter().map(|p| json!([p.x, p.y])).collect();
let cmd = if self.erase { "path.erase" } else { "path.smooth" };
vec![Action::Exec(cmd.into(), json!({"id": id, "points": points, "tolerance": cx.tol(6.0)}))]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let Some((_, off, pts)) = &self.drag else { return vec![] };
let mut path = BezPath::new();
for (i, p) in pts.iter().enumerate() {
let p = *p + *off;
if i == 0 {
path.move_to(p);
} else {
path.line_to(p);
}
}
vec![Overlay::Path { path, color: if self.erase { [220, 60, 60] } else { [60, 120, 220] }, dashed: true }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Pen
}
fn busy(&self) -> bool {
self.drag.is_some()
}
}

View File

@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 63 | 26 | 8 | 78% |
| P1 | 97 | 64 | 25 | 8 | 79% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| Area | Parity |
@ -18,7 +18,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Application shell & workspace | 80% |
| Documents, pages, spreads | 84% |
| Layers | 95% |
| Frames, shapes & paths | 87% |
| Frames, shapes & paths | 88% |
| Transform | 93% |
| Fill, stroke, colour | 81% |
| Effects & transparency | 50% |
@ -99,7 +99,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Frames, shapes & paths | Polygon settings | P0 | D |
| Frames, shapes & paths | Line tool | P0 | D |
| Frames, shapes & paths | Pen, add/delete anchor, convert direction | P0 | D |
| Frames, shapes & paths | Pencil, Smooth, Erase | P1 | P |
| Frames, shapes & paths | Pencil, Smooth, Erase | P1 | D |
| Frames, shapes & paths | Scissors | P1 | D |
| Frames, shapes & paths | Content type | P0 | D |
| Frames, shapes & paths | Convert Shape | P1 | P |