Skip to main content

steel_registry/blocks/
shapes.rs

1use steel_utils::{BlockLocalAabb, axis::Axis};
2
3/// Vanilla shape boolean operation.
4///
5/// Mirrors `net.minecraft.world.phys.shapes.BooleanOp`. Operations where
6/// `apply(false, false)` is true are not valid for `join_is_not_empty`, matching
7/// vanilla's guard for unbounded outside-space results.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BooleanOp {
10    False,
11    NotOr,
12    OnlySecond,
13    NotFirst,
14    OnlyFirst,
15    NotSecond,
16    NotSame,
17    NotAnd,
18    And,
19    Same,
20    Second,
21    Causes,
22    First,
23    CausedBy,
24    Or,
25    True,
26}
27
28impl BooleanOp {
29    #[must_use]
30    pub const fn apply(self, first: bool, second: bool) -> bool {
31        match self {
32            Self::False => false,
33            Self::NotOr => !first && !second,
34            Self::OnlySecond => second && !first,
35            Self::NotFirst => !first,
36            Self::OnlyFirst => first && !second,
37            Self::NotSecond => !second,
38            Self::NotSame => first != second,
39            Self::NotAnd => !first || !second,
40            Self::And => first && second,
41            Self::Same => first == second,
42            Self::Second => second,
43            Self::Causes => !first || second,
44            Self::First => first,
45            Self::CausedBy => first || !second,
46            Self::Or => first || second,
47            Self::True => true,
48        }
49    }
50}
51
52/// A block-local voxel shape.
53///
54/// This currently stores the optimized AABB list extracted from vanilla data.
55/// It is intentionally a domain type rather than a raw slice so the full
56/// vanilla shape implementation can grow behind the same API.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct VoxelShape {
59    boxes: &'static [BlockLocalAabb],
60}
61
62impl VoxelShape {
63    /// Empty shape.
64    pub const EMPTY: Self = Self::from_boxes(&[]);
65
66    /// Full block shape.
67    pub const FULL_BLOCK: Self = Self::from_boxes(FULL_BLOCK_BOXES);
68
69    /// Creates a shape from static block-local boxes.
70    #[must_use]
71    pub const fn from_boxes(boxes: &'static [BlockLocalAabb]) -> Self {
72        Self { boxes }
73    }
74
75    /// Returns the block-local boxes backing this shape.
76    #[must_use]
77    pub const fn boxes(self) -> &'static [BlockLocalAabb] {
78        self.boxes
79    }
80
81    /// Returns an iterator over the block-local boxes.
82    pub fn iter(self) -> core::slice::Iter<'static, BlockLocalAabb> {
83        self.boxes.iter()
84    }
85
86    /// Returns the number of block-local boxes in this shape.
87    #[must_use]
88    pub const fn len(self) -> usize {
89        self.boxes.len()
90    }
91
92    /// Returns true if this shape has no non-empty boxes.
93    #[must_use]
94    pub fn is_empty(self) -> bool {
95        self.boxes.iter().all(|aabb| aabb.is_empty())
96    }
97
98    /// Returns the minimum coordinate on `axis`, or positive infinity for an empty shape.
99    #[must_use]
100    pub fn min(self, axis: Axis) -> f64 {
101        self.boxes
102            .iter()
103            .filter(|aabb| !aabb.is_empty())
104            .map(|aabb| aabb.min(axis))
105            .fold(f64::INFINITY, f64::min)
106    }
107
108    /// Returns the maximum coordinate on `axis`, or negative infinity for an empty shape.
109    #[must_use]
110    pub fn max(self, axis: Axis) -> f64 {
111        self.boxes
112            .iter()
113            .filter(|aabb| !aabb.is_empty())
114            .map(|aabb| aabb.max(axis))
115            .fold(f64::NEG_INFINITY, f64::max)
116    }
117
118    /// Returns the union bounds of this shape, or `None` for empty shapes.
119    #[must_use]
120    pub fn bounds(self) -> Option<BlockLocalAabb> {
121        let first = self.boxes.iter().find(|aabb| !aabb.is_empty())?;
122        let mut min_x = first.min_x();
123        let mut min_y = first.min_y();
124        let mut min_z = first.min_z();
125        let mut max_x = first.max_x();
126        let mut max_y = first.max_y();
127        let mut max_z = first.max_z();
128
129        for aabb in self.boxes {
130            if aabb.is_empty() {
131                continue;
132            }
133            min_x = min_x.min(aabb.min_x());
134            min_y = min_y.min(aabb.min_y());
135            min_z = min_z.min(aabb.min_z());
136            max_x = max_x.max(aabb.max_x());
137            max_y = max_y.max(aabb.max_y());
138            max_z = max_z.max(aabb.max_z());
139        }
140
141        Some(BlockLocalAabb::new(
142            min_x, min_y, min_z, max_x, max_y, max_z,
143        ))
144    }
145
146    /// Returns true when this shape extends outside its owning block.
147    ///
148    /// Mirrors vanilla `BlockState.hasLargeCollisionShape()` for collision
149    /// iterator filtering.
150    #[must_use]
151    pub fn has_large_collision_shape(self) -> bool {
152        [Axis::X, Axis::Y, Axis::Z]
153            .into_iter()
154            .any(|axis| self.min(axis) < 0.0 || self.max(axis) > 1.0)
155    }
156}
157
158impl IntoIterator for VoxelShape {
159    type IntoIter = core::slice::Iter<'static, BlockLocalAabb>;
160    type Item = &'static BlockLocalAabb;
161
162    fn into_iter(self) -> Self::IntoIter {
163        self.iter()
164    }
165}
166
167/// An ID referencing a registered VoxelShape in the ShapeRegistry.
168///
169/// Use this to refer to shapes in a compact way. The actual shape data
170/// can be retrieved from the ShapeRegistry using this ID.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
172pub struct ShapeId(pub u16);
173
174impl ShapeId {
175    /// The empty shape (no AABBs).
176    pub const EMPTY: ShapeId = ShapeId(0);
177
178    /// A full block shape.
179    pub const FULL_BLOCK: ShapeId = ShapeId(1);
180}
181
182/// Registry for VoxelShapes.
183///
184/// Shapes are registered once and referenced by ShapeId. This allows
185/// deduplication of shapes and compact storage of shape references.
186///
187/// Vanilla shapes are registered at startup. Plugins can register
188/// additional shapes for custom blocks.
189pub struct ShapeRegistry {
190    shapes: Vec<VoxelShape>,
191    allows_registering: bool,
192}
193
194impl Default for ShapeRegistry {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl ShapeRegistry {
201    /// Creates a new shape registry with the standard empty and full block shapes.
202    #[must_use]
203    pub fn new() -> Self {
204        let mut registry = Self {
205            shapes: Vec::new(),
206            allows_registering: true,
207        };
208
209        // Register the two standard shapes - IDs must match ShapeId::EMPTY and ShapeId::FULL_BLOCK
210        let empty_id = registry.register(VoxelShape::EMPTY);
211        debug_assert_eq!(empty_id, ShapeId::EMPTY);
212
213        let full_id = registry.register(VoxelShape::FULL_BLOCK);
214        debug_assert_eq!(full_id, ShapeId::FULL_BLOCK);
215
216        registry
217    }
218
219    /// Registers a new shape and returns its ID.
220    ///
221    /// # Panics
222    /// Panics if the registry has been frozen.
223    pub fn register(&mut self, shape: VoxelShape) -> ShapeId {
224        assert!(
225            self.allows_registering,
226            "Cannot register shapes after the registry has been frozen"
227        );
228
229        let id = ShapeId(self.shapes.len() as u16);
230        self.shapes.push(shape);
231        id
232    }
233
234    /// Gets the shape for a given ID.
235    ///
236    /// Returns an empty shape if the ID is invalid.
237    #[must_use]
238    pub fn get(&self, id: ShapeId) -> VoxelShape {
239        self.shapes
240            .get(id.0 as usize)
241            .copied()
242            .unwrap_or(VoxelShape::EMPTY)
243    }
244
245    /// Returns the number of registered shapes.
246    #[must_use]
247    pub fn len(&self) -> usize {
248        self.shapes.len()
249    }
250
251    /// Returns true if no shapes are registered.
252    #[must_use]
253    pub fn is_empty(&self) -> bool {
254        self.shapes.is_empty()
255    }
256
257    /// Freezes the registry, preventing further registrations.
258    pub fn freeze(&mut self) {
259        self.allows_registering = false;
260    }
261}
262
263const FULL_BLOCK_BOXES: &[BlockLocalAabb] = &[BlockLocalAabb::FULL_BLOCK];
264
265const VOXEL_EPSILON: f64 = 1.0e-7;
266
267/// Shape data for a block state.
268#[derive(Debug, Clone, Copy)]
269pub struct BlockShapes {
270    pub collision: VoxelShape,
271    pub support: VoxelShape,
272    pub outline: VoxelShape,
273    pub occlusion: VoxelShape,
274    pub interaction: VoxelShape,
275    pub visual: VoxelShape,
276}
277
278impl BlockShapes {
279    /// Creates new block shapes.
280    #[must_use]
281    pub const fn new(
282        collision: VoxelShape,
283        support: VoxelShape,
284        outline: VoxelShape,
285        occlusion: VoxelShape,
286        interaction: VoxelShape,
287        visual: VoxelShape,
288    ) -> Self {
289        Self {
290            collision,
291            support,
292            outline,
293            occlusion,
294            interaction,
295            visual,
296        }
297    }
298
299    /// Full block for every shape channel except interaction.
300    pub const FULL_BLOCK: BlockShapes = BlockShapes::new(
301        VoxelShape::FULL_BLOCK,
302        VoxelShape::FULL_BLOCK,
303        VoxelShape::FULL_BLOCK,
304        VoxelShape::FULL_BLOCK,
305        VoxelShape::EMPTY,
306        VoxelShape::FULL_BLOCK,
307    );
308
309    /// Empty shapes for all shape channels.
310    pub const EMPTY: BlockShapes = BlockShapes::new(
311        VoxelShape::EMPTY,
312        VoxelShape::EMPTY,
313        VoxelShape::EMPTY,
314        VoxelShape::EMPTY,
315        VoxelShape::EMPTY,
316        VoxelShape::EMPTY,
317    );
318}
319
320use super::properties::Direction;
321
322/// Returns the overall bounding box of a voxel shape (union of all AABBs).
323///
324/// The shape must be non-empty; panics otherwise.
325#[must_use]
326pub fn bounding_box(shape: VoxelShape) -> BlockLocalAabb {
327    match shape.bounds() {
328        Some(bounds) => bounds,
329        None => panic!("bounding_box called on empty shape"),
330    }
331}
332
333/// Checks if a shape is a full block (covers the entire 0-1 cube).
334///
335/// This matches vanilla's `Block.isShapeFullBlock()` used by `isSolidRender()`.
336///
337#[must_use]
338pub fn is_shape_full_block(shape: VoxelShape) -> bool {
339    !join_is_not_empty(VoxelShape::FULL_BLOCK, shape, BooleanOp::NotSame)
340}
341
342/// Returns true if applying `op` to two voxel shapes produces any filled space.
343///
344/// This is the box-backed equivalent of vanilla `Shapes.joinIsNotEmpty`. It
345/// decomposes both shapes into a shared coordinate grid and tests occupancy in
346/// each cell. The current representation does not materialize a joined shape;
347/// it answers the boolean query needed for full-block and occlusion checks.
348///
349/// # Panics
350/// Panics if `op.apply(false, false)` is true, matching vanilla's invalid
351/// operation guard for unbounded outside-space results.
352#[must_use]
353pub fn join_is_not_empty(first: VoxelShape, second: VoxelShape, op: BooleanOp) -> bool {
354    if op.apply(false, false) {
355        panic!("join_is_not_empty cannot use an operation that includes empty outside space");
356    }
357
358    let first_empty = first.is_empty();
359    let second_empty = second.is_empty();
360    if first_empty || second_empty {
361        return op.apply(!first_empty, !second_empty);
362    }
363
364    if first == second {
365        return op.apply(true, true);
366    }
367
368    let first_only_matters = op.apply(true, false);
369    let second_only_matters = op.apply(false, true);
370    for axis in [Axis::X, Axis::Y, Axis::Z] {
371        if first.max(axis) < second.min(axis) - VOXEL_EPSILON {
372            return first_only_matters || second_only_matters;
373        }
374        if second.max(axis) < first.min(axis) - VOXEL_EPSILON {
375            return first_only_matters || second_only_matters;
376        }
377    }
378
379    let mut x_edges = shape_edges(first, second, Axis::X);
380    let mut y_edges = shape_edges(first, second, Axis::Y);
381    let mut z_edges = shape_edges(first, second, Axis::Z);
382    sort_and_dedup_voxel_edges(&mut x_edges);
383    sort_and_dedup_voxel_edges(&mut y_edges);
384    sort_and_dedup_voxel_edges(&mut z_edges);
385
386    for x in x_edges.windows(2) {
387        if x[1] - x[0] <= VOXEL_EPSILON {
388            continue;
389        }
390        for y in y_edges.windows(2) {
391            if y[1] - y[0] <= VOXEL_EPSILON {
392                continue;
393            }
394            for z in z_edges.windows(2) {
395                if z[1] - z[0] <= VOXEL_EPSILON {
396                    continue;
397                }
398                let first_full = shape_fills_cell(first, x[0], x[1], y[0], y[1], z[0], z[1]);
399                let second_full = shape_fills_cell(second, x[0], x[1], y[0], y[1], z[0], z[1]);
400                if op.apply(first_full, second_full) {
401                    return true;
402                }
403            }
404        }
405    }
406
407    false
408}
409
410/// Materializes the unoptimized cell boxes produced by a shape boolean operation.
411///
412/// Vanilla parity: `Shapes.joinUnoptimized(first, second, op)`, expressed as
413/// block-local boxes instead of a lazily merged voxel shape.
414///
415/// # Panics
416/// Panics if `op.apply(false, false)` is true, matching vanilla's invalid
417/// operation guard for unbounded outside-space results.
418#[must_use]
419pub fn join_unoptimized_boxes(
420    first: VoxelShape,
421    second: VoxelShape,
422    op: BooleanOp,
423) -> Vec<BlockLocalAabb> {
424    if op.apply(false, false) {
425        panic!("join_unoptimized_boxes cannot use an operation that includes empty outside space");
426    }
427
428    if first.is_empty() && second.is_empty() {
429        return Vec::new();
430    }
431
432    let mut x_edges = shape_edges(first, second, Axis::X);
433    let mut y_edges = shape_edges(first, second, Axis::Y);
434    let mut z_edges = shape_edges(first, second, Axis::Z);
435    sort_and_dedup_voxel_edges(&mut x_edges);
436    sort_and_dedup_voxel_edges(&mut y_edges);
437    sort_and_dedup_voxel_edges(&mut z_edges);
438
439    let mut boxes = Vec::new();
440    for x in x_edges.windows(2) {
441        if x[1] - x[0] <= VOXEL_EPSILON {
442            continue;
443        }
444        for y in y_edges.windows(2) {
445            if y[1] - y[0] <= VOXEL_EPSILON {
446                continue;
447            }
448            for z in z_edges.windows(2) {
449                if z[1] - z[0] <= VOXEL_EPSILON {
450                    continue;
451                }
452
453                let first_full = shape_fills_cell(first, x[0], x[1], y[0], y[1], z[0], z[1]);
454                let second_full = shape_fills_cell(second, x[0], x[1], y[0], y[1], z[0], z[1]);
455                if op.apply(first_full, second_full) {
456                    boxes.push(BlockLocalAabb::new(x[0], y[0], z[0], x[1], y[1], z[1]));
457                }
458            }
459        }
460    }
461
462    boxes
463}
464
465fn shape_edges(first: VoxelShape, second: VoxelShape, axis: Axis) -> Vec<f64> {
466    let mut edges = Vec::with_capacity((first.len() + second.len()) * 2);
467    for shape in [first, second] {
468        for aabb in shape {
469            if aabb.is_empty() {
470                continue;
471            }
472            edges.push(aabb.min(axis));
473            edges.push(aabb.max(axis));
474        }
475    }
476    edges
477}
478
479fn sort_and_dedup_voxel_edges(edges: &mut Vec<f64>) {
480    edges.sort_by(|a, b| a.total_cmp(b));
481    edges.dedup_by(|a, b| (*a - *b).abs() <= VOXEL_EPSILON);
482}
483
484fn shape_fills_cell(
485    shape: VoxelShape,
486    min_x: f64,
487    max_x: f64,
488    min_y: f64,
489    max_y: f64,
490    min_z: f64,
491    max_z: f64,
492) -> bool {
493    shape.into_iter().any(|aabb| {
494        !aabb.is_empty()
495            && aabb.min_x() <= min_x + VOXEL_EPSILON
496            && aabb.max_x() >= max_x - VOXEL_EPSILON
497            && aabb.min_y() <= min_y + VOXEL_EPSILON
498            && aabb.max_y() >= max_y - VOXEL_EPSILON
499            && aabb.min_z() <= min_z + VOXEL_EPSILON
500            && aabb.max_z() >= max_z - VOXEL_EPSILON
501    })
502}
503
504/// Support type for `is_face_sturdy` checks.
505///
506/// Determines what kind of support a block face provides for other blocks.
507/// Used by fences, walls, torches, etc. to decide if they can connect/attach.
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509pub enum SupportType {
510    /// Full face support - the entire face must be solid.
511    /// Used by most blocks that need a solid surface.
512    Full,
513    /// Center support - only the center of the face needs to be solid.
514    /// Used by things like hanging signs that only need a small attachment point.
515    Center,
516    /// Rigid support - most of the face must be solid, but allows small gaps.
517    /// Used by bells and similar blocks.
518    Rigid,
519}
520
521/// Vanilla `SupportType.CENTER`: `Block.column(2.0, 0.0, 10.0)`.
522const CENTER_SUPPORT_MIN: f64 = 7.0 / 16.0;
523const CENTER_SUPPORT_MAX: f64 = 9.0 / 16.0;
524const CENTER_SUPPORT_Y_MAX: f64 = 10.0 / 16.0;
525
526/// Vanilla `SupportType.RIGID`: `Shapes.block() ONLY_FIRST Block.column(12.0, 0.0, 16.0)`.
527const RIGID_BORDER: f64 = 0.125; // 2/16
528
529/// Checks if a shape fully covers a face (for `SupportType::Full`).
530///
531/// Returns true if the 2D projection of the shape on the given face
532/// completely covers the 1x1 face area.
533#[must_use]
534pub fn is_face_full(shape: VoxelShape, direction: Direction) -> bool {
535    face_rectangles_cover(shape, direction, 0.0, 1.0, 0.0, 1.0)
536}
537
538/// Checks if a shape provides center support on a face.
539///
540/// The center area is a 12x12 pixel region (0.125 to 0.875 on each axis).
541#[must_use]
542pub fn is_face_center_supported(shape: VoxelShape, direction: Direction) -> bool {
543    if shape.is_empty() {
544        return false;
545    }
546
547    match direction {
548        Direction::Down | Direction::Up => face_rectangles_cover(
549            shape,
550            direction,
551            CENTER_SUPPORT_MIN,
552            CENTER_SUPPORT_MAX,
553            CENTER_SUPPORT_MIN,
554            CENTER_SUPPORT_MAX,
555        ),
556        Direction::North | Direction::South => face_rectangles_cover(
557            shape,
558            direction,
559            CENTER_SUPPORT_MIN,
560            CENTER_SUPPORT_MAX,
561            0.0,
562            CENTER_SUPPORT_Y_MAX,
563        ),
564        Direction::West | Direction::East => face_rectangles_cover(
565            shape,
566            direction,
567            0.0,
568            CENTER_SUPPORT_Y_MAX,
569            CENTER_SUPPORT_MIN,
570            CENTER_SUPPORT_MAX,
571        ),
572    }
573}
574
575/// Checks if a shape provides rigid support on a face.
576///
577/// Rigid support requires coverage of vanilla's fixed 3D support mask.
578#[must_use]
579pub fn is_face_rigid_supported(shape: VoxelShape, direction: Direction) -> bool {
580    if shape.is_empty() {
581        return false;
582    }
583
584    match direction {
585        Direction::Down | Direction::Up => {
586            face_rectangles_cover(shape, direction, 0.0, RIGID_BORDER, 0.0, 1.0)
587                && face_rectangles_cover(shape, direction, 1.0 - RIGID_BORDER, 1.0, 0.0, 1.0)
588                && face_rectangles_cover(
589                    shape,
590                    direction,
591                    RIGID_BORDER,
592                    1.0 - RIGID_BORDER,
593                    0.0,
594                    RIGID_BORDER,
595                )
596                && face_rectangles_cover(
597                    shape,
598                    direction,
599                    RIGID_BORDER,
600                    1.0 - RIGID_BORDER,
601                    1.0 - RIGID_BORDER,
602                    1.0,
603                )
604        }
605        Direction::North | Direction::South | Direction::West | Direction::East => {
606            is_face_full(shape, direction)
607        }
608    }
609}
610
611/// Checks if a shape is sturdy on a face for the given support type.
612#[must_use]
613pub fn is_face_sturdy(shape: VoxelShape, direction: Direction, support_type: SupportType) -> bool {
614    match support_type {
615        SupportType::Full => is_face_full(shape, direction),
616        SupportType::Center => is_face_center_supported(shape, direction),
617        SupportType::Rigid => is_face_rigid_supported(shape, direction),
618    }
619}
620
621#[derive(Clone, Copy)]
622struct FaceRect {
623    min_a: f64,
624    max_a: f64,
625    min_b: f64,
626    max_b: f64,
627}
628
629const FACE_EPSILON: f64 = 1.0e-6;
630
631fn face_rectangles_cover(
632    shape: VoxelShape,
633    direction: Direction,
634    target_min_a: f64,
635    target_max_a: f64,
636    target_min_b: f64,
637    target_max_b: f64,
638) -> bool {
639    let mut rects = Vec::new();
640    for aabb in shape {
641        let Some(rect) = face_rect_for_aabb(*aabb, direction) else {
642            continue;
643        };
644        if rect.max_a <= target_min_a
645            || rect.min_a >= target_max_a
646            || rect.max_b <= target_min_b
647            || rect.min_b >= target_max_b
648        {
649            continue;
650        }
651        rects.push(FaceRect {
652            min_a: rect.min_a.max(target_min_a),
653            max_a: rect.max_a.min(target_max_a),
654            min_b: rect.min_b.max(target_min_b),
655            max_b: rect.max_b.min(target_max_b),
656        });
657    }
658
659    if rects.is_empty() {
660        return false;
661    }
662
663    let mut a_edges = vec![target_min_a, target_max_a];
664    let mut b_edges = vec![target_min_b, target_max_b];
665    for rect in &rects {
666        a_edges.push(rect.min_a);
667        a_edges.push(rect.max_a);
668        b_edges.push(rect.min_b);
669        b_edges.push(rect.max_b);
670    }
671    sort_and_dedup_edges(&mut a_edges);
672    sort_and_dedup_edges(&mut b_edges);
673
674    for a_pair in a_edges.windows(2) {
675        if a_pair[1] - a_pair[0] <= FACE_EPSILON {
676            continue;
677        }
678        for b_pair in b_edges.windows(2) {
679            if b_pair[1] - b_pair[0] <= FACE_EPSILON {
680                continue;
681            }
682            let covered = rects.iter().any(|rect| {
683                rect.min_a <= a_pair[0] + FACE_EPSILON
684                    && rect.max_a >= a_pair[1] - FACE_EPSILON
685                    && rect.min_b <= b_pair[0] + FACE_EPSILON
686                    && rect.max_b >= b_pair[1] - FACE_EPSILON
687            });
688            if !covered {
689                return false;
690            }
691        }
692    }
693
694    true
695}
696
697fn face_rect_for_aabb(aabb: BlockLocalAabb, direction: Direction) -> Option<FaceRect> {
698    let rect = match direction {
699        Direction::Down if aabb.min_y() <= FACE_EPSILON => FaceRect {
700            min_a: aabb.min_x(),
701            max_a: aabb.max_x(),
702            min_b: aabb.min_z(),
703            max_b: aabb.max_z(),
704        },
705        Direction::Up if aabb.max_y() >= 1.0 - FACE_EPSILON => FaceRect {
706            min_a: aabb.min_x(),
707            max_a: aabb.max_x(),
708            min_b: aabb.min_z(),
709            max_b: aabb.max_z(),
710        },
711        Direction::North if aabb.min_z() <= FACE_EPSILON => FaceRect {
712            min_a: aabb.min_x(),
713            max_a: aabb.max_x(),
714            min_b: aabb.min_y(),
715            max_b: aabb.max_y(),
716        },
717        Direction::South if aabb.max_z() >= 1.0 - FACE_EPSILON => FaceRect {
718            min_a: aabb.min_x(),
719            max_a: aabb.max_x(),
720            min_b: aabb.min_y(),
721            max_b: aabb.max_y(),
722        },
723        Direction::West if aabb.min_x() <= FACE_EPSILON => FaceRect {
724            min_a: aabb.min_y(),
725            max_a: aabb.max_y(),
726            min_b: aabb.min_z(),
727            max_b: aabb.max_z(),
728        },
729        Direction::East if aabb.max_x() >= 1.0 - FACE_EPSILON => FaceRect {
730            min_a: aabb.min_y(),
731            max_a: aabb.max_y(),
732            min_b: aabb.min_z(),
733            max_b: aabb.max_z(),
734        },
735        _ => return None,
736    };
737
738    if rect.min_a >= rect.max_a || rect.min_b >= rect.max_b {
739        return None;
740    }
741    Some(rect)
742}
743
744fn sort_and_dedup_edges(edges: &mut Vec<f64>) {
745    edges.sort_by(|a, b| a.total_cmp(b));
746    edges.dedup_by(|a, b| (*a - *b).abs() <= FACE_EPSILON);
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    const QUADRANT_TOP_FACE: &[BlockLocalAabb] = &[
754        BlockLocalAabb::new(0.0, 0.5, 0.0, 0.5, 1.0, 0.5),
755        BlockLocalAabb::new(0.5, 0.5, 0.0, 1.0, 1.0, 0.5),
756        BlockLocalAabb::new(0.0, 0.5, 0.5, 0.5, 1.0, 1.0),
757        BlockLocalAabb::new(0.5, 0.5, 0.5, 1.0, 1.0, 1.0),
758    ];
759
760    const GAPPED_TOP_FACE: &[BlockLocalAabb] = &[
761        BlockLocalAabb::new(0.0, 0.5, 0.0, 0.45, 1.0, 1.0),
762        BlockLocalAabb::new(0.55, 0.5, 0.0, 1.0, 1.0, 1.0),
763    ];
764
765    const VANILLA_AZALEA_SHAPE: &[BlockLocalAabb] = &[
766        BlockLocalAabb::new(0.375, 0.0, 0.375, 0.625, 1.0, 0.625),
767        BlockLocalAabb::new(0.0, 0.5, 0.0, 0.375, 1.0, 1.0),
768        BlockLocalAabb::new(0.375, 0.5, 0.0, 1.0, 1.0, 0.375),
769        BlockLocalAabb::new(0.375, 0.5, 0.625, 1.0, 1.0, 1.0),
770        BlockLocalAabb::new(0.625, 0.5, 0.375, 1.0, 1.0, 0.625),
771    ];
772
773    const SPLIT_FULL_BLOCK: &[BlockLocalAabb] = &[
774        BlockLocalAabb::new(0.0, 0.0, 0.0, 0.5, 1.0, 1.0),
775        BlockLocalAabb::new(0.5, 0.0, 0.0, 1.0, 1.0, 1.0),
776    ];
777
778    const LOWER_HALF_BLOCK: &[BlockLocalAabb] =
779        &[BlockLocalAabb::new(0.0, 0.0, 0.0, 1.0, 0.5, 1.0)];
780
781    const UPPER_HALF_BLOCK: &[BlockLocalAabb] =
782        &[BlockLocalAabb::new(0.0, 0.5, 0.0, 1.0, 1.0, 1.0)];
783
784    const OVERLAPPING_HALF_BLOCKS: &[BlockLocalAabb] = &[
785        BlockLocalAabb::new(0.0, 0.0, 0.0, 0.75, 1.0, 1.0),
786        BlockLocalAabb::new(0.25, 0.0, 0.0, 1.0, 1.0, 1.0),
787    ];
788
789    const ZERO_VOLUME_BOX: &[BlockLocalAabb] = &[BlockLocalAabb::new(0.0, 0.0, 0.0, 1.0, 0.0, 1.0)];
790
791    const LARGE_COLLISION_SHAPE: &[BlockLocalAabb] =
792        &[BlockLocalAabb::new(-0.25, 0.0, 0.0, 1.0, 1.0, 1.0)];
793
794    const RIGID_TOP_RING: &[BlockLocalAabb] = &[
795        BlockLocalAabb::new(0.0, 0.0, 0.0, RIGID_BORDER, 1.0, 1.0),
796        BlockLocalAabb::new(1.0 - RIGID_BORDER, 0.0, 0.0, 1.0, 1.0, 1.0),
797        BlockLocalAabb::new(
798            RIGID_BORDER,
799            0.0,
800            0.0,
801            1.0 - RIGID_BORDER,
802            1.0,
803            RIGID_BORDER,
804        ),
805        BlockLocalAabb::new(
806            RIGID_BORDER,
807            0.0,
808            1.0 - RIGID_BORDER,
809            1.0 - RIGID_BORDER,
810            1.0,
811            1.0,
812        ),
813    ];
814
815    const RIGID_CENTER_PANEL: &[BlockLocalAabb] = &[BlockLocalAabb::new(
816        RIGID_BORDER,
817        0.0,
818        RIGID_BORDER,
819        1.0 - RIGID_BORDER,
820        1.0,
821        1.0 - RIGID_BORDER,
822    )];
823
824    const RIGID_WEST_FACE_RING: &[BlockLocalAabb] = &[
825        BlockLocalAabb::new(0.0, 0.0, 0.0, 1.0, RIGID_BORDER, 1.0),
826        BlockLocalAabb::new(0.0, 1.0 - RIGID_BORDER, 0.0, 1.0, 1.0, 1.0),
827        BlockLocalAabb::new(
828            0.0,
829            RIGID_BORDER,
830            0.0,
831            1.0,
832            1.0 - RIGID_BORDER,
833            RIGID_BORDER,
834        ),
835        BlockLocalAabb::new(
836            0.0,
837            RIGID_BORDER,
838            1.0 - RIGID_BORDER,
839            1.0,
840            1.0 - RIGID_BORDER,
841            1.0,
842        ),
843    ];
844
845    #[test]
846    fn boolean_op_matches_vanilla_truth_table() {
847        assert!(BooleanOp::OnlyFirst.apply(true, false));
848        assert!(!BooleanOp::OnlyFirst.apply(false, true));
849        assert!(BooleanOp::NotSame.apply(true, false));
850        assert!(!BooleanOp::NotSame.apply(true, true));
851        assert!(BooleanOp::Or.apply(false, true));
852        assert!(!BooleanOp::And.apply(true, false));
853    }
854
855    #[test]
856    fn join_is_not_empty_detects_intersection() {
857        assert!(join_is_not_empty(
858            VoxelShape::from_boxes(OVERLAPPING_HALF_BLOCKS),
859            VoxelShape::from_boxes(LOWER_HALF_BLOCK),
860            BooleanOp::And
861        ));
862    }
863
864    #[test]
865    fn join_is_not_empty_rejects_disjoint_and() {
866        assert!(!join_is_not_empty(
867            VoxelShape::from_boxes(LOWER_HALF_BLOCK),
868            VoxelShape::from_boxes(UPPER_HALF_BLOCK),
869            BooleanOp::And
870        ));
871    }
872
873    #[test]
874    fn join_is_not_empty_detects_only_first_remainder() {
875        assert!(join_is_not_empty(
876            VoxelShape::FULL_BLOCK,
877            VoxelShape::from_boxes(LOWER_HALF_BLOCK),
878            BooleanOp::OnlyFirst
879        ));
880    }
881
882    #[test]
883    fn join_unoptimized_boxes_materializes_only_second_remainder() {
884        let remainder = join_unoptimized_boxes(
885            VoxelShape::from_boxes(LOWER_HALF_BLOCK),
886            VoxelShape::FULL_BLOCK,
887            BooleanOp::OnlySecond,
888        );
889
890        assert_eq!(
891            remainder,
892            vec![BlockLocalAabb::new(0.0, 0.5, 0.0, 1.0, 1.0, 1.0)]
893        );
894    }
895
896    #[test]
897    fn shape_full_block_accepts_tiled_boxes() {
898        assert!(is_shape_full_block(VoxelShape::from_boxes(
899            SPLIT_FULL_BLOCK
900        )));
901    }
902
903    #[test]
904    fn shape_full_block_rejects_partial_boxes() {
905        assert!(!is_shape_full_block(VoxelShape::from_boxes(
906            LOWER_HALF_BLOCK
907        )));
908    }
909
910    #[test]
911    fn zero_volume_boxes_are_empty() {
912        assert!(VoxelShape::from_boxes(ZERO_VOLUME_BOX).is_empty());
913        assert!(!join_is_not_empty(
914            VoxelShape::from_boxes(ZERO_VOLUME_BOX),
915            VoxelShape::FULL_BLOCK,
916            BooleanOp::And
917        ));
918    }
919
920    #[test]
921    fn large_collision_shape_matches_vanilla_bounds_rule() {
922        assert!(!VoxelShape::EMPTY.has_large_collision_shape());
923        assert!(!VoxelShape::FULL_BLOCK.has_large_collision_shape());
924        assert!(VoxelShape::from_boxes(LARGE_COLLISION_SHAPE).has_large_collision_shape());
925    }
926
927    #[test]
928    fn face_full_accepts_union_covering_face() {
929        assert!(is_face_full(
930            VoxelShape::from_boxes(QUADRANT_TOP_FACE),
931            Direction::Up
932        ));
933    }
934
935    #[test]
936    fn face_full_rejects_union_with_gap() {
937        assert!(!is_face_full(
938            VoxelShape::from_boxes(GAPPED_TOP_FACE),
939            Direction::Up
940        ));
941    }
942
943    #[test]
944    fn face_full_accepts_vanilla_azalea_top_shape() {
945        assert!(is_face_full(
946            VoxelShape::from_boxes(VANILLA_AZALEA_SHAPE),
947            Direction::Up
948        ));
949    }
950
951    #[test]
952    fn rigid_support_accepts_border_ring_covered_by_multiple_boxes() {
953        assert!(is_face_rigid_supported(
954            VoxelShape::from_boxes(RIGID_TOP_RING),
955            Direction::Up
956        ));
957    }
958
959    #[test]
960    fn rigid_support_rejects_center_panel_without_border_ring() {
961        assert!(!is_face_rigid_supported(
962            VoxelShape::from_boxes(RIGID_CENTER_PANEL),
963            Direction::Up
964        ));
965    }
966
967    #[test]
968    fn rigid_support_rejects_side_border_ring_without_full_face() {
969        assert!(!is_face_rigid_supported(
970            VoxelShape::from_boxes(RIGID_WEST_FACE_RING),
971            Direction::West
972        ));
973    }
974}