diff --git a/ROADMAP.md b/ROADMAP.md index b9517f82..6e93b452 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,7 +116,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. -- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes. +- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects), guides dragged out of the rulers, and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 716b3b22..b8b968cd 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -3,8 +3,9 @@ use vectorcraft_doc::hit::{HitOptions, hit_test}; use vectorcraft_doc::{Document, NodeId, NodeKind}; -use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use crate::bbox::Handle; use crate::{Overlay, ToolContext}; pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc]; @@ -154,6 +155,57 @@ impl Targets { } (d, ov) } + + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for + /// dragging `handle` of `r` (about the centre when `from_center`): the edges that handle moves + /// land on the nearest target within `tol`. A proportional scale stays proportional, so only + /// the closer of its two axes snaps. + pub fn snap_scale(&self, r: Rect, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec) { + let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; + let (start, [sx, _, _, sy, _, _]) = (handle.pos(r), a.as_coeffs()); + let (ax, ay) = handle.axes(); + // The nearest target to the edge a scale `s` takes from `v0` (about `o`), as (the scale + // landing on it, distance, target, the point it comes from). A target on the fixed side + // would collapse or flip the box: it is no candidate. + let near = |on: bool, s: f64, v0: f64, o: f64, ts: &[(f64, Point, Kind)]| { + if !on || (v0 - o).abs() <= 1e-9 { + return None; + } + let v = o + (v0 - o) * s; + ts.iter() + .map(|(t, from, _)| ((t - o) / (v0 - o), (t - v).abs(), *t, *from)) + .filter(|(k, d, _, _)| *d <= tol && k.is_finite() && k * s > 0.0) + .min_by(|p, q| p.1.total_cmp(&q.1)) + }; + let mut hx = near(ax, sx, start.x, origin.x, &self.xs); + let mut hy = near(ay, sy, start.y, origin.y, &self.ys); + if proportional && let (Some(x), Some(y)) = (hx, hy) { + if x.1 <= y.1 { + hy = None; + } else { + hx = None; + } + } + let (mut nx, mut ny) = (hx.map_or(sx, |h| h.0), hy.map_or(sy, |h| h.0)); + if proportional { + // The two scales are equal but for their signs. + if hx.is_some() { + ny = nx.abs() * sy.signum(); + } else if hy.is_some() { + nx = ny.abs() * sx.signum(); + } + } + let out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); + let nr = out.transform_rect_bbox(r); + let mut ov = vec![]; + if let Some((_, _, x, from)) = hx { + ov.push(Overlay::Line { a: Point::new(x, from.y.min(nr.y0)), b: Point::new(x, from.y.max(nr.y1)), color: MAGENTA, dashed: false }); + } + if let Some((_, _, y, from)) = hy { + ov.push(Overlay::Line { a: Point::new(from.x.min(nr.x0), y), b: Point::new(from.x.max(nr.x1), y), color: MAGENTA, dashed: false }); + } + (out, ov) + } } /// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. @@ -209,4 +261,38 @@ mod tests { assert_eq!(dv.x, -2.0); assert!(!ov.is_empty()); } + + #[test] + fn scale_snap_lands_the_moved_edges_on_targets() { + use crate::bbox::scale_for_drag; + let (d, _) = doc_with_rect(); + // The document's rect is 100..200: a box beside it is resized against it. + let t = Targets::collect(&d, &[], None); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + let snap = |h: Handle, p: Point, shift: bool, alt: bool| { + let (a, ov) = t.snap_scale(r, h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); + (a.transform_rect_bbox(r), ov) + }; + // The bottom edge lands on the neighbour's bottom: same height. + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0)); + assert_eq!(ov.len(), 1); + // A corner snaps each axis on its own: y to the neighbour, x stays free. + let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0)); + assert_eq!(ov.len(), 1); + // Proportional: the snapped axis carries the other one. + let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0)); + // From the centre the dragged edge still lands on the target. + let (nr, _) = snap(Handle::Bottom, Point::new(275.0, 198.0), false, true); + assert_eq!(nr, Rect::new(250.0, 50.0, 300.0, 200.0)); + // Out of reach, and the fixed edge's own target: untouched. + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 180.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 180.0)); + assert!(ov.is_empty()); + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 102.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0)); + assert!(ov.is_empty()); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index cea3fef2..1a3383d7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -136,6 +136,10 @@ impl Tool for SelectionTool { let areas = cx.selection.objects.iter().any(is_area); // Only area type: the step resizes type areas; anything else scales. let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" }; + // Smart Guides align the dragged edges with the other objects. They + // are page lines: a turned box's edges don't run along them. + self.targets = + (cx.smart_guides && bx.angle == 0.0).then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None)); self.state = State::Scaling { handle, bx, areas }; return vec![Action::Begin(label.into())]; } @@ -205,7 +209,11 @@ impl Tool for SelectionTool { } (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { // Scale in the box's own frame: along the objects' axes when it is rotated. - let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); + let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); + self.guides.clear(); + if let Some(t) = &self.targets { + (a, self.guides) = t.snap_scale(bx.rect, handle, a, m.shift, m.alt, cx.tol(5.0)); + } let nr = a.transform_rect_bbox(bx.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }); @@ -246,6 +254,8 @@ impl Tool for SelectionTool { (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { self.state = State::Idle; self.measure = None; + self.guides.clear(); + self.targets = None; vec![Action::Commit] } (PointerKind::Up, State::Marquee { start, add, .. }) => { @@ -437,6 +447,37 @@ mod tests { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } + #[test] + fn handle_drag_snaps_to_the_other_objects_with_smart_guides() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut c = cx(&d, &s, &p); + let height = |a: &[Action]| { + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + v["matrix"][3].as_f64().unwrap() * 50.0 + }; + // The bottom handle, dragged near the neighbour's bottom edge (y = 200), lands on it. + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); + assert!((height(&a) - 100.0).abs() < 1e-9, "{a:?}"); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); + t.pointer(&c, &ev(PointerKind::Up, 275.0, 197.0)); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); + // Smart Guides off: the edge follows the pointer. + c.smart_guides = false; + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); + assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}"); + } + #[test] fn handle_drag_on_area_type_resizes_its_frame() { let (d, text) = doc_with_area_type(); diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 1fa70074..faaf0c2a 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -82,6 +82,9 @@ pub struct FreeTransformTool { /// The current (distorted) quad while dragging corners. quad: Option<[Point; 4]>, measure: Option<(Point, String)>, + /// Smart Guides for a scale: the other objects' edges, and the lines of the current snap. + targets: Option, + guides: Vec, } /// Shear for dragging side `h` of `r` by `d` (about the opposite side, or the centre with Alt). @@ -178,6 +181,7 @@ impl FreeTransformTool { fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action { let shift = m.shift || self.constrain; let delta = p - d.start; + self.guides.clear(); let xf = match d.op { Op::Corners(h, mode) => { let q = distort_quad(d.rect, h, delta, mode); @@ -192,7 +196,10 @@ impl FreeTransformTool { Affine::translate(v) } Op::Scale(h) => { - let a = scale_for_drag(d.rect, h, p, shift, m.alt); + let mut a = scale_for_drag(d.rect, h, p, shift, m.alt); + if let Some(t) = &self.targets { + (a, self.guides) = t.snap_scale(d.rect, h, a, shift, m.alt, cx.tol(5.0)); + } let nr = a.transform_rect_bbox(d.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); a @@ -225,6 +232,8 @@ impl Tool for FreeTransformTool { self.drag = None; let Some(r) = selection_bounds(cx) else { return vec![] }; if let Some(op) = self.classify(cx, r, p, ev.mods) { + self.targets = + (cx.smart_guides && op.handle().is_some()).then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None)); self.drag = Some(Drag { op, rect: r, start: p, began: false }); } vec![] @@ -259,6 +268,8 @@ impl Tool for FreeTransformTool { let d = self.drag.take(); self.quad = None; self.measure = None; + self.guides.clear(); + self.targets = None; if d.is_some_and(|d| d.began) { vec![Action::Commit] } else { vec![] } } _ => vec![], @@ -270,6 +281,8 @@ impl Tool for FreeTransformTool { self.drag = None; self.quad = None; self.measure = None; + self.guides.clear(); + self.targets = None; return vec![Action::Cancel]; } vec![] @@ -290,6 +303,7 @@ impl Tool for FreeTransformTool { o.push(Overlay::Anchor { p: a.midpoint(b), color: BLUE, filled: false, size: 6.0 }); } } + o.extend(self.guides.iter().cloned()); if let Some((p, t)) = &self.measure { o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() }); } @@ -332,6 +346,8 @@ impl Tool for FreeTransformTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.quad = None; + self.guides.clear(); + self.targets = None; if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] } } }