diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a14c617f3..e11fb77c9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -237,7 +237,9 @@ fn pen_draws_and_direct_selection_edits() { s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 80.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 80.0), v).unwrap(); let a = s.doc().unwrap().doc.item(id).unwrap().path.subpaths[0].anchors[0].p; - assert_eq!(a, designcraft_geom::Point::new(90.0, 80.0)); + // The pen placed this anchor on the baseline at y 96. Dragging toward (90, 80) + // proposes y 76, and the baseline at 72 is inside the zone, so the anchor lands at (90, 72). + assert_eq!(a, designcraft_geom::Point::new(90.0, 72.0)); } #[test] diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 19bec8046..3c75e51cd 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -17,6 +17,8 @@ pub struct PenTool { anchors: Vec<(Point, Point, Point)>, dragging: bool, hover: Point, + /// Smart guides in canvas coordinates, as `snap_point` returned them. + guides: Vec, } fn anchor_json(a: &(Point, Point, Point)) -> Value { @@ -29,8 +31,20 @@ impl PenTool { self.spread = None; self.anchors.clear(); self.dragging = false; + self.guides.clear(); vec![] } + + /// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas. + fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point { + if shift && let Some(last) = self.anchors.last() { + p = last.0 + designcraft_geom::constrain_angle(p - last.0, 45.0); + } + let (p, guides) = crate::snap::snap_point(cx, spread, p); + self.guides = guides; + p + } + fn commit_anchor(&mut self) -> Vec { let Some(sr) = self.spread else { return vec![] }; let Some(last) = self.anchors.last() else { return vec![] }; @@ -51,18 +65,27 @@ impl Tool for PenTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let Some((sr, sp)) = cx.layout.spread_at(ev.pos) else { return vec![] }; + let Some((sr, sp)) = cx.layout.spread_at(ev.pos) else { + self.guides.clear(); + return vec![]; + }; self.hover = ev.pos; // The pen continues on the spread where the path started. let sp = match self.spread { Some(s) if s != sr => cx.layout.to_spread(s, ev.pos), _ => sp, }; + let snap_spread = self.spread.unwrap_or(sr); // Pick up the item id created by path.create (the newest selected path). if self.path.is_none() && self.anchors.len() >= 2 { self.path = cx.selection.items.last().map(|i| i.0); } match ev.kind { + PointerKind::Move => { + let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); + self.hover = cx.layout.to_canvas(snap_spread, p); + vec![] + } PointerKind::Down => { // Close on the first anchor. if self.anchors.len() >= 2 @@ -76,12 +99,7 @@ impl Tool for PenTool { self.finish(); return out; } - let mut p = sp; - if ev.mods.shift - && let Some(last) = self.anchors.last() - { - p = last.0 + designcraft_geom::constrain_angle(p - last.0, 45.0); - } + let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); if self.anchors.is_empty() { self.spread = Some(sr); } @@ -120,9 +138,12 @@ impl Tool for PenTool { } fn overlays(&self, cx: &ToolContext) -> Vec { - let Some(sr) = self.spread else { return vec![] }; - let xf = cx.layout.xf(sr); let mut out = Vec::new(); + let Some(sr) = self.spread else { + out.extend(self.guides.iter().cloned()); + return out; + }; + let xf = cx.layout.xf(sr); if let Some(last) = self.anchors.last() && !self.dragging { @@ -139,6 +160,8 @@ impl Tool for PenTool { out.push(Overlay::Line { a: xf * a.1, b: xf * a.2, color: [79, 153, 255], dashed: false }); let _ = BezPath::new(); } + // Guides are already canvas. Do not run them through the spread transform again. + out.extend(self.guides.iter().cloned()); out } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 4e7e9eefe..07c470d0e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -5,7 +5,7 @@ //! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool. use designcraft_doc::{ItemId, SpreadRef}; -use designcraft_geom::{Point, Rect, Vec2}; +use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde_json::{Value, json}; use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, ToolKey, rect_json, spread_json}; @@ -13,12 +13,42 @@ use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, S #[derive(Clone, Debug)] enum Drag { None, - Pending { start: Point, hit: bool }, - Move { start: Point, origin_spread: SpreadRef, bounds0: Option, exclude: Vec }, - Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef }, - Rotate { center: Point, start_angle: f64, start_rotation: Option }, - Marquee { start: Point, cur: Point }, - Anchor { id: u64, si: usize, ai: usize, handle: Option<&'static str>, start: Point }, + Pending { + start: Point, + hit: bool, + }, + Move { + start: Point, + origin_spread: SpreadRef, + bounds0: Option, + exclude: Vec, + }, + Resize { + handle: usize, + start: Point, + from: Rect, + spread: SpreadRef, + }, + Rotate { + center: Point, + start_angle: f64, + start_rotation: Option, + }, + Marquee { + start: Point, + cur: Point, + }, + Anchor { + id: u64, + si: usize, + ai: usize, + handle: Option<&'static str>, + start: Point, + /// Spread and the anchor's spread position at pointer down. + /// None when that point could not be read. The live document during a drag is the + /// previous preview, so the snap must not read the anchor from it again. + at: Option<(SpreadRef, Point)>, + }, } /// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle). @@ -49,6 +79,20 @@ pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) None } +/// Spread holding the anchor, and the anchor in that spread's coordinates. +fn anchor_spread_point(cx: &ToolContext, id: u64, si: usize, ai: usize) -> Option<(SpreadRef, Point)> { + let item_id = ItemId(id); + let loc = cx.doc.find(item_id)?; + let it = cx.doc.item(item_id)?; + let local = it.path.subpaths.get(si)?.anchors.get(ai)?.p; + Some((loc.spread, (cx.doc.parent_xf(&loc) * it.xf) * local)) +} + +/// Spread-space correction as a canvas delta. Translation cancels, leaving the linear part of `xf`. +fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 { + (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) +} + pub struct SelectionTool { direct: bool, drag: Drag, @@ -337,7 +381,8 @@ impl Tool for SelectionTool { if self.direct && let Some((id, si, ai, handle)) = anchor_at(cx, p) { - self.drag = Drag::Anchor { id, si, ai, handle, start: p }; + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; } if !self.direct @@ -565,9 +610,44 @@ impl Tool for SelectionTool { // Screen y points down: a positive screen angle is clockwise. vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] } - Drag::Anchor { id, si, ai, handle, start } => { + Drag::Anchor { id, si, ai, handle, start, at } => { let d = p - start; - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": d.x, "dy": d.y}); + let mut dx = d.x; + let mut dy = d.y; + self.guides.clear(); + // Direction handles do not snap. An anchor excludes the path it is on. + if handle.is_none() + && let Some((spread, origin)) = at + { + let proposed = origin + cx.layout.delta_to_spread(spread, d); + let exclude = [ItemId(id)]; + let hit = crate::snap::snap( + cx, + SnapRequest { + spread, + gesture: Gesture::Point, + rect: Rect::from_points(proposed, proposed), + x_edges: [true, true, true], + y_edges: [true, true, true], + exclude: &exclude, + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: proposed, + }, + ); + // dx and dy stay in canvas space. Snap.delta is spread space. + let corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); + if corr.x.is_finite() { + dx += corr.x; + } + if corr.y.is_finite() { + dy += corr.y; + } + self.guides = hit.guides; + } + let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); if let Some(h) = handle { params["handle"] = json!(h); } @@ -640,7 +720,7 @@ impl Tool for SelectionTool { fn overlays(&self, _cx: &ToolContext) -> Vec { match &self.drag { Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], - Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } => self.guides.clone(), + Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), _ => vec![], } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 01edf808a..b2cb2c314 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1,6 +1,8 @@ +use std::sync::Arc; + use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; -use designcraft_doc::{Document, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef}; +use designcraft_doc::{Document, Guide, Item, ItemId, Orientation, ParaFormat, Selection, Shape, SpreadRef}; use designcraft_geom::shapes; use designcraft_geom::{Point, Rect, Unit}; @@ -521,3 +523,32 @@ fn add_rotated(doc: &mut Document, rect: Rect, degrees: f64) -> ItemId { doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); id } + +#[test] +fn pen_click_snaps_to_a_guide() { + let mut d = Document::new(&NewDocument::default()); + Arc::make_mut(&mut d.spreads[0]).pages[0].guides.push(Guide { + orientation: Orientation::Vertical, + position: 100.0, + spread: true, + locked: false, + layer: None, + liquid: false, + }); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let mut cx = ctx(&d, &s, &c, &l); + cx.snap = SnapView::FACTORY; + let mut t = create("pen"); + let click = |t: &mut Box, cx: &ToolContext, x: f64, y: f64| { + t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y)) + }; + assert!(click(&mut t, &cx, 102.0, 80.0).is_empty()); + let up = click(&mut t, &cx, 180.0, 80.0); + let Action::Exec(cmd, p) = &up[0] else { + panic!("expected path.create, got {up:?}"); + }; + assert_eq!(cmd, "path.create"); + let x = p["anchors"][0]["p"][0].as_f64().unwrap(); + assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); +}