diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e11fb77c9..8f2f39b0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -69,7 +69,10 @@ fn tool_gesture_creates_one_undo_step() { s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 30.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 48.0, 30.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 48.0, 30.0), v).unwrap(); - assert_eq!(s.doc().unwrap().doc.spreads[0].items[0].bounds().x0, 36.0); + // The rectangle tool's 1 pt centered stroke sits half a point outside the path. + // The visible edge lands on the 36 pt margin. + let it = &s.doc().unwrap().doc.spreads[0].items[0]; + assert!((it.visible_bounds().x0 - 36.0).abs() < 1e-6, "visible {:?} path {:?}", it.visible_bounds(), it.bounds()); } #[test] diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index b7e94412b..87b4284cc 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -1,10 +1,11 @@ //! Frame and shape tools (Rectangle/Ellipse/Polygon Frame, Rectangle/Ellipse/Polygon, Line). //! Drag to draw (Shift = square/45°, Alt = from centre); click opens the size dialog. +use designcraft_doc::SpreadRef; use designcraft_geom::{Point, Rect}; use serde_json::json; -use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, rect_json, spread_json}; +use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, Snap, SnapRequest, Tool, ToolContext, rect_json, spread_json}; pub struct FrameTool { id: &'static str, @@ -109,6 +110,209 @@ fn length_on(edges: [bool; 3], size: f64) -> Option { len.is_finite().then_some(len) } +fn line_hit(cx: &ToolContext, spread: SpreadRef, point: Point, len: Option) -> Snap { + crate::snap::snap( + cx, + SnapRequest { + spread, + gesture: Gesture::Create, + rect: Rect::from_points(point, point), + x_edges: [true, true, true], + y_edges: [true, true, true], + exclude: &[], + copying: false, + lengths: [len, None], + angle: None, + radius: 0.0, + pointer: point, + }, + ) +} + +/// Position corrections only. A length win is not an x shove. +fn apply_position(mut end: Point, hit: &Snap) -> (Point, bool, bool) { + let x_len = finite_length(hit.length_delta[0]); + let y_len = finite_length(hit.length_delta[1]); + if x_len.is_none() && hit.delta.x.is_finite() { + end.x += hit.delta.x; + } + if y_len.is_none() && hit.delta.y.is_finite() { + end.y += hit.delta.y; + } + let x_locked = x_len.is_none() && hit.delta.x.is_finite() && hit.delta.x.abs() > 1e-9; + let y_locked = y_len.is_none() && hit.delta.y.is_finite() && hit.delta.y.abs() > 1e-9; + (end, x_locked, y_locked) +} + +fn finite_length(v: Option) -> Option { + v.filter(|d| d.is_finite()) +} + +/// Slide along the segment so its length equals `current + diff`. +fn length_slide(anchor: Point, end: Point, diff: f64) -> Point { + let dir = end - anchor; + let len = dir.hypot(); + if !len.is_finite() || len <= 1e-9 || !diff.is_finite() { + return end; + } + let target = len + diff; + if !target.is_finite() || target < 0.0 { + return end; + } + let scale = target / len; + if !scale.is_finite() { + return end; + } + Point::new(anchor.x + dir.x * scale, anchor.y + dir.y * scale) +} + +/// A length match moves the free endpoint along the segment. +/// +/// An axis a position pass already won stays put. The segment is not rescaled about the +/// anchor after that position delta, because the won axis would leave its guide. +fn commit_line_end(anchor: Point, proposed: Point, hit: &Snap) -> Point { + let len = (proposed - anchor).hypot(); + let (mut end, x_locked, y_locked) = apply_position(proposed, hit); + let Some(diff) = finite_length(hit.length_delta[0]).or(finite_length(hit.length_delta[1])) else { + return end; + }; + if !x_locked && !y_locked { + return length_slide(anchor, proposed, diff); + } + let target = len + diff; + if !target.is_finite() || target < 0.0 { + return end; + } + if y_locked && !x_locked { + let dy = end.y - anchor.y; + let remain = target * target - dy * dy; + if remain >= 0.0 && remain.is_finite() { + let vx = proposed.x - anchor.x; + let sign = if vx.abs() > 1e-9 { vx.signum() } else { 1.0 }; + end.x = anchor.x + sign * remain.sqrt(); + } + return end; + } + if x_locked && !y_locked { + let dx = end.x - anchor.x; + let remain = target * target - dx * dx; + if remain >= 0.0 && remain.is_finite() { + let vy = proposed.y - anchor.y; + let sign = if vy.abs() > 1e-9 { vy.signum() } else { 1.0 }; + end.y = anchor.y + sign * remain.sqrt(); + } + } + end +} + +/// Shift line: slide along the ray. A position hit wins. Otherwise the length does. +fn snap_shift_line(cx: &ToolContext, spread: SpreadRef, anchor: Point, proposed: Point, guides: &mut Vec) -> Point { + let pos = line_hit(cx, spread, proposed, None); + if let Some(end) = ray_position(anchor, proposed, &pos) { + *guides = pos.guides; + return end; + } + let len = (proposed - anchor).hypot(); + let hit = line_hit(cx, spread, proposed, len.is_finite().then_some(len)); + let diff = finite_length(hit.length_delta[0]); + let end = match diff { + Some(diff) => length_slide(anchor, proposed, diff), + None => proposed, + }; + *guides = hit.guides; + if let Some(diff) = diff { + crate::snap::lay_line_dimension(guides, cx.layout.xf(spread), anchor, proposed, end, diff); + } + end +} + +fn snap_free_line(cx: &ToolContext, spread: SpreadRef, anchor: Point, proposed: Point, guides: &mut Vec) -> Point { + let len = (proposed - anchor).hypot(); + let hit = line_hit(cx, spread, proposed, len.is_finite().then_some(len)); + let end = commit_line_end(anchor, proposed, &hit); + let diff = finite_length(hit.length_delta[0]); + *guides = hit.guides; + if let Some(diff) = diff { + crate::snap::lay_line_dimension(guides, cx.layout.xf(spread), anchor, proposed, end, diff); + } + end +} + +/// Closest position correction, applied along the ray. None when neither axis has one. +fn ray_position(anchor: Point, end: Point, hit: &Snap) -> Option { + let dir = end - anchor; + let len = dir.hypot(); + if !len.is_finite() || len <= 1e-9 { + return None; + } + let ux = dir.x / len; + let uy = dir.y / len; + let slide = closer_slide(ux, uy, nonzero_delta(hit.delta.x), nonzero_delta(hit.delta.y))?; + let t = len + slide; + t.is_finite().then_some(Point::new(anchor.x + ux * t, anchor.y + uy * t)) +} + +fn nonzero_delta(delta: f64) -> Option { + (delta.is_finite() && delta.abs() > 1e-9).then_some(delta) +} + +fn closer_slide(ux: f64, uy: f64, x_pos: Option, y_pos: Option) -> Option { + let mut best: Option<(f64, f64)> = None; + if let Some(dx) = x_pos + && ux.abs() > 1e-9 + { + let slide = dx / ux; + if slide.is_finite() { + best = Some((dx.abs(), slide)); + } + } + if let Some(dy) = y_pos + && uy.abs() > 1e-9 + { + let slide = dy / uy; + if slide.is_finite() && best.is_none_or(|(dist, _)| dy.abs() < dist) { + best = Some((dy.abs(), slide)); + } + } + best.map(|(_, slide)| slide) +} + +/// Shift already constrained the shape. Snap only the driving edge, then rebuild the other. +fn restore_shift_frame(anchor: Point, end: Point, rect: Rect, driver_x: bool, from_center: bool) -> Rect { + if from_center { + let half = if driver_x { + let edge = if end.x >= anchor.x { rect.x1 } else { rect.x0 }; + (edge - anchor.x).abs() + } else { + let edge = if end.y >= anchor.y { rect.y1 } else { rect.y0 }; + (edge - anchor.y).abs() + }; + if !half.is_finite() { + return rect; + } + return Rect::new(anchor.x - half, anchor.y - half, anchor.x + half, anchor.y + half); + } + let span = if driver_x { rect.x1 - rect.x0 } else { rect.y1 - rect.y0 }; + if !span.is_finite() { + return rect; + } + let size = span.abs(); + let sx = (end.x - anchor.x).signum(); + let sy = (end.y - anchor.y).signum(); + if sx == 0.0 || sy == 0.0 || !size.is_finite() { + return rect; + } + if driver_x { + let y_far = anchor.y + sy * size; + let (y0, y1) = if sy > 0.0 { (anchor.y, y_far) } else { (y_far, anchor.y) }; + Rect::new(rect.x0, y0, rect.x1, y1) + } else { + let x_far = anchor.x + sx * size; + let (x0, x1) = if sx > 0.0 { (anchor.x, x_far) } else { (x_far, anchor.x) }; + Rect::new(x0, rect.y0, x1, rect.y1) + } +} + impl Tool for FrameTool { fn id(&self) -> &'static str { self.id @@ -146,46 +350,34 @@ impl Tool for FrameTool { ))); } if shape == "line" { - // Shift constrains the angle first. The free endpoint still snaps. + // Shift constrains the angle first. The endpoint then snaps along that ray. let mut b = cx.layout.to_spread(sr, ev.pos); if ev.mods.shift { let v = b - sa; b = sa + designcraft_geom::constrain_angle(v, 45.0); } if cx.snap.any() { - let len = (b - sa).hypot(); - let hit = crate::snap::snap( - cx, - SnapRequest { - spread: sr, - gesture: Gesture::Create, - rect: Rect::from_points(b, b), - x_edges: [true, true, true], - y_edges: [true, true, true], - exclude: &[], - copying: false, - lengths: [len.is_finite().then_some(len), None], - angle: None, - radius: 0.0, - pointer: b, - }, - ); - // Position and length both land on the endpoint. Rescaling the segment would leave the other axis. - if hit.delta.x.is_finite() { - b.x += hit.delta.x; - } - if hit.delta.y.is_finite() { - b.y += hit.delta.y; - } - self.guides = hit.guides; + b = if ev.mods.shift { + snap_shift_line(cx, sr, sa, b, &mut self.guides) + } else { + snap_free_line(cx, sr, sa, b, &mut self.guides) + }; } out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]}))); } else { - // Shift constrains the shape first. The free edges still snap. + // Shift constrains the shape first. Only the driving edge snaps, then the other follows. let end = cx.layout.to_spread(sr, ev.pos); let mut r = drag_rect(sa, end, ev.mods); if cx.snap.any() { - let (x_edges, y_edges) = moving_edges(sa, end, r, ev.mods.alt); + let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, ev.mods.alt); + let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs(); + if ev.mods.shift { + if driver_x { + y_edges = [false, false, false]; + } else { + x_edges = [false, false, false]; + } + } let hit = crate::snap::snap( cx, SnapRequest { @@ -203,6 +395,9 @@ impl Tool for FrameTool { }, ); r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta); + if ev.mods.shift { + r = restore_shift_frame(sa, end, r, driver_x, ev.mods.alt); + } self.guides = hit.guides; } self.last = Some((spread_json(sr), r)); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c28e4fe66..247494f84 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -250,7 +250,7 @@ impl ToolContext<'_> { self.doc.item(id) } - /// Canvas bounds of the selected items. + /// Canvas bounds of the selected items' paths. Handles and resize use this geometric box. pub fn selection_bounds(&self) -> Option { let mut r: Option = None; for id in &self.selection.items { @@ -260,6 +260,21 @@ impl ToolContext<'_> { } r } + + /// Canvas union of the selection's visible bounds. A group uses the alignment box. + pub fn selection_visible_bounds(&self) -> Option { + let mut acc: Option = None; + for id in &self.selection.items { + let (Some(it), Some(xf)) = (self.doc.item(*id), self.item_canvas_xf(*id)) else { continue }; + let Some(local) = crate::snap::moving_bounds(it) else { continue }; + let b = xf.transform_rect_bbox(local); + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { + continue; + } + acc = Some(acc.map_or(b, |have| have.union(b))); + } + acc + } } /// Visual feedback (canvas coordinates) drawn by the UI. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 07c470d0e..8f59c3503 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -445,7 +445,7 @@ impl Tool for SelectionTool { if hit && !cx.selection.items.is_empty() { let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0)); let exclude = cx.selection.items.clone(); - self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_bounds(), exclude }; + self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_visible_bounds(), exclude }; let label = if ev.mods.alt { "Duplicate" } else { "Move" }; let mut v = vec![Action::Begin(label.into())]; v.extend(self.pointer(cx, ev)); @@ -468,27 +468,38 @@ impl Tool for SelectionTool { screen_x_locked = true; } } + // Snap on the spread under the pointer. Shift uses that spread's view rotation. + let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread); // 180 degrees keeps the screen axis. 90 and 270 swap it into the other spread axis. - let odd_turn = cx.layout.slot(origin_spread).is_some_and(|slot| slot.rotation % 2 == 1); + let odd_turn = cx.layout.slot(target).is_some_and(|slot| slot.rotation % 2 == 1); let spread_x_locked = if odd_turn { screen_y_locked } else { screen_x_locked }; let spread_y_locked = if odd_turn { screen_x_locked } else { screen_y_locked }; let x_edges = if spread_x_locked { [false; 3] } else { [true; 3] }; let y_edges = if spread_y_locked { [false; 3] } else { [true; 3] }; self.guides.clear(); - // The movement in the spread's own coordinates (its view may be turned). + // The movement in the origin spread's coordinates (its view may be turned). let mut ds = cx.layout.delta_to_spread(origin_spread, d); let b0s = bounds0.map(|b| cx.layout.xf(origin_spread).inverse().transform_rect_bbox(b)); + let q0 = b0s.map_or(Point::ORIGIN, |b| Point::new(b.x0, b.y0)); + // Same corner conversion transform.move already uses when the spread changes. + let mut landed = cx.layout.to_spread(target, cx.layout.to_canvas(origin_spread, q0 + ds)); // Command suspends snapping. A Shift-locked spread axis is not offered. if !ev.mods.cmd && cx.snap.any() && let Some(b0) = b0s { + let proposed = if target == origin_spread { + b0 + ds + } else { + let shift = landed - q0; + Rect::new(b0.x0 + shift.x, b0.y0 + shift.y, b0.x1 + shift.x, b0.y1 + shift.y) + }; let hit = crate::snap::snap( cx, SnapRequest { - spread: origin_spread, + spread: target, gesture: Gesture::Move, - rect: b0 + ds, + rect: proposed, x_edges, y_edges, exclude: &exclude, @@ -496,26 +507,37 @@ impl Tool for SelectionTool { lengths: [None, None], angle: None, radius: 0.0, - pointer: cx.layout.to_spread(origin_spread, p), + pointer: cx.layout.to_spread(target, p), }, ); - if x_edges != [false, false, false] { - ds.x += hit.delta.x; - } - if y_edges != [false, false, false] { - ds.y += hit.delta.y; + if target == origin_spread { + if x_edges != [false, false, false] && hit.delta.x.is_finite() { + ds.x += hit.delta.x; + } + if y_edges != [false, false, false] && hit.delta.y.is_finite() { + ds.y += hit.delta.y; + } + } else { + if x_edges != [false, false, false] && hit.delta.x.is_finite() { + landed.x += hit.delta.x; + } + if y_edges != [false, false, false] && hit.delta.y.is_finite() { + landed.y += hit.delta.y; + } } self.guides = hit.guides; } - // Dragging to another spread moves the items there. - let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread); - let mut params = json!({"dx": ds.x, "dy": ds.y, "copy": ev.mods.alt}); - if target != origin_spread { - // Where the selection's corner lands, in the target spread. - let q0 = b0s.map_or(Point::ORIGIN, |b| Point::new(b.x0, b.y0)); - let landed = cx.layout.to_spread(target, cx.layout.to_canvas(origin_spread, q0 + ds)); + // Same spread: dx, dy are the origin spread translation. Crossing: the corner + // conversion, so the reparented item lands on the snapped target point. + let (dx, dy) = if target == origin_spread { + (ds.x, ds.y) + } else { let dt = landed - q0; - params = json!({"dx": dt.x, "dy": dt.y, "copy": ev.mods.alt, "toSpread": spread_json(target)}); + (dt.x, dt.y) + }; + let mut params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt}); + if target != origin_spread { + params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt, "toSpread": spread_json(target)}); } vec![Action::Preview("transform.move".into(), params)] } diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 92b0417eb..c3b251754 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -252,14 +252,15 @@ fn zone_tol(cx: &ToolContext) -> Option { /// Matched rotation, in the same degrees `transform.rotate` receives. /// /// Runs only for [`Gesture::Rotate`], and only when Smart Guides and Smart Dimensions are on. -/// `angle` is compared with `decompose(item.xf).rotation` in the same sign. The tolerance is -/// the zone length subtended at `radius` (`atan(zone / radius)` in degrees). A radius below -/// 1 pt, or a non-finite angle, radius, or zone, does not hit. Candidates are visible items -/// on the requested spread, including children of a group, plus shown parent items. Each -/// candidate is that item's own transform, not an ancestor composed in. Hidden items and the -/// shared exclude list are skipped. An unrotated item contributes 0. The closest angle wins, -/// folding whole turns so a few degrees across 0 still match. The measure is the angle in -/// degrees and there is no guide line. +/// `angle` is compared with `decompose` of the absolute transform in the same sign. That +/// transform is the ancestor chain composed with `item.xf`. A top-level item has no ancestor, +/// so it stays `decompose(item.xf)`. The tolerance is the zone length subtended at `radius` +/// (`atan(zone / radius)` in degrees). A radius below 1 pt, or a non-finite angle, radius, or +/// zone, does not hit. Candidates are visible items on the requested spread, including children +/// of a group, plus shown parent items. A child inside a rotated group uses the composed angle, +/// not its local 0. Hidden items and the shared exclude list are skipped. An unrotated item +/// contributes 0. The closest angle wins, folding whole turns so a few degrees across 0 still +/// match. The measure is the angle in degrees and there is no guide line. fn rotation_snap(cx: &ToolContext, req: &SnapRequest<'_>, zone_length: f64) -> Snap { let Some(angle) = match_rotation(cx, req, zone_length) else { return Snap::default(); @@ -288,7 +289,7 @@ fn match_rotation(cx: &ToolContext, req: &SnapRequest<'_>, zone_length: f64) -> let mut seen = HashSet::new(); if let Some(spread) = cx.doc.spread(req.spread) { for item in &spread.items { - push_rotation(&mut best, cx.selection, req, item, false, 0, &mut seen, proposed, tol); + push_rotation(&mut best, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen, proposed, tol); } } // Parent editing already lists those items on the spread. Do not add them again. @@ -299,7 +300,7 @@ fn match_rotation(cx: &ToolContext, req: &SnapRequest<'_>, zone_length: f64) -> } /// Shown parent items, same page and `based_on` walk as parent lengths. -/// Each item is passed through [`push_rotation`], so the angle is still its own `xf`. +/// Parent items are only translated onto the page, so the angle is still `item.xf`. fn push_parent_rotations( best: &mut Option<(f64, f64)>, cx: &ToolContext, @@ -351,7 +352,7 @@ fn push_parent_rotation_chain( if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) { continue; } - push_rotation(best, cx.selection, req, item, false, 0, seen_items, proposed, tol); + push_rotation(best, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items, proposed, tol); } } let Some(next_id) = next else { return }; @@ -364,6 +365,7 @@ fn push_rotation( selection: &Selection, req: &SnapRequest<'_>, item: &Item, + ancestor: Affine, skipped: bool, depth: usize, seen: &mut HashSet, @@ -373,32 +375,36 @@ fn push_rotation( if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden { return; } + let abs = ancestor * item.xf; let skip = skipped || align_skips_item(selection, req, item.id); - if !skip && let Some(angle) = own_rotation(item) { + if !skip && let Some(angle) = rotation_of(abs) { let dist = angular_distance(proposed, angle); if dist.is_finite() && dist <= tol && best.is_none_or(|(have, _)| dist < have) { *best = Some((dist, angle)); } } + if !xf_finite(abs) { + return; + } for child in item.children() { - push_rotation(best, selection, req, child, skip, depth + 1, seen, proposed, tol); + push_rotation(best, selection, req, child, abs, skip, depth + 1, seen, proposed, tol); } } -/// `item.xf` alone. A non-finite transform contributes nothing, and identity contributes 0. -fn own_rotation(item: &Item) -> Option { - if !xf_finite(item.xf) { +/// Decompose rotation of an absolute transform. A non-finite matrix contributes nothing. +fn rotation_of(xf: Affine) -> Option { + if !xf_finite(xf) { return None; } - let rotation = designcraft_geom::decompose::decompose(item.xf).rotation; + let rotation = designcraft_geom::decompose::decompose(xf).rotation; rotation.is_finite().then_some(rotation) } -/// Decompose rotation of the first selected item, captured before a preview changes `xf`. -/// None when nothing is selected. +/// Decompose rotation of the first selected item's own `xf`, captured before a preview changes it. +/// None when nothing is selected. The command composes onto that `xf`, so ancestors stay out. pub(crate) fn reference_rotation(cx: &ToolContext) -> Option { let id = *cx.selection.items.first()?; - own_rotation(cx.doc.item(id)?) + rotation_of(cx.doc.item(id)?.xf) } /// Degrees folded into the range `decompose` reports, `(-180, 180]`. @@ -961,6 +967,38 @@ pub(crate) fn lay_dimension_guides( } } +/// Move a line's dimension tick onto the committed segment. +/// +/// The dimensions pass draws a short horizontal tick at the proposed endpoint because the +/// length is carried on x. The guide that remains is the segment from the anchor to the +/// endpoint the gesture commits. +pub(crate) fn lay_line_dimension(guides: &mut Vec, xf: Affine, anchor: Point, proposed: Point, committed: Point, diff: f64) { + if !diff.is_finite() { + return; + } + let rect = Rect::from_points(proposed, proposed); + let Some((old_a, old_b)) = dimension_segment(rect, Axis::X, [true, true, true], diff) else { return }; + let (oa, ob) = (xf * old_a, xf * old_b); + let (na, nb) = (xf * anchor, xf * committed); + if !point_finite(na) || !point_finite(nb) { + return; + } + let mut found = false; + for g in guides.iter_mut() { + if let Overlay::Guide { a, b } = g + && segment_same(*a, *b, oa, ob) + { + *a = na; + *b = nb; + found = true; + break; + } + } + if !found { + guides.push(Overlay::Guide { a: na, b: nb }); + } +} + fn segment_same(a0: Point, b0: Point, a1: Point, b1: Point) -> bool { let close = |p: Point, q: Point| (p.x - q.x).abs() <= 1e-6 && (p.y - q.y).abs() <= 1e-6; (close(a0, a1) && close(b0, b1)) || (close(a0, b1) && close(b0, a1)) @@ -1172,8 +1210,9 @@ const ALIGN_GROUP_DEPTH: usize = 32; /// `align_centers` is on, and only when the request's middle flag is set. Hidden items are /// skipped. Without copying, excluded items are skipped. With copying, that list is the /// drag-start set and stays a target. Live selection items outside that set are preview -/// copies and are skipped. A group with children uses the union of those children's visible -/// bounds in group space, then the group's transform. It does not use the group's own stroke. +/// copies and are skipped. A group with children uses the union of each child's visible bounds +/// after that child's transform, then the group's transform. Transform each child, then union. +/// It does not rotate the local union as one rectangle, and it does not use the group's own stroke. /// Parent items are added for a document page that shows them, in document spread space. /// A layout built for parent editing already has those items on the spread, so they are not /// added again. @@ -1221,18 +1260,42 @@ fn item_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item) - rect_finite(bounds).then_some(bounds) } -/// Union of children's visible bounds in this group's space, then `item.xf`. +/// Union of each child's visible bounds transformed by `item.xf`. +/// +/// Transform each child, then union. Rotating the local union as one rectangle is larger when +/// a rotated group's children do not fill that rectangle. Hidden and excluded children stay out. +/// Nested groups use the same rule. A child's stroke is part of that child's visible bounds. +/// The group's own stroke is not added. fn group_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item, seen: &mut HashSet, depth: usize) -> Option { + group_box(item, seen, depth, Some((selection, req))) +} + +/// Visible box of an item being moved. Groups use the same box as alignment, with no exclude list. +pub(crate) fn moving_bounds(item: &Item) -> Option { + if item.is_group() && !item.children().is_empty() { + let mut seen = HashSet::new(); + return group_box(item, &mut seen, 0, None); + } + let bounds = item.visible_bounds(); + rect_finite(bounds).then_some(bounds) +} + +fn group_box(item: &Item, seen: &mut HashSet, depth: usize, filter: Option<(&Selection, &SnapRequest<'_>)>) -> Option { if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) { return None; } let mut acc: Option = None; for child in item.children() { - if child.hidden || align_skips_item(selection, req, child.id) { + if child.hidden { + continue; + } + if let Some((selection, req)) = filter + && align_skips_item(selection, req, child.id) + { continue; } let local = if child.is_group() && !child.children().is_empty() { - group_target_rect(selection, req, child, seen, depth + 1) + group_box(child, seen, depth + 1, filter) } else { let bounds = child.visible_bounds(); rect_finite(bounds).then_some(bounds) @@ -1241,9 +1304,16 @@ fn group_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item, if !rect_finite(local) { continue; } - acc = Some(acc.map_or(local, |have| have.union(local))); + let placed = item.xf.transform_rect_bbox(local); + if !rect_finite(placed) { + continue; + } + acc = Some(match acc { + Some(have) => have.union(placed), + None => placed, + }); } - acc.map(|r| item.xf.transform_rect_bbox(r)).filter(|r| rect_finite(*r)) + acc.filter(|r| rect_finite(*r)) } /// Parent items shown on the document spread, shifted into that spread's space. @@ -1992,4 +2062,80 @@ mod tests { let angle = hit.angle.expect("parent rotation hit"); assert!((angle - 30.0).abs() < 1e-6); } + + #[test] + fn rotated_group_uses_the_per_child_union() { + // Two children do not fill their local union. Rotating that union as one rectangle + // swings the empty corners out. The snap target is each child's visible box, transformed, then united. + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let mut child_a = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + child_a.stroke = Stroke { weight: 2.0, ..Stroke::default() }; + let child_b = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 30.0, 50.0, 40.0))); + let mut group = Item::new(ItemId(doc.alloc()), layer, Shape::Group, designcraft_geom::PathData::default()); + let xf = Affine::translate((300.0, 400.0)) * Affine::rotate((-45.0_f64).to_radians()); + group.xf = xf; + group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child_a), Arc::new(child_b)] }; + doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let a_vis = Rect::new(-1.0, -1.0, 11.0, 11.0); + let b_vis = Rect::new(40.0, 30.0, 50.0, 40.0); + let per_child = xf.transform_rect_bbox(a_vis).union(xf.transform_rect_bbox(b_vis)); + let rotated_union = xf.transform_rect_bbox(a_vis.union(b_vis)); + // At 45 degrees the empty corners move y, not x. The two boxes share x0. + assert!((per_child.y0 - rotated_union.y0).abs() > 1.0, "fixture does not separate the two boxes"); + let gap = 2.0; + // A short rect just above the per-child top. Its other edges stay outside the zone of the rotated union. + let moving = Rect::new(per_child.x0, per_child.y0 - gap - 4.0, per_child.x0 + 20.0, per_child.y0 - gap); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let mut cx = ctx_on(&doc, &sel, &cache, &layout); + cx.snap.snap_to_guides = false; + cx.snap.snap_to_document_grid = false; + let hit = snap(&cx, y_req(moving)); + assert!((hit.delta.y - gap).abs() < 1e-4, "delta.y {}, want {gap} onto {}", hit.delta.y, per_child.y0); + let wrong = rotated_union.y0 - moving.y1; + assert!((hit.delta.y - wrong).abs() > 1.0, "delta matched the rotated local union"); + } + + #[test] + fn rotated_group_child_does_not_offer_local_zero() { + // The group is at 30 degrees. Its child stays at local 0, so the absolute angle is 30. + // 2 degrees is inside the zone of 0 and far from 30. Local 0 must not win. + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let child = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0))); + let mut group = Item::new(ItemId(doc.alloc()), layer, Shape::Group, designcraft_geom::PathData::default()); + group.xf = Affine::rotate((-30.0_f64).to_radians()); + group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] }; + doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let mut cx = ctx_on(&doc, &sel, &cache, &layout); + cx.snap.snap_to_guides = false; + let ask = |angle: f64| { + snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Rotate, + rect: Rect::new(100.0, 100.0, 160.0, 140.0), + x_edges: [false, false, false], + y_edges: [false, false, false], + exclude: &[], + copying: false, + lengths: [None, None], + angle: Some(angle), + radius: 80.0, + pointer: Point::new(180.0, 120.0), + }, + ) + }; + let near_zero = ask(2.0); + assert!(near_zero.angle.is_none(), "local 0 was a target: {:?}", near_zero.angle); + let near_group = ask(28.0); + let angle = near_group.angle.expect("absolute 30"); + assert!((angle - 30.0).abs() < 1e-6, "angle {angle}"); + } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index b2cb2c314..6c75b0da6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; -use designcraft_doc::{Document, Guide, Item, ItemId, Orientation, ParaFormat, Selection, Shape, SpreadRef}; +use designcraft_doc::{Document, Guide, Item, ItemId, Orientation, ParaFormat, Selection, Shape, SpreadRef, Stroke}; use designcraft_geom::shapes; use designcraft_geom::{Point, Rect, Unit}; @@ -399,9 +399,162 @@ fn line_length_keeps_the_crossed_guide() { let end = params["b"].as_array().unwrap(); let (bx, by) = (end[0].as_f64().unwrap(), end[1].as_f64().unwrap()); let len = 80.0_f64.hypot(40.0); - let expect_x = 180.0 + (92.0 - len); + let target = 92.0_f64; + let dy = 142.0 - 100.0; + // Y stays on the guide. X is solved so the segment length is 92, not by adding the difference to x. + let remain = target * target - dy * dy; + let expect_x = 100.0 + remain.sqrt(); assert!((by - 142.0).abs() < 1e-4, "endpoint y {by} left the guide at 142 (x {bx})"); - assert!((bx - expect_x).abs() < 1e-4, "endpoint x {bx}, want {expect_x} from the length delta on X only"); + assert!((bx - expect_x).abs() < 1e-4, "endpoint x {bx}, want {expect_x}"); + let got = (bx - 100.0).hypot(by - 100.0); + assert!((got - target).abs() < 1e-4, "length {got}, raw was {len}"); + assert!((bx - (180.0 + (target - len))).abs() > 0.1, "x still took the raw length difference"); +} + +#[test] +fn stroked_move_aligns_visible_edges() { + // 2 pt centered strokes. The other path ends at 70, so its visible edge is 71. + // A geometric move would land the path on 71. The visible edge must land there instead. + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let stroke = Stroke { weight: 2.0, ..Stroke::default() }; + let make = |doc: &mut Document, rect: Rect| { + let id = ItemId(doc.alloc()); + let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); + item.stroke = stroke.clone(); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + id + }; + let _other = make(&mut doc, Rect::new(40.0, 100.0, 70.0, 140.0)); + let moving = make(&mut doc, Rect::new(100.0, 100.0, 160.0, 140.0)); + let sel = Selection::items(vec![moving]); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let cx = dims_ctx(&doc, &sel, &cache, &layout, true); + let mut t = create("selection"); + let down = spread_pt(&layout, 130.0, 120.0); + let drag = spread_pt(&layout, 103.0, 120.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y)); + let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag.x, drag.y)); + let dx = match actions.last() { + Some(Action::Preview(cmd, p)) => { + assert_eq!(cmd, "transform.move"); + p["dx"].as_f64().unwrap() + } + other => panic!("{other:?}"), + }; + assert!((dx - -28.0).abs() < 1e-4, "dx {dx}, want -28 so the visible edge meets 71"); +} + +#[test] +fn vertical_line_length_matches_along_the_segment() { + // Vertical, length 80, near a 84 side. The match must move y, not add 4 to x. + let mut doc = Document::new(&NewDocument::default()); + add_rect(&mut doc, Rect::new(400.0, 400.0, 450.0, 484.0)); + let sel = Selection::default(); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let cx = dims_ctx(&doc, &sel, &cache, &layout, false); + let mut t = create("line"); + let a = spread_pt(&layout, 100.0, 100.0); + let b = spread_pt(&layout, 100.0, 180.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, a.x, a.y)); + let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, b.x, b.y)); + let params = actions + .iter() + .find_map(|act| match act { + Action::Preview(cmd, p) if cmd == "line.create" => Some(p), + _ => None, + }) + .unwrap(); + let end = params["b"].as_array().unwrap(); + let (bx, by) = (end[0].as_f64().unwrap(), end[1].as_f64().unwrap()); + assert!((bx - 100.0).abs() < 1e-4, "x moved to {bx}"); + assert!((by - 184.0).abs() < 1e-4, "y {by}, want 184"); + let xf = layout.xf(SpreadRef::Doc(0)); + let seg = (xf * Point::new(100.0, 100.0), xf * Point::new(100.0, 184.0)); + let overlays = t.overlays(&cx); + assert!(guide_matches(&overlays, seg.0, seg.1), "guide is not the segment [{}]", guide_list(&overlays)); +} + +#[test] +fn shift_frame_stays_square_on_the_driving_guide() { + // Pointer dx is larger, so x drives. The square's right edge is 3 pt from the guide at 203. + // Y must follow that snapped size, not stay at the unconstrained square. + let mut doc = Document::new(&NewDocument::default()); + doc.settings.baseline_grid.increment = 0.0; + Arc::make_mut(&mut doc.spreads[0]).pages[0].guides.push(Guide { + orientation: Orientation::Vertical, + position: 203.0, + spread: true, + locked: false, + layer: None, + liquid: false, + }); + let sel = Selection::default(); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let mut cx = ctx(&doc, &sel, &cache, &layout); + cx.snap = SnapView::FACTORY; + cx.snap.snap_to_document_grid = false; + cx.snap.smart_dimensions = false; + let mut t = create("rectangle"); + let mods = Mods { shift: true, ..Mods::default() }; + let a = spread_pt(&layout, 120.0, 120.0); + let b = spread_pt(&layout, 200.0, 150.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, a.x, a.y).with_mods(mods)); + let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, b.x, b.y).with_mods(mods)); + let rect = actions + .iter() + .find_map(|act| match act { + Action::Preview(cmd, p) if cmd == "frame.create" => p["rect"].as_array(), + _ => None, + }) + .unwrap(); + let (x0, y0, x1, y1) = (rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap(), rect[2].as_f64().unwrap(), rect[3].as_f64().unwrap()); + assert!((x1 - 203.0).abs() < 1e-4, "driving edge {x1} left the guide"); + assert!((y1 - y0 - (x1 - x0)).abs() < 1e-4, "not square: {x0} {y0} {x1} {y1}"); + assert!((y1 - 203.0).abs() < 1e-4, "other edge {y1} did not follow the snapped size"); +} + +#[test] +fn drag_onto_the_next_spread_snaps_to_that_guide() { + // The origin spread has a guide 4 pt from the unsnapped top. The destination guide is 2 pt away. + // Snapping on the origin would move by 4. Snapping under the pointer moves by 2. + let mut doc = Document::new(&NewDocument { pages: 2, facing_pages: false, ..NewDocument::default() }); + doc.settings.baseline_grid.increment = 0.0; + let id = add_rect(&mut doc, Rect::new(100.0, 100.0, 160.0, 140.0)); + let guide = |position: f64| Guide { orientation: Orientation::Horizontal, position, spread: true, locked: false, layer: None, liquid: false }; + Arc::make_mut(&mut doc.spreads[1]).pages[0].guides.push(guide(52.0)); + let sel = Selection::items(vec![id]); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + assert_eq!(layout.slots.len(), 2); + let item = Rect::new(100.0, 100.0, 160.0, 140.0); + let raw_top = 50.0; + let corner0 = layout.to_canvas(SpreadRef::Doc(0), Point::new(item.x0, item.y0)); + let corner_raw = layout.to_canvas(SpreadRef::Doc(1), Point::new(item.x0, raw_top)); + let travel = designcraft_geom::Vec2::new(corner_raw.x - corner0.x, corner_raw.y - corner0.y); + let origin_top = item.y0 + layout.delta_to_spread(SpreadRef::Doc(0), travel).y; + Arc::make_mut(&mut doc.spreads[0]).pages[0].guides.push(guide(origin_top - 4.0)); + let mut cx = ctx(&doc, &sel, &cache, &layout); + cx.snap = SnapView::FACTORY; + cx.snap.snap_to_document_grid = false; + let down = layout.to_canvas(SpreadRef::Doc(0), item.center()); + let drag_at = Point::new(down.x + (corner_raw.x - corner0.x), down.y + (corner_raw.y - corner0.y)); + let mut t = create("selection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y)); + let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag_at.x, drag_at.y)); + let params = match actions.last() { + Some(Action::Preview(cmd, p)) => { + assert_eq!(cmd, "transform.move"); + p + } + other => panic!("{other:?}"), + }; + let dy = params["dy"].as_f64().unwrap(); + assert_eq!(params["toSpread"], spread_json(SpreadRef::Doc(1))); + assert!((dy - (52.0 - item.y0)).abs() < 1e-3, "dy {dy}, origin snap would not land on 52"); } #[test]