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:
parent
8276d0a303
commit
cf775072be
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue