diff --git a/ROADMAP.md b/ROADMAP.md index 66983a405..2505cade1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,7 +52,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent 1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`, `06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and - isolation mode. Do it side by side with Illustrator where the owner allows. 60–90 h. + isolation mode. The Shaper’s rough-square recognition is corrected; Shaper Groups remain pending. 60–90 h. 2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h. @@ -118,6 +118,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **Tools:** + - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position; circles, ellipses, polygons, lines and scribble deletion remain covered. Shaper Groups (merge/punch) are still pending. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. @@ -171,7 +172,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper, the remaining Pen modifier nuances) | 15–20 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition, the remaining Pen modifier nuances; Shaper Groups pending) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | @@ -203,7 +204,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 | +| Drawing tools | 7 | 85% | rough-square Shaper recognition corrected; remaining: Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c97b572b4..37db76a3e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -731,6 +731,34 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() { assert!(s.doc().unwrap().doc.node(id).is_none()); } +#[test] +fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() { + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for (kind, x, y) in [ + (PointerKind::Down, 150.0, 100.0), + (PointerKind::Drag, 200.0, 105.0), + (PointerKind::Drag, 194.0, 200.0), + (PointerKind::Drag, 110.0, 191.0), + (PointerKind::Drag, 100.0, 100.0), + (PointerKind::Up, 150.0, 100.0), + ] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + let id = *s.doc().unwrap().selection.objects.first().expect("recognized square"); + assert!(matches!( + s.doc().unwrap().doc.node(id).unwrap().kind, + vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. } + )); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_some()); +} + #[test] fn a_panicking_command_rolls_back_instead_of_crashing() { let mut s = session(); diff --git a/crates/geom/src/recognize.rs b/crates/geom/src/recognize.rs index eb4f27e56..e95d391c1 100644 --- a/crates/geom/src/recognize.rs +++ b/crates/geom/src/recognize.rs @@ -1,9 +1,9 @@ //! 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). +//! simplification independent of where drawing began, its edge/angle fit and how well it fills +//! its bounding box. Samples are spaced by distance so pointer speed does not bias the fit. +//! A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it covers). use crate::{Point, Rect, Vec2}; @@ -48,30 +48,126 @@ fn dist_to_segment(p: Point, a: Point, b: Point) -> f64 { p.distance(a + ab * t) } -/// Douglas–Peucker simplification. +/// Douglas–Peucker simplification with bounded stack use, even for noisy strokes. 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 keep = vec![false; pts.len()]; + if let Some(first) = keep.first_mut() { + *first = true; } - let mut left = simplify(&pts[..=i], tol); - let right = simplify(&pts[i..], tol); - left.pop(); - left.extend(right); - left + if let Some(last) = keep.last_mut() { + *last = true; + } + let mut pending = vec![(0, pts.len() - 1)]; + while let Some((start, end)) = pending.pop() { + let Some(window) = pts.get(start..=end) else { continue }; + let (Some(&a), Some(&b)) = (window.first(), window.last()) else { continue }; + let (offset, deviation) = window + .iter() + .enumerate() + .skip(1) + .take(window.len().saturating_sub(2)) + .map(|(i, p)| (i, dist_to_segment(*p, a, b))) + .fold((0, 0.0), |best, candidate| if candidate.1 > best.1 { candidate } else { best }); + if deviation > tol { + let split = start + offset; + if let Some(k) = keep.get_mut(split) { + *k = true; + } + if split > start + 1 { + pending.push((start, split)); + } + if end > split + 1 { + pending.push((split, end)); + } + } + } + pts.iter().zip(keep).filter_map(|(p, keep)| keep.then_some(*p)).collect() +} + +/// Sample by distance, so pointer speed/density cannot bias the shape fit. Bounds and length +/// still come from the complete stroke, but simplification works on at most 256 samples. +fn resample(pts: &[Point], length: f64) -> Vec { + const SAMPLES: usize = 256; + let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return Vec::new() }; + let mut out = vec![first]; + let mut walked = 0.0; + for (&a, &b) in pts.iter().zip(pts.iter().skip(1)) { + let segment = a.distance(b); + if segment <= 0.0 { + continue; + } + while out.len() < SAMPLES - 1 { + let target = length * (out.len() as f64 / (SAMPLES - 1) as f64); + if target > walked + segment { + break; + } + out.push(a + (b - a) * ((target - walked) / segment).clamp(0.0, 1.0)); + } + walked += segment; + } + out.push(last); + out +} + +/// A closed stroke has no special starting corner. Split at distant points and simplify both +/// halves, then discard collinear seam points instead of counting them as polygon vertices. +fn closed_corners(pts: &[Point], tol: f64) -> Vec { + let Some(&first) = pts.first() else { return Vec::new() }; + let start = pts.iter().enumerate().max_by(|(_, a), (_, b)| a.distance(first).total_cmp(&b.distance(first))).map_or(0, |(i, _)| i); + let ring: Vec = pts.iter().skip(start).chain(pts.iter().take(start)).copied().collect(); + let Some(&first) = ring.first() else { return Vec::new() }; + let split = ring.iter().enumerate().max_by(|(_, a), (_, b)| a.distance(first).total_cmp(&b.distance(first))).map_or(0, |(i, _)| i); + let (Some(left), Some(right)) = (ring.get(..=split), ring.get(split..)) else { return Vec::new() }; + let mut corners = simplify(left, tol); + corners.pop(); + let other: Vec = right.iter().copied().chain(std::iter::once(first)).collect(); + let mut other = simplify(&other, tol); + other.pop(); + corners.extend(other); + while corners.len() > 3 { + let n = corners.len(); + let redundant = corners.iter().enumerate().find_map(|(i, p)| { + let (&a, &b) = (corners.get((i + n - 1) % n)?, corners.get((i + 1) % n)?); + (dist_to_segment(*p, a, b) <= tol).then_some(i) + }); + let Some(i) = redundant else { break }; + corners.remove(i); + } + corners } 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 + let Some(&origin) = poly.first() else { return 0.0 }; + // Translating to the first point avoids cancellation far from the document origin. + poly.iter().zip(poly.iter().skip(1)).map(|(&a, &b)| (a - origin).cross(b - origin)).sum::().abs() / 2.0 +} + +/// Four substantial, approximately perpendicular edges, even with uneven sides and corners. +fn is_rectangle(pts: &[Point], corners: &[Point], size: f64) -> bool { + if corners.len() != 4 || !is_polygonal(pts, corners, size, 0.055) { + return false; + } + right_angles(corners, size) +} + +fn right_angles(corners: &[Point], size: f64) -> bool { + let around: Vec = corners.iter().cycle().take(corners.len() + 2).copied().collect(); + around.windows(3).all(|w| { + let [a, b, c] = w else { return false }; + let (u, v) = (*b - *a, *c - *b); + let (lu, lv) = (u.hypot(), v.hypot()); + lu > 0.03 * size && lv > 0.03 * size && (u.dot(v) / (lu * lv)).abs() < 0.45 + }) +} + +fn axis_aligned(corners: &[Point]) -> bool { + corners.iter().zip(corners.iter().cycle().skip(1)).all(|(&a, &b)| { + let edge = b - a; + edge.x.abs().min(edge.y.abs()) < 0.4 * edge.hypot() + }) } /// Number of sharp direction reversals (turns of more than 120°) along the stroke. @@ -87,22 +183,27 @@ fn reversals(pts: &[Point], min_seg: f64) -> usize { /// Recognise a freehand stroke (document points, in drawing order). pub fn recognize(pts: &[Point]) -> Option { - if pts.len() < 2 { + if pts.len() < 2 || pts.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { return None; } let b = bbox(pts); let size = b.width().max(b.height()); - if size < 2.0 { + if size < 2.0 || !size.is_finite() { return None; } let len = path_len(pts); - let (first, last) = (pts[0], pts[pts.len() - 1]); + if !len.is_finite() || len <= 0.0 { + return None; + } + let samples = resample(pts, len); + let pts = samples.as_slice(); + let (&first, &last) = (pts.first()?, pts.last()?); + let chord = first.distance(last); // 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 }); @@ -111,13 +212,11 @@ pub fn recognize(pts: &[Point]) -> Option { 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 corners = closed_corners(pts, size * 0.12); let fill = area(pts) / (b.width() * b.height()).max(1e-9); + if !fill.is_finite() { + return None; + } let center = b.center(); let square = |r: Rect| { let (w, h) = (r.width(), r.height()); @@ -136,13 +235,13 @@ pub fn recognize(pts: &[Point]) -> Option { 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))), + 4 if is_rectangle(pts, &corners, size) && axis_aligned(&corners) => Some(Recognized::Rectangle(square(b))), // A diamond: a square standing on a corner. - 4 if (0.4..0.62).contains(&fill) => { + 4 if (0.4..0.62).contains(&fill) && is_rectangle(pts, &corners, size) => { 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) => { + n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size, 0.02) => { 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 }) } @@ -153,11 +252,16 @@ pub fn recognize(pts: &[Point]) -> Option { /// 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::() +fn is_polygonal(pts: &[Point], corners: &[Point], size: f64, tolerance: f64) -> bool { + if pts.is_empty() || corners.len() < 3 { + return false; + } + let dev: f64 = pts + .iter() + .map(|p| corners.iter().zip(corners.iter().cycle().skip(1)).map(|(&a, &b)| dist_to_segment(*p, a, b)).fold(f64::MAX, f64::min)) + .sum::() / pts.len() as f64; - dev < 0.02 * size + dev < tolerance * size } #[cfg(test)] @@ -199,6 +303,27 @@ mod tests { assert!((b.width() - b.height()).abs() < 1e-9, "near-squares snap to squares"); } + #[test] + fn rough_squares_are_rectangles_from_any_starting_point() { + let cases = [ + [Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], + [Point::new(0.0, 8.0), Point::new(94.0, 0.0), Point::new(100.0, 95.0), Point::new(7.0, 100.0)], + ]; + for vertices in cases { + for wobble in [0.5, 4.0] { + let mut stroke = wobbly(&vertices, 24, wobble); + stroke.pop(); + for start in [0, 5, 12, 20, 30, 47, 60, 79] { + stroke.rotate_left(start); + let mut closed = stroke.clone(); + closed.push(stroke[0] + Vec2::new(2.0, -1.0)); + assert!(matches!(recognize(&closed), Some(Recognized::Rectangle(_))), "wobble {wobble}, start {start}: {:?}", recognize(&closed)); + stroke.rotate_right(start); + } + } + } + } + #[test] fn ellipse_circle_triangle_hexagon_line() { assert!( @@ -220,6 +345,63 @@ mod tests { assert!(matches!(recognize(&line), Some(Recognized::Line { .. }))); } + #[test] + fn circles_and_real_polygons_keep_their_identity_across_stroke_seams() { + for (rx, ry) in [(50.0, 50.0), (80.0, 40.0), (50.0, 40.0)] { + let mut stroke = circle(Point::new(100.0, 100.0), rx, ry, 192); + stroke.pop(); + for start in [0, 13, 37, 62, 109] { + stroke.rotate_left(start); + let mut closed = stroke.clone(); + closed.push(stroke[0]); + assert!(matches!(recognize(&closed), Some(Recognized::Ellipse(_))), "ellipse {rx}/{ry}, seam {start}: {:?}", recognize(&closed)); + stroke.rotate_right(start); + } + } + for sides in [3, 5, 6, 8] { + let corners: Vec = (0..sides) + .map(|i| { + let a = std::f64::consts::TAU * i as f64 / sides as f64; + Point::new(100.0 + 80.0 * a.cos(), 100.0 + 80.0 * a.sin()) + }) + .collect(); + let mut stroke = wobbly(&corners, 24, 1.0); + stroke.pop(); + for start in [0, 9, 17, 29, 45] { + stroke.rotate_left(start); + let mut closed = stroke.clone(); + closed.push(stroke[0]); + assert!( + matches!(recognize(&closed), Some(Recognized::Polygon { sides: n, .. }) if n == sides), + "{sides} sides, seam {start}: {:?}", + recognize(&closed) + ); + stroke.rotate_right(start); + } + } + } + + #[test] + fn square_fit_ignores_sampling_density_and_document_offset() { + let mut stroke = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], 24, 4.0); + for scale in [0.25, 1.0, 20.0] { + let shifted: Vec = stroke.iter().map(|p| Point::new(1e9 + p.x * scale, -1e9 + p.y * scale)).collect(); + assert!(matches!(recognize(&shifted), Some(Recognized::Rectangle(_))), "scale {scale}: {:?}", recognize(&shifted)); + } + let dense: Vec = stroke.iter().enumerate().flat_map(|(i, p)| std::iter::repeat_n(*p, if i < 24 { 100 } else { 1 })).collect(); + assert_eq!(recognize(&stroke), recognize(&dense)); + stroke.extend(std::iter::repeat_n(*stroke.last().unwrap(), 10_000)); + assert!(matches!(recognize(&stroke), Some(Recognized::Rectangle(_)))); + } + + #[test] + fn non_finite_and_overflowing_strokes_are_not_shapes() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(recognize(&[Point::new(0.0, 0.0), Point::new(bad, 100.0)]), None); + } + assert_eq!(recognize(&[Point::new(-f64::MAX, 0.0), Point::new(f64::MAX, 100.0)]), None); + } + #[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(); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 66511f886..050c4bf01 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -244,6 +244,10 @@ impl Tool for ShaperTool { vec![] } PointerKind::Up => { + // A fast final segment may arrive only on release; it still closes the stroke. + if !self.points.is_empty() && self.points.last().is_none_or(|p| *p != ev.pos) { + self.points.push(ev.pos); + } 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) {