Skip to main content

blockworx_editor/widget/
movement.rs

1//! Moving shapes: the per-kind moves, the collision rules that gate them, and
2//! the group move that keeps a selection's relative layout — plus the waypoint
3//! reconciliation each move owes the routes that touch it.
4
5use ahash::HashSet;
6use blockworx_geom::Vec2;
7
8use blockworx_doc::{
9    geometry::{GridRect, GridVec},
10    id::{BlockId, PinId},
11};
12
13use crate::{
14    edit::geometry::{self as edit_geometry, Moving, Shape},
15    grid::{GRID_SIZE, artwork_rect, px_rect, snap_to_grid},
16    shape::ShapeId,
17    widget::drawing::Drawing,
18};
19
20impl Drawing<'_> {
21    /// The cells a move of `shapes` by `delta` would have to share with
22    /// something the router routes around — empty when it would be taken.
23    ///
24    /// Asked of the same rule the commit is refused by
25    /// ([`edit_geometry::move_conflicts`]), so a drag cannot show a landing
26    /// the release then refuses.
27    pub fn conflicts(&self, shapes: &[ShapeId], delta: Vec2, moving: Moving) -> Vec<GridRect> {
28        let members: Vec<Shape> = shapes.iter().copied().map(Shape::from).collect();
29        edit_geometry::move_conflicts(&self.indexed(), &members, delta, moving)
30    }
31
32    /// The `delta` a move of `shape` will actually apply, so a drag preview can
33    /// match the commit. An icon is held inside the block it belongs to; every
34    /// other shape moves by `delta` unchanged.
35    pub fn constrain_move(&self, shape: ShapeId, delta: Vec2) -> Vec2 {
36        match shape {
37            ShapeId::Icon(rid) => match (self.icon(rid), self.block(rid)) {
38                (Some(icon), Some(block)) => {
39                    let box_now = artwork_rect(icon.rect);
40                    let contained = crate::shape::block::contain_rect(
41                        box_now.translate(delta),
42                        px_rect(block.rect),
43                    );
44                    contained.min - box_now.min
45                }
46                _ => delta,
47            },
48            _ => delta,
49        }
50    }
51
52    /// Move any shape (block, port, text box, area, image, or icon) by
53    /// `delta`. Blocks and ports are collision-checked; annotations float
54    /// freely, and an icon is held inside the block it belongs to.
55    pub fn move_shape(&mut self, id: ShapeId, delta: Vec2) {
56        let _s = tracing::info_span!("emit_move").entered();
57        self.author("move_shape", |indexed, sink| {
58            edit_geometry::move_shape(indexed, Shape::from(id), delta, sink);
59        });
60    }
61
62    /// The pins riding a set of moved shapes: a moved block carries its whole
63    /// boundary, a moved port is one pin of its own — the classification the
64    /// preview reconciles routes against. The commit runs the same rule from
65    /// the emitter's side ([`edit_geometry::move_group`]).
66    pub(super) fn riding_pins(
67        &self,
68        moved_blocks: &HashSet<BlockId>,
69        moved_ports: &HashSet<PinId>,
70    ) -> HashSet<PinId> {
71        let mut pins: HashSet<PinId> = moved_ports.clone();
72        for &block in moved_blocks {
73            pins.extend(
74                self.block_pins(crate::path::Scope::Block(block))
75                    .into_iter()
76                    .map(|(id, _)| id),
77            );
78        }
79        pins
80    }
81
82    /// Drop the stale approach waypoints on every route of this scope that
83    /// straddles a move: exactly one endpoint anchored to a moved shape. The
84    /// moved side's leading (or trailing) auto-corners are removed so the
85    /// re-route regenerates a clean approach; routes fully inside or fully
86    /// outside the move keep their waypoints. Commit-time only: the
87    /// single-shape drop calls it directly, a group drag folds the same trim
88    /// into [`edit_geometry::move_group`]'s own riders, and the live preview
89    /// *computes* the identical set without applying it.
90    #[tracing::instrument(level = "info", skip_all)]
91    pub fn trim_partial_route_approaches(&mut self, moved: &[ShapeId]) {
92        let (moved_rects, moved_pins, _) = moved_set_and_delta(moved, Vec2::ZERO);
93        let riding = self.riding_pins(&moved_rects, &moved_pins);
94        self.trim_approaches(&riding);
95    }
96
97    /// Trim the approach waypoints of every route ending on `pin` (a single
98    /// dragged pin or port), from that endpoint's side.
99    pub fn trim_anchor_approach(&mut self, pin: PinId) {
100        self.trim_approaches(&HashSet::from_iter([pin]));
101    }
102
103    /// One authoring for every wire the trim touches, as a group move's riders
104    /// are: each authored op re-folds the gesture and re-indexes the
105    /// document, so a wire at a time paid that once per straddling wire.
106    /// With no offset the riders leave a wire with both ends riding as it is.
107    fn trim_approaches(&mut self, riding: &HashSet<PinId>) {
108        let scopes: HashSet<_> = [self.current_scope().wire_id()].into_iter().collect();
109        self.author("trim_approaches", |indexed, sink| {
110            edit_geometry::push_route_riders(indexed, riding, &scopes, GridVec::ZERO, sink);
111        });
112    }
113
114    /// Move several shapes rigidly by `delta`. Intra-group collisions are ignored
115    /// (the group keeps its relative layout); the move is rejected wholesale if
116    /// any moved shape would overlap a shape that is not part of `ids`. Each
117    /// route the selection touches is reconciled in the same commit: one fully
118    /// inside keeps its shape and shifts its corners rigidly, one that straddles
119    /// the set drops its stale approach on the moved side.
120    pub fn move_shapes(&mut self, ids: &[ShapeId], delta: Vec2) {
121        let members: Vec<Shape> = ids.iter().copied().map(Shape::from).collect();
122        self.author("move_shapes", |indexed, sink| {
123            edit_geometry::move_group(indexed, &members, delta, sink);
124        });
125    }
126}
127
128/// Shared derivation used by both the group-drag COMMIT (`move_shapes`) and its
129/// live PREVIEW ([`Drawing::preview_drag`]): the moved block/port id
130/// sets plus the grid-cell delta. `delta` is the raw (unsnapped) move vector;
131/// both paths pass the identical `delta`, so the returned `grid_delta` — and
132/// therefore the waypoint offset — is byte-identical between preview and commit
133/// (no release snap).
134pub(super) fn moved_set_and_delta(
135    ids: &[ShapeId],
136    delta: Vec2,
137) -> (HashSet<BlockId>, HashSet<PinId>, GridVec) {
138    let moved_rects: HashSet<BlockId> = ids
139        .iter()
140        .filter_map(|id| match id {
141            ShapeId::Rect(rid) => Some(*rid),
142            _ => None,
143        })
144        .collect();
145    let moved_pins: HashSet<PinId> = ids
146        .iter()
147        .filter_map(|id| match id {
148            ShapeId::Port(pid) => Some(*pid),
149            _ => None,
150        })
151        .collect();
152    let snapped = snap_to_grid(delta.to_pos2()).to_vec2();
153    let grid_delta = GridVec::new(
154        (snapped.x / GRID_SIZE).round() as i32,
155        (snapped.y / GRID_SIZE).round() as i32,
156    );
157    (moved_rects, moved_pins, grid_delta)
158}
159
160#[cfg(test)]
161mod tests {
162    use blockworx_doc::{
163        fixtures::{block_id, image_id, route_id},
164        geometry::GridPoint,
165        id::BlockId,
166        values::PinSide,
167    };
168    use blockworx_geom::{Pos2, Rect, pos2, vec2};
169
170    use crate::{
171        grid::{GRID_SIZE, artwork_rect},
172        path::Scope,
173        shape::ShapeId,
174        widget::{
175            drawing::Drawing,
176            test_fixtures::{self as fx, Scene, two_blocks_with_a_routed_waypoint},
177        },
178    };
179
180    fn corners(drawing: &Drawing<'_>, rid: blockworx_doc::id::RouteId) -> Vec<GridPoint> {
181        drawing
182            .auto_route(rid)
183            .expect("the route is in this scope")
184            .route
185            .waypoints
186            .iter()
187            .map(|wp| wp.pos)
188            .collect()
189    }
190
191    fn icon_min(scene: &mut Scene, id: BlockId) -> Pos2 {
192        artwork_rect(
193            scene
194                .drawing()
195                .icon(id)
196                .expect("the block carries an icon")
197                .rect,
198        )
199        .min
200    }
201
202    fn image_rect(scene: &mut Scene, id: blockworx_doc::id::ImageId) -> Rect {
203        artwork_rect(
204            scene
205                .drawing()
206                .image(id)
207                .expect("the image is in this scope")
208                .rect,
209        )
210    }
211
212    fn image_min(scene: &mut Scene, id: blockworx_doc::id::ImageId) -> Pos2 {
213        image_rect(scene, id).min
214    }
215
216    fn block_rect(drawing: &Drawing<'_>, id: BlockId) -> Rect {
217        drawing
218            .shape(ShapeId::Rect(id))
219            .expect("the block is in this scope")
220            .gui_rect()
221    }
222
223    /// Three tall blocks in a row, each wire crossing a slot so it has to bend:
224    /// A→B lives wholly inside the {A, B} selection, B→C straddles it. Both
225    /// wires start from the corners the solver itself lays down, so a claim
226    /// about what a move does to them is a claim about a settled shape.
227    fn settled_row() -> Scene {
228        let tall = |n, x: f32| {
229            fx::block_in(
230                n,
231                Scope::Root,
232                Rect::from_min_max(pos2(x, 0.0), pos2(x + 40.0, 120.0)),
233            )
234        };
235        let mut scene = Scene::new(vec![
236            tall(1, 0.0),
237            tall(2, 300.0),
238            tall(3, 600.0),
239            fx::pin(4, 1, PinSide::East, 0),
240            fx::pin(5, 2, PinSide::West, 1),
241            fx::pin(6, 2, PinSide::East, 0),
242            fx::pin(9, 3, PinSide::West, 1),
243            // A throwaway corner apiece: the settling rip-up has something to
244            // clear, so the gesture authors an op and its rider promotes the
245            // solved corners back onto the wires.
246            fx::route(7, Scope::Root, 4, 5, &[(10, 4)]),
247            fx::route(8, Scope::Root, 6, 9, &[(30, 4)]),
248        ]);
249        fx::reconstruct(&mut scene);
250        scene.commit(|drawing| {
251            drawing.reroute(route_id(7));
252            drawing.reroute(route_id(8));
253        });
254        scene
255    }
256
257    fn shifted(corners: &[GridPoint], dy: i32) -> Vec<GridPoint> {
258        corners
259            .iter()
260            .map(|p| GridPoint {
261                x: p.x,
262                y: p.y + dy,
263            })
264            .collect()
265    }
266
267    #[test]
268    fn move_shapes_moves_fully_selected_route_waypoints() {
269        // Moving BOTH endpoints of a route drags its corners rigidly along by
270        // the same grid delta, so the wire keeps its shape.
271        let mut scene = settled_row();
272        let before = corners(&scene.drawing(), route_id(7));
273        assert!(!before.is_empty(), "the settled wire has a shape to carry");
274
275        scene.commit(|drawing| {
276            drawing.move_shapes(
277                &[ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2))],
278                vec2(0.0, 4.0 * GRID_SIZE),
279            );
280        });
281
282        assert_eq!(
283            corners(&scene.drawing(), route_id(7)),
284            shifted(&before, 4),
285            "every corner shifted by the (0, 4) grid delta the blocks moved"
286        );
287    }
288
289    #[test]
290    fn move_shapes_group_translates_internal_routes_and_trims_boundary_ones() {
291        // One drag of the group {A, B} treats its two wires differently in the
292        // same commit: A→B is wholly inside and travels rigidly, while B→C
293        // leaves the selection and re-approaches C from B’s new position
294        // instead of being dragged into the shape it used to have.
295        let (a, b) = (block_id(1), block_id(2));
296        let (internal_id, boundary_id) = (route_id(7), route_id(8));
297        let mut scene = settled_row();
298        let internal_before = corners(&scene.drawing(), internal_id);
299        let boundary_before = corners(&scene.drawing(), boundary_id);
300        assert!(!internal_before.is_empty() && !boundary_before.is_empty());
301
302        scene.commit(|drawing| {
303            drawing.move_shapes(
304                &[ShapeId::Rect(a), ShapeId::Rect(b)],
305                vec2(0.0, 4.0 * GRID_SIZE),
306            );
307        });
308
309        let drawing = scene.drawing();
310        assert_eq!(
311            corners(&drawing, internal_id),
312            shifted(&internal_before, 4),
313            "the fully-selected route travelled rigidly with its endpoints"
314        );
315        assert_ne!(
316            corners(&drawing, boundary_id),
317            shifted(&boundary_before, 4),
318            "the straddling route re-approached instead of translating"
319        );
320    }
321
322    #[test]
323    fn move_shapes_trims_a_partially_selected_route_approach() {
324        // Moving only ONE endpoint of a route drops the stale approach corners on
325        // the moved side: the route straddles the selection boundary, so keeping
326        // the old waypoint would pin the wire to the block's former position and
327        // route it back through the corner it left behind. Block A is the moved
328        // start endpoint and the lone (unlocked) waypoint is its approach corner,
329        // so the trim removes it, leaving the route to auto-route clean.
330        let mut scene = two_blocks_with_a_routed_waypoint();
331
332        scene.commit(|drawing| {
333            drawing.move_shapes(&[ShapeId::Rect(block_id(1))], vec2(GRID_SIZE, 0.0));
334        });
335
336        let after = corners(&scene.drawing(), route_id(5));
337        assert!(
338            after.is_empty(),
339            "the stale approach waypoint was trimmed; got {after:?}"
340        );
341    }
342
343    #[test]
344    fn move_shapes_moves_an_image_freely() {
345        let sid = image_id(1);
346        let mut scene = Scene::new(vec![
347            fx::asset().1,
348            fx::image(
349                1,
350                Scope::Root,
351                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 40.0)),
352            ),
353        ]);
354        let before = image_min(&mut scene, sid);
355
356        let delta = vec2(GRID_SIZE, 2.0 * GRID_SIZE);
357        scene.commit(|drawing| drawing.move_shapes(&[ShapeId::Image(sid)], delta));
358
359        assert_eq!(
360            image_min(&mut scene, sid),
361            before + delta,
362            "the image moves freely (no grid snap)"
363        );
364    }
365
366    #[test]
367    fn a_background_image_does_not_move_with_a_block() {
368        // Background images are independent shapes: one sitting inside a block
369        // stays put when the block moves (unlike the old "stuck image" behavior).
370        let (a, inside) = (block_id(1), image_id(2));
371        let mut scene = Scene::new(vec![
372            fx::block(1, 0.0), // (0,0)..(40,40)
373            fx::asset().1,
374            fx::image(
375                2,
376                Scope::Root,
377                Rect::from_min_max(pos2(15.0, 15.0), pos2(25.0, 25.0)),
378            ),
379        ]);
380        let before = image_min(&mut scene, inside);
381        let block_before = block_rect(&scene.drawing(), a);
382
383        scene.commit(|drawing| drawing.move_shape(ShapeId::Rect(a), vec2(4.0 * GRID_SIZE, 0.0)));
384
385        // Precondition: the block really moved, so the image standing still is
386        // a statement about the image and not about a refused gesture.
387        assert_ne!(block_rect(&scene.drawing(), a), block_before);
388        assert_eq!(
389            image_min(&mut scene, inside),
390            before,
391            "a background image is not dragged by a moving block"
392        );
393    }
394
395    #[test]
396    fn an_icon_moves_with_its_block() {
397        // An icon is a property of its block, so moving the block moves the icon.
398        let a = block_id(1);
399        let mut scene = Scene::new(vec![
400            fx::block(1, 0.0), // (0,0)..(40,40)
401            fx::asset().1,
402            fx::icon(1, Rect::from_min_max(pos2(8.0, 8.0), pos2(32.0, 32.0))),
403        ]);
404        let before = icon_min(&mut scene, a);
405
406        scene.commit(|drawing| drawing.move_shape(ShapeId::Rect(a), vec2(4.0 * GRID_SIZE, 0.0)));
407
408        assert_eq!(
409            icon_min(&mut scene, a),
410            before + vec2(4.0 * GRID_SIZE, 0.0),
411            "the icon travels with its block"
412        );
413    }
414
415    #[test]
416    fn move_icon_is_clamped_inside_its_block() {
417        let a = block_id(1);
418        let mut scene = Scene::new(vec![
419            fx::block(1, 0.0), // (0,0)..(40,40)
420            fx::asset().1,
421            fx::icon(1, Rect::from_min_max(pos2(8.0, 8.0), pos2(32.0, 32.0))),
422        ]);
423        let before = icon_min(&mut scene, a);
424        // Shove the icon far past the block's bottom-right corner.
425        scene.commit(|drawing| drawing.move_shape(ShapeId::Icon(a), vec2(1000.0, 1000.0)));
426
427        let drawing = scene.drawing();
428        let icon = artwork_rect(drawing.icon(a).unwrap().rect);
429        // Precondition: the shove moved the icon at all, so containment is the
430        // clamp speaking rather than a move that never happened.
431        assert_ne!(icon.min, before);
432        assert!(
433            block_rect(&drawing, a).contains_rect(icon),
434            "the icon stays within its block"
435        );
436    }
437
438    #[test]
439    fn an_icon_co_selected_with_its_block_is_not_moved_twice() {
440        // An icon follows its block. If both are in one move (e.g. shift-clicking
441        // the icon onto its selected block), `move_shapes` must move the icon
442        // once, not once via the block and again on its own.
443        let a = block_id(1);
444        let mut scene = Scene::new(vec![
445            fx::block(1, 0.0), // (0,0)..(40,40)
446            fx::asset().1,
447            fx::icon(1, Rect::from_min_max(pos2(8.0, 8.0), pos2(32.0, 32.0))),
448        ]);
449        let before = icon_min(&mut scene, a);
450
451        scene.commit(|drawing| {
452            drawing.move_shapes(
453                &[ShapeId::Rect(a), ShapeId::Icon(a)],
454                vec2(4.0 * GRID_SIZE, 0.0),
455            );
456        });
457
458        assert_eq!(
459            icon_min(&mut scene, a),
460            before + vec2(4.0 * GRID_SIZE, 0.0),
461            "the icon shifts by one block-move, not two"
462        );
463    }
464
465    #[test]
466    fn an_image_never_blocks_a_block_move() {
467        // Images float like text boxes: an image sitting where a block wants to
468        // move must not veto the move.
469        let a = block_id(1);
470        let mut scene = Scene::new(vec![
471            fx::block(1, 0.0), // (0,0)..(40,40)
472            fx::asset().1,
473            // An image covering the block's destination one cell to the right.
474            fx::image(
475                2,
476                Scope::Root,
477                Rect::from_min_max(pos2(40.0, 0.0), pos2(80.0, 40.0)),
478            ),
479        ]);
480        let before = block_rect(&scene.drawing(), a);
481        // Precondition: the image really does cover where the block is headed.
482        assert!(
483            image_rect(&mut scene, image_id(2))
484                .intersects(before.translate(vec2(2.0 * GRID_SIZE, 0.0)))
485        );
486
487        scene.commit(|drawing| drawing.move_shape(ShapeId::Rect(a), vec2(2.0 * GRID_SIZE, 0.0)));
488
489        assert_ne!(
490            block_rect(&scene.drawing(), a),
491            before,
492            "the block moved despite the overlapping image"
493        );
494    }
495
496    #[test]
497    fn an_already_overlapping_group_can_be_moved() {
498        // A pasted copy overlaps its sources. Because it already overlaps a
499        // non-member, a nudge that doesn't create a *new* collision must
500        // still be allowed.
501        let (copy_a, copy_b) = (block_id(3), block_id(4));
502        let mut scene = Scene::new(vec![
503            fx::block(1, 0.0),
504            fx::block(2, 120.0),
505            fx::block(3, 30.0),  // a copy sitting atop block 1
506            fx::block(4, 150.0), // a copy sitting atop block 2
507        ]);
508        let pasted = [ShapeId::Rect(copy_a), ShapeId::Rect(copy_b)];
509        let before: Vec<Rect> = {
510            let drawing = scene.drawing();
511            pasted
512                .iter()
513                .map(|&id| drawing.shape(id).unwrap().gui_rect())
514                .collect()
515        };
516        // Precondition: the pasted copies start out overlapping their sources,
517        // which is what makes "no NEW collision" the rule under test.
518        assert!(before[0].intersects(block_rect(&scene.drawing(), block_id(1))));
519
520        scene.commit(|drawing| drawing.move_shapes(&pasted, vec2(GRID_SIZE, 0.0)));
521
522        let drawing = scene.drawing();
523        for (&id, b) in pasted.iter().zip(before) {
524            assert_eq!(
525                drawing.shape(id).unwrap().gui_rect(),
526                b.translate(vec2(GRID_SIZE, 0.0)),
527                "each pasted block shifts one grid cell; the move is not a no-op"
528            );
529        }
530    }
531
532    #[test]
533    fn move_shapes_still_blocks_a_move_into_a_clear_neighbor() {
534        // Guard preservation: A and B don't overlap; a delta that would drive the
535        // member A onto the clear non-member B must still be rejected wholesale.
536        let a = block_id(1);
537        let mut scene = Scene::new(vec![
538            fx::block(1, 0.0),   // (0,0)..(40,40)
539            fx::block(2, 120.0), // clear, well to the right
540        ]);
541        let before = block_rect(&scene.drawing(), a);
542        // Precondition: A is clear of B now, and its destination is not.
543        assert!(!before.intersects(block_rect(&scene.drawing(), block_id(2))));
544        assert!(
545            before
546                .translate(vec2(120.0, 0.0))
547                .intersects(block_rect(&scene.drawing(), block_id(2)))
548        );
549
550        // Slide A right so its destination lands on top of B.
551        scene.commit(|drawing| drawing.move_shapes(&[ShapeId::Rect(a)], vec2(120.0, 0.0)));
552
553        assert_eq!(
554            block_rect(&scene.drawing(), a),
555            before,
556            "the move into a clear neighbor is rejected"
557        );
558    }
559
560    #[test]
561    fn move_shapes_allows_a_nudge_that_does_not_worsen_overlap() {
562        // A member that already overlaps a non-member may be nudged, as long as
563        // the move creates no collision that wasn't already present.
564        let a = block_id(1);
565        let mut scene = Scene::new(vec![
566            fx::block(1, 0.0),  // member,     (0,0)..(40,40)
567            fx::block(2, 10.0), // non-member, (10,0)..(50,40) — overlaps A
568        ]);
569        let before = block_rect(&scene.drawing(), a);
570        // Precondition: the member already overlaps the non-member.
571        assert!(before.intersects(block_rect(&scene.drawing(), block_id(2))));
572
573        scene.commit(|drawing| drawing.move_shapes(&[ShapeId::Rect(a)], vec2(GRID_SIZE, 0.0)));
574
575        assert_eq!(
576            block_rect(&scene.drawing(), a),
577            before.translate(vec2(GRID_SIZE, 0.0)),
578            "an already-overlapping member is still allowed to move"
579        );
580    }
581}