M2.9: Shaper tool — rough rectangles, ellipses, triangles/polygons/diamonds and lines become clean live shapes (geom::recognize), squares/circles snap; a scribble deletes the objects it crosses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:10:44 -04:00
parent 55e24d6622
commit fa2d901a7e
5 changed files with 360 additions and 4 deletions

View File

@ -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,

View File

@ -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());
}

View File

@ -9,6 +9,7 @@
pub mod hit;
pub mod path;
pub mod recognize;
pub mod shapes;
pub mod snap;

View File

@ -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<Point> {
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::<f64>().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<Recognized> {
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<Point> = 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::<f64>() / 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::<f64>() / 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::<f64>() / 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::<f64>()
/ 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<Point> {
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<Point> {
(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<Point> = (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<Point> = (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<Point> = (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<Point> = (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);
}
}

View File

@ -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<Box<dyn Tool>> {
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<Point>,
}
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<Action> {
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<u64> = 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<Action> {
if key == ToolKey::Escape {
self.points.clear();
}
vec![]
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
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"]