M1.10: Rotate/Reflect/Scale/Shear reference point snaps — a click or Alt-click within 5 px of an anchor or object centre (of the selection or the object under the pointer) pivots exactly there, so Alt-clicking a corner flips/rotates from that corner; hovering shows the anchor/center label first; follows View → Snap to Point or Smart Guides (snap_to_point now reaches the tools)

This commit is contained in:
bflatastic 2026-10-02 22:23:24 +07:00
parent 8276d0a303
commit cf775072be
6 changed files with 106 additions and 10 deletions

View File

@ -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 `<mask>` 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 |

View File

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

View File

@ -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<Overlay>) {
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<Overlay>) {
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::*;

View File

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

View File

@ -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<NodeId>)>,
drag: Option<Drag>,
measure: Option<(Point, String)>,
/// Snap feedback while hovering ("anchor" / "center" label).
hover: Vec<Overlay>,
}
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);

View File

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