diff --git a/ROADMAP.md b/ROADMAP.md index 0778ca8..acd6c1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 254486c..6427450 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -37,9 +37,78 @@ pub fn specs() -> Vec { 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 { + 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 = 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 { 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 = (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 = (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 = (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(); diff --git a/crates/geom/src/edit_path.rs b/crates/geom/src/edit_path.rs new file mode 100644 index 0000000..2678ba4 --- /dev/null +++ b/crates/geom/src/edit_path.rs @@ -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 { + 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 { + let (seg, t) = at; + let c = sp.segment(seg); + let left = c.subsegment(0.0..t); + let mut out: Vec = 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 { + 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 = 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) -> Vec { + 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 { + 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 = 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::>(), 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::>(), 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); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 2e17785..e6520f4 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod corners; +pub mod edit_path; pub mod freehand; pub mod hit; pub mod path; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 36b4a01..f26b21e 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -262,6 +262,8 @@ pub fn create(id: &str) -> Box { "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)), diff --git a/crates/tools/src/pencil.rs b/crates/tools/src/pencil.rs index 75f032a..8abb319 100644 --- a/crates/tools/src/pencil.rs +++ b/crates/tools/src/pencil.rs @@ -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)>, +} + +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 { + 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 { + 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() + } +} diff --git a/docs/parity.md b/docs/parity.md index bbfa06c..e9879e0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |