Skip to main content

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