From 8e7cff341fad99a9891f50bd1202a92caea681e4 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy Date: Tue, 6 Oct 2026 02:46:04 +1100 Subject: [PATCH] Snap: dimension guides follow the final edge Shift plus Alt restores the aspect from the original center after the size snaps. A line keeps the runner delta on both axes. Dimension guides are redrawn on the committed side. Grouped own size decomposes the absolute transform once. --- crates/tools/src/frame.rs | 19 +--- crates/tools/src/select.rs | 87 +++++++++++++++-- crates/tools/src/snap.rs | 154 ++++++++++++++++++++++++----- crates/tools/src/tests.rs | 193 ++++++++++++++++++++++++++++++++++++- crates/tools/src/xform.rs | 24 +++-- 5 files changed, 419 insertions(+), 58 deletions(-) diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 0e913eccf..b7e94412b 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -170,23 +170,12 @@ impl Tool for FrameTool { pointer: b, }, ); - if hit.length_delta[1].is_none() { - b.y += hit.delta.y; - } - if hit.length_delta[0].is_none() { + // 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 let Some(diff) = hit.length_delta[0] - && diff.is_finite() - { - let v = b - sa; - let cur = v.hypot(); - if cur > 1e-9 { - let next = cur + diff; - if next.is_finite() { - b = sa + v * (next / cur); - } - } + if hit.delta.y.is_finite() { + b.y += hit.delta.y; } self.guides = hit.guides; } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index d00a8d94b..77baa8bdb 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -198,6 +198,77 @@ fn apply_aspect(from: Rect, handle: usize, rect: Rect, s: f64) -> Rect { Rect::new(x0, y0, x1, y1) } +/// Snap changes the size. Shift then restores the aspect, and Alt places that size on the original center. +/// +/// Nudging one edge of an already centered rect walks the center. Mirroring that nudge doubles a length match. +fn finish_snapped_resize( + from: Rect, + handle: usize, + proposed: Rect, + mods: Mods, + driver_x: bool, + x_edges: [bool; 3], + y_edges: [bool; 3], + hit: &crate::Snap, +) -> Rect { + if !mods.alt { + let mut to = crate::snap::nudge_edges(proposed, x_edges, y_edges, hit.delta); + if mods.shift && from.width() > 1e-9 && from.height() > 1e-9 { + let s = if driver_x { (to.x1 - to.x0) / from.width() } else { (to.y1 - to.y0) / from.height() }; + if s.is_finite() { + to = apply_aspect(from, handle, to, s); + } + } + return to; + } + let mut w = proposed.x1 - proposed.x0; + let mut h = proposed.y1 - proposed.y0; + w += centered_size_delta(x_edges, hit.delta.x, hit.length_delta[0]); + h += centered_size_delta(y_edges, hit.delta.y, hit.length_delta[1]); + if mods.shift && from.width() > 1e-9 && from.height() > 1e-9 { + if driver_x { + let s = w / from.width(); + if s.is_finite() { + h = from.height() * s; + } + } else { + let s = h / from.height(); + if s.is_finite() { + w = from.width() * s; + } + } + } + if !(w.is_finite() && h.is_finite()) { + return proposed; + } + let c = from.center(); + Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0) +} + +/// Length match: add the difference once. Position match: the flagged edge moves and the other side mirrors. +fn centered_size_delta(edges: [bool; 3], delta: f64, length_delta: Option) -> f64 { + if let Some(diff) = length_delta.filter(|d| d.is_finite()) { + return diff; + } + let edge = axis_size_delta(edges, delta); + if edge.is_finite() { edge * 2.0 } else { 0.0 } +} + +/// Signed change in `hi - lo` when one flagged edge moves by `delta`. +fn axis_size_delta(edges: [bool; 3], delta: f64) -> f64 { + if !delta.is_finite() { + return 0.0; + } + let [low_edge, _, high_edge] = edges; + if high_edge && !low_edge { + delta + } else if low_edge && !high_edge { + -delta + } else { + 0.0 + } +} + fn apply_alt(from: Rect, handle: usize, rect: Rect) -> Rect { let (x0, y0, x1, y1) = (rect.x0, rect.y0, rect.x1, rect.y1); let c = from.center(); @@ -424,29 +495,25 @@ impl Tool for SelectionTool { } let exclude = cx.selection.items.clone(); let (pad_x, pad_y) = single_stroke_pad(cx); - let hit = crate::snap::snap( + let proposed = to; + let mut hit = crate::snap::snap( cx, SnapRequest { spread, gesture: Gesture::Resize, - rect: to, + rect: proposed, x_edges, y_edges, exclude: &exclude, copying: false, - lengths: [axis_length(x_edges, to.width(), pad_x), axis_length(y_edges, to.height(), pad_y)], + lengths: [axis_length(x_edges, proposed.width(), pad_x), axis_length(y_edges, proposed.height(), pad_y)], angle: None, radius: 0.0, pointer, }, ); - to = crate::snap::nudge_edges(to, x_edges, y_edges, hit.delta); - if ev.mods.shift && !ev.mods.alt && from.width() > 1e-9 && from.height() > 1e-9 { - let s = if driver_x { (to.x1 - to.x0) / from.width() } else { (to.y1 - to.y0) / from.height() }; - if s.is_finite() { - to = apply_aspect(from, handle, to, s); - } - } + to = finish_snapped_resize(from, handle, proposed, ev.mods, driver_x, x_edges, y_edges, &hit); + crate::snap::lay_dimension_guides(&mut hit.guides, cx.layout.xf(spread), proposed, to, x_edges, y_edges, hit.length_delta); self.guides = hit.guides; } vec![Action::Preview( diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 3f962de78..41d12db4a 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use designcraft_doc::{Item, ItemId, Orientation, PageSide, Selection, SpreadId, SpreadRef, StrokeAlign}; use designcraft_geom::snap::snap_to_grid; -use designcraft_geom::{Point, Rect, Vec2, format_measure}; +use designcraft_geom::{Affine, Point, Rect, Vec2, format_measure}; pub use crate::{Gesture, Snap, SnapRequest, SnapView}; @@ -384,10 +384,11 @@ fn pass_spacing(_cx: &ToolContext, _req: &SnapRequest<'_>, _axis: Axis, _tol: f6 /// Other items' own side lengths, on resize and create. /// /// Own size is the path box in item space, inflated the way [`Item::visible_bounds`] inflates, -/// then multiplied by the absolute scale (ancestors included, rotation dropped). Width and height -/// are both candidates for whichever length is set. The closest length inside the zone wins. -/// Delta is the length difference applied from the moving edge. A rotated rectangle contributes -/// its side lengths, not the axis-aligned box. +/// then scaled by the absolute transform. That transform is the parent chain composed with the +/// item and decomposed once, so a rotated child inside a non-uniform group keeps the spread-space +/// sides. Rotation is dropped. Width and height are both candidates for whichever length is set. +/// The closest length inside the zone wins. Delta is the length difference applied from the moving +/// edge. A rotated rectangle contributes its side lengths, not the axis-aligned box. fn pass_dimensions(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { if !matches!(req.gesture, Gesture::Resize | Gesture::Create) || !cx.snap.smart_guides || !cx.snap.smart_dimensions { return None; @@ -441,8 +442,15 @@ fn length_edge_delta(flags: [bool; 3], diff: f64) -> f64 { /// Guide along the side whose length matched, after the edge has moved. fn dimension_mark(req: &SnapRequest<'_>, axis: Axis, diff: f64, text: String) -> DimMark { - let rect = req.rect; - let flags = axis_flags(req, axis); + let (a, b) = dimension_segment(req.rect, axis, axis_flags(req, axis), diff).unwrap_or((Point::ORIGIN, Point::ORIGIN)); + DimMark { a, b, text } +} + +/// Spread-space segment along the matched side. `diff` 0 is the side of `rect` itself. +fn dimension_segment(rect: Rect, axis: Axis, flags: [bool; 3], diff: f64) -> Option<(Point, Point)> { + if !diff.is_finite() || !rect_finite(rect) { + return None; + } let (a, b) = match axis { Axis::X => { let (x0, x1) = grown_span(rect.x0, rect.x1, flags, diff); @@ -455,7 +463,59 @@ fn dimension_mark(req: &SnapRequest<'_>, axis: Axis, diff: f64, text: String) -> (Point::new(x, y0), Point::new(x, y1)) } }; - DimMark { a, b, text } + (point_finite(a) && point_finite(b)).then_some((a, b)) +} + +fn point_finite(p: Point) -> bool { + p.x.is_finite() && p.y.is_finite() +} + +/// Move dimension guides onto the rect the gesture commits. +/// +/// Snap draws the guide on `pre` plus the length difference along the flagged edge. Aspect re-lock +/// and a centered scale move that side afterwards. The guide then follows the committed side. +pub(crate) fn lay_dimension_guides( + guides: &mut Vec, + xf: Affine, + pre: Rect, + committed: Rect, + x_edges: [bool; 3], + y_edges: [bool; 3], + length_delta: [Option; 2], +) { + if !rect_finite(committed) { + return; + } + let axes = [(Axis::X, x_edges, length_delta[0]), (Axis::Y, y_edges, length_delta[1])]; + for (axis, edges, diff) in axes { + let Some(diff) = diff else { continue }; + let Some((old_a, old_b)) = dimension_segment(pre, axis, edges, diff) else { continue }; + let Some((new_a, new_b)) = dimension_segment(committed, axis, edges, 0.0) else { continue }; + let (oa, ob) = (xf * old_a, xf * old_b); + let (na, nb) = (xf * new_a, xf * new_b); + if !point_finite(na) || !point_finite(nb) { + continue; + } + 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)) } fn grown_span(a0: f64, a1: f64, flags: [bool; 3], diff: f64) -> (f64, f64) { @@ -477,7 +537,7 @@ fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec { let mut seen = HashSet::new(); if let Some(sp) = cx.doc.spread(req.spread) { for item in &sp.items { - push_own_lengths(&mut out, cx.selection, req, item, 1.0, 1.0, false, 0, &mut seen); + push_own_lengths(&mut out, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen); } } if !layout_edits_parents(cx) { @@ -491,8 +551,7 @@ fn push_own_lengths( selection: &Selection, req: &SnapRequest<'_>, item: &Item, - ancestor_sx: f64, - ancestor_sy: f64, + ancestor: Affine, skipped: bool, depth: usize, seen: &mut HashSet, @@ -500,29 +559,32 @@ fn push_own_lengths( if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden { return; } + let abs = ancestor * item.xf; + if !xf_finite(abs) { + return; + } let skip = skipped || align_skips_item(selection, req, item.id); if item.is_group() && !item.children().is_empty() { - let decomposed = designcraft_geom::decompose::decompose(item.xf); - let sx = ancestor_sx * decomposed.scale_x.abs(); - let sy = ancestor_sy * decomposed.scale_y.abs(); - if !sx.is_finite() || !sy.is_finite() { - return; - } for child in item.children() { - push_own_lengths(out, selection, req, child, sx, sy, skip, depth + 1, seen); + push_own_lengths(out, selection, req, child, abs, skip, depth + 1, seen); } return; } if skip { return; } - let Some((w, h)) = leaf_own_size(item, ancestor_sx, ancestor_sy) else { return }; + let Some((w, h)) = leaf_own_size(item, abs) else { return }; out.push(w); out.push(h); } -/// Path bounds in item space, stroke outset, then absolute scale. Rotation is not applied. -fn leaf_own_size(item: &Item, ancestor_sx: f64, ancestor_sy: f64) -> Option<(f64, f64)> { +fn xf_finite(m: Affine) -> bool { + m.as_coeffs().iter().all(|c| c.is_finite()) +} + +/// Path bounds in item space, stroke outset, then one decompose of the absolute transform. +/// Rotation is not applied to the side lengths. +fn leaf_own_size(item: &Item, abs: Affine) -> Option<(f64, f64)> { let local = item.path.bounds()?; if !rect_finite(local) { return None; @@ -535,9 +597,9 @@ fn leaf_own_size(item: &Item, ancestor_sx: f64, ancestor_sy: f64) -> Option<(f64 if !rect_finite(local) { return None; } - let decomposed = designcraft_geom::decompose::decompose(item.xf); - let sx = ancestor_sx * decomposed.scale_x.abs(); - let sy = ancestor_sy * decomposed.scale_y.abs(); + let decomposed = designcraft_geom::decompose::decompose(abs); + let sx = decomposed.scale_x.abs(); + let sy = decomposed.scale_y.abs(); if !sx.is_finite() || !sy.is_finite() { return None; } @@ -601,7 +663,7 @@ fn push_parent_length_chain( if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) { continue; } - push_own_lengths(out, cx.selection, req, item, 1.0, 1.0, false, 0, seen_items); + push_own_lengths(out, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items); } } let Some(next_id) = next else { return }; @@ -1278,4 +1340,46 @@ mod tests { assert!((hit.delta.x - 3.0).abs() < 1e-6); assert!(matches!(hit.guides.last(), Some(Overlay::Measure { text, .. }) if text.contains("200"))); } + + #[test] + fn grouped_child_matches_spread_side() { + // Group scale 2 on X and 1 on Y. Child rotated -90 degrees (decompose reports +90). + // Spread-space sides are local width times 1 (100) and local height times 2 (80). + // 82 is 2 pt from 80 and far from 200, which is what per-node scales would offer. + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let child_id = ItemId(doc.alloc()); + let mut child = Item::new(child_id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0))); + child.xf = Affine::rotate((-90.0_f64).to_radians()); + let group_id = ItemId(doc.alloc()); + let mut group = Item::new(group_id, layer, Shape::Group, designcraft_geom::PathData::default()); + group.xf = Affine::scale_non_uniform(2.0, 1.0); + group.content = designcraft_doc::Content::Group { items: vec![std::sync::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; + cx.snap.snap_to_document_grid = false; + cx.snap.align_edges = false; + cx.snap.align_centers = false; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Resize, + rect: Rect::new(200.0, 200.0, 282.0, 240.0), + x_edges: [false, false, true], + y_edges: [false, false, false], + exclude: &[], + copying: false, + lengths: [Some(82.0), None], + angle: None, + radius: 0.0, + pointer: Point::new(282.0, 220.0), + }, + ); + assert!((hit.delta.x - -2.0).abs() < 1e-6, "delta.x = {}, want -2 toward local height times 2", hit.delta.x); + } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index c47595fc5..dcbf815d1 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1,7 +1,8 @@ use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; -use designcraft_doc::{Document, ParaFormat, Selection, SpreadRef}; -use designcraft_geom::{Rect, Unit}; +use designcraft_doc::{Document, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef}; +use designcraft_geom::shapes; +use designcraft_geom::{Point, Rect, Unit}; use super::*; @@ -292,3 +293,191 @@ fn turned_spread_maps_points_both_ways() { let dv = slot.delta_to_spread(designcraft_geom::Vec2::new(10.0, 0.0)); assert!(dv.x.abs() < 1e-9 && (dv.y + 10.0).abs() < 1e-9 || (dv.y - 10.0).abs() < 1e-9, "{dv:?}"); } + +fn add_rect(doc: &mut Document, rect: Rect) -> ItemId { + let layer = doc.default_layer(); + let id = ItemId(doc.alloc()); + let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + id +} + +/// Dimensions on, ruler guides and the document grid off. Alignment is optional. +fn dims_ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout, align: bool) -> ToolContext<'a> { + let mut cx = ctx(d, s, c, l); + cx.snap = SnapView::FACTORY; + cx.snap.snap_to_guides = false; + cx.snap.snap_to_document_grid = false; + cx.snap.align_edges = align; + cx.snap.align_centers = align; + cx +} + +fn spread_pt(l: &CanvasLayout, x: f64, y: f64) -> Point { + l.to_canvas(SpreadRef::Doc(0), Point::new(x, y)) +} + +fn near(a: Point, b: Point) -> bool { + (a.x - b.x).abs() < 1e-4 && (a.y - b.y).abs() < 1e-4 +} + +fn guide_matches(overlays: &[Overlay], a: Point, b: Point) -> bool { + overlays.iter().any(|o| match o { + Overlay::Guide { a: ga, b: gb } => (near(*ga, a) && near(*gb, b)) || (near(*ga, b) && near(*gb, a)), + _ => false, + }) +} + +fn guide_list(overlays: &[Overlay]) -> String { + overlays + .iter() + .filter_map(|o| match o { + Overlay::Guide { a, b } => Some(format!("({:.4},{:.4})-({:.4},{:.4})", a.x, a.y, b.x, b.y)), + _ => None, + }) + .collect::>() + .join("; ") +} + +#[test] +fn shift_alt_resize_keeps_aspect_and_center() { + // 100 by 50, center (150, 125), aspect 2. Corner handle, Shift and Alt. + // The centered width is 160. Another item's side is 163, inside the zone. + let mut doc = Document::new(&NewDocument::default()); + let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 150.0)); + add_rect(&mut doc, Rect::new(400.0, 400.0, 563.0, 430.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, false); + let mut t = create("selection"); + let mods = Mods { shift: true, alt: true, ..Mods::default() }; + let down = spread_pt(&layout, 200.0, 150.0); + let drag = spread_pt(&layout, 230.0, 155.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y).with_mods(mods)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag.x, drag.y).with_mods(mods)); + let params = match &a[..] { + [Action::Preview(cmd, p)] => { + assert_eq!(cmd, "transform.resize"); + p + } + other => panic!("{other:?}"), + }; + let to = params["to"].as_array().unwrap(); + let (x0, y0, x1, y1) = (to[0].as_f64().unwrap(), to[1].as_f64().unwrap(), to[2].as_f64().unwrap(), to[3].as_f64().unwrap()); + let (w, h) = (x1 - x0, y1 - y0); + let (cx0, cy0) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + assert!((cx0 - 150.0).abs() < 1e-4 && (cy0 - 125.0).abs() < 1e-4, "center walked to ({cx0}, {cy0}) rect {to:?}"); + assert!((w / h - 2.0).abs() < 1e-4, "aspect {} rect {to:?}", w / h); + assert!((w - 163.0).abs() < 1e-4, "driving length {w}, want the matched 163, not twice the correction"); +} + +#[test] +fn line_length_keeps_the_crossed_guide() { + // Diagonal from (100, 100) to (180, 140). Length is about 89.44, near a 92 side. + // Endpoint y is 2 pt from the other item's top, so Y is a position snap and X is the length. + let mut doc = Document::new(&NewDocument::default()); + add_rect(&mut doc, Rect::new(400.0, 142.0, 492.0, 180.0)); + let sel = Selection::default(); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let cx = dims_ctx(&doc, &sel, &cache, &layout, true); + let mut t = create("line"); + let a = spread_pt(&layout, 100.0, 100.0); + let b = spread_pt(&layout, 180.0, 140.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()); + let len = 80.0_f64.hypot(40.0); + let expect_x = 180.0 + (92.0 - len); + 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"); +} + +#[test] +fn dimension_guide_lies_on_the_committed_side() { + let mut problems = Vec::new(); + + // Centered scale. Width 120 grows to the matched 123, so each side moves 1.5, not 3. + let mut doc = Document::new(&NewDocument::default()); + let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 160.0)); + add_rect(&mut doc, Rect::new(400.0, 400.0, 523.0, 440.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, false); + let mut t = create("scale"); + let center = spread_pt(&layout, 150.0, 130.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, center.x + 50.0, center.y + 30.0)); + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, center.x + 60.0, center.y + 40.0)); + let (sx, sy) = match &drag[..] { + [Action::Preview(cmd, p)] => { + assert_eq!(cmd, "transform.scale"); + (p["sx"].as_f64().unwrap(), p["sy"].as_f64().unwrap()) + } + other => panic!("{other:?}"), + }; + let committed = Rect::new(150.0 - 50.0 * sx.abs(), 130.0 - 30.0 * sy.abs(), 150.0 + 50.0 * sx.abs(), 130.0 + 30.0 * sy.abs()); + let xf = layout.xf(SpreadRef::Doc(0)); + let bottom = (xf * Point::new(committed.x0, committed.y1), xf * Point::new(committed.x1, committed.y1)); + let overlays = t.overlays(&cx); + let scale_guides = overlays.iter().filter(|o| matches!(o, Overlay::Guide { .. })).count(); + if scale_guides != 1 || !guide_matches(&overlays, bottom.0, bottom.1) { + problems.push(format!( + "scale guide is not on the committed bottom ({:.4},{:.4})-({:.4},{:.4}), sx {sx} sy {sy}, {scale_guides} guides [{}]", + bottom.0.x, + bottom.0.y, + bottom.1.x, + bottom.1.y, + guide_list(&overlays) + )); + } + + // Shift resize. Width snaps 130 to 133, then the height follows the aspect and the bottom moves. + let mut doc = Document::new(&NewDocument::default()); + let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 150.0)); + add_rect(&mut doc, Rect::new(400.0, 400.0, 533.0, 430.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, false); + let mut t = create("selection"); + let mods = Mods { shift: true, ..Mods::default() }; + let down = spread_pt(&layout, 200.0, 150.0); + let drag_at = spread_pt(&layout, 230.0, 155.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag_at.x, drag_at.y).with_mods(mods)); + let to = match &a[..] { + [Action::Preview(cmd, p)] => { + assert_eq!(cmd, "transform.resize"); + p["to"].as_array().unwrap() + } + other => panic!("{other:?}"), + }; + let rect = Rect::new(to[0].as_f64().unwrap(), to[1].as_f64().unwrap(), to[2].as_f64().unwrap(), to[3].as_f64().unwrap()); + let xf = layout.xf(SpreadRef::Doc(0)); + let y = rect.y0.max(rect.y1); + let (x0, x1) = (rect.x0.min(rect.x1), rect.x0.max(rect.x1)); + let side = (xf * Point::new(x0, y), xf * Point::new(x1, y)); + let overlays = t.overlays(&cx); + let shift_guides = overlays.iter().filter(|o| matches!(o, Overlay::Guide { .. })).count(); + if shift_guides != 1 || !guide_matches(&overlays, side.0, side.1) { + problems.push(format!( + "shift guide is not on the committed side ({:.4},{:.4})-({:.4},{:.4}), rect {to:?}, {shift_guides} guides [{}]", + side.0.x, + side.0.y, + side.1.x, + side.1.y, + guide_list(&overlays) + )); + } + assert!(problems.is_empty(), "{}", problems.join(" | ")); +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index bb9e01000..24f89937f 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -98,18 +98,18 @@ impl Tool for XformTool { if w0 > 1e-9 && h0 > 1e-9 { let center = from.center(); let (mut w, mut h) = (w0 * sx.abs(), h0 * sy.abs()); - let to = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0); + let pre = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0); let pointer = cx.layout.to_spread(spread, ev.pos); let x_edges = if x_drives { edge_toward(pointer.x, center.x) } else { [false, false, false] }; let y_edges = if x_drives && ev.mods.shift { [false, false, false] } else { edge_toward(pointer.y, center.y) }; let exclude = cx.selection.items.clone(); let (pad_x, pad_y) = scale_stroke_pad(cx); - let hit = crate::snap::snap( + let mut hit = crate::snap::snap( cx, SnapRequest { spread, gesture: Gesture::Resize, - rect: to, + rect: pre, x_edges, y_edges, exclude: &exclude, @@ -141,9 +141,21 @@ impl Tool for XformTool { w = w0 * (h / h0); } } - if w.is_finite() && h.is_finite() && w.abs() > 1e-9 && h.abs() > 1e-9 { - sx = (w / w0) * sign_x; - sy = (h / h0) * sign_y; + if w.is_finite() && h.is_finite() { + let committed = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0); + crate::snap::lay_dimension_guides( + &mut hit.guides, + cx.layout.xf(spread), + pre, + committed, + x_edges, + y_edges, + hit.length_delta, + ); + if w.abs() > 1e-9 && h.abs() > 1e-9 { + sx = (w / w0) * sign_x; + sy = (h / h0) * sign_y; + } } self.guides = hit.guides; }