D: Pencil tool — freehand strokes to smooth paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 16:30:59 -04:00
co-authored by Claude Opus 5.5
parent 0f86308791
commit cc33d0ba15
5 changed files with 217 additions and 0 deletions
+116
View File
@@ -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<Point> {
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<Anchor> {
// Drop repeated samples first.
let mut pts: Vec<Point> = 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<Point> = (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<Point> = (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<Point> = (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);
}
}
+1
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
pub mod corners;
pub mod freehand;
pub mod hit;
pub mod path;
pub mod pathfinder;
+2
View File
@@ -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<dyn Tool> {
"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)),
+73
View File
@@ -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<Point>)>,
}
impl Tool for PencilTool {
fn id(&self) -> &'static str {
"pencil"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
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<Point> = 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<Overlay> {
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()
}
}
+25
View File
@@ -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:?}"),
}
}