Skip to main content

blockworx/edit/
geometry.rs

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