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:
parent
55e24d6622
commit
fa2d901a7e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
pub mod recognize;
|
||||
pub mod shapes;
|
||||
pub mod snap;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue