Skip to main content

steel_core/physics/
shapes.rs

1//! `VoxelShape` collision operations.
2//!
3//! Implements vanilla's `Shapes` class methods for AABB-list based collision.
4
5use steel_registry::blocks::properties::Direction;
6use steel_registry::blocks::shapes::{
7    OffsetVoxelShape, VoxelShape, is_offset_shape_full_block, is_shape_full_block,
8};
9use steel_utils::{BlockLocalAabb, BlockPos, WorldAabb, axis::Axis};
10
11const COLLISION_EPSILON: f64 = 1.0e-7;
12
13/// Computes the maximum safe movement along an axis for an entity AABB through a list of obstacle shapes.
14///
15/// This is the core collision function used by vanilla's `Shapes.collide()`.
16///
17/// # Arguments
18/// * `axis` - The axis along which to move (X, Y, or Z)
19/// * `entity_aabb` - The entity's current bounding box
20/// * `shapes` - List of obstacle shapes (block collision boxes) to test against
21/// * `desired_movement` - The desired movement distance along the axis
22///
23/// # Returns
24/// The maximum safe movement that won't cause collision (may be less than `desired_movement`).
25/// Returns the input value if no collision occurs.
26///
27/// # Algorithm
28/// For each obstacle AABB, check if the entity AABB (moved by `desired_movement` on the given axis)
29/// would intersect on the other two axes. If so, clip the movement to stop at the obstacle's face.
30///
31/// Matches: `net.minecraft.world.phys.shapes.Shapes.collide(Direction.Axis, AABB, List<AABB>, double)`
32#[must_use]
33pub fn collide(
34    axis: Axis,
35    entity_aabb: &WorldAabb,
36    shapes: &[WorldAabb],
37    desired_movement: f64,
38) -> f64 {
39    if desired_movement.abs() < COLLISION_EPSILON {
40        return 0.0;
41    }
42
43    let mut movement = desired_movement;
44
45    for shape in shapes {
46        movement = collide_single(axis, entity_aabb, shape, movement);
47
48        if movement.abs() < COLLISION_EPSILON {
49            return 0.0;
50        }
51    }
52
53    movement
54}
55
56fn collide_single(
57    axis: Axis,
58    entity_aabb: &WorldAabb,
59    obstacle: &WorldAabb,
60    desired_movement: f64,
61) -> f64 {
62    let (first_cross_axis, second_cross_axis) = cross_axes(axis);
63    if !overlaps_for_collision(entity_aabb, obstacle, first_cross_axis)
64        || !overlaps_for_collision(entity_aabb, obstacle, second_cross_axis)
65    {
66        return desired_movement;
67    }
68
69    if desired_movement > 0.0 {
70        let max_move = obstacle.min(axis) - entity_aabb.max(axis);
71        if max_move >= -COLLISION_EPSILON && max_move < desired_movement {
72            max_move
73        } else {
74            desired_movement
75        }
76    } else {
77        let max_move = obstacle.max(axis) - entity_aabb.min(axis);
78        if max_move <= COLLISION_EPSILON && max_move > desired_movement {
79            max_move
80        } else {
81            desired_movement
82        }
83    }
84}
85
86const fn cross_axes(axis: Axis) -> (Axis, Axis) {
87    match axis {
88        Axis::X => (Axis::Y, Axis::Z),
89        Axis::Y => (Axis::X, Axis::Z),
90        Axis::Z => (Axis::X, Axis::Y),
91    }
92}
93
94fn overlaps_for_collision(entity_aabb: &WorldAabb, obstacle: &WorldAabb, axis: Axis) -> bool {
95    // Vanilla looks up cross-axis cells using min + epsilon and max - epsilon.
96    entity_aabb.max(axis) - COLLISION_EPSILON > obstacle.min(axis)
97        && entity_aabb.min(axis) + COLLISION_EPSILON < obstacle.max(axis)
98}
99
100/// Tests if two shapes have a non-empty intersection (boolean AND operation).
101///
102/// This is used for "new collision" detection in movement validation.
103///
104/// # Arguments
105/// * `aabb1` - First AABB (typically entity's position after movement)
106/// * `aabb2` - Second AABB (typically a block collision shape)
107///
108/// # Returns
109/// `true` if the AABBs intersect (have overlapping volume), `false` otherwise.
110///
111/// Matches: `Shapes.joinIsNotEmpty(shape1, shape2, BooleanOp.AND)`
112#[must_use]
113pub fn join_is_not_empty(aabb1: &WorldAabb, aabb2: &WorldAabb) -> bool {
114    aabb1.intersects(*aabb2)
115}
116
117/// Translates a `VoxelShape` (block-local AABB) to world coordinates.
118///
119/// # Arguments
120/// * `shape` - Block-local AABB (0.0-1.0 space)
121/// * `block_pos` - World position of the block
122///
123/// # Returns
124/// World-space AABB at the block position.
125#[must_use]
126pub fn translate_shape(shape: &BlockLocalAabb, block_pos: BlockPos) -> WorldAabb {
127    shape.at_block(block_pos)
128}
129
130/// Checks if two voxel shapes fully occlude the face between them.
131/// Returns true if fluid/objects cannot pass through the face.
132///
133/// Direct equivalent of vanilla's `Shapes.mergedFaceOccludes(shape1, shape2, direction)`.
134///
135/// The algorithm:
136/// 1. Fast path: if **either** shape is a full cube → `true` (face fully sealed).
137/// 2. For each shape, keep only the face slice that actually touches the shared
138///    face boundary (shapes that don't reach the boundary contribute nothing).
139/// 3. Project both slices onto a 16×16 rasterisation grid and check if their
140///    union covers all 256 pixels.
141///
142/// Note: vanilla uses exact discrete-voxel arithmetic; the 16×16 rasterisation
143/// used here is equivalent for all vanilla block shapes (aligned to 1/16) but
144/// may have floating-point rounding for non-standard shapes from future mods.
145#[must_use]
146pub fn merged_face_occludes(shape1: VoxelShape, shape2: VoxelShape, direction: Direction) -> bool {
147    // Fast path — vanilla: if EITHER shape is a full block the face is sealed.
148    // (SteelMC previously required BOTH to be full — that was wrong.)
149    let is_s1_full = is_shape_full_block(shape1);
150    let is_s2_full = is_shape_full_block(shape2);
151
152    if is_s1_full || is_s2_full {
153        return true;
154    }
155
156    if shape1.is_empty() && shape2.is_empty() {
157        return false;
158    }
159
160    // Vanilla assigns shape3 / shape4 based on axis direction, then zeroes out
161    // any shape that does not actually touch the shared face boundary.
162    // We replicate this by passing the expected face to project_shape_onto_grid:
163    // shape1 contributes via the face it presents *toward* `direction` (its max face).
164    // shape2 contributes via the face it presents *against* `direction` (its min face).
165    // project_shape_onto_grid already checks `touches_face` per AABB, which is
166    // equivalent to vanilla's per-shape boundary check for single-AABB shapes.
167
168    let mut grid = [false; 256];
169    let mut coverage_count = 0;
170
171    // Project shape1 on the face it presents in `direction`
172    coverage_count += project_shape_onto_grid(shape1, direction, &mut grid);
173    if coverage_count == 256 {
174        return true;
175    }
176
177    // Project shape2 on the face it presents against `direction`
178    coverage_count += project_shape_onto_grid(shape2, direction.opposite(), &mut grid);
179    coverage_count == 256
180}
181
182/// Checks if two position-offset voxel shapes fully occlude the face between them.
183///
184/// This is the offset-aware form used by block states whose collision shape
185/// depends on `BlockState.getOffset(level, pos)`.
186#[must_use]
187pub fn merged_offset_face_occludes(
188    shape1: OffsetVoxelShape,
189    shape2: OffsetVoxelShape,
190    direction: Direction,
191) -> bool {
192    if is_offset_shape_full_block(shape1) || is_offset_shape_full_block(shape2) {
193        return true;
194    }
195
196    if shape1.is_empty() && shape2.is_empty() {
197        return false;
198    }
199
200    let mut grid = [false; 256];
201    let mut coverage_count = 0;
202
203    coverage_count += project_offset_shape_onto_grid(shape1, direction, &mut grid);
204    if coverage_count == 256 {
205        return true;
206    }
207
208    coverage_count += project_offset_shape_onto_grid(shape2, direction.opposite(), &mut grid);
209    coverage_count == 256
210}
211
212/// Checks whether two face occlusion shapes fully cover a block face.
213#[must_use]
214pub fn face_shape_occludes(
215    shape1: VoxelShape,
216    shape1_face: Direction,
217    shape2: VoxelShape,
218    shape2_face: Direction,
219) -> bool {
220    if is_shape_full_block(shape1) || is_shape_full_block(shape2) {
221        return true;
222    }
223
224    if shape1.is_empty() && shape2.is_empty() {
225        return false;
226    }
227
228    let mut grid = [false; 256];
229    let mut coverage_count = 0;
230
231    coverage_count += project_shape_onto_grid(shape1, shape1_face, &mut grid);
232    if coverage_count == 256 {
233        return true;
234    }
235
236    coverage_count += project_shape_onto_grid(shape2, shape2_face, &mut grid);
237    coverage_count == 256
238}
239
240fn project_shape_onto_grid(shape: VoxelShape, face: Direction, grid: &mut [bool; 256]) -> usize {
241    let mut added_coverage = 0;
242
243    for aabb in shape {
244        let touches_face = match face {
245            Direction::Down => aabb.min_y() <= 1.0e-5,
246            Direction::Up => aabb.max_y() >= 1.0 - 1.0e-5,
247            Direction::North => aabb.min_z() <= 1.0e-5,
248            Direction::South => aabb.max_z() >= 1.0 - 1.0e-5,
249            Direction::West => aabb.min_x() <= 1.0e-5,
250            Direction::East => aabb.max_x() >= 1.0 - 1.0e-5,
251        };
252
253        if !touches_face {
254            continue;
255        }
256
257        let (min_u, max_u, min_v, max_v) = match face {
258            Direction::Down | Direction::Up => {
259                (aabb.min_x(), aabb.max_x(), aabb.min_z(), aabb.max_z())
260            }
261            Direction::North | Direction::South => {
262                (aabb.min_x(), aabb.max_x(), aabb.min_y(), aabb.max_y())
263            }
264            Direction::West | Direction::East => {
265                (aabb.min_z(), aabb.max_z(), aabb.min_y(), aabb.max_y())
266            }
267        };
268
269        let u_start = ((min_u * 16.0).round() as i32).clamp(0, 16) as usize;
270        let u_end = ((max_u * 16.0).round() as i32).clamp(0, 16) as usize;
271        let v_start = ((min_v * 16.0).round() as i32).clamp(0, 16) as usize;
272        let v_end = ((max_v * 16.0).round() as i32).clamp(0, 16) as usize;
273
274        for u in u_start..u_end {
275            for v in v_start..v_end {
276                let idx = u * 16 + v;
277                if !grid[idx] {
278                    grid[idx] = true;
279                    added_coverage += 1;
280                }
281            }
282        }
283    }
284
285    added_coverage
286}
287
288fn project_offset_shape_onto_grid(
289    shape: OffsetVoxelShape,
290    face: Direction,
291    grid: &mut [bool; 256],
292) -> usize {
293    let mut added_coverage = 0;
294
295    for aabb in shape.iter() {
296        let touches_face = match face {
297            Direction::Down => aabb.min_y() <= 1.0e-5,
298            Direction::Up => aabb.max_y() >= 1.0 - 1.0e-5,
299            Direction::North => aabb.min_z() <= 1.0e-5,
300            Direction::South => aabb.max_z() >= 1.0 - 1.0e-5,
301            Direction::West => aabb.min_x() <= 1.0e-5,
302            Direction::East => aabb.max_x() >= 1.0 - 1.0e-5,
303        };
304
305        if !touches_face {
306            continue;
307        }
308
309        let (min_u, max_u, min_v, max_v) = match face {
310            Direction::Down | Direction::Up => {
311                (aabb.min_x(), aabb.max_x(), aabb.min_z(), aabb.max_z())
312            }
313            Direction::North | Direction::South => {
314                (aabb.min_x(), aabb.max_x(), aabb.min_y(), aabb.max_y())
315            }
316            Direction::West | Direction::East => {
317                (aabb.min_z(), aabb.max_z(), aabb.min_y(), aabb.max_y())
318            }
319        };
320
321        let u_start = ((min_u * 16.0).round() as i32).clamp(0, 16) as usize;
322        let u_end = ((max_u * 16.0).round() as i32).clamp(0, 16) as usize;
323        let v_start = ((min_v * 16.0).round() as i32).clamp(0, 16) as usize;
324        let v_end = ((max_v * 16.0).round() as i32).clamp(0, 16) as usize;
325
326        for u in u_start..u_end {
327            for v in v_start..v_end {
328                let idx = u * 16 + v;
329                if !grid[idx] {
330                    grid[idx] = true;
331                    added_coverage += 1;
332                }
333            }
334        }
335    }
336
337    added_coverage
338}
339
340#[cfg(test)]
341#[expect(clippy::float_cmp, reason = "exact match against vanilla test vectors")]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_collide_no_obstacle() {
347        let entity = WorldAabb::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
348
349        let result = collide(Axis::X, &entity, &[], 5.0);
350        assert_eq!(result, 5.0, "Should move full distance with no obstacles");
351    }
352
353    #[test]
354    fn test_collide_with_obstacle() {
355        let entity = WorldAabb::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
356
357        // Obstacle at x=2, blocking positive X movement
358        let obstacle = WorldAabb::new(2.0, 0.0, 0.0, 3.0, 1.0, 1.0);
359
360        let result = collide(Axis::X, &entity, &[obstacle], 5.0);
361        assert_eq!(
362            result, 1.0,
363            "Should stop at obstacle face (2.0 - 1.0 = 1.0)"
364        );
365    }
366
367    #[test]
368    fn test_collide_no_overlap_on_other_axes() {
369        let entity = WorldAabb::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
370
371        // Obstacle at x=2 but y=5 (no Y overlap)
372        let obstacle = WorldAabb::new(2.0, 5.0, 0.0, 3.0, 6.0, 1.0);
373
374        let result = collide(Axis::X, &entity, &[obstacle], 5.0);
375        assert_eq!(result, 5.0, "Should ignore obstacle with no Y overlap");
376    }
377
378    #[test]
379    fn collide_ignores_cross_axis_overlap_below_vanilla_epsilon() {
380        let entity = WorldAabb::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
381        let obstacle = WorldAabb::new(2.0, 1.0 - 0.5e-7, 0.0, 3.0, 2.0, 1.0);
382
383        let result = collide(Axis::X, &entity, &[obstacle], 5.0);
384        assert_eq!(result, 5.0);
385    }
386
387    #[test]
388    fn collide_keeps_cross_axis_overlap_above_vanilla_epsilon() {
389        let entity = WorldAabb::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
390        let obstacle = WorldAabb::new(2.0, 1.0 - 2.0e-7, 0.0, 3.0, 2.0, 1.0);
391
392        let result = collide(Axis::X, &entity, &[obstacle], 5.0);
393        assert_eq!(result, 1.0);
394    }
395
396    #[test]
397    fn test_join_is_not_empty_intersecting() {
398        let aabb1 = WorldAabb::new(0.0, 0.0, 0.0, 2.0, 2.0, 2.0);
399        let aabb2 = WorldAabb::new(1.0, 1.0, 1.0, 3.0, 3.0, 3.0);
400
401        assert!(
402            join_is_not_empty(&aabb1, &aabb2),
403            "Overlapping AABBs should intersect"
404        );
405    }
406
407    #[test]
408    fn test_join_is_not_empty_non_intersecting() {
409        let aabb1 = WorldAabb::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
410        let aabb2 = WorldAabb::new(2.0, 2.0, 2.0, 3.0, 3.0, 3.0);
411
412        assert!(
413            !join_is_not_empty(&aabb1, &aabb2),
414            "Separate AABBs should not intersect"
415        );
416    }
417
418    #[test]
419    fn test_translate_shape() {
420        let shape = BlockLocalAabb::new(0.0, 0.0, 0.0, 1.0, 0.5, 1.0); // Half slab
421        let block_pos = BlockPos::new(10, 64, -5);
422
423        let result = translate_shape(&shape, block_pos);
424
425        assert_eq!(result.min_x(), 10.0);
426        assert_eq!(result.min_y(), 64.0);
427        assert_eq!(result.min_z(), -5.0);
428        assert_eq!(result.max_x(), 11.0);
429        assert_eq!(result.max_y(), 64.5);
430        assert_eq!(result.max_z(), -4.0);
431    }
432
433    #[test]
434    fn merged_offset_face_occludes_respects_shape_offset() {
435        let shifted_up =
436            OffsetVoxelShape::new(VoxelShape::FULL_BLOCK, glam::DVec3::new(0.0, 0.25, 0.0));
437        let empty = OffsetVoxelShape::without_offset(VoxelShape::EMPTY);
438
439        assert!(!merged_offset_face_occludes(
440            shifted_up,
441            empty,
442            Direction::Down
443        ));
444    }
445}