mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 06:06:49 -06:00
Snap: visible bounds and constrained gestures
This commit is contained in:
committed by
Brandon Thomas
parent
b0f9773205
commit
78605f2944
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user