1use super::*;
2use crate::chunk::gameplay_chunk_lookup_cache::LocalFullChunkHolderCache;
3use crate::physics::block_has_extensible_collision_behavior;
4use steel_registry::blocks::BlockRef;
5
6#[derive(Debug)]
10pub enum RaytraceAction {
11 Pass,
13 CheckShape,
15 ImmediateHit,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ClipBlockShape {
22 Collider,
24 Outline,
26 Visual,
28 FallDamageResetting {
30 entity_is_player: bool,
32 },
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ClipFluid {
38 None,
40 SourceOnly,
42 Any,
44 Water,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct ClipHitResult {
51 pub location: DVec3,
53 pub direction: Direction,
55 pub block_pos: BlockPos,
57 pub miss: bool,
59 pub inside: bool,
61 pub world_border_hit: bool,
63}
64
65const VANILLA_RAY_ENDPOINT_ADJUSTMENT: f64 = -1.0e-7;
66const AABB_CLIP_EPSILON: f64 = 1.0e-7;
67
68const EXPLOSION_EXPOSURE_CACHE_SIZE: usize = 256;
72const EXPLOSION_EXPOSURE_CACHE_MASK: usize = EXPLOSION_EXPOSURE_CACHE_SIZE - 1;
73const EXPLOSION_EXPOSURE_FIBONACCI_HASH_MULTIPLIER: u64 = 0x9e37_79b9_7f4a_7c15;
74const EMPTY_EXPOSURE_CACHE_GENERATION: u32 = 0;
75const FIRST_EXPOSURE_CACHE_GENERATION: u32 = 1;
76
77const EXPLOSION_EXPOSURE_CLEAR_GRID_AXIS_SIZE: usize = 19;
80const EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_BITS: usize = 2;
81const EXPLOSION_EXPOSURE_CLEAR_GRID_STATES_PER_WORD: usize =
82 u64::BITS as usize / EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_BITS;
83const EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_MASK: u64 =
84 (1 << EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_BITS) - 1;
85const EXPLOSION_EXPOSURE_CLEAR_GRID_POSITIONS: usize = EXPLOSION_EXPOSURE_CLEAR_GRID_AXIS_SIZE
86 * EXPLOSION_EXPOSURE_CLEAR_GRID_AXIS_SIZE
87 * EXPLOSION_EXPOSURE_CLEAR_GRID_AXIS_SIZE;
88const EXPLOSION_EXPOSURE_CLEAR_GRID_WORDS: usize =
89 EXPLOSION_EXPOSURE_CLEAR_GRID_POSITIONS.div_ceil(EXPLOSION_EXPOSURE_CLEAR_GRID_STATES_PER_WORD);
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92#[repr(u8)]
93enum ExplosionExposureClearGridState {
94 Unresolved = 0,
95 StaticEmpty = 1,
96 RequiresExactCollision = 2,
97}
98
99const UNRESOLVED_EXPOSURE_GRID_WORD: u64 = ExplosionExposureClearGridState::Unresolved as u64;
100
101impl ExplosionExposureClearGridState {
102 const fn for_collision(is_empty: bool) -> Self {
103 if is_empty {
104 Self::StaticEmpty
105 } else {
106 Self::RequiresExactCollision
107 }
108 }
109
110 const fn from_encoded(encoded: u64) -> Self {
111 if encoded == Self::Unresolved as u64 {
112 Self::Unresolved
113 } else if encoded == Self::StaticEmpty as u64 {
114 Self::StaticEmpty
115 } else {
116 Self::RequiresExactCollision
118 }
119 }
120}
121
122#[derive(Clone, Copy)]
123struct ExplosionExposureClearGridBounds {
124 min: BlockPos,
125 size_x: usize,
126 size_y: usize,
127 size_z: usize,
128}
129
130struct ExplosionExposureClearGrid {
131 bounds: Option<ExplosionExposureClearGridBounds>,
132 states: [u64; EXPLOSION_EXPOSURE_CLEAR_GRID_WORDS],
134}
135
136impl ExplosionExposureClearGrid {
137 const fn new() -> Self {
138 Self {
139 bounds: None,
140 states: [UNRESOLVED_EXPOSURE_GRID_WORD; EXPLOSION_EXPOSURE_CLEAR_GRID_WORDS],
141 }
142 }
143
144 fn configure(&mut self, min: BlockPos, max: BlockPos) {
145 let Some(size_x) = inclusive_axis_size(min.x(), max.x()) else {
146 self.bounds = None;
147 return;
148 };
149 let Some(size_y) = inclusive_axis_size(min.y(), max.y()) else {
150 self.bounds = None;
151 return;
152 };
153 let Some(size_z) = inclusive_axis_size(min.z(), max.z()) else {
154 self.bounds = None;
155 return;
156 };
157 let Some(volume) = size_x
158 .checked_mul(size_y)
159 .and_then(|plane| plane.checked_mul(size_z))
160 else {
161 self.bounds = None;
162 return;
163 };
164 if volume > EXPLOSION_EXPOSURE_CLEAR_GRID_POSITIONS {
165 self.bounds = None;
166 return;
167 }
168
169 self.bounds = Some(ExplosionExposureClearGridBounds {
170 min,
171 size_x,
172 size_y,
173 size_z,
174 });
175 self.states.fill(UNRESOLVED_EXPOSURE_GRID_WORD);
176 }
177
178 fn clear(&mut self) {
179 self.states.fill(UNRESOLVED_EXPOSURE_GRID_WORD);
180 }
181
182 fn index(&self, pos: BlockPos) -> Option<usize> {
183 let bounds = self.bounds?;
184 let x = pos.x().wrapping_sub(bounds.min.x()) as u32 as usize;
188 let y = pos.y().wrapping_sub(bounds.min.y()) as u32 as usize;
189 let z = pos.z().wrapping_sub(bounds.min.z()) as u32 as usize;
190 if x >= bounds.size_x || y >= bounds.size_y || z >= bounds.size_z {
191 return None;
192 }
193 Some((y * bounds.size_z + z) * bounds.size_x + x)
194 }
195
196 const fn state(&self, index: usize) -> ExplosionExposureClearGridState {
197 let word = index / EXPLOSION_EXPOSURE_CLEAR_GRID_STATES_PER_WORD;
198 let shift = (index % EXPLOSION_EXPOSURE_CLEAR_GRID_STATES_PER_WORD)
199 * EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_BITS;
200 let encoded = (self.states[word] >> shift) & EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_MASK;
201 ExplosionExposureClearGridState::from_encoded(encoded)
202 }
203
204 fn record(&mut self, index: usize, state: ExplosionExposureClearGridState) {
205 debug_assert_ne!(state, ExplosionExposureClearGridState::Unresolved);
206 let word = index / EXPLOSION_EXPOSURE_CLEAR_GRID_STATES_PER_WORD;
207 let shift = (index % EXPLOSION_EXPOSURE_CLEAR_GRID_STATES_PER_WORD)
208 * EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_BITS;
209 let mask = EXPLOSION_EXPOSURE_CLEAR_GRID_STATE_MASK << shift;
210 let value = (state as u64) << shift;
211 self.states[word] = (self.states[word] & !mask) | value;
212 }
213}
214
215fn inclusive_axis_size(min: i32, max: i32) -> Option<usize> {
216 let size = usize::try_from(max.checked_sub(min)?.checked_add(1)?).ok()?;
217 (size != 0).then_some(size)
218}
219
220#[derive(Clone, Copy)]
221struct ExplosionExposureCacheEntry {
222 pos: BlockPos,
223 collision: OffsetVoxelShape,
224 generation: u32,
225}
226
227impl ExplosionExposureCacheEntry {
228 const EMPTY: Self = Self {
229 pos: BlockPos::new(0, 0, 0),
230 collision: OffsetVoxelShape::without_offset(VoxelShape::EMPTY),
231 generation: EMPTY_EXPOSURE_CACHE_GENERATION,
232 };
233}
234
235#[derive(Clone, Copy)]
236struct ExplosionExposureBlockRead {
237 state: BlockStateId,
238 cacheable: bool,
239}
240
241pub(crate) struct ExplosionExposureRaycast<'world> {
250 world: &'world World,
251 collision_context: BlockCollisionContext,
252 full_chunks: LocalFullChunkHolderCache,
253 entries: [ExplosionExposureCacheEntry; EXPLOSION_EXPOSURE_CACHE_SIZE],
254 clear_grid: ExplosionExposureClearGrid,
255 generation: u32,
256 #[cfg(test)]
257 stats: ExplosionExposureRaycastStats,
258}
259
260#[cfg(test)]
261#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
262pub(crate) struct ExplosionExposureRaycastStats {
263 pub(crate) block_visits: usize,
264 pub(crate) cache_hits: usize,
265 pub(crate) state_lookups: usize,
266 pub(crate) collision_lookups: usize,
267 pub(crate) clear_grid_hits: usize,
268 pub(crate) clear_grid_resolutions: usize,
269 pub(crate) stable_air_queries: usize,
270 pub(crate) stable_air_hits: usize,
271}
272
273impl<'world> ExplosionExposureRaycast<'world> {
274 pub(crate) const fn new(
275 world: &'world World,
276 collision_context: BlockCollisionContext,
277 ) -> Self {
278 Self {
279 world,
280 collision_context,
281 full_chunks: LocalFullChunkHolderCache::new(),
282 entries: [ExplosionExposureCacheEntry::EMPTY; EXPLOSION_EXPOSURE_CACHE_SIZE],
283 clear_grid: ExplosionExposureClearGrid::new(),
284 generation: FIRST_EXPOSURE_CACHE_GENERATION,
285 #[cfg(test)]
286 stats: ExplosionExposureRaycastStats {
287 block_visits: 0,
288 cache_hits: 0,
289 state_lookups: 0,
290 collision_lookups: 0,
291 clear_grid_hits: 0,
292 clear_grid_resolutions: 0,
293 stable_air_queries: 0,
294 stable_air_hits: 0,
295 },
296 }
297 }
298
299 pub(crate) fn configure_clear_grid(&mut self, min: BlockPos, max: BlockPos) {
304 self.clear_grid.configure(min, max);
305 }
306
307 pub(crate) fn clear(&mut self) {
309 self.clear_grid.clear();
310 if self.generation == u32::MAX {
311 self.entries.fill(ExplosionExposureCacheEntry::EMPTY);
312 self.generation = FIRST_EXPOSURE_CACHE_GENERATION;
313 } else {
314 self.generation += 1;
315 }
316 }
317
318 pub(crate) const fn set_collision_context(&mut self, collision_context: BlockCollisionContext) {
320 self.collision_context = collision_context;
321 }
322
323 pub(crate) fn stable_air_box_is_clear(&mut self, bounds: BlockRegionBounds) -> bool {
325 #[cfg(test)]
326 {
327 self.stats.stable_air_queries += 1;
328 }
329 #[expect(
330 clippy::redundant_closure_for_method_calls,
331 reason = "the method item cannot satisfy the region callback's higher-ranked lifetime"
332 )]
333 let Some(clear) = self
334 .world
335 .try_with_block_region(bounds, |region| region.contains_only_stable_air())
336 else {
337 return false;
338 };
339 #[cfg(test)]
340 if clear {
341 self.stats.stable_air_hits += 1;
342 }
343 clear
344 }
345
346 fn retain_cache_after_collision_query(&mut self, block: BlockRef) -> bool {
351 let retain = !block_has_extensible_collision_behavior(block);
352 if !retain {
353 self.clear();
354 }
355 retain
356 }
357
358 pub(crate) fn is_path_clear(&mut self, from: DVec3, to: DVec3) -> bool {
360 is_collision_path_clear(from, to, |pos| self.block_intersects_ray(pos, from, to))
361 }
362
363 fn block_intersects_ray(&mut self, pos: BlockPos, from: DVec3, to: DVec3) -> bool {
364 #[cfg(test)]
365 {
366 self.stats.block_visits += 1;
367 }
368
369 let mut unresolved_grid_index = None;
370 if let Some(index) = self.clear_grid.index(pos) {
371 match self.clear_grid.state(index) {
372 ExplosionExposureClearGridState::StaticEmpty => {
373 #[cfg(test)]
374 {
375 self.stats.clear_grid_hits += 1;
376 }
377 return false;
378 }
379 ExplosionExposureClearGridState::RequiresExactCollision => {}
380 ExplosionExposureClearGridState::Unresolved => {
381 unresolved_grid_index = Some(index);
382 }
383 }
384 }
385
386 self.block_intersects_ray_exact(pos, from, to, unresolved_grid_index)
387 }
388
389 fn block_intersects_ray_exact(
390 &mut self,
391 pos: BlockPos,
392 from: DVec3,
393 to: DVec3,
394 unresolved_grid_index: Option<usize>,
395 ) -> bool {
396 let cache_index = explosion_exposure_cache_index(pos);
397 let entry = self.entries[cache_index];
398 if entry.generation == self.generation && entry.pos == pos {
399 #[cfg(test)]
400 {
401 self.stats.cache_hits += 1;
402 }
403 if let Some(index) = unresolved_grid_index {
404 let grid_state =
405 ExplosionExposureClearGridState::for_collision(entry.collision.is_empty());
406 self.clear_grid.record(index, grid_state);
407 #[cfg(test)]
408 {
409 self.stats.clear_grid_resolutions += 1;
410 }
411 }
412 return Self::static_collision_intersects(entry.collision, pos, from, to);
413 }
414
415 #[cfg(test)]
416 {
417 self.stats.state_lookups += 1;
418 self.stats.collision_lookups += 1;
419 }
420 let block_read = self
421 .world
422 .explosion_exposure_block_state(pos, &mut self.full_chunks);
423 let state = block_read.state;
424 let block = state.get_block();
425 let behavior = BLOCK_BEHAVIORS.get_behavior(block);
426 if block.config.dynamic_shape {
427 let boxes =
428 behavior.get_collision_boxes(state, self.world, pos, self.collision_context);
429 self.retain_cache_after_collision_query(block);
430 return boxes
431 .into_iter()
432 .any(|aabb| World::clip_local_aabb(pos, from, to, aabb).is_some());
433 }
434
435 let shape = behavior.get_collision_shape(state, self.world, pos, self.collision_context);
436 let offset = if shape.is_empty() {
437 DVec3::ZERO
438 } else {
439 behavior.get_collision_shape_offset(state, self.world, pos, self.collision_context)
440 };
441 let collision = OffsetVoxelShape::new(shape, offset);
442 let retain_cache = self.retain_cache_after_collision_query(block);
443 let intersects = Self::static_collision_intersects(collision, pos, from, to);
444 if block_read.cacheable && retain_cache {
445 let collision_is_empty = collision.is_empty();
446 let mut recorded_in_grid = false;
447 if let Some(index) = unresolved_grid_index {
448 let grid_state = ExplosionExposureClearGridState::for_collision(collision_is_empty);
449 self.clear_grid.record(index, grid_state);
450 recorded_in_grid = true;
451 #[cfg(test)]
452 {
453 self.stats.clear_grid_resolutions += 1;
454 }
455 }
456 if !recorded_in_grid || !collision_is_empty {
459 self.entries[cache_index] = ExplosionExposureCacheEntry {
460 pos,
461 collision,
462 generation: self.generation,
463 };
464 }
465 }
466 intersects
467 }
468
469 fn static_collision_intersects(
470 collision: OffsetVoxelShape,
471 pos: BlockPos,
472 from: DVec3,
473 to: DVec3,
474 ) -> bool {
475 collision
476 .iter()
477 .any(|aabb| World::clip_local_aabb(pos, from, to, aabb).is_some())
478 }
479
480 #[cfg(test)]
481 pub(crate) const fn stats(&self) -> ExplosionExposureRaycastStats {
482 self.stats
483 }
484}
485
486#[inline]
487fn explosion_exposure_cache_index(pos: BlockPos) -> usize {
488 let tag = PackedBlockPos::from(pos).as_raw() as u64;
489 let mut mixed = tag.wrapping_mul(EXPLOSION_EXPOSURE_FIBONACCI_HASH_MULTIPLIER);
492 mixed ^= mixed >> (u64::BITS / 2);
493 mixed ^= mixed >> (u64::BITS / 4);
494 (mixed as usize) & EXPLOSION_EXPOSURE_CACHE_MASK
495}
496
497#[inline]
498fn is_collision_path_clear(
499 start_pos: DVec3,
500 end_pos: DVec3,
501 mut blocks_ray: impl FnMut(BlockPos) -> bool,
502) -> bool {
503 if start_pos == end_pos {
504 return true;
505 }
506
507 let to = vanilla_lerp_vec3(VANILLA_RAY_ENDPOINT_ADJUSTMENT, end_pos, start_pos);
508 let from = vanilla_lerp_vec3(VANILLA_RAY_ENDPOINT_ADJUSTMENT, start_pos, end_pos);
509 let mut block = BlockPos::from(from);
510 if blocks_ray(block) {
511 return false;
512 }
513
514 let difference = to - from;
515 let step = glam::IVec3::new(
516 minecraft_sign(difference.x),
517 minecraft_sign(difference.y),
518 minecraft_sign(difference.z),
519 );
520 let delta = DVec3::new(
521 if step.x == 0 {
522 f64::MAX
523 } else {
524 f64::from(step.x) / difference.x
525 },
526 if step.y == 0 {
527 f64::MAX
528 } else {
529 f64::from(step.y) / difference.y
530 },
531 if step.z == 0 {
532 f64::MAX
533 } else {
534 f64::from(step.z) / difference.z
535 },
536 );
537 let mut next = DVec3::new(
538 delta.x
539 * if step.x > 0 {
540 1.0 - (from.x - from.x.floor())
541 } else {
542 from.x - from.x.floor()
543 },
544 delta.y
545 * if step.y > 0 {
546 1.0 - (from.y - from.y.floor())
547 } else {
548 from.y - from.y.floor()
549 },
550 delta.z
551 * if step.z > 0 {
552 1.0 - (from.z - from.z.floor())
553 } else {
554 from.z - from.z.floor()
555 },
556 );
557
558 while next.x <= 1.0 || next.y <= 1.0 || next.z <= 1.0 {
559 if next.x < next.y && next.x < next.z {
560 block.0.x += step.x;
561 next.x += delta.x;
562 } else if next.y < next.z {
563 block.0.y += step.y;
564 next.y += delta.y;
565 } else {
566 block.0.z += step.z;
567 next.z += delta.z;
568 }
569 if blocks_ray(block) {
570 return false;
571 }
572 }
573
574 true
575}
576
577pub(crate) fn collision_path_axis_block_bounds(
583 start: f64,
584 end: f64,
585 max_steps: usize,
586) -> Option<(i32, i32)> {
587 if !start.is_finite() || !end.is_finite() {
588 return None;
589 }
590
591 let to = vanilla_lerp(VANILLA_RAY_ENDPOINT_ADJUSTMENT, end, start);
592 let from = vanilla_lerp(VANILLA_RAY_ENDPOINT_ADJUSTMENT, start, end);
593 let can_initialize_block_traversal = to.is_finite()
594 && from.is_finite()
595 && from >= f64::from(i32::MIN)
596 && from < f64::from(i32::MAX) + 1.0;
597 if !can_initialize_block_traversal {
598 return None;
599 }
600
601 let mut block = from.floor() as i32;
602 let mut min = block;
603 let mut max = block;
604 let difference = to - from;
605 let step = minecraft_sign(difference);
606 let delta = if step == 0 {
607 f64::MAX
608 } else {
609 f64::from(step) / difference
610 };
611 let from_fraction = from - from.floor();
612 let mut next = delta
613 * if step > 0 {
614 1.0 - from_fraction
615 } else {
616 from_fraction
617 };
618 if !delta.is_finite() || !next.is_finite() {
619 return None;
620 }
621
622 let mut steps = 0;
623 while next <= 1.0 {
624 if steps == max_steps {
625 return None;
626 }
627 block = block.checked_add(step)?;
628 min = min.min(block);
629 max = max.max(block);
630 next += delta;
631 steps += 1;
632 }
633 Some((min, max))
634}
635
636#[inline]
637const fn minecraft_sign(number: f64) -> i32 {
638 if number == 0.0 {
639 0
640 } else if number > 0.0 {
641 1
642 } else {
643 -1
644 }
645}
646
647#[inline]
648fn vanilla_lerp(alpha: f64, start: f64, end: f64) -> f64 {
649 start + alpha * (end - start)
650}
651
652#[inline]
653fn vanilla_lerp_vec3(alpha: f64, start: DVec3, end: DVec3) -> DVec3 {
654 DVec3::new(
657 vanilla_lerp(alpha, start.x, end.x),
658 vanilla_lerp(alpha, start.y, end.y),
659 vanilla_lerp(alpha, start.z, end.z),
660 )
661}
662
663impl ClipHitResult {
664 #[must_use]
666 pub const fn is_miss(self) -> bool {
667 self.miss
668 }
669}
670
671impl World {
672 fn explosion_exposure_block_state(
675 &self,
676 pos: BlockPos,
677 full_chunks: &mut LocalFullChunkHolderCache,
678 ) -> ExplosionExposureBlockRead {
679 if !self.is_in_valid_bounds(pos) {
680 return ExplosionExposureBlockRead {
681 state: REGISTRY.blocks.get_base_state_id(&vanilla_blocks::VOID_AIR),
682 cacheable: true,
683 };
684 }
685
686 full_chunks.block_state(&self.chunk_map, pos).map_or_else(
687 || ExplosionExposureBlockRead {
688 state: REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR),
689 cacheable: false,
690 },
691 |state| ExplosionExposureBlockRead {
692 state,
693 cacheable: true,
694 },
695 )
696 }
697
698 pub fn ray_outline_check(
700 &self,
701 block_pos: BlockPos,
702 from: DVec3,
703 to: DVec3,
704 ) -> (bool, Option<Direction>) {
705 let state = self.get_block_state(block_pos);
706 let shape = state.get_outline_shape_at(block_pos);
707
708 match Self::clip_shape(block_pos, from, to, shape) {
709 Some(hit) => (true, Some(hit.direction)),
710 None => (false, None),
711 }
712 }
713
714 #[must_use]
716 pub fn clip(
717 &self,
718 start_pos: DVec3,
719 end_pos: DVec3,
720 block_shape: ClipBlockShape,
721 fluid: ClipFluid,
722 ) -> ClipHitResult {
723 if start_pos == end_pos {
724 return Self::clip_miss(start_pos, end_pos);
725 }
726
727 let adjust = -1.0e-7f64;
728 let to = end_pos.lerp(start_pos, adjust);
729 let from = start_pos.lerp(end_pos, adjust);
730
731 let mut block = BlockPos::new(
732 from.x.floor() as i32,
733 from.y.floor() as i32,
734 from.z.floor() as i32,
735 );
736
737 if let Some(hit) = self.clip_block_and_fluid(block, start_pos, end_pos, block_shape, fluid)
738 {
739 return hit;
740 }
741
742 let difference = to - from;
743
744 let step = difference.signum().as_ivec3();
745
746 let delta = DVec3::new(
747 if step.x == 0 {
748 f64::MAX
749 } else {
750 (f64::from(step.x)) / difference.x
751 },
752 if step.y == 0 {
753 f64::MAX
754 } else {
755 (f64::from(step.y)) / difference.y
756 },
757 if step.z == 0 {
758 f64::MAX
759 } else {
760 (f64::from(step.z)) / difference.z
761 },
762 );
763
764 let mut next = DVec3::new(
765 delta.x
766 * (if step.x > 0 {
767 1.0 - (from.x - from.x.floor())
768 } else {
769 from.x - from.x.floor()
770 }),
771 delta.y
772 * (if step.y > 0 {
773 1.0 - (from.y - from.y.floor())
774 } else {
775 from.y - from.y.floor()
776 }),
777 delta.z
778 * (if step.z > 0 {
779 1.0 - (from.z - from.z.floor())
780 } else {
781 from.z - from.z.floor()
782 }),
783 );
784
785 while next.x <= 1.0 || next.y <= 1.0 || next.z <= 1.0 {
786 if next.x < next.y && next.x < next.z {
787 block.0.x += step.x;
788 next.x += delta.x;
789 } else if next.y < next.z {
790 block.0.y += step.y;
791 next.y += delta.y;
792 } else {
793 block.0.z += step.z;
794 next.z += delta.z;
795 }
796
797 if let Some(hit) =
798 self.clip_block_and_fluid(block, start_pos, end_pos, block_shape, fluid)
799 {
800 return hit;
801 }
802 }
803
804 Self::clip_miss(start_pos, end_pos)
805 }
806
807 #[cfg(test)]
809 pub(crate) fn is_block_collision_path_clear(
810 &self,
811 start_pos: DVec3,
812 end_pos: DVec3,
813 collision_context: BlockCollisionContext,
814 ) -> bool {
815 is_collision_path_clear(start_pos, end_pos, |pos| {
816 let state = self.get_block_state(pos);
817 let boxes = BLOCK_BEHAVIORS
818 .get_behavior(state.get_block())
819 .get_collision_boxes(state, self, pos, collision_context);
820 boxes
821 .into_iter()
822 .any(|aabb| Self::clip_local_aabb(pos, start_pos, end_pos, aabb).is_some())
823 })
824 }
825
826 #[must_use]
829 pub fn clip_including_border(
830 &self,
831 start_pos: DVec3,
832 end_pos: DVec3,
833 block_shape: ClipBlockShape,
834 fluid: ClipFluid,
835 ) -> ClipHitResult {
836 let hit = self.clip(start_pos, end_pos, block_shape, fluid);
837 let border = self.world_border_snapshot();
838 if border.is_within_bounds_with_margin(start_pos.x, start_pos.z, 0.0)
839 && !border.is_within_bounds_with_margin(hit.location.x, hit.location.z, 0.0)
840 {
841 let delta = hit.location - start_pos;
842 let location = border.clamp_vec3_to_bound(hit.location);
843 return ClipHitResult {
844 location,
845 direction: Self::approximate_nearest_direction(delta),
846 block_pos: BlockPos::from(location),
847 miss: false,
848 inside: false,
849 world_border_hit: true,
850 };
851 }
852 hit
853 }
854
855 pub(super) fn clip_block_and_fluid(
856 &self,
857 pos: BlockPos,
858 from: DVec3,
859 to: DVec3,
860 block_shape: ClipBlockShape,
861 fluid: ClipFluid,
862 ) -> Option<ClipHitResult> {
863 let state = self.get_block_state(pos);
864 let block_result = Self::clip_shape(
865 pos,
866 from,
867 to,
868 self.clip_block_shape(state, pos, block_shape),
869 )
870 .map(|hit| Self::clip_with_interaction_override(pos, from, to, state, hit));
871 let fluid_result = self.clip_fluid_shape(pos, from, to, state, fluid);
872
873 match (block_result, fluid_result) {
874 (Some(block_hit), Some(fluid_hit)) => {
875 let block_distance = from.distance_squared(block_hit.location);
876 let fluid_distance = from.distance_squared(fluid_hit.location);
877 if block_distance <= fluid_distance {
878 Some(block_hit)
879 } else {
880 Some(fluid_hit)
881 }
882 }
883 (Some(hit), None) | (None, Some(hit)) => Some(hit),
884 (None, None) => None,
885 }
886 }
887
888 pub(super) fn clip_with_interaction_override(
889 pos: BlockPos,
890 from: DVec3,
891 to: DVec3,
892 state: BlockStateId,
893 block_hit: ClipHitResult,
894 ) -> ClipHitResult {
895 let Some(override_hit) =
896 Self::clip_shape(pos, from, to, state.get_interaction_shape_at(pos))
897 else {
898 return block_hit;
899 };
900
901 if from.distance_squared(override_hit.location) < from.distance_squared(block_hit.location)
902 {
903 ClipHitResult {
904 direction: override_hit.direction,
905 ..block_hit
906 }
907 } else {
908 block_hit
909 }
910 }
911
912 pub(super) fn clip_block_shape(
913 &self,
914 state: BlockStateId,
915 pos: BlockPos,
916 shape: ClipBlockShape,
917 ) -> OffsetVoxelShape {
918 match shape {
919 ClipBlockShape::Collider => state.get_collision_shape_at(pos),
920 ClipBlockShape::Outline => state.get_outline_shape_at(pos),
921 ClipBlockShape::Visual => state.get_visual_shape_at(pos),
922 ClipBlockShape::FallDamageResetting { entity_is_player } => {
923 OffsetVoxelShape::without_offset(
924 self.fall_damage_resetting_shape(state, entity_is_player),
925 )
926 }
927 }
928 }
929
930 pub(super) fn fall_damage_resetting_shape(
931 &self,
932 state: BlockStateId,
933 entity_is_player: bool,
934 ) -> VoxelShape {
935 let block = state.get_block();
936 if block.has_tag(&BlockTag::FALL_DAMAGE_RESETTING) {
937 return VoxelShape::FULL_BLOCK;
938 }
939
940 if !entity_is_player {
941 return VoxelShape::EMPTY;
942 }
943
944 if block == &vanilla_blocks::END_GATEWAY || block == &vanilla_blocks::END_PORTAL {
945 return VoxelShape::FULL_BLOCK;
946 }
947
948 if block == &vanilla_blocks::NETHER_PORTAL
949 && self.get_game_rule(&PLAYERS_NETHER_PORTAL_DEFAULT_DELAY) == 0
950 {
951 return VoxelShape::FULL_BLOCK;
952 }
953
954 VoxelShape::EMPTY
955 }
956
957 pub(super) fn clip_fluid_shape(
958 &self,
959 pos: BlockPos,
960 from: DVec3,
961 to: DVec3,
962 state: BlockStateId,
963 fluid: ClipFluid,
964 ) -> Option<ClipHitResult> {
965 let fluid_state = state.get_fluid_state();
966 let can_pick = match fluid {
967 ClipFluid::None => false,
968 ClipFluid::SourceOnly => fluid_state.is_source(),
969 ClipFluid::Any => !fluid_state.is_empty(),
970 ClipFluid::Water => fluid_state.is_water(),
971 };
972 if !can_pick {
973 return None;
974 }
975
976 let height = self.fluid_clip_height(pos, fluid_state);
977 Self::clip_local_aabb(
978 pos,
979 from,
980 to,
981 BlockLocalAabb::new(0.0, 0.0, 0.0, 1.0, height, 1.0),
982 )
983 }
984
985 pub(super) fn fluid_clip_height(&self, pos: BlockPos, fluid_state: FluidState) -> f64 {
986 let above_fluid = self.get_block_state(pos.above()).get_fluid_state();
987 Self::fluid_clip_height_from_above(fluid_state, above_fluid)
988 }
989
990 pub(super) fn fluid_clip_height_from_above(
991 fluid_state: FluidState,
992 above_fluid: FluidState,
993 ) -> f64 {
994 if FLUID_BEHAVIORS
995 .get_behavior(fluid_state.fluid_id)
996 .is_same(above_fluid.fluid_id)
997 {
998 1.0
999 } else {
1000 f64::from(fluid_state.own_height())
1001 }
1002 }
1003
1004 pub(super) fn clip_shape(
1005 block_pos: BlockPos,
1006 from: DVec3,
1007 to: DVec3,
1008 shape: OffsetVoxelShape,
1009 ) -> Option<ClipHitResult> {
1010 if shape.is_empty() {
1011 return None;
1012 }
1013
1014 if (to - from).length_squared() < 1.0e-7 {
1015 return None;
1016 }
1017
1018 let block_vec = DVec3::new(
1019 f64::from(block_pos.x()),
1020 f64::from(block_pos.y()),
1021 f64::from(block_pos.z()),
1022 );
1023 let inside_test_point = from + (to - from) * 0.001;
1024 if Self::shape_contains_world_point(shape, block_vec, inside_test_point) {
1025 return Some(ClipHitResult {
1026 location: inside_test_point,
1027 direction: Self::approximate_nearest_direction(to - from).opposite(),
1028 block_pos,
1029 miss: false,
1030 inside: true,
1031 world_border_hit: false,
1032 });
1033 }
1034
1035 let mut closest: Option<(f64, Direction)> = None;
1036
1037 for shape in shape.iter() {
1038 let world_min = DVec3::new(shape.min_x(), shape.min_y(), shape.min_z()) + block_vec;
1039 let world_max = DVec3::new(shape.max_x(), shape.max_y(), shape.max_z()) + block_vec;
1040
1041 if let Some(hit) = Self::intersects_aabb_with_t(from, to, world_min, world_max)
1042 && hit.0 > 0.0
1043 && hit.0 < 1.0
1044 && closest.is_none_or(|(best_t, _)| hit.0 < best_t)
1045 {
1046 closest = Some(hit);
1047 }
1048 }
1049
1050 closest.map(|(t, direction)| ClipHitResult {
1051 location: from + (to - from) * t,
1052 direction,
1053 block_pos,
1054 miss: false,
1055 inside: false,
1056 world_border_hit: false,
1057 })
1058 }
1059
1060 pub(super) fn clip_local_aabb(
1061 block_pos: BlockPos,
1062 from: DVec3,
1063 to: DVec3,
1064 aabb: BlockLocalAabb,
1065 ) -> Option<ClipHitResult> {
1066 if aabb.is_empty() {
1067 return None;
1068 }
1069
1070 if (to - from).length_squared() < 1.0e-7 {
1071 return None;
1072 }
1073
1074 let block_vec = DVec3::new(
1075 f64::from(block_pos.x()),
1076 f64::from(block_pos.y()),
1077 f64::from(block_pos.z()),
1078 );
1079 let inside_test_point = from + (to - from) * 0.001;
1080 if Self::local_aabb_contains_world_point(aabb, block_vec, inside_test_point) {
1081 return Some(ClipHitResult {
1082 location: inside_test_point,
1083 direction: Self::approximate_nearest_direction(to - from).opposite(),
1084 block_pos,
1085 miss: false,
1086 inside: true,
1087 world_border_hit: false,
1088 });
1089 }
1090
1091 let world_min = DVec3::new(aabb.min_x(), aabb.min_y(), aabb.min_z()) + block_vec;
1092 let world_max = DVec3::new(aabb.max_x(), aabb.max_y(), aabb.max_z()) + block_vec;
1093 Self::intersects_aabb_with_t(from, to, world_min, world_max).and_then(|(t, direction)| {
1094 if t > 0.0 && t < 1.0 {
1095 Some(ClipHitResult {
1096 location: from + (to - from) * t,
1097 direction,
1098 block_pos,
1099 miss: false,
1100 inside: false,
1101 world_border_hit: false,
1102 })
1103 } else {
1104 None
1105 }
1106 })
1107 }
1108
1109 pub(super) fn shape_contains_world_point(
1110 shape: OffsetVoxelShape,
1111 block_vec: DVec3,
1112 point: DVec3,
1113 ) -> bool {
1114 shape
1115 .iter()
1116 .any(|aabb| Self::local_aabb_contains_world_point(aabb, block_vec, point))
1117 }
1118
1119 pub(super) fn local_aabb_contains_world_point(
1120 aabb: BlockLocalAabb,
1121 block_vec: DVec3,
1122 point: DVec3,
1123 ) -> bool {
1124 let local = point - block_vec;
1125 !aabb.is_empty()
1126 && local.x >= aabb.min_x()
1127 && local.x < aabb.max_x()
1128 && local.y >= aabb.min_y()
1129 && local.y < aabb.max_y()
1130 && local.z >= aabb.min_z()
1131 && local.z < aabb.max_z()
1132 }
1133
1134 pub(super) fn clip_miss(from: DVec3, to: DVec3) -> ClipHitResult {
1135 ClipHitResult {
1136 location: to,
1137 direction: Self::approximate_nearest_direction(from - to),
1138 block_pos: BlockPos::from(to),
1139 miss: true,
1140 inside: false,
1141 world_border_hit: false,
1142 }
1143 }
1144
1145 pub(super) fn approximate_nearest_direction(vector: DVec3) -> Direction {
1146 let mut result = Direction::North;
1147 let mut highest_dot = 0.0;
1148 for direction in [
1149 Direction::Down,
1150 Direction::Up,
1151 Direction::North,
1152 Direction::South,
1153 Direction::West,
1154 Direction::East,
1155 ] {
1156 let dot = vector.dot(direction.offset_vec().as_dvec3());
1157 if dot > highest_dot {
1158 highest_dot = dot;
1159 result = direction;
1160 }
1161 }
1162 result
1163 }
1164
1165 pub(super) fn intersects_aabb_with_t(
1167 start: DVec3,
1168 end: DVec3,
1169 min: DVec3,
1170 max: DVec3,
1171 ) -> Option<(f64, Direction)> {
1172 let delta = end - start;
1173 let mut scale = 1.0;
1174 let mut direction = None;
1175
1176 if delta.x > AABB_CLIP_EPSILON {
1177 Self::clip_aabb_face(
1178 &mut scale,
1179 &mut direction,
1180 delta,
1181 start,
1182 min.x,
1183 [min.y, max.y, min.z, max.z],
1184 Direction::West,
1185 );
1186 } else if delta.x < -AABB_CLIP_EPSILON {
1187 Self::clip_aabb_face(
1188 &mut scale,
1189 &mut direction,
1190 delta,
1191 start,
1192 max.x,
1193 [min.y, max.y, min.z, max.z],
1194 Direction::East,
1195 );
1196 }
1197
1198 let y_delta = DVec3::new(delta.y, delta.z, delta.x);
1199 let y_start = DVec3::new(start.y, start.z, start.x);
1200 if delta.y > AABB_CLIP_EPSILON {
1201 Self::clip_aabb_face(
1202 &mut scale,
1203 &mut direction,
1204 y_delta,
1205 y_start,
1206 min.y,
1207 [min.z, max.z, min.x, max.x],
1208 Direction::Down,
1209 );
1210 } else if delta.y < -AABB_CLIP_EPSILON {
1211 Self::clip_aabb_face(
1212 &mut scale,
1213 &mut direction,
1214 y_delta,
1215 y_start,
1216 max.y,
1217 [min.z, max.z, min.x, max.x],
1218 Direction::Up,
1219 );
1220 }
1221
1222 let z_delta = DVec3::new(delta.z, delta.x, delta.y);
1223 let z_start = DVec3::new(start.z, start.x, start.y);
1224 if delta.z > AABB_CLIP_EPSILON {
1225 Self::clip_aabb_face(
1226 &mut scale,
1227 &mut direction,
1228 z_delta,
1229 z_start,
1230 min.z,
1231 [min.x, max.x, min.y, max.y],
1232 Direction::North,
1233 );
1234 } else if delta.z < -AABB_CLIP_EPSILON {
1235 Self::clip_aabb_face(
1236 &mut scale,
1237 &mut direction,
1238 z_delta,
1239 z_start,
1240 max.z,
1241 [min.x, max.x, min.y, max.y],
1242 Direction::South,
1243 );
1244 }
1245
1246 direction.map(|direction| (scale, direction))
1247 }
1248
1249 #[inline]
1250 fn clip_aabb_face(
1251 scale: &mut f64,
1252 direction: &mut Option<Direction>,
1253 delta: DVec3,
1254 start: DVec3,
1255 face: f64,
1256 side_bounds: [f64; 4],
1257 new_direction: Direction,
1258 ) {
1259 let candidate_scale = (face - start.x) / delta.x;
1260 let side_b = start.y + candidate_scale * delta.y;
1261 let side_c = start.z + candidate_scale * delta.z;
1262 if 0.0 < candidate_scale
1263 && candidate_scale < *scale
1264 && side_bounds[0] - AABB_CLIP_EPSILON < side_b
1265 && side_b < side_bounds[1] + AABB_CLIP_EPSILON
1266 && side_bounds[2] - AABB_CLIP_EPSILON < side_c
1267 && side_c < side_bounds[3] + AABB_CLIP_EPSILON
1268 {
1269 *scale = candidate_scale;
1270 *direction = Some(new_direction);
1271 }
1272 }
1273
1274 pub fn raytrace<F>(
1278 &self,
1279 start_pos: DVec3,
1280 end_pos: DVec3,
1281 hit_check: F,
1282 ) -> (Option<BlockPos>, Option<Direction>)
1283 where
1284 F: Fn(BlockPos, &Self) -> RaytraceAction,
1285 {
1286 if start_pos == end_pos {
1287 return (None, None);
1288 }
1289
1290 let adjust = -1.0e-7f64;
1291 let to = end_pos.lerp(start_pos, adjust);
1292 let from = start_pos.lerp(end_pos, adjust);
1293
1294 let mut block = BlockPos::new(
1295 from.x.floor() as i32,
1296 from.y.floor() as i32,
1297 from.z.floor() as i32,
1298 );
1299
1300 match hit_check(block, self) {
1301 RaytraceAction::ImmediateHit => return (Some(block), None),
1302 RaytraceAction::CheckShape => {
1303 let (hit, face) = self.ray_outline_check(block, start_pos, end_pos);
1304 if hit {
1305 return (Some(block), face);
1306 }
1307 }
1308 RaytraceAction::Pass => {}
1309 }
1310
1311 let difference = to - from;
1312
1313 let step = difference.signum().as_ivec3();
1314
1315 let delta = DVec3::new(
1316 if step.x == 0 {
1317 f64::MAX
1318 } else {
1319 (f64::from(step.x)) / difference.x
1320 },
1321 if step.y == 0 {
1322 f64::MAX
1323 } else {
1324 (f64::from(step.y)) / difference.y
1325 },
1326 if step.z == 0 {
1327 f64::MAX
1328 } else {
1329 (f64::from(step.z)) / difference.z
1330 },
1331 );
1332
1333 let mut next = DVec3::new(
1334 delta.x
1335 * (if step.x > 0 {
1336 1.0 - (from.x - from.x.floor())
1337 } else {
1338 from.x - from.x.floor()
1339 }),
1340 delta.y
1341 * (if step.y > 0 {
1342 1.0 - (from.y - from.y.floor())
1343 } else {
1344 from.y - from.y.floor()
1345 }),
1346 delta.z
1347 * (if step.z > 0 {
1348 1.0 - (from.z - from.z.floor())
1349 } else {
1350 from.z - from.z.floor()
1351 }),
1352 );
1353
1354 while next.x <= 1.0 || next.y <= 1.0 || next.z <= 1.0 {
1355 let block_direction = if next.x < next.y && next.x < next.z {
1360 block.0.x += step.x;
1361 next.x += delta.x;
1362 if step.x > 0 {
1363 Direction::West
1364 } else {
1365 Direction::East
1366 }
1367 } else if next.y < next.x && next.y < next.z {
1368 block.0.y += step.y;
1369 next.y += delta.y;
1370 if step.y > 0 {
1371 Direction::Down
1372 } else {
1373 Direction::Up
1374 }
1375 } else {
1376 block.0.z += step.z;
1377 next.z += delta.z;
1378 if step.z > 0 {
1379 Direction::North
1380 } else {
1381 Direction::South
1382 }
1383 };
1384
1385 match hit_check(block, self) {
1386 RaytraceAction::ImmediateHit => {
1387 return (Some(block), Some(block_direction));
1388 }
1389 RaytraceAction::CheckShape => {
1390 let (hit, face) = self.ray_outline_check(block, start_pos, end_pos);
1391 if hit {
1392 return (Some(block), face);
1393 }
1394 }
1395 RaytraceAction::Pass => {}
1396 }
1397 }
1398
1399 (None, None)
1400 }
1401}
1402
1403#[cfg(test)]
1404mod voxel_shape_clip_tests {
1405 use super::*;
1406
1407 fn axis_point(axis: usize, primary: f64, side_b: f64, side_c: f64) -> DVec3 {
1408 match axis {
1409 0 => DVec3::new(primary, side_b, side_c),
1410 1 => DVec3::new(side_c, primary, side_b),
1411 2 => DVec3::new(side_b, side_c, primary),
1412 _ => unreachable!("the fixture only defines the three coordinate axes"),
1413 }
1414 }
1415
1416 fn axis_bounds(axis: usize, primary_min: f64, primary_max: f64) -> (DVec3, DVec3) {
1417 (
1418 axis_point(axis, primary_min, 0.0, 0.0),
1419 axis_point(axis, primary_max, 1.0, 1.0),
1420 )
1421 }
1422
1423 fn primary_component(vector: DVec3, axis: usize) -> f64 {
1424 match axis {
1425 0 => vector.x,
1426 1 => vector.y,
1427 2 => vector.z,
1428 _ => unreachable!("the fixture only defines the three coordinate axes"),
1429 }
1430 }
1431
1432 #[test]
1433 fn voxel_shape_clip_matches_java_full_cube_at_exact_opposite_boundaries() {
1434 let shape = OffsetVoxelShape::without_offset(VoxelShape::FULL_BLOCK);
1435
1436 assert!(
1439 World::clip_shape(
1440 BlockPos::ZERO,
1441 DVec3::new(0.0, 0.5, 0.5),
1442 DVec3::new(1000.0, 0.5, 0.5),
1443 shape,
1444 )
1445 .is_none()
1446 );
1447
1448 let Some(hit) = World::clip_shape(
1449 BlockPos::ZERO,
1450 DVec3::new(1.0, 0.5, 0.5),
1451 DVec3::new(-999.0, 0.5, 0.5),
1452 shape,
1453 ) else {
1454 panic!("the lower-bound probe should start inside the full cube");
1455 };
1456 assert!(hit.inside);
1457 assert_eq!(hit.location, DVec3::new(0.0, 0.5, 0.5));
1458 assert_eq!(hit.direction, Direction::East);
1459 }
1460
1461 #[test]
1462 fn aabb_clip_matches_java_strict_side_epsilon_in_every_axis() {
1463 for (axis, positive_direction) in [
1464 (0, Direction::West),
1465 (1, Direction::Down),
1466 (2, Direction::North),
1467 ] {
1468 let (min, max) = axis_bounds(axis, 0.0, 1.0);
1469 let Some((_, direction)) = World::intersects_aabb_with_t(
1470 axis_point(axis, -1.0, 1.0 + 0.5e-7, -0.5e-7),
1471 axis_point(axis, 2.0, 1.0 + 0.5e-7, -0.5e-7),
1472 min,
1473 max,
1474 ) else {
1475 panic!("Java's side epsilon should accept axis {axis}");
1476 };
1477 assert_eq!(direction, positive_direction);
1478
1479 assert!(
1480 World::intersects_aabb_with_t(
1481 axis_point(axis, -1.0, 1.0 + AABB_CLIP_EPSILON, 0.5),
1482 axis_point(axis, 2.0, 1.0 + AABB_CLIP_EPSILON, 0.5),
1483 min,
1484 max,
1485 )
1486 .is_none(),
1487 "Java's upper side epsilon is strict on axis {axis}",
1488 );
1489 assert!(
1490 World::intersects_aabb_with_t(
1491 axis_point(axis, -1.0, 0.5, -AABB_CLIP_EPSILON),
1492 axis_point(axis, 2.0, 0.5, -AABB_CLIP_EPSILON),
1493 min,
1494 max,
1495 )
1496 .is_none(),
1497 "Java's lower side epsilon is strict on axis {axis}",
1498 );
1499 }
1500 }
1501
1502 #[test]
1503 fn aabb_clip_matches_java_direction_threshold_in_every_axis() {
1504 let threshold = AABB_CLIP_EPSILON;
1505 let above_threshold = f64::from_bits(threshold.to_bits() + 1);
1506 for (axis, positive_direction, negative_direction) in [
1507 (0, Direction::West, Direction::East),
1508 (1, Direction::Down, Direction::Up),
1509 (2, Direction::North, Direction::South),
1510 ] {
1511 let exact_positive_from = axis_point(axis, -threshold / 2.0, 0.5, 0.5);
1512 let exact_positive_to = axis_point(axis, threshold / 2.0, 0.5, 0.5);
1513 assert_eq!(
1514 primary_component(exact_positive_to - exact_positive_from, axis).to_bits(),
1515 threshold.to_bits(),
1516 );
1517 let (positive_min, positive_max) = axis_bounds(axis, 0.0, 1.0);
1518 assert!(
1519 World::intersects_aabb_with_t(
1520 exact_positive_from,
1521 exact_positive_to,
1522 positive_min,
1523 positive_max,
1524 )
1525 .is_none(),
1526 "Java does not test a positive face at exactly +1e-7 on axis {axis}",
1527 );
1528
1529 let above_positive_from = axis_point(axis, -above_threshold / 2.0, 0.5, 0.5);
1530 let above_positive_to = axis_point(axis, above_threshold / 2.0, 0.5, 0.5);
1531 let Some((_, direction)) = World::intersects_aabb_with_t(
1532 above_positive_from,
1533 above_positive_to,
1534 positive_min,
1535 positive_max,
1536 ) else {
1537 panic!("Java tests a positive face above +1e-7 on axis {axis}");
1538 };
1539 assert_eq!(direction, positive_direction);
1540
1541 let exact_negative_from = axis_point(axis, threshold / 2.0, 0.5, 0.5);
1542 let exact_negative_to = axis_point(axis, -threshold / 2.0, 0.5, 0.5);
1543 assert_eq!(
1544 primary_component(exact_negative_to - exact_negative_from, axis).to_bits(),
1545 (-threshold).to_bits(),
1546 );
1547 let (negative_min, negative_max) = axis_bounds(axis, -1.0, 0.0);
1548 assert!(
1549 World::intersects_aabb_with_t(
1550 exact_negative_from,
1551 exact_negative_to,
1552 negative_min,
1553 negative_max,
1554 )
1555 .is_none(),
1556 "Java does not test a negative face at exactly -1e-7 on axis {axis}",
1557 );
1558
1559 let above_negative_from = axis_point(axis, above_threshold / 2.0, 0.5, 0.5);
1560 let above_negative_to = axis_point(axis, -above_threshold / 2.0, 0.5, 0.5);
1561 let Some((_, direction)) = World::intersects_aabb_with_t(
1562 above_negative_from,
1563 above_negative_to,
1564 negative_min,
1565 negative_max,
1566 ) else {
1567 panic!("Java tests a negative face below -1e-7 on axis {axis}");
1568 };
1569 assert_eq!(direction, negative_direction);
1570 }
1571 }
1572
1573 #[test]
1574 fn aabb_clip_preserves_java_axis_order_for_equal_corner_hits() {
1575 let Some((_, direction)) = World::intersects_aabb_with_t(
1576 DVec3::new(-1.0, -1.0, 0.5),
1577 DVec3::new(2.0, 2.0, 0.5),
1578 DVec3::ZERO,
1579 DVec3::ONE,
1580 ) else {
1581 panic!("the corner fixture should hit the full cube");
1582 };
1583 assert_eq!(direction, Direction::West);
1584 }
1585}
1586
1587#[cfg(test)]
1588mod explosion_exposure_cache_tests {
1589 use super::*;
1590 use crate::test_support::fresh_test_world;
1591 use steel_registry::{
1592 blocks::{Block, behavior::BlockConfig},
1593 vanilla_blocks,
1594 };
1595
1596 const TEST_DDA_MAX_STEPS: usize = 64;
1597
1598 #[test]
1599 fn scalar_axis_envelope_matches_full_vanilla_dda_visits() {
1600 for (start, end) in [
1601 (DVec3::splat(0.5), DVec3::new(5.5, 2.5, -3.5)),
1602 (DVec3::new(1.0, 2.0, 3.0), DVec3::new(0.0, 2.0, 3.0)),
1603 (DVec3::splat(-0.5), DVec3::new(-4.5, -4.5, -1.5)),
1604 (DVec3::splat(0.5), DVec3::splat(4.5)),
1605 ] {
1606 let mut visits = Vec::new();
1607 assert!(is_collision_path_clear(start, end, |pos| {
1608 visits.push(pos);
1609 false
1610 }));
1611 for axis in 0..3 {
1612 let (expected_min, expected_max) = visits
1613 .iter()
1614 .map(|pos| pos.0[axis])
1615 .fold((i32::MAX, i32::MIN), |(min, max), value| {
1616 (min.min(value), max.max(value))
1617 });
1618 assert_eq!(
1619 collision_path_axis_block_bounds(start[axis], end[axis], TEST_DDA_MAX_STEPS),
1620 Some((expected_min, expected_max)),
1621 "axis={axis}, start={start:?}, end={end:?}, visits={visits:?}"
1622 );
1623 }
1624 }
1625 }
1626
1627 #[test]
1628 fn collision_path_matches_vanilla_axis_tie_breaking() {
1629 let start = DVec3::splat(0.5);
1630 for (end, expected) in [
1631 (DVec3::new(1.5, 1.5, 0.75), BlockPos::new(0, 1, 0)),
1632 (DVec3::new(1.5, 0.75, 1.5), BlockPos::new(0, 0, 1)),
1633 (DVec3::new(0.75, 1.5, 1.5), BlockPos::new(0, 0, 1)),
1634 (DVec3::splat(1.5), BlockPos::new(0, 0, 1)),
1635 ] {
1636 let mut first_step = None;
1637 is_collision_path_clear(start, end, |pos| {
1638 if pos == BlockPos::ZERO {
1639 return false;
1640 }
1641 first_step = Some(pos);
1642 true
1643 });
1644 assert_eq!(first_step, Some(expected), "end={end:?}");
1645 }
1646 }
1647
1648 #[test]
1649 fn collision_path_preserves_vanilla_lerp_rounding_at_integer_boundaries() {
1650 let start = DVec3::new(1.0, 0.5, 0.5);
1651 let end = DVec3::new(1.000_000_001, 0.5, 0.5);
1652 let mut visits = Vec::new();
1653
1654 assert!(is_collision_path_clear(start, end, |pos| {
1655 visits.push(pos);
1656 false
1657 }));
1658 assert_eq!(visits, [BlockPos::new(0, 0, 0), BlockPos::new(1, 0, 0)]);
1660 }
1661
1662 #[test]
1663 fn extensible_collision_query_invalidates_every_exposure_cache() {
1664 static PLUGIN_BLOCK: Block = Block::new(
1665 Identifier::new_static("exposure_test", "mutating_shape"),
1666 BlockConfig::new(),
1667 &[],
1668 );
1669
1670 let fixture = fresh_test_world("extensible_exposure_cache_boundary");
1671
1672 let world = &fixture.world;
1673 let mut raycast =
1674 ExplosionExposureRaycast::new(world.as_ref(), BlockCollisionContext::empty());
1675 let pos = BlockPos::new(0, 64, 0);
1676 raycast.configure_clear_grid(pos, pos);
1677 let Some(grid_index) = raycast.clear_grid.index(pos) else {
1678 panic!("the configured position should be represented in the clear grid");
1679 };
1680 raycast
1681 .clear_grid
1682 .record(grid_index, ExplosionExposureClearGridState::StaticEmpty);
1683 let direct_index = explosion_exposure_cache_index(pos);
1684 raycast.entries[direct_index] = ExplosionExposureCacheEntry {
1685 pos,
1686 collision: OffsetVoxelShape::without_offset(VoxelShape::EMPTY),
1687 generation: raycast.generation,
1688 };
1689
1690 let initial_generation = raycast.generation;
1691 assert!(raycast.retain_cache_after_collision_query(&vanilla_blocks::STONE));
1692 assert_eq!(raycast.generation, initial_generation);
1693 assert_eq!(
1694 raycast.clear_grid.state(grid_index),
1695 ExplosionExposureClearGridState::StaticEmpty,
1696 );
1697 assert!(!raycast.retain_cache_after_collision_query(&PLUGIN_BLOCK));
1698 assert_ne!(raycast.generation, initial_generation);
1699 assert_eq!(
1700 raycast.clear_grid.state(grid_index),
1701 ExplosionExposureClearGridState::Unresolved,
1702 );
1703 assert_ne!(raycast.entries[direct_index].generation, raycast.generation);
1704 }
1705}