From cc33d0ba15ec5a21f6ed0cd84553fc5e282e8d61 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:30:59 -0400 Subject: [PATCH] =?UTF-8?q?D:=20Pencil=20tool=20=E2=80=94=20freehand=20str?= =?UTF-8?q?okes=20to=20smooth=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/freehand.rs | 116 ++++++++++++++++++++++++++++++++++++ crates/geom/src/lib.rs | 1 + crates/tools/src/lib.rs | 2 + crates/tools/src/pencil.rs | 73 +++++++++++++++++++++++ crates/tools/src/tests.rs | 25 ++++++++ 5 files changed, 217 insertions(+) create mode 100644 crates/geom/src/freehand.rs create mode 100644 crates/tools/src/pencil.rs diff --git a/crates/geom/src/freehand.rs b/crates/geom/src/freehand.rs new file mode 100644 index 000000000..53ddf9611 --- /dev/null +++ b/crates/geom/src/freehand.rs @@ -0,0 +1,116 @@ +//! Freehand drawing (Pencil tool): a dragged polyline simplified to a few smooth anchors. + +use crate::Point; +use crate::path::{Anchor, AnchorKind}; + +/// Ramer–Douglas–Peucker: the points that keep the polyline within `tol`. +fn simplify(pts: &[Point], tol: f64) -> Vec { + if pts.len() < 3 { + return pts.to_vec(); + } + let mut keep = vec![false; pts.len()]; + keep[0] = true; + keep[pts.len() - 1] = true; + let mut stack = vec![(0, pts.len() - 1)]; + while let Some((a, b)) = stack.pop() { + let (pa, pb) = (pts[a], pts[b]); + let d = pb - pa; + let len = d.hypot(); + let mut best = (0.0, 0); + for (i, p) in pts.iter().enumerate().take(b).skip(a + 1) { + let dist = if len < 1e-9 { (*p - pa).hypot() } else { ((*p - pa).cross(d) / len).abs() }; + if dist > best.0 { + best = (dist, i); + } + } + if best.0 > tol { + keep[best.1] = true; + stack.push((a, best.1)); + stack.push((best.1, b)); + } + } + pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect() +} + +/// Smooth anchors through a freehand stroke: simplified within `tol`, with Catmull–Rom handles +/// (a third of the way to the neighbours). Ends are corners unless `closed`. +pub fn fit(points: &[Point], tol: f64, closed: bool) -> Vec { + // Drop repeated samples first. + let mut pts: Vec = Vec::with_capacity(points.len()); + for p in points { + if pts.last().is_none_or(|q: &Point| (*q - *p).hypot() > 1e-6) { + pts.push(*p); + } + } + let mut pts = simplify(&pts, tol.max(1e-3)); + if closed && pts.len() > 2 && (pts[0] - pts[pts.len() - 1]).hypot() < tol * 4.0 { + pts.pop(); + } + let n = pts.len(); + if n < 2 { + return pts.into_iter().map(Anchor::corner).collect(); + } + (0..n) + .map(|i| { + let p = pts[i]; + let prev = if i > 0 { + Some(pts[i - 1]) + } else if closed { + Some(pts[n - 1]) + } else { + None + }; + let next = if i + 1 < n { + Some(pts[i + 1]) + } else if closed { + Some(pts[0]) + } else { + None + }; + match (prev, next) { + (Some(a), Some(b)) => { + let t = (b - a) / 6.0; + // Shorter handles toward a closer neighbour keep curves from overshooting. + let (la, lb) = ((p - a).hypot(), (b - p).hypot()); + let k = |l: f64| if la + lb > 1e-9 { 2.0 * l / (la + lb) } else { 1.0 }; + Anchor { p, h_in: p - t * k(la), h_out: p + t * k(lb), kind: AnchorKind::Smooth } + } + _ => Anchor::corner(p), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn freehand_line_and_curve() { + // A wobbly straight stroke becomes two corners. + let line: Vec = (0..=100).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.3 } else { -0.3 })).collect(); + let a = fit(&line, 2.0, false); + assert_eq!(a.len(), 2); + assert_eq!(a[0].kind, AnchorKind::Corner); + // A semicircle keeps a handful of smooth anchors. + let arc: Vec = (0..=180) + .map(|d| { + let t = (d as f64).to_radians(); + Point::new(100.0 * t.cos(), 100.0 * t.sin()) + }) + .collect(); + let a = fit(&arc, 1.0, false); + assert!(a.len() >= 4 && a.len() <= 20, "{}", a.len()); + assert!(a[1..a.len() - 1].iter().all(|x| x.kind == AnchorKind::Smooth)); + // Closed: the end that meets the start is merged. + let circle: Vec = (0..=360) + .map(|d| { + let t = (d as f64).to_radians(); + Point::new(50.0 * t.cos(), 50.0 * t.sin()) + }) + .collect(); + let c = fit(&circle, 1.0, true); + assert!(c.iter().all(|x| x.kind == AnchorKind::Smooth)); + assert!((c[0].p - c[c.len() - 1].p).hypot() > 1.0); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 4d987c02e..4fce052b4 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 freehand; pub mod hit; pub mod path; pub mod pathfinder; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 7ef784088..8be96f46c 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -15,6 +15,7 @@ mod gradient; pub mod layout; mod nav; mod pen; +mod pencil; pub mod select; pub mod snap; mod text; @@ -260,6 +261,7 @@ pub fn create(id: &str) -> Box { "type" => Box::new(text::TypeTool::default()), "pen" => Box::new(pen::PenTool::default()), "gradientSwatch" => Box::new(gradient::GradientTool::default()), + "pencil" => Box::new(pencil::PencilTool::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)), diff --git a/crates/tools/src/pencil.rs b/crates/tools/src/pencil.rs new file mode 100644 index 000000000..75f032a0d --- /dev/null +++ b/crates/tools/src/pencil.rs @@ -0,0 +1,73 @@ +//! Pencil tool (N): draw freehand; the stroke becomes a smooth path. Alt while releasing closes +//! it. + +use designcraft_doc::SpreadRef; +use designcraft_geom::{BezPath, Point}; +use serde_json::json; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; + +#[derive(Default)] +pub struct PencilTool { + /// Spread, canvas offset of that spread, and the canvas points so far. + stroke: Option<(SpreadRef, designcraft_geom::Vec2, Vec)>, +} + +impl Tool for PencilTool { + fn id(&self) -> &'static str { + "pencil" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + let Some((sr, _)) = cx.layout.spread_at(ev.pos) else { return vec![] }; + self.stroke = Some((sr, cx.layout.offset(sr), vec![ev.pos])); + vec![] + } + PointerKind::Drag => { + if let Some((_, _, pts)) = &mut self.stroke + && pts.last().is_none_or(|q| (*q - ev.pos).hypot() >= cx.tol(1.0)) + { + pts.push(ev.pos); + } + vec![] + } + PointerKind::Up => { + let Some((sr, off, mut pts)) = self.stroke.take() else { return vec![] }; + pts.push(ev.pos); + let spread: Vec = pts.iter().map(|p| *p - off).collect(); + // Simplify to about two screen pixels. + let anchors = designcraft_geom::freehand::fit(&spread, cx.tol(2.0), ev.mods.alt); + if anchors.len() < 2 { + return vec![]; + } + let a: Vec<_> = + anchors.iter().map(|a| json!({"p": [a.p.x, a.p.y], "in": [a.h_in.x, a.h_in.y], "out": [a.h_out.x, a.h_out.y]})).collect(); + vec![Action::Exec("path.create".into(), json!({"spread": sr, "anchors": a, "closed": ev.mods.alt}))] + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + let Some((_, _, pts)) = &self.stroke else { return vec![] }; + let mut path = BezPath::new(); + for (i, p) in pts.iter().enumerate() { + if i == 0 { + path.move_to(*p); + } else { + path.line_to(*p); + } + } + vec![Overlay::Path { path, color: [0, 0, 0], dashed: false }] + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Pen + } + + fn busy(&self) -> bool { + self.stroke.is_some() + } +} diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index c693b5812..ba50a4e3f 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -155,3 +155,28 @@ fn scissors_tool_cuts_where_clicked() { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0 + off.x, 100.0 + off.y)); assert_eq!(a, vec![Action::Exec("path.split".into(), serde_json::json!({"id": fid.0, "at": [150.0, 100.0]}))]); } + +#[test] +fn pencil_draws_a_smooth_path() { + let d = Document::new(&NewDocument::default()); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.offset(SpreadRef::Doc(0)); + let mut t = create("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0 + off.x, 100.0 + off.y)); + for i in 1..=90 { + let a = (i as f64 * 2.0).to_radians(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0 + 50.0 * a.sin() + off.x, 100.0 + 50.0 * (1.0 - a.cos()) + off.y)); + } + assert!(t.busy()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0 + off.x, 150.0 + off.y)); + match &a[..] { + [Action::Exec(cmd, p)] => { + assert_eq!(cmd, "path.create"); + let n = p["anchors"].as_array().unwrap().len(); + assert!((3..30).contains(&n), "{n} anchors"); + assert_eq!(p["anchors"][0]["p"], serde_json::json!([100.0, 100.0])); + } + other => panic!("{other:?}"), + } +}