Skip to main content

blockworx_editor/widget/
drawing.rs

1//! [`Drawing`]: the editing surface for one level of the document.
2//!
3//! This module holds the view itself — construction, the per-kind accessors that
4//! add, read, and edit shapes, deletion, and the broad-phase candidate queries.
5//! The rest of the surface lives in sibling modules, each carrying its own
6//! `impl Drawing` block:
7//!
8//! - [`hit_target`](crate::widget::hit_target) — what sits at a point, in z-order.
9//! - [`clipboard`](crate::widget::clipboard) — copy, cut, and paste.
10//! - [`routing`](crate::widget::routing) — solving and re-solving a level's wires.
11//! - [`movement`](crate::widget::movement) — moving shapes and the routes that follow.
12
13/// Paint-layer precedence of a shape, matching [`Drawing::shapes`] order
14/// (blocks, ports, texts, images) with areas last. Paired with the
15/// entity's write order it reproduces that walk exactly, which is how the
16/// index-limited candidate list and the linear scan stay one order.
17fn shape_layer_rank(id: ShapeId) -> u8 {
18    match id {
19        ShapeId::Rect(_) => 0,
20        ShapeId::Port(_) => 1,
21        ShapeId::Text(_) => 2,
22        ShapeId::Image(_) => 3,
23        ShapeId::Area(_) => 4,
24        // Icons have their own foreground hit-test (`icon_at_pos`) and never enter
25        // the shared candidate scan, so this rank is never actually consulted.
26        ShapeId::Icon(_) => 5,
27    }
28}
29
30/// The entity a shape names to the delete cascade. An icon has none — it is
31/// a value on its block, not an entity, so it is zeroed rather than
32/// deleted.
33fn title_target(id: ShapeId) -> Option<crate::edit::naming::TitleTarget> {
34    use crate::edit::naming::TitleTarget;
35    match id {
36        ShapeId::Rect(id) => Some(TitleTarget::Block(id)),
37        ShapeId::Area(id) => Some(TitleTarget::Area(id)),
38        ShapeId::Port(_) | ShapeId::Text(_) | ShapeId::Image(_) | ShapeId::Icon(_) => None,
39    }
40}
41
42fn delete_target(id: ShapeId) -> Option<crate::edit::delete::Target> {
43    use crate::edit::delete::Target;
44    Some(match id {
45        ShapeId::Rect(id) => Target::Block(id),
46        ShapeId::Port(id) => Target::Pin(id),
47        ShapeId::Text(id) => Target::Text(id),
48        ShapeId::Area(id) => Target::Area(id),
49        ShapeId::Image(id) => Target::Image(id),
50        ShapeId::Icon(_) => return None,
51    })
52}
53
54use blockworx_doc::{
55    block_model::{Area, Asset, Block, Icon, Image, Pin, Route, Text},
56    commit::CommitBuilder,
57    document::{BlockIndex, Document as DocDocument, IndexedDocument, chronological},
58    entity::Entity,
59    geometry::{PinSlot, Waypoint},
60    hash::AssetHash,
61    id::{Allocator, AreaId, BlockId, Id, IdKind, ImageId, PinId, RouteId, RouteLabelId, TextId},
62    values::PinDir,
63};
64use blockworx_geom::{Pos2, Rect};
65
66use crate::edit::naming::Authoring;
67use crate::render::Course;
68use crate::theme::Style;
69use crate::{
70    edit::{
71        self,
72        geometry::PinMove,
73        naming::{InterfaceLock, LabelFitWidth, TagVisibility},
74    },
75    grid::{artwork_rect, pin_slot},
76    path::BlockPath,
77    shape::{BlockShape, Deletable, PinLocation, PortShape, RoleTarget, ShapeId, ShapeRef},
78    widget::{
79        auto_route::{Crossing, RouteGeometry, Wire, route_labels},
80        spatial::{HitId, SpatialIndex},
81    },
82};
83use blockworx_paint::Renderer;
84
85/// The slot a boundary offset in world pixels names.
86fn slot_at(location: PinLocation) -> PinSlot {
87    PinSlot {
88        side: location.side,
89        offset: pin_slot(location.offset),
90    }
91}
92
93/// What [`Drawing::standing_conflicts`] marks, in world space.
94#[derive(Default, Debug)]
95pub(crate) struct Conflicts {
96    /// Where two routing shapes share cells.
97    pub overlaps: Vec<Rect>,
98    /// Where a wire runs through a routing shape, grown half a cell so the
99    /// draw pass has an area to hatch.
100    pub crossings: Vec<Rect>,
101}
102
103/// Whether wires route around `id`: the blocks and the ports.
104fn is_routing(id: ShapeId) -> bool {
105    matches!(id, ShapeId::Rect(_) | ShapeId::Port(_))
106}
107
108/// What a preview draws this frame that the document does not hold:
109/// shapes at hypothetical rects, and the routes re-solved against them. The
110/// cull set and the conflict pass read it — the spatial index holds committed
111/// rects, so a shape whose committed rect is off screen must still draw when
112/// its preview is on screen, and is marked where it is dragged — and a
113/// [`Drawing`] is one frame, so a preview cannot outlive the frame that made
114/// it.
115#[derive(Default)]
116pub(crate) struct Previewed {
117    shapes: Vec<(ShapeId, Rect)>,
118    routes: Vec<RouteId>,
119}
120
121/// A transient view onto one scope of the document. Reads project the
122/// scope's entities out of `indexed`; writes are emitted into `sink` and
123/// only become document state when the gesture seals and submits.
124pub struct Drawing<'a> {
125    /// The session's prediction. Reads go through [`Drawing::indexed`],
126    /// which overlays whatever the gesture has already authored — never
127    /// this field directly, which is what makes a gesture able to read its
128    /// own writes.
129    base: IndexedDocument<'a>,
130    path: &'a BlockPath,
131    /// Optional spatial index over the current level's hittables, for broad-phase
132    /// viewport culling and hit-testing. Built and cached by `App`; `None` on the
133    /// paths that don't need it (SVG export, tests, popups), where queries fall
134    /// back to a full linear scan with identical results.
135    index: Option<&'a SpatialIndex>,
136    /// The document's derived state — solver geometry, measurement caches,
137    /// propagated accents. Mandatory where the index is optional: derived
138    /// state has no linear-scan fallback, and the solver passes that fill
139    /// it run inside `Drawing` methods, so it rides every borrow.
140    pub(super) presentation: &'a mut crate::presentation::Presentation,
141    /// The gesture in progress. Private, and reachable only through
142    /// [`Drawing::author`]: a tool cannot hold the sink, and a write cannot
143    /// skip advancing the prediction the next read will see.
144    gesture: &'a mut crate::gesture::Gesture,
145    previewed: Previewed,
146}
147
148/// A committed title/type-label placement: the resolved offset (world px
149/// — grid-rounded and clamped by the caller, who owns the text metrics)
150/// and the side the release height chose, when it chose one.
151#[derive(Clone, Copy, Debug)]
152pub struct LabelPlacement {
153    pub offset: f32,
154    pub side: Option<blockworx_doc::values::LabelSide>,
155}
156
157impl From<LabelPlacement> for edit::naming::LabelPlacement {
158    fn from(placement: LabelPlacement) -> Self {
159        edit::naming::LabelPlacement {
160            offset: placement.offset.into(),
161            side: placement.side,
162        }
163    }
164}
165
166/// Prepare the whole document to be looked at: turn what the document
167/// *authors* into what the canvas *shows*.
168///
169/// A route's full path is stored as its corner waypoints, never as solved edge
170/// geometry, so every scope's wires are *reconstructed* from those corners — straight legs drawn
171/// directly, only genuinely blocked/non-colinear legs routed (see
172/// [`Drawing::reconstruct_routes`]). A clean document is rebuilt with zero
173/// pathfinding, so persisted and hand-adjusted geometry survives unchanged.
174/// This runs for *all* scopes, not just the current view: navigation does not
175/// re-reconstruct, and a foreign commit can land anywhere.
176///
177/// Read-only — the sink it opens is local and discarded, because preparing a
178/// document to be looked at has nothing to author. Call it through
179/// [`Presentation::refresh_routes`](crate::presentation::Presentation::refresh_routes),
180/// which owns the stamp gate.
181/// Reconstruct one scope's wires, and only the ones `reconstructing` names —
182/// what a commit that knows what it disturbed needs, and what a preview's next
183/// borrow needs to take its supposition back. [`present_document`] is what an
184/// open or a foreign commit needs, which is everything.
185pub(crate) fn reconstruct_scope(
186    indexed: &IndexedDocument<'_>,
187    presentation: &mut crate::presentation::Presentation,
188    path: &BlockPath,
189    reconstructing: crate::widget::routing::Reconstructing<'_>,
190) {
191    presentation.keep_routes(|id| indexed.doc.route(&id).is_some());
192    let mut discarded = crate::gesture::Gesture::idle();
193    Drawing::new(*indexed, path, presentation, &mut discarded)
194        .reconstruct_routes_reaching(reconstructing);
195    debug_assert!(
196        discarded.ops().is_empty(),
197        "reconstruction is a read; it must author nothing"
198    );
199}
200
201pub(crate) fn present_document(
202    indexed: &IndexedDocument<'_>,
203    presentation: &mut crate::presentation::Presentation,
204) {
205    // A deleted wire has no geometry, and dropping it here is what keeps
206    // the map from outliving the document (the removal and the deletion were
207    // one statement before the deletion became an op).
208    presentation.keep_routes(|id| indexed.doc.route(&id).is_some());
209    let mut discarded = crate::gesture::Gesture::idle();
210    for path in wired_scopes(indexed) {
211        Drawing::new(*indexed, &path, presentation, &mut discarded).reconstruct_routes();
212    }
213    debug_assert!(
214        discarded.ops().is_empty(),
215        "reconstruction is a read; it must author nothing"
216    );
217}
218
219/// Every level that holds wires, as the path a drawing of it opens on.
220fn wired_scopes(indexed: &IndexedDocument<'_>) -> Vec<BlockPath> {
221    indexed
222        .index
223        .blocks
224        .iter()
225        .filter(|(_, entry)| !entry.routes.is_empty())
226        .map(|(&id, _)| {
227            let mut path = BlockPath::empty();
228            if let crate::path::Scope::Block(id) = crate::path::Scope::from_wire(id) {
229                path.push(id);
230            }
231            path
232        })
233        .collect()
234}
235
236/// How many solves a document may take to stop moving its corners.
237const SETTLING_PASSES: usize = 8;
238
239/// `document` with every wire's corners stored as the solve leaves them once
240/// it has nothing more to promote — what the solve rider converges on over a
241/// document's seals, and so what one the editor saved carries. A single solve
242/// is not enough: corners promoted from a bare wire re-solve differently from
243/// the wire they came from.
244///
245/// # Errors
246/// A settling the document refuses to fold.
247pub fn settle_corners(
248    mut document: blockworx_doc::document::Document,
249) -> Result<blockworx_doc::document::Document, blockworx_doc::document::FoldError> {
250    for _ in 0..SETTLING_PASSES {
251        let Some(commit) = settling_pass(&document) else {
252            break;
253        };
254        document = document.try_apply(&commit)?;
255    }
256    Ok(document)
257}
258
259/// One solve of every wired level, as the commit its promoted corners make.
260/// `None` when it promotes nothing new.
261fn settling_pass(
262    document: &blockworx_doc::document::Document,
263) -> Option<blockworx_doc::commit::Commit> {
264    let mut index = blockworx_doc::document::DocIndex::of(document);
265    let indexed = index.view(document);
266    let mut presentation = crate::presentation::Presentation::default();
267    present_document(&indexed, &mut presentation);
268    let mut settled = blockworx_doc::commit::CommitBuilder::new("Settled every wire's corners");
269    for path in wired_scopes(&indexed) {
270        crate::widget::routing::settle_scope(&indexed, &path, &presentation, &mut settled);
271    }
272    settled.seal()
273}
274
275#[cfg(test)]
276mod settling {
277    use blockworx_doc::fixtures::scale::build_scale;
278
279    use super::{settle_corners, settling_pass};
280
281    /// A generated grid stores no corners; settled, its turning wires carry
282    /// them, and one more solve finds nothing left to store.
283    #[test]
284    fn a_settled_document_is_a_fixed_point_of_the_solve() {
285        let generated = build_scale(3);
286        assert!(
287            generated
288                .routes()
289                .all(|(_, route)| route.waypoints.is_empty()),
290            "precondition: the grid is generated without corners",
291        );
292        let settled = settle_corners(generated).expect("the settling folds");
293        assert!(
294            settled
295                .routes()
296                .any(|(_, route)| !route.waypoints.is_empty())
297        );
298        let again = settling_pass(&settled);
299        assert!(
300            again.is_none(),
301            "the settled document still moves under the solve: {:?}",
302            again.map(|commit| commit
303                .ops()
304                .iter()
305                .map(|op| op.narrate())
306                .collect::<Vec<_>>()),
307        );
308    }
309}
310
311impl<'a> Drawing<'a> {
312    pub fn new(
313        base: IndexedDocument<'a>,
314        path: &'a BlockPath,
315        presentation: &'a mut crate::presentation::Presentation,
316        gesture: &'a mut crate::gesture::Gesture,
317    ) -> Self {
318        let view = gesture.view(base);
319        presentation.refresh_accents(&view);
320        presentation.refresh_routes(&view);
321        Self {
322            base,
323            path,
324            index: None,
325            presentation,
326            gesture,
327            previewed: Previewed::default(),
328        }
329    }
330
331    /// The document as this gesture has left it: the session's prediction
332    /// with the gesture's own ops folded on. Every read goes through here,
333    /// so a tool that just created a block can name it in the same frame.
334    pub(super) fn indexed(&self) -> IndexedDocument<'_> {
335        self.gesture.view(self.base)
336    }
337
338    /// [`Self::indexed`] and the presentation together, borrowing only the
339    /// fields each reads — for a pass that fills solved geometry while
340    /// reading the document it was solved from.
341    pub(super) fn split(
342        &mut self,
343    ) -> (IndexedDocument<'_>, &mut crate::presentation::Presentation) {
344        (self.gesture.view(self.base), self.presentation)
345    }
346
347    /// Whether this session may write at all — the answer the sink itself
348    /// gives, so what the tools offer and what the write door accepts come
349    /// from one value.
350    pub fn writability(&self) -> blockworx_store::doc::Writability {
351        self.gesture.writability()
352    }
353
354    /// Whether the add-affordances that belong to no particular shape — the
355    /// route-start targets, a wire's handles — are on offer this frame.
356    pub fn authoring(&self) -> Authoring {
357        self.writability().into()
358    }
359
360    /// Whether `shape`'s own add-affordances are on offer: the session must
361    /// be writable *and* the shape's interface unlocked.
362    pub fn authoring_of(&self, shape: ShapeId) -> Authoring {
363        Authoring::of(self.writability(), self.shape_owner_locked(shape).into())
364    }
365
366    /// The proof a material interface edit needs on `block`, or `None` when
367    /// the lock refuses one — the read half of `crate::edit::lock`, for a
368    /// tool that must decide before it commits.
369    pub fn unlocked_scope(&self, block: BlockId) -> Option<edit::lock::UnlockedScope> {
370        edit::lock::UnlockedScope::of(&self.indexed(), crate::path::Scope::Block(block))
371    }
372
373    /// A fresh document-global id, taken from the document's marks as this
374    /// gesture has left them. The tools take the minted id back, so a
375    /// gesture can anchor to what it just made.
376    pub fn mint<K: IdKind>(&mut self) -> Id<K> {
377        self.gesture.mint(self.base.doc)
378    }
379
380    /// The allocator an emitter mints a data-dependent number of ids from
381    /// (paste). Every other creating emitter takes its ids pre-minted.
382    pub(super) fn ids(&self) -> Allocator {
383        self.gesture.ids(self.base.doc)
384    }
385
386    /// Author into the gesture. The one write door in the waist: `emit`
387    /// receives the document as [`Self::indexed`] would report it and the
388    /// sink to push into, and the prediction advances before it returns.
389    pub(super) fn author(
390        &mut self,
391        what: &'static str,
392        emit: impl FnOnce(&IndexedDocument<'_>, &mut CommitBuilder),
393    ) {
394        self.gesture.author(self.base, what, emit);
395    }
396
397    /// Like [`Self::new`], but with a spatial index (see the `index` field) so
398    /// broad-phase queries (culling, hit-testing) skip the full linear scan.
399    pub fn new_indexed(
400        base: IndexedDocument<'a>,
401        path: &'a BlockPath,
402        index: &'a SpatialIndex,
403        presentation: &'a mut crate::presentation::Presentation,
404        gesture: &'a mut crate::gesture::Gesture,
405    ) -> Self {
406        let view = gesture.view(base);
407        presentation.refresh_accents(&view);
408        presentation.refresh_routes(&view);
409        Self {
410            base,
411            path,
412            index: Some(index),
413            presentation,
414            gesture,
415            previewed: Previewed::default(),
416        }
417    }
418
419    /// The scope being drawn: the path's last segment, or the document
420    /// root, which is a scope like any other.
421    pub fn current_scope(&self) -> crate::path::Scope {
422        self.path.scope()
423    }
424
425    /// What the current scope holds. `None` only when the scope's block
426    /// has been deleted under the path.
427    pub(super) fn scope(&self) -> Option<&BlockIndex> {
428        self.indexed().index.scope(self.current_scope().wire_id())
429    }
430
431    /// The block whose interior is being drawn, or `None` at the document
432    /// root — which has a scope but no block entity behind it.
433    pub(super) fn current(&self) -> Option<&Block> {
434        self.current_scope()
435            .block()
436            .and_then(|id| self.held_block(id))
437    }
438
439    pub(super) fn held_block(&self, id: BlockId) -> Option<&Block> {
440        self.indexed().doc.block(&id)
441    }
442
443    pub fn held_pin(&self, id: PinId) -> Option<&Pin> {
444        self.indexed().doc.pin(&id)
445    }
446
447    /// Which shape draws `pin` in this scope: the scope's own boundary port,
448    /// or a stub on the child block that owns it. The port-vs-pin split is
449    /// resolved here, once, from the ownership the document records.
450    pub fn pin_shape(&self, pin: PinId) -> Option<ShapeId> {
451        let owner = self.held_pin(pin)?.owner;
452        Some(
453            if crate::path::Scope::from_wire(owner) == self.current_scope() {
454                ShapeId::Port(pin)
455            } else {
456                ShapeId::Rect(owner)
457            },
458        )
459    }
460
461    /// A child block as the geometry layer draws it — body plus the pins that
462    /// hang off it. The one unwrap of [`Self::shape`]'s block arm, so callers
463    /// reaching for `BaseShape`'s block-only methods do not each spell it.
464    pub fn block_shape(&self, id: BlockId) -> Option<BlockShape<'_>> {
465        match self.shape(ShapeId::Rect(id))? {
466            ShapeRef::Block(block) => Some(block),
467            _ => None,
468        }
469    }
470
471    /// The shape drawing `pin` here, paired with the pin entity: the stub's
472    /// geometry hangs off the shape, its labels off the pin, and every
473    /// pin-level read wants both.
474    pub fn pin_on_shape(&self, pin: PinId) -> Option<(ShapeRef<'_>, &Pin)> {
475        Some((self.shape(self.pin_shape(pin)?)?, self.held_pin(pin)?))
476    }
477
478    /// Whether the block that owns `pin` is locked. A locked block keeps
479    /// its pin/port interface frozen, so callers consult this before any
480    /// material pin edit.
481    pub fn pin_owner_locked(&self, pin: PinId) -> bool {
482        self.held_pin(pin)
483            .and_then(|pin| self.held_block(pin.owner))
484            .is_some_and(|owner| owner.locked)
485    }
486
487    /// Whether the current block (whose ports these are) is locked. The
488    /// document root is never locked — there is no block to freeze.
489    pub fn current_locked(&self) -> bool {
490        self.current().is_some_and(|b| b.locked)
491    }
492
493    /// Whether the block that owns `id`'s pins is locked: a child block by its own
494    /// flag, a port by the current block's flag. Shapes without a pin interface
495    /// (text, area, image) are never locked.
496    pub fn shape_owner_locked(&self, id: ShapeId) -> bool {
497        match id {
498            ShapeId::Rect(rid) => self.held_block(rid).is_some_and(|b| b.locked),
499            ShapeId::Port(_) => self.current_locked(),
500            ShapeId::Text(_) | ShapeId::Area(_) | ShapeId::Image(_) | ShapeId::Icon(_) => false,
501        }
502    }
503
504    /// The derived pin-accent lookup for the shapes of this scope: every
505    /// stub drawn here takes its color from the wires routed here, whether
506    /// it belongs to a child block or to the scope's own boundary.
507    pub fn shape_accents(&self) -> crate::presentation::ShapeAccents<'_> {
508        crate::presentation::ShapeAccents::new(self.current_scope(), &self.presentation.pin_accents)
509    }
510
511    // ── Scope reads ──────────────────────────────────────────────────────────
512    //
513    // What this scope holds, resolved out of the index and handed to the
514    // geometry layer, which never looks anything up itself.
515
516    /// `ids` resolved to their entities in `chronological` order — the
517    /// doc crate's one draw-order policy, so painting, hit-testing, and the
518    /// router cannot disagree about which shape is on top.
519    fn ordered<'s, K, T>(
520        &'s self,
521        ids: impl IntoIterator<Item = Id<K>>,
522        lookup: impl Fn(&'s DocDocument, &Id<K>) -> Option<&'s T>,
523    ) -> Vec<(Id<K>, &'s T)>
524    where
525        K: IdKind + Ord + Copy,
526        T: Entity + 's,
527    {
528        let doc = self.indexed().doc;
529        let held = |id: &Id<K>| lookup(doc, id);
530        let entries: Vec<(Id<K>, &T)> = ids
531            .into_iter()
532            .filter_map(|id| Some((id, held(&id)?)))
533            .collect();
534        chronological(entries.iter().copied())
535            .into_iter()
536            .filter_map(|id| Some((id, held(&id)?)))
537            .collect()
538    }
539
540    /// The scope's child blocks, in draw order.
541    pub fn child_blocks(&self) -> Vec<(BlockId, &Block)> {
542        crate::path::child_blocks(&self.indexed(), self.current_scope())
543            .into_iter()
544            .filter_map(|id| Some((id, self.held_block(id)?)))
545            .collect()
546    }
547
548    /// One child block of this scope. A block the scope does not hold is not
549    /// this drawing's to read, even though the document holds it.
550    pub fn block(&self, id: BlockId) -> Option<&Block> {
551        self.scope()?
552            .children
553            .contains(&id)
554            .then(|| self.held_block(id))
555            .flatten()
556    }
557
558    /// A scope's own pins, in draw order — the set the geometry layer is
559    /// handed alongside the block (or, at the root, the document's own
560    /// boundary), since a pin is its own entity.
561    pub(super) fn block_pins(&self, scope: crate::path::Scope) -> Vec<(PinId, &Pin)> {
562        let Some(entry) = self.indexed().index.scope(scope.wire_id()) else {
563            return Vec::new();
564        };
565        self.ordered(entry.pins.iter().copied(), |doc, id| doc.pin(id))
566    }
567
568    fn scope_texts(&self) -> Vec<(TextId, &Text)> {
569        let Some(scope) = self.scope() else {
570            return Vec::new();
571        };
572        self.ordered(scope.texts.iter().copied(), |doc, id| doc.text(id))
573    }
574
575    fn scope_areas(&self) -> Vec<(AreaId, &Area)> {
576        let Some(scope) = self.scope() else {
577            return Vec::new();
578        };
579        self.ordered(scope.areas.iter().copied(), |doc, id| doc.area(id))
580    }
581
582    fn scope_images(&self) -> Vec<(ImageId, &Image)> {
583        let Some(scope) = self.scope() else {
584            return Vec::new();
585        };
586        self.ordered(scope.images.iter().copied(), |doc, id| doc.image(id))
587    }
588
589    /// Whether this scope holds what `owner` names. A scope whose block has
590    /// been deleted under the path holds nothing, which is what keeps a
591    /// single lookup agreeing with a walk of the index's per-scope sets.
592    fn scope_owns(&self, owner: BlockId) -> bool {
593        self.scope().is_some() && crate::path::Scope::from_wire(owner) == self.current_scope()
594    }
595
596    /// An entity, iff this scope owns it. Ownership is the `owner`
597    /// register, which is what the index's per-scope sets are built from —
598    /// so this answers the same question as a scan of them, in one lookup.
599    fn scope_owned<'s, T: Entity>(
600        &'s self,
601        entity: Option<&'s T>,
602        owner: impl Fn(&T) -> BlockId,
603    ) -> Option<&'s T> {
604        entity.filter(|entity| self.scope_owns(owner(entity)))
605    }
606
607    /// The bytes an asset hash names, or `None` when the document does not
608    /// hold them — which the zero icon's null hash also reads as.
609    fn asset(&self, hash: &AssetHash) -> Option<&Asset> {
610        self.indexed().doc.asset(hash)
611    }
612
613    /// Delete a shape or route. The cascade — a block's subtree, the wires
614    /// landing on any pin it carries, each wire's labels — is the delete
615    /// emitter's own closure, so one selection is one commit however deep
616    /// it reaches.
617    pub fn delete(&mut self, what: Deletable) {
618        let targets: Vec<edit::delete::Target> = match what {
619            Deletable::Shape(id) => self.shape_targets(&[id]),
620            Deletable::Shapes(ids) => self.shape_targets(&ids),
621            Deletable::Route(rid) => vec![edit::delete::Target::Route(rid)],
622            Deletable::Pins(pins) => pins.into_iter().map(edit::delete::Target::Pin).collect(),
623        };
624        self.author("delete", |indexed, sink| {
625            edit::delete::selection(indexed, &targets, sink);
626        });
627    }
628
629    /// The entities `shapes` name, zeroing every icon among them on the way:
630    /// an icon is a value on its block, so it has no id for the cascade to
631    /// delete and its own emitter clears it instead.
632    fn shape_targets(&mut self, shapes: &[ShapeId]) -> Vec<edit::delete::Target> {
633        for &id in shapes {
634            if let ShapeId::Icon(block) = id {
635                self.author("shape_targets", |indexed, sink| {
636                    edit::assets::delete_icon(indexed.doc, block, sink);
637                });
638            }
639        }
640        shapes.iter().filter_map(|&id| delete_target(id)).collect()
641    }
642
643    // ── Route accessors ──────────────────────────────────────────────────────
644
645    /// The scope's wires, in draw order — the doc crate's one
646    /// `chronological` policy, so painting, hit-testing, and the crossing
647    /// pass all walk the same sequence.
648    #[tracing::instrument(level = "info", skip_all)]
649    pub(super) fn scope_route_ids(&self) -> Vec<RouteId> {
650        let Some(scope) = self.scope() else {
651            return Vec::new();
652        };
653        let doc = self.indexed().doc;
654        let held: Vec<(RouteId, &Route)> = scope
655            .routes
656            .iter()
657            .filter_map(|&id| Some((id, doc.route(&id)?)))
658            .collect();
659        chronological(held.iter().copied())
660    }
661
662    pub(super) fn route(&self, id: RouteId) -> Option<&Route> {
663        self.indexed().doc.route(&id)
664    }
665
666    pub fn auto_routes(&self) -> impl Iterator<Item = (RouteId, Wire<'_>)> {
667        self.scope_route_ids()
668            .into_iter()
669            .filter_map(|id| Some((id, self.auto_route(id)?)))
670            .collect::<Vec<_>>()
671            .into_iter()
672    }
673
674    /// One wire of this scope: the authored route bundled with the labels
675    /// the index hangs off it. A route the scope does not hold is not this
676    /// drawing's to read, even though the document holds it.
677    pub fn auto_route(&self, id: RouteId) -> Option<Wire<'_>> {
678        let route = self
679            .route(id)
680            .filter(|route| self.scope_owns(route.owner))?;
681        Some(Wire {
682            route,
683            labels: route_labels(&self.indexed(), id),
684        })
685    }
686    /// Wire `from` to `to` along the solved `waypoints`, in this scope. A
687    /// destination the gesture drew onto a free slot arrives as
688    /// [`RouteEnd::Fresh`](crate::edit::create::RouteEnd::Fresh) and is
689    /// stamped in the same commit; an owner that refuses it takes the wire
690    /// with it.
691    pub fn add_route(
692        &mut self,
693        from: PinId,
694        to: crate::edit::create::RouteEnd,
695        waypoints: Vec<Waypoint>,
696    ) -> RouteId {
697        let id = self.mint();
698        let owner = self.current_scope();
699        self.author("add_route", |indexed, sink| {
700            edit::create::route(
701                indexed,
702                edit::create::NewRoute {
703                    id,
704                    owner,
705                    from,
706                    to,
707                    waypoints,
708                },
709                sink,
710            );
711        });
712        id
713    }
714    /// The solved geometry beside `id`, or `None` for a route that has not
715    /// been reconstructed — nothing to draw or hit-test yet.
716    pub fn route_geometry(&self, id: RouteId) -> Option<&RouteGeometry> {
717        self.presentation.routes.get(&id)
718    }
719
720    // ── Image (background image) accessors ───────────────────────────────────
721
722    /// Create a free-floating background image with `asset` as its content,
723    /// boxed by `placement`, returning its id as a `ShapeId`.
724    pub fn add_image(&mut self, placement: edit::assets::Placement, asset: &Asset) -> ShapeId {
725        let id: ImageId = self.mint();
726        let owner = self.current_scope();
727        self.author("add_image", |indexed, sink| {
728            edit::assets::image(
729                indexed.doc,
730                edit::assets::NewImage {
731                    id,
732                    owner,
733                    placement,
734                },
735                asset,
736                sink,
737            );
738        });
739        ShapeId::Image(id)
740    }
741    #[cfg_attr(not(test), allow(dead_code))]
742    pub fn image(&self, id: ImageId) -> Option<&Image> {
743        self.scope_owned(self.indexed().doc.image(&id), |i| i.owner)
744    }
745
746    // ── Icon (block foreground image) accessors ───────────────────────────────
747
748    /// The artwork block `id` carries, or `None` for the zero icon — an
749    /// empty box with the null asset is "no image", not a blank picture.
750    pub fn icon(&self, id: BlockId) -> Option<&Icon> {
751        self.block(id)
752            .and_then(|b| crate::edit::geometry::artwork(&b.icon))
753    }
754    /// Attach (or replace) block `id`'s icon with `asset`, sized to a default
755    /// box centered on the block.
756    pub fn set_icon(&mut self, id: BlockId, asset: &Asset) {
757        self.author("set_icon", |indexed, sink| {
758            edit::assets::set_icon(indexed.doc, id, asset, sink);
759        });
760    }
761
762    // ── Block (rect) accessors ────────────────────────────────────────────────
763
764    pub fn add_block(&mut self, start: Pos2, end: Pos2) -> BlockId {
765        let id = self.mint();
766        let scope = self.current_scope();
767        self.author("add_block", |indexed, sink| {
768            edit::create::block(
769                indexed.doc,
770                edit::create::NewBlock {
771                    id,
772                    scope,
773                    start,
774                    end,
775                },
776                sink,
777            );
778        });
779        id
780    }
781    /// Alias kept for callers that haven't been updated yet.
782    pub fn add_rect_box(&mut self, start: Pos2, end: Pos2) -> BlockId {
783        self.add_block(start, end)
784    }
785
786    // ── Text box accessors ────────────────────────────────────────────────────
787
788    /// Create an empty, default-sized text box with its top-left corner at
789    /// `pos`, returning its freshly assigned id. The caller typically follows up
790    /// by opening the text editor on the returned box.
791    pub fn add_text_box(&mut self, pos: Pos2) -> TextId {
792        let id = self.mint();
793        let scope = self.current_scope();
794        self.author("add_text_box", |_indexed, sink| {
795            edit::create::text_box(id, scope, pos, sink);
796        });
797        id
798    }
799    pub fn text_box(&self, id: TextId) -> Option<&Text> {
800        self.scope_owned(self.indexed().doc.text(&id), |t| t.owner)
801    }
802
803    // ── Area accessors ─────────────────────────────────────────────────────
804
805    /// Create a boundary area covering the drag rectangle `start..end`,
806    /// returning its id as a `ShapeId`. The caller (the `NewArea` tool)
807    /// typically selects it afterwards.
808    pub fn add_area(&mut self, start: Pos2, end: Pos2) -> ShapeId {
809        let id: AreaId = self.mint();
810        let scope = self.current_scope();
811        self.author("add_area", |_indexed, sink| {
812            edit::create::area(id, scope, start, end, sink);
813        });
814        ShapeId::Area(id)
815    }
816    /// Iterate the current level's areas — the top layer, drawn above the
817    /// blocks and routes. Kept separate from [`Self::shapes`] so areas neither
818    /// render in the base layer nor act as routing obstacles.
819    pub fn areas(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
820        self.scope_areas()
821            .into_iter()
822            .map(|(id, c)| (ShapeId::Area(id), ShapeRef::Area(c)))
823    }
824
825    // ── Port accessors ───────────────────────────────────────────────────────
826
827    /// Insert a port whose name is auto-derived from its freshly assigned id
828    /// (`"Port N"`). The id is only known after insertion, hence the two-step
829    /// insert-then-rename. The boundary `side`+`offset` is picked from the
830    /// first entry of `current().new_pin_locations()`; the current block is
831    /// grown by one pin slot (one [`PIN_PITCH`](crate::grid::PIN_PITCH)) in height if no boundary
832    /// location is available.
833    pub fn add_port_auto_named(&mut self, inner: Rect) -> PinId {
834        let id = self.mint();
835        let scope = self.current_scope();
836        self.author("add_port_auto_named", |indexed, sink| {
837            // A port is a material edit, so the scope has to accept one.
838            let Some(owner) = edit::lock::UnlockedScope::of(indexed, scope) else {
839                return;
840            };
841            edit::create::port(
842                indexed,
843                edit::create::NewPort {
844                    id,
845                    owner,
846                    start: inner.min,
847                    end: inner.max,
848                },
849                sink,
850            );
851        });
852        id
853    }
854
855    // ── Combined shape accessors ──────────────────────────────────────────────
856
857    /// Generic lookup: returns a `ShapeRef` regardless of kind.
858    pub fn shape(&self, id: ShapeId) -> Option<ShapeRef<'_>> {
859        match id {
860            ShapeId::Rect(rid) => Some(ShapeRef::Block(self.shape_of_block(rid, self.block(rid)?))),
861            // A port body is the scope's *own* pin; the same pin seen from the
862            // parent is a stub on this block's rect instead (`pin_shape`).
863            ShapeId::Port(pid) => {
864                let pin = self
865                    .held_pin(pid)
866                    .filter(|pin| self.scope_owns(pin.owner))?;
867                Some(ShapeRef::Port(PortShape { id: pid, pin }))
868            }
869            ShapeId::Text(tid) => {
870                let text = self.scope_owned(self.indexed().doc.text(&tid), |t| t.owner)?;
871                let extent = self.presentation.text_extents.valid_for(tid, text);
872                Some(ShapeRef::text(text, extent))
873            }
874            ShapeId::Area(cid) => self
875                .scope_owned(self.indexed().doc.area(&cid), |c| c.owner)
876                .map(ShapeRef::Area),
877            ShapeId::Image(sid) => {
878                let image = self.scope_owned(self.indexed().doc.image(&sid), |i| i.owner)?;
879                Some(ShapeRef::artwork(
880                    artwork_rect(image.rect),
881                    self.asset(&image.asset),
882                ))
883            }
884            ShapeId::Icon(rid) => {
885                let icon = crate::edit::geometry::artwork(&self.block(rid)?.icon)?;
886                Some(ShapeRef::artwork(
887                    artwork_rect(icon.rect),
888                    self.asset(&icon.asset),
889                ))
890            }
891        }
892    }
893    /// The pin-tag visibility of a selected port, for the selection bar's toggle.
894    /// `None` for shapes that carry no such tag (blocks, text boxes, areas,
895    /// images).
896    pub fn shape_tag_hidden(&self, id: ShapeId) -> Option<bool> {
897        match id {
898            // A port body *is* one pin, so the port's own id is the pin's.
899            ShapeId::Port(pid) => Some(self.shape(id)?.pin(pid)?.tag_hidden),
900            ShapeId::Rect(_)
901            | ShapeId::Text(_)
902            | ShapeId::Area(_)
903            | ShapeId::Image(_)
904            | ShapeId::Icon(_) => None,
905        }
906    }
907
908    /// Set the pin-tag visibility of a port — the counterpart to
909    /// [`shape_tag_hidden`](Self::shape_tag_hidden). No-op for other shapes.
910    pub fn set_shape_tag_hidden(&mut self, id: ShapeId, tag: TagVisibility) {
911        // A port body *is* one pin, so the port's own id is the pin's.
912        if let ShapeId::Port(pid) = id {
913            self.author("set_shape_tag_hidden", |indexed, sink| {
914                edit::naming::set_tag_visibility(indexed.doc, &[pid], tag, sink);
915            });
916        }
917    }
918
919    /// Give `target` the accent role `role` (`None` clears it). A route's
920    /// role repropagates the derived pin accents when the commit lands.
921    pub fn set_role(&mut self, target: RoleTarget, role: Option<u8>) {
922        let target = match target {
923            RoleTarget::Block(id) => edit::naming::AccentTarget::Block(id),
924            RoleTarget::Port(id) => edit::naming::AccentTarget::Port(id),
925            RoleTarget::Route(id) => edit::naming::AccentTarget::Route(id),
926            RoleTarget::Area(id) => edit::naming::AccentTarget::Area(id),
927            RoleTarget::Text(id) => edit::naming::AccentTarget::Text(id),
928        };
929        self.author("set_role", |indexed, sink| {
930            edit::naming::set_accent(indexed.doc, target, role, sink);
931        });
932    }
933
934    /// Set the I/O direction on every pin in the group whose owner is
935    /// unlocked — a locked block keeps its pin interface frozen.
936    pub fn set_pins_kind(&mut self, pins: &[PinId], dir: PinDir) {
937        self.author("set_pins_kind", |indexed, sink| {
938            let targets = edit::lock::MaterialPin::all(indexed.doc, pins);
939            edit::naming::set_dirs(indexed.doc, &targets, dir, sink);
940        });
941    }
942
943    /// Cycle one pin's I/O direction in place — the selected-stub click.
944    /// A locked owner declines: direction is what a pin *is*.
945    pub fn cycle_pin_kind(&mut self, pin: PinId) {
946        self.author("cycle_pin_kind", |indexed, sink| {
947            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
948                return;
949            };
950            edit::naming::cycle_dir(indexed.doc, target, sink);
951        });
952    }
953
954    /// Show or hide the location tags on a pin group.
955    pub fn set_pins_tag_hidden(&mut self, pins: &[PinId], tag: TagVisibility) {
956        self.author("set_pins_tag_hidden", |indexed, sink| {
957            edit::naming::set_tag_visibility(indexed.doc, pins, tag, sink);
958        });
959    }
960
961    /// Freeze or thaw a block's pin interface.
962    pub fn set_block_locked(&mut self, block: BlockId, lock: InterfaceLock) {
963        self.author("set_block_locked", |indexed, sink| {
964            edit::naming::set_locked(indexed.doc, block, lock, sink);
965        });
966    }
967
968    /// Write `text` to `shape`'s title, if it carries one — a block or a
969    /// area.
970    pub fn set_title_text(&mut self, shape: ShapeId, text: &str) {
971        let Some(target) = title_target(shape) else {
972            return;
973        };
974        self.author("set_title_text", |indexed, sink| {
975            edit::naming::rename_title(indexed.doc, target, text, sink);
976        });
977    }
978
979    /// Commit a dragged title's placement.
980    pub fn place_title(&mut self, shape: ShapeId, placement: LabelPlacement) {
981        let target = match shape {
982            ShapeId::Rect(id) => edit::naming::LabelTarget::BlockTitle(id),
983            ShapeId::Area(id) => edit::naming::LabelTarget::AreaTitle(id),
984            _ => return,
985        };
986        self.author("place_title", |indexed, sink| {
987            edit::naming::place_label(indexed.doc, target, placement.into(), sink);
988        });
989    }
990
991    /// Write `text` to block `rect`'s type label.
992    pub fn set_type_label_text(&mut self, rect: BlockId, text: &str) {
993        self.author("set_type_label_text", |indexed, sink| {
994            edit::naming::rename_type(indexed.doc, rect, text, sink);
995        });
996    }
997
998    /// Commit a dragged type label's placement.
999    pub fn place_type_label(&mut self, rect: BlockId, placement: LabelPlacement) {
1000        self.author("place_type_label", |indexed, sink| {
1001            edit::naming::place_label(
1002                indexed.doc,
1003                edit::naming::LabelTarget::BlockType(rect),
1004                placement.into(),
1005                sink,
1006            );
1007        });
1008    }
1009
1010    /// The width pin `pin`'s port body must reach to hold its labels, with
1011    /// `name`/`type_name` overriding what it holds now — the measurement a
1012    /// rename or retype owes its emitter, which cannot make one for itself.
1013    pub fn label_fit(
1014        &self,
1015        pin: PinId,
1016        name: Option<&str>,
1017        type_name: Option<&str>,
1018    ) -> LabelFitWidth {
1019        let held = self.held_pin(pin);
1020        let name = name.unwrap_or_else(|| held.map_or("", |pin| &pin.name));
1021        let type_name = type_name.unwrap_or_else(|| held.map_or("", |pin| &pin.type_name));
1022        LabelFitWidth::new(crate::shape::port::width_for_labels(name, type_name))
1023    }
1024
1025    /// Rename a pin, widening the port body to fit its labels. A locked
1026    /// block keeps its pin interface frozen — the commit is dropped.
1027    pub fn rename_pin(&mut self, pin: PinId, text: &str, fit: LabelFitWidth) {
1028        self.author("rename_pin", |indexed, sink| {
1029            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
1030                return;
1031            };
1032            edit::naming::rename_pin(indexed.doc, target, text, fit, sink);
1033        });
1034    }
1035
1036    /// Set a pin's location tag. Tags extend outward over the stub, so
1037    /// no widening. Locked owners drop the commit, as with renames.
1038    pub fn set_pin_tag(&mut self, pin: PinId, text: &str) {
1039        self.author("set_pin_tag", |indexed, sink| {
1040            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
1041                return;
1042            };
1043            edit::naming::set_tag(indexed.doc, target, text, sink);
1044        });
1045    }
1046
1047    /// Set a pin's type label, widening the port body to fit. Locked
1048    /// owners drop the commit.
1049    pub fn retype_pin(&mut self, pin: PinId, text: &str, fit: LabelFitWidth) {
1050        self.author("retype_pin", |indexed, sink| {
1051            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
1052                return;
1053            };
1054            edit::naming::retype_pin(indexed.doc, target, text, fit, sink);
1055        });
1056    }
1057
1058    /// Add a pin at `loc` on `block`, auto-named "Port N" from its ordinal.
1059    /// Returns the id so the caller can anchor a route to it. A locked
1060    /// block keeps its pin interface frozen — `None`.
1061    pub fn add_named_pin(&mut self, block: BlockId, loc: PinLocation) -> Option<PinId> {
1062        // Asked here as well as inside, because the caller needs the answer:
1063        // a declined stamp has no id to hand back.
1064        if edit::lock::UnlockedScope::of(&self.indexed(), crate::path::Scope::Block(block))
1065            .is_none()
1066        {
1067            tracing::debug!(target: "edit", edit = "add_named_pin", "declined");
1068            return None;
1069        }
1070        let id = self.mint();
1071        self.author("add_named_pin", |indexed, sink| {
1072            let Some(owner) =
1073                edit::lock::UnlockedScope::of(indexed, crate::path::Scope::Block(block))
1074            else {
1075                return;
1076            };
1077            edit::create::pin(
1078                indexed,
1079                edit::create::NewPin {
1080                    id,
1081                    owner,
1082                    slot: slot_at(loc),
1083                },
1084                sink,
1085            );
1086        });
1087        Some(id)
1088    }
1089
1090    /// Commit a handle-drag resize: the caller resolved the final rect
1091    /// (constraints, snapping, magnetism, which corner moved), this writes
1092    /// it — geometry, carried pins, and icon box.
1093    pub fn apply_resize(&mut self, shape: ShapeId, new_rect: Rect) {
1094        let target = match shape {
1095            ShapeId::Rect(id) => edit::geometry::ResizeTarget::Block(id),
1096            ShapeId::Port(id) => edit::geometry::ResizeTarget::Port(id),
1097            ShapeId::Area(id) => edit::geometry::ResizeTarget::Area(id),
1098            ShapeId::Image(id) => edit::geometry::ResizeTarget::Image(id),
1099            ShapeId::Icon(id) => edit::geometry::ResizeTarget::Icon(id),
1100            ShapeId::Text(id) => edit::geometry::ResizeTarget::Text(id),
1101        };
1102        self.author("apply_resize", |indexed, sink| {
1103            edit::geometry::resize(indexed, target, new_rect, sink);
1104        });
1105    }
1106
1107    /// Move a pin to `to`, which the gesture already resolved against the
1108    /// live preview ([`BaseShape::pin_drop_candidate`](crate::shape::BaseShape::pin_drop_candidate))
1109    /// — so the drop lands exactly where the drag promised. A destination
1110    /// another pin holds writes nothing.
1111    pub fn move_pin_snapped(&mut self, pin: PinId, to: PinSlot) {
1112        self.author("move_pin_snapped", |indexed, sink| {
1113            edit::geometry::move_pin(indexed, pin, to, sink);
1114        });
1115    }
1116
1117    /// Name a wire. An empty/whitespace name removes `label` entirely and
1118    /// clears the shared name, so the wire goes back to unlabeled rather
1119    /// than carrying a blank slot.
1120    pub fn set_route_name(&mut self, route: RouteId, label: RouteLabelId, text: &str) {
1121        if text.trim().is_empty() {
1122            self.author("set_route_name", |indexed, sink| {
1123                edit::naming::clear_wire_label(indexed.doc, label, sink);
1124            });
1125        } else {
1126            self.author("set_route_name", |indexed, sink| {
1127                edit::naming::rename_route(indexed.doc, route, text, sink);
1128            });
1129        }
1130    }
1131
1132    /// Commit a text box's content. Emptying the box discards it rather than
1133    /// leaving it invisible.
1134    ///
1135    /// The extent is not written here: it is measured on demand by
1136    /// [`Self::refresh_text_extents`], because the text can change without an
1137    /// editor being involved and a cache only filled by the editor goes
1138    /// stale the moment it does.
1139    pub fn set_text_box_content(&mut self, id: TextId, text: &str) {
1140        self.author("set_text_box_content", |indexed, sink| {
1141            edit::naming::edit_text(indexed.doc, id, text, sink);
1142        });
1143    }
1144
1145    /// Measure any text box in this scope whose cached extent no longer
1146    /// matches its text, so the box draws and hit-tests at its real size.
1147    ///
1148    /// On demand rather than on commit. A box's text changes under the cache
1149    /// whenever the editor was not what changed it — an undo, a redo — and an
1150    /// extent written only by the editor would leave those boxes on the
1151    /// character-count estimate until someone re-opened them. Cheap by
1152    /// construction: an entry that
1153    /// still matches its text is not measured again, so a frame that changed
1154    /// no text measures nothing.
1155    pub fn refresh_text_extents(&mut self, painter: &Style<'_, impl Renderer>) {
1156        let stale: Vec<(TextId, Text)> = self
1157            .scope_texts()
1158            .into_iter()
1159            .filter(|(id, text)| {
1160                self.presentation
1161                    .text_extents
1162                    .valid_for(*id, text)
1163                    .is_none()
1164            })
1165            .map(|(id, text)| (id, text.clone()))
1166            .collect();
1167        for (id, text) in stale {
1168            let width = crate::render::text_box::BoxWidth::of(&text);
1169            let size = crate::render::text_box::measure_box_size(painter, &text.text, width);
1170            self.presentation.text_extents.set(id, &text, size);
1171        }
1172    }
1173
1174    /// Drop a name label on `route` at the projection of `pos` onto its
1175    /// polyline. `None` for a wire with no solved geometry to project onto.
1176    pub fn add_route_label(&mut self, route: RouteId, pos: Pos2) -> Option<RouteLabelId> {
1177        let distance = self.route_geometry(route)?.distance_along(pos);
1178        let id = self.mint();
1179        self.author("add_route_label", |indexed, sink| {
1180            edit::create::wire_label(indexed.doc, id, route, distance, sink);
1181        });
1182        Some(id)
1183    }
1184
1185    /// Place a wire's name label at an arc length along its route — the label
1186    /// drag's one document write, of the distance the preview settled on.
1187    pub fn place_route_label(
1188        &mut self,
1189        label: RouteLabelId,
1190        dist: blockworx_doc::geometry::FracVal,
1191    ) {
1192        self.author("place_route_label", |indexed, sink| {
1193            edit::geometry::place_wire_label(indexed.doc, label, dist, sink);
1194        });
1195    }
1196
1197    /// Mirror a selected shape left/right. No-op for shapes without pins; the
1198    /// caller re-routes afterwards.
1199    ///
1200    /// A **port** flips only its `port_orientation` (the way its stub faces), so
1201    /// the pin it represents on the parent block doesn't move. A **block** flips
1202    /// the edge `side` of all its pins, but first freezes each pin's
1203    /// `port_orientation` to its current value — so descending into the block
1204    /// shows the same boundary ports as before and its internal routes are left
1205    /// intact.
1206    pub fn flip_shape_pins(&mut self, id: ShapeId) {
1207        let target = match id {
1208            ShapeId::Rect(rid) => edit::geometry::FlipTarget::Block(rid),
1209            ShapeId::Port(pid) => edit::geometry::FlipTarget::Port(pid),
1210            ShapeId::Text(_) | ShapeId::Area(_) | ShapeId::Image(_) | ShapeId::Icon(_) => return,
1211        };
1212        self.author("flip_shape_pins", |indexed, sink| {
1213            edit::geometry::flip_pins(indexed, target, sink);
1214        });
1215    }
1216
1217    /// Add a level above the whole document: a fresh root whose sole child
1218    /// is the old one, which demotes to a child rect.
1219    ///
1220    /// Reachable from nothing on screen: a navigation button that quietly
1221    /// writes a block is the wrong door for it (docs/ui-issues-2.md, item 8),
1222    /// and the right one has not been chosen.
1223    pub fn wrap_top(&mut self) {
1224        let id = self.mint();
1225        self.author("wrap_top", |indexed, sink| {
1226            edit::create::wrap_top(indexed, id, sink);
1227        });
1228    }
1229
1230    /// Vertically mirror a block's pins about its center: a pin in slot `offset`
1231    /// moves to slot `h - offset`, where `h` ([`crate::grid::max_pin_slot`]) is
1232    /// the block's
1233    /// height in slots. Each pin keeps its `side`; only its vertical slot moves.
1234    /// A double flip is an exact identity. The caller re-routes afterwards.
1235    pub fn flip_block_vertical(&mut self, block: BlockId) {
1236        self.author("flip_block_vertical", |indexed, sink| {
1237            edit::geometry::flip_vertical(indexed, block, sink);
1238        });
1239    }
1240
1241    /// The current level's child blocks paired with their ids, for callers that
1242    /// need the `Block` directly rather than as a `ShapeRef`.
1243    pub fn current_blocks(&self) -> impl Iterator<Item = (BlockId, &Block)> {
1244        self.child_blocks().into_iter()
1245    }
1246
1247    /// A block as the renderer sees it: its pins, and whether it opens a scope
1248    /// of its own. The one place a `Block` becomes a [`BlockShape`], so no
1249    /// caller can hand the renderer a block whose interior it never asked about.
1250    fn shape_of_block<'s>(&'s self, id: BlockId, block: &'s Block) -> BlockShape<'s> {
1251        BlockShape::new(
1252            block,
1253            self.block_pins(crate::path::Scope::Block(id)),
1254            crate::path::structure(&self.indexed(), id),
1255        )
1256    }
1257
1258    /// The child blocks of the current level, as renderable shapes — each
1259    /// bundled with the pins that hang off it.
1260    pub fn blocks_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1261        self.child_blocks().into_iter().map(|(id, block)| {
1262            (
1263                ShapeId::Rect(id),
1264                ShapeRef::Block(self.shape_of_block(id, block)),
1265            )
1266        })
1267    }
1268    /// The current level's boundary ports, as renderable shapes: the scope's
1269    /// own pins, drawn as the port bodies their `rect` register places.
1270    pub fn ports_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1271        self.block_pins(self.current_scope())
1272            .into_iter()
1273            .map(|(id, pin)| (ShapeId::Port(id), ShapeRef::Port(PortShape { id, pin })))
1274    }
1275    /// The current level's text boxes, as renderable shapes.
1276    pub fn texts_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1277        self.scope_texts().into_iter().map(|(id, text)| {
1278            let extent = self.presentation.text_extents.valid_for(id, text);
1279            (ShapeId::Text(id), ShapeRef::text(text, extent))
1280        })
1281    }
1282    /// The current level's free-floating background images, as renderable shapes.
1283    pub fn images_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1284        self.scope_images().into_iter().map(|(id, image)| {
1285            (
1286                ShapeId::Image(id),
1287                ShapeRef::artwork(artwork_rect(image.rect), self.asset(&image.asset)),
1288            )
1289        })
1290    }
1291    /// The current level's block icons, as renderable shapes — the foreground
1292    /// layer, drawn on top of everything (see
1293    /// [`DrawingPasses`](crate::widget::DrawingPasses)).
1294    pub fn icons(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1295        self.child_blocks().into_iter().filter_map(|(id, block)| {
1296            let icon = crate::edit::geometry::artwork(&block.icon)?;
1297            Some((
1298                ShapeId::Icon(id),
1299                ShapeRef::artwork(artwork_rect(icon.rect), self.asset(&icon.asset)),
1300            ))
1301        })
1302    }
1303    /// Iterate over all shapes (blocks first, then ports, then text boxes, then
1304    /// images). This order is for non-render uses (hit-testing, copy); the
1305    /// painted layer order lives in [`DrawingPasses`](crate::widget::DrawingPasses).
1306    pub fn shapes(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1307        self.blocks_layer()
1308            .chain(self.ports_layer())
1309            .chain(self.texts_layer())
1310            .chain(self.images_layer())
1311    }
1312
1313    /// Whether every destination is free and in bounds — the gate a group
1314    /// pin move's *preview* asks before drawing it as valid. The same
1315    /// predicate the commit runs, so the preview cannot promise a move the
1316    /// emitter will refuse.
1317    pub fn can_relocate_pins(&self, moves: &[PinMove]) -> bool {
1318        crate::edit::geometry::relocation_fits(&self.indexed(), moves)
1319    }
1320
1321    /// Apply a group pin move iff [`Self::can_relocate_pins`] holds; returns
1322    /// whether it was applied. On success each pin takes its new slot.
1323    pub fn relocate_pins(&mut self, moves: &[PinMove]) -> bool {
1324        let applied = self.can_relocate_pins(moves);
1325        self.author("relocate_pins", |indexed, sink| {
1326            edit::geometry::relocate_pins(indexed, moves, sink);
1327        });
1328        applied
1329    }
1330
1331    /// Shift `anchors` rigidly by `slot_delta` slots (negative = up), keeping
1332    /// each pin's side. The shift is clamped to the tightest range that keeps
1333    /// every pin on its block — like a multi-pin drag — so a
1334    /// group moves as one and never runs off an edge.
1335    pub fn nudge_pins(&mut self, pins: &[PinId], slot_delta: i32) {
1336        self.author("nudge_pins", |indexed, sink| {
1337            edit::geometry::nudge_pins(
1338                indexed,
1339                pins,
1340                edit::geometry::SlotDelta::new(slot_delta),
1341                sink,
1342            );
1343        });
1344    }
1345
1346    /// World-space bounding box of everything this scope *draws* — the whole
1347    /// diagram, wires, labels and waypoints included, not just the shapes that
1348    /// hold them. Taken by running the real draw passes into a measuring
1349    /// backend, so a shape kind added later joins the extent the moment it is
1350    /// drawn. `None` when the scope draws nothing.
1351    pub fn content_bounds(&self, painter: &Style<'_, impl Renderer>) -> Option<Rect> {
1352        let mut extent = blockworx_paint::Extent::measuring_through(painter.renderer());
1353        crate::widget::DrawingPasses::new(self).draw(&mut Style::new(painter.theme(), &mut extent));
1354        extent.finish()
1355    }
1356
1357    // ── Broad-phase candidate selection ─────────────────────────────────────
1358    //
1359    // Shared by hit-testing (query rect = the cursor neighborhood) and, via the
1360    // same index, viewport culling. With an index only nearby candidates are
1361    // returned; without one, the full collection in its usual order — so the
1362    // no-index paths (SVG export, tests) behave exactly as before.
1363
1364    /// Where `id` sits in the draw order: its paint layer, then the
1365    /// document's one chronological order — ascending id — within that
1366    /// layer. Sorting the index's (unordered) hits by this reproduces the
1367    /// linear [`Self::shapes`] walk exactly, so the two broad phases
1368    /// cannot disagree about which shape is on top.
1369    fn draw_key(id: ShapeId) -> (u8, ShapeId) {
1370        (shape_layer_rank(id), id)
1371    }
1372
1373    /// Shapes (blocks/ports/texts/images — **not** areas) whose coarse bounds
1374    /// fall near `query`, in draw order. Index-limited when available;
1375    /// otherwise every shape in [`Self::shapes`] order, which is the same
1376    /// order. Areas are queried by their own (short) linear scan, so they
1377    /// are excluded here.
1378    pub fn shape_candidates(&self, query: Rect) -> Vec<(ShapeId, ShapeRef<'_>)> {
1379        match self.index {
1380            Some(idx) => {
1381                let mut v: Vec<(ShapeId, ShapeRef<'_>)> = idx
1382                    .in_rect(query)
1383                    .filter_map(|hit| match hit {
1384                        // Areas run their own linear scan; icons are hit-tested
1385                        // only in their block's context (see `icon_at_pos`), never
1386                        // as free candidates — but both stay in the index for
1387                        // culling. Excluding them here keeps the indexed hit-test and
1388                        // marquee identical to the no-index `shapes()` path.
1389                        HitId::Shape(id) if !matches!(id, ShapeId::Area(_) | ShapeId::Icon(_)) => {
1390                            self.shape(id).map(|s| (id, s))
1391                        }
1392                        _ => None,
1393                    })
1394                    .collect();
1395                v.sort_by_key(|(id, _)| Self::draw_key(*id));
1396                v
1397            }
1398            None => self.shapes().collect(),
1399        }
1400    }
1401
1402    /// [`Self::shape_candidates`] back-to-front: the shape painted last —
1403    /// the one on top — comes first, so a top-down hit test takes the first
1404    /// match. Hit order is the reverse of draw order, and this is the only
1405    /// place that says so.
1406    pub fn hit_candidates(&self, query: Rect) -> Vec<(ShapeId, ShapeRef<'_>)> {
1407        let mut v = self.shape_candidates(query);
1408        v.reverse();
1409        v
1410    }
1411
1412    /// Record where this frame's preview puts each shape. A block's icon rides
1413    /// inside its block, so the block's previewed rect is where the icon goes
1414    /// too — recorded here rather than by each tool that draws them together.
1415    pub(super) fn preview_shapes(&mut self, previews: &[(ShapeId, Rect)]) {
1416        self.previewed.shapes.clear();
1417        for &(id, rect) in previews {
1418            self.previewed.shapes.push((id, rect));
1419            if let Some(icon) = id.block().map(ShapeId::Icon)
1420                && self.shape(icon).is_some()
1421            {
1422                self.previewed.shapes.push((icon, rect));
1423            }
1424        }
1425    }
1426
1427    /// The scope this drawing is over, for a caller that has to say *where*
1428    /// something happened as well as what.
1429    pub(super) fn scope_path(&self) -> &BlockPath {
1430        self.path
1431    }
1432
1433    /// Record the wires this frame's preview re-solved — the ones now drawn
1434    /// somewhere the document does not put them.
1435    pub(super) fn preview_routes(&mut self, routes: Vec<RouteId>) {
1436        self.previewed.routes = routes;
1437    }
1438
1439    /// Ids to draw when culling the scene to `viewport` (world space): the
1440    /// hittables whose bounds are on screen. `None` means "no index — draw
1441    /// everything", which is what the SVG export and tests want (they must not
1442    /// cull). Shares the one index with the hit-test broad phase.
1443    ///
1444    /// The index is keyed by the document value, so it answers for *committed*
1445    /// geometry only. A live preview moves where a shape paints without moving
1446    /// its rect, so this frame's preview is asked too and its answers are
1447    /// unioned in — that set is the dragged shapes and the wires re-solved
1448    /// against them, never the document.
1449    pub(crate) fn visible_ids(&self, viewport: Rect) -> Option<std::collections::HashSet<HitId>> {
1450        let idx = self.index?;
1451        let mut ids: std::collections::HashSet<HitId> = idx.in_rect(viewport).collect();
1452        for &(id, rect) in &self.previewed.shapes {
1453            if self.shape(id).is_some_and(|shape| {
1454                crate::render::bounds::shape_bounds_at(&shape, rect).intersects(viewport)
1455            }) {
1456                ids.insert(HitId::Shape(id));
1457            }
1458        }
1459        for &id in &self.previewed.routes {
1460            if self
1461                .auto_route(id)
1462                .zip(self.route_geometry(id))
1463                .is_some_and(|(wire, geometry)| {
1464                    crate::render::bounds::route_bounds(&wire, geometry).intersects(viewport)
1465                })
1466            {
1467                ids.insert(HitId::Route(id));
1468            }
1469        }
1470        Some(ids)
1471    }
1472
1473    /// The hops each of `among` draws. `among` is in draw order and holds every wire through any
1474    /// point whose hop is wanted — what the index reports for a rectangle, or
1475    /// the whole scope ([`route_hops`](super::auto_route::route_hops)).
1476    pub fn hops(&self, among: &[RouteId]) -> Hops {
1477        let _s = tracing::info_span!("hops", routes = among.len()).entered();
1478        let drawn: Vec<(RouteId, &RouteGeometry)> = among
1479            .iter()
1480            .filter_map(|&id| Some((id, self.route_geometry(id)?)))
1481            .collect();
1482        let hops = super::auto_route::route_hops(drawn.iter().map(|&(_, geometry)| geometry));
1483        let hops = drawn
1484            .into_iter()
1485            .zip(hops)
1486            .filter(|(_, hops)| !hops.is_empty())
1487            .map(|((id, _), hops)| (id, hops))
1488            .collect();
1489        Hops(hops)
1490    }
1491
1492    /// How to draw `id`: its geometry and `hops`. `None` for a wire with no
1493    /// solved geometry, which draws nothing.
1494    pub fn course<'h>(&'h self, id: RouteId, hops: &'h [Crossing]) -> Option<Course<'h>> {
1495        Some(Course {
1496            geometry: self.route_geometry(id)?,
1497            hops,
1498        })
1499    }
1500
1501    /// The standing conflicts `shapes` are party to: where two routing shapes
1502    /// overlap, and each illegal stretch of a wire that settled on its
1503    /// fallback L through one — the same crossing the router refuses a leg
1504    /// for, so the mark and the refusal cannot disagree. Each is found by
1505    /// asking the spatial index what lies near the shape; a shape a preview
1506    /// is dragging counts where the preview draws it, as does every wire the
1507    /// preview re-solved, since the index holds only committed geometry. With
1508    /// no index (an export), one is built for the ask.
1509    pub(crate) fn standing_conflicts(&self, shapes: &[ShapeId]) -> Conflicts {
1510        let built;
1511        let index = if let Some(index) = self.index {
1512            index
1513        } else {
1514            built = SpatialIndex::from_drawing(self);
1515            &built
1516        };
1517        let doc = self.indexed().doc;
1518        let cells = |id: ShapeId| {
1519            self.previewed
1520                .shapes
1521                .iter()
1522                .find(|&&(shape, _)| shape == id)
1523                .map(|&(_, rect)| crate::grid::grid_rect(rect.min, rect.max))
1524                .or_else(|| {
1525                    crate::edit::geometry::placement(doc, crate::edit::geometry::Shape::from(id))
1526                        .map(|(_, cells)| cells)
1527                })
1528        };
1529        let previewed = |hit: &HitId| match *hit {
1530            HitId::Shape(id) => self.previewed.shapes.iter().any(|&(shape, _)| shape == id),
1531            HitId::Route(id) => self.previewed.routes.contains(&id),
1532        };
1533        let from_preview = self
1534            .previewed
1535            .shapes
1536            .iter()
1537            .map(|&(id, _)| HitId::Shape(id))
1538            .chain(self.previewed.routes.iter().map(|&id| HitId::Route(id)));
1539        // A pair is found from both of its shapes when both are asked about;
1540        // it is kept from the lesser.
1541        let asked: std::collections::HashSet<ShapeId> = shapes.iter().copied().collect();
1542        let mut conflicts = Conflicts::default();
1543        for &one in shapes {
1544            let Some(own) = cells(one) else {
1545                continue;
1546            };
1547            let rect = crate::grid::px_rect(own);
1548            let block = super::routing::obstacle_rect(rect);
1549            let near = index
1550                .in_rect(rect)
1551                .filter(|hit| !previewed(hit))
1552                .chain(from_preview.clone());
1553            for hit in near {
1554                match hit {
1555                    HitId::Shape(other)
1556                        if other != one
1557                            && is_routing(other)
1558                            && (other > one || !asked.contains(&other)) =>
1559                    {
1560                        let overlap = cells(other).and_then(|other| own.intersection(other));
1561                        conflicts.overlaps.extend(overlap.map(crate::grid::px_rect));
1562                    }
1563                    HitId::Route(id) => {
1564                        let Some(geometry) = self.route_geometry(id) else {
1565                            continue;
1566                        };
1567                        conflicts.crossings.extend(
1568                            geometry
1569                                .iter_edges()
1570                                .filter_map(|(_, edge)| block.edge_crossing(edge.start, edge.end))
1571                                .map(|(from, to)| {
1572                                    let (from, to) = (
1573                                        crate::grid::px_point(from.into()),
1574                                        crate::grid::px_point(to.into()),
1575                                    );
1576                                    Rect::from_min_max(from.min(to), from.max(to))
1577                                        .expand(crate::grid::GRID_SIZE / 2.0)
1578                                }),
1579                        );
1580                    }
1581                    HitId::Shape(_) => {}
1582                }
1583            }
1584        }
1585        conflicts
1586    }
1587
1588    /// The hops one wire draws, against the wires the index finds near it —
1589    /// for a caller redrawing it alone, over the scene.
1590    pub fn hops_of(&self, id: RouteId) -> Vec<Crossing> {
1591        let Some(bounds) = self
1592            .auto_route(id)
1593            .zip(self.route_geometry(id))
1594            .map(|(wire, geometry)| crate::render::bounds::route_bounds(&wire, geometry))
1595        else {
1596            return Vec::new();
1597        };
1598        let near: Vec<RouteId> = self
1599            .route_candidates(bounds)
1600            .into_iter()
1601            .map(|(id, _)| id)
1602            .collect();
1603        self.hops(&near).0.remove(&id).unwrap_or_default()
1604    }
1605
1606    /// Routes whose coarse bounds fall near `query`. Index-limited when available,
1607    /// sorted back into `auto_routes()` (insertion) order so that when wires
1608    /// overlap — e.g. at a crossing — the same one wins as in the linear scan;
1609    /// otherwise every route in `auto_routes()` order.
1610    pub(crate) fn route_candidates(&self, query: Rect) -> Vec<(RouteId, Wire<'_>)> {
1611        match self.index {
1612            Some(idx) => {
1613                let order = self.scope_route_ids();
1614                let mut v: Vec<(RouteId, Wire<'_>)> = idx
1615                    .in_rect(query)
1616                    .filter_map(|hit| match hit {
1617                        HitId::Route(id) => self.auto_route(id).map(|r| (id, r)),
1618                        HitId::Shape(_) => None,
1619                    })
1620                    .collect();
1621                v.sort_by_key(|(id, _)| {
1622                    order
1623                        .iter()
1624                        .position(|other| other == id)
1625                        .unwrap_or(usize::MAX)
1626                });
1627                v
1628            }
1629            None => self.auto_routes().collect(),
1630        }
1631    }
1632}
1633
1634/// The hops a pass's wires draw, from [`Drawing::hops`].
1635pub struct Hops(ahash::HashMap<RouteId, Vec<Crossing>>);
1636
1637impl Hops {
1638    /// Where `id` jumps the wires it crosses — nothing, for a wire that
1639    /// crosses none or was not among those the hops were computed over.
1640    pub fn of(&self, id: RouteId) -> &[Crossing] {
1641        self.0.get(&id).map_or(&[], Vec::as_slice)
1642    }
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647    use super::*;
1648    use crate::path::Scope;
1649    use crate::{
1650        edit::lower::slot_capacity,
1651        grid::GRID_SIZE,
1652        shape::Deletable,
1653        widget::test_fixtures::{self as fx, Scene},
1654    };
1655    use blockworx_doc::{
1656        fixtures::{block_id, pin_id, route_id},
1657        values::PinSide as DocPinSide,
1658    };
1659    use blockworx_geom::{pos2, vec2};
1660    use blockworx_paint::FontChoice;
1661    use blockworx_text::measure::Measured;
1662
1663    /// Two blocks four cells square, `x` cells from the origin each.
1664    fn two_blocks(one: i32, other: i32) -> Scene {
1665        Scene::new(vec![
1666            fx::block_in(1, Scope::Root, fx::cells(one, 0, 4, 4)),
1667            fx::block_in(2, Scope::Root, fx::cells(other, 0, 4, 4)),
1668        ])
1669    }
1670
1671    fn both() -> [ShapeId; 2] {
1672        [ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2))]
1673    }
1674
1675    /// An overlap the document holds is marked once, over the cells the two
1676    /// share.
1677    #[test]
1678    fn an_overlap_is_marked_once_over_the_shared_cells() {
1679        let mut scene = two_blocks(0, 2);
1680        let conflicts = scene.drawing().standing_conflicts(&both());
1681        assert_eq!(conflicts.overlaps, vec![fx::cells(2, 0, 2, 4)]);
1682    }
1683
1684    /// The index holds committed rects, so a dragged block is asked about
1685    /// where the preview draws it: dragged onto a neighbour it is marked
1686    /// there, and dragged off one the mark it stood in goes with it.
1687    #[test]
1688    fn a_dragged_block_is_marked_where_the_preview_draws_it() {
1689        let mut scene = two_blocks(0, 10);
1690        let mut drawing = scene.drawing();
1691        assert!(
1692            drawing.standing_conflicts(&both()).overlaps.is_empty(),
1693            "precondition: the committed blocks stand clear"
1694        );
1695        drawing.preview_shapes(&[(ShapeId::Rect(block_id(2)), fx::cells(3, 0, 4, 4))]);
1696        assert_eq!(
1697            drawing.standing_conflicts(&both()).overlaps,
1698            vec![fx::cells(3, 0, 1, 4)]
1699        );
1700
1701        let mut scene = two_blocks(0, 2);
1702        let mut drawing = scene.drawing();
1703        assert!(
1704            !drawing.standing_conflicts(&both()).overlaps.is_empty(),
1705            "precondition: the committed blocks overlap"
1706        );
1707        drawing.preview_shapes(&[(ShapeId::Rect(block_id(2)), fx::cells(10, 0, 4, 4))]);
1708        assert!(drawing.standing_conflicts(&both()).overlaps.is_empty());
1709    }
1710
1711    /// The scene asks only about what is on screen, and a neighbour off it is
1712    /// still found through the index: an overlap is one fact, whichever of
1713    /// the two is asked about.
1714    #[test]
1715    fn an_overlap_is_found_from_either_block() {
1716        let mut scene = two_blocks(0, 2);
1717        let drawing = scene.drawing();
1718        for one in both() {
1719            assert_eq!(
1720                drawing.standing_conflicts(&[one]).overlaps,
1721                vec![fx::cells(2, 0, 2, 4)],
1722                "asked about {one:?}"
1723            );
1724        }
1725    }
1726
1727    /// The whole minting path, through the waist the tools hold: a document
1728    /// loaded with blocks up to `b7` mints `b8` next, per kind, and a deleted
1729    /// entity keeps its number out of circulation.
1730    #[test]
1731    fn a_tool_mints_one_past_the_highest_id_the_document_holds() {
1732        let mut scene = Scene::new(vec![fx::block(7, 0.0), fx::pin(2, 7, DocPinSide::East, 0)]);
1733        assert!(
1734            scene.drawing().held_block(block_id(8)).is_none(),
1735            "precondition: b8 is free before the gesture",
1736        );
1737
1738        let (made, _) = scene.authored(|drawing| {
1739            (
1740                drawing.add_block(pos2(200.0, 0.0), pos2(240.0, 40.0)),
1741                drawing.add_text_box(pos2(300.0, 0.0)),
1742            )
1743        });
1744        assert_eq!(
1745            made,
1746            (block_id(8), blockworx_doc::fixtures::text_id(1)),
1747            "the block counts on from b7 while the texts start their own space at 1",
1748        );
1749
1750        scene.authored(|drawing| drawing.delete(Deletable::Shape(ShapeId::Rect(block_id(8)))));
1751        assert!(
1752            scene.drawing().held_block(block_id(8)).is_none(),
1753            "precondition: b8 is deleted",
1754        );
1755        let (again, _) =
1756            scene.authored(|drawing| drawing.add_block(pos2(0.0, 200.0), pos2(40.0, 240.0)));
1757        assert_eq!(
1758            again,
1759            block_id(9),
1760            "the marks never fall, so the next block cannot land on a departed id",
1761        );
1762    }
1763
1764    /// A scene whose wire is dragged well clear of both blocks by a locked
1765    /// waypoint — the shape of the user report "fit to extent does not
1766    /// include the routes".
1767    fn wire_outside_the_blocks() -> Scene {
1768        Scene::new(vec![
1769            fx::block(1, 0.0),
1770            fx::block(2, 120.0),
1771            fx::pin(3, 1, DocPinSide::East, 0),
1772            fx::pin(4, 2, DocPinSide::West, 0),
1773            fx::route(5, Scope::Root, 3, 4, &[(8, -30), (8, -30)]),
1774        ])
1775    }
1776
1777    /// What a fit frames is the whole diagram, not the shapes that hold it:
1778    /// a wire routed far above every block is still part of the drawing, and
1779    /// a view that leaves it out has cropped the document.
1780    #[test]
1781    fn the_content_bounds_hold_the_wires_too() {
1782        let mut scene = wire_outside_the_blocks();
1783        let theme = crate::theme::Theme::default();
1784        let canvas = Measured::new(FontChoice::default(), theme.palette().clone());
1785        let drawing = scene.drawing();
1786
1787        let blocks = drawing
1788            .blocks_layer()
1789            .map(|(_, shape)| shape.gui_rect())
1790            .reduce(Rect::union)
1791            .expect("the scene holds blocks");
1792        let wire = drawing
1793            .auto_routes()
1794            .filter_map(|(id, _)| drawing.route_geometry(id))
1795            .flat_map(|geometry| geometry.points())
1796            .fold(Rect::NOTHING, |acc, p| acc.union(Rect::from_min_max(p, p)));
1797        assert!(
1798            wire.is_positive() && !blocks.contains_rect(wire),
1799            "precondition: the wire must escape the blocks it joins \
1800             (wire {wire:?}, blocks {blocks:?})",
1801        );
1802
1803        let bounds = canvas
1804            .frame(|painter| drawing.content_bounds(&Style::new(&theme, painter)))
1805            .expect("the scene draws something");
1806        assert!(
1807            bounds.contains_rect(wire),
1808            "the fit cropped the wire: {bounds:?} does not hold {wire:?}",
1809        );
1810    }
1811
1812    /// A gesture reads the session's prediction, which only advances at
1813    /// submit — so without the write door staging the sink, a block created
1814    /// mid-gesture would be invisible to the rest of that gesture.
1815    ///
1816    /// The claim is not just "something is there": what the gesture reads is
1817    /// what the sealed commit produces, so a tool seeding an editor from the
1818    /// prediction cannot be seeded from a different document than the one the
1819    /// user ends up with.
1820    #[test]
1821    fn a_gesture_reads_the_block_it_just_created() {
1822        let mut scene = Scene::new(vec![]);
1823        let (created, staged) = scene.commit(|drawing| {
1824            let created = drawing.add_rect_box(pos2(0.0, 0.0), pos2(40.0, 40.0));
1825            let seen = drawing
1826                .shape(ShapeId::Rect(created))
1827                .expect("the gesture's own block is in the document it reads");
1828            assert!(
1829                seen.title().is_some(),
1830                "including the title an editor would be seeded from",
1831            );
1832            (created, seen.gui_rect())
1833        });
1834        let settled = scene
1835            .drawing()
1836            .shape(ShapeId::Rect(created))
1837            .expect("the sealed commit folded")
1838            .gui_rect();
1839        assert_eq!(
1840            staged, settled,
1841            "the geometry read mid-gesture is the geometry the commit lands",
1842        );
1843        assert!(
1844            staged.area() > 0.0,
1845            "and it is a real rect, so the comparison is not two empties",
1846        );
1847    }
1848
1849    /// The other half: what the gesture has *not* written is not there. A
1850    /// staged prediction that invented rows would hide the errors the fold
1851    /// exists to catch.
1852    #[test]
1853    fn a_gesture_reads_nothing_it_did_not_write() {
1854        let mut scene = Scene::new(vec![tall_block(1)]);
1855        scene.commit(|drawing| {
1856            assert!(
1857                drawing.shape(ShapeId::Rect(block_id(1))).is_some(),
1858                "the scene's own block is there",
1859            );
1860            assert!(
1861                drawing.shape(ShapeId::Rect(block_id(2))).is_none(),
1862                "a block no one authored is not",
1863            );
1864        });
1865    }
1866
1867    /// A text box's bounds go stale whenever the editor was not what changed
1868    /// its text: an extent written only by the editor's commit would leave an
1869    /// undone box drawn and hit-tested at its character-count estimate until
1870    /// someone double-clicked it to start another edit cycle.
1871    ///
1872    /// Measured on demand, so the frame that shows the changed text is
1873    /// the frame that measures it.
1874    #[test]
1875    fn a_text_boxs_extent_is_measured_for_whatever_text_it_holds() {
1876        /// One frame's worth: measure what is stale, then read back the
1877        /// extent for the text the box actually holds.
1878        fn frame(
1879            canvas: &Measured,
1880            scene: &mut Scene,
1881            id: TextId,
1882        ) -> Option<blockworx_doc::geometry::GridSize> {
1883            canvas.frame(|painter| {
1884                let theme = crate::theme::Theme::default();
1885                let style = crate::theme::Style::new(&theme, painter);
1886                let mut drawing = scene.drawing();
1887                drawing.refresh_text_extents(&style);
1888                let text = drawing.text_box(id).cloned()?;
1889                drawing.presentation.text_extents.valid_for(id, &text)
1890            })
1891        }
1892
1893        let id = blockworx_doc::fixtures::text_id(1);
1894        let mut scene = Scene::new(vec![fx::text(1, Scope::Root, "short", pos2(0.0, 0.0))]);
1895        let canvas = Measured::new(
1896            FontChoice::default(),
1897            crate::theme::Theme::default().palette().clone(),
1898        );
1899
1900        // Nothing has typed into this box, so nothing ever wrote its extent —
1901        // the state a freshly opened document is in, and the one an undo
1902        // leaves behind.
1903        assert!(
1904            {
1905                let drawing = scene.drawing();
1906                let text = drawing
1907                    .text_box(id)
1908                    .cloned()
1909                    .expect("the box is in the scene");
1910                drawing
1911                    .presentation
1912                    .text_extents
1913                    .valid_for(id, &text)
1914                    .is_none()
1915            },
1916            "precondition: no extent has been measured yet",
1917        );
1918        let short = frame(&canvas, &mut scene, id).expect("the frame measured the box it found");
1919
1920        // The text changes with no editor involved: what an undo does.
1921        scene.apply(vec![fx::text_content(
1922            1,
1923            "a much longer line of text than the box was measured for",
1924        )]);
1925        let grown = frame(&canvas, &mut scene, id).expect("the next frame measured the new text");
1926
1927        assert!(
1928            grown.w > short.w,
1929            "the box grew to fit the longer text ({short:?} -> {grown:?}) with no \
1930             edit cycle to trigger it",
1931        );
1932    }
1933
1934    /// A block tall enough to offer several pin slots, so the slot arithmetic
1935    /// under test has room to move.
1936    fn tall_block(n: u32) -> blockworx_doc::opcode::OpCodes {
1937        fx::block_in(
1938            n,
1939            Scope::Root,
1940            Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 150.0)),
1941        )
1942    }
1943
1944    fn slot(scene: &mut Scene, pin: PinId) -> PinSlot {
1945        scene
1946            .drawing()
1947            .held_pin(pin)
1948            .expect("the pin is in the document")
1949            .slot
1950    }
1951
1952    /// The highest slot the block `n` can hold — the clamp every pin move
1953    /// is bounded by.
1954    fn capacity(scene: &mut Scene, block: BlockId) -> u32 {
1955        slot_capacity(
1956            scene
1957                .drawing()
1958                .held_block(block)
1959                .expect("the block is in the document")
1960                .rect
1961                .size
1962                .h,
1963        )
1964    }
1965
1966    #[test]
1967    fn add_port_auto_named_adds_a_boundary_port() {
1968        // Both doors onto a boundary port — a click with the tool and a cell
1969        // dropped on the canvas — do nothing but call this, so it must
1970        // actually produce a port on the current scope.
1971        let mut scene = Scene::new(vec![]);
1972        assert!(
1973            scene.drawing().ports_layer().next().is_none(),
1974            "precondition: the root scope starts with no ports"
1975        );
1976
1977        let rect = Rect::from_min_size(pos2(10.0, 10.0), vec2(4.0 * GRID_SIZE, GRID_SIZE));
1978        let id = scene.commit(|drawing| drawing.add_port_auto_named(rect));
1979
1980        let ports: Vec<PinId> = scene.drawing().ports_layer().map(|(_, _)| id).collect();
1981        assert_eq!(ports, vec![id], "exactly the stamped port is drawn");
1982        assert_eq!(
1983            scene
1984                .drawing()
1985                .held_pin(id)
1986                .expect("the port was inserted")
1987                .name,
1988            "Port 1",
1989        );
1990    }
1991
1992    /// Editing a pin's tag is a register write: the pin keeps its id, so the
1993    /// wires anchored to it stay anchored.
1994    #[test]
1995    fn editing_a_tag_keeps_the_route_anchors() {
1996        let (a, b, rid) = (pin_id(3), pin_id(4), route_id(5));
1997        let mut scene = Scene::new(vec![
1998            fx::block(1, 0.0),
1999            fx::block(2, 80.0),
2000            fx::pin(3, 1, DocPinSide::East, 0),
2001            fx::pin(4, 2, DocPinSide::West, 0),
2002            fx::route(5, Scope::Root, 3, 4, &[]),
2003        ]);
2004
2005        scene.commit(|drawing| drawing.set_pin_tag(a, "A1"));
2006
2007        let drawing = scene.drawing();
2008        assert_eq!(drawing.held_pin(a).expect("the pin survives").tag, "A1");
2009        let wire = drawing.auto_route(rid).expect("the wire survives");
2010        assert_eq!((wire.route.from, wire.route.to), (a, b));
2011    }
2012
2013    #[test]
2014    fn deleting_a_block_drops_its_whole_subtree() {
2015        let (parent, grandchild) = (block_id(1), block_id(2));
2016        let mut scene = Scene::new(vec![
2017            fx::block(1, 0.0),
2018            fx::block_in(
2019                2,
2020                Scope::Block(parent),
2021                Rect::from_min_max(pos2(8.0, 8.0), pos2(48.0, 48.0)),
2022            ),
2023        ]);
2024        assert!(
2025            scene.drawing().block(parent).is_some()
2026                && scene.drawing().held_block(grandchild).is_some(),
2027            "precondition: both blocks are in the document"
2028        );
2029
2030        scene.commit(|drawing| drawing.delete(Deletable::Shape(ShapeId::Rect(parent))));
2031
2032        let drawing = scene.drawing();
2033        assert!(
2034            drawing.held_block(parent).is_none(),
2035            "deleted block is gone"
2036        );
2037        assert!(
2038            drawing.held_block(grandchild).is_none(),
2039            "descendant subtree is gone too (no orphans)"
2040        );
2041        assert!(
2042            drawing.child_blocks().is_empty(),
2043            "the block is unlinked from the scope that held it"
2044        );
2045    }
2046
2047    // Arrow-key pin moves: one slot per nudge, clamped at the block's top and
2048    // bottom slots.
2049    #[test]
2050    fn nudge_pins_shifts_one_slot_and_clamps_at_the_edges() {
2051        let (a, pa) = (block_id(1), pin_id(3));
2052        let mut scene = Scene::new(vec![tall_block(1), fx::pin(3, 1, DocPinSide::East, 1)]);
2053        let max = capacity(&mut scene, a);
2054        assert!(max >= 2, "the tall block must hold at least slots 0..=2");
2055
2056        assert_eq!(slot(&mut scene, pa).offset, 1);
2057        scene.commit(|d| d.nudge_pins(&[pa], 1));
2058        assert_eq!(slot(&mut scene, pa).offset, 2);
2059        scene.commit(|d| d.nudge_pins(&[pa], -1));
2060        assert_eq!(slot(&mut scene, pa).offset, 1);
2061        // Clamp at the top.
2062        scene.commit(|d| d.nudge_pins(&[pa], -1));
2063        assert_eq!(slot(&mut scene, pa).offset, 0);
2064        scene.commit(|d| d.nudge_pins(&[pa], -1));
2065        assert_eq!(slot(&mut scene, pa).offset, 0);
2066        // Clamp at the bottom.
2067        scene.commit(|d| d.nudge_pins(&[pa], max as i32 + 5));
2068        assert_eq!(slot(&mut scene, pa).offset, max);
2069        scene.commit(|d| d.nudge_pins(&[pa], 1));
2070        assert_eq!(slot(&mut scene, pa).offset, max);
2071    }
2072
2073    // A group of pins shifts rigidly, and is held in place when any member is
2074    // already at the boundary in the nudge direction.
2075    #[test]
2076    fn nudge_pins_moves_a_group_rigidly_and_stops_at_a_member_boundary() {
2077        let (p0, p1) = (pin_id(3), pin_id(4));
2078        let mut scene = Scene::new(vec![
2079            tall_block(1),
2080            fx::pin(3, 1, DocPinSide::East, 0),
2081            fx::pin(4, 1, DocPinSide::East, 1),
2082        ]);
2083
2084        // p0 is at the top, so the whole group can't move up.
2085        scene.commit(|d| d.nudge_pins(&[p0, p1], -1));
2086        assert_eq!(
2087            (slot(&mut scene, p0).offset, slot(&mut scene, p1).offset),
2088            (0, 1)
2089        );
2090        // Down by one: both shift together.
2091        scene.commit(|d| d.nudge_pins(&[p0, p1], 1));
2092        assert_eq!(
2093            (slot(&mut scene, p0).offset, slot(&mut scene, p1).offset),
2094            (1, 2)
2095        );
2096    }
2097
2098    // Deleting a multi-selection drops every shape in it.
2099    #[test]
2100    fn delete_shapes_removes_all_of_them() {
2101        let (a, b) = (block_id(1), block_id(2));
2102        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::block(2, 80.0)]);
2103
2104        scene.commit(|d| d.delete(Deletable::Shapes(vec![ShapeId::Rect(a), ShapeId::Rect(b)])));
2105
2106        let drawing = scene.drawing();
2107        assert!(drawing.block(a).is_none());
2108        assert!(drawing.block(b).is_none());
2109    }
2110
2111    #[test]
2112    fn delete_pins_drops_the_pins_and_their_routes() {
2113        let (pa, pb, rid) = (pin_id(3), pin_id(4), route_id(5));
2114        let mut scene = Scene::new(vec![
2115            fx::block(1, 0.0),
2116            fx::block(2, 120.0),
2117            fx::pin(3, 1, DocPinSide::East, 0),
2118            fx::pin(4, 2, DocPinSide::West, 0),
2119            fx::route(5, Scope::Root, 3, 4, &[]),
2120        ]);
2121        assert!(
2122            scene.drawing().auto_route(rid).is_some(),
2123            "precondition: the wire is in this scope"
2124        );
2125
2126        scene.commit(|d| d.delete(Deletable::Pins(vec![pa])));
2127
2128        let drawing = scene.drawing();
2129        assert!(drawing.held_pin(pa).is_none(), "the pin is gone");
2130        assert!(drawing.auto_route(rid).is_none(), "and the wire with it");
2131        assert!(drawing.held_pin(pb).is_some(), "the far endpoint survives");
2132    }
2133
2134    #[test]
2135    fn relocate_pins_moves_a_free_group_but_rejects_a_collision() {
2136        // A tall block so several pin slots fit.
2137        let (p0, p1, blocker) = (pin_id(3), pin_id(4), pin_id(5));
2138        let mut scene = Scene::new(vec![
2139            fx::block_in(
2140                1,
2141                Scope::Root,
2142                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 240.0)),
2143            ),
2144            fx::pin(3, 1, DocPinSide::East, 0),
2145            fx::pin(4, 1, DocPinSide::East, 1),
2146            fx::pin(5, 1, DocPinSide::East, 4),
2147        ]);
2148        let to = |pin, offset| PinMove {
2149            pin,
2150            to: PinSlot {
2151                side: DocPinSide::East,
2152                offset,
2153            },
2154        };
2155
2156        // Shift the group {slot0, slot1} down by two slots → {2, 3}: free.
2157        let ok = [to(p0, 2), to(p1, 3)];
2158        assert!(scene.drawing().can_relocate_pins(&ok));
2159        assert!(scene.commit(|d| d.relocate_pins(&ok)));
2160        assert_eq!(slot(&mut scene, p0).offset, 2);
2161        assert_eq!(slot(&mut scene, p1).offset, 3);
2162
2163        // Now shifting onto the blocker's slot (4) collides → rejected, no change.
2164        let bad = [to(p0, 3), to(p1, 4)];
2165        assert!(!scene.drawing().can_relocate_pins(&bad));
2166        assert!(!scene.commit(|d| d.relocate_pins(&bad)));
2167        assert_eq!(slot(&mut scene, p1).offset, 3);
2168        assert_eq!(slot(&mut scene, blocker).offset, 4);
2169    }
2170
2171    // Vertical flip mirrors each pin's slot about the block center (offset →
2172    // h - offset), so a top pin lands at the bottom; a double flip is identity.
2173    #[test]
2174    fn flip_block_vertical_mirrors_pin_slots_and_is_an_involution() {
2175        let (a, top, mid, low) = (block_id(1), pin_id(3), pin_id(4), pin_id(5));
2176        let mut scene = Scene::new(vec![
2177            tall_block(1),
2178            fx::pin(3, 1, DocPinSide::East, 0),
2179            fx::pin(4, 1, DocPinSide::West, 1),
2180            fx::pin(5, 1, DocPinSide::East, 2),
2181        ]);
2182        let h = capacity(&mut scene, a);
2183        assert!(h >= 2, "the tall block must hold at least slots 0..=2");
2184        let before = (
2185            slot(&mut scene, top),
2186            slot(&mut scene, mid),
2187            slot(&mut scene, low),
2188        );
2189
2190        scene.commit(|d| d.flip_block_vertical(a));
2191
2192        // The top pin lands at the bottom slot; each pin keeps its side.
2193        assert_eq!(slot(&mut scene, top).offset, h);
2194        assert_eq!(slot(&mut scene, mid).offset, h - 1);
2195        assert_eq!(slot(&mut scene, low).offset, h - 2);
2196        assert_eq!(slot(&mut scene, top).side, before.0.side);
2197        assert_eq!(slot(&mut scene, mid).side, before.1.side);
2198        assert_eq!(slot(&mut scene, low).side, before.2.side);
2199
2200        // Flipping again restores the original layout exactly.
2201        scene.commit(|d| d.flip_block_vertical(a));
2202        assert_eq!(slot(&mut scene, top).offset, before.0.offset);
2203        assert_eq!(slot(&mut scene, mid).offset, before.1.offset);
2204        assert_eq!(slot(&mut scene, low).offset, before.2.offset);
2205    }
2206
2207    // ── Locked blocks ─────────────────────────────────────────────────────────
2208
2209    #[test]
2210    fn pin_owner_locked_reads_the_pins_owning_block() {
2211        let mut scene = Scene::new(vec![
2212            fx::block(1, 0.0),
2213            fx::block(2, 80.0),
2214            fx::pin(3, 1, DocPinSide::East, 0),
2215            fx::pin(4, 2, DocPinSide::East, 0),
2216            fx::locked(1),
2217        ]);
2218        let drawing = scene.drawing();
2219        assert!(drawing.pin_owner_locked(pin_id(3)));
2220        assert!(!drawing.pin_owner_locked(pin_id(4)));
2221        // A pin the document does not hold cannot be frozen by anything.
2222        assert!(!drawing.pin_owner_locked(pin_id(9)));
2223    }
2224
2225    #[test]
2226    fn shape_owner_locked_covers_blocks_and_ports() {
2227        // Descend into `c` so its own pin renders as a port and the current
2228        // block (`c`) governs the port lock.
2229        let (c, g, port) = (block_id(1), block_id(2), pin_id(3));
2230        let mut scene = Scene::new(vec![
2231            fx::block(1, 0.0),
2232            fx::block_in(
2233                2,
2234                Scope::Block(c),
2235                Rect::from_min_max(pos2(60.0, 0.0), pos2(100.0, 40.0)),
2236            ),
2237            fx::pin_at(
2238                3,
2239                Scope::Block(c),
2240                "io",
2241                fx::slot(DocPinSide::West, 0),
2242                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 16.0)),
2243            ),
2244            fx::locked(1),
2245        ])
2246        .inside(c);
2247        let drawing = scene.drawing();
2248        // The current block `c` is locked, so its port reports locked.
2249        assert!(drawing.shape_owner_locked(ShapeId::Port(port)));
2250        assert!(drawing.current_locked());
2251        // The child block `g` is unlocked.
2252        assert!(!drawing.shape_owner_locked(ShapeId::Rect(g)));
2253    }
2254
2255    /// The document root is a scope without a block entity behind it, so
2256    /// there is nothing to freeze — its ports are always editable.
2257    #[test]
2258    fn the_root_scope_is_never_locked() {
2259        let mut scene = Scene::new(vec![fx::pin_at(
2260            3,
2261            Scope::Root,
2262            "io",
2263            fx::slot(DocPinSide::West, 0),
2264            Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 16.0)),
2265        )]);
2266        let drawing = scene.drawing();
2267        assert!(
2268            drawing.current().is_none(),
2269            "the root has no block entity, or this proves nothing"
2270        );
2271        assert!(!drawing.current_locked());
2272        assert!(!drawing.shape_owner_locked(ShapeId::Port(pin_id(3))));
2273    }
2274
2275    #[test]
2276    fn delete_pin_is_a_no_op_on_a_locked_block() {
2277        let pa = pin_id(3);
2278        let mut scene = Scene::new(vec![
2279            fx::block(1, 0.0),
2280            fx::pin(3, 1, DocPinSide::East, 0),
2281            fx::locked(1),
2282        ]);
2283        let before = scene.doc.stamp();
2284
2285        scene.commit(|d| d.delete(Deletable::Pins(vec![pa])));
2286
2287        assert_eq!(
2288            scene.doc.stamp(),
2289            before,
2290            "a refused delete authors nothing"
2291        );
2292        assert!(
2293            scene.drawing().held_pin(pa).is_some(),
2294            "locked block keeps its pin"
2295        );
2296    }
2297
2298    #[test]
2299    fn delete_port_is_a_no_op_on_a_locked_block() {
2300        // A port lives on the current scope's own boundary; lock that block and
2301        // delete one of its ports from inside.
2302        let (c, pc) = (block_id(1), pin_id(3));
2303        let mut scene = Scene::new(vec![
2304            fx::block(1, 0.0),
2305            fx::pin_at(
2306                3,
2307                Scope::Block(c),
2308                "io",
2309                fx::slot(DocPinSide::West, 0),
2310                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 16.0)),
2311            ),
2312            fx::locked(1),
2313        ])
2314        .inside(c);
2315        let before = scene.doc.stamp();
2316
2317        scene.commit(|d| d.delete(Deletable::Shape(ShapeId::Port(pc))));
2318
2319        assert_eq!(
2320            scene.doc.stamp(),
2321            before,
2322            "a refused delete authors nothing"
2323        );
2324        assert!(
2325            scene.drawing().shape(ShapeId::Port(pc)).is_some(),
2326            "locked block keeps its port"
2327        );
2328    }
2329
2330    #[test]
2331    fn set_block_locked_flips_the_flag() {
2332        let a = block_id(1);
2333        let mut scene = Scene::new(vec![fx::block(1, 0.0)]);
2334        let locked = |scene: &mut Scene| {
2335            scene
2336                .drawing()
2337                .held_block(a)
2338                .expect("the block is in the document")
2339                .locked
2340        };
2341        assert!(!locked(&mut scene));
2342
2343        scene.commit(|d| d.set_block_locked(a, InterfaceLock::Locked));
2344        assert!(locked(&mut scene));
2345        scene.commit(|d| d.set_block_locked(a, InterfaceLock::Unlocked));
2346        assert!(!locked(&mut scene));
2347    }
2348
2349    /// The lock's scope: a frozen interface is about what a pin *is*, not
2350    /// where it sits, so a locked block still repositions its pins.
2351    #[test]
2352    fn a_locked_block_still_repositions_its_pins() {
2353        let pa = pin_id(3);
2354        let mut scene = Scene::new(vec![
2355            tall_block(1),
2356            fx::pin(3, 1, DocPinSide::East, 0),
2357            fx::locked(1),
2358        ]);
2359        assert_eq!(slot(&mut scene, pa).offset, 0);
2360        assert!(scene.drawing().pin_owner_locked(pa), "precondition: frozen");
2361
2362        scene.commit(|d| {
2363            d.move_pin_snapped(
2364                pa,
2365                PinSlot {
2366                    side: DocPinSide::East,
2367                    offset: 1,
2368                },
2369            );
2370        });
2371
2372        assert_eq!(
2373            slot(&mut scene, pa).offset,
2374            1,
2375            "moving a pin along its block is presentation, which a lock allows",
2376        );
2377    }
2378
2379    /// And the half the lock does keep: a frozen block refuses the edit that
2380    /// changes what its pin carries.
2381    #[test]
2382    fn a_locked_block_refuses_its_pins_direction() {
2383        let pa = pin_id(3);
2384        let mut scene = Scene::new(vec![
2385            tall_block(1),
2386            fx::pin(3, 1, DocPinSide::East, 0),
2387            fx::locked(1),
2388        ]);
2389        assert!(scene.drawing().pin_owner_locked(pa), "precondition: frozen");
2390        let before = scene.doc.stamp();
2391
2392        scene.commit(|d| d.cycle_pin_kind(pa));
2393
2394        assert_eq!(
2395            scene.doc.stamp(),
2396            before,
2397            "I/O direction is what the pin means, and a lock protects it",
2398        );
2399    }
2400}