Skip to main content

blockworx/widget/
alignment.rs

1//! Alignment guides shown while dragging. A dragged element's features (edges,
2//! centers, pin stubs, or a single corner) are matched against the same features
3//! of every other shape; each like-kind coincidence draws a thin line spanning
4//! the aligned pair, drawn over the diagram so a center guide (which runs
5//! through the opaque shapes it aligns) stays visible.
6//!
7//! Grid-quantized shapes (blocks, areas) coincide at discrete grid steps, so
8//! their guides are stable. Free-positioned images/icons don't, so
9//! [`snap_drag_offset`] makes their drag magnetic — snapping a center onto a
10//! nearby feature so the guide lands instead of merely flashing past.
11
12use blockworx_doc::id::PinId;
13use blockworx_geom::{Pos2, Rect, Vec2, pos2, vec2};
14
15use crate::{
16    edit::geometry::PinMove,
17    grid::PIN_PITCH,
18    shape::{
19        ShapeId, ShapeRef,
20        pin::{PinSide, slot},
21    },
22    theme::{Role, Style},
23    widget::drawing::Drawing,
24};
25use blockworx_paint::Renderer;
26
27/// Coordinates within this many world units count as aligned when drawing a
28/// guide (only absorbs float error — grid shapes coincide exactly).
29const EPS: f32 = 0.5;
30
31/// How near (world units) a freely-dragged image/icon feature must be to a static
32/// feature for [`snap_drag_offset`] to pull it into exact alignment.
33const SNAP_RADIUS: f32 = 6.0;
34
35#[derive(Clone, Copy, PartialEq)]
36enum Axis {
37    /// A constant-x line; its perpendicular span is a y range.
38    Vertical,
39    /// A constant-y line; its perpendicular span is an x range.
40    Horizontal,
41}
42
43#[derive(Clone, Copy, PartialEq)]
44enum Kind {
45    Edge,
46    Center,
47    PinStub,
48}
49
50#[derive(Clone, Copy)]
51struct Candidate {
52    axis: Axis,
53    kind: Kind,
54    coord: f32,
55    lo: f32,
56    hi: f32,
57}
58
59/// A rect's four edge candidates (the left/right verticals, top/bottom
60/// horizontals) — no centers.
61fn push_rect_edges(rect: Rect, out: &mut Vec<Candidate>) {
62    let (t, b, l, r) = (rect.top(), rect.bottom(), rect.left(), rect.right());
63    out.push(Candidate {
64        axis: Axis::Vertical,
65        kind: Kind::Edge,
66        coord: l,
67        lo: t,
68        hi: b,
69    });
70    out.push(Candidate {
71        axis: Axis::Vertical,
72        kind: Kind::Edge,
73        coord: r,
74        lo: t,
75        hi: b,
76    });
77    out.push(Candidate {
78        axis: Axis::Horizontal,
79        kind: Kind::Edge,
80        coord: t,
81        lo: l,
82        hi: r,
83    });
84    out.push(Candidate {
85        axis: Axis::Horizontal,
86        kind: Kind::Edge,
87        coord: b,
88        lo: l,
89        hi: r,
90    });
91}
92
93fn push_rect_candidates(rect: Rect, out: &mut Vec<Candidate>) {
94    push_rect_edges(rect, out);
95    let (t, b, l, r) = (rect.top(), rect.bottom(), rect.left(), rect.right());
96    out.push(Candidate {
97        axis: Axis::Vertical,
98        kind: Kind::Center,
99        coord: rect.center().x,
100        lo: t,
101        hi: b,
102    });
103    out.push(Candidate {
104        axis: Axis::Horizontal,
105        kind: Kind::Center,
106        coord: rect.center().y,
107        lo: l,
108        hi: r,
109    });
110}
111
112/// A single point contributes one vertical and one horizontal guide candidate.
113fn push_point_candidates(p: Pos2, kind: Kind, out: &mut Vec<Candidate>) {
114    out.push(Candidate {
115        axis: Axis::Vertical,
116        kind,
117        coord: p.x,
118        lo: p.y,
119        hi: p.y,
120    });
121    out.push(Candidate {
122        axis: Axis::Horizontal,
123        kind,
124        coord: p.y,
125        lo: p.x,
126        hi: p.x,
127    });
128}
129
130/// Each pin's stub row: a *horizontal* guide at the stub's y, shifted by
131/// `offset`. Only the row is offered — a pin sits at a fixed offset from the edge
132/// it attaches to, so its x is not a meaningful alignment feature (the edge is);
133/// its y lets pins on different blocks line up. Works for block pins and the port
134/// pin.
135fn push_pin_rows(shape: &ShapeRef, offset: Vec2, out: &mut Vec<Candidate>) {
136    shape.with_pins(|pid, _pin| {
137        if let Some(stub) = shape.pin_stub_rect(pid) {
138            let c = stub.center() + offset;
139            out.push(Candidate {
140                axis: Axis::Horizontal,
141                kind: Kind::PinStub,
142                coord: c.y,
143                lo: c.x,
144                hi: c.x,
145            });
146        }
147    });
148}
149
150/// The features a shape contributes. Blocks: edges, centers, and pin rows.
151/// Ports: the pin row plus two verticals at the pentagon's tip and flat back.
152/// Areas: edges and centers. Text boxes: only the top-left corner (their box
153/// is normally hidden). Images/icons: only the center, so they align
154/// center-to-center (an icon to its containing block's center). `offset` previews
155/// a drag (zero for a resting shape).
156fn shape_candidates(id: ShapeId, shape: &ShapeRef, offset: Vec2, out: &mut Vec<Candidate>) {
157    match id {
158        ShapeId::Rect(_) => {
159            push_rect_candidates(shape.gui_rect().translate(offset), out);
160            push_pin_rows(shape, offset, out);
161        }
162        ShapeId::Port(_) => {
163            // A horizontal guide through the pin (to line up with other pins),
164            // plus two verticals at the pentagon's tip and flat back — its bbox's
165            // left/right edges. The pin's x is a fixed offset from the tip, so it
166            // is not an alignment feature.
167            push_pin_rows(shape, offset, out);
168            let r = shape.gui_rect().translate(offset);
169            for x in [r.left(), r.right()] {
170                out.push(Candidate {
171                    axis: Axis::Vertical,
172                    kind: Kind::Edge,
173                    coord: x,
174                    lo: r.top(),
175                    hi: r.bottom(),
176                });
177            }
178        }
179        ShapeId::Area(_) => push_rect_candidates(shape.gui_rect().translate(offset), out),
180        ShapeId::Text(_) => {
181            push_point_candidates(
182                shape.gui_rect().translate(offset).left_top(),
183                Kind::Edge,
184                out,
185            );
186        }
187        ShapeId::Image(_) | ShapeId::Icon(_) => {
188            push_point_candidates(
189                shape.gui_rect().translate(offset).center(),
190                Kind::Center,
191                out,
192            );
193        }
194    }
195}
196
197fn match_guides(moving: &[Candidate], statics: &[Candidate]) -> Vec<[Pos2; 2]> {
198    // (axis, coord, lo, hi) — guides sharing an axis and coordinate merge so a
199    // column of aligned shapes yields a single line spanning all of them.
200    let mut merged: Vec<(Axis, f32, f32, f32)> = Vec::new();
201    for m in moving {
202        for s in statics {
203            if m.axis != s.axis || m.kind != s.kind || (m.coord - s.coord).abs() >= EPS {
204                continue;
205            }
206            let (lo, hi) = (m.lo.min(s.lo), m.hi.max(s.hi));
207            match merged
208                .iter_mut()
209                .find(|(a, c, _, _)| *a == m.axis && (*c - m.coord).abs() < EPS)
210            {
211                Some(e) => {
212                    e.2 = e.2.min(lo);
213                    e.3 = e.3.max(hi);
214                }
215                None => merged.push((m.axis, m.coord, lo, hi)),
216            }
217        }
218    }
219    merged
220        .into_iter()
221        .map(|(axis, coord, lo, hi)| match axis {
222            Axis::Vertical => [pos2(coord, lo), pos2(coord, hi)],
223            Axis::Horizontal => [pos2(lo, coord), pos2(hi, coord)],
224        })
225        .collect()
226}
227
228/// Draw the computed guide segments. Call this *after* the scene so a center
229/// guide — which runs through the opaque shapes it aligns — stays visible.
230pub fn draw_guides(guides: &[[Pos2; 2]], painter: &mut Style<'_, impl Renderer>) {
231    for &seg in guides {
232        painter.line_segment(seg, (1.0, Role::AlignmentGuide));
233    }
234}
235
236/// The resting alignment features of every shape (plus areas) except those
237/// `is_excluded` — the static set a drag or resize matches its moving features
238/// against.
239fn build_statics(data: &Drawing, is_excluded: impl Fn(ShapeId) -> bool) -> Vec<Candidate> {
240    let mut statics = Vec::new();
241    for (id, shape) in data.shapes().chain(data.areas()) {
242        if !is_excluded(id) {
243            shape_candidates(id, &shape, Vec2::ZERO, &mut statics);
244        }
245    }
246    statics
247}
248
249/// Guides for a shape (or group) drag: the dragged shapes — previewed at
250/// `offset` — contribute their features; every other shape (including areas)
251/// contributes the same at its resting position.
252pub fn shape_drag_guides(data: &Drawing, dragged: &[ShapeId], offset: Vec2) -> Vec<[Pos2; 2]> {
253    let mut moving = Vec::new();
254    for &id in dragged {
255        if let Some(shape) = data.shape(id) {
256            shape_candidates(id, &shape, offset, &mut moving);
257        }
258    }
259    let statics = build_statics(data, |id| dragged.contains(&id));
260    match_guides(&moving, &statics)
261}
262
263/// Alignment guides for a resize: the shape's features at its previewed `resized`
264/// rect, matched against every other shape's resting features. A resize doesn't
265/// translate the shape, so it can't reuse [`shape_drag_guides`]'s `offset` path.
266pub fn resize_guides(data: &Drawing, id: ShapeId, resized: Rect) -> Vec<[Pos2; 2]> {
267    let mut moving = Vec::new();
268    resized_candidates(id, resized, &mut moving);
269    let statics = build_statics(data, |sid| sid == id);
270    match_guides(&moving, &statics)
271}
272
273/// The features a shape at `rect` contributes while being resized: rect shapes
274/// (blocks, areas) offer edges + centers; images/icons offer edges (the ones
275/// the resize magnetism snaps — see [`snap_resize_corner`]); ports offer their
276/// tip/back columns. Text boxes aren't resizable.
277fn resized_candidates(id: ShapeId, rect: Rect, out: &mut Vec<Candidate>) {
278    match id {
279        ShapeId::Rect(_) | ShapeId::Area(_) => push_rect_candidates(rect, out),
280        ShapeId::Image(_) | ShapeId::Icon(_) => push_rect_edges(rect, out),
281        ShapeId::Port(_) => {
282            for x in [rect.left(), rect.right()] {
283                out.push(Candidate {
284                    axis: Axis::Vertical,
285                    kind: Kind::Edge,
286                    coord: x,
287                    lo: rect.top(),
288                    hi: rect.bottom(),
289                });
290            }
291        }
292        ShapeId::Text(_) => {}
293    }
294}
295
296/// Snap a resize's dragged `corner` to a nearby static *edge* — its x to the
297/// nearest static vertical edge, its y to the nearest static horizontal edge,
298/// each within [`SNAP_RADIUS`] and independently — so a free image/icon edge
299/// lines up as a move would. `exclude` is the shape being resized (its own edges
300/// aren't targets). Returns the corrected corner.
301pub fn snap_resize_corner(data: &Drawing, exclude: ShapeId, corner: Pos2) -> Pos2 {
302    let statics = build_statics(data, |sid| sid == exclude);
303    let mut best_x: Option<f32> = None;
304    let mut best_y: Option<f32> = None;
305    for s in statics.iter().filter(|s| s.kind == Kind::Edge) {
306        let (cur, best) = match s.axis {
307            Axis::Vertical => (corner.x, &mut best_x),
308            Axis::Horizontal => (corner.y, &mut best_y),
309        };
310        let d = s.coord - cur;
311        if d.abs() <= SNAP_RADIUS && best.is_none_or(|b: f32| d.abs() < b.abs()) {
312            *best = Some(d);
313        }
314    }
315    pos2(
316        corner.x + best_x.unwrap_or(0.0),
317        corner.y + best_y.unwrap_or(0.0),
318    )
319}
320
321/// Magnetic alignment for a freely-positioned image/icon drag: adjust `raw_offset`
322/// so a dragged feature snaps exactly onto a nearby static feature of the same
323/// kind/axis (within [`SNAP_RADIUS`]), per axis independently — so a drag can snap
324/// horizontally, vertically, or both. Returns `raw_offset` unchanged where nothing
325/// is near. The static set is every other shape (plus areas), matching
326/// [`shape_drag_guides`], so a snap always coincides with a drawn guide.
327pub fn snap_drag_offset(data: &Drawing, dragged: &[ShapeId], raw_offset: Vec2) -> Vec2 {
328    let mut moving = Vec::new();
329    for &id in dragged {
330        if let Some(shape) = data.shape(id) {
331            shape_candidates(id, &shape, raw_offset, &mut moving);
332        }
333    }
334    let statics = build_statics(data, |id| dragged.contains(&id));
335    // Smallest within-radius correction on each axis. A `Vertical` candidate's
336    // `coord` is an x (corrects the offset's x); a `Horizontal` one is a y.
337    let mut best_x: Option<f32> = None;
338    let mut best_y: Option<f32> = None;
339    for m in &moving {
340        for s in &statics {
341            if m.axis != s.axis || m.kind != s.kind {
342                continue;
343            }
344            let d = s.coord - m.coord;
345            if d.abs() > SNAP_RADIUS {
346                continue;
347            }
348            let best = match m.axis {
349                Axis::Vertical => &mut best_x,
350                Axis::Horizontal => &mut best_y,
351            };
352            if best.is_none_or(|b: f32| d.abs() < b.abs()) {
353                *best = Some(d);
354            }
355        }
356    }
357    raw_offset + vec2(best_x.unwrap_or(0.0), best_y.unwrap_or(0.0))
358}
359
360/// Guides for a pin drag: each previewed pin — the same [`PinMove`] the
361/// commit will carry — matches against every other pin stub except
362/// `exclude`.
363pub fn pin_drag_guides(data: &Drawing, moving: &[PinMove], exclude: &[PinId]) -> Vec<[Pos2; 2]> {
364    let mut moving_c = Vec::new();
365    for &PinMove { pin: pid, to } in moving {
366        let Some(shape) = data.pin_shape(pid).and_then(|id| data.shape(id)) else {
367            continue;
368        };
369        let (Some(stub), Some(pin)) = (shape.pin_stub_rect(pid), shape.pin(pid)) else {
370            continue;
371        };
372        let rect = shape.gui_rect();
373        let half = stub.width() / 2.0;
374        let x = match to.side {
375            PinSide::East => rect.right() + half,
376            PinSide::West => rect.left() - half,
377        };
378        let y = stub.center().y + (to.offset as f32 - slot(pin).offset as f32) * PIN_PITCH;
379        // A pin aligns by its row (y) only — its x is fixed to the block edge.
380        moving_c.push(Candidate {
381            axis: Axis::Horizontal,
382            kind: Kind::PinStub,
383            coord: y,
384            lo: x,
385            hi: x,
386        });
387    }
388    let mut statics = Vec::new();
389    for (_, shape) in data.shapes() {
390        shape.with_pins(|pid, _pin| {
391            if !exclude.contains(&pid)
392                && let Some(stub) = shape.pin_stub_rect(pid)
393            {
394                let c = stub.center();
395                statics.push(Candidate {
396                    axis: Axis::Horizontal,
397                    kind: Kind::PinStub,
398                    coord: c.y,
399                    lo: c.x,
400                    hi: c.x,
401                });
402            }
403        });
404    }
405    match_guides(&moving_c, &statics)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::path::Scope;
412    use crate::widget::test_fixtures::{self as fx, Scene};
413    use blockworx_doc::{
414        fixtures::{block_id, image_id, pin_id},
415        geometry::PinSlot,
416        id::BlockId,
417    };
418
419    fn rect_at(x: f32, y: f32, w: f32, h: f32) -> Rect {
420        Rect::from_min_max(pos2(x, y), pos2(x + w, y + h))
421    }
422
423    fn block_at(n: u32, x: f32, y: f32, w: f32, h: f32) -> blockworx_doc::opcode::OpCodes {
424        fx::block_in(n, Scope::Root, rect_at(x, y, w, h))
425    }
426
427    // Vertical guides hold x constant; horizontal guides hold y constant.
428    fn vlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
429        guides
430            .iter()
431            .filter(|g| (g[0].x - g[1].x).abs() < 0.5)
432            .map(|g| g[0].x)
433            .collect()
434    }
435    fn hlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
436        guides
437            .iter()
438            .filter(|g| (g[0].y - g[1].y).abs() < 0.5)
439            .map(|g| g[0].y)
440            .collect()
441    }
442    fn has(coords: &[f32], c: f32) -> bool {
443        coords.iter().any(|&x| (x - c).abs() < 0.5)
444    }
445    fn stub_center(d: &Drawing, block: BlockId, pin: PinId) -> Pos2 {
446        d.shape(ShapeId::Rect(block))
447            .unwrap()
448            .pin_stub_rect(pin)
449            .unwrap()
450            .center()
451    }
452
453    fn rect_of(d: &Drawing, id: ShapeId) -> Rect {
454        d.shape(id).unwrap().gui_rect()
455    }
456
457    // Dragging a block so an edge meets another block's edge yields a guide at
458    // that coordinate; a non-coincident offset yields none. (Coordinates come
459    // from the snapped `gui_rect`s, so grid snapping doesn't matter.)
460    #[test]
461    fn shape_drag_guides_flags_edge_alignment() {
462        // Different y so only vertical (edge-x) alignment is possible.
463        let mut scene = Scene::new(vec![
464            block_at(1, 0.0, 0.0, 45.0, 45.0),
465            block_at(2, 90.0, 150.0, 45.0, 45.0),
466        ]);
467        let drawing = scene.drawing();
468        let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
469        let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
470
471        // Land A's right edge exactly on B's left edge.
472        let guides = shape_drag_guides(&drawing, &[a], vec2(br.left() - ar.right(), 0.0));
473        assert!(has(&vlines(&guides), br.left()));
474        // No coincidence at a small, non-aligning offset.
475        let none = shape_drag_guides(&drawing, &[a], vec2(7.0, 0.0));
476        assert!(none.is_empty());
477    }
478
479    // Center-to-center alignment yields a guide even when no edges coincide
480    // (the blocks have different widths, so their edges can't also line up).
481    #[test]
482    fn shape_drag_guides_flags_center_alignment() {
483        let mut scene = Scene::new(vec![
484            block_at(1, 0.0, 0.0, 30.0, 45.0),
485            block_at(2, 75.0, 150.0, 75.0, 45.0),
486        ]);
487        let drawing = scene.drawing();
488        let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
489        let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
490        assert!(
491            (ar.width() - br.width()).abs() > 0.5,
492            "the blocks must differ in width, or edges could align too"
493        );
494
495        let guides = shape_drag_guides(&drawing, &[a], vec2(br.center().x - ar.center().x, 0.0));
496        assert!(has(&vlines(&guides), br.center().x));
497    }
498
499    // Two pin stubs sharing a row produce a horizontal guide on that row.
500    #[test]
501    fn shape_drag_guides_flags_pin_stub_alignment() {
502        let mut scene = Scene::new(vec![
503            block_at(1, 0.0, 0.0, 40.0, 150.0),
504            fx::pin(3, 1, PinSide::East, 1),
505            block_at(2, 300.0, 0.0, 40.0, 150.0),
506            fx::pin(4, 2, PinSide::East, 1),
507        ]);
508        let drawing = scene.drawing();
509
510        let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
511        // Pure horizontal drag keeps A's pin on the same row as B's.
512        let guides = shape_drag_guides(&drawing, &[ShapeId::Rect(block_id(1))], vec2(100.0, 0.0));
513        assert!(has(&hlines(&guides), row));
514    }
515
516    // A text box contributes only its top-left corner: its left edge aligns, but
517    // its (hidden) right edge does not.
518    #[test]
519    fn shape_drag_guides_text_box_uses_only_its_corner() {
520        let mut scene = Scene::new(vec![
521            block_at(1, 0.0, 300.0, 40.0, 40.0),
522            fx::text(5, Scope::Root, "some text", pos2(100.0, 100.0)),
523        ]);
524        let drawing = scene.drawing();
525        let a = ShapeId::Rect(block_id(1));
526        let tb = rect_of(&drawing, ShapeId::Text(blockworx_doc::fixtures::text_id(5)));
527        let ar = rect_of(&drawing, a);
528
529        // Drag A so its left edge meets the text box's top-left x → a guide.
530        let g1 = shape_drag_guides(&drawing, &[a], vec2(tb.left() - ar.left(), 0.0));
531        assert!(has(&vlines(&g1), tb.left()));
532        // Drag A so its left edge meets the text box's RIGHT edge → no guide
533        // there (the right edge is not an alignment feature).
534        let g2 = shape_drag_guides(&drawing, &[a], vec2(tb.right() - ar.left(), 0.0));
535        assert!(!has(&vlines(&g2), tb.right()));
536    }
537
538    // An image dragged so its center lands on a block's center yields both a
539    // vertical and horizontal center guide (image features are center-only).
540    #[test]
541    fn shape_drag_guides_flags_image_center_alignment() {
542        let mut scene = Scene::new(vec![
543            fx::asset().1,
544            block_at(1, 100.0, 100.0, 40.0, 40.0),
545            fx::image(5, Scope::Root, rect_at(0.0, 0.0, 25.0, 25.0)),
546        ]);
547        let drawing = scene.drawing();
548        let img = ShapeId::Image(image_id(5));
549        let ic = rect_of(&drawing, img).center();
550        let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
551
552        let guides = shape_drag_guides(&drawing, &[img], bc - ic);
553        assert!(has(&vlines(&guides), bc.x));
554        assert!(has(&hlines(&guides), bc.y));
555    }
556
557    // A block's icon aligns to the block's own center (the block is a static).
558    #[test]
559    fn shape_drag_guides_flags_icon_to_block_center() {
560        // The icon box is centered on the block, so a zero drag aligns it.
561        let mut scene = Scene::new(vec![
562            fx::asset().1,
563            block_at(1, 0.0, 0.0, 60.0, 60.0),
564            fx::icon(1, rect_at(15.0, 15.0, 30.0, 30.0)),
565        ]);
566        let drawing = scene.drawing();
567        let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
568        let icon = ShapeId::Icon(block_id(1));
569        assert!(
570            (rect_of(&drawing, icon).center() - bc).length() < 0.5,
571            "the icon must start centered, or a zero drag proves nothing"
572        );
573
574        let guides = shape_drag_guides(&drawing, &[icon], Vec2::ZERO);
575        assert!(has(&vlines(&guides), bc.x));
576        assert!(has(&hlines(&guides), bc.y));
577    }
578
579    // Magnetism pulls a near miss (within SNAP_RADIUS) exactly onto a center, and
580    // leaves a far drag untouched.
581    #[test]
582    fn snap_drag_offset_pulls_a_near_center_and_ignores_a_far_one() {
583        let mut scene = Scene::new(vec![
584            fx::asset().1,
585            block_at(1, 100.0, 100.0, 40.0, 40.0),
586            fx::image(5, Scope::Root, rect_at(0.0, 0.0, 20.0, 20.0)),
587        ]);
588        let drawing = scene.drawing();
589        let img = ShapeId::Image(image_id(5));
590        let ic = rect_of(&drawing, img).center();
591        let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
592
593        // Land the image center 3px short of the block center (< SNAP_RADIUS).
594        let near = (bc - ic) - vec2(3.0, 3.0);
595        let snapped = snap_drag_offset(&drawing, &[img], near);
596        assert!(
597            ((ic + snapped) - bc).length() < 1e-3,
598            "snapped onto the center"
599        );
600
601        let far = vec2(1000.0, 1000.0);
602        assert_eq!(
603            snap_drag_offset(&drawing, &[img], far),
604            far,
605            "far drag unchanged"
606        );
607    }
608
609    // A previewed pin moved onto another pin's row produces a guide; the dragged
610    // pin's own resting stub is excluded from the static set.
611    #[test]
612    fn pin_drag_guides_flags_a_shared_row() {
613        let mut scene = Scene::new(vec![
614            block_at(1, 0.0, 0.0, 40.0, 150.0),
615            fx::pin(3, 1, PinSide::East, 0),
616            block_at(2, 300.0, 0.0, 40.0, 150.0),
617            fx::pin(4, 2, PinSide::East, 2),
618        ]);
619        let drawing = scene.drawing();
620
621        let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
622        // Move A's pin to slot 2 → same row as B's pin.
623        let moved = PinMove {
624            pin: pin_id(3),
625            to: PinSlot {
626                side: PinSide::East,
627                offset: 2,
628            },
629        };
630        let guides = pin_drag_guides(&drawing, &[moved], &[pin_id(3)]);
631        assert!(has(&hlines(&guides), row));
632    }
633
634    // A block pin offers only a horizontal (row) guide: its x is fixed to the
635    // block edge, so a vertical guide through it would be noise.
636    #[test]
637    fn a_block_pin_offers_only_a_row_guide() {
638        let mut scene = Scene::new(vec![
639            block_at(1, 0.0, 0.0, 40.0, 150.0),
640            fx::pin(3, 1, PinSide::East, 1),
641        ]);
642        let drawing = scene.drawing();
643        let id = ShapeId::Rect(block_id(1));
644        let shape = drawing.shape(id).unwrap();
645
646        let mut out = Vec::new();
647        shape_candidates(id, &shape, Vec2::ZERO, &mut out);
648        let pins: Vec<&Candidate> = out.iter().filter(|c| c.kind == Kind::PinStub).collect();
649        assert!(!pins.is_empty(), "the pin contributes a row guide");
650        assert!(
651            pins.iter().all(|c| c.axis == Axis::Horizontal),
652            "a pin never offers a vertical (column) guide"
653        );
654    }
655
656    // A port offers a horizontal guide through its pin plus two verticals at the
657    // pentagon's tip and flat back (its bbox's left/right edges) — but no vertical
658    // through the pin itself.
659    #[test]
660    fn a_port_offers_a_pin_row_and_tip_and_back_columns() {
661        let pid = pin_id(3);
662        let mut scene = Scene::new(vec![fx::pin_at(
663            3,
664            Scope::Root,
665            "x",
666            fx::slot(PinSide::East, 0),
667            rect_at(10.0, 20.0, 40.0, 30.0),
668        )]);
669        let drawing = scene.drawing();
670        let id = ShapeId::Port(pid);
671        let shape = drawing.shape(id).unwrap();
672        let r = shape.gui_rect();
673
674        let mut out = Vec::new();
675        shape_candidates(id, &shape, Vec2::ZERO, &mut out);
676        assert!(
677            out.iter()
678                .any(|c| c.kind == Kind::PinStub && c.axis == Axis::Horizontal),
679            "a horizontal guide through the pin"
680        );
681        assert!(
682            out.iter()
683                .filter(|c| c.kind == Kind::PinStub)
684                .all(|c| c.axis == Axis::Horizontal),
685            "no vertical guide through the pin"
686        );
687        let vlines: Vec<f32> = out
688            .iter()
689            .filter(|c| c.axis == Axis::Vertical)
690            .map(|c| c.coord)
691            .collect();
692        assert!(has(&vlines, r.left()), "a vertical at one pentagon end");
693        assert!(has(&vlines, r.right()), "a vertical at the other end");
694    }
695
696    // Resizing a block so an edge lands on another block's edge yields a guide at
697    // that coordinate; a non-coincident resize yields none.
698    #[test]
699    fn resize_guides_flag_an_edge_meeting_another() {
700        let mut scene = Scene::new(vec![
701            block_at(1, 0.0, 0.0, 45.0, 45.0),
702            block_at(2, 90.0, 150.0, 45.0, 45.0),
703        ]);
704        let drawing = scene.drawing();
705        let a = ShapeId::Rect(block_id(1));
706        let br = rect_of(&drawing, ShapeId::Rect(block_id(2)));
707
708        // Grow A's right edge out to B's left edge → a vertical guide there.
709        let onto = Rect::from_min_max(pos2(0.0, 0.0), pos2(br.left(), 45.0));
710        let guides = resize_guides(&drawing, a, onto);
711        assert!(has(&vlines(&guides), br.left()));
712
713        // A resize that lines nothing up yields no guide.
714        let off = Rect::from_min_max(pos2(0.0, 0.0), pos2(52.0, 45.0));
715        assert!(resize_guides(&drawing, a, off).is_empty());
716    }
717
718    // A resize's dragged corner snaps its edge onto a nearby static edge, per
719    // axis: a near x snaps, a far y is left alone; a far corner is unchanged.
720    #[test]
721    fn snap_resize_corner_snaps_a_dragged_edge_to_a_static_edge() {
722        let mut scene = Scene::new(vec![
723            fx::asset().1,
724            block_at(1, 0.0, 0.0, 45.0, 45.0),
725            fx::image(5, Scope::Root, rect_at(100.0, 100.0, 60.0, 40.0)),
726        ]);
727        let drawing = scene.drawing();
728        let img = ShapeId::Image(image_id(5));
729        let ar = rect_of(&drawing, ShapeId::Rect(block_id(1)));
730
731        // A corner 3px shy of block a's right edge (< SNAP_RADIUS) snaps its x
732        // onto it; its y has no nearby edge and is left free.
733        let corner = pos2(ar.right() - 3.0, 500.0);
734        let snapped = snap_resize_corner(&drawing, img, corner);
735        assert!((snapped.x - ar.right()).abs() < 1e-3, "x snaps to the edge");
736        assert_eq!(snapped.y, 500.0, "y has no nearby edge");
737
738        let far = pos2(1000.0, 1000.0);
739        assert_eq!(
740            snap_resize_corner(&drawing, img, far),
741            far,
742            "far corner unchanged"
743        );
744    }
745}