Snap: visible bounds and constrained gestures

This commit is contained in:
Cameron Gilroy
2026-10-06 09:05:59 -04:00
committed by Brandon Thomas
parent b0f9773205
commit 78605f2944
6 changed files with 614 additions and 80 deletions
+4 -1
View File
@@ -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]
+224 -29
View File
@@ -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<f64> {
len.is_finite().then_some(len)
}
fn line_hit(cx: &ToolContext, spread: SpreadRef, point: Point, len: Option<f64>) -> 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<f64>) -> Option<f64> {
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<Overlay>) -> 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<Overlay>) -> 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<Point> {
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<f64> {
(delta.is_finite() && delta.abs() > 1e-9).then_some(delta)
}
fn closer_slide(ux: f64, uy: f64, x_pos: Option<f64>, y_pos: Option<f64>) -> Option<f64> {
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));
+16 -1
View File
@@ -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<Rect> {
let mut r: Option<Rect> = 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<Rect> {
let mut acc: Option<Rect> = 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.
+41 -19
View File
@@ -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)]
}
+173 -27
View File
@@ -252,14 +252,15 @@ fn zone_tol(cx: &ToolContext) -> Option<f64> {
/// 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<ItemId>,
@@ -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<f64> {
if !xf_finite(item.xf) {
/// Decompose rotation of an absolute transform. A non-finite matrix contributes nothing.
fn rotation_of(xf: Affine) -> Option<f64> {
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<f64> {
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<Overlay>, 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<ItemId>, depth: usize) -> Option<Rect> {
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<Rect> {
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<ItemId>, depth: usize, filter: Option<(&Selection, &SnapRequest<'_>)>) -> Option<Rect> {
if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) {
return None;
}
let mut acc: Option<Rect> = 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}");
}
}
+156 -3
View File
@@ -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]