From fa2d901a7e39baf6309da3f0e888d17ecfff0798 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 09:10:44 -0400 Subject: [PATCH] =?UTF-8?q?M2.9:=20Shaper=20tool=20=E2=80=94=20rough=20rec?= =?UTF-8?q?tangles,=20ellipses,=20triangles/polygons/diamonds=20and=20line?= =?UTF-8?q?s=20become=20clean=20live=20shapes=20(geom::recognize),=20squar?= =?UTF-8?q?es/circles=20snap;=20a=20scribble=20deletes=20the=20objects=20i?= =?UTF-8?q?t=20crosses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/engine/src/tests.rs | 37 ++++++ crates/geom/src/lib.rs | 1 + crates/geom/src/recognize.rs | 237 +++++++++++++++++++++++++++++++++++ crates/tools/src/extra.rs | 83 +++++++++++- 5 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 crates/geom/src/recognize.rs diff --git a/ROADMAP.md b/ROADMAP.md index eb01b354..90cbcc3e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,7 +88,7 @@ are counted bottom-up. | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 | -| Drawing tools | 7 | 80% | Shaper, pen/pencil modifier nuances, Touch Type | 15–20 | +| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 75% | freeform gradients, swatch libraries (original), Tile Edge Color | 10–15 | | Strokes, brushes, width profiles | 5 | 65% | brush options depth, brush libraries (generated in code) | 15–25 | @@ -118,8 +118,8 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c (integration, review and shared files such as `menus.rs` serialize some work): **~85–140 h** to feature parity, **~115–190 h** to full parity. -_Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 68 of 79 -tools implemented (missing: Shaper, Slice ×2, Touch Type, vertical type ×3, Print Tiling); 31 panels of ~45; 44 live +_Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 69 of 79 +tools implemented (missing: Slice ×2, Touch Type, vertical type ×3, Print Tiling); 31 panels of ~45; 44 live effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests. _Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e568fcfe..7425fdd7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -439,3 +439,40 @@ fn snap_to_pixel_rounds_drawing_and_moves() { let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); assert_eq!((b.x0, b.y0), (17.0, 16.0)); } + +#[test] +fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() { + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let stroke = |s: &mut Session, pts: &[(f64, f64)]| { + s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1), v).unwrap(); + for p in &pts[1..] { + s.pointer(&PointerEvent::new(PointerKind::Drag, p.0, p.1), v).unwrap(); + } + let l = pts[pts.len() - 1]; + s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1), v).unwrap(); + }; + // A rough rectangle. + let mut rect = vec![]; + for (a, b) in + [((100.0, 100.0), (300.0, 102.0)), ((300.0, 102.0), (298.0, 200.0)), ((298.0, 200.0), (101.0, 199.0)), ((101.0, 199.0), (103.0, 104.0))] + { + for i in 0..20 { + let t = i as f64 / 20.0; + rect.push((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)); + } + } + stroke(&mut s, &rect); + let id = s.doc().unwrap().selection.objects[0]; + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!( + matches!(n.kind, vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }), + "{:?}", + n.kind_label() + ); + // A zig-zag over it deletes it. + let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect(); + stroke(&mut s, &zig); + assert!(s.doc().unwrap().doc.node(id).is_none()); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index e3db5bd0..8145625d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -9,6 +9,7 @@ pub mod hit; pub mod path; +pub mod recognize; pub mod shapes; pub mod snap; diff --git a/crates/geom/src/recognize.rs b/crates/geom/src/recognize.rs new file mode 100644 index 00000000..eb4f27e5 --- /dev/null +++ b/crates/geom/src/recognize.rs @@ -0,0 +1,237 @@ +//! Shape recognition for the Shaper tool: a rough freehand stroke becomes a clean shape. +//! +//! The stroke is classified by how closed it is, how many corners survive a coarse polyline +//! simplification (Douglas–Peucker at a fraction of the stroke's size) and how well it fills its +//! bounding box. A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it +//! covers). + +use crate::{Point, Rect, Vec2}; + +/// What the Shaper recognised. +#[derive(Clone, Debug, PartialEq)] +pub enum Recognized { + Line { + a: Point, + b: Point, + }, + /// Axis-aligned rectangle (squares snap when the sides are within 10%). + Rectangle(Rect), + /// Axis-aligned ellipse in `rect` (circles snap when the axes are within 10%). + Ellipse(Rect), + /// Regular polygon (triangle, pentagon, hexagon…): centre, radius, side count and the rotation + /// (degrees) of the first vertex from straight up. + Polygon { + center: Point, + radius: f64, + sides: u32, + rotation: f64, + }, + /// A zig-zag over existing art: delete what it covers. + Scribble(Rect), +} + +fn bbox(pts: &[Point]) -> Rect { + pts.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)) +} + +fn path_len(pts: &[Point]) -> f64 { + pts.windows(2).map(|w| w[0].distance(w[1])).sum() +} + +fn dist_to_segment(p: Point, a: Point, b: Point) -> f64 { + let ab = b - a; + let l2 = ab.hypot2(); + if l2 < 1e-12 { + return p.distance(a); + } + let t = ((p - a).dot(ab) / l2).clamp(0.0, 1.0); + p.distance(a + ab * t) +} + +/// Douglas–Peucker simplification. +fn simplify(pts: &[Point], tol: f64) -> Vec { + if pts.len() < 3 { + return pts.to_vec(); + } + let (a, b) = (pts[0], pts[pts.len() - 1]); + let (i, d) = pts[1..pts.len() - 1] + .iter() + .enumerate() + .map(|(i, p)| (i + 1, dist_to_segment(*p, a, b))) + .fold((0, 0.0), |m, x| if x.1 > m.1 { x } else { m }); + if d <= tol { + return vec![a, b]; + } + let mut left = simplify(&pts[..=i], tol); + let right = simplify(&pts[i..], tol); + left.pop(); + left.extend(right); + left +} + +fn area(poly: &[Point]) -> f64 { + let n = poly.len(); + (0..n).map(|i| poly[i].to_vec2().cross(poly[(i + 1) % n].to_vec2())).sum::().abs() / 2.0 +} + +/// Number of sharp direction reversals (turns of more than 120°) along the stroke. +fn reversals(pts: &[Point], min_seg: f64) -> usize { + let s = simplify(pts, min_seg); + s.windows(3) + .filter(|w| { + let (u, v): (Vec2, Vec2) = (w[1] - w[0], w[2] - w[1]); + u.hypot() > min_seg && v.hypot() > min_seg && u.dot(v) / (u.hypot() * v.hypot()) < -0.5 + }) + .count() +} + +/// Recognise a freehand stroke (document points, in drawing order). +pub fn recognize(pts: &[Point]) -> Option { + if pts.len() < 2 { + return None; + } + let b = bbox(pts); + let size = b.width().max(b.height()); + if size < 2.0 { + return None; + } + let len = path_len(pts); + let (first, last) = (pts[0], pts[pts.len() - 1]); + // Scribble: several sharp reversals packed into a small area relative to the stroke length. + if reversals(pts, size * 0.08) >= 3 && len > 3.0 * size { + return Some(Recognized::Scribble(b)); + } + // Line: every point near the chord. + let chord = first.distance(last); + let max_dev = pts.iter().map(|p| dist_to_segment(*p, first, last)).fold(0.0, f64::max); + if chord > 0.5 * size && max_dev < 0.08 * chord.max(1.0) && len < 1.3 * chord { + return Some(Recognized::Line { a: first, b: last }); + } + // Closed shapes: the ends meet (within a fifth of the size). + if chord > 0.25 * size { + return None; + } + let mut closed: Vec = pts.to_vec(); + closed.push(first); + let mut corners = simplify(&closed, size * 0.12); + if corners.len() > 1 && corners[0].distance(corners[corners.len() - 1]) < size * 0.15 { + corners.pop(); + } + let fill = area(pts) / (b.width() * b.height()).max(1e-9); + let center = b.center(); + let square = |r: Rect| { + let (w, h) = (r.width(), r.height()); + if (w - h).abs() < 0.1 * w.max(h) { + let s = (w + h) / 2.0; + Rect::from_center_size(r.center(), (s, s)) + } else { + r + } + }; + match corners.len() { + 3 => { + let radius = corners.iter().map(|p| p.distance(center)).sum::() / 3.0; + // Rotation of the vertex nearest straight up (y grows downward). + let top = corners.iter().copied().min_by(|a, b| a.y.total_cmp(&b.y)).unwrap_or(center); + let rot = (top - center).atan2().to_degrees() + 90.0; + Some(Recognized::Polygon { center, radius, sides: 3, rotation: rot }) + } + 4 if fill > 0.86 => Some(Recognized::Rectangle(square(b))), + // A diamond: a square standing on a corner. + 4 if (0.4..0.62).contains(&fill) => { + let radius = corners.iter().map(|p| p.distance(center)).sum::() / 4.0; + Some(Recognized::Polygon { center, radius, sides: 4, rotation: 0.0 }) + } + n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size) => { + let radius = corners.iter().map(|p| p.distance(center)).sum::() / n as f64; + Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: 0.0 }) + } + _ if fill > 0.6 => Some(Recognized::Ellipse(square(b))), + _ => None, + } +} + +/// Do the stroke's points hug the straight edges between `corners` (rather than bulging like an +/// ellipse)? +fn is_polygonal(pts: &[Point], corners: &[Point], size: f64) -> bool { + let n = corners.len(); + let dev: f64 = pts.iter().map(|p| (0..n).map(|i| dist_to_segment(*p, corners[i], corners[(i + 1) % n])).fold(f64::MAX, f64::min)).sum::() + / pts.len() as f64; + dev < 0.02 * size +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A wobbly stroke through `corners` (closed), `k` samples per edge. + fn wobbly(corners: &[Point], k: usize, wobble: f64) -> Vec { + let mut out = vec![]; + let n = corners.len(); + for i in 0..n { + let (a, b) = (corners[i], corners[(i + 1) % n]); + for j in 0..k { + let t = j as f64 / k as f64; + let w = ((i * k + j) as f64 * 1.7).sin() * wobble; + out.push(a + (b - a) * t + Vec2::new(w, -w)); + } + } + out.push(corners[0] + Vec2::new(3.0, 2.0)); + out + } + + fn circle(c: Point, rx: f64, ry: f64, k: usize) -> Vec { + (0..=k) + .map(|i| { + let a = std::f64::consts::TAU * i as f64 / k as f64; + c + Vec2::new(rx * a.cos() + (a * 5.0).sin(), ry * a.sin()) + }) + .collect() + } + + #[test] + fn rough_rectangle_and_square() { + let r = wobbly(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)], 20, 2.0); + let Some(Recognized::Rectangle(b)) = recognize(&r) else { panic!("{:?}", recognize(&r)) }; + assert!((b.width() - 200.0).abs() < 8.0 && (b.height() - 100.0).abs() < 8.0); + let s = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 95.0), Point::new(0.0, 95.0)], 20, 1.0); + let Some(Recognized::Rectangle(b)) = recognize(&s) else { panic!() }; + assert!((b.width() - b.height()).abs() < 1e-9, "near-squares snap to squares"); + } + + #[test] + fn ellipse_circle_triangle_hexagon_line() { + assert!( + matches!(recognize(&circle(Point::new(100.0, 100.0), 80.0, 40.0, 72)), Some(Recognized::Ellipse(r)) if r.width() > 150.0 && r.height() < 100.0) + ); + assert!( + matches!(recognize(&circle(Point::new(100.0, 100.0), 50.0, 48.0, 72)), Some(Recognized::Ellipse(r)) if (r.width() - r.height()).abs() < 1e-9) + ); + let tri = wobbly(&[Point::new(100.0, 0.0), Point::new(200.0, 170.0), Point::new(0.0, 170.0)], 25, 1.5); + assert!(matches!(recognize(&tri), Some(Recognized::Polygon { sides: 3, rotation, .. }) if rotation.abs() < 5.0), "{:?}", recognize(&tri)); + let hex: Vec = (0..6) + .map(|i| { + let a = std::f64::consts::TAU * i as f64 / 6.0; + Point::new(100.0 + 80.0 * a.cos(), 100.0 + 80.0 * a.sin()) + }) + .collect(); + assert!(matches!(recognize(&wobbly(&hex, 15, 0.5)), Some(Recognized::Polygon { sides: 6, .. })), "{:?}", recognize(&wobbly(&hex, 15, 0.5))); + let line: Vec = (0..30).map(|i| Point::new(i as f64 * 10.0, i as f64 * 3.0 + (i as f64).sin())).collect(); + assert!(matches!(recognize(&line), Some(Recognized::Line { .. }))); + } + + #[test] + fn scribble_and_noise() { + let z: Vec = (0..40).map(|i| Point::new(100.0 + (i % 2) as f64 * 60.0, 100.0 + i as f64 * 2.0)).collect(); + assert!(matches!(recognize(&z), Some(Recognized::Scribble(_)))); + assert_eq!(recognize(&[Point::new(0.0, 0.0)]), None); + // An open curl is not a shape. + let curl: Vec = (0..60) + .map(|i| { + let a = i as f64 * 0.08; + Point::new(100.0 + 50.0 * a.cos(), 100.0 + 50.0 * a.sin()) + }) + .collect(); + assert_eq!(recognize(&curl), None); + } +} diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 0f99f7f1..66511f88 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -1,4 +1,4 @@ -//! Flare, Reshape and Graph tools. +//! Flare, Reshape, Shaper and Graph tools. //! //! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options), //! then a click sets the end point of the rings. Reshape: drag a point of a selected path; the @@ -14,6 +14,7 @@ pub fn create(id: &str) -> Option> { Some(match id { "flare" => Box::new(FlareTool::default()), "reshape" => Box::new(ReshapeTool::default()), + "shaper" => Box::new(ShaperTool::default()), id if vectorcraft_doc::GraphKind::parse(id).is_some() && id.ends_with("Graph") => Box::new(GraphTool::new(id)), _ => return None, }) @@ -215,6 +216,85 @@ impl Tool for ReshapeTool { } } +/// Shaper Tool: draw a rough shape and it becomes a clean live shape (rectangle, ellipse, +/// triangle/polygon, line); scribble over art to delete it. +#[derive(Default)] +pub struct ShaperTool { + points: Vec, +} + +impl Tool for ShaperTool { + fn id(&self) -> &'static str { + "shaper" + } + fn busy(&self) -> bool { + !self.points.is_empty() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + use vectorcraft_geom::recognize::{Recognized, recognize}; + match ev.kind { + PointerKind::Down => { + self.points = vec![ev.pos]; + vec![] + } + PointerKind::Drag => { + if !self.points.is_empty() && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) { + self.points.push(ev.pos); + } + vec![] + } + PointerKind::Up => { + let pts = std::mem::take(&mut self.points); + let r = |x: vectorcraft_geom::Rect| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height() }); + match recognize(&pts) { + Some(Recognized::Line { a, b }) => vec![Action::Exec("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))], + Some(Recognized::Rectangle(x)) => vec![Action::Exec("shape.rectangle".into(), r(x))], + Some(Recognized::Ellipse(x)) => vec![Action::Exec("shape.ellipse".into(), r(x))], + Some(Recognized::Polygon { center, radius, sides, rotation }) => { + vec![Action::Exec( + "shape.polygon".into(), + json!({ "cx": center.x, "cy": center.y, "radius": radius, "sides": sides, "rotation": rotation }), + )] + } + Some(Recognized::Scribble(_)) => { + // Delete the topmost objects the scribble crosses. + let mut ids: Vec = vec![]; + for p in &pts { + if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, *p, cx.hit_options()) { + let id = h.top_object(cx.isolation).0; + if !ids.contains(&id) { + ids.push(id); + } + } + } + if ids.is_empty() { + return vec![]; + } + vec![Action::Exec("select.set".into(), json!({ "ids": ids })), Action::Exec("edit.clear".into(), json!({ "ids": ids }))] + } + None => vec![], + } + } + _ => vec![], + } + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape { + self.points.clear(); + } + vec![] + } + fn overlays(&self, _cx: &ToolContext) -> Vec { + if self.points.len() < 2 { + return vec![]; + } + vec![Overlay::Path { path: crate::draw2::polyline(&self.points), color: BLUE, width: 1.5, dashed: false }] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Crosshair + } +} + /// Graph tools: drag the plot rectangle (Shift = square, Alt = from the centre); a click opens the /// size dialog. A new graph opens the Graph Data dialog. pub struct GraphTool { @@ -303,6 +383,7 @@ mod tests { fn create_covers_both() { assert_eq!(create("flare").unwrap().id(), "flare"); assert_eq!(create("reshape").unwrap().id(), "reshape"); + assert_eq!(create("shaper").unwrap().id(), "shaper"); assert!(create("pen").is_none()); for g in ["columnGraph", "stackedColumnGraph", "barGraph", "stackedBarGraph", "lineGraph", "areaGraph", "scatterGraph", "pieGraph", "radarGraph"]