mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
D: Pencil tool — freehand strokes to smooth paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0f86308791
commit
cc33d0ba15
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod corners;
|
||||
pub mod freehand;
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
pub mod pathfinder;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user