Skip to main content

blockworx_editor/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.
33pub const 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.
252#[tracing::instrument(level = "info", skip_all)]
253pub fn shape_drag_guides(data: &Drawing, dragged: &[ShapeId], offset: Vec2) -> Vec<[Pos2; 2]> {
254    let mut moving = Vec::new();
255    for &id in dragged {
256        if let Some(shape) = data.shape(id) {
257            shape_candidates(id, &shape, offset, &mut moving);
258        }
259    }
260    let statics = build_statics(data, |id| dragged.contains(&id));
261    match_guides(&moving, &statics)
262}
263
264/// Alignment guides for a resize: the shape's features at its previewed `resized`
265/// rect, matched against every other shape's resting features. A resize doesn't
266/// translate the shape, so it can't reuse [`shape_drag_guides`]'s `offset` path.
267pub fn resize_guides(data: &Drawing, id: ShapeId, resized: Rect) -> Vec<[Pos2; 2]> {
268    let mut moving = Vec::new();
269    resized_candidates(id, resized, &mut moving);
270    let statics = build_statics(data, |sid| sid == id);
271    match_guides(&moving, &statics)
272}
273
274/// The features a shape at `rect` contributes while being resized: rect shapes
275/// (blocks, areas) offer edges + centers; images/icons offer edges (the ones
276/// the resize magnetism snaps — see [`snap_resize_corner`]); ports offer their
277/// tip/back columns. Text boxes aren't resizable.
278fn resized_candidates(id: ShapeId, rect: Rect, out: &mut Vec<Candidate>) {
279    match id {
280        ShapeId::Rect(_) | ShapeId::Area(_) => push_rect_candidates(rect, out),
281        ShapeId::Image(_) | ShapeId::Icon(_) => push_rect_edges(rect, out),
282        ShapeId::Port(_) => {
283            for x in [rect.left(), rect.right()] {
284                out.push(Candidate {
285                    axis: Axis::Vertical,
286                    kind: Kind::Edge,
287                    coord: x,
288                    lo: rect.top(),
289                    hi: rect.bottom(),
290                });
291            }
292        }
293        ShapeId::Text(_) => {}
294    }
295}
296
297/// Snap a resize's dragged `corner` to a nearby static *edge* — its x to the
298/// nearest static vertical edge, its y to the nearest static horizontal edge,
299/// each within [`SNAP_RADIUS`] and independently — so a free image/icon edge
300/// lines up as a move would. `exclude` is the shape being resized (its own edges
301/// aren't targets). Returns the corrected corner.
302pub fn snap_resize_corner(data: &Drawing, exclude: ShapeId, corner: Pos2) -> Pos2 {
303    let statics = build_statics(data, |sid| sid == exclude);
304    let mut best_x: Option<f32> = None;
305    let mut best_y: Option<f32> = None;
306    for s in statics.iter().filter(|s| s.kind == Kind::Edge) {
307        let (cur, best) = match s.axis {
308            Axis::Vertical => (corner.x, &mut best_x),
309            Axis::Horizontal => (corner.y, &mut best_y),
310        };
311        let d = s.coord - cur;
312        if d.abs() <= SNAP_RADIUS && best.is_none_or(|b: f32| d.abs() < b.abs()) {
313            *best = Some(d);
314        }
315    }
316    pos2(
317        corner.x + best_x.unwrap_or(0.0),
318        corner.y + best_y.unwrap_or(0.0),
319    )
320}
321
322/// Magnetic alignment for a freely-positioned image/icon drag: adjust `raw_offset`
323/// so a dragged feature snaps exactly onto a nearby static feature of the same
324/// kind/axis (within [`SNAP_RADIUS`]), per axis independently — so a drag can snap
325/// horizontally, vertically, or both. Returns `raw_offset` unchanged where nothing
326/// is near. The static set is every other shape (plus areas), matching
327/// [`shape_drag_guides`], so a snap always coincides with a drawn guide.
328pub fn snap_drag_offset(data: &Drawing, dragged: &[ShapeId], raw_offset: Vec2) -> Vec2 {
329    let mut moving = Vec::new();
330    for &id in dragged {
331        if let Some(shape) = data.shape(id) {
332            shape_candidates(id, &shape, raw_offset, &mut moving);
333        }
334    }
335    let statics = build_statics(data, |id| dragged.contains(&id));
336    // Smallest within-radius correction on each axis. A `Vertical` candidate's
337    // `coord` is an x (corrects the offset's x); a `Horizontal` one is a y.
338    let mut best_x: Option<f32> = None;
339    let mut best_y: Option<f32> = None;
340    for m in &moving {
341        for s in &statics {
342            if m.axis != s.axis || m.kind != s.kind {
343                continue;
344            }
345            let d = s.coord - m.coord;
346            if d.abs() > SNAP_RADIUS {
347                continue;
348            }
349            let best = match m.axis {
350                Axis::Vertical => &mut best_x,
351                Axis::Horizontal => &mut best_y,
352            };
353            if best.is_none_or(|b: f32| d.abs() < b.abs()) {
354                *best = Some(d);
355            }
356        }
357    }
358    raw_offset + vec2(best_x.unwrap_or(0.0), best_y.unwrap_or(0.0))
359}
360
361/// Guides for a pin drag: each previewed pin — the same [`PinMove`] the
362/// commit will carry — matches against every other pin stub except
363/// `exclude`.
364pub fn pin_drag_guides(data: &Drawing, moving: &[PinMove], exclude: &[PinId]) -> Vec<[Pos2; 2]> {
365    let mut moving_c = Vec::new();
366    for &PinMove { pin: pid, to } in moving {
367        let Some(shape) = data.pin_shape(pid).and_then(|id| data.shape(id)) else {
368            continue;
369        };
370        let (Some(stub), Some(pin)) = (shape.pin_stub_rect(pid), shape.pin(pid)) else {
371            continue;
372        };
373        let rect = shape.gui_rect();
374        let half = stub.width() / 2.0;
375        let x = match to.side {
376            PinSide::East => rect.right() + half,
377            PinSide::West => rect.left() - half,
378        };
379        let y = stub.center().y + (to.offset as f32 - slot(pin).offset as f32) * PIN_PITCH;
380        // A pin aligns by its row (y) only — its x is fixed to the block edge.
381        moving_c.push(Candidate {
382            axis: Axis::Horizontal,
383            kind: Kind::PinStub,
384            coord: y,
385            lo: x,
386            hi: x,
387        });
388    }
389    let mut statics = Vec::new();
390    for (_, shape) in data.shapes() {
391        shape.with_pins(|pid, _pin| {
392            if !exclude.contains(&pid)
393                && let Some(stub) = shape.pin_stub_rect(pid)
394            {
395                let c = stub.center();
396                statics.push(Candidate {
397                    axis: Axis::Horizontal,
398                    kind: Kind::PinStub,
399                    coord: c.y,
400                    lo: c.x,
401                    hi: c.x,
402                });
403            }
404        });
405    }
406    match_guides(&moving_c, &statics)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::path::Scope;
413    use crate::widget::test_fixtures::{self as fx, Scene};
414    use blockworx_doc::{
415        fixtures::{block_id, image_id, pin_id},
416        geometry::PinSlot,
417        id::BlockId,
418    };
419
420    fn rect_at(x: f32, y: f32, w: f32, h: f32) -> Rect {
421        Rect::from_min_max(pos2(x, y), pos2(x + w, y + h))
422    }
423
424    fn block_at(n: u32, x: f32, y: f32, w: f32, h: f32) -> blockworx_doc::opcode::OpCodes {
425        fx::block_in(n, Scope::Root, rect_at(x, y, w, h))
426    }
427
428    // Vertical guides hold x constant; horizontal guides hold y constant.
429    fn vlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
430        guides
431            .iter()
432            .filter(|g| (g[0].x - g[1].x).abs() < 0.5)
433            .map(|g| g[0].x)
434            .collect()
435    }
436    fn hlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
437        guides
438            .iter()
439            .filter(|g| (g[0].y - g[1].y).abs() < 0.5)
440            .map(|g| g[0].y)
441            .collect()
442    }
443    fn has(coords: &[f32], c: f32) -> bool {
444        coords.iter().any(|&x| (x - c).abs() < 0.5)
445    }
446    fn stub_center(d: &Drawing, block: BlockId, pin: PinId) -> Pos2 {
447        d.shape(ShapeId::Rect(block))
448            .unwrap()
449            .pin_stub_rect(pin)
450            .unwrap()
451            .center()
452    }
453
454    fn rect_of(d: &Drawing, id: ShapeId) -> Rect {
455        d.shape(id).unwrap().gui_rect()
456    }
457
458    // Dragging a block so an edge meets another block's edge yields a guide at
459    // that coordinate; a non-coincident offset yields none. (Coordinates come
460    // from the snapped `gui_rect`s, so grid snapping doesn't matter.)
461    #[test]
462    fn shape_drag_guides_flags_edge_alignment() {
463        // Different y so only vertical (edge-x) alignment is possible.
464        let mut scene = Scene::new(vec![
465            block_at(1, 0.0, 0.0, 45.0, 45.0),
466            block_at(2, 90.0, 150.0, 45.0, 45.0),
467        ]);
468        let drawing = scene.drawing();
469        let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
470        let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
471
472        // Land A's right edge exactly on B's left edge.
473        let guides = shape_drag_guides(&drawing, &[a], vec2(br.left() - ar.right(), 0.0));
474        assert!(has(&vlines(&guides), br.left()));
475        // No coincidence at a small, non-aligning offset.
476        let none = shape_drag_guides(&drawing, &[a], vec2(7.0, 0.0));
477        assert!(none.is_empty());
478    }
479
480    // Center-to-center alignment yields a guide even when no edges coincide
481    // (the blocks have different widths, so their edges can't also line up).
482    #[test]
483    fn shape_drag_guides_flags_center_alignment() {
484        let mut scene = Scene::new(vec![
485            block_at(1, 0.0, 0.0, 30.0, 45.0),
486            block_at(2, 75.0, 150.0, 75.0, 45.0),
487        ]);
488        let drawing = scene.drawing();
489        let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
490        let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
491        assert!(
492            (ar.width() - br.width()).abs() > 0.5,
493            "the blocks must differ in width, or edges could align too"
494        );
495
496        let guides = shape_drag_guides(&drawing, &[a], vec2(br.center().x - ar.center().x, 0.0));
497        assert!(has(&vlines(&guides), br.center().x));
498    }
499
500    // Two pin stubs sharing a row produce a horizontal guide on that row.
501    #[test]
502    fn shape_drag_guides_flags_pin_stub_alignment() {
503        let mut scene = Scene::new(vec![
504            block_at(1, 0.0, 0.0, 40.0, 150.0),
505            fx::pin(3, 1, PinSide::East, 1),
506            block_at(2, 300.0, 0.0, 40.0, 150.0),
507            fx::pin(4, 2, PinSide::East, 1),
508        ]);
509        let drawing = scene.drawing();
510
511        let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
512        // Pure horizontal drag keeps A's pin on the same row as B's.
513        let guides = shape_drag_guides(&drawing, &[ShapeId::Rect(block_id(1))], vec2(100.0, 0.0));
514        assert!(has(&hlines(&guides), row));
515    }
516
517    // A text box contributes only its top-left corner: its left edge aligns, but
518    // its (hidden) right edge does not.
519    #[test]
520    fn shape_drag_guides_text_box_uses_only_its_corner() {
521        let mut scene = Scene::new(vec![
522            block_at(1, 0.0, 300.0, 40.0, 40.0),
523            fx::text(5, Scope::Root, "some text", pos2(100.0, 100.0)),
524        ]);
525        let drawing = scene.drawing();
526        let a = ShapeId::Rect(block_id(1));
527        let tb = rect_of(&drawing, ShapeId::Text(blockworx_doc::fixtures::text_id(5)));
528        let ar = rect_of(&drawing, a);
529
530        // Drag A so its left edge meets the text box's top-left x → a guide.
531        let g1 = shape_drag_guides(&drawing, &[a], vec2(tb.left() - ar.left(), 0.0));
532        assert!(has(&vlines(&g1), tb.left()));
533        // Drag A so its left edge meets the text box's RIGHT edge → no guide
534        // there (the right edge is not an alignment feature).
535        let g2 = shape_drag_guides(&drawing, &[a], vec2(tb.right() - ar.left(), 0.0));
536        assert!(!has(&vlines(&g2), tb.right()));
537    }
538
539    // An image dragged so its center lands on a block's center yields both a
540    // vertical and horizontal center guide (image features are center-only).
541    #[test]
542    fn shape_drag_guides_flags_image_center_alignment() {
543        let mut scene = Scene::new(vec![
544            fx::asset().1,
545            block_at(1, 100.0, 100.0, 40.0, 40.0),
546            fx::image(5, Scope::Root, rect_at(0.0, 0.0, 25.0, 25.0)),
547        ]);
548        let drawing = scene.drawing();
549        let img = ShapeId::Image(image_id(5));
550        let ic = rect_of(&drawing, img).center();
551        let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
552
553        let guides = shape_drag_guides(&drawing, &[img], bc - ic);
554        assert!(has(&vlines(&guides), bc.x));
555        assert!(has(&hlines(&guides), bc.y));
556    }
557
558    // A block's icon aligns to the block's own center (the block is a static).
559    #[test]
560    fn shape_drag_guides_flags_icon_to_block_center() {
561        // The icon box is centered on the block, so a zero drag aligns it.
562        let mut scene = Scene::new(vec![
563            fx::asset().1,
564            block_at(1, 0.0, 0.0, 60.0, 60.0),
565            fx::icon(1, rect_at(15.0, 15.0, 30.0, 30.0)),
566        ]);
567        let drawing = scene.drawing();
568        let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
569        let icon = ShapeId::Icon(block_id(1));
570        assert!(
571            (rect_of(&drawing, icon).center() - bc).length() < 0.5,
572            "the icon must start centered, or a zero drag proves nothing"
573        );
574
575        let guides = shape_drag_guides(&drawing, &[icon], Vec2::ZERO);
576        assert!(has(&vlines(&guides), bc.x));
577        assert!(has(&hlines(&guides), bc.y));
578    }
579
580    // Magnetism pulls a near miss (within SNAP_RADIUS) exactly onto a center, and
581    // leaves a far drag untouched.
582    #[test]
583    fn snap_drag_offset_pulls_a_near_center_and_ignores_a_far_one() {
584        let mut scene = Scene::new(vec![
585            fx::asset().1,
586            block_at(1, 100.0, 100.0, 40.0, 40.0),
587            fx::image(5, Scope::Root, rect_at(0.0, 0.0, 20.0, 20.0)),
588        ]);
589        let drawing = scene.drawing();
590        let img = ShapeId::Image(image_id(5));
591        let ic = rect_of(&drawing, img).center();
592        let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
593
594        // Land the image center 3px short of the block center (< SNAP_RADIUS).
595        let near = (bc - ic) - vec2(3.0, 3.0);
596        let snapped = snap_drag_offset(&drawing, &[img], near);
597        assert!(
598            ((ic + snapped) - bc).length() < 1e-3,
599            "snapped onto the center"
600        );
601
602        let far = vec2(1000.0, 1000.0);
603        assert_eq!(
604            snap_drag_offset(&drawing, &[img], far),
605            far,
606            "far drag unchanged"
607        );
608    }
609
610    // A previewed pin moved onto another pin's row produces a guide; the dragged
611    // pin's own resting stub is excluded from the static set.
612    #[test]
613    fn pin_drag_guides_flags_a_shared_row() {
614        let mut scene = Scene::new(vec![
615            block_at(1, 0.0, 0.0, 40.0, 150.0),
616            fx::pin(3, 1, PinSide::East, 0),
617            block_at(2, 300.0, 0.0, 40.0, 150.0),
618            fx::pin(4, 2, PinSide::East, 2),
619        ]);
620        let drawing = scene.drawing();
621
622        let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
623        // Move A's pin to slot 2 → same row as B's pin.
624        let moved = PinMove {
625            pin: pin_id(3),
626            to: PinSlot {
627                side: PinSide::East,
628                offset: 2,
629            },
630        };
631        let guides = pin_drag_guides(&drawing, &[moved], &[pin_id(3)]);
632        assert!(has(&hlines(&guides), row));
633    }
634
635    // A block pin offers only a horizontal (row) guide: its x is fixed to the
636    // block edge, so a vertical guide through it would be noise.
637    #[test]
638    fn a_block_pin_offers_only_a_row_guide() {
639        let mut scene = Scene::new(vec![
640            block_at(1, 0.0, 0.0, 40.0, 150.0),
641            fx::pin(3, 1, PinSide::East, 1),
642        ]);
643        let drawing = scene.drawing();
644        let id = ShapeId::Rect(block_id(1));
645        let shape = drawing.shape(id).unwrap();
646
647        let mut out = Vec::new();
648        shape_candidates(id, &shape, Vec2::ZERO, &mut out);
649        let pins: Vec<&Candidate> = out.iter().filter(|c| c.kind == Kind::PinStub).collect();
650        assert!(!pins.is_empty(), "the pin contributes a row guide");
651        assert!(
652            pins.iter().all(|c| c.axis == Axis::Horizontal),
653            "a pin never offers a vertical (column) guide"
654        );
655    }
656
657    // A port offers a horizontal guide through its pin plus two verticals at the
658    // pentagon's tip and flat back (its bbox's left/right edges) — but no vertical
659    // through the pin itself.
660    #[test]
661    fn a_port_offers_a_pin_row_and_tip_and_back_columns() {
662        let pid = pin_id(3);
663        let mut scene = Scene::new(vec![fx::pin_at(
664            3,
665            Scope::Root,
666            "x",
667            fx::slot(PinSide::East, 0),
668            rect_at(10.0, 20.0, 40.0, 30.0),
669        )]);
670        let drawing = scene.drawing();
671        let id = ShapeId::Port(pid);
672        let shape = drawing.shape(id).unwrap();
673        let r = shape.gui_rect();
674
675        let mut out = Vec::new();
676        shape_candidates(id, &shape, Vec2::ZERO, &mut out);
677        assert!(
678            out.iter()
679                .any(|c| c.kind == Kind::PinStub && c.axis == Axis::Horizontal),
680            "a horizontal guide through the pin"
681        );
682        assert!(
683            out.iter()
684                .filter(|c| c.kind == Kind::PinStub)
685                .all(|c| c.axis == Axis::Horizontal),
686            "no vertical guide through the pin"
687        );
688        let vlines: Vec<f32> = out
689            .iter()
690            .filter(|c| c.axis == Axis::Vertical)
691            .map(|c| c.coord)
692            .collect();
693        assert!(has(&vlines, r.left()), "a vertical at one pentagon end");
694        assert!(has(&vlines, r.right()), "a vertical at the other end");
695    }
696
697    // Resizing a block so an edge lands on another block's edge yields a guide at
698    // that coordinate; a non-coincident resize yields none.
699    #[test]
700    fn resize_guides_flag_an_edge_meeting_another() {
701        let mut scene = Scene::new(vec![
702            block_at(1, 0.0, 0.0, 45.0, 45.0),
703            block_at(2, 90.0, 150.0, 45.0, 45.0),
704        ]);
705        let drawing = scene.drawing();
706        let a = ShapeId::Rect(block_id(1));
707        let br = rect_of(&drawing, ShapeId::Rect(block_id(2)));
708
709        // Grow A's right edge out to B's left edge → a vertical guide there.
710        let onto = Rect::from_min_max(pos2(0.0, 0.0), pos2(br.left(), 45.0));
711        let guides = resize_guides(&drawing, a, onto);
712        assert!(has(&vlines(&guides), br.left()));
713
714        // A resize that lines nothing up yields no guide.
715        let off = Rect::from_min_max(pos2(0.0, 0.0), pos2(52.0, 45.0));
716        assert!(resize_guides(&drawing, a, off).is_empty());
717    }
718
719    // A resize's dragged corner snaps its edge onto a nearby static edge, per
720    // axis: a near x snaps, a far y is left alone; a far corner is unchanged.
721    #[test]
722    fn snap_resize_corner_snaps_a_dragged_edge_to_a_static_edge() {
723        let mut scene = Scene::new(vec![
724            fx::asset().1,
725            block_at(1, 0.0, 0.0, 45.0, 45.0),
726            fx::image(5, Scope::Root, rect_at(100.0, 100.0, 60.0, 40.0)),
727        ]);
728        let drawing = scene.drawing();
729        let img = ShapeId::Image(image_id(5));
730        let ar = rect_of(&drawing, ShapeId::Rect(block_id(1)));
731
732        // A corner 3px shy of block a's right edge (< SNAP_RADIUS) snaps its x
733        // onto it; its y has no nearby edge and is left free.
734        let corner = pos2(ar.right() - 3.0, 500.0);
735        let snapped = snap_resize_corner(&drawing, img, corner);
736        assert!((snapped.x - ar.right()).abs() < 1e-3, "x snaps to the edge");
737        assert_eq!(snapped.y, 500.0, "y has no nearby edge");
738
739        let far = pos2(1000.0, 1000.0);
740        assert_eq!(
741            snap_resize_corner(&drawing, img, far),
742            far,
743            "far corner unchanged"
744        );
745    }
746}