Skip to main content

bloq_graph/
block.rs

1//! Block, pipe, and moving-block geometry primitives.
2
3use std::cmp::Ordering;
4
5use bloq_utils::{Basis, Direction, PauliBasis, PortRole, RGBA, UDirection};
6use glam::{IVec2, IVec3};
7use smallvec::{SmallVec, smallvec};
8use strum::{Display, EnumString};
9
10use crate::{BlockGraphError, CubeHeight};
11
12/// Reserved/connectable cell offsets; ordinary single-cell blocks stay on the
13/// stack so hot placement and validation loops do not allocate.
14pub(crate) type Offsets = SmallVec<[IVec3; 2]>;
15
16/// Errors produced when constructing or converting block kinds.
17#[derive(Debug, Clone, thiserror::Error)]
18#[non_exhaustive]
19pub enum BlockError {
20    /// The three axis bases do not encode a supported cube orientation.
21    #[error("invalid bases for CubeKind: {0:?}")]
22    InvalidCubeBases([Basis; 3]),
23    /// A spatial cube orientation was used as a walking boundary.
24    #[error("spatial cube kind {0} is not a valid Walking boundary kind")]
25    SpatialCubeNotWalkingBoundary(CubeKind),
26    /// A walking displacement is zero or leaves the adjacent 3-by-3 plane.
27    #[error(
28        "invalid Walking movement vector {0}; expected one of (+/-1,0), (0,+/-1), or (+/-1,+/-1)"
29    )]
30    InvalidWalkingMovement(IVec2),
31    /// A patch-rotation displacement is not an axis-aligned unit step.
32    #[error("invalid PatchRotation movement vector {0}; expected one of (+/-1,0) or (0,+/-1)")]
33    InvalidPatchRotationMovement(IVec2),
34    /// Text does not name a supported block kind.
35    #[error("invalid BlockKind: {0}")]
36    InvalidBlockKind(String),
37    /// A cube height was assigned to a non-cube block.
38    #[error("cube height can only be set on cube blocks, got {0}")]
39    CubeHeightOnNonCube(BlockKind),
40    /// A display color was assigned to a non-port block.
41    #[error("port color can only be set on Port blocks, got {0}")]
42    PortColorOnNonPort(BlockKind),
43    /// A port role was assigned to a non-port block.
44    #[error("port role can only be set on Port blocks, got {0}")]
45    PortRoleOnNonPort(BlockKind),
46    /// A symbolic cube height is zero or negative.
47    #[error("cube height {numerator}d/{denominator} is not positive")]
48    NonPositiveCubeHeight {
49        /// Unsigned magnitude of the invalid height numerator.
50        numerator: u32,
51        /// Denominator of the invalid height.
52        denominator: u32,
53    },
54    /// A cube height needs more cells than the graph format supports.
55    #[error("cube height occupies {cells} cells, exceeding supported maximum 65535")]
56    CubeHeightExceedsRange {
57        /// Required number of time-axis cells.
58        cells: u32,
59    },
60    /// A cube footprint extends beyond the coordinate range.
61    #[error("cube at z={z} occupying {cells} cells exceeds the graph coordinate range")]
62    CubeHeightCoordinateOverflow {
63        /// Cube's starting time coordinate.
64        z: i32,
65        /// Number of cells in the cube footprint.
66        cells: u32,
67    },
68    /// Text does not encode a valid symbolic cube height.
69    #[error("invalid cube height expression `{0}`; expected `k*d + n`, e.g. `d`, `2d`, `3d/2-1`")]
70    InvalidCubeHeight(String),
71}
72
73/// A cube block's boundary orientation, named by the Pauli basis (`X` or `Z`)
74/// assigned to each of the three axes in order.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumString, Display)]
76pub enum CubeKind {
77    /// X boundary on x; Z boundaries on y and z.
78    XZZ,
79    /// Z boundary on x; X boundary on y; Z boundary on z.
80    ZXZ,
81    /// Z boundaries on x and y; X boundary on z.
82    ZZX,
83    /// Z boundary on x; X boundaries on y and z.
84    ZXX,
85    /// X boundaries on x and z; Z boundary on y.
86    XZX,
87    /// X boundaries on x and y; Z boundary on z.
88    XXZ,
89}
90
91impl CubeKind {
92    /// Returns the per-axis boundary bases in `[x, y, z]` order.
93    pub const fn bases(&self) -> [Basis; 3] {
94        match self {
95            CubeKind::XZZ => [Basis::X, Basis::Z, Basis::Z],
96            CubeKind::ZXZ => [Basis::Z, Basis::X, Basis::Z],
97            CubeKind::ZZX => [Basis::Z, Basis::Z, Basis::X],
98            CubeKind::ZXX => [Basis::Z, Basis::X, Basis::X],
99            CubeKind::XZX => [Basis::X, Basis::Z, Basis::X],
100            CubeKind::XXZ => [Basis::X, Basis::X, Basis::Z],
101        }
102    }
103
104    /// Returns the axis whose basis differs from the other two (the correlation
105    /// surface normal).
106    pub const fn normal_direction(&self) -> UDirection {
107        match self {
108            CubeKind::XZZ | CubeKind::ZXX => UDirection::X,
109            CubeKind::ZXZ | CubeKind::XZX => UDirection::Y,
110            CubeKind::ZZX | CubeKind::XXZ => UDirection::Z,
111        }
112    }
113
114    /// Returns the basis on the [`normal_direction`](Self::normal_direction) axis.
115    pub const fn normal_basis(&self) -> Basis {
116        self.bases()[self.normal_direction().index()]
117    }
118
119    /// Returns whether the normal-axis boundary is X-type.
120    pub const fn is_x_type(&self) -> bool {
121        matches!(self.normal_basis(), Basis::X)
122    }
123
124    /// Returns the boundary basis on the x axis.
125    pub const fn x(&self) -> Basis {
126        self.bases()[0]
127    }
128
129    /// Returns the boundary basis on the y axis.
130    pub const fn y(&self) -> Basis {
131        self.bases()[1]
132    }
133
134    /// Returns the boundary basis on the z axis.
135    pub const fn z(&self) -> Basis {
136        self.bases()[2]
137    }
138
139    /// Returns whether the cube's normal lies along the time (`Z`) axis, making
140    /// it a spatial rather than temporal boundary.
141    pub const fn is_spatial(&self) -> bool {
142        matches!(self, CubeKind::ZZX | CubeKind::XXZ)
143    }
144}
145
146impl TryFrom<[Basis; 3]> for CubeKind {
147    type Error = BlockError;
148
149    fn try_from(bases: [Basis; 3]) -> Result<Self, Self::Error> {
150        match bases {
151            [Basis::X, Basis::Z, Basis::Z] => Ok(CubeKind::XZZ),
152            [Basis::Z, Basis::X, Basis::Z] => Ok(CubeKind::ZXZ),
153            [Basis::Z, Basis::Z, Basis::X] => Ok(CubeKind::ZZX),
154            [Basis::Z, Basis::X, Basis::X] => Ok(CubeKind::ZXX),
155            [Basis::X, Basis::Z, Basis::X] => Ok(CubeKind::XZX),
156            [Basis::X, Basis::X, Basis::Z] => Ok(CubeKind::XXZ),
157            _ => Err(BlockError::InvalidCubeBases(bases)),
158        }
159    }
160}
161
162/// Boundary orientation of a walking block, restricted to the four temporal
163/// (non-spatial) cube orientations.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumString, Display)]
165pub enum WalkingBoundaryKind {
166    /// X boundary on x; Z boundaries on y and z.
167    XZZ,
168    /// Z boundary on x; X boundary on y; Z boundary on z.
169    ZXZ,
170    /// Z boundary on x; X boundaries on y and z.
171    ZXX,
172    /// X boundaries on x and z; Z boundary on y.
173    XZX,
174}
175
176impl WalkingBoundaryKind {
177    /// All four boundary kinds.
178    pub const ALL: [Self; 4] = [Self::ZXZ, Self::ZXX, Self::XZZ, Self::XZX];
179
180    /// Returns the per-axis boundary bases in `[x, y, z]` order.
181    ///
182    /// Delegates to the equivalent [`CubeKind`] rather than re-tabulating the
183    /// rows (not `const`: `From` cannot be called in a const context).
184    pub fn bases(&self) -> [Basis; 3] {
185        CubeKind::from(*self).bases()
186    }
187
188    /// Returns the boundary basis on the x axis.
189    pub fn x(&self) -> Basis {
190        self.bases()[0]
191    }
192
193    /// Returns the boundary basis on the y axis.
194    pub fn y(&self) -> Basis {
195        self.bases()[1]
196    }
197
198    /// Returns the boundary basis on the z axis.
199    pub fn z(&self) -> Basis {
200        self.bases()[2]
201    }
202
203    /// Returns the kind with every `X` and `Z` boundary basis swapped.
204    pub const fn flip_xz_basis(&self) -> Self {
205        match self {
206            WalkingBoundaryKind::XZZ => WalkingBoundaryKind::ZXX,
207            WalkingBoundaryKind::ZXZ => WalkingBoundaryKind::XZX,
208            WalkingBoundaryKind::ZXX => WalkingBoundaryKind::XZZ,
209            WalkingBoundaryKind::XZX => WalkingBoundaryKind::ZXZ,
210        }
211    }
212}
213
214impl TryFrom<CubeKind> for WalkingBoundaryKind {
215    type Error = BlockError;
216
217    fn try_from(kind: CubeKind) -> Result<Self, Self::Error> {
218        match kind {
219            CubeKind::XZZ => Ok(Self::XZZ),
220            CubeKind::ZXZ => Ok(Self::ZXZ),
221            CubeKind::ZXX => Ok(Self::ZXX),
222            CubeKind::XZX => Ok(Self::XZX),
223            CubeKind::ZZX | CubeKind::XXZ => Err(BlockError::SpatialCubeNotWalkingBoundary(kind)),
224        }
225    }
226}
227
228impl From<WalkingBoundaryKind> for CubeKind {
229    fn from(kind: WalkingBoundaryKind) -> Self {
230        match kind {
231            WalkingBoundaryKind::XZZ => CubeKind::XZZ,
232            WalkingBoundaryKind::ZXZ => CubeKind::ZXZ,
233            WalkingBoundaryKind::ZXX => CubeKind::ZXX,
234            WalkingBoundaryKind::XZX => CubeKind::XZX,
235        }
236    }
237}
238
239/// A walking block: a boundary orientation plus a diagonal in-plane movement
240/// that advances one time layer while shifting the patch by `movement`.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242pub struct WalkingKind {
243    boundary: WalkingBoundaryKind,
244    movement: IVec2,
245}
246
247impl WalkingKind {
248    /// The default `ZXZ` boundary moving one cell in `+x`.
249    pub const DEFAULT: Self = Self {
250        boundary: WalkingBoundaryKind::ZXZ,
251        movement: IVec2::new(1, 0),
252    };
253
254    /// Constructs a walking kind, validating the movement vector.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`BlockError::InvalidWalkingMovement`] unless each component of
259    /// `movement` is in `-1..=1` and the vector is nonzero.
260    pub fn new(boundary: WalkingBoundaryKind, movement: IVec2) -> Result<Self, BlockError> {
261        if !Self::is_valid_movement(movement) {
262            return Err(BlockError::InvalidWalkingMovement(movement));
263        }
264        Ok(Self { boundary, movement })
265    }
266
267    /// Returns the walking patch's boundary orientation.
268    pub const fn boundary(self) -> WalkingBoundaryKind {
269        self.boundary
270    }
271
272    /// Returns the in-plane displacement per time layer.
273    pub const fn movement(self) -> IVec2 {
274        self.movement
275    }
276
277    /// Returns the movement as a 3D vector with `+1` on the time axis.
278    pub const fn movement_3d(self) -> IVec3 {
279        movement_3d(self.movement)
280    }
281
282    /// Returns a copy with a different boundary orientation.
283    pub const fn with_boundary(self, boundary: WalkingBoundaryKind) -> Self {
284        Self {
285            boundary,
286            movement: self.movement,
287        }
288    }
289
290    /// Returns the end cell reached from `start`.
291    ///
292    /// # Panics
293    ///
294    /// Panics if the endpoint exceeds the coordinate range. Use
295    /// [`try_end_position`](Self::try_end_position) for untrusted positions.
296    pub fn end_position(self, start: IVec3) -> IVec3 {
297        self.try_end_position(start)
298            .expect("walking endpoint must fit in the i32 coordinate range")
299    }
300
301    /// Returns the end cell reached from `start`.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`BlockGraphError::CoordinateOverflow`] if the endpoint exceeds
306    /// the coordinate range.
307    pub fn try_end_position(self, start: IVec3) -> Result<IVec3, BlockGraphError> {
308        crate::checked_add_position(start, self.movement_3d())
309    }
310
311    /// Returns the two exposed connection endpoints (start and end) as offsets.
312    pub fn connectable_offsets(self) -> [IVec3; 2] {
313        movement_connectable_offsets(self.movement)
314    }
315
316    /// Returns every cell the block reserves, as offsets from its position.
317    pub fn reserved_offsets(self) -> Vec<IVec3> {
318        movement_footprint(self.movement)
319    }
320
321    const fn is_valid_movement(movement: IVec2) -> bool {
322        let x_ok = movement.x == -1 || movement.x == 0 || movement.x == 1;
323        let y_ok = movement.y == -1 || movement.y == 0 || movement.y == 1;
324        let nonzero = movement.x != 0 || movement.y != 0;
325        x_ok && y_ok && nonzero
326    }
327}
328
329/// Orders two movement steps componentwise. `IVec2` has no `Ord`, which is why
330/// both moving-block kinds hand-write `Ord` instead of deriving it.
331fn cmp_movement(left: IVec2, right: IVec2) -> Ordering {
332    left.x.cmp(&right.x).then_with(|| left.y.cmp(&right.y))
333}
334
335/// A moving block's step as a 3-D vector: the `movement` offset plus the one
336/// time layer it always spans.
337const fn movement_3d(movement: IVec2) -> IVec3 {
338    IVec3::new(movement.x, movement.y, 1)
339}
340
341/// The start and end cells of a moving block, as offsets from its position.
342const fn movement_connectable_offsets(movement: IVec2) -> [IVec3; 2] {
343    [IVec3::ZERO, movement_3d(movement)]
344}
345
346/// Reserved cells for a two-layer moving block: the axis-aligned bounding box
347/// of the start and end footprints across both time layers.
348fn movement_footprint(movement: IVec2) -> Vec<IVec3> {
349    let x_min = 0.min(movement.x);
350    let x_max = 0.max(movement.x);
351    let y_min = 0.min(movement.y);
352    let y_max = 0.max(movement.y);
353    let mut offsets = Vec::new();
354    for x in x_min..=x_max {
355        for y in y_min..=y_max {
356            for z in 0..=1 {
357                offsets.push(IVec3::new(x, y, z));
358            }
359        }
360    }
361    offsets
362}
363
364impl Default for WalkingKind {
365    fn default() -> Self {
366        Self::DEFAULT
367    }
368}
369
370impl PartialOrd for WalkingKind {
371    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
372        Some(self.cmp(other))
373    }
374}
375
376impl Ord for WalkingKind {
377    fn cmp(&self, other: &Self) -> Ordering {
378        self.boundary
379            .cmp(&other.boundary)
380            .then_with(|| cmp_movement(self.movement, other.movement))
381    }
382}
383
384/// A patch-rotation block: rotates a surface code patch by 90 degrees over one
385/// time layer, translating it by an axis-aligned `movement` step.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
387pub struct PatchRotationKind {
388    basis: Basis,
389    movement: IVec2,
390}
391
392impl PatchRotationKind {
393    /// The default `X`-basis rotation moving one cell in `+x`.
394    pub const DEFAULT: Self = Self {
395        basis: Basis::X,
396        movement: IVec2::new(1, 0),
397    };
398
399    /// Constructs a patch-rotation kind, validating the movement vector.
400    ///
401    /// # Errors
402    ///
403    /// Returns [`BlockError::InvalidPatchRotationMovement`] unless `movement` is
404    /// a unit step along exactly one of the `x` or `y` axes.
405    pub fn new(basis: Basis, movement: IVec2) -> Result<Self, BlockError> {
406        if !Self::is_valid_movement(movement) {
407            return Err(BlockError::InvalidPatchRotationMovement(movement));
408        }
409        Ok(Self { basis, movement })
410    }
411
412    /// Returns the patch's starting boundary basis.
413    pub const fn basis(self) -> Basis {
414        self.basis
415    }
416
417    /// Returns the boundary basis on the `x` axis, flipped when the block moves
418    /// along `x` (the rotation swaps the two boundary orientations).
419    pub const fn x_axis_boundary_basis(self) -> Basis {
420        if self.movement.x != 0 {
421            self.basis.flip()
422        } else {
423            self.basis
424        }
425    }
426
427    /// Returns the axis-aligned in-plane displacement.
428    pub const fn movement(self) -> IVec2 {
429        self.movement
430    }
431
432    /// Returns the movement as a 3D vector with `+1` on the time axis.
433    pub const fn movement_3d(self) -> IVec3 {
434        movement_3d(self.movement)
435    }
436
437    /// Returns a copy with a different basis.
438    pub const fn with_basis(self, basis: Basis) -> Self {
439        Self {
440            basis,
441            movement: self.movement,
442        }
443    }
444
445    /// Returns the end cell reached from `start`.
446    ///
447    /// # Panics
448    ///
449    /// Panics if the endpoint exceeds the coordinate range. Use
450    /// [`try_end_position`](Self::try_end_position) for untrusted positions.
451    pub fn end_position(self, start: IVec3) -> IVec3 {
452        self.try_end_position(start)
453            .expect("patch-rotation endpoint must fit in the i32 coordinate range")
454    }
455
456    /// Returns the end cell reached from `start`.
457    ///
458    /// # Errors
459    ///
460    /// Returns [`BlockGraphError::CoordinateOverflow`] if the endpoint exceeds
461    /// the coordinate range.
462    pub fn try_end_position(self, start: IVec3) -> Result<IVec3, BlockGraphError> {
463        crate::checked_add_position(start, self.movement_3d())
464    }
465
466    /// Returns the two exposed connection endpoints (start and end) as offsets.
467    pub fn connectable_offsets(self) -> [IVec3; 2] {
468        movement_connectable_offsets(self.movement)
469    }
470
471    /// Returns every cell the block reserves, as offsets from its position.
472    pub fn reserved_offsets(self) -> Vec<IVec3> {
473        movement_footprint(self.movement)
474    }
475
476    /// Returns the boundary bases exposed at `endpoint`, or `None` if `endpoint`
477    /// is neither the start nor end cell. The bases flip between the two ends.
478    pub fn bases_at_endpoint(self, start: IVec3, endpoint: IVec3) -> Option<[Basis; 3]> {
479        let basis = self.x_axis_boundary_basis();
480        let x_basis = if endpoint == start {
481            basis
482        } else if self
483            .try_end_position(start)
484            .is_ok_and(|end| endpoint == end)
485        {
486            basis.flip()
487        } else {
488            return None;
489        };
490        Some([x_basis, x_basis.flip(), x_basis])
491    }
492
493    /// Returns the bases on the pipe face at `endpoint`, or `None` if `endpoint`
494    /// is neither the start nor end cell.
495    ///
496    /// Unlike [`bases_at_endpoint`](Self::bases_at_endpoint), the pipe face uses
497    /// the block's construction `basis` directly rather than the `x`-axis
498    /// boundary basis.
499    pub fn pipe_face_bases_at_endpoint(self, start: IVec3, endpoint: IVec3) -> Option<[Basis; 3]> {
500        if endpoint == start {
501            Some([self.basis, self.basis.flip(), self.basis])
502        } else if self
503            .try_end_position(start)
504            .is_ok_and(|end| endpoint == end)
505        {
506            let basis = self.basis.flip();
507            Some([basis, basis.flip(), basis])
508        } else {
509            None
510        }
511    }
512
513    const fn is_valid_movement(movement: IVec2) -> bool {
514        let is_x_step = (movement.x == -1 || movement.x == 1) && movement.y == 0;
515        let is_y_step = movement.x == 0 && (movement.y == -1 || movement.y == 1);
516        is_x_step || is_y_step
517    }
518}
519
520impl Default for PatchRotationKind {
521    fn default() -> Self {
522        Self::DEFAULT
523    }
524}
525
526impl PartialOrd for PatchRotationKind {
527    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
528        Some(self.cmp(other))
529    }
530}
531
532impl Ord for PatchRotationKind {
533    fn cmp(&self, other: &Self) -> Ordering {
534        self.basis
535            .cmp(&other.basis)
536            .then_with(|| cmp_movement(self.movement, other.movement))
537    }
538}
539
540/// The two Pauli bases a selective block chooses between when resolved. The
541/// resolve condition being true selects the first named basis, false the second.
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Display, EnumString)]
543#[strum(
544    ascii_case_insensitive,
545    parse_err_ty = String,
546    parse_err_fn = invalid_selective_kind
547)]
548pub enum SelectiveKind {
549    /// Select between X when true and Y when false.
550    XY,
551    /// Select between X when true and Z when false.
552    XZ,
553    /// Select between Y when true and Z when false.
554    YZ,
555}
556
557impl SelectiveKind {
558    /// Returns the Pauli basis chosen when the resolve condition is true.
559    pub const fn pauli_if_true(&self) -> PauliBasis {
560        match self {
561            SelectiveKind::XY | SelectiveKind::XZ => PauliBasis::X,
562            SelectiveKind::YZ => PauliBasis::Y,
563        }
564    }
565
566    /// Returns the Pauli basis chosen when the resolve condition is false.
567    pub const fn pauli_if_false(&self) -> PauliBasis {
568        match self {
569            SelectiveKind::XY => PauliBasis::Y,
570            SelectiveKind::XZ | SelectiveKind::YZ => PauliBasis::Z,
571        }
572    }
573
574    /// Returns the kind with its `X` and `Z` roles swapped.
575    pub const fn flip_xz_basis(&self) -> Self {
576        match self {
577            SelectiveKind::XY => SelectiveKind::YZ,
578            SelectiveKind::XZ => SelectiveKind::XZ,
579            SelectiveKind::YZ => SelectiveKind::XY,
580        }
581    }
582}
583
584/// The kind of a [`Block`], determining its surface code semantics and lattice
585/// footprint.
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Display, Default)]
587pub enum BlockKind {
588    /// A single-cell cube with a fixed boundary orientation.
589    #[strum(to_string = "{0}")]
590    Cube(CubeKind),
591    /// A patch that walks diagonally by one cell over one time layer.
592    #[strum(to_string = "Walking")]
593    Walking(WalkingKind),
594    /// A patch that rotates 90 degrees over one time layer.
595    #[strum(to_string = "PatchRotation")]
596    PatchRotation(PatchRotationKind),
597    /// A Y-basis state injection block.
598    Y,
599    /// A fixed terminal transversal measurement in the X or Z basis.
600    #[strum(to_string = "{0}")]
601    Measurement(Basis),
602    /// An open connection endpoint (graph input or output).
603    #[default]
604    Port,
605    /// A T-state (magic state) block, lowered via cultivation.
606    T,
607    /// A selective measurement/preparation block resolved at runtime.
608    #[strum(to_string = "{0}")]
609    Selective(SelectiveKind),
610}
611
612impl BlockKind {
613    /// Returns whether this is a cube block.
614    pub const fn is_cube(&self) -> bool {
615        matches!(self, BlockKind::Cube(_))
616    }
617
618    /// Returns whether this is a walking block.
619    pub const fn is_walking(&self) -> bool {
620        matches!(self, BlockKind::Walking(_))
621    }
622
623    /// Returns whether this is a patch-rotation block.
624    pub const fn is_patch_rotation(&self) -> bool {
625        matches!(self, BlockKind::PatchRotation(_))
626    }
627
628    /// Returns whether this is an open port.
629    pub const fn is_port(&self) -> bool {
630        matches!(self, BlockKind::Port)
631    }
632
633    /// Returns whether this is a Y-state block.
634    pub const fn is_y(&self) -> bool {
635        matches!(self, BlockKind::Y)
636    }
637
638    /// Returns whether this is a fixed terminal measurement.
639    pub const fn is_measurement(&self) -> bool {
640        matches!(self, BlockKind::Measurement(_))
641    }
642
643    /// Returns whether this is a T-state block.
644    pub const fn is_t(&self) -> bool {
645        matches!(self, BlockKind::T)
646    }
647
648    /// Returns whether this is a runtime-selective block.
649    pub const fn is_selective(&self) -> bool {
650        matches!(self, BlockKind::Selective(_))
651    }
652
653    /// Returns whether the block's behavior is resolved at runtime (selective or
654    /// T blocks) rather than fixed at compile time.
655    pub const fn is_dynamic(&self) -> bool {
656        matches!(self, BlockKind::Selective(_) | BlockKind::T)
657    }
658
659    /// Returns a copy with every `X` and `Z` boundary basis swapped.
660    ///
661    /// # Panics
662    ///
663    /// Panics if an internally stored cube kind violates its basis invariant.
664    pub fn flip_xz_basis(self) -> Self {
665        match self {
666            BlockKind::Cube(kind) => {
667                let bases = kind.bases().map(Basis::flip);
668                BlockKind::Cube(
669                    CubeKind::try_from(bases)
670                        .expect("flipping X/Z bases preserves cube kind validity"),
671                )
672            }
673            BlockKind::Walking(kind) => {
674                BlockKind::Walking(kind.with_boundary(kind.boundary().flip_xz_basis()))
675            }
676            BlockKind::PatchRotation(kind) => {
677                BlockKind::PatchRotation(kind.with_basis(kind.basis().flip()))
678            }
679            BlockKind::Measurement(basis) => BlockKind::Measurement(basis.flip()),
680            BlockKind::Selective(kind) => BlockKind::Selective(kind.flip_xz_basis()),
681            BlockKind::Y | BlockKind::Port | BlockKind::T => self,
682        }
683    }
684
685    /// Returns whether the block is Clifford (everything except the T block).
686    pub const fn is_clifford(&self) -> bool {
687        !self.is_t()
688    }
689
690    /// Returns one representative of every block kind, using default parameters
691    /// for the parameterized kinds.
692    pub const fn all_kinds() -> [BlockKind; 16] {
693        [
694            BlockKind::Cube(CubeKind::XZZ),
695            BlockKind::Cube(CubeKind::ZXZ),
696            BlockKind::Cube(CubeKind::ZZX),
697            BlockKind::Cube(CubeKind::ZXX),
698            BlockKind::Cube(CubeKind::XZX),
699            BlockKind::Cube(CubeKind::XXZ),
700            BlockKind::Y,
701            BlockKind::Measurement(Basis::X),
702            BlockKind::Measurement(Basis::Z),
703            BlockKind::T,
704            BlockKind::Port,
705            BlockKind::Walking(WalkingKind::DEFAULT),
706            BlockKind::PatchRotation(PatchRotationKind::DEFAULT),
707            BlockKind::Selective(SelectiveKind::XY),
708            BlockKind::Selective(SelectiveKind::XZ),
709            BlockKind::Selective(SelectiveKind::YZ),
710        ]
711    }
712
713    /// Returns the cells the kind reserves, as offsets from its position.
714    ///
715    /// Cube height is applied per-block by [`Block::reserved_offsets`], so the
716    /// single-cell kinds here reserve only the origin.
717    pub fn reserved_offsets(self) -> Offsets {
718        match self {
719            BlockKind::Walking(kind) => kind.reserved_offsets().into(),
720            BlockKind::PatchRotation(kind) => kind.reserved_offsets().into(),
721            _ => smallvec![IVec3::ZERO],
722        }
723    }
724
725    /// Returns whether two blocks may both reserve `overlap_pos`.
726    ///
727    /// Only walking blocks permit shared reserved cells, and never at a hard
728    /// endpoint of either block.
729    pub fn allows_reserved_overlap(
730        self,
731        self_pos: IVec3,
732        other: BlockKind,
733        other_pos: IVec3,
734        overlap_pos: IVec3,
735    ) -> bool {
736        match (self, other) {
737            (BlockKind::Walking(kind), BlockKind::Walking(other_kind)) => {
738                walking_walking_overlap_allowed(kind, self_pos, other_kind, other_pos, overlap_pos)
739            }
740            (BlockKind::Walking(kind), other) => {
741                walking_other_overlap_allowed(kind, self_pos, other, other_pos, overlap_pos)
742            }
743            (other, BlockKind::Walking(kind)) => {
744                walking_other_overlap_allowed(kind, other_pos, other, self_pos, overlap_pos)
745            }
746            _ => false,
747        }
748    }
749
750    /// Returns the cells that can host pipe connections, as offsets from the
751    /// block position.
752    pub fn connectable_offsets(self) -> Offsets {
753        match self {
754            BlockKind::Walking(kind) => kind.connectable_offsets().into(),
755            BlockKind::PatchRotation(kind) => kind.connectable_offsets().into(),
756            _ => smallvec![IVec3::ZERO],
757        }
758    }
759
760    /// Returns the block's per-axis boundary bases, or `None` for kinds without
761    /// a fixed boundary frame (`Y`, measurement, `Port`, `T`, and selective blocks).
762    pub fn bases(self) -> Option<[Basis; 3]> {
763        match self {
764            BlockKind::Cube(kind) => Some(kind.bases()),
765            BlockKind::Walking(kind) => Some(kind.boundary().bases()),
766            BlockKind::PatchRotation(kind) => kind.bases_at_endpoint(IVec3::ZERO, IVec3::ZERO),
767            BlockKind::Y
768            | BlockKind::Measurement(_)
769            | BlockKind::Port
770            | BlockKind::T
771            | BlockKind::Selective(_) => None,
772        }
773    }
774
775    /// Returns the boundary bases exposed at `endpoint`, or `None` if the block
776    /// exposes no connectable endpoint there.
777    pub fn bases_at_endpoint(self, block_pos: IVec3, endpoint: IVec3) -> Option<[Basis; 3]> {
778        match self {
779            BlockKind::Cube(kind) => (endpoint == block_pos).then_some(kind.bases()),
780            BlockKind::Walking(kind) => kind
781                .connectable_offsets()
782                .into_iter()
783                .filter_map(|offset| crate::checked_add_position(block_pos, offset).ok())
784                .any(|position| position == endpoint)
785                .then_some(kind.boundary().bases()),
786            BlockKind::PatchRotation(kind) => kind.bases_at_endpoint(block_pos, endpoint),
787            BlockKind::Y
788            | BlockKind::Measurement(_)
789            | BlockKind::Port
790            | BlockKind::T
791            | BlockKind::Selective(_) => None,
792        }
793    }
794
795    /// Returns the bases on the pipe face at `endpoint`.
796    ///
797    /// Matches [`bases_at_endpoint`](Self::bases_at_endpoint) except for patch
798    /// rotation, whose pipe face uses the construction basis directly.
799    pub fn pipe_face_bases_at_endpoint(
800        self,
801        block_pos: IVec3,
802        endpoint: IVec3,
803    ) -> Option<[Basis; 3]> {
804        match self {
805            BlockKind::PatchRotation(kind) => kind.pipe_face_bases_at_endpoint(block_pos, endpoint),
806            kind => kind.bases_at_endpoint(block_pos, endpoint),
807        }
808    }
809}
810
811fn walking_other_overlap_allowed(
812    kind: WalkingKind,
813    walking_pos: IVec3,
814    other: BlockKind,
815    other_pos: IVec3,
816    overlap_pos: IVec3,
817) -> bool {
818    let Ok(walking_end) = kind.try_end_position(walking_pos) else {
819        return false;
820    };
821    if overlap_pos == walking_pos || overlap_pos == walking_end {
822        return false;
823    }
824    match other {
825        BlockKind::Port => other_pos.z == walking_pos.z,
826        _ => false,
827    }
828}
829
830fn walking_walking_overlap_allowed(
831    kind: WalkingKind,
832    pos: IVec3,
833    other_kind: WalkingKind,
834    other_pos: IVec3,
835    overlap_pos: IVec3,
836) -> bool {
837    if kind.movement() != other_kind.movement() || pos.z != other_pos.z {
838        return false;
839    }
840    let (Ok(end), Ok(other_end)) = (
841        kind.try_end_position(pos),
842        other_kind.try_end_position(other_pos),
843    ) else {
844        return false;
845    };
846    let hard_endpoint = overlap_pos == pos || overlap_pos == end;
847    let other_hard_endpoint = overlap_pos == other_pos || overlap_pos == other_end;
848    !(hard_endpoint && other_hard_endpoint)
849}
850
851impl std::str::FromStr for BlockKind {
852    type Err = BlockError;
853
854    fn from_str(s: &str) -> Result<Self, Self::Err> {
855        let s = s.to_uppercase();
856        if let Ok(cube_kind) = s.parse::<CubeKind>() {
857            Ok(BlockKind::Cube(cube_kind))
858        } else if s == "Y" {
859            Ok(BlockKind::Y)
860        } else if s == "X" {
861            Ok(BlockKind::Measurement(Basis::X))
862        } else if s == "Z" {
863            Ok(BlockKind::Measurement(Basis::Z))
864        } else if s == "PORT" {
865            Ok(BlockKind::Port)
866        } else if s == "T" {
867            Ok(BlockKind::T)
868        } else if let Ok(selective_kind) = s.parse::<SelectiveKind>() {
869            Ok(BlockKind::Selective(selective_kind))
870        } else {
871            Err(BlockError::InvalidBlockKind(s))
872        }
873    }
874}
875
876fn invalid_selective_kind(value: &str) -> String {
877    format!("Invalid SelectiveKind: {}", value.to_uppercase())
878}
879
880/// Returns whether `tag` is a legal block/pipe tag.
881///
882/// Tags are non-empty and contain no whitespace, controls, `<`, or `>`.
883pub fn is_valid_tag(tag: &str) -> bool {
884    !tag.is_empty() && tag.chars().all(is_tag_char)
885}
886
887pub(crate) fn is_tag_char(c: char) -> bool {
888    !c.is_whitespace() && !c.is_control() && !matches!(c, '<' | '>')
889}
890
891/// A single block placed at a 3D lattice position in a [`BlockGraph`](crate::BlockGraph).
892///
893/// # Examples
894///
895/// ```
896/// use bloq_graph::{Block, BlockKind, CubeKind};
897/// use glam::IVec3;
898///
899/// let block = Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ)).with_tag("entry")?;
900/// assert_eq!(block.pos(), IVec3::ZERO);
901/// assert_eq!(block.kind(), BlockKind::Cube(CubeKind::ZXZ));
902/// assert_eq!(block.tag(), Some("entry"));
903/// # Ok::<(), bloq_graph::BlockGraphError>(())
904/// ```
905#[derive(Debug, Clone, PartialEq, Eq, Hash)]
906pub struct Block {
907    pub(crate) pos: IVec3,
908    pub(crate) kind: BlockKind,
909    pub(crate) height: CubeHeight,
910    pub(crate) port_color: Option<RGBA>,
911    pub(crate) port_role: PortRole,
912    pub(crate) tag: String,
913}
914
915impl std::fmt::Display for Block {
916    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
917        match self.kind {
918            BlockKind::Walking(kind) => {
919                write!(f, "walk {} {} ->", kind.boundary(), self.pos)?;
920                match kind.try_end_position(self.pos) {
921                    Ok(end) => write!(f, " {end}")?,
922                    Err(_) => write!(f, " [endpoint overflows i32]")?,
923                }
924            }
925            BlockKind::PatchRotation(kind) => {
926                write!(f, "rotate {} {} ->", kind.basis(), self.pos)?;
927                match kind.try_end_position(self.pos) {
928                    Ok(end) => write!(f, " {end}")?,
929                    Err(_) => write!(f, " [endpoint overflows i32]")?,
930                }
931            }
932            kind => {
933                write!(f, "{} {}", kind, self.pos)?;
934                if !self.height.is_default() && kind.is_cube() {
935                    write!(f, " height={}", self.height)?;
936                }
937                if let Some(color) = self.port_color {
938                    write!(f, " color={:02x}{:02x}{:02x}", color.r, color.g, color.b)?;
939                }
940                if self.port_role != PortRole::Auto {
941                    write!(f, " role={}", self.port_role.as_str())?;
942                }
943            }
944        }
945        if !self.tag.is_empty() {
946            write!(f, " <{}>", self.tag)?;
947        }
948        Ok(())
949    }
950}
951
952impl PartialOrd for Block {
953    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
954        Some(self.cmp(other))
955    }
956}
957
958impl Ord for Block {
959    fn cmp(&self, other: &Self) -> Ordering {
960        self.tag
961            .is_empty()
962            .cmp(&other.tag.is_empty())
963            .then_with(|| self.tag.cmp(&other.tag))
964            .then_with(|| self.pos.to_array().cmp(&other.pos.to_array()))
965            .then_with(|| self.kind.cmp(&other.kind))
966            .then_with(|| self.height.cmp(&other.height))
967            .then_with(|| self.port_color.cmp(&other.port_color))
968            .then_with(|| self.port_role.cmp(&other.port_role))
969    }
970}
971
972impl Default for Block {
973    fn default() -> Self {
974        Self::new(IVec3::ZERO, BlockKind::default())
975    }
976}
977
978impl Block {
979    /// Create an untagged block.
980    pub fn new(pos: impl Into<IVec3>, kind: impl Into<BlockKind>) -> Self {
981        Self {
982            pos: pos.into(),
983            kind: kind.into(),
984            height: CubeHeight::DEFAULT,
985            port_color: None,
986            port_role: PortRole::Auto,
987            tag: String::new(),
988        }
989    }
990
991    /// Returns the block's anchor position.
992    pub fn pos(&self) -> IVec3 {
993        self.pos
994    }
995
996    /// Returns the block kind.
997    pub fn kind(&self) -> BlockKind {
998        self.kind
999    }
1000
1001    /// Returns the symbolic cube height, always [`CubeHeight::DEFAULT`] for
1002    /// non-cube kinds.
1003    pub fn height(&self) -> CubeHeight {
1004        if self.kind.is_cube() {
1005            self.height
1006        } else {
1007            CubeHeight::DEFAULT
1008        }
1009    }
1010
1011    /// Returns how many lattice cells the block occupies along the time axis:
1012    /// the *footprint* half of [`height`](Self::height), driving positioning,
1013    /// occupancy, connectivity and visualization. Not the compiled round count
1014    /// ([`CubeHeight::rounds`](crate::CubeHeight::rounds)) — `height=d/2` occupies
1015    /// one cell but compiles to `ceil(d/2)` rounds.
1016    pub fn height_cells(&self) -> u32 {
1017        self.height().cells()
1018    }
1019
1020    /// Returns the block with its cube height set to `height`.
1021    ///
1022    /// # Errors
1023    ///
1024    /// Returns a [`BlockError`] if the block is not a cube, or if the
1025    /// resulting footprint leaves the coordinate range.
1026    pub fn with_height(mut self, height: CubeHeight) -> Result<Self, BlockError> {
1027        self.set_height_checked(height)?;
1028        Ok(self)
1029    }
1030
1031    /// Returns this Port's display color, or `None` for non-Ports.
1032    pub fn port_color(&self) -> Option<RGBA> {
1033        self.kind
1034            .is_port()
1035            .then_some(self.port_color.unwrap_or(RGBA::PORT_GRAY))
1036    }
1037
1038    /// Sets this Port's RGB display color. Alpha stays fixed and default gray is
1039    /// omitted from serialization.
1040    ///
1041    /// # Errors
1042    ///
1043    /// Returns [`BlockError::PortColorOnNonPort`] for a non-Port block.
1044    pub fn with_port_color(mut self, rgb: [u8; 3]) -> Result<Self, BlockError> {
1045        self.set_port_color_checked(rgb)?;
1046        Ok(self)
1047    }
1048
1049    /// Returns this Port's assigned direction, or `None` for non-Ports.
1050    pub fn port_role(&self) -> Option<PortRole> {
1051        self.kind.is_port().then_some(self.port_role)
1052    }
1053
1054    /// Assigns this Port's role.
1055    ///
1056    /// # Errors
1057    ///
1058    /// Returns [`BlockError::PortRoleOnNonPort`] if this is not a Port block.
1059    pub fn with_port_role(mut self, role: PortRole) -> Result<Self, BlockError> {
1060        self.set_port_role_checked(role)?;
1061        Ok(self)
1062    }
1063
1064    /// Returns the nonempty user tag, if present.
1065    pub fn tag(&self) -> Option<&str> {
1066        (!self.tag.is_empty()).then_some(self.tag.as_str())
1067    }
1068
1069    /// Returns the block tagged with `tag`. An empty `tag` clears the tag.
1070    ///
1071    /// # Errors
1072    ///
1073    /// Returns [`BlockGraphError::InvalidTag`] unless `tag` is empty or
1074    /// satisfies [`is_valid_tag`]; an invalid tag would not survive a
1075    /// [`to_blog_text`](crate::BlockGraph::to_blog_text) round-trip.
1076    pub fn with_tag(mut self, tag: impl Into<String>) -> Result<Self, BlockGraphError> {
1077        self.set_tag(tag)?;
1078        Ok(self)
1079    }
1080
1081    /// Returns a copy of the block translated by `offset`.
1082    ///
1083    /// # Panics
1084    ///
1085    /// Panics if the translated footprint exceeds the coordinate range. Use
1086    /// [`try_with_shift`](Self::try_with_shift) for untrusted offsets.
1087    pub fn with_shift(&self, offset: impl Into<IVec3>) -> Self {
1088        self.try_with_shift(offset)
1089            .expect("shifted block footprint must fit in the i32 coordinate range")
1090    }
1091
1092    /// Returns a copy of the block translated by `offset`.
1093    ///
1094    /// # Errors
1095    ///
1096    /// Returns [`BlockGraphError::CoordinateOverflow`] if any translated
1097    /// footprint cell exceeds the coordinate range.
1098    pub fn try_with_shift(&self, offset: impl Into<IVec3>) -> Result<Self, BlockGraphError> {
1099        let pos = crate::checked_add_position(self.pos, offset.into())?;
1100        for relative in self.reserved_offsets() {
1101            crate::checked_add_position(pos, relative)?;
1102        }
1103        let mut shifted = self.clone();
1104        shifted.pos = pos;
1105        Ok(shifted)
1106    }
1107
1108    /// Returns every cell the block occupies, as offsets from its position.
1109    ///
1110    /// A cube reserves one cell per height cell; other kinds defer to
1111    /// [`BlockKind::reserved_offsets`].
1112    pub fn reserved_offsets(&self) -> Offsets {
1113        match self.kind {
1114            BlockKind::Cube(_) => (0..self.height_cells_i32())
1115                .map(|z| IVec3::new(0, 0, z))
1116                .collect(),
1117            kind => kind.reserved_offsets(),
1118        }
1119    }
1120
1121    pub(crate) fn checked_reserved_positions(&self) -> Result<Vec<IVec3>, BlockGraphError> {
1122        self.reserved_offsets()
1123            .into_iter()
1124            .map(|offset| crate::checked_add_position(self.pos, offset))
1125            .collect()
1126    }
1127
1128    /// Returns the cells that can host pipe connections, as offsets from the
1129    /// block position.
1130    ///
1131    /// A multi-cell cube exposes its bottom and top cells; other kinds defer to
1132    /// [`BlockKind::connectable_offsets`].
1133    pub fn connectable_offsets(&self) -> Offsets {
1134        match self.kind {
1135            BlockKind::Cube(_) if self.height_cells() > 1 => {
1136                smallvec![IVec3::ZERO, IVec3::new(0, 0, self.height_cells_i32() - 1)]
1137            }
1138            kind => kind.connectable_offsets(),
1139        }
1140    }
1141
1142    /// Iterates over the absolute cells the block reserves, skipping any that
1143    /// would leave the `i32` lattice.
1144    fn reserved_positions(&self) -> impl Iterator<Item = IVec3> + '_ {
1145        self.reserved_offsets()
1146            .into_iter()
1147            .filter_map(|offset| crate::checked_add_position(self.pos, offset).ok())
1148    }
1149
1150    /// Iterates over the absolute cells the block exposes for pipe connections,
1151    /// skipping any that would leave the `i32` lattice.
1152    fn connectable_positions(&self) -> impl Iterator<Item = IVec3> + '_ {
1153        self.connectable_offsets()
1154            .into_iter()
1155            .filter_map(|offset| crate::checked_add_position(self.pos, offset).ok())
1156    }
1157
1158    /// Returns whether the block reserves the cell at `position`.
1159    pub(crate) fn occupies_position(&self, position: IVec3) -> bool {
1160        self.reserved_positions()
1161            .any(|occupied| occupied == position)
1162    }
1163
1164    /// Returns whether the block reserves any cell in time layer `z`.
1165    pub fn occupies_layer(&self, z: i32) -> bool {
1166        self.reserved_offsets()
1167            .into_iter()
1168            .filter_map(|offset| self.pos.z.checked_add(offset.z))
1169            .any(|occupied_z| occupied_z == z)
1170    }
1171
1172    /// Returns the boundary bases exposed at the connectable `endpoint`, or
1173    /// `None` if the block exposes no endpoint there.
1174    pub fn bases_at_endpoint(&self, endpoint: IVec3) -> Option<[Basis; 3]> {
1175        match self.kind {
1176            BlockKind::Cube(kind) => self
1177                .connectable_positions()
1178                .any(|position| position == endpoint)
1179                .then_some(kind.bases()),
1180            kind => kind.bases_at_endpoint(self.pos, endpoint),
1181        }
1182    }
1183
1184    /// Returns the exposed connectable endpoint facing `dir`.
1185    ///
1186    /// Multi-span blocks can reserve interior cells that are not valid external
1187    /// endpoints; for example a multi-cell cube resolves `+Z` to its top cell.
1188    pub fn endpoint_for_direction(&self, dir: Direction) -> IVec3 {
1189        self.connectable_positions()
1190            .find(|endpoint| {
1191                crate::checked_add_position(*endpoint, dir.to_ivec3())
1192                    .ok()
1193                    .is_none_or(|neighbor| !self.occupies_position(neighbor))
1194            })
1195            .unwrap_or(self.pos)
1196    }
1197
1198    pub(crate) fn set_kind(&mut self, kind: BlockKind) {
1199        self.kind = kind;
1200        if !self.kind.is_cube() {
1201            self.height = CubeHeight::DEFAULT;
1202        }
1203        if !self.kind.is_port() {
1204            self.port_color = None;
1205            self.port_role = PortRole::Auto;
1206        }
1207    }
1208
1209    // An empty tag means "untagged" (the writer omits it), so it clears rather
1210    // than errors — the editor clears tags by submitting an empty string.
1211    pub(crate) fn set_tag(&mut self, tag: impl Into<String>) -> Result<(), BlockGraphError> {
1212        let tag = tag.into();
1213        if !tag.is_empty() && !is_valid_tag(&tag) {
1214            return Err(BlockGraphError::InvalidTag { tag });
1215        }
1216        self.tag = tag;
1217        Ok(())
1218    }
1219
1220    fn set_height_checked(&mut self, height: CubeHeight) -> Result<(), BlockError> {
1221        if !self.kind.is_cube() {
1222            return Err(BlockError::CubeHeightOnNonCube(self.kind));
1223        }
1224        // `CubeHeight::new` is the only constructor and already caps cells at
1225        // `MAX_CUBE_HEIGHT_CELLS`, so `cells - 1` always fits an `i32`.
1226        let cells = height.cells();
1227        if self.pos.z.checked_add(cells as i32 - 1).is_none() {
1228            return Err(BlockError::CubeHeightCoordinateOverflow {
1229                z: self.pos.z,
1230                cells,
1231            });
1232        }
1233        self.height = height;
1234        Ok(())
1235    }
1236
1237    pub(crate) fn set_port_color_checked(&mut self, rgb: [u8; 3]) -> Result<(), BlockError> {
1238        if !self.kind.is_port() {
1239            return Err(BlockError::PortColorOnNonPort(self.kind));
1240        }
1241        let color = RGBA {
1242            r: rgb[0],
1243            g: rgb[1],
1244            b: rgb[2],
1245            a: RGBA::PORT_GRAY.a,
1246        };
1247        self.port_color = (color != RGBA::PORT_GRAY).then_some(color);
1248        Ok(())
1249    }
1250
1251    pub(crate) fn set_port_role_checked(&mut self, role: PortRole) -> Result<(), BlockError> {
1252        if !self.kind.is_port() {
1253            return Err(BlockError::PortRoleOnNonPort(self.kind));
1254        }
1255        self.port_role = role;
1256        Ok(())
1257    }
1258
1259    fn height_cells_i32(&self) -> i32 {
1260        i32::try_from(self.height_cells()).expect("cube height cells are bounded by 65535")
1261    }
1262}
1263
1264/// A directed pipe connecting one block to an adjacent block.
1265///
1266/// # Examples
1267///
1268/// ```
1269/// use bloq_graph::{Direction, Pipe};
1270/// use glam::IVec3;
1271///
1272/// let pipe = Pipe::new(IVec3::ZERO, Direction::ZPLUS)
1273///     .with_hadamard()
1274///     .with_tag("bridge")?;
1275/// assert_eq!(pipe.src(), IVec3::ZERO);
1276/// assert_eq!(pipe.dir(), Direction::ZPLUS);
1277/// assert!(pipe.is_hadamard());
1278/// assert_eq!(pipe.tag(), Some("bridge"));
1279/// # Ok::<(), bloq_graph::BlockGraphError>(())
1280/// ```
1281#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
1282pub struct Pipe {
1283    pub(crate) src: IVec3,
1284    pub(crate) dir: Direction,
1285    pub(crate) hadamard: bool,
1286    pub(crate) tag: String,
1287}
1288
1289impl std::fmt::Display for Pipe {
1290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1291        f.write_str(&self.display_with_ids(|_| None))
1292    }
1293}
1294
1295impl Pipe {
1296    pub(crate) fn display_with_ids(&self, p2i: impl Fn(IVec3) -> Option<u32>) -> String {
1297        let source = p2i(self.src)
1298            .map(|id| id.to_string())
1299            .unwrap_or_else(|| self.src.to_string());
1300        let tag = if self.tag.is_empty() {
1301            String::new()
1302        } else {
1303            format!(" <{}>", self.tag)
1304        };
1305        format!(
1306            "{source} {} {}{tag}",
1307            if self.hadamard { "-H>" } else { "->" },
1308            self.dir
1309        )
1310    }
1311
1312    /// Create an untagged, non-Hadamard pipe.
1313    pub fn new(src: impl Into<IVec3>, dir: impl Into<Direction>) -> Self {
1314        Self {
1315            src: src.into(),
1316            dir: dir.into(),
1317            hadamard: false,
1318            tag: String::new(),
1319        }
1320    }
1321
1322    /// Returns the pipe's canonical source position.
1323    pub fn src(&self) -> IVec3 {
1324        self.src
1325    }
1326
1327    /// Returns the direction from source to destination.
1328    pub fn dir(&self) -> Direction {
1329        self.dir
1330    }
1331
1332    /// Returns whether the pipe carries a Hadamard basis change.
1333    pub fn is_hadamard(&self) -> bool {
1334        self.hadamard
1335    }
1336
1337    /// Returns the nonempty user tag, if present.
1338    pub fn tag(&self) -> Option<&str> {
1339        (!self.tag.is_empty()).then_some(self.tag.as_str())
1340    }
1341
1342    /// Returns this pipe marked as a Hadamard pipe.
1343    pub fn with_hadamard(mut self) -> Self {
1344        self.hadamard = true;
1345        self
1346    }
1347
1348    /// Returns the pipe tagged with `tag`. An empty `tag` clears the tag.
1349    ///
1350    /// # Errors
1351    ///
1352    /// Returns [`BlockGraphError::InvalidTag`] unless `tag` is empty or
1353    /// satisfies [`is_valid_tag`]; an invalid tag would not survive a
1354    /// [`to_blog_text`](crate::BlockGraph::to_blog_text) round-trip.
1355    pub fn with_tag(mut self, tag: impl Into<String>) -> Result<Self, BlockGraphError> {
1356        self.set_tag(tag)?;
1357        Ok(self)
1358    }
1359
1360    /// Returns the source and destination positions as a `(src, dst)` pair.
1361    ///
1362    /// # Panics
1363    ///
1364    /// Panics if stepping from the source exceeds the coordinate range. Use
1365    /// [`try_endpoints`](Self::try_endpoints) for untrusted pipes.
1366    pub fn endpoints(&self) -> (IVec3, IVec3) {
1367        self.try_endpoints()
1368            .expect("pipe destination must fit in the i32 coordinate range")
1369    }
1370
1371    /// Returns the source and destination positions as a `(src, dst)` pair.
1372    ///
1373    /// # Errors
1374    ///
1375    /// Returns [`BlockGraphError::CoordinateOverflow`] if stepping from the
1376    /// source exceeds the coordinate range.
1377    pub fn try_endpoints(&self) -> Result<(IVec3, IVec3), BlockGraphError> {
1378        Ok((self.src, self.try_dst()?))
1379    }
1380
1381    /// Returns the destination position (`src` stepped one cell along `dir`).
1382    ///
1383    /// # Panics
1384    ///
1385    /// Panics if stepping from the source exceeds the coordinate range. Use
1386    /// [`try_dst`](Self::try_dst) for untrusted pipes.
1387    pub fn dst(&self) -> IVec3 {
1388        self.try_dst()
1389            .expect("pipe destination must fit in the i32 coordinate range")
1390    }
1391
1392    /// Returns the destination position (`src` stepped one cell along `dir`).
1393    ///
1394    /// # Errors
1395    ///
1396    /// Returns [`BlockGraphError::CoordinateOverflow`] if stepping from the
1397    /// source exceeds the coordinate range.
1398    pub fn try_dst(&self) -> Result<IVec3, BlockGraphError> {
1399        crate::checked_add_position(self.src, self.dir.to_ivec3())
1400    }
1401
1402    /// Returns a copy of the pipe translated by `offset`.
1403    ///
1404    /// # Panics
1405    ///
1406    /// Panics if either translated endpoint exceeds the coordinate range. Use
1407    /// [`try_with_shift`](Self::try_with_shift) for untrusted offsets.
1408    pub fn with_shift(&self, offset: impl Into<IVec3>) -> Self {
1409        self.try_with_shift(offset)
1410            .expect("shifted pipe endpoints must fit in the i32 coordinate range")
1411    }
1412
1413    /// Returns a copy of the pipe translated by `offset`.
1414    ///
1415    /// # Errors
1416    ///
1417    /// Returns [`BlockGraphError::CoordinateOverflow`] if either translated
1418    /// endpoint exceeds the coordinate range.
1419    pub fn try_with_shift(&self, offset: impl Into<IVec3>) -> Result<Self, BlockGraphError> {
1420        let offset = offset.into();
1421        let src = crate::checked_add_position(self.src, offset)?;
1422        crate::checked_add_position(src, self.dir.to_ivec3())?;
1423        Ok(Self {
1424            src,
1425            dir: self.dir,
1426            hadamard: self.hadamard,
1427            tag: self.tag.clone(),
1428        })
1429    }
1430
1431    // See `Block::set_tag`: an empty tag clears rather than errors.
1432    pub(crate) fn set_tag(&mut self, tag: impl Into<String>) -> Result<(), BlockGraphError> {
1433        let tag = tag.into();
1434        if !tag.is_empty() && !is_valid_tag(&tag) {
1435            return Err(BlockGraphError::InvalidTag { tag });
1436        }
1437        self.tag = tag;
1438        Ok(())
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445
1446    #[test]
1447    fn test_block_order_prefers_tagged_blocks_then_tag_then_position() {
1448        let mut blocks = [
1449            Block::new(IVec3::new(2, 0, 0), BlockKind::Port),
1450            Block::new(IVec3::new(1, 0, 0), BlockKind::Port)
1451                .with_tag("b")
1452                .expect("valid tag"),
1453            Block::new(IVec3::new(0, 0, 0), BlockKind::Port)
1454                .with_tag("a")
1455                .expect("valid tag"),
1456        ];
1457
1458        blocks.sort();
1459
1460        assert_eq!(
1461            blocks.iter().map(|block| block.pos).collect::<Vec<_>>(),
1462            vec![
1463                IVec3::new(0, 0, 0),
1464                IVec3::new(1, 0, 0),
1465                IVec3::new(2, 0, 0)
1466            ]
1467        );
1468    }
1469
1470    #[test]
1471    fn test_block_order_breaks_ties_by_kind() {
1472        let mut blocks = [
1473            Block::new(IVec3::ZERO, BlockKind::Y)
1474                .with_tag("tag")
1475                .expect("valid tag"),
1476            Block::new(IVec3::ZERO, BlockKind::Port)
1477                .with_tag("tag")
1478                .expect("valid tag"),
1479        ];
1480
1481        blocks.sort();
1482
1483        assert_eq!(blocks[0].kind, BlockKind::Y);
1484        assert_eq!(blocks[1].kind, BlockKind::Port);
1485    }
1486
1487    #[test]
1488    fn patch_rotation_x_axis_boundary_basis_matches_construction_frame() {
1489        for (basis, movement, expected) in [
1490            (Basis::X, IVec2::NEG_X, Basis::Z),
1491            (Basis::X, IVec2::X, Basis::Z),
1492            (Basis::X, IVec2::NEG_Y, Basis::X),
1493            (Basis::X, IVec2::Y, Basis::X),
1494            (Basis::Z, IVec2::NEG_X, Basis::X),
1495            (Basis::Z, IVec2::X, Basis::X),
1496            (Basis::Z, IVec2::NEG_Y, Basis::Z),
1497            (Basis::Z, IVec2::Y, Basis::Z),
1498        ] {
1499            assert_eq!(
1500                PatchRotationKind::new(basis, movement)
1501                    .unwrap()
1502                    .x_axis_boundary_basis(),
1503                expected
1504            );
1505        }
1506    }
1507
1508    #[test]
1509    fn test_block_kind_from_str_stays_canonical_for_selectives() {
1510        assert_eq!(
1511            "YZ".parse::<BlockKind>().unwrap(),
1512            BlockKind::Selective(SelectiveKind::YZ)
1513        );
1514        "ZY".parse::<BlockKind>().unwrap_err();
1515    }
1516
1517    #[test]
1518    fn measurement_kind_round_trips_and_flips() {
1519        for (text, basis) in [("X", Basis::X), ("Z", Basis::Z)] {
1520            let kind = BlockKind::Measurement(basis);
1521            assert_eq!(text.parse::<BlockKind>().unwrap(), kind);
1522            assert_eq!(kind.to_string(), text);
1523            assert_eq!(kind.flip_xz_basis(), BlockKind::Measurement(basis.flip()));
1524        }
1525        for old in ["MX", "MZ", "TransversalX", "TransversalZ"] {
1526            old.parse::<BlockKind>().unwrap_err();
1527        }
1528    }
1529
1530    #[test]
1531    fn with_tag_rejects_tags_the_parser_cannot_read_back() {
1532        for bad in ["has space", "angle>bracket", "angle<bracket", "nul\0byte"] {
1533            let block = Block::new(IVec3::ZERO, BlockKind::Port).with_tag(bad);
1534            assert!(
1535                matches!(block, Err(BlockGraphError::InvalidTag { .. })),
1536                "tag {bad:?} should be rejected"
1537            );
1538            let pipe = Pipe::new(IVec3::ZERO, Direction::ZPLUS).with_tag(bad);
1539            assert!(
1540                matches!(pipe, Err(BlockGraphError::InvalidTag { .. })),
1541                "tag {bad:?} should be rejected"
1542            );
1543        }
1544    }
1545
1546    #[test]
1547    fn empty_tag_clears_instead_of_erroring() {
1548        // The editor clears a tag by submitting an empty string; internally an
1549        // empty tag already means "untagged" and the writer omits it.
1550        let block = Block::new(IVec3::ZERO, BlockKind::Port)
1551            .with_tag("kept")
1552            .expect("valid tag")
1553            .with_tag("")
1554            .expect("empty tag clears");
1555        assert_eq!(block.tag(), None);
1556
1557        let pipe = Pipe::new(IVec3::ZERO, Direction::ZPLUS)
1558            .with_tag("kept")
1559            .expect("valid tag")
1560            .with_tag("")
1561            .expect("empty tag clears");
1562        assert_eq!(pipe.tag(), None);
1563    }
1564
1565    #[test]
1566    fn with_tag_accepts_symbols() {
1567        for tag in ["if", "0", "^", "()", "π"] {
1568            let block = Block::new(IVec3::ZERO, BlockKind::Port)
1569                .with_tag(tag)
1570                .unwrap();
1571            assert_eq!(block.tag(), Some(tag));
1572        }
1573    }
1574
1575    #[test]
1576    fn cube_height_footprint_is_bounded_before_reserved_offset_allocation() {
1577        let near_top = Block::new(IVec3::new(0, 0, i32::MAX), BlockKind::Cube(CubeKind::ZXZ));
1578        assert!(matches!(
1579            near_top
1580                .clone()
1581                .with_height("2d".parse().expect("valid height")),
1582            Err(BlockError::CubeHeightCoordinateOverflow { .. })
1583        ));
1584        // A sub-unit height stays single-cell, so it fits where `2d` does not.
1585        near_top
1586            .with_height("d/2".parse().expect("valid height"))
1587            .unwrap();
1588    }
1589
1590    #[test]
1591    fn cube_height_is_cube_only_and_resets_with_the_kind() {
1592        let port = Block::new(IVec3::ZERO, BlockKind::Port);
1593        assert!(matches!(
1594            port.with_height("2d".parse().expect("valid height")),
1595            Err(BlockError::CubeHeightOnNonCube(BlockKind::Port))
1596        ));
1597
1598        let mut cube = Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
1599            .with_height("2d".parse().expect("valid height"))
1600            .expect("cube accepts a height");
1601        assert_eq!(cube.height_cells(), 2);
1602        cube.set_kind(BlockKind::Y);
1603        assert_eq!(cube.height(), CubeHeight::DEFAULT);
1604        assert_eq!(cube.height_cells(), 1);
1605    }
1606
1607    #[test]
1608    fn port_color_is_port_only_and_keeps_default_alpha() {
1609        let mut port = Block::new(IVec3::ZERO, BlockKind::Port)
1610            .with_port_color([0xeb, 0x40, 0x34])
1611            .expect("Port accepts a color");
1612        assert_eq!(
1613            port.port_color(),
1614            Some(RGBA::from_hex(0xeb4034, RGBA::PORT_GRAY.a))
1615        );
1616        assert_eq!(port.to_string(), "Port [0, 0, 0] color=eb4034");
1617
1618        port.set_kind(BlockKind::Y);
1619        assert_eq!(port.port_color(), None);
1620        assert!(matches!(
1621            port.with_port_color([0, 0, 0]),
1622            Err(BlockError::PortColorOnNonPort(BlockKind::Y))
1623        ));
1624    }
1625
1626    #[test]
1627    fn cube_height_drives_cells_not_rounds() {
1628        let half = Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
1629            .with_height("d/2".parse().expect("valid height"))
1630            .expect("cube accepts a height");
1631        // One cell, exactly like an ordinary cube: no extra reserved offsets, no
1632        // second connectable endpoint.
1633        assert_eq!(half.reserved_offsets().as_slice(), [IVec3::ZERO]);
1634        assert_eq!(half.connectable_offsets().as_slice(), [IVec3::ZERO]);
1635        assert_eq!(half.height().rounds(7), 4);
1636
1637        let tall = half
1638            .with_height("3d/2".parse().expect("valid height"))
1639            .expect("cube accepts a height");
1640        assert_eq!(
1641            tall.reserved_offsets().as_slice(),
1642            [IVec3::ZERO, IVec3::new(0, 0, 1)]
1643        );
1644        assert_eq!(tall.height().rounds(7), 11);
1645    }
1646
1647    #[test]
1648    fn display_emits_the_canonical_height_only_for_non_default_cubes() {
1649        let plain = Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ));
1650        assert_eq!(plain.to_string(), "ZXZ [0, 0, 0]");
1651
1652        let scaled = plain
1653            .with_height("2d/4".parse().expect("valid height"))
1654            .expect("cube accepts a height");
1655        assert_eq!(scaled.to_string(), "ZXZ [0, 0, 0] height=d/2");
1656    }
1657
1658    #[test]
1659    fn pipe_shift_validates_the_translated_endpoints() {
1660        let shifted = Pipe::new(IVec3::new(i32::MAX, 0, 0), Direction::XPLUS)
1661            .try_with_shift(IVec3::NEG_X)
1662            .expect("translation brings both endpoints into range");
1663
1664        assert_eq!(
1665            shifted.try_endpoints().unwrap(),
1666            (IVec3::new(i32::MAX - 1, 0, 0), IVec3::new(i32::MAX, 0, 0),)
1667        );
1668    }
1669
1670    #[test]
1671    fn moving_block_queries_handle_unrepresentable_endpoints() {
1672        let start = IVec3::splat(i32::MAX);
1673        let wrapped_endpoint = IVec3::splat(i32::MIN);
1674        let walking = WalkingKind::new(WalkingBoundaryKind::ZXZ, IVec2::ONE).unwrap();
1675        let rotation = PatchRotationKind::new(Basis::X, IVec2::X).unwrap();
1676
1677        walking.try_end_position(start).unwrap_err();
1678        rotation.try_end_position(start).unwrap_err();
1679
1680        for kind in [
1681            BlockKind::Walking(walking),
1682            BlockKind::PatchRotation(rotation),
1683        ] {
1684            let block = Block::new(start, kind);
1685            assert!(!block.occupies_position(wrapped_endpoint));
1686            assert!(!block.occupies_layer(i32::MIN));
1687            assert!(block.bases_at_endpoint(wrapped_endpoint).is_none());
1688            assert!(kind.bases_at_endpoint(start, wrapped_endpoint).is_none());
1689            assert!(
1690                kind.pipe_face_bases_at_endpoint(start, wrapped_endpoint)
1691                    .is_none()
1692            );
1693            assert!(block.to_string().contains("[endpoint overflows i32]"));
1694        }
1695    }
1696}