diff --git a/ROADMAP.md b/ROADMAP.md index b907759a..fde02e31 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -118,7 +118,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, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. - **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, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), 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. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index e9350e80..a2d43d79 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -2,9 +2,10 @@ //! 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}; +use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox}; +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,99 @@ impl Targets { } (d, ov) } + + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for + /// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own + /// frame, and so is the result. The corners and the side that handle moves land on the nearest + /// target within `tol` on the page, whatever the box's angle. A handle with one way to go (a + /// side, or a proportional corner) slides along it until one of them is on a target. + pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec) { + type Hit = Option<(f64, Point)>; + let r = bx.rect; + let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; + let (d0, [sx, _, _, sy, _, _]) = (handle.pos(r) - origin, a.as_coeffs()); + let (to_doc, to_local) = (bx.to_doc(), bx.to_doc().inverse()); + // A scale that collapses or flips the box is no snap. + let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0; + let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit { + ts.iter().map(|(t, from, _)| (*t, *from)).filter(|(t, _)| (t - v).abs() <= tol).min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs())) + }; + let (mut nx, mut ny) = (sx, sy); + let (mut hit_x, mut hit_y): (Hit, Hit) = (None, None); + if handle.is_corner() && !proportional { + // The corner goes anywhere: it takes the target's x, its y, or both. + let p = to_doc * (a * handle.pos(r)); + let (hx, hy) = (nearest(p.x, &self.xs), nearest(p.y, &self.ys)); + for (tx, ty) in [(hx, hy), (hx, None), (None, hy)] { + if tx.is_none() && ty.is_none() { + continue; + } + let q = to_local * Point::new(tx.map_or(p.x, |h| h.0), ty.map_or(p.y, |h| h.0)); + let (mut cx, mut cy) = ((q.x - origin.x) / d0.x, (q.y - origin.y) / d0.y); + // Square to the page, the axis without a target stays exactly where it was. + if bx.angle == 0.0 { + (cx, cy) = (if tx.is_some() { cx } else { sx }, if ty.is_some() { cy } else { sy }); + } + if valid(cx, sx) && valid(cy, sy) { + (nx, ny, hit_x, hit_y) = (cx, cy, tx, ty); + break; + } + } + } else { + // One parameter `s`: the scale is `c + s·e` on each axis. + let (ax, _) = handle.axes(); + let (e, c, s0) = match (handle.is_corner(), ax, proportional) { + (true, _, _) => ((sx.signum(), sy.signum()), (0.0, 0.0), sx.abs()), + (false, true, true) => ((1.0, sx.signum()), (0.0, 0.0), sx), + (false, true, false) => ((1.0, 0.0), (0.0, 1.0), sx), + (false, false, true) => ((sy.signum(), 1.0), (0.0, 0.0), sy), + (false, false, false) => ((0.0, 1.0), (1.0, 0.0), sy), + }; + // The points that may land on a target: the handle, and the two ends of its side. + let i = handle as usize; + let ends = if handle.is_corner() { [None, None] } else { [Handle::ALL.get((i + 1) % 8), Handle::ALL.get((i + 7) % 8)] }; + // How far the handle travels per unit of `s`: a snap may not drag it far from the + // pointer, as it would for a target the side runs almost along. + let travel = (e.0 * d0.x).hypot(e.1 * d0.y); + let mut best: Option<(f64, f64, bool, (f64, Point))> = None; + for h in std::iter::once(handle).chain(ends.into_iter().flatten().copied()) { + let v = h.pos(r) - origin; + let base = to_doc * (origin + Vec2::new(c.0 * v.x, c.1 * v.y)); + let dir = (to_doc * Point::new(e.0 * v.x, e.1 * v.y)).to_vec2(); + let p = base + dir * s0; + for (is_x, at, b, d, ts) in [(true, p.x, base.x, dir.x, &self.xs), (false, p.y, base.y, dir.y, &self.ys)] { + if d.abs() <= 1e-9 { + continue; + } + let Some(hit) = nearest(at, ts) else { continue }; + let s = (hit.0 - b) / d; + let moved = (s - s0).abs() * travel; + if valid(s, s0) && moved <= 2.0 * tol && best.is_none_or(|k| moved < k.1) { + best = Some((s, moved, is_x, hit)); + } + } + } + if let Some((s, _, is_x, hit)) = best { + (nx, ny) = (c.0 + s * e.0, c.1 + s * e.1); + if is_x { + hit_x = Some(hit); + } else { + hit_y = Some(hit); + } + } + } + let out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); + // The lines run from the target's own point across the resized box, on the page. + let nr = (to_doc * out).transform_rect_bbox(r); + let mut ov = vec![]; + if let Some((x, from)) = hit_x { + 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)) = hit_y { + 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. @@ -235,4 +329,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(&OrientedBox::aligned(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..c6e287f8 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -136,6 +136,8 @@ 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 what the handle moves with the other objects. + self.targets = cx.smart_guides.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 +207,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, 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 +252,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 +445,76 @@ 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_of_a_turned_box_snaps_on_the_page() { + use vectorcraft_doc::{Appearance, Node}; + let matrix = |a: &[Action]| { + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]) + }; + // A 50 pt square beside the 100..200 rect, turned about its centre (275, 125). + for (turn, low) in [(90.0_f64, 150.0), (45.0, 125.0 + 25.0 * std::f64::consts::SQRT_2)] { + 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 c = Point::new(275.0, 125.0); + d.node_mut(id) + .unwrap() + .transform(Affine::translate(c.to_vec2()) * Affine::rotate(turn.to_radians()) * Affine::translate(-c.to_vec2()), false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let bx = selection_box(&cx).unwrap(); + assert_ne!(bx.angle, 0.0); + // The handle at the lowest point of the shape: a side at 90°, a corner at 45°. + let grab = Point::new(275.0, low); + assert!(Handle::ALL.iter().any(|h| (bx.to_doc() * h.pos(bx.rect)).distance(grab) < 1e-6), "{bx:?}"); + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, grab.x, grab.y)), vec![Action::Begin("Scale".into())]); + // Dragged near the neighbour's bottom edge (y = 200), it lands on it. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 275.0, 197.0)); + let landed = matrix(&a) * grab; + assert!((landed.y - 200.0).abs() < 1e-6 && (landed.x - 275.0).abs() < 1e-6, "{turn}: {landed:?}"); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { .. }))); + t.pointer(&cx, &ev(PointerKind::Up, 275.0, 197.0)); + } + } + #[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..0a01cfda 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(&vectorcraft_doc::OrientedBox::aligned(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![] } } }