Files
designcraft/crates/tools/src/snap.rs
T
Cameron Gilroy 8e7cff341f 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.
2026-10-06 09:05:59 -04:00

1386 lines
52 KiB
Rust

//! Snapping and smart guides.
//!
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone.
//! Ruler guides, margins, and columns draw nothing. Alignment draws [`Overlay::Guide`] in
//! canvas coordinates. [`snap_rect`] is the older closest-target helper.
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::{Affine, Point, Rect, Vec2, format_measure};
pub use crate::{Gesture, Snap, SnapRequest, SnapView};
use crate::{Overlay, ToolContext};
#[derive(Clone, Copy)]
struct Target {
v: f64,
/// Extent of the target along the other axis (for drawing the guide).
lo: f64,
hi: f64,
smart: bool,
}
fn targets(cx: &ToolContext, sr: SpreadRef, exclude: &[ItemId]) -> (Vec<Target>, Vec<Target>) {
let mut xs = Vec::new();
let mut ys = Vec::new();
let Some(sp) = cx.doc.spread(sr) else { return (xs, ys) };
let push = |v: &mut Vec<Target>, val: f64, lo: f64, hi: f64, smart: bool| v.push(Target { v: val, lo, hi, smart });
for p in &sp.pages {
let b = p.bounds();
let m = p.margin_rect();
for x in [b.x0, b.center().x, b.x1, m.x0, m.x1] {
push(&mut xs, x, b.y0, b.y1, false);
}
for y in [b.y0, b.center().y, b.y1, m.y0, m.y1] {
push(&mut ys, y, b.x0, b.x1, false);
}
for c in p.column_rects() {
push(&mut xs, c.x0, b.y0, b.y1, false);
push(&mut xs, c.x1, b.y0, b.y1, false);
}
for g in &p.guides {
match g.orientation {
Orientation::Vertical => push(&mut xs, g.position, b.y0, b.y1, false),
Orientation::Horizontal => push(&mut ys, g.position, b.x0, b.x1, false),
}
}
}
for it in &sp.items {
if exclude.contains(&it.id) || it.hidden {
continue;
}
let b = it.bounds();
for x in [b.x0, b.center().x, b.x1] {
push(&mut xs, x, b.y0, b.y1, true);
}
for y in [b.y0, b.center().y, b.y1] {
push(&mut ys, y, b.x0, b.x1, true);
}
}
(xs, ys)
}
fn best(cands: &[f64], targets: &[Target], tol: f64) -> Option<(f64, Target)> {
let mut out: Option<(f64, Target)> = None;
for &c in cands {
for t in targets {
let d = t.v - c;
if d.abs() <= tol && out.is_none_or(|(od, _)| d.abs() < od.abs()) {
out = Some((d, *t));
}
}
}
out
}
/// Closest target on each free axis. Tolerance is 5 screen pixels, same as [`snap_rect`].
fn snap_axes(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId], x_free: bool, y_free: bool) -> Snap {
let tol = cx.tol(5.0);
let (xs, ys) = targets(cx, sr, exclude);
let mut s = Snap::default();
let xf = cx.layout.xf(sr);
if x_free && let Some((d, t)) = best(&[r.x0, r.center().x, r.x1], &xs, tol) {
s.delta.x = d;
let lo = t.lo.min(r.y0) - 6.0;
let hi = t.hi.max(r.y1) + 6.0;
s.guides.push(guide(xf * Point::new(t.v, lo), xf * Point::new(t.v, hi), t.smart));
}
if y_free && let Some((d, t)) = best(&[r.y0, r.center().y, r.y1], &ys, tol) {
s.delta.y = d;
let lo = t.lo.min(r.x0) - 6.0;
let hi = t.hi.max(r.x1) + 6.0;
s.guides.push(guide(xf * Point::new(lo, t.v), xf * Point::new(hi, t.v), t.smart));
}
s
}
/// Snap a moving rect (spread coords). Edges and centre snap independently per axis.
pub fn snap_rect(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId]) -> Snap {
snap_axes(cx, sr, r, exclude, true, true)
}
/// Snap a single point (drawing tools).
///
/// Zero-size rect, and every edge flag on, so the point can meet a guide or an alignment target.
pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec<Overlay>) {
let s = snap(
cx,
SnapRequest {
spread: sr,
gesture: Gesture::Point,
rect: Rect::from_points(p, p),
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &[],
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: p,
},
);
(p + s.delta, s.guides)
}
fn rect_finite(r: Rect) -> bool {
r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()
}
/// First hit on each free axis.
///
/// A non-finite rect, or no snap category, returns no correction. An axis whose three edge
/// flags are all false stays at 0 and draws nothing. The zone is [`SnapView::zone_px`] screen
/// pixels, converted with [`ToolContext::tol`]. A zone of 0 or less is no hit.
pub fn snap(cx: &ToolContext, req: SnapRequest<'_>) -> Snap {
if !rect_finite(req.rect) || !cx.snap.any() {
return Snap::default();
}
let Some(tol) = zone_tol(cx) else {
return Snap::default();
};
// Three false flags lock the axis: do not move it and do not draw a guide for it.
let x_free = req.x_edges != [false; 3];
let y_free = req.y_edges != [false; 3];
let x_hit = x_free.then(|| first_on_axis(cx, &req, Axis::X, tol)).flatten();
let y_hit = y_free.then(|| first_on_axis(cx, &req, Axis::Y, tol)).flatten();
let mut out = Snap::default();
if let Some(hit) = &x_hit {
out.delta.x = hit.delta;
out.length_delta[0] = hit.length_delta;
}
if let Some(hit) = &y_hit {
out.delta.y = hit.delta;
out.length_delta[1] = hit.length_delta;
}
let placed = Rect::new(req.rect.x0 + out.delta.x, req.rect.y0 + out.delta.y, req.rect.x1 + out.delta.x, req.rect.y1 + out.delta.y);
if let Some(hit) = &x_hit {
push_hit_guides(&mut out.guides, cx, &req, Axis::X, hit, placed);
}
if let Some(hit) = &y_hit {
push_hit_guides(&mut out.guides, cx, &req, Axis::Y, hit, placed);
}
out
}
/// Move the flagged edges by `delta`.
///
/// One edge takes the whole delta. Both edges of an axis split it, low edge one way and high
/// edge the other, so a length match keeps the centre. A non-finite component is ignored.
pub(crate) fn nudge_edges(mut rect: Rect, x_edges: [bool; 3], y_edges: [bool; 3], delta: Vec2) -> Rect {
nudge_axis(&mut rect.x0, &mut rect.x1, x_edges, delta.x);
nudge_axis(&mut rect.y0, &mut rect.y1, y_edges, delta.y);
rect
}
fn nudge_axis(lo: &mut f64, hi: &mut f64, flags: [bool; 3], delta: f64) {
if !delta.is_finite() {
return;
}
let [low_edge, _, high_edge] = flags;
if low_edge && high_edge {
*lo -= delta;
*hi += delta;
} else if low_edge {
*lo += delta;
} else if high_edge {
*hi += delta;
}
}
#[derive(Clone, Copy)]
enum Axis {
X,
Y,
}
#[derive(Clone)]
struct AxisHit {
delta: f64,
/// Position of the winning line on this axis.
at: f64,
/// Target box for an alignment guide. None when the hit draws nothing.
span: Option<Rect>,
/// Matched length minus the proposed length, when this hit is a dimension match.
length_delta: Option<f64>,
/// Spread-space segment along the matched side, plus the measure text.
dimension: Option<DimMark>,
}
#[derive(Clone)]
struct DimMark {
a: Point,
b: Point,
text: String,
}
struct Best {
dist: f64,
hit: AxisHit,
}
/// Screen pixels to spread points. A zone of 0 or less, or a non-finite zone, is no hit.
fn zone_tol(cx: &ToolContext) -> Option<f64> {
let zone = cx.snap.zone_px;
if !zone.is_finite() || zone <= 0.0 {
return None;
}
let tol = cx.tol(zone);
if tol.is_finite() && tol > 0.0 { Some(tol) } else { None }
}
fn first_on_axis(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
pass_grid(cx, req, axis, tol)
.or_else(|| pass_baseline(cx, req, axis, tol))
.or_else(|| pass_guides(cx, req, axis, tol))
.or_else(|| pass_align(cx, req, axis, tol))
.or_else(|| pass_spacing(cx, req, axis, tol))
.or_else(|| pass_dimensions(cx, req, axis, tol))
}
/// Document grid. No overlay.
///
/// The interval is the major spacing divided by the subdivision count. A count below 1 counts
/// as 1. `grid.vertical` spaces the vertical lines (x). `grid.horizontal` spaces the horizontal
/// lines (y). A spacing of 0 or less skips that axis. The origin is the page-bounds origin of
/// the page under the rect center. Whether the grid is shown is not read.
fn pass_grid(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.snap_to_document_grid {
return None;
}
let spacing = grid_interval(cx, axis)?;
let origin = grid_origin(cx, req, axis)?;
let mut best: Option<Best> = None;
offer_enabled(&mut best, req.rect, axis_flags(req, axis), axis, tol, |v| Some(snap_to_grid(v, spacing, origin)));
best.map(|b| b.hit)
}
/// Major spacing divided by the subdivision count. Non-finite or non-positive spacing is no hit.
fn grid_interval(cx: &ToolContext, axis: Axis) -> Option<f64> {
let grid = &cx.doc.settings.grid;
let major = match axis {
Axis::X => grid.vertical,
Axis::Y => grid.horizontal,
};
if !major.is_finite() || major <= 0.0 {
return None;
}
let interval = major / f64::from(grid.subdivisions.max(1));
(interval.is_finite() && interval > 0.0).then_some(interval)
}
/// Page-bounds origin on `axis` for the page under the rect center.
fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option<f64> {
let spread = cx.doc.spread(req.spread)?;
let bounds = spread.pages.get(spread.page_at_x(req.rect.center().x)?)?.bounds();
let origin = match axis {
Axis::X => bounds.x0,
Axis::Y => bounds.y0,
};
origin.is_finite().then_some(origin)
}
/// Baseline grid. No overlay.
///
/// Lines are `page.y0 + start + n * increment` for n >= 0 while the line is inside the page
/// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the
/// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not
/// turn this off. Only a page the rect crosses on x contributes.
fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides {
return None;
}
let baseline = &cx.doc.settings.baseline_grid;
let increment = baseline.increment;
if !increment.is_finite() || increment <= 0.0 || !baseline.start.is_finite() {
return None;
}
let spread = cx.doc.spread(req.spread)?;
let flags = axis_flags(req, axis);
let (moving_lo, moving_hi) = perp_ends(req.rect, axis);
let mut best: Option<Best> = None;
for page in &spread.pages {
let bounds = page.bounds();
let (lo, hi) = perp_ends(bounds, axis);
if !ranges_overlap(moving_lo, moving_hi, lo, hi) {
continue;
}
let origin = bounds.y0 + baseline.start;
offer_enabled(&mut best, req.rect, flags, axis, tol, |v| nearest_baseline(v, origin, increment, bounds.y0, bounds.y1));
}
best.map(|b| b.hit)
}
/// Nearest baseline at or after `origin`, strictly inside `[y0, y1)`.
fn nearest_baseline(v: f64, origin: f64, increment: f64, y0: f64, y1: f64) -> Option<f64> {
let (n_min, n_max) = baseline_n_range(origin, increment, y0, y1)?;
let snapped = snap_to_grid(v, increment, origin);
if baseline_line_ok(snapped, origin, increment, y0, y1) {
return Some(snapped);
}
let n = ((snapped - origin) / increment).round();
if !n.is_finite() {
return None;
}
let n = if n < n_min {
n_min
} else if n > n_max {
n_max
} else {
n
};
let line = origin + n * increment;
baseline_line_ok(line, origin, increment, y0, y1).then_some(line)
}
fn baseline_n_range(origin: f64, increment: f64, y0: f64, y1: f64) -> Option<(f64, f64)> {
if !origin.is_finite() || !increment.is_finite() || increment <= 0.0 || !y0.is_finite() || !y1.is_finite() || y0 >= y1 {
return None;
}
let mut n_min = if origin < y0 { ((y0 - origin) / increment).ceil() } else { 0.0 };
if !n_min.is_finite() || n_min < 0.0 {
return None;
}
if origin + n_min * increment < y0 {
n_min += 1.0;
}
let mut n_max = ((y1 - origin) / increment).floor();
if !n_max.is_finite() {
return None;
}
if origin + n_max * increment >= y1 {
n_max -= 1.0;
}
(n_max >= n_min).then_some((n_min, n_max))
}
fn baseline_line_ok(line: f64, origin: f64, increment: f64, y0: f64, y1: f64) -> bool {
if !line.is_finite() || !increment.is_finite() || increment <= 0.0 || line < y0 || line >= y1 {
return false;
}
let n = ((line - origin) / increment).round();
n.is_finite() && n >= 0.0
}
/// Each edge whose flag is set, against its own target. Nothing is drawn.
fn offer_enabled(best: &mut Option<Best>, moving: Rect, flags: [bool; 3], axis: Axis, tol: f64, mut target_at: impl FnMut(f64) -> Option<f64>) {
let (left, mid, right) = axis_triple(moving, axis);
let [use_left, use_mid, use_right] = flags;
for (on, v) in [(use_left, left), (use_mid, mid), (use_right, right)] {
if !on {
continue;
}
let Some(target) = target_at(v) else { continue };
offer(best, v, target, tol, None);
}
}
fn pass_spacing(_cx: &ToolContext, _req: &SnapRequest<'_>, _axis: Axis, _tol: f64) -> Option<AxisHit> {
None
}
/// 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 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<AxisHit> {
if !matches!(req.gesture, Gesture::Resize | Gesture::Create) || !cx.snap.smart_guides || !cx.snap.smart_dimensions {
return None;
}
let proposed = match axis {
Axis::X => req.lengths[0],
Axis::Y => req.lengths[1],
}?;
if !proposed.is_finite() {
return None;
}
let mut best: Option<(f64, f64)> = None;
for len in dimension_lengths(cx, req) {
if !len.is_finite() || len < 0.0 {
continue;
}
let dist = (len - proposed).abs();
if dist <= tol && best.is_none_or(|(have, _)| dist < have) {
best = Some((dist, len));
}
}
let (_, length) = best?;
let diff = length - proposed;
if !diff.is_finite() {
return None;
}
let flags = axis_flags(req, axis);
let text = format_measure(length, cx.unit);
Some(AxisHit {
delta: length_edge_delta(flags, diff),
at: 0.0,
span: None,
length_delta: Some(diff),
dimension: Some(dimension_mark(req, axis, diff, text)),
})
}
/// Edge shift so adding it to the moving edge grows the length by `diff`.
fn length_edge_delta(flags: [bool; 3], diff: f64) -> f64 {
let [low_edge, mid, high_edge] = flags;
if low_edge && high_edge && !mid {
diff / 2.0
} else if high_edge && !low_edge {
diff
} else if low_edge && !high_edge {
-diff
} else {
diff
}
}
/// 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 (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);
let y = rect.y0.max(rect.y1);
(Point::new(x0, y), Point::new(x1, y))
}
Axis::Y => {
let (y0, y1) = grown_span(rect.y0, rect.y1, flags, diff);
let x = rect.x0.max(rect.x1);
(Point::new(x, y0), Point::new(x, y1))
}
};
(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<Overlay>,
xf: Affine,
pre: Rect,
committed: Rect,
x_edges: [bool; 3],
y_edges: [bool; 3],
length_delta: [Option<f64>; 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) {
let (lo, hi) = if a0 <= a1 { (a0, a1) } else { (a1, a0) };
let [low_edge, mid, high_edge] = flags;
if low_edge && high_edge && !mid {
let mid_v = (lo + hi) / 2.0;
let half = (hi - lo) / 2.0 + diff / 2.0;
(mid_v - half, mid_v + half)
} else if low_edge && !high_edge {
(lo - diff, hi)
} else {
(lo, hi + diff)
}
}
fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec<f64> {
let mut out = Vec::new();
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, Affine::IDENTITY, false, 0, &mut seen);
}
}
if !layout_edits_parents(cx) {
push_parent_lengths(&mut out, cx, req, &mut seen);
}
out
}
fn push_own_lengths(
out: &mut Vec<f64>,
selection: &Selection,
req: &SnapRequest<'_>,
item: &Item,
ancestor: Affine,
skipped: bool,
depth: usize,
seen: &mut HashSet<ItemId>,
) {
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() {
for child in item.children() {
push_own_lengths(out, selection, req, child, abs, skip, depth + 1, seen);
}
return;
}
if skip {
return;
}
let Some((w, h)) = leaf_own_size(item, abs) else { return };
out.push(w);
out.push(h);
}
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;
}
let outset = stroke_outset(item);
if !outset.is_finite() {
return None;
}
let local = if outset > 0.0 { local.inflate(outset, outset) } else { local };
if !rect_finite(local) {
return None;
}
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;
}
let w = (local.x1 - local.x0).abs() * sx;
let h = (local.y1 - local.y0).abs() * sy;
(w.is_finite() && h.is_finite()).then_some((w, h))
}
/// Same outset as [`Item::visible_bounds`]: half the weight when centered, the whole weight when
/// outside, nothing when inside or when the stroke is none. Open paths also count arrowheads.
fn stroke_outset(item: &Item) -> f64 {
if item.stroke.is_none() {
return 0.0;
}
let w = match item.stroke.align {
StrokeAlign::Center => item.stroke.weight / 2.0,
StrokeAlign::Inside => 0.0,
StrokeAlign::Outside => item.stroke.weight,
};
if item.path.is_closed() { w } else { w.max(item.stroke.extent() - item.stroke.weight) }
}
fn push_parent_lengths(out: &mut Vec<f64>, cx: &ToolContext, req: &SnapRequest<'_>, seen_items: &mut HashSet<ItemId>) {
let SpreadRef::Doc(si) = req.spread else { return };
let page_count = cx.doc.spreads.get(si).map(|sp| sp.pages.len()).unwrap_or(0);
let first = cx.doc.first_page_of_spread(si);
for pi in 0..page_count {
let Some(abs) = first.checked_add(pi) else { continue };
let Some(page) = cx.doc.page(abs) else { continue };
if !page.show_parent_items {
continue;
}
let doc_side = page.side;
let overridden = page.overridden.clone();
let Some((ppi, _)) = cx.doc.parent_page_for(abs) else { continue };
let mut seen_spreads = HashSet::new();
push_parent_length_chain(out, cx, req, doc_side, &overridden, ppi, seen_items, &mut seen_spreads);
}
}
fn push_parent_length_chain(
out: &mut Vec<f64>,
cx: &ToolContext,
req: &SnapRequest<'_>,
doc_side: PageSide,
overridden: &[ItemId],
index: usize,
seen_items: &mut HashSet<ItemId>,
seen_spreads: &mut HashSet<SpreadId>,
) {
let Some(parent) = cx.doc.parents.get(index) else { return };
if !seen_spreads.insert(parent.id) {
return;
}
let next = parent.parent.as_ref().and_then(|info| info.based_on);
if let Some(page_idx) = shown_parent_page_index(parent.pages.len(), doc_side) {
for item in &parent.items {
if overridden.contains(&item.id) {
continue;
}
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, Affine::IDENTITY, false, 0, seen_items);
}
}
let Some(next_id) = next else { return };
let Some(next_index) = cx.doc.parent_index(next_id) else { return };
push_parent_length_chain(out, cx, req, doc_side, overridden, next_index, seen_items, seen_spreads);
}
/// Ruler guides, margins, and column sides. No overlay.
///
/// A page guide attracts when the moving rect overlaps that page on the other axis. A spread
/// guide uses the spread bounds. Locked guides are included. A guide is a target only when
/// `Guide::visible_in` accepts it.
fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.snap_to_guides || !cx.snap.show_guides {
return None;
}
let sp = cx.doc.spread(req.spread)?;
let spread_bounds = sp.bounds();
let flags = axis_flags(req, axis);
let (moving_lo, moving_hi) = perp_ends(req.rect, axis);
let mut best: Option<Best> = None;
for page in &sp.pages {
let page_bounds = page.bounds();
for guide in &page.guides {
if !guide.visible_in(cx.doc) || !guide.position.is_finite() || guide.orientation != axis_orientation(axis) {
continue;
}
let range = if guide.spread { spread_bounds } else { page_bounds };
let (range_lo, range_hi) = perp_ends(range, axis);
if !ranges_overlap(moving_lo, moving_hi, range_lo, range_hi) {
continue;
}
offer_line(&mut best, req.rect, flags, axis, guide.position, tol);
}
let margin = page.margin_rect();
let (a, b) = match axis {
Axis::X => (margin.x0, margin.x1),
Axis::Y => (margin.y0, margin.y1),
};
offer_line(&mut best, req.rect, flags, axis, a, tol);
offer_line(&mut best, req.rect, flags, axis, b, tol);
if matches!(axis, Axis::X) {
for column in page.column_rects() {
offer_line(&mut best, req.rect, flags, axis, column.x0, tol);
offer_line(&mut best, req.rect, flags, axis, column.x1, tol);
}
}
}
best.map(|b| b.hit)
}
/// How deep a group may nest before alignment stops walking it.
const ALIGN_GROUP_DEPTH: usize = 32;
/// Page and item edges and centers. Draws one [`Overlay::Guide`] per winning axis.
///
/// Edges match edges only while `align_edges` is on. Centers match centers only while
/// `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.
/// 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.
fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.smart_guides || (!cx.snap.align_edges && !cx.snap.align_centers) {
return None;
}
let flags = axis_flags(req, axis);
let mut best: Option<Best> = None;
if let Some(sp) = cx.doc.spread(req.spread) {
for page in &sp.pages {
offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
}
for item in &sp.items {
let Some(rect) = item_target_rect(cx.selection, req, item) else { continue };
offer_box(&mut best, req.rect, flags, axis, rect, tol, cx.snap.align_edges, cx.snap.align_centers);
}
}
if !layout_edits_parents(cx) {
offer_parent_items(&mut best, cx, req, flags, axis, tol);
}
best.map(|b| b.hit)
}
/// Drag-start ids stay targets while copying. Preview copies are the live selection minus that set.
fn align_skips_item(selection: &Selection, req: &SnapRequest<'_>, id: ItemId) -> bool {
if req.copying { selection.items.contains(&id) && !req.exclude.contains(&id) } else { req.exclude.contains(&id) }
}
/// Parent spreads are the canvas while a parent is being edited. Their items are ordinary targets.
fn layout_edits_parents(cx: &ToolContext) -> bool {
cx.layout.slots.iter().any(|slot| matches!(slot.spread, SpreadRef::Parent(_)))
}
/// Visible box used for alignment. Groups with children contribute the union of the children.
fn item_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item) -> Option<Rect> {
if item.hidden || align_skips_item(selection, req, item.id) {
return None;
}
if item.is_group() && !item.children().is_empty() {
let mut seen = HashSet::new();
return group_target_rect(selection, req, item, &mut seen, 0);
}
let bounds = item.visible_bounds();
rect_finite(bounds).then_some(bounds)
}
/// Union of children's visible bounds in this group's space, then `item.xf`.
fn group_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item, seen: &mut HashSet<ItemId>, depth: usize) -> 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) {
continue;
}
let local = if child.is_group() && !child.children().is_empty() {
group_target_rect(selection, req, child, seen, depth + 1)
} else {
let bounds = child.visible_bounds();
rect_finite(bounds).then_some(bounds)
};
let Some(local) = local else { continue };
if !rect_finite(local) {
continue;
}
acc = Some(acc.map_or(local, |have| have.union(local)));
}
acc.map(|r| item.xf.transform_rect_bbox(r)).filter(|r| rect_finite(*r))
}
/// Parent items shown on the document spread, shifted into that spread's space.
fn offer_parent_items(best: &mut Option<Best>, cx: &ToolContext, req: &SnapRequest<'_>, flags: [bool; 3], axis: Axis, tol: f64) {
let SpreadRef::Doc(si) = req.spread else { return };
let page_count = cx.doc.spreads.get(si).map(|sp| sp.pages.len()).unwrap_or(0);
let first = cx.doc.first_page_of_spread(si);
for pi in 0..page_count {
let Some(abs) = first.checked_add(pi) else { continue };
let Some(page) = cx.doc.page(abs) else { continue };
if !page.show_parent_items {
continue;
}
let doc_x = page.x;
let doc_side = page.side;
let overridden = page.overridden.clone();
let Some((ppi, _)) = cx.doc.parent_page_for(abs) else { continue };
let mut seen = HashSet::new();
offer_parent_chain(best, cx, req, flags, axis, tol, doc_x, doc_side, &overridden, ppi, &mut seen);
}
}
/// Walk `based_on`. A repeated spread id ends the walk.
fn offer_parent_chain(
best: &mut Option<Best>,
cx: &ToolContext,
req: &SnapRequest<'_>,
flags: [bool; 3],
axis: Axis,
tol: f64,
doc_x: f64,
doc_side: PageSide,
overridden: &[ItemId],
index: usize,
seen: &mut HashSet<SpreadId>,
) {
let Some(parent) = cx.doc.parents.get(index) else { return };
if !seen.insert(parent.id) {
return;
}
let next = parent.parent.as_ref().and_then(|info| info.based_on);
if let Some(page_idx) = shown_parent_page_index(parent.pages.len(), doc_side)
&& let Some(parent_page) = parent.pages.get(page_idx)
{
let dx = doc_x - parent_page.x;
if dx.is_finite() {
for item in &parent.items {
if overridden.contains(&item.id) {
continue;
}
// Same other-page skip as the renderer: a facing parent only shows items
// whose center sits on the parent page this document page uses.
if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) {
continue;
}
let Some(rect) = item_target_rect(cx.selection, req, item) else { continue };
offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, cx.snap.align_edges, cx.snap.align_centers);
}
}
}
let Some(next_id) = next else { return };
let Some(next_index) = cx.doc.parent_index(next_id) else { return };
offer_parent_chain(best, cx, req, flags, axis, tol, doc_x, doc_side, overridden, next_index, seen);
}
/// Left page of a facing parent, otherwise the last page. Same choice as `Document::parent_page_for`.
fn shown_parent_page_index(page_count: usize, side: PageSide) -> Option<usize> {
if page_count >= 2 && side == PageSide::Left { Some(0) } else { page_count.checked_sub(1) }
}
fn shift_x(r: Rect, dx: f64) -> Rect {
Rect::new(r.x0 + dx, r.y0, r.x1 + dx, r.y1)
}
fn axis_flags(req: &SnapRequest<'_>, axis: Axis) -> [bool; 3] {
match axis {
Axis::X => req.x_edges,
Axis::Y => req.y_edges,
}
}
fn axis_orientation(axis: Axis) -> Orientation {
match axis {
Axis::X => Orientation::Vertical,
Axis::Y => Orientation::Horizontal,
}
}
/// Left/center/right, or top/center/bottom.
fn axis_triple(r: Rect, axis: Axis) -> (f64, f64, f64) {
match axis {
Axis::X => (r.x0, r.center().x, r.x1),
Axis::Y => (r.y0, r.center().y, r.y1),
}
}
/// The rect's extent on the axis perpendicular to `axis`.
fn perp_ends(r: Rect, axis: Axis) -> (f64, f64) {
match axis {
Axis::X => (r.y0, r.y1),
Axis::Y => (r.x0, r.x1),
}
}
fn ranges_overlap(a0: f64, a1: f64, b0: f64, b1: f64) -> bool {
if !a0.is_finite() || !a1.is_finite() || !b0.is_finite() || !b1.is_finite() {
return false;
}
let (a_lo, a_hi) = (a0.min(a1), a0.max(a1));
let (b_lo, b_hi) = (b0.min(b1), b0.max(b1));
a_lo <= b_hi && a_hi >= b_lo
}
/// Any requested edge, including the center, against one guide line. No span, so nothing is drawn.
fn offer_line(best: &mut Option<Best>, moving: Rect, flags: [bool; 3], axis: Axis, target: f64, tol: f64) {
let (left, mid, right) = axis_triple(moving, axis);
let [use_left, use_mid, use_right] = flags;
if use_left {
offer(best, left, target, tol, None);
}
if use_mid {
offer(best, mid, target, tol, None);
}
if use_right {
offer(best, right, target, tol, None);
}
}
fn offer_box(best: &mut Option<Best>, moving: Rect, flags: [bool; 3], axis: Axis, target: Rect, tol: f64, edges: bool, centers: bool) {
let (m0, m_mid, m1) = axis_triple(moving, axis);
let (t0, t_mid, t1) = axis_triple(target, axis);
let [use_left, use_mid, use_right] = flags;
if edges {
if use_left {
offer(best, m0, t0, tol, Some(target));
offer(best, m0, t1, tol, Some(target));
}
if use_right {
offer(best, m1, t0, tol, Some(target));
offer(best, m1, t1, tol, Some(target));
}
}
if centers && use_mid {
offer(best, m_mid, t_mid, tol, Some(target));
}
}
fn offer(best: &mut Option<Best>, moving: f64, target: f64, tol: f64, span: Option<Rect>) {
if !moving.is_finite() || !target.is_finite() {
return;
}
let delta = target - moving;
let dist = delta.abs();
if dist <= tol && best.as_ref().is_none_or(|b| dist < b.dist) {
*best = Some(Best { dist, hit: AxisHit { delta, at: target, span, length_delta: None, dimension: None } });
}
}
fn push_hit_guides(out: &mut Vec<Overlay>, cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, hit: &AxisHit, moving: Rect) {
push_alignment_guide(out, cx, req.spread, axis, hit, moving);
let Some(mark) = &hit.dimension else { return };
let xf = cx.layout.xf(req.spread);
if mark.a.x.is_finite() && mark.a.y.is_finite() && mark.b.x.is_finite() && mark.b.y.is_finite() {
out.push(Overlay::Guide { a: xf * mark.a, b: xf * mark.b });
}
let p = xf * req.pointer;
if p.x.is_finite() && p.y.is_finite() {
out.push(Overlay::Measure { p, text: mark.text.clone() });
}
}
fn push_alignment_guide(out: &mut Vec<Overlay>, cx: &ToolContext, spread: SpreadRef, axis: Axis, hit: &AxisHit, moving: Rect) {
let Some(target) = hit.span else { return };
let xf = cx.layout.xf(spread);
let (a, b) = match axis {
Axis::X => {
let lo = moving.y0.min(moving.y1).min(target.y0.min(target.y1)) - 6.0;
let hi = moving.y0.max(moving.y1).max(target.y0.max(target.y1)) + 6.0;
(xf * Point::new(hit.at, lo), xf * Point::new(hit.at, hi))
}
Axis::Y => {
let lo = moving.x0.min(moving.x1).min(target.x0.min(target.x1)) - 6.0;
let hi = moving.x0.max(moving.x1).max(target.x0.max(target.x1)) + 6.0;
(xf * Point::new(lo, hit.at), xf * Point::new(hi, hit.at))
}
};
out.push(Overlay::Guide { a, b });
}
fn guide(a: Point, b: Point, smart: bool) -> Overlay {
if smart { Overlay::Guide { a, b } } else { Overlay::Line { a, b, color: [255, 0, 255], dashed: false } }
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Guide, Item, Margins, Selection, Shape, Stroke};
use designcraft_geom::shapes;
use designcraft_geom::{Affine, Unit};
use crate::layout::CanvasLayout;
use super::*;
// The factory flags are const. Pin them anyway.
#[test]
#[allow(clippy::assertions_on_constants)]
fn factory_zone_is_four_pixels() {
assert!(SnapView::FACTORY.snap_to_guides);
assert!(!SnapView::FACTORY.snap_to_document_grid);
assert!(SnapView::FACTORY.smart_guides);
assert_eq!(SnapView::FACTORY.zone_px, 4.0);
assert!(!SnapView::OFF.any());
assert!(SnapView::FACTORY.any());
}
fn doc_with_items(moving: Rect, other: Rect, guide_x: f64) -> (Document, ItemId, ItemId) {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let make = |doc: &mut Document, rect: Rect| {
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
};
let moving_id = make(&mut doc, moving);
let other_id = make(&mut doc, other);
let page = &mut Arc::make_mut(&mut doc.spreads[0]).pages[0];
page.guides.push(Guide { orientation: Orientation::Vertical, position: guide_x, spread: false, locked: false, layer: None, liquid: false });
(doc, moving_id, other_id)
}
fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: &'a CanvasLayout) -> ToolContext<'a> {
ToolContext { doc, selection: sel, cache, layout, zoom: 1.0, layer: doc.default_layer(), snap: SnapView::FACTORY, unit: Unit::Points }
}
fn y_req(rect: Rect) -> SnapRequest<'static> {
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect,
x_edges: [false, false, false],
y_edges: [true, true, true],
exclude: &[],
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
}
}
fn move_req<'a>(rect: Rect, exclude: &'a [ItemId]) -> SnapRequest<'a> {
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect,
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude,
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
}
}
#[test]
fn guide_beats_a_closer_object_edge() {
// Right edge at 100.5. Object edge at 100 (0.5 away). Guide at 104 (3.5 away). Zone is 4.
let (doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.5, 140.0), Rect::new(90.0, 100.0, 100.0, 160.0), 104.0);
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.zone_px = 4.0;
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(80.0, 100.0, 100.5, 140.0),
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x - 3.5).abs() < 1e-6);
assert!(hit.guides.is_empty());
}
#[test]
fn parent_centered_stroke_attracts_at_the_outer_edge() {
let mut doc = Document::new(&NewDocument::default());
let pid = doc.add_parent("A", "A-Parent", 1, 12.0, Margins::uniform(36.0));
doc.apply_parent(&[0], Some(pid)).unwrap();
let layer = doc.default_layer();
let id = ItemId(doc.alloc());
let (ppi, ppg) = doc.parent_page_for(0).unwrap();
let parent_x = doc.parents[ppi].pages[ppg].x;
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(parent_x + 72.0, 140.0, parent_x + 252.0, 300.0)));
item.stroke = Stroke::default(); // 1 pt, center
doc.insert_item(SpreadRef::Parent(ppi), item, 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;
// Moving left edge at 253. Visible parent right edge is 252.5.
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(253.0, 140.0, 353.0, 240.0),
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[],
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x - -0.5).abs() < 1e-6, "delta {}", hit.delta.x);
}
#[test]
fn object_edge_snaps_when_guide_snap_is_off() {
let (doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.5, 140.0), Rect::new(90.0, 100.0, 100.0, 160.0), 104.0);
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 hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(80.0, 100.0, 100.5, 140.0),
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x - -0.5).abs() < 1e-6);
assert!(matches!(hit.guides.first(), Some(Overlay::Guide { .. })));
}
#[test]
fn hidden_layer_guide_is_not_a_target() {
let (mut doc, moving, _) = doc_with_items(Rect::new(80.0, 100.0, 102.0, 140.0), Rect::new(300.0, 100.0, 340.0, 140.0), 104.0);
let layer = doc.default_layer();
doc.layers.iter_mut().find(|l| l.id == layer).unwrap().visible = false;
Arc::make_mut(&mut doc.spreads[0]).pages[0].guides[0].layer = Some(layer);
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let cx = ctx_on(&doc, &sel, &cache, &layout);
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(80.0, 100.0, 102.0, 140.0),
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[moving],
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert_eq!(hit.delta.x, 0.0);
}
#[test]
fn alt_copy_snaps_to_the_original() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 40.0, 160.0, 80.0)));
doc.insert_item(SpreadRef::Doc(0), item, 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 hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(162.0, 40.0, 222.0, 80.0),
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[id],
copying: true,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x - -2.0).abs() < 1e-6);
}
#[test]
fn alt_copy_does_not_stick_to_the_preview() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let make = |doc: &mut Document, rect: Rect| {
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
};
let original = make(&mut doc, Rect::new(100.0, 40.0, 160.0, 80.0));
let ghost = make(&mut doc, Rect::new(161.0, 40.0, 221.0, 80.0));
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::items(vec![ghost]);
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.snap_to_guides = false;
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(161.0, 40.0, 221.0, 80.0),
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[original],
copying: true,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x - -1.0).abs() < 1e-6, "delta.x = {}", hit.delta.x);
}
#[test]
fn grid_beats_a_closer_guide() {
let mut doc = Document::new(&NewDocument::default());
doc.settings.grid.horizontal = 72.0;
doc.settings.grid.vertical = 72.0;
doc.settings.grid.subdivisions = 8; // 9 pt
let layer = doc.default_layer();
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)));
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
// Guide 1 pt away at x = 11. Subdivision line at 9 is 1 pt the other way. Grid must win.
Arc::make_mut(&mut doc.spreads[0]).pages[0].guides.push(Guide {
orientation: Orientation::Vertical,
position: 11.0,
spread: true,
locked: false,
layer: None,
liquid: false,
});
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_document_grid = true;
let hit = snap(&cx, move_req(Rect::new(10.0, 10.0, 40.0, 40.0), &[id]));
assert!((hit.delta.x - -1.0).abs() < 1e-6);
assert!(hit.guides.is_empty());
}
#[test]
fn hidden_baseline_still_snaps_and_zero_increment_does_not() {
let mut doc = Document::new(&NewDocument::default());
doc.settings.baseline_grid.start = 36.0;
doc.settings.baseline_grid.increment = 12.0;
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let cx = ctx_on(&doc, &sel, &cache, &layout);
// Top at 49 wants the line at 48 (start 36, increment 12). The margin at 36 is 13 pt away.
let hit = snap(&cx, y_req(Rect::new(100.0, 49.0, 140.0, 80.0)));
assert!((hit.delta.y - -1.0).abs() < 1e-6);
let mut dead = doc.clone();
dead.settings.baseline_grid.increment = 0.0;
let layout = CanvasLayout::new(&dead, false);
let cx = ctx_on(&dead, &sel, &cache, &layout);
let hit = snap(&cx, y_req(Rect::new(100.0, 49.0, 140.0, 80.0)));
assert_eq!(hit.delta.y, 0.0);
}
#[test]
fn rotated_item_matches_its_own_side() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let id = ItemId(doc.alloc());
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 40.0)));
item.xf = Affine::rotate((-30.0_f64).to_radians());
doc.insert_item(SpreadRef::Doc(0), item, 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;
// Width 193.2 is the axis-aligned box. 200 is the side. Zone 4 reaches 200 from 197, not from 193.
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Resize,
rect: Rect::new(80.0, 200.0, 277.0, 280.0),
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[],
copying: false,
lengths: [Some(197.0), None],
angle: None,
radius: 0.0,
pointer: Point::new(277.0, 240.0),
},
);
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);
}
}