Snap: equal spacing on moves

This commit is contained in:
Cameron Gilroy 2026-10-06 03:19:30 +11:00 committed by Brandon Thomas
parent 8e7cff341f
commit 5a74bf2d6e
1 changed files with 325 additions and 4 deletions

View File

@ -2,7 +2,8 @@
//!
//! [`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.
//! canvas coordinates. Equal spacing draws [`Overlay::Gap`] the same way, through one
//! spread-to-canvas transform. [`snap_rect`] is the older closest-target helper.
use std::collections::HashSet;
@ -207,6 +208,16 @@ struct AxisHit {
length_delta: Option<f64>,
/// Spread-space segment along the matched side, plus the measure text.
dimension: Option<DimMark>,
/// Spacing segments in spread space. Empty unless this hit is a spacing match.
gaps: Vec<GapMark>,
}
/// Spacing segment in spread space. Drawn after one spread-to-canvas transform.
#[derive(Clone)]
struct GapMark {
a: Point,
b: Point,
label: String,
}
#[derive(Clone)]
@ -377,8 +388,232 @@ fn offer_enabled(best: &mut Option<Best>, moving: Rect, flags: [bool; 3], axis:
}
}
fn pass_spacing(_cx: &ToolContext, _req: &SnapRequest<'_>, _axis: Axis, _tol: f64) -> Option<AxisHit> {
None
/// Equal gaps on a move, when smart guides and smart spacing are on.
///
/// Boxes are the top-level visible bounds on this spread. A group contributes the union of
/// its children. Parent items are not targets. `req.rect` is the moving box and is not also a
/// stationary box. A box counts only when its range overlaps on the other axis. The nearest
/// box on a side that overlaps the mover, or a zero gap, produces no gap, and a farther box
/// is not used instead. A candidate shifts the mover so one of its gaps equals one stationary
/// nearest-neighbor gap. The shift has to fall inside the zone. The closest shift wins.
/// One gap overlay is drawn for every gap of that length, including the moving gap after the
/// shift. Create, resize, rotate, and point do not match.
fn pass_spacing(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !matches!(req.gesture, Gesture::Move) || !cx.snap.smart_guides || !cx.snap.smart_spacing {
return None;
}
if !tol.is_finite() || tol <= 0.0 {
return None;
}
let mover = interval_on(req.rect, axis)?;
let boxes = spacing_intervals(cx, req, axis, &mover);
let stationary = stationary_gaps(&boxes);
if stationary.is_empty() {
return None;
}
let mut best: Option<SpacingChoice> = None;
if let Some(n) = nearest_side(&mover, &boxes, None, true) {
for g in &stationary {
offer_spacing(&mut best, g.gap - n.gap, g.gap, tol);
}
}
if let Some(n) = nearest_side(&mover, &boxes, None, false) {
for g in &stationary {
offer_spacing(&mut best, n.gap - g.gap, g.gap, tol);
}
}
let choice = best?;
let shifted = Interval { lo: mover.lo + choice.delta, hi: mover.hi + choice.delta, perp_lo: mover.perp_lo, perp_hi: mover.perp_hi };
if !shifted.lo.is_finite() || !shifted.hi.is_finite() {
return None;
}
let label = format_measure(choice.distance, cx.unit);
let mut gaps = Vec::new();
push_moving_gaps(&mut gaps, &shifted, &boxes, axis, choice.distance, &label);
for g in &stationary {
if gap_matches(g.gap, choice.distance) {
push_gap_mark(&mut gaps, axis, g.left, g.right, g.perp_lo, g.perp_hi, &label);
}
}
Some(AxisHit { delta: choice.delta, at: 0.0, span: None, length_delta: None, dimension: None, gaps })
}
/// Top-level visible boxes that overlap `mover` on the other axis.
///
/// The moving rect is not one of them. Parents are not walked.
fn spacing_intervals(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, mover: &Interval) -> Vec<Interval> {
let mut out = Vec::new();
let Some(sp) = cx.doc.spread(req.spread) else { return out };
for item in &sp.items {
let Some(rect) = item_target_rect(cx.selection, req, item) else { continue };
let Some(span) = interval_on(rect, axis) else { continue };
if ranges_overlap(mover.perp_lo, mover.perp_hi, span.perp_lo, span.perp_hi) {
out.push(span);
}
}
out
}
struct Interval {
lo: f64,
hi: f64,
perp_lo: f64,
perp_hi: f64,
}
struct SideGap {
gap: f64,
edge: f64,
perp_lo: f64,
perp_hi: f64,
}
struct StoredGap {
gap: f64,
left: f64,
right: f64,
perp_lo: f64,
perp_hi: f64,
}
struct SpacingChoice {
dist: f64,
delta: f64,
distance: f64,
}
fn interval_on(rect: Rect, axis: Axis) -> Option<Interval> {
if !rect_finite(rect) {
return None;
}
let (a0, a1, p0, p1) = match axis {
Axis::X => (rect.x0, rect.x1, rect.y0, rect.y1),
Axis::Y => (rect.y0, rect.y1, rect.x0, rect.x1),
};
Some(Interval { lo: a0.min(a1), hi: a0.max(a1), perp_lo: p0.min(p1), perp_hi: p0.max(p1) })
}
/// Nearest positive gap on one side of `mover`.
///
/// `low` looks toward decreasing coordinates. A box that overlaps that edge, or a zero gap,
/// is nearer than any open gap, so the side produces nothing and a farther box is not used.
/// `skip` drops a box that would otherwise be compared with itself.
fn nearest_side(mover: &Interval, boxes: &[Interval], skip: Option<usize>, low: bool) -> Option<SideGap> {
let mut blocked = false;
let mut best: Option<SideGap> = None;
for (i, b) in boxes.iter().enumerate() {
if skip == Some(i) || !ranges_overlap(mover.perp_lo, mover.perp_hi, b.perp_lo, b.perp_hi) {
continue;
}
match side_gap(mover, b, low) {
SideKind::Block => blocked = true,
SideKind::Ignore => {}
SideKind::Gap { gap, edge } => {
if best.as_ref().is_none_or(|have| gap < have.gap) {
best = Some(SideGap { gap, edge, perp_lo: b.perp_lo, perp_hi: b.perp_hi });
}
}
}
}
if blocked { None } else { best }
}
enum SideKind {
Block,
Ignore,
Gap { gap: f64, edge: f64 },
}
fn side_gap(mover: &Interval, b: &Interval, low: bool) -> SideKind {
let (gap, edge, crosses, past) = if low {
(mover.lo - b.hi, b.hi, b.lo < mover.lo && b.hi > mover.lo, b.hi > mover.lo)
} else {
(b.lo - mover.hi, b.lo, b.lo < mover.hi && b.hi > mover.hi, b.lo < mover.hi)
};
if crosses {
return SideKind::Block;
}
if past {
return SideKind::Ignore;
}
if !gap.is_finite() {
return SideKind::Ignore;
}
if gap <= 0.0 { SideKind::Block } else { SideKind::Gap { gap, edge } }
}
/// Nearest-neighbor gaps between stationary boxes that overlap on the other axis.
///
/// Only the high side of each box is recorded, so a pair is one gap. An overlap or a zero
/// gap on that side contributes nothing.
fn stationary_gaps(boxes: &[Interval]) -> Vec<StoredGap> {
let mut out = Vec::new();
for (i, a) in boxes.iter().enumerate() {
let Some(n) = nearest_side(a, boxes, Some(i), false) else { continue };
let Some((perp_lo, perp_hi)) = perp_span(a.perp_lo, a.perp_hi, n.perp_lo, n.perp_hi) else { continue };
out.push(StoredGap { gap: n.gap, left: a.hi, right: n.edge, perp_lo, perp_hi });
}
out
}
fn perp_span(a0: f64, a1: f64, b0: f64, b1: f64) -> Option<(f64, f64)> {
let lo = a0.max(b0);
let hi = a1.min(b1);
(lo.is_finite() && hi.is_finite() && lo <= hi).then_some((lo, hi))
}
fn offer_spacing(best: &mut Option<SpacingChoice>, delta: f64, distance: f64, tol: f64) {
if !delta.is_finite() || !distance.is_finite() || distance <= 0.0 {
return;
}
let dist = delta.abs();
if dist <= tol && best.as_ref().is_none_or(|have| dist < have.dist) {
*best = Some(SpacingChoice { dist, delta, distance });
}
}
fn gap_matches(gap: f64, distance: f64) -> bool {
gap.is_finite() && distance.is_finite() && (gap - distance).abs() <= 1e-6
}
/// Moving gaps after the shift. A side is drawn when its new nearest gap equals `distance`.
fn push_moving_gaps(out: &mut Vec<GapMark>, shifted: &Interval, boxes: &[Interval], axis: Axis, distance: f64, label: &str) {
if let Some(n) = nearest_side(shifted, boxes, None, true)
&& gap_matches(n.gap, distance)
&& let Some((perp_lo, perp_hi)) = perp_span(shifted.perp_lo, shifted.perp_hi, n.perp_lo, n.perp_hi)
{
push_gap_mark(out, axis, n.edge, shifted.lo, perp_lo, perp_hi, label);
}
if let Some(n) = nearest_side(shifted, boxes, None, false)
&& gap_matches(n.gap, distance)
&& let Some((perp_lo, perp_hi)) = perp_span(shifted.perp_lo, shifted.perp_hi, n.perp_lo, n.perp_hi)
{
push_gap_mark(out, axis, shifted.hi, n.edge, perp_lo, perp_hi, label);
}
}
fn push_gap_mark(out: &mut Vec<GapMark>, axis: Axis, left: f64, right: f64, perp_lo: f64, perp_hi: f64, label: &str) {
let Some((a, b)) = gap_ends(axis, left, right, perp_lo, perp_hi) else { return };
if out.iter().any(|g| segment_same(g.a, g.b, a, b)) {
return;
}
out.push(GapMark { a, b, label: label.to_string() });
}
/// Facing edges. The line sits on the midpoint of the perpendicular overlap.
fn gap_ends(axis: Axis, left: f64, right: f64, perp_lo: f64, perp_hi: f64) -> Option<(Point, Point)> {
if !left.is_finite() || !right.is_finite() || !perp_lo.is_finite() || !perp_hi.is_finite() || right <= left {
return None;
}
let mid = (perp_lo + perp_hi) * 0.5;
if !mid.is_finite() {
return None;
}
let (a, b) = match axis {
Axis::X => (Point::new(left, mid), Point::new(right, mid)),
Axis::Y => (Point::new(mid, left), Point::new(mid, right)),
};
(point_finite(a) && point_finite(b)).then_some((a, b))
}
/// Other items' own side lengths, on resize and create.
@ -423,6 +658,7 @@ fn pass_dimensions(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64
span: None,
length_delta: Some(diff),
dimension: Some(dimension_mark(req, axis, diff, text)),
gaps: Vec::new(),
})
}
@ -950,12 +1186,13 @@ fn offer(best: &mut Option<Best>, moving: f64, target: f64, tol: f64, span: Opti
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 } });
*best = Some(Best { dist, hit: AxisHit { delta, at: target, span, length_delta: None, dimension: None, gaps: Vec::new() } });
}
}
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);
push_gap_guides(out, cx, req.spread, hit);
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() {
@ -967,6 +1204,20 @@ fn push_hit_guides(out: &mut Vec<Overlay>, cx: &ToolContext, req: &SnapRequest<'
}
}
fn push_gap_guides(out: &mut Vec<Overlay>, cx: &ToolContext, spread: SpreadRef, hit: &AxisHit) {
if hit.gaps.is_empty() {
return;
}
let xf = cx.layout.xf(spread);
for g in &hit.gaps {
let a = xf * g.a;
let b = xf * g.b;
if point_finite(a) && point_finite(b) {
out.push(Overlay::Gap { a, b, label: g.label.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);
@ -1382,4 +1633,74 @@ mod tests {
);
assert!((hit.delta.x - -2.0).abs() < 1e-6, "delta.x = {}, want -2 toward local height times 2", hit.delta.x);
}
#[test]
fn move_matches_a_stationary_gap() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
// Stationary gap of 20 between [0,40] and [60,90]. Moving box at [112,150], 22 away from 90.
for r in [Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(60.0, 0.0, 90.0, 30.0), Rect::new(112.0, 0.0, 150.0, 30.0)] {
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r));
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
let moving = doc.spreads[0].items[2].id;
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(112.0, 0.0, 150.0, 30.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 - -2.0).abs() < 1e-6);
assert!(hit.guides.iter().any(|g| matches!(g, Overlay::Gap { .. })));
}
#[test]
fn create_does_not_match_spacing() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
for r in [Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(60.0, 0.0, 90.0, 30.0)] {
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r));
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.smart_dimensions = false;
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Create,
rect: Rect::new(112.0, 0.0, 150.0, 30.0),
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[],
copying: false,
lengths: [Some(38.0), None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x).abs() < 1e-6);
}
}