1use 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#[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 entity_aabb.max(axis) - COLLISION_EPSILON > obstacle.min(axis)
97 && entity_aabb.min(axis) + COLLISION_EPSILON < obstacle.max(axis)
98}
99
100#[must_use]
113pub fn join_is_not_empty(aabb1: &WorldAabb, aabb2: &WorldAabb) -> bool {
114 aabb1.intersects(*aabb2)
115}
116
117#[must_use]
126pub fn translate_shape(shape: &BlockLocalAabb, block_pos: BlockPos) -> WorldAabb {
127 shape.at_block(block_pos)
128}
129
130#[must_use]
146pub fn merged_face_occludes(shape1: VoxelShape, shape2: VoxelShape, direction: Direction) -> bool {
147 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 let mut grid = [false; 256];
169 let mut coverage_count = 0;
170
171 coverage_count += project_shape_onto_grid(shape1, direction, &mut grid);
173 if coverage_count == 256 {
174 return true;
175 }
176
177 coverage_count += project_shape_onto_grid(shape2, direction.opposite(), &mut grid);
179 coverage_count == 256
180}
181
182#[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#[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 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 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); 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}