diff --git a/ROADMAP.md b/ROADMAP.md index ce478a91..6b3f8261 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,7 +35,7 @@ _Last updated: 2026-10-02._ - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. - **Cutting:** Eraser, Scissors, Knife. - - **Transform:** Rotate, Reflect, Scale, Shear, Reshape, Free Transform (distort/perspective). + - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective). - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. @@ -58,7 +58,7 @@ _Last updated: 2026-10-02._ | # | Milestone | Status | Est. remaining, one agent (h) | |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | -| M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 4–6 | +| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index a9e60c82..4203b139 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -16,13 +16,24 @@ pub struct ViewInfo { pub show_bbox: bool, /// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi). pub snap_to_pixel: bool, + /// View → Snap to Point: picked points (a transform's reference point) land on anchors. + pub snap_to_point: bool, /// View → Show Corner Widget. pub corner_widgets: bool, } impl Default for ViewInfo { fn default() -> Self { - Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true, snap_to_pixel: false, corner_widgets: true } + Self { + zoom: 1.0, + outline: false, + smart_guides: true, + snap_to_grid: false, + show_bbox: true, + snap_to_pixel: false, + snap_to_point: true, + corner_widgets: true, + } } } @@ -65,6 +76,7 @@ impl Session { snap_to_grid: view.snap_to_grid, show_bbox: view.show_bbox, snap_to_pixel: view.snap_to_pixel, + snap_to_point: view.snap_to_point, corner_widgets: view.corner_widgets, }; f(self.tool.as_mut(), &cx) diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index ebceac88..716b3b22 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -1,6 +1,7 @@ //! Smart Guides: snapping to anchors, object bounds (edges/centres), artboards, with the magenta //! construction lines and labels Illustrator users expect. +use vectorcraft_doc::hit::{HitOptions, hit_test}; use vectorcraft_doc::{Document, NodeId, NodeKind}; use vectorcraft_geom::{Point, Rect, Vec2}; @@ -75,6 +76,28 @@ impl Targets { t } + /// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds + /// reach into `near`. + fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self { + let mut t = Targets::default(); + for n in roots.iter().filter_map(|id| doc.node(*id)) { + n.walk(&mut |c| { + if c.is_container() || !c.visible { + return; + } + let Some(b) = c.geometric_bounds() else { return }; + if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 { + return; + } + if let NodeKind::Path { path, .. } = &c.kind { + t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor))); + } + t.points.push((b.center(), Kind::Center)); + }); + } + t + } + /// Snap a single point. Returns the snapped point and guide overlays. pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec) { if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) { @@ -148,6 +171,19 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec< Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0)) } +/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the +/// selection or of the object under the pointer, when Snap to Point or Smart Guides is on. +pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec) { + if !(cx.snap_to_point || cx.smart_guides) { + return (p, vec![]); + } + let tol = cx.tol(5.0); + let mut roots = cx.selection.objects.clone(); + roots.extend(hit_test(cx.doc, p, HitOptions { tol, ..cx.hit_options() }).map(|h| h.leaf)); + let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol); + Targets::points_near(cx.doc, &roots, near).snap_point(p, tol) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 2b57c671..440ee67c 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -151,6 +151,8 @@ pub struct ToolContext<'a> { pub show_bbox: bool, /// View → Snap to Pixel. pub snap_to_pixel: bool, + /// View → Snap to Point: picked points (a transform's reference point) land on anchors. + pub snap_to_point: bool, /// View → Show Corner Widget: live rectangles show draggable Live Corners widgets. pub corner_widgets: bool, } @@ -320,6 +322,7 @@ pub(crate) mod testutil { snap_to_grid: false, show_bbox: true, snap_to_pixel: false, + snap_to_point: true, corner_widgets: true, } } diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index b0c7f655..df135121 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -3,7 +3,9 @@ //! The reference point defaults to the centre of the selection. A click sets it (shown as a cyan //! target); a drag transforms about it with a live preview (Shift constrains to 45° / uniform, Alt //! at release makes a copy). Alt-click sets the point and opens the tool's dialog; double-click -//! or Return opens the dialog too. +//! or Return opens the dialog too. A clicked or Alt-clicked point snaps to the nearest anchor or +//! centre (Snap to Point / Smart Guides), labelled while hovering, so transforms pivot exactly +//! on a corner. use serde_json::{Value, json}; use vectorcraft_doc::NodeId; @@ -11,6 +13,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::target_overlays; use crate::bbox::rotate_for_drag; +use crate::guides::snap_pick; use crate::select::{matrix_json, selection_bounds}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -54,6 +57,8 @@ impl TransformKind { #[derive(Clone, Copy, Debug)] struct Drag { start: Point, + /// The pressed point snapped to an anchor or centre (becomes the reference point on a click). + pick: Point, origin: Point, began: bool, bounds: Rect, @@ -66,6 +71,8 @@ pub struct TransformTool { origin: Option<(Point, Vec)>, drag: Option, measure: Option<(Point, String)>, + /// Snap feedback while hovering ("anchor" / "center" label). + hover: Vec, } fn about(o: Point, a: Affine) -> Affine { @@ -79,7 +86,7 @@ pub fn reflect_matrix(o: Point, theta: f64) -> Affine { impl TransformTool { pub fn new(kind: TransformKind) -> Self { - Self { kind, origin: None, drag: None, measure: None } + Self { kind, origin: None, drag: None, measure: None, hover: vec![] } } /// The effective reference point: the custom one if it belongs to the current selection, @@ -160,13 +167,15 @@ impl Tool for TransformTool { match ev.kind { PointerKind::Down => { let Some(bounds) = selection_bounds(cx) else { return vec![] }; + self.hover.clear(); + let (pick, _) = snap_pick(cx, p); if ev.mods.alt { - self.origin = Some((p, cx.selection.objects.clone())); + self.origin = Some((pick, cx.selection.objects.clone())); self.drag = None; - return vec![self.dialog(p)]; + return vec![self.dialog(pick)]; } let origin = self.reference_point(cx).unwrap_or(bounds.center()); - self.drag = Some(Drag { start: p, origin, began: false, bounds, last: Affine::IDENTITY }); + self.drag = Some(Drag { start: p, pick, origin, began: false, bounds, last: Affine::IDENTITY }); vec![] } PointerKind::Drag => { @@ -194,7 +203,7 @@ impl Tool for TransformTool { vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit] } else { // A click sets the reference point. - self.origin = Some((d.start, cx.selection.objects.clone())); + self.origin = Some((d.pick, cx.selection.objects.clone())); vec![] } } @@ -205,7 +214,10 @@ impl Tool for TransformTool { None => vec![], } } - PointerKind::Move => vec![], + PointerKind::Move => { + self.hover = if self.drag.is_none() && !cx.selection.is_empty() { snap_pick(cx, p).1 } else { vec![] }; + vec![] + } } } @@ -230,6 +242,7 @@ impl Tool for TransformTool { if let Some(r) = reference { o.extend(target_overlays(r, cx.tol(8.0))); } + o.extend(self.hover.iter().cloned()); if let (Some(d), TransformKind::Reflect | TransformKind::Rotate) = (self.drag, self.kind) && d.began && let Some((p, _)) = &self.measure @@ -360,6 +373,37 @@ mod tests { assert!(matches!(&t.key(&cx, ToolKey::Enter, Mods::default())[0], Action::Dialog(k, v) if k == "rotate" && v["angle"] == 0)); } + #[test] + fn alt_click_and_click_snap_the_reference_point_to_anchors_and_centres() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut c = cx(&d, &s, &p); + // Alt-click 3 px from the top-left corner: the reflect pivots exactly on the corner. + let mut t = TransformTool::new(TransformKind::Reflect); + let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt())); + assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [100.0, 100.0]}))]); + // A plain click near the bottom-right corner, then near the centre. + let mut t = TransformTool::new(TransformKind::Rotate); + t.pointer(&c, &ev(PointerKind::Down, 198.0, 203.0)); + t.pointer(&c, &ev(PointerKind::Up, 198.0, 203.0)); + assert_eq!(t.reference_point(&c), Some(Point::new(200.0, 200.0))); + t.pointer(&c, &ev(PointerKind::Down, 151.0, 148.0)); + t.pointer(&c, &ev(PointerKind::Up, 151.0, 148.0)); + assert_eq!(t.reference_point(&c), Some(Point::new(150.0, 150.0))); + // Hovering a corner labels it before the click. + t.pointer(&c, &ev(PointerKind::Move, 199.0, 101.0)); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == Point::new(200.0, 100.0)))); + t.pointer(&c, &ev(PointerKind::Move, 175.0, 120.0)); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { .. }))); + // Snapping off: the point stays where it was clicked. + c.snap_to_point = false; + c.smart_guides = false; + let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt())); + assert!(matches!(&a[0], Action::Dialog(_, v) if v["origin"] == json!([102.0, 98.0]))); + } + #[test] fn reflect_vertical_axis_mirrors_x() { let t = TransformTool::new(TransformKind::Reflect); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4705a6c0..7d96ef8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -212,6 +212,7 @@ impl VectorcraftApp { snap_to_grid: self.ui.view.snap_to_grid, snap_to_pixel: self.ui.view.snap_to_pixel, show_bbox: self.ui.view.bounding_box, + snap_to_point: self.ui.view.snap_to_point, corner_widgets: self.ui.view.corner_widgets, } }