Skip to main content

blockworx_editor/edit/
geometry.rs

1//! The geometry family: the emitters
2//! that move, resize, and re-slot what already exists, plus the route
3//! lists those gestures carry. The gesture solved its own constraints —
4//! magnetism, minimum sizes, the re-routed polyline — and hands the result
5//! in; the list arithmetic over the indexed document is the emitter's own.
6//!
7//! A keyboard nudge has no emitter of its own: a one-cell arrow delta is
8//! [`move_shape`] / [`move_group`] and a one-slot pin delta is
9//! [`nudge_pins`].
10
11use ahash::{HashSet, HashSetExt};
12use blockworx_doc::{
13    block_model::{
14        AreaUpdate, Block, BlockUpdate, Icon, ImageUpdate, Pin, PinUpdate, RouteLabelUpdate,
15        RouteUpdate, TextUpdate,
16    },
17    commit::CommitBuilder,
18    document::{Document, IndexedDocument},
19    geometry::{FracVal, GridPoint, GridRect, GridSize, GridVec, PinSlot, Waypoint},
20    hash::AssetHash,
21    id::{AreaId, BlockId, ImageId, PinId, RouteId, RouteLabelId, TextId},
22    opcode::{Crud, OpCodes},
23};
24use blockworx_geom::{Rect, Vec2};
25
26use crate::edit::create::PathOrdinal;
27use crate::edit::lower::slot_capacity;
28use crate::grid::{
29    artwork_rect, grid_point, grid_rect, grid_u32, grid_vec, px_rect, px_u, px_vec, screen_rect,
30};
31use crate::shape::block::{contain_rect, icon_rect_after_resize, resize_pin_shift};
32use crate::shape::port::PORT_HEIGHT;
33
34/// What a drag grabbed. An icon is named by the block that owns it, as it
35/// has no id of its own.
36#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37pub enum Shape {
38    Block(BlockId),
39    Port(PinId),
40    Text(TextId),
41    Area(AreaId),
42    Image(ImageId),
43    Icon(BlockId),
44}
45
46/// The widget layer's [`ShapeId`](crate::shape::ShapeId) is the same six
47/// variants over the same ids — one enum in two modules until the shape
48/// layer can name this one directly. Encoded here so preview and commit
49/// cannot disagree about which shape a gesture grabbed.
50impl From<crate::shape::ShapeId> for Shape {
51    fn from(id: crate::shape::ShapeId) -> Self {
52        use crate::shape::ShapeId;
53        match id {
54            ShapeId::Rect(id) => Shape::Block(id),
55            ShapeId::Port(id) => Shape::Port(id),
56            ShapeId::Text(id) => Shape::Text(id),
57            ShapeId::Area(id) => Shape::Area(id),
58            ShapeId::Image(id) => Shape::Image(id),
59            ShapeId::Icon(id) => Shape::Icon(id),
60        }
61    }
62}
63
64impl Shape {
65    /// Whether moving this shape can change routing. Only blocks
66    /// (obstacles) and ports (endpoints) are in the routing graph;
67    /// annotations neither reroute nor block a neighbour's move.
68    fn affects_routing(self) -> bool {
69        matches!(self, Shape::Block(_) | Shape::Port(_))
70    }
71}
72
73/// The scope a routing shape occupies with its rect: a block sits in its
74/// parent's interior, a port body in its owner's. Rects only compare
75/// within one scope — two scopes are two coordinate spaces.
76pub fn placement(doc: &Document, shape: Shape) -> Option<(BlockId, GridRect)> {
77    match shape {
78        Shape::Block(id) => {
79            let block = doc.block(&id)?;
80            Some((block.parent, block.rect))
81        }
82        Shape::Port(id) => {
83            let pin = doc.pin(&id)?;
84            Some((pin.owner, pin.rect))
85        }
86        Shape::Text(_) | Shape::Area(_) | Shape::Image(_) | Shape::Icon(_) => None,
87    }
88}
89
90/// Every routing shape drawn in `scope`, with its rect — the obstacle set
91/// a move is tested against. The document root is a scope like any other,
92/// holding top-level blocks and no ports.
93fn scope_shapes<'a>(
94    indexed: &'a IndexedDocument<'a>,
95    scope: BlockId,
96) -> impl Iterator<Item = (Shape, GridRect)> + 'a {
97    let entry = indexed.index.scope(scope);
98    let children = entry
99        .into_iter()
100        .flat_map(|entry| entry.children.iter())
101        .filter_map(|&id| Some((Shape::Block(id), indexed.doc.block(&id)?.rect)));
102    let ports = entry
103        .into_iter()
104        .flat_map(|entry| entry.pins.iter())
105        .filter_map(|&id| Some((Shape::Port(id), indexed.doc.pin(&id)?.rect)));
106    children.chain(ports)
107}
108
109/// Which rule a move is judged by — the gesture, not the count: a group of
110/// one is still a group, and is still exempt from the overlap it already had.
111#[derive(Clone, Copy, PartialEq, Eq, Debug)]
112pub enum Moving {
113    /// One shape dragged on its own: any overlap at the destination refuses
114    /// it, even one it already had.
115    Alone,
116    /// A selection moved together: only an overlap the move *creates*
117    /// refuses it, so a freshly pasted group sitting on its neighbour can
118    /// still be nudged, and the group keeps its own layout.
119    AsAGroup,
120}
121
122/// Whether moving `shapes` by `delta` would be refused.
123///
124/// The one resolver, so a drag's preview and the commit it previews cannot
125/// come to different answers: a refusal the preview did not show is a
126/// gesture that undoes itself on release, in silence.
127pub fn move_refused(
128    indexed: &IndexedDocument<'_>,
129    shapes: &[Shape],
130    delta: Vec2,
131    moving: Moving,
132) -> bool {
133    !move_conflicts(indexed, shapes, delta, moving).is_empty()
134}
135
136/// The cells a move would have to share with something the router routes
137/// around — every overlap that refuses it, not merely that one does.
138///
139/// Only the overlaps that *refuse* the move: on a group move an overlap the
140/// selection already had refuses nothing, and returning it would have the
141/// drawing claim a refusal that is not happening.
142pub fn move_conflicts(
143    indexed: &IndexedDocument<'_>,
144    shapes: &[Shape],
145    delta: Vec2,
146    moving: Moving,
147) -> Vec<GridRect> {
148    let grid = grid_vec(delta);
149    match (moving, shapes) {
150        (_, []) => Vec::new(),
151        (Moving::Alone, [one]) => {
152            placement(indexed.doc, *one).map_or_else(Vec::new, |(scope, rect)| {
153                let dst = rect.translate(grid);
154                scope_shapes(indexed, scope)
155                    .filter(|&(other, _)| other != *one)
156                    .filter_map(|(_, obstacle)| dst.intersection(obstacle))
157                    .collect()
158            })
159        }
160        // A lone drag carries one shape; anything else is the group gesture,
161        // whatever it holds.
162        (Moving::Alone | Moving::AsAGroup, _) => {
163            let set: HashSet<Shape> = shapes.iter().copied().collect();
164            let moved: Vec<(BlockId, GridRect, GridRect)> = shapes
165                .iter()
166                .filter(|shape| shape.affects_routing())
167                .filter_map(|&shape| placement(indexed.doc, shape))
168                .map(|(scope, src)| (scope, src, src.translate(grid)))
169                .collect();
170            let scopes: HashSet<BlockId> = moved.iter().map(|&(scope, ..)| scope).collect();
171            let mut conflicts = Vec::new();
172            for scope in scopes {
173                let obstacles: Vec<GridRect> = scope_shapes(indexed, scope)
174                    .filter(|(other, _)| !set.contains(other))
175                    .map(|(_, obstacle)| obstacle)
176                    .collect();
177                for obstacle in obstacles {
178                    // `member_scope == scope` as the group rule has it: a
179                    // member of another scope is not an obstacle's neighbour.
180                    conflicts.extend(
181                        moved
182                            .iter()
183                            .filter(|&&(member_scope, ..)| member_scope == scope)
184                            .filter(|&&(_, src, _)| !src.intersects(obstacle))
185                            .filter_map(|&(_, _, dst)| dst.intersection(obstacle)),
186                    );
187                }
188            }
189            conflicts
190        }
191    }
192}
193
194/// The single-shape rule: a destination overlapping any other routing shape
195/// rejects the move, even one the shape already overlapped.
196pub fn move_blocked(
197    indexed: &IndexedDocument<'_>,
198    moving: Shape,
199    scope: BlockId,
200    dst: GridRect,
201) -> bool {
202    scope_shapes(indexed, scope).any(|(other, rect)| other != moving && dst.intersects(rect))
203}
204
205/// The group rule: only a *new* overlap rejects, so a selection already
206/// sitting on its neighbour (a fresh paste) can still be nudged. Intra-group
207/// overlaps are ignored — the group keeps its layout.
208pub fn group_move_blocked(
209    indexed: &IndexedDocument<'_>,
210    members: &HashSet<Shape>,
211    moved: &[(BlockId, GridRect, GridRect)],
212) -> bool {
213    let scopes: HashSet<BlockId> = moved.iter().map(|&(scope, ..)| scope).collect();
214    scopes.into_iter().any(|scope| {
215        scope_shapes(indexed, scope)
216            .filter(|(other, _)| !members.contains(other))
217            .any(|(_, obstacle)| {
218                moved.iter().any(|&(member_scope, src, dst)| {
219                    member_scope == scope && dst.intersects(obstacle) && !src.intersects(obstacle)
220                })
221            })
222    })
223}
224
225/// The artwork an icon value holds, or `None` for the zero icon — the
226/// null asset is the "no image" zero, so an empty box must never be
227/// written as if it were artwork.
228pub(crate) fn artwork(icon: &Icon) -> Option<&Icon> {
229    (icon.asset != AssetHash::default()).then_some(icon)
230}
231
232/// Artwork carried by a translation of `delta` world px.
233pub(crate) fn shifted_icon(icon: &Icon, delta: Vec2) -> Icon {
234    Icon {
235        rect: screen_rect(artwork_rect(icon.rect).translate(delta)),
236        ..icon.clone()
237    }
238}
239
240fn icon_of(block: &Block) -> Option<&Icon> {
241    artwork(&block.icon)
242}
243
244/// The block's icon carried by a translation of `delta` world px.
245fn carried_icon(block: &Block, delta: Vec2) -> Option<Icon> {
246    Some(shifted_icon(icon_of(block)?, delta))
247}
248
249/// A block's icon after a free-pixel drag of its own: shifted, then held
250/// inside the block.
251fn contained_icon(block: &Block, delta: Vec2) -> Option<Icon> {
252    let moved = carried_icon(block, delta)?;
253    Some(Icon {
254        rect: screen_rect(contain_rect(artwork_rect(moved.rect), px_rect(block.rect))),
255        ..moved
256    })
257}
258
259fn push_icon(block_id: BlockId, block: &Block, icon: Option<Icon>, builder: &mut CommitBuilder) {
260    let Some(icon) = icon.filter(|icon| *icon != block.icon) else {
261        return;
262    };
263    builder.push(OpCodes::Block(
264        block_id,
265        Crud::Update(BlockUpdate::Icon(icon)),
266    ));
267}
268
269/// The per-kind translation every move shares, collision already decided.
270/// `grid` is the snapped cell delta and `px` its world-space twin, so the
271/// block body and the icon riding it cannot disagree.
272fn translate(doc: &Document, shape: Shape, delta: (GridVec, Vec2), builder: &mut CommitBuilder) {
273    let (grid, px) = delta;
274    match shape {
275        Shape::Block(id) => {
276            let Some(block) = doc.block(&id) else {
277                return;
278            };
279            let rect = block.rect;
280            if grid != GridVec::ZERO {
281                builder.push(OpCodes::Block(
282                    id,
283                    Crud::Update(BlockUpdate::Rect(rect.translate(grid))),
284                ));
285            }
286            push_icon(id, block, carried_icon(block, px), builder);
287        }
288        Shape::Port(id) => {
289            let Some(pin) = doc.pin(&id) else {
290                return;
291            };
292            if grid != GridVec::ZERO {
293                builder.push(OpCodes::Pin(
294                    id,
295                    Crud::Update(PinUpdate::Rect(pin.rect.translate(grid))),
296                ));
297            }
298        }
299        Shape::Text(id) => {
300            let Some(live) = doc.text(&id) else {
301                return;
302            };
303            if grid != GridVec::ZERO {
304                builder.push(OpCodes::Text(
305                    id,
306                    Crud::Update(TextUpdate::Pos(live.pos + grid)),
307                ));
308            }
309        }
310        Shape::Area(id) => {
311            let Some(live) = doc.area(&id) else {
312                return;
313            };
314            if grid != GridVec::ZERO {
315                builder.push(OpCodes::Area(
316                    id,
317                    Crud::Update(AreaUpdate::Rect(live.rect.translate(grid))),
318                ));
319            }
320        }
321        Shape::Image(id) => {
322            let Some(live) = doc.image(&id) else {
323                return;
324            };
325            let rect = screen_rect(artwork_rect(live.rect).translate(px));
326            if rect != live.rect {
327                builder.push(OpCodes::Image(id, Crud::Update(ImageUpdate::Rect(rect))));
328            }
329        }
330        Shape::Icon(id) => {
331            let Some(block) = doc.block(&id) else {
332                return;
333            };
334            push_icon(id, block, contained_icon(block, px), builder);
335        }
336    }
337}
338
339/// Inventory row "Move Shape": drag one shape to a new position. Blocks,
340/// ports, text, and areas commit on the grid; artwork — a free-floating
341/// image or a block's icon — moves in free pixels, and an icon is held
342/// inside the block it belongs to.
343pub fn move_shape(
344    indexed: &IndexedDocument<'_>,
345    shape: Shape,
346    delta: Vec2,
347    builder: &mut CommitBuilder,
348) {
349    let grid = grid_vec(delta);
350    if move_refused(indexed, &[shape], delta, Moving::Alone) {
351        return;
352    }
353    // Artwork never snaps on a single drag (the inventory's single-move
354    // exception); every other kind rides the snapped delta.
355    let px = match shape {
356        Shape::Image(_) | Shape::Icon(_) => delta,
357        Shape::Block(_) | Shape::Port(_) | Shape::Text(_) | Shape::Area(_) => px_vec(grid),
358    };
359    translate(indexed.doc, shape, (grid, px), builder);
360}
361
362/// Inventory row "Move Group": one shared snapped delta across a marquee
363/// selection — images included, unlike a single move — with the wires the
364/// selection touches kept coherent.
365pub fn move_group(
366    indexed: &IndexedDocument<'_>,
367    members: &[Shape],
368    delta: Vec2,
369    builder: &mut CommitBuilder,
370) {
371    let grid = grid_vec(delta);
372    if move_refused(indexed, members, delta, Moving::AsAGroup) {
373        return;
374    }
375    let set: HashSet<Shape> = members.iter().copied().collect();
376    let moved: Vec<(BlockId, GridRect, GridRect)> = members
377        .iter()
378        .filter(|shape| shape.affects_routing())
379        .filter_map(|&shape| placement(indexed.doc, shape))
380        .map(|(scope, src)| (scope, src, src.translate(grid)))
381        .collect();
382    // The wires a move reconciles are the ones drawn *where it happened*. A
383    // block's own pins are its boundary ports, which are endpoints at two
384    // levels at once — its interior wires anchor to the same ids — and the
385    // interior did not move, so it must not be reconciled.
386    let scopes: HashSet<BlockId> = moved.iter().map(|&(scope, ..)| scope).collect();
387    for &shape in members {
388        // An icon travels with its block, so one that is co-selected with
389        // its own block has already moved — shifting it again would double
390        // the delta.
391        if matches!(shape, Shape::Icon(id) if set.contains(&Shape::Block(id))) {
392            continue;
393        }
394        translate(indexed.doc, shape, (grid, px_vec(grid)), builder);
395    }
396    push_route_riders(
397        indexed,
398        &riding_pins(indexed, members),
399        &scopes,
400        grid,
401        builder,
402    );
403}
404
405/// The pins that ride a move: a moved block carries its whole boundary,
406/// and a moved port is one pin of its own.
407fn riding_pins(indexed: &IndexedDocument<'_>, members: &[Shape]) -> HashSet<PinId> {
408    let mut pins = HashSet::new();
409    for &shape in members {
410        match shape {
411            Shape::Block(id) => {
412                if let Some(entry) = indexed.index.blocks.get(&id) {
413                    pins.extend(entry.pins.iter().copied());
414                }
415            }
416            Shape::Port(id) => {
417                if indexed.doc.pin(&id).is_some() {
418                    pins.insert(id);
419                }
420            }
421            Shape::Text(_) | Shape::Area(_) | Shape::Image(_) | Shape::Icon(_) => {}
422        }
423    }
424    pins
425}
426
427/// Which end of a wire rode the move — the side whose approach corners
428/// went stale.
429#[derive(Clone, Copy, PartialEq, Eq, Debug)]
430pub enum RouteEnd {
431    From,
432    To,
433}
434
435/// Port of `AutoRoute::approach_waypoint_ids` (`src/document/auto_route.rs:
436/// 162`): the polyline minus up to two unlocked corners nearest the moved
437/// end, stopping at the first locked one so a hand-placed bend survives.
438pub fn trimmed_approach(waypoints: &[Waypoint], end: RouteEnd) -> Vec<Waypoint> {
439    fn doomed<'a>(corners: impl Iterator<Item = &'a Waypoint>) -> usize {
440        const APPROACH: usize = 2;
441        corners.take(APPROACH).take_while(|wp| !wp.locked).count()
442    }
443    match end {
444        RouteEnd::From => waypoints[doomed(waypoints.iter())..].to_vec(),
445        RouteEnd::To => waypoints[..waypoints.len() - doomed(waypoints.iter().rev())].to_vec(),
446    }
447}
448
449/// The corners [`trimmed_approach`] drops, by path ordinal — the same rule
450/// read from the preview's side, which routes *around* them instead of
451/// removing them. A trim is a prefix or a suffix, so the surviving count
452/// names the dropped set exactly.
453pub fn trimmed_ordinals(waypoints: &[Waypoint], end: RouteEnd) -> Vec<PathOrdinal> {
454    let kept = trimmed_approach(waypoints, end).len();
455    let range = match end {
456        RouteEnd::From => 0..waypoints.len() - kept,
457        RouteEnd::To => kept..waypoints.len(),
458    };
459    range.map(PathOrdinal::new).collect()
460}
461
462/// Whether the wire reverses direction at `cur`: the `prev → cur` and
463/// `cur → next` runs share an axis and point opposite ways. A 90° turn or
464/// a straight run through `cur` is not a reversal.
465fn reverses_at(prev: GridPoint, cur: GridPoint, next: GridPoint) -> bool {
466    let (ax, ay) = (cur.x - prev.x, cur.y - prev.y);
467    let (bx, by) = (next.x - cur.x, next.y - cur.y);
468    (ay == 0 && by == 0 && ax != 0 && bx != 0 && (ax > 0) != (bx > 0))
469        || (ax == 0 && bx == 0 && ay != 0 && by != 0 && (ay > 0) != (by > 0))
470}
471
472/// The corners a backtracking prune drops from the polyline running
473/// `start → waypoints → end`: a 180° kink the wire doubles back on, which
474/// strict waypoint routing leaves behind when a corner sits *behind* its
475/// neighbours. Iterative, because dropping one can expose a reversal
476/// between its former neighbours; ordinals name the survivors' original
477/// positions, so the preview can route around exactly what the commit
478/// would remove. Locks do not protect a corner — a user bend that only
479/// makes the wire reverse is still a kink.
480pub fn backtracking_ordinals(
481    waypoints: &[Waypoint],
482    start: GridPoint,
483    end: GridPoint,
484) -> Vec<PathOrdinal> {
485    let mut seq: Vec<(usize, GridPoint)> = waypoints
486        .iter()
487        .enumerate()
488        .map(|(i, wp)| (i, wp.pos))
489        .collect();
490    let mut doomed = Vec::new();
491    loop {
492        let hit = (0..seq.len()).find(|&i| {
493            let prev = if i == 0 { start } else { seq[i - 1].1 };
494            let next = if i + 1 == seq.len() {
495                end
496            } else {
497                seq[i + 1].1
498            };
499            reverses_at(prev, seq[i].1, next)
500        });
501        match hit {
502            Some(i) => doomed.push(PathOrdinal::new(seq.remove(i).0)),
503            None => break,
504        }
505    }
506    doomed
507}
508
509/// The polyline with `doomed` dropped — the prune applied, for the commit
510/// that writes it and the preview that previews it.
511pub fn pruned_waypoints(waypoints: &[Waypoint], doomed: &[PathOrdinal]) -> Vec<Waypoint> {
512    waypoints
513        .iter()
514        .enumerate()
515        .filter(|(i, _)| !doomed.contains(&PathOrdinal::new(*i)))
516        .map(|(_, wp)| *wp)
517        .collect()
518}
519
520/// The two waypoint riders a move owes its wires: a route with both
521/// endpoints riding keeps its shape, so its corners translate rigidly; one
522/// that straddles the move drops its stale approach on the moved side so
523/// the re-route rebuilds a clean one. Only wires drawn in a scope the move
524/// happened in are reconciled — a moved block's interior did not move with
525/// it, however many of its pins the wires in there land on.
526///
527/// With a zero `grid` only the straddlers change: what a single shape's
528/// drop and a pin drag owe their wires.
529pub(crate) fn push_route_riders(
530    indexed: &IndexedDocument<'_>,
531    moved: &HashSet<PinId>,
532    scopes: &HashSet<BlockId>,
533    grid: GridVec,
534    builder: &mut CommitBuilder,
535) {
536    let touched: HashSet<RouteId> = moved
537        .iter()
538        .filter_map(|pin| indexed.index.routes_by_endpoint.get(pin))
539        .flatten()
540        .copied()
541        .collect();
542    let mut touched: Vec<RouteId> = touched.into_iter().collect();
543    touched.sort();
544    for id in touched {
545        let Some(route) = indexed.doc.route(&id) else {
546            continue;
547        };
548        if !scopes.contains(&route.owner) {
549            continue;
550        }
551        let stored = route.waypoints.clone();
552        let waypoints = match (moved.contains(&route.from), moved.contains(&route.to)) {
553            (true, true) => stored
554                .iter()
555                .map(|wp| Waypoint {
556                    pos: wp.pos + grid,
557                    ..*wp
558                })
559                .collect(),
560            (true, false) => trimmed_approach(&stored, RouteEnd::From),
561            (false, true) => trimmed_approach(&stored, RouteEnd::To),
562            (false, false) => continue,
563        };
564        if waypoints != stored {
565            builder.push(OpCodes::Route(
566                id,
567                Crud::Update(RouteUpdate::Waypoints(waypoints)),
568            ));
569        }
570    }
571}
572
573/// What a handle drag resizes. Text boxes are absent: their extent follows
574/// their content, so they carry no handles. An icon is named by the block
575/// that owns it, as it has no id of its own.
576#[derive(Clone, Copy, Debug)]
577pub enum ResizeTarget {
578    Block(BlockId),
579    Port(PinId),
580    Area(AreaId),
581    Image(ImageId),
582    Icon(BlockId),
583    Text(TextId),
584}
585
586fn owned_pins<'a>(
587    indexed: &'a IndexedDocument<'a>,
588    owner: BlockId,
589) -> impl Iterator<Item = (PinId, &'a Pin)> + 'a {
590    indexed
591        .index
592        .blocks
593        .get(&owner)
594        .into_iter()
595        .flat_map(|entry| entry.pins.iter())
596        .filter_map(|&id| Some((id, indexed.doc.pin(&id)?)))
597}
598
599/// Owned pins in a stable order, so a rider's ops read the same on every
600/// run (the index's sets iterate by hash).
601fn owned_pins_ordered<'a>(
602    indexed: &'a IndexedDocument<'a>,
603    owner: BlockId,
604) -> Vec<(PinId, &'a Pin)> {
605    let mut pins: Vec<(PinId, &Pin)> = owned_pins(indexed, owner).collect();
606    pins.sort_by_key(|&(id, _)| id);
607    pins
608}
609
610/// Inventory row "Resize Shape": the gesture resolved the final rect —
611/// minimum sizes, the block-height ladder, aspect snapping, magnetism —
612/// and this writes it with the riders it carries: a block's pins ride up
613/// when the new height no longer holds the lowest slot, and its icon rides
614/// the center and is held inside the new box.
615pub fn resize(
616    indexed: &IndexedDocument<'_>,
617    target: ResizeTarget,
618    to: Rect,
619    builder: &mut CommitBuilder,
620) {
621    match target {
622        ResizeTarget::Block(id) => {
623            let Some(block) = indexed.doc.block(&id) else {
624                return;
625            };
626            let old = block.rect;
627            let new = grid_rect(to.min, to.max);
628            let pins = owned_pins_ordered(indexed, id);
629            let shift = resize_pin_shift(
630                pins.iter().map(|(_, pin)| pin.slot.offset),
631                px_u(new.size.h),
632            );
633            if shift > 0 {
634                for (pin_id, pin) in pins {
635                    let slot = pin.slot;
636                    builder.push(OpCodes::Pin(
637                        pin_id,
638                        Crud::Update(PinUpdate::Slot(PinSlot {
639                            offset: slot.offset.saturating_sub(shift),
640                            ..slot
641                        })),
642                    ));
643                }
644            }
645            if new != old {
646                builder.push(OpCodes::Block(id, Crud::Update(BlockUpdate::Rect(new))));
647            }
648            let resized = icon_of(block).map(|icon| Icon {
649                rect: screen_rect(icon_rect_after_resize(
650                    artwork_rect(icon.rect),
651                    px_rect(old),
652                    px_rect(new),
653                )),
654                ..icon.clone()
655            });
656            push_icon(id, block, resized, builder);
657        }
658        ResizeTarget::Port(id) => {
659            let Some(pin) = indexed.doc.pin(&id) else {
660                return;
661            };
662            let rect = pin.rect;
663            // Only the width moves: a port keeps the slot row it sits on
664            // and the canonical body height.
665            let new = GridRect {
666                top_left: GridPoint {
667                    x: grid_point(to.min).x,
668                    y: rect.top(),
669                },
670                size: GridSize {
671                    w: grid_u32(to.width()),
672                    h: PORT_HEIGHT,
673                },
674            };
675            if new != rect {
676                builder.push(OpCodes::Pin(id, Crud::Update(PinUpdate::Rect(new))));
677            }
678        }
679        ResizeTarget::Area(id) => {
680            let Some(live) = indexed.doc.area(&id) else {
681                return;
682            };
683            let new = grid_rect(to.min, to.max);
684            if new != live.rect {
685                builder.push(OpCodes::Area(id, Crud::Update(AreaUpdate::Rect(new))));
686            }
687        }
688        // Only the width is the user's: the height follows the rows the
689        // text wraps into at that width.
690        ResizeTarget::Text(id) => {
691            let Some(live) = indexed.doc.text(&id) else {
692                return;
693            };
694            let pos = grid_point(to.min);
695            if pos != live.pos {
696                builder.push(OpCodes::Text(id, Crud::Update(TextUpdate::Pos(pos))));
697            }
698            let width = Some(crate::render::text_box::BoxWidth::spanning(to));
699            if width != live.width {
700                builder.push(OpCodes::Text(id, Crud::Update(TextUpdate::Width(width))));
701            }
702        }
703        ResizeTarget::Image(id) => {
704            let Some(live) = indexed.doc.image(&id) else {
705                return;
706            };
707            let new = screen_rect(to);
708            if new != live.rect {
709                builder.push(OpCodes::Image(id, Crud::Update(ImageUpdate::Rect(new))));
710            }
711        }
712        // An icon never leaves the block it belongs to, however its corners
713        // were dragged — the same containment [`move_shape`] applies. The
714        // resize gesture clamps to the block as well, so this is the rule
715        // stated where it holds rather than a second clamp.
716        ResizeTarget::Icon(id) => {
717            let Some(block) = indexed.doc.block(&id) else {
718                return;
719            };
720            let resized = icon_of(block).map(|icon| Icon {
721                rect: screen_rect(contain_rect(to, px_rect(block.rect))),
722                ..icon.clone()
723            });
724            push_icon(id, block, resized, builder);
725        }
726    }
727}
728
729/// Inventory row "Move Pin": drag a pin to another slot or the opposite
730/// edge. The slot the drop resolved to arrives decided (the gesture picks
731/// between the nearest slot and its neighbour against the live preview);
732/// a destination another pin already holds writes nothing.
733pub fn move_pin(
734    indexed: &IndexedDocument<'_>,
735    pin: PinId,
736    to: PinSlot,
737    builder: &mut CommitBuilder,
738) {
739    // No lock check: which slot a pin sits in is presentation, and a lock
740    // freezes the interface's meaning rather than its layout
741    // (`crate::edit::lock`).
742    let Some(live) = indexed.doc.pin(&pin) else {
743        return;
744    };
745    if live.slot == to {
746        return;
747    }
748    let owner = live.owner;
749    if owned_pins(indexed, owner).any(|(id, other)| id != pin && other.slot == to) {
750        return;
751    }
752    builder.push(OpCodes::Pin(pin, Crud::Update(PinUpdate::Slot(to))));
753}
754
755/// One pin's destination in a group relocation.
756#[derive(Clone, Copy, Debug)]
757pub struct PinMove {
758    pub pin: PinId,
759    pub to: PinSlot,
760}
761
762/// Whether every destination is free and in bounds — an all-or-nothing
763/// precheck. The group's own vacated slots count as free, since the movers
764/// leave them in the same commit; two movers may not share one.
765pub fn relocation_fits(indexed: &IndexedDocument<'_>, moves: &[PinMove]) -> bool {
766    let movers: HashSet<PinId> = moves.iter().map(|m| m.pin).collect();
767    let mut destinations: HashSet<(BlockId, PinSlot)> = HashSet::new();
768    moves.iter().all(|m| {
769        let Some(pin) = indexed.doc.pin(&m.pin) else {
770            return false;
771        };
772        let owner = pin.owner;
773        let Some(block) = indexed.doc.block(&owner) else {
774            return false;
775        };
776        destinations.insert((owner, m.to))
777            && m.to.offset <= slot_capacity(block.rect.size.h)
778            && !owned_pins(indexed, owner)
779                .any(|(id, other)| !movers.contains(&id) && other.slot == m.to)
780    })
781}
782
783/// Inventory row "Relocate Pin Group": move a multi-pin selection rigidly
784/// to new slots. All or nothing — one destination that is taken, out of
785/// bounds, or on a frozen interface leaves the whole group where it is.
786pub fn relocate_pins(
787    indexed: &IndexedDocument<'_>,
788    moves: &[PinMove],
789    builder: &mut CommitBuilder,
790) {
791    if !relocation_fits(indexed, moves) {
792        return;
793    }
794    for m in moves {
795        let Some(pin) = indexed.doc.pin(&m.pin) else {
796            continue;
797        };
798        if pin.slot != m.to {
799            builder.push(OpCodes::Pin(m.pin, Crud::Update(PinUpdate::Slot(m.to))));
800        }
801    }
802}
803
804/// A whole-slot keyboard shift, negative upward.
805#[derive(Clone, Copy, Debug, PartialEq, Eq)]
806pub struct SlotDelta(i32);
807
808impl SlotDelta {
809    pub fn new(slots: i32) -> Self {
810        Self(slots)
811    }
812}
813
814impl From<SlotDelta> for i32 {
815    fn from(delta: SlotDelta) -> i32 {
816        delta.0
817    }
818}
819
820/// Inventory row "Nudge Pins": shift a pin group by whole slots, clamped
821/// to the tightest range that keeps every pin on its own block, then
822/// relocated as a group.
823pub fn nudge_pins(
824    indexed: &IndexedDocument<'_>,
825    pins: &[PinId],
826    delta: SlotDelta,
827    builder: &mut CommitBuilder,
828) {
829    let (mut lowest, mut highest) = (i32::MIN, i32::MAX);
830    let mut moving: Vec<(PinId, PinSlot)> = Vec::new();
831    for &id in pins {
832        let Some(pin) = indexed.doc.pin(&id) else {
833            continue;
834        };
835        let Some(owner) = indexed.doc.block(&pin.owner) else {
836            continue;
837        };
838        let slot = pin.slot;
839        lowest = lowest.max(-(slot.offset as i32));
840        highest = highest.min(slot_capacity(owner.rect.size.h) as i32 - slot.offset as i32);
841        moving.push((id, slot));
842    }
843    if lowest > highest {
844        return;
845    }
846    let delta = i32::from(delta).clamp(lowest, highest);
847    if delta == 0 {
848        return;
849    }
850    let moves: Vec<PinMove> = moving
851        .into_iter()
852        .map(|(pin, slot)| PinMove {
853            pin,
854            to: PinSlot {
855                offset: (slot.offset as i32 + delta).max(0) as u32,
856                ..slot
857            },
858        })
859        .collect();
860    relocate_pins(indexed, &moves, builder);
861}
862
863/// What a left/right mirror flips.
864#[derive(Clone, Copy, Debug)]
865pub enum FlipTarget {
866    Block(BlockId),
867    Port(PinId),
868}
869
870/// Inventory row "Flip Shape Pins": mirror a shape's pins left↔right.
871///
872/// A block moves every pin to the opposite edge while freezing how each
873/// port body faces, so descending into the block shows the same boundary
874/// as before; a port turns its own body around and stays on its slot.
875/// Both come out as a toggle of `flip_lr`: the register reads `false` as
876/// "faces `slot.side` flipped" and `true` as "faces `slot.side`", so a pin
877/// whose edge flips must swap the flag to keep pointing the same way, and
878/// a body told to turn around swaps it too.
879pub fn flip_pins(indexed: &IndexedDocument<'_>, target: FlipTarget, builder: &mut CommitBuilder) {
880    match target {
881        FlipTarget::Block(id) => {
882            // No lock check: which way a block's pins face is presentation
883            // (`crate::edit::lock`) — the same pins, the same signals, drawn
884            // the other way round.
885            if indexed.doc.block(&id).is_none() {
886                return;
887            }
888            for (pin_id, pin) in owned_pins_ordered(indexed, id) {
889                let slot = pin.slot;
890                builder.push(OpCodes::Pin(
891                    pin_id,
892                    Crud::Update(PinUpdate::Slot(PinSlot {
893                        side: slot.side.flip(),
894                        ..slot
895                    })),
896                ));
897                builder.push(OpCodes::Pin(
898                    pin_id,
899                    Crud::Update(PinUpdate::FlipLR(!pin.flip_lr)),
900                ));
901            }
902        }
903        FlipTarget::Port(id) => {
904            let Some(pin) = indexed.doc.pin(&id) else {
905                return;
906            };
907            builder.push(OpCodes::Pin(
908                id,
909                Crud::Update(PinUpdate::FlipLR(!pin.flip_lr)),
910            ));
911        }
912    }
913}
914
915/// Inventory row "Flip Block Vertical": mirror a block's pins top↔bottom
916/// about its own slot capacity, keeping each pin's edge. An exact
917/// involution, so a double flip is the identity.
918pub fn flip_vertical(indexed: &IndexedDocument<'_>, block: BlockId, builder: &mut CommitBuilder) {
919    // No lock check, as [`flip_pins`]: mirroring the pin order is
920    // presentation.
921    let Some(live) = indexed.doc.block(&block) else {
922        return;
923    };
924    let capacity = slot_capacity(live.rect.size.h);
925    for (id, pin) in owned_pins_ordered(indexed, block) {
926        let slot = pin.slot;
927        let mirrored = PinSlot {
928            offset: capacity.saturating_sub(slot.offset),
929            ..slot
930        };
931        if mirrored != slot {
932            builder.push(OpCodes::Pin(id, Crud::Update(PinUpdate::Slot(mirrored))));
933        }
934    }
935}
936
937/// Inventory row "Move Wire Label": slide a wire's name label along its
938/// route, to the arc length the drag settled on.
939pub fn place_wire_label(
940    doc: &Document,
941    label: RouteLabelId,
942    pos: FracVal,
943    builder: &mut CommitBuilder,
944) {
945    let Some(live) = doc.route_label(&label) else {
946        return;
947    };
948    if live.pos == pos {
949        return;
950    }
951    builder.push(OpCodes::RouteLabel(
952        label,
953        Crud::Update(RouteLabelUpdate::Pos(pos)),
954    ));
955}
956
957/// A finished hand-edit of a wire's path: the corner list the drag
958/// promoted, and each label re-projected onto the geometry that list
959/// solves to.
960#[derive(Clone, Debug)]
961pub struct RouteEdit<'a> {
962    pub route: RouteId,
963    pub waypoints: Vec<Waypoint>,
964    pub anchors: &'a [(RouteLabelId, FracVal)],
965}
966
967/// Inventory row "Edit Route (commit)": the drag's one document write. The
968/// polyline is one atomic value, so the promoted corners land wholesale.
969pub fn commit_route_edit(doc: &Document, edit: RouteEdit<'_>, builder: &mut CommitBuilder) {
970    let Some(route) = doc.route(&edit.route) else {
971        return;
972    };
973    if edit.waypoints != route.waypoints {
974        builder.push(OpCodes::Route(
975            edit.route,
976            Crud::Update(RouteUpdate::Waypoints(edit.waypoints)),
977        ));
978    }
979    for &(label, pos) in edit.anchors {
980        place_wire_label(doc, label, pos, builder);
981    }
982}
983
984/// Inventory row "Reroute Wire / Block": drop the user's corners on `route`
985/// and let the router solve afresh. Under the log the clear and the
986/// re-promotion are one write of the solved list; a route the solver left
987/// out is the bare clear.
988///
989/// One wire per call, deliberately: the block-wide form is
990/// `Drawing::reroute_block`, which picks the wires *drawn on the level being
991/// viewed* and calls this for each. Deliberately not every wire ending on the
992/// block: those drawn a level down have nothing re-laying them, so a
993/// document-wide rule would disagree with what the user is looking at.
994pub fn reroute(
995    indexed: &IndexedDocument<'_>,
996    route: RouteId,
997    solved: &[(RouteId, Vec<Waypoint>)],
998    builder: &mut CommitBuilder,
999) {
1000    let Some(live) = indexed.doc.route(&route) else {
1001        return;
1002    };
1003    let stored = live.waypoints.clone();
1004    let waypoints: Vec<Waypoint> = solved
1005        .iter()
1006        .find_map(|(id, list)| (*id == route).then(|| list.clone()))
1007        .unwrap_or_default();
1008    if waypoints != stored {
1009        builder.push(OpCodes::Route(
1010            route,
1011            Crud::Update(RouteUpdate::Waypoints(waypoints)),
1012        ));
1013    }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use crate::edit::harness::{
1020        block_create, fold, image_create, pin_create, route_create, route_label_create,
1021        seals_to_nothing, wired,
1022    };
1023    use crate::grid::GRID_SIZE;
1024    use blockworx_doc::document::DocIndex;
1025    use blockworx_doc::{
1026        block_model::{Area, Image, Text},
1027        fixtures::{area_id, block_id, image_id, pin_id, route_id, route_label_id, text_id},
1028        values::PinSide,
1029    };
1030    use blockworx_geom::{pos2, vec2};
1031
1032    /// A drag delta, in whole grid cells.
1033    fn by(x: f32, y: f32) -> Vec2 {
1034        vec2(x * GRID_SIZE, y * GRID_SIZE)
1035    }
1036
1037    /// A world-space box, in whole grid cells.
1038    fn cells(min: (f32, f32), max: (f32, f32)) -> Rect {
1039        Rect::from_min_max(
1040            pos2(min.0 * GRID_SIZE, min.1 * GRID_SIZE),
1041            pos2(max.0 * GRID_SIZE, max.1 * GRID_SIZE),
1042        )
1043    }
1044
1045    fn rect(x: i32, y: i32, w: u32, h: u32) -> GridRect {
1046        GridRect {
1047            top_left: GridPoint { x, y },
1048            size: GridSize { w, h },
1049        }
1050    }
1051
1052    fn slot(side: PinSide, offset: u32) -> PinSlot {
1053        PinSlot { side, offset }
1054    }
1055
1056    /// A corner the router solved, free to be re-solved.
1057    fn corner(x: i32, y: i32) -> Waypoint {
1058        Waypoint {
1059            pos: GridPoint { x, y },
1060            locked: false,
1061        }
1062    }
1063
1064    /// A corner the user placed, which a trim must respect.
1065    fn pinned(x: i32, y: i32) -> Waypoint {
1066        Waypoint {
1067            locked: true,
1068            ..corner(x, y)
1069        }
1070    }
1071
1072    /// A payload hash standing in for real artwork (the payload op itself
1073    /// is `assets`' business); only a non-null asset reads as "this block
1074    /// has an icon".
1075    fn asset() -> AssetHash {
1076        AssetHash::of(b"icon")
1077    }
1078
1079    fn place_block(id: u32, to: GridRect) -> OpCodes {
1080        OpCodes::Block(block_id(id), Crud::Update(BlockUpdate::Rect(to)))
1081    }
1082
1083    fn reparent(child: u32, parent: u32) -> OpCodes {
1084        OpCodes::Block(
1085            block_id(child),
1086            Crud::Update(BlockUpdate::Parent(block_id(parent))),
1087        )
1088    }
1089
1090    fn lock(id: u32) -> OpCodes {
1091        OpCodes::Block(block_id(id), Crud::Update(BlockUpdate::Locked(true)))
1092    }
1093
1094    fn set_icon(id: u32, to: Rect) -> OpCodes {
1095        OpCodes::Block(
1096            block_id(id),
1097            Crud::Update(BlockUpdate::Icon(Icon {
1098                asset: asset(),
1099                rect: screen_rect(to),
1100            })),
1101        )
1102    }
1103
1104    fn place_pin(id: u32, to: GridRect) -> OpCodes {
1105        OpCodes::Pin(pin_id(id), Crud::Update(PinUpdate::Rect(to)))
1106    }
1107
1108    fn seat_pin(id: u32, to: PinSlot) -> OpCodes {
1109        OpCodes::Pin(pin_id(id), Crud::Update(PinUpdate::Slot(to)))
1110    }
1111
1112    fn place_image(id: u32, to: Rect) -> OpCodes {
1113        OpCodes::Image(
1114            image_id(id),
1115            Crud::Update(ImageUpdate::Rect(screen_rect(to))),
1116        )
1117    }
1118
1119    fn place_area(id: u32, to: GridRect) -> OpCodes {
1120        OpCodes::Area(area_id(id), Crud::Update(AreaUpdate::Rect(to)))
1121    }
1122
1123    fn wire(id: u32, waypoints: Vec<Waypoint>) -> OpCodes {
1124        OpCodes::Route(
1125            route_id(id),
1126            Crud::Update(RouteUpdate::Waypoints(waypoints)),
1127        )
1128    }
1129
1130    fn block_of(doc: &Document, id: u32) -> &Block {
1131        doc.block(&block_id(id)).expect("the scene's block exists")
1132    }
1133
1134    fn pin_of(doc: &Document, id: u32) -> &Pin {
1135        doc.pin(&pin_id(id)).expect("the scene's pin exists")
1136    }
1137
1138    fn text_of(doc: &Document, id: u32) -> &Text {
1139        doc.text(&text_id(id)).expect("the scene's text exists")
1140    }
1141
1142    fn area_of(doc: &Document, id: u32) -> &Area {
1143        doc.area(&area_id(id)).expect("the scene's area exists")
1144    }
1145
1146    fn image_of(doc: &Document, id: u32) -> &Image {
1147        doc.image(&image_id(id)).expect("the scene's image exists")
1148    }
1149
1150    fn box_of(doc: &Document, id: u32) -> GridRect {
1151        block_of(doc, id).rect
1152    }
1153
1154    fn icon_box(doc: &Document, id: u32) -> Rect {
1155        artwork_rect(
1156            icon_of(block_of(doc, id))
1157                .expect("the block carries an icon")
1158                .rect,
1159        )
1160    }
1161
1162    fn corners_of(doc: &Document, id: u32) -> Vec<Waypoint> {
1163        doc.route(&route_id(id))
1164            .expect("the scene's wire exists")
1165            .waypoints
1166            .clone()
1167    }
1168
1169    /// Block 1 as the scope: child blocks 10 and 11 side by side, block
1170    /// 1's own port body parked clear of both, a wire between the children
1171    /// (16) and one running from 11 out to that port (17).
1172    fn scene() -> Document {
1173        let doc = wired();
1174        let mut builder = CommitBuilder::new("Furnished an interior");
1175        builder.extend([
1176            block_create(10),
1177            block_create(11),
1178            reparent(10, 1),
1179            reparent(11, 1),
1180            place_block(1, rect(0, 0, 40, 40)),
1181            place_block(10, rect(0, 0, 8, 16)),
1182            place_block(11, rect(20, 0, 8, 16)),
1183            place_pin(3, rect(10, 20, 4, 2)),
1184            pin_create(12, 10),
1185            pin_create(13, 11),
1186            pin_create(14, 11),
1187            seat_pin(12, slot(PinSide::East, 0)),
1188            seat_pin(13, slot(PinSide::West, 0)),
1189            seat_pin(14, slot(PinSide::East, 1)),
1190            route_create(16, 1, 12, 13),
1191            route_create(17, 1, 14, 3),
1192            wire(16, vec![corner(9, 4), corner(15, 4), corner(19, 4)]),
1193            wire(17, vec![corner(30, 4), corner(35, 4), corner(38, 4)]),
1194        ]);
1195        let doc = fold(builder, &doc);
1196        assert!(
1197            !box_of(&doc, 10).intersects(box_of(&doc, 11)),
1198            "precondition: the two children start clear of each other"
1199        );
1200        assert!(
1201            !box_of(&doc, 10).intersects(pin_of(&doc, 3).rect)
1202                && !box_of(&doc, 11).intersects(pin_of(&doc, 3).rect),
1203            "precondition: the scope's own port body obstructs neither child"
1204        );
1205        doc
1206    }
1207
1208    #[test]
1209    fn move_shape_translates_a_block_and_the_icon_riding_it() {
1210        let doc = scene();
1211        let mut index = DocIndex::default();
1212        let mut builder = CommitBuilder::new("Gave the block an icon");
1213        builder.push(set_icon(10, cells((2.0, 4.0), (6.0, 8.0))));
1214        let doc = fold(builder, &doc);
1215
1216        let mut builder = CommitBuilder::new("Moved a block");
1217        move_shape(
1218            &index.view(&doc),
1219            Shape::Block(block_id(10)),
1220            by(0.0, 2.0),
1221            &mut builder,
1222        );
1223        let doc = fold(builder, &doc);
1224
1225        assert_eq!(box_of(&doc, 10), rect(0, 2, 8, 16));
1226        assert_eq!(
1227            icon_box(&doc, 10),
1228            cells((2.0, 6.0), (6.0, 10.0)),
1229            "the icon travels with its block"
1230        );
1231    }
1232
1233    #[test]
1234    fn a_single_move_is_rejected_by_any_overlap_at_its_destination() {
1235        let doc = scene();
1236        let mut index = DocIndex::default();
1237        let destination = box_of(&doc, 10).translate(GridVec::new(20, 0));
1238        assert!(
1239            destination.intersects(box_of(&doc, 11)),
1240            "precondition: the destination lands on the neighbour"
1241        );
1242
1243        let mut builder = CommitBuilder::new("Moved a block onto its neighbour");
1244        move_shape(
1245            &index.view(&doc),
1246            Shape::Block(block_id(10)),
1247            by(20.0, 0.0),
1248            &mut builder,
1249        );
1250        seals_to_nothing(builder);
1251    }
1252
1253    /// The preview and the commit ask one resolver, so a drag cannot show a
1254    /// landing the release then refuses — which is the whole of the paper cut
1255    /// "dropping a block onto another block silently authors nothing".
1256    #[test]
1257    fn what_the_preview_calls_refused_is_what_the_commit_refuses() {
1258        let doc = scene();
1259        let mut index = DocIndex::default();
1260        let onto_the_neighbour = by(20.0, 0.0);
1261        let into_the_clear = by(0.0, 40.0);
1262        let moving = Shape::Block(block_id(10));
1263        assert!(
1264            box_of(&doc, 10)
1265                .translate(GridVec::new(20, 0))
1266                .intersects(box_of(&doc, 11)),
1267            "precondition: one destination lands on the neighbour and one does not",
1268        );
1269        assert!(
1270            !box_of(&doc, 10)
1271                .translate(GridVec::new(0, 40))
1272                .intersects(box_of(&doc, 11)),
1273        );
1274
1275        let view = index.view(&doc);
1276        assert!(
1277            move_refused(&view, &[moving], onto_the_neighbour, Moving::Alone),
1278            "the preview would have shown a landing the commit refuses",
1279        );
1280        assert!(!move_refused(
1281            &view,
1282            &[moving],
1283            into_the_clear,
1284            Moving::Alone
1285        ));
1286
1287        // And the commit agrees, both ways.
1288        let mut refused = CommitBuilder::new("Moved a block onto its neighbour");
1289        move_shape(&view, moving, onto_the_neighbour, &mut refused);
1290        seals_to_nothing(refused);
1291
1292        let mut taken = CommitBuilder::new("Moved a block into the clear");
1293        move_shape(&view, moving, into_the_clear, &mut taken);
1294        assert!(
1295            taken.seal().is_some(),
1296            "a move the preview called free authored nothing",
1297        );
1298    }
1299
1300    /// The drawing marks the cells, so the cells are what the rule has to
1301    /// hand back — and a destination can conflict with more than one shape.
1302    #[test]
1303    fn a_move_hands_back_every_overlap_that_refuses_it() {
1304        let doc = scene();
1305        let mut index = DocIndex::default();
1306        let view = index.view(&doc);
1307        let moving = Shape::Block(block_id(10));
1308
1309        let clear = move_conflicts(&view, &[moving], by(0.0, 40.0), Moving::Alone);
1310        assert!(
1311            clear.is_empty(),
1312            "a move into the clear conflicts with nothing"
1313        );
1314
1315        let onto = move_conflicts(&view, &[moving], by(20.0, 0.0), Moving::Alone);
1316        assert_eq!(onto.len(), 1, "one neighbour, one overlap: {onto:?}");
1317        let overlap = onto[0];
1318        let destination = box_of(&doc, 10).translate(GridVec::new(20, 0));
1319        assert!(
1320            overlap.intersects(destination) && overlap.intersects(box_of(&doc, 11)),
1321            "the overlap is the cells the two would share, not either shape",
1322        );
1323        assert!(
1324            overlap.size.w <= destination.size.w && overlap.size.h <= destination.size.h,
1325            "an overlap cannot be bigger than what is moving",
1326        );
1327    }
1328
1329    /// The group rule exempts the overlap a selection arrived with, so the
1330    /// drawing must not mark it: hatching one would claim a refusal that is
1331    /// not happening.
1332    #[test]
1333    fn a_group_reports_only_the_overlaps_its_move_creates() {
1334        let doc = scene();
1335        let mut builder = CommitBuilder::new("Parked the pair on each other");
1336        builder.push(place_block(11, rect(4, 0, 8, 16)));
1337        let doc = fold(builder, &doc);
1338        let mut index = DocIndex::default();
1339        let view = index.view(&doc);
1340        let pair = [Shape::Block(block_id(10)), Shape::Block(block_id(11))];
1341        assert!(
1342            box_of(&doc, 10).intersects(box_of(&doc, 11)),
1343            "precondition: the two already overlap, as a fresh paste leaves them",
1344        );
1345
1346        assert!(
1347            move_conflicts(&view, &pair, by(0.0, 40.0), Moving::AsAGroup).is_empty(),
1348            "the overlap they arrived with is not a conflict the move created",
1349        );
1350    }
1351
1352    /// A group of one is still a group: the gesture decides which rule
1353    /// applies, not how many shapes it happens to carry.
1354    #[test]
1355    fn a_group_of_one_keeps_the_group_rule() {
1356        let doc = scene();
1357        let mut index = DocIndex::default();
1358        let view = index.view(&doc);
1359        let sitting_on_it = Shape::Block(block_id(10));
1360        let onto_the_neighbour = by(20.0, 0.0);
1361
1362        assert!(
1363            move_refused(&view, &[sitting_on_it], onto_the_neighbour, Moving::Alone),
1364            "precondition: dragged alone, this destination is refused",
1365        );
1366        assert!(
1367            !move_refused(&view, &[sitting_on_it], by(0.0, 40.0), Moving::AsAGroup),
1368            "a group move into the clear is taken whatever its size",
1369        );
1370    }
1371
1372    #[test]
1373    fn only_a_group_move_exempts_an_overlap_the_shapes_already_had() {
1374        let doc = scene();
1375        let mut index = DocIndex::default();
1376        let mut builder = CommitBuilder::new("Parked the pair on each other");
1377        builder.push(place_block(11, rect(4, 0, 8, 16)));
1378        let doc = fold(builder, &doc);
1379        assert!(
1380            box_of(&doc, 10).intersects(box_of(&doc, 11)),
1381            "precondition: the two already overlap, so only a *new* overlap can be created"
1382        );
1383
1384        let mut builder = CommitBuilder::new("Nudged one of them");
1385        move_shape(
1386            &index.view(&doc),
1387            Shape::Block(block_id(10)),
1388            by(-1.0, 0.0),
1389            &mut builder,
1390        );
1391        seals_to_nothing(builder);
1392
1393        let mut builder = CommitBuilder::new("Nudged it as a group");
1394        move_group(
1395            &index.view(&doc),
1396            &[Shape::Block(block_id(10))],
1397            by(-1.0, 0.0),
1398            &mut builder,
1399        );
1400        let doc = fold(builder, &doc);
1401        assert_eq!(
1402            box_of(&doc, 10),
1403            rect(-1, 0, 8, 16),
1404            "the group rule rejects only the overlaps a move creates"
1405        );
1406    }
1407
1408    #[test]
1409    fn move_shape_slides_a_port_body_a_text_box_and_an_area_on_the_grid() {
1410        let doc = scene();
1411        let mut index = DocIndex::default();
1412        let mut builder = CommitBuilder::new("Sized the area");
1413        builder.push(place_area(8, rect(2, 2, 6, 4)));
1414        let doc = fold(builder, &doc);
1415
1416        let mut builder = CommitBuilder::new("Moved the annotations");
1417        move_shape(
1418            &index.view(&doc),
1419            Shape::Port(pin_id(3)),
1420            by(2.0, 3.0),
1421            &mut builder,
1422        );
1423        move_shape(
1424            &index.view(&doc),
1425            Shape::Text(text_id(7)),
1426            by(1.4, -0.4),
1427            &mut builder,
1428        );
1429        move_shape(
1430            &index.view(&doc),
1431            Shape::Area(area_id(8)),
1432            by(1.0, 1.0),
1433            &mut builder,
1434        );
1435        let doc = fold(builder, &doc);
1436
1437        assert_eq!(pin_of(&doc, 3).rect, rect(12, 23, 4, 2));
1438        assert_eq!(
1439            text_of(&doc, 7).pos,
1440            GridPoint { x: 1, y: 0 },
1441            "a raw drag commits at the cell it snapped to"
1442        );
1443        assert_eq!(area_of(&doc, 8).rect, rect(3, 3, 6, 4));
1444    }
1445
1446    #[test]
1447    fn artwork_moves_in_free_pixels_and_an_icon_stays_inside_its_block() {
1448        let doc = scene();
1449        let mut index = DocIndex::default();
1450        let mut builder = CommitBuilder::new("Placed the artwork");
1451        builder.extend([
1452            image_create(20, 1),
1453            place_image(20, cells((10.0, 10.0), (14.0, 14.0))),
1454            set_icon(10, cells((2.0, 4.0), (6.0, 8.0))),
1455        ]);
1456        let doc = fold(builder, &doc);
1457
1458        let mut builder = CommitBuilder::new("Nudged the artwork");
1459        move_shape(
1460            &index.view(&doc),
1461            Shape::Image(image_id(20)),
1462            vec2(7.5, -3.25),
1463            &mut builder,
1464        );
1465        move_shape(
1466            &index.view(&doc),
1467            Shape::Icon(block_id(10)),
1468            vec2(1000.0, 1000.0),
1469            &mut builder,
1470        );
1471        let doc = fold(builder, &doc);
1472
1473        assert_eq!(
1474            artwork_rect(image_of(&doc, 20).rect),
1475            cells((10.0, 10.0), (14.0, 14.0)).translate(vec2(7.5, -3.25)),
1476            "an image keeps the pixels the drag gave it — no snap"
1477        );
1478        let block = px_rect(box_of(&doc, 10));
1479        let icon = icon_box(&doc, 10);
1480        assert!(
1481            block.contains_rect(icon),
1482            "the icon is held inside the block it belongs to"
1483        );
1484        assert_eq!(
1485            icon,
1486            Rect::from_min_max(block.max - vec2(60.0, 60.0), block.max),
1487            "a shove past the corner shifts the icon back, without shrinking it"
1488        );
1489    }
1490
1491    #[test]
1492    fn a_move_of_nothing_and_a_move_of_a_ghost_push_nothing() {
1493        let doc = scene();
1494        let mut index = DocIndex::default();
1495
1496        let mut builder = CommitBuilder::new("Moved a block by nothing");
1497        move_shape(
1498            &index.view(&doc),
1499            Shape::Block(block_id(10)),
1500            Vec2::ZERO,
1501            &mut builder,
1502        );
1503        seals_to_nothing(builder);
1504
1505        let mut builder = CommitBuilder::new("Moved a block by half a cell");
1506        move_shape(
1507            &index.view(&doc),
1508            Shape::Block(block_id(10)),
1509            by(0.4, 0.0),
1510            &mut builder,
1511        );
1512        seals_to_nothing(builder);
1513
1514        let mut builder = CommitBuilder::new("Moved a ghost");
1515        move_shape(
1516            &index.view(&doc),
1517            Shape::Block(block_id(99)),
1518            by(1.0, 0.0),
1519            &mut builder,
1520        );
1521        seals_to_nothing(builder);
1522
1523        let mut builder = CommitBuilder::new("Moved a ghost group");
1524        move_group(
1525            &index.view(&doc),
1526            &[Shape::Image(image_id(99)), Shape::Block(block_id(99))],
1527            by(1.0, 0.0),
1528            &mut builder,
1529        );
1530        seals_to_nothing(builder);
1531    }
1532
1533    #[test]
1534    fn move_group_shifts_every_member_by_one_snapped_delta() {
1535        let doc = scene();
1536        let mut index = DocIndex::default();
1537        let mut builder = CommitBuilder::new("Placed the annotations");
1538        builder.extend([
1539            image_create(20, 1),
1540            place_image(20, cells((10.0, 10.0), (14.0, 14.0))),
1541            place_area(8, rect(2, 2, 6, 4)),
1542        ]);
1543        let doc = fold(builder, &doc);
1544
1545        let mut builder = CommitBuilder::new("Moved a selection");
1546        move_group(
1547            &index.view(&doc),
1548            &[
1549                Shape::Block(block_id(10)),
1550                Shape::Block(block_id(11)),
1551                Shape::Text(text_id(7)),
1552                Shape::Area(area_id(8)),
1553                Shape::Image(image_id(20)),
1554            ],
1555            by(1.4, 0.0),
1556            &mut builder,
1557        );
1558        let doc = fold(builder, &doc);
1559
1560        assert_eq!(box_of(&doc, 10), rect(1, 0, 8, 16));
1561        assert_eq!(box_of(&doc, 11), rect(21, 0, 8, 16));
1562        assert_eq!(text_of(&doc, 7).pos, GridPoint { x: 1, y: 0 });
1563        assert_eq!(area_of(&doc, 8).rect, rect(3, 2, 6, 4));
1564        assert_eq!(
1565            artwork_rect(image_of(&doc, 20).rect),
1566            cells((11.0, 10.0), (15.0, 14.0)),
1567            "an image in a group takes the shared snapped delta, unlike a single move"
1568        );
1569    }
1570
1571    #[test]
1572    fn a_group_move_onto_a_clear_neighbour_is_rejected_wholesale() {
1573        let doc = scene();
1574        let mut index = DocIndex::default();
1575        let destination = box_of(&doc, 10).translate(GridVec::new(20, 0));
1576        assert!(
1577            !box_of(&doc, 10).intersects(box_of(&doc, 11))
1578                && destination.intersects(box_of(&doc, 11)),
1579            "precondition: the neighbour is clear now and covered at the destination"
1580        );
1581
1582        let mut builder = CommitBuilder::new("Moved a selection onto a neighbour");
1583        move_group(
1584            &index.view(&doc),
1585            &[Shape::Block(block_id(10)), Shape::Text(text_id(7))],
1586            by(20.0, 0.0),
1587            &mut builder,
1588        );
1589        seals_to_nothing(builder);
1590    }
1591
1592    #[test]
1593    fn a_wire_with_both_ends_riding_translates_its_corners_rigidly() {
1594        let doc = scene();
1595        let mut index = DocIndex::default();
1596        assert_eq!(
1597            (
1598                pin_of(&doc, 12).owner,
1599                pin_of(&doc, 13).owner,
1600                pin_of(&doc, 3).owner
1601            ),
1602            (block_id(10), block_id(11), block_id(1)),
1603            "precondition: wire 16 runs between the two moved blocks and wire 17 leaves them"
1604        );
1605
1606        let mut builder = CommitBuilder::new("Moved the pair");
1607        move_group(
1608            &index.view(&doc),
1609            &[Shape::Block(block_id(10)), Shape::Block(block_id(11))],
1610            by(0.0, 4.0),
1611            &mut builder,
1612        );
1613        let doc = fold(builder, &doc);
1614
1615        assert_eq!(
1616            corners_of(&doc, 16),
1617            vec![corner(9, 8), corner(15, 8), corner(19, 8)],
1618            "a wire that keeps its shape shifts its corners by the same cell delta"
1619        );
1620        assert_eq!(
1621            corners_of(&doc, 17),
1622            vec![corner(38, 4)],
1623            "the straddling wire drops its stale approach on the moved side"
1624        );
1625    }
1626
1627    #[test]
1628    fn a_straddling_wire_trims_from_the_end_that_moved() {
1629        let doc = scene();
1630        let mut index = DocIndex::default();
1631        let mut builder = CommitBuilder::new("Moved one block");
1632        move_group(
1633            &index.view(&doc),
1634            &[Shape::Block(block_id(11))],
1635            by(0.0, 4.0),
1636            &mut builder,
1637        );
1638        let moved = fold(builder, &doc);
1639
1640        assert_eq!(
1641            corners_of(&moved, 16),
1642            vec![corner(9, 4)],
1643            "wire 16 ends on the moved block, so its trailing corners go"
1644        );
1645        assert_eq!(
1646            corners_of(&moved, 17),
1647            vec![corner(38, 4)],
1648            "wire 17 starts on the moved block, so its leading corners go"
1649        );
1650    }
1651
1652    #[test]
1653    fn a_trim_stops_at_the_first_corner_the_user_placed() {
1654        let trimmed = |corners: Vec<Waypoint>| {
1655            let doc = scene();
1656            let mut builder = CommitBuilder::new("Pinned a corner");
1657            builder.push(wire(17, corners));
1658            let doc = fold(builder, &doc);
1659
1660            let mut builder = CommitBuilder::new("Moved one block");
1661            move_group(
1662                &DocIndex::default().view(&doc),
1663                &[Shape::Block(block_id(11))],
1664                by(0.0, 4.0),
1665                &mut builder,
1666            );
1667            corners_of(&fold(builder, &doc), 17)
1668        };
1669
1670        assert_eq!(
1671            trimmed(vec![pinned(30, 4), corner(35, 4), corner(38, 4)]),
1672            vec![pinned(30, 4), corner(35, 4), corner(38, 4)],
1673            "a locked corner nearest the moved end stops the trim before it starts"
1674        );
1675        assert_eq!(
1676            trimmed(vec![corner(30, 4), pinned(35, 4), corner(38, 4)]),
1677            vec![pinned(35, 4), corner(38, 4)],
1678            "the trim stops at the first locked corner"
1679        );
1680        assert_eq!(
1681            trimmed(vec![
1682                corner(30, 4),
1683                corner(35, 4),
1684                corner(38, 4),
1685                corner(39, 4)
1686            ]),
1687            vec![corner(38, 4), corner(39, 4)],
1688            "at most two corners go, however many are stale"
1689        );
1690    }
1691
1692    #[test]
1693    fn a_dragged_port_trims_the_wires_that_end_on_it() {
1694        let doc = scene();
1695        let mut index = DocIndex::default();
1696        let mut builder = CommitBuilder::new("Moved a port body");
1697        move_group(
1698            &index.view(&doc),
1699            &[Shape::Port(pin_id(3))],
1700            by(1.0, 0.0),
1701            &mut builder,
1702        );
1703        let doc = fold(builder, &doc);
1704
1705        assert_eq!(pin_of(&doc, 3).rect, rect(11, 20, 4, 2));
1706        assert_eq!(
1707            corners_of(&doc, 17),
1708            vec![corner(30, 4)],
1709            "the port is wire 17's far end, so the trim comes off the back"
1710        );
1711    }
1712
1713    /// A block's boundary pins are endpoints at two levels: the parent's
1714    /// wires land on them, and so do the block's own interior wires. Moving
1715    /// the block in the parent moves neither the interior nor its wires, so
1716    /// the reconciliation must not reach them.
1717    #[test]
1718    fn a_move_leaves_the_moved_blocks_interior_wires_alone() {
1719        let doc = scene();
1720        let mut index = DocIndex::default();
1721        // A grandchild inside block 11, wired to block 11's own pin 13 — an
1722        // interior wire owned by 11, anchored to a pin the parent also sees.
1723        let mut builder = CommitBuilder::new("Wired the interior");
1724        builder.extend([
1725            block_create(30),
1726            reparent(30, 11),
1727            pin_create(31, 30),
1728            route_create(32, 11, 13, 31),
1729            wire(32, vec![corner(2, 2), corner(4, 2)]),
1730        ]);
1731        let doc = fold(builder, &doc);
1732        let interior = corners_of(&doc, 32);
1733        assert_eq!(interior.len(), 2, "precondition: the interior wire bends");
1734        assert!(
1735            index.view(&doc).index.routes_by_endpoint[&pin_id(13)].contains(&route_id(32)),
1736            "precondition: the interior wire really lands on the moved block's pin"
1737        );
1738
1739        let mut builder = CommitBuilder::new("Moved the block in its parent");
1740        move_group(
1741            &index.view(&doc),
1742            &[Shape::Block(block_id(11))],
1743            by(0.0, 4.0),
1744            &mut builder,
1745        );
1746        let doc = fold(builder, &doc);
1747
1748        assert_eq!(
1749            corners_of(&doc, 32),
1750            interior,
1751            "the interior wire is drawn in a scope the move never touched"
1752        );
1753    }
1754
1755    #[test]
1756    fn an_icon_co_selected_with_its_block_shifts_once() {
1757        let doc = scene();
1758        let mut index = DocIndex::default();
1759        let mut builder = CommitBuilder::new("Gave the block an icon");
1760        builder.push(set_icon(10, cells((2.0, 4.0), (6.0, 8.0))));
1761        let doc = fold(builder, &doc);
1762
1763        let mut builder = CommitBuilder::new("Moved a block with its icon selected");
1764        move_group(
1765            &index.view(&doc),
1766            &[Shape::Block(block_id(10)), Shape::Icon(block_id(10))],
1767            by(2.0, 0.0),
1768            &mut builder,
1769        );
1770        let doc = fold(builder, &doc);
1771
1772        assert_eq!(
1773            icon_box(&doc, 10),
1774            cells((4.0, 4.0), (8.0, 8.0)),
1775            "the icon shifts by one block move, not by two"
1776        );
1777    }
1778
1779    /// Block 10 tall enough for five slots, with pins near the top and the
1780    /// bottom of that range — the span a shrink has to preserve.
1781    fn spanned() -> Document {
1782        let doc = scene();
1783        let mut builder = CommitBuilder::new("Spread the pins");
1784        builder.extend([
1785            pin_create(15, 10),
1786            seat_pin(12, slot(PinSide::East, 4)),
1787            seat_pin(15, slot(PinSide::West, 2)),
1788        ]);
1789        let doc = fold(builder, &doc);
1790        assert_eq!(
1791            slot_capacity(box_of(&doc, 10).size.h),
1792            4,
1793            "precondition: the block is exactly tall enough for its lowest pin"
1794        );
1795        doc
1796    }
1797
1798    #[test]
1799    fn resize_writes_the_new_box_and_rides_the_pins_up_when_they_stop_fitting() {
1800        let doc = spanned();
1801        let mut index = DocIndex::default();
1802        let mut builder = CommitBuilder::new("Gave the block an icon");
1803        builder.push(set_icon(10, cells((2.0, 4.0), (6.0, 8.0))));
1804        let doc = fold(builder, &doc);
1805        assert_eq!(
1806            slot_capacity(4),
1807            0,
1808            "precondition: four cells hold slot 0 alone"
1809        );
1810
1811        let mut builder = CommitBuilder::new("Shrank a block");
1812        resize(
1813            &index.view(&doc),
1814            ResizeTarget::Block(block_id(10)),
1815            cells((0.0, 0.0), (8.0, 4.0)),
1816            &mut builder,
1817        );
1818        let doc = fold(builder, &doc);
1819
1820        assert_eq!(box_of(&doc, 10), rect(0, 0, 8, 4));
1821        assert_eq!(
1822            (pin_of(&doc, 12).slot.offset, pin_of(&doc, 15).slot.offset),
1823            (2, 0),
1824            "the group rides up by the highest pin's own offset, keeping its span"
1825        );
1826        assert_eq!(
1827            pin_of(&doc, 12).slot,
1828            slot(PinSide::East, 2),
1829            "a pin keeps the edge it sits on"
1830        );
1831        assert_eq!(
1832            icon_box(&doc, 10),
1833            cells((2.0, 0.0), (6.0, 4.0)),
1834            "the icon rides the block's center, then is held inside the smaller box"
1835        );
1836    }
1837
1838    #[test]
1839    fn a_resize_that_still_fits_its_pins_leaves_them_alone() {
1840        let doc = spanned();
1841        let mut index = DocIndex::default();
1842        let mut builder = CommitBuilder::new("Widened a block");
1843        resize(
1844            &index.view(&doc),
1845            ResizeTarget::Block(block_id(10)),
1846            cells((0.0, 0.0), (12.0, 16.0)),
1847            &mut builder,
1848        );
1849        let commit = builder.seal().expect("the block widened");
1850        assert_eq!(
1851            commit.ops().len(),
1852            1,
1853            "a resize with room to spare is one write"
1854        );
1855        let doc = doc
1856            .try_apply(&commit)
1857            .expect("the fold accepts the gesture");
1858
1859        assert_eq!(box_of(&doc, 10), rect(0, 0, 12, 16));
1860        assert_eq!(pin_of(&doc, 12).slot.offset, 4);
1861    }
1862
1863    #[test]
1864    fn resize_keeps_a_ports_slot_row_and_canonical_height() {
1865        let doc = scene();
1866        let mut index = DocIndex::default();
1867        let mut builder = CommitBuilder::new("Widened a port body");
1868        resize(
1869            &index.view(&doc),
1870            ResizeTarget::Port(pin_id(3)),
1871            cells((9.0, 0.0), (15.0, 9.0)),
1872            &mut builder,
1873        );
1874        let doc = fold(builder, &doc);
1875
1876        assert_eq!(
1877            pin_of(&doc, 3).rect,
1878            rect(9, 20, 6, PORT_HEIGHT),
1879            "only the left edge and the width move"
1880        );
1881    }
1882
1883    #[test]
1884    fn resize_writes_an_area_on_the_grid_and_an_image_in_free_pixels() {
1885        let doc = scene();
1886        let mut index = DocIndex::default();
1887        let mut builder = CommitBuilder::new("Placed an image");
1888        builder.extend([
1889            image_create(20, 1),
1890            place_image(20, cells((10.0, 10.0), (14.0, 14.0))),
1891        ]);
1892        let doc = fold(builder, &doc);
1893
1894        let mut builder = CommitBuilder::new("Resized the annotations");
1895        resize(
1896            &index.view(&doc),
1897            ResizeTarget::Area(area_id(8)),
1898            cells((1.0, 1.0), (5.4, 4.0)),
1899            &mut builder,
1900        );
1901        resize(
1902            &index.view(&doc),
1903            ResizeTarget::Image(image_id(20)),
1904            Rect::from_min_max(pos2(150.0, 150.0), pos2(207.5, 190.0)),
1905            &mut builder,
1906        );
1907        let doc = fold(builder, &doc);
1908
1909        assert_eq!(area_of(&doc, 8).rect, rect(1, 1, 4, 3));
1910        assert_eq!(
1911            artwork_rect(image_of(&doc, 20).rect),
1912            Rect::from_min_max(pos2(150.0, 150.0), pos2(207.5, 190.0)),
1913            "artwork resizes off the grid"
1914        );
1915    }
1916
1917    #[test]
1918    fn a_resize_to_the_box_it_already_has_pushes_nothing() {
1919        let doc = scene();
1920        let mut index = DocIndex::default();
1921
1922        let mut builder = CommitBuilder::new("Resized a block to itself");
1923        resize(
1924            &index.view(&doc),
1925            ResizeTarget::Block(block_id(10)),
1926            cells((0.0, 0.0), (8.0, 16.0)),
1927            &mut builder,
1928        );
1929        seals_to_nothing(builder);
1930
1931        let mut builder = CommitBuilder::new("Resized a ghost");
1932        resize(
1933            &index.view(&doc),
1934            ResizeTarget::Area(area_id(99)),
1935            cells((0.0, 0.0), (4.0, 4.0)),
1936            &mut builder,
1937        );
1938        seals_to_nothing(builder);
1939    }
1940
1941    #[test]
1942    fn move_pin_takes_a_free_slot_and_declines_a_taken_one() {
1943        let doc = scene();
1944        let mut index = DocIndex::default();
1945        assert_eq!(
1946            pin_of(&doc, 14).slot,
1947            slot(PinSide::East, 1),
1948            "precondition: the destination below is already held by a sibling"
1949        );
1950
1951        let mut builder = CommitBuilder::new("Dropped a pin on a taken slot");
1952        move_pin(
1953            &index.view(&doc),
1954            pin_id(13),
1955            slot(PinSide::East, 1),
1956            &mut builder,
1957        );
1958        seals_to_nothing(builder);
1959
1960        let mut builder = CommitBuilder::new("Dropped a pin where it already sits");
1961        move_pin(
1962            &index.view(&doc),
1963            pin_id(13),
1964            slot(PinSide::West, 0),
1965            &mut builder,
1966        );
1967        seals_to_nothing(builder);
1968
1969        let mut builder = CommitBuilder::new("Dropped a pin on a free slot");
1970        move_pin(
1971            &index.view(&doc),
1972            pin_id(13),
1973            slot(PinSide::East, 0),
1974            &mut builder,
1975        );
1976        let doc = fold(builder, &doc);
1977        assert_eq!(pin_of(&doc, 13).slot, slot(PinSide::East, 0));
1978    }
1979
1980    /// The scene with the pins' owner frozen — the arm every slot edit
1981    /// must decline.
1982    fn frozen() -> Document {
1983        let doc = scene();
1984        let mut builder = CommitBuilder::new("Locked a block");
1985        builder.push(lock(11));
1986        let doc = fold(builder, &doc);
1987        assert!(
1988            block_of(&doc, 11).locked,
1989            "precondition: the owner is locked"
1990        );
1991        doc
1992    }
1993
1994    /// A lock freezes what a pin *is*, not where it sits. Slot edits and
1995    /// flips are presentation (`crate::edit::lock`), so a frozen interface
1996    /// takes them — the same pins, the same signals, drawn differently.
1997    #[test]
1998    fn a_frozen_interface_still_takes_every_slot_edit() {
1999        let doc = frozen();
2000        let mut index = DocIndex::default();
2001
2002        let mut builder = CommitBuilder::new("Dropped a frozen pin");
2003        move_pin(
2004            &index.view(&doc),
2005            pin_id(13),
2006            slot(PinSide::East, 0),
2007            &mut builder,
2008        );
2009        assert!(builder.seal().is_some(), "a locked pin still moves slots");
2010
2011        let mut builder = CommitBuilder::new("Relocated a frozen group");
2012        relocate_pins(
2013            &index.view(&doc),
2014            &[PinMove {
2015                pin: pin_id(13),
2016                to: slot(PinSide::East, 0),
2017            }],
2018            &mut builder,
2019        );
2020        assert!(builder.seal().is_some(), "and relocates as a group");
2021
2022        let mut builder = CommitBuilder::new("Nudged a frozen group");
2023        nudge_pins(
2024            &index.view(&doc),
2025            &[pin_id(13), pin_id(14)],
2026            SlotDelta::new(1),
2027            &mut builder,
2028        );
2029        assert!(builder.seal().is_some(), "and nudges");
2030
2031        let mut builder = CommitBuilder::new("Flipped a frozen block");
2032        flip_pins(
2033            &index.view(&doc),
2034            FlipTarget::Block(block_id(11)),
2035            &mut builder,
2036        );
2037        assert!(builder.seal().is_some(), "and flips left/right");
2038
2039        let mut builder = CommitBuilder::new("Flipped a frozen port");
2040        flip_pins(
2041            &index.view(&doc),
2042            FlipTarget::Port(pin_id(13)),
2043            &mut builder,
2044        );
2045        assert!(builder.seal().is_some(), "port included");
2046
2047        let mut builder = CommitBuilder::new("Mirrored a frozen block");
2048        flip_vertical(&index.view(&doc), block_id(11), &mut builder);
2049        assert!(builder.seal().is_some(), "and mirrors top/bottom");
2050    }
2051
2052    /// The lock freezes the pin interface, not the canvas: a locked block
2053    /// still moves and resizes.
2054    #[test]
2055    fn a_frozen_interface_still_moves_and_resizes() {
2056        let doc = frozen();
2057        let mut index = DocIndex::default();
2058        let mut builder = CommitBuilder::new("Moved and resized a locked block");
2059        move_shape(
2060            &index.view(&doc),
2061            Shape::Block(block_id(11)),
2062            by(1.0, 0.0),
2063            &mut builder,
2064        );
2065        resize(
2066            &index.view(&doc),
2067            ResizeTarget::Block(block_id(11)),
2068            cells((20.0, 0.0), (30.0, 16.0)),
2069            &mut builder,
2070        );
2071        let doc = fold(builder, &doc);
2072
2073        assert_eq!(
2074            box_of(&doc, 11),
2075            rect(20, 0, 10, 16),
2076            "the resize is the later write of the two"
2077        );
2078    }
2079
2080    #[test]
2081    fn relocate_pins_swaps_a_group_through_the_slots_it_vacates() {
2082        let doc = scene();
2083        let mut index = DocIndex::default();
2084        let mut builder = CommitBuilder::new("Swapped two pins");
2085        relocate_pins(
2086            &index.view(&doc),
2087            &[
2088                PinMove {
2089                    pin: pin_id(13),
2090                    to: slot(PinSide::East, 1),
2091                },
2092                PinMove {
2093                    pin: pin_id(14),
2094                    to: slot(PinSide::West, 0),
2095                },
2096            ],
2097            &mut builder,
2098        );
2099        let doc = fold(builder, &doc);
2100
2101        assert_eq!(pin_of(&doc, 13).slot, slot(PinSide::East, 1));
2102        assert_eq!(pin_of(&doc, 14).slot, slot(PinSide::West, 0));
2103    }
2104
2105    #[test]
2106    fn a_group_relocation_is_all_or_nothing() {
2107        let doc = scene();
2108        let mut index = DocIndex::default();
2109        let indexed = index.view(&doc);
2110        let refused = |moves: &[PinMove], label: &'static str| {
2111            let mut builder = CommitBuilder::new(label);
2112            relocate_pins(&indexed, moves, &mut builder);
2113            seals_to_nothing(builder);
2114        };
2115
2116        refused(
2117            &[PinMove {
2118                pin: pin_id(13),
2119                to: slot(PinSide::East, 1),
2120            }],
2121            "Moved one pin onto a sibling that stays put",
2122        );
2123        assert_eq!(
2124            slot_capacity(box_of(&doc, 11).size.h),
2125            4,
2126            "precondition: slot 5 is past the block's own capacity"
2127        );
2128        refused(
2129            &[
2130                PinMove {
2131                    pin: pin_id(13),
2132                    to: slot(PinSide::West, 1),
2133                },
2134                PinMove {
2135                    pin: pin_id(14),
2136                    to: slot(PinSide::East, 5),
2137                },
2138            ],
2139            "Moved a group off the end of its block",
2140        );
2141        refused(
2142            &[
2143                PinMove {
2144                    pin: pin_id(13),
2145                    to: slot(PinSide::West, 2),
2146                },
2147                PinMove {
2148                    pin: pin_id(14),
2149                    to: slot(PinSide::West, 2),
2150                },
2151            ],
2152            "Moved two pins onto one slot",
2153        );
2154        refused(
2155            &[PinMove {
2156                pin: pin_id(99),
2157                to: slot(PinSide::West, 2),
2158            }],
2159            "Moved a ghost",
2160        );
2161    }
2162
2163    #[test]
2164    fn nudge_pins_shifts_a_group_rigidly_and_clamps_at_the_block_edges() {
2165        let doc = scene();
2166        let mut index = DocIndex::default();
2167        assert_eq!(
2168            (pin_of(&doc, 13).slot.offset, pin_of(&doc, 14).slot.offset),
2169            (0, 1),
2170            "precondition: the group starts against the top edge"
2171        );
2172        let group = [pin_id(13), pin_id(14)];
2173
2174        let mut builder = CommitBuilder::new("Nudged the group up");
2175        nudge_pins(&index.view(&doc), &group, SlotDelta::new(-1), &mut builder);
2176        seals_to_nothing(builder);
2177
2178        let mut builder = CommitBuilder::new("Nudged the group down");
2179        nudge_pins(&index.view(&doc), &group, SlotDelta::new(1), &mut builder);
2180        let doc = fold(builder, &doc);
2181        assert_eq!(
2182            (pin_of(&doc, 13).slot.offset, pin_of(&doc, 14).slot.offset),
2183            (1, 2)
2184        );
2185
2186        let mut builder = CommitBuilder::new("Nudged the group off the bottom");
2187        nudge_pins(&index.view(&doc), &group, SlotDelta::new(10), &mut builder);
2188        let doc = fold(builder, &doc);
2189        assert_eq!(
2190            (pin_of(&doc, 13).slot.offset, pin_of(&doc, 14).slot.offset),
2191            (3, 4),
2192            "the shift clamps to the tightest range that keeps every pin on its block"
2193        );
2194    }
2195
2196    /// Inventory row "Keyboard Nudge": arrow keys are a one-cell shape
2197    /// delta and a one-slot pin delta, so the rows above cover it whole.
2198    #[test]
2199    fn a_keyboard_nudge_is_one_cell_or_one_slot_through_the_rows_above() {
2200        let doc = scene();
2201        let mut index = DocIndex::default();
2202
2203        let mut builder = CommitBuilder::new("Nudged a block");
2204        move_shape(
2205            &index.view(&doc),
2206            Shape::Block(block_id(10)),
2207            by(-1.0, 0.0),
2208            &mut builder,
2209        );
2210        let moved = fold(builder, &doc);
2211        assert_eq!(box_of(&moved, 10), rect(-1, 0, 8, 16));
2212
2213        let mut builder = CommitBuilder::new("Nudged a selection");
2214        move_group(
2215            &index.view(&doc),
2216            &[Shape::Block(block_id(10)), Shape::Block(block_id(11))],
2217            by(0.0, 1.0),
2218            &mut builder,
2219        );
2220        let moved = fold(builder, &doc);
2221        assert_eq!(box_of(&moved, 10), rect(0, 1, 8, 16));
2222        assert_eq!(box_of(&moved, 11), rect(20, 1, 8, 16));
2223
2224        let mut builder = CommitBuilder::new("Nudged a pin");
2225        nudge_pins(
2226            &index.view(&doc),
2227            &[pin_id(14)],
2228            SlotDelta::new(1),
2229            &mut builder,
2230        );
2231        let moved = fold(builder, &doc);
2232        assert_eq!(pin_of(&moved, 14).slot, slot(PinSide::East, 2));
2233    }
2234
2235    #[test]
2236    fn flip_pins_moves_every_pin_to_the_far_edge_and_freezes_how_it_faces() {
2237        let doc = scene();
2238        let mut index = DocIndex::default();
2239        assert!(
2240            !pin_of(&doc, 13).flip_lr,
2241            "precondition: the pins face the default way — opposite their own edge"
2242        );
2243
2244        let mut builder = CommitBuilder::new("Flipped a block");
2245        flip_pins(
2246            &index.view(&doc),
2247            FlipTarget::Block(block_id(11)),
2248            &mut builder,
2249        );
2250        let flipped = fold(builder, &doc);
2251
2252        assert_eq!(pin_of(&flipped, 13).slot, slot(PinSide::East, 0));
2253        assert_eq!(pin_of(&flipped, 14).slot, slot(PinSide::West, 1));
2254        assert!(
2255            pin_of(&flipped, 13).flip_lr && pin_of(&flipped, 14).flip_lr,
2256            "the body keeps pointing the way it did, which on the far edge reads as flipped"
2257        );
2258
2259        let mut builder = CommitBuilder::new("Flipped it back");
2260        flip_pins(
2261            &index.view(&flipped),
2262            FlipTarget::Block(block_id(11)),
2263            &mut builder,
2264        );
2265        let back = fold(builder, &flipped);
2266        for id in [13, 14] {
2267            assert_eq!(pin_of(&back, id).slot, pin_of(&doc, id).slot);
2268            assert_eq!(
2269                pin_of(&back, id).flip_lr,
2270                pin_of(&doc, id).flip_lr,
2271                "a double flip is the identity"
2272            );
2273        }
2274    }
2275
2276    #[test]
2277    fn flipping_a_port_turns_its_body_around_and_leaves_its_slot() {
2278        let doc = scene();
2279        let mut index = DocIndex::default();
2280        let mut builder = CommitBuilder::new("Flipped a port");
2281        flip_pins(
2282            &index.view(&doc),
2283            FlipTarget::Port(pin_id(13)),
2284            &mut builder,
2285        );
2286        let flipped = fold(builder, &doc);
2287
2288        assert!(pin_of(&flipped, 13).flip_lr);
2289        assert_eq!(
2290            pin_of(&flipped, 13).slot,
2291            slot(PinSide::West, 0),
2292            "a port stays on the slot it occupies as a pin"
2293        );
2294
2295        let mut builder = CommitBuilder::new("Flipped it back");
2296        flip_pins(
2297            &index.view(&flipped),
2298            FlipTarget::Port(pin_id(13)),
2299            &mut builder,
2300        );
2301        let back = fold(builder, &flipped);
2302        assert!(!pin_of(&back, 13).flip_lr);
2303    }
2304
2305    #[test]
2306    fn flip_vertical_mirrors_the_slots_about_the_blocks_capacity() {
2307        let doc = scene();
2308        let mut index = DocIndex::default();
2309        let capacity = slot_capacity(box_of(&doc, 11).size.h);
2310        assert_eq!(capacity, 4, "precondition: the block holds five slots");
2311
2312        let mut builder = CommitBuilder::new("Mirrored a block");
2313        flip_vertical(&index.view(&doc), block_id(11), &mut builder);
2314        let flipped = fold(builder, &doc);
2315
2316        assert_eq!(pin_of(&flipped, 13).slot, slot(PinSide::West, 4));
2317        assert_eq!(pin_of(&flipped, 14).slot, slot(PinSide::East, 3));
2318
2319        let mut builder = CommitBuilder::new("Mirrored it back");
2320        flip_vertical(&index.view(&flipped), block_id(11), &mut builder);
2321        let back = fold(builder, &flipped);
2322        for id in [13, 14] {
2323            assert_eq!(
2324                pin_of(&back, id).slot,
2325                pin_of(&doc, id).slot,
2326                "a double mirror is the identity"
2327            );
2328        }
2329    }
2330
2331    #[test]
2332    fn place_wire_label_writes_the_arc_length_once() {
2333        let doc = scene();
2334        let mut builder = CommitBuilder::new("Labelled a wire");
2335        builder.push(route_label_create(6, 16));
2336        let doc = fold(builder, &doc);
2337
2338        let mut builder = CommitBuilder::new("Slid the label");
2339        place_wire_label(&doc, route_label_id(6), FracVal::from(12.5), &mut builder);
2340        let doc = fold(builder, &doc);
2341        assert_eq!(
2342            doc.route_label(&route_label_id(6))
2343                .expect("the label exists")
2344                .pos,
2345            FracVal::from(12.5)
2346        );
2347
2348        let mut builder = CommitBuilder::new("Slid it nowhere");
2349        place_wire_label(&doc, route_label_id(6), FracVal::from(12.5), &mut builder);
2350        seals_to_nothing(builder);
2351
2352        let mut builder = CommitBuilder::new("Slid a ghost label");
2353        place_wire_label(&doc, route_label_id(99), FracVal::from(1.0), &mut builder);
2354        seals_to_nothing(builder);
2355    }
2356
2357    #[test]
2358    fn commit_route_edit_writes_the_promoted_corners_and_re_anchors_the_labels() {
2359        let doc = scene();
2360        let mut builder = CommitBuilder::new("Labelled a wire twice");
2361        builder.extend([route_label_create(6, 16), route_label_create(9, 16)]);
2362        let doc = fold(builder, &doc);
2363
2364        let promoted = vec![pinned(9, 6), corner(15, 6), corner(19, 6)];
2365        let mut builder = CommitBuilder::new("Edited a route");
2366        commit_route_edit(
2367            &doc,
2368            RouteEdit {
2369                route: route_id(16),
2370                waypoints: promoted.clone(),
2371                anchors: &[
2372                    (route_label_id(6), FracVal::from(4.5)),
2373                    (route_label_id(9), FracVal::default()),
2374                ],
2375            },
2376            &mut builder,
2377        );
2378        let commit = builder.seal().expect("the edit produced ops");
2379        assert_eq!(
2380            commit.ops().len(),
2381            2,
2382            "a label already at its distance takes no write of its own"
2383        );
2384        let doc = doc
2385            .try_apply(&commit)
2386            .expect("the fold accepts the gesture");
2387
2388        assert_eq!(corners_of(&doc, 16), promoted);
2389        assert_eq!(
2390            doc.route_label(&route_label_id(6))
2391                .expect("the label exists")
2392                .pos,
2393            FracVal::from(4.5)
2394        );
2395
2396        let mut builder = CommitBuilder::new("Edited it to the shape it has");
2397        commit_route_edit(
2398            &doc,
2399            RouteEdit {
2400                route: route_id(16),
2401                waypoints: promoted,
2402                anchors: &[],
2403            },
2404            &mut builder,
2405        );
2406        seals_to_nothing(builder);
2407    }
2408
2409    #[test]
2410    fn reroute_writes_the_re_solved_list_for_one_wire() {
2411        let doc = scene();
2412        let mut index = DocIndex::default();
2413        let solved = vec![corner(12, 2)];
2414        let mut builder = CommitBuilder::new("Rerouted a wire");
2415        reroute(
2416            &index.view(&doc),
2417            route_id(16),
2418            &[(route_id(16), solved.clone())],
2419            &mut builder,
2420        );
2421        let doc = fold(builder, &doc);
2422        assert_eq!(corners_of(&doc, 16), solved);
2423        assert_eq!(
2424            corners_of(&doc, 17).len(),
2425            3,
2426            "the wire that was not asked for is untouched"
2427        );
2428
2429        let mut builder = CommitBuilder::new("Rerouted it to what it has");
2430        reroute(
2431            &index.view(&doc),
2432            route_id(16),
2433            &[(route_id(16), solved)],
2434            &mut builder,
2435        );
2436        seals_to_nothing(builder);
2437    }
2438
2439    /// `Drawing::reroute_block` calls this with an empty solved list and lets
2440    /// the next pass re-lay the wire, so "no solution offered" has to mean
2441    /// "drop the corners" rather than "leave them".
2442    #[test]
2443    fn a_wire_the_solver_left_out_keeps_the_bare_clear() {
2444        let doc = scene();
2445        let mut index = DocIndex::default();
2446        assert!(
2447            !corners_of(&doc, 16).is_empty(),
2448            "precondition: the wire has corners to lose"
2449        );
2450
2451        let mut builder = CommitBuilder::new("Rerouted a wire");
2452        reroute(&index.view(&doc), route_id(16), &[], &mut builder);
2453        let doc = fold(builder, &doc);
2454
2455        assert!(corners_of(&doc, 16).is_empty());
2456    }
2457}