Skip to main content

steel_core/world/
raycast.rs

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/// Controls how a block position is treated during a raytrace traversal.
7///
8/// Returned by the predicate closure passed to [`World::raytrace`].
9#[derive(Debug)]
10pub enum RaytraceAction {
11    /// Skip this block and continue traversal (transparent block).
12    Pass,
13    /// Test the block's voxel shape for a precise ray intersection.
14    CheckShape,
15    /// Immediately treat this block as a hit without shape testing.
16    ImmediateHit,
17}
18
19/// Block shape channel used by vanilla-style world clipping.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ClipBlockShape {
22    /// `ClipContext.Block.COLLIDER`
23    Collider,
24    /// `ClipContext.Block.OUTLINE`
25    Outline,
26    /// `ClipContext.Block.VISUAL`
27    Visual,
28    /// `ClipContext.Block.FALLDAMAGE_RESETTING`
29    FallDamageResetting {
30        /// Whether the clip context entity is a player.
31        entity_is_player: bool,
32    },
33}
34
35/// Fluid shape filter used by vanilla-style world clipping.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ClipFluid {
38    /// `ClipContext.Fluid.NONE`
39    None,
40    /// `ClipContext.Fluid.SOURCE_ONLY`
41    SourceOnly,
42    /// `ClipContext.Fluid.ANY`
43    Any,
44    /// `ClipContext.Fluid.WATER`
45    Water,
46}
47
48/// Result of a vanilla-style world clip.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct ClipHitResult {
51    /// Exact hit location in world coordinates.
52    pub location: DVec3,
53    /// Hit face, or the miss direction for misses.
54    pub direction: Direction,
55    /// Block position containing the hit or miss endpoint.
56    pub block_pos: BlockPos,
57    /// Whether this result is a miss.
58    pub miss: bool,
59    /// Whether the ray started inside the hit shape.
60    pub inside: bool,
61    /// Whether this hit was synthesized by the world border.
62    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
68// Radius-four TNT exposure repeatedly visits a few hundred nearby positions. A 256-entry direct
69// cache retains almost all of that working set without heap allocation or cross-explosion lifetime.
70// Its size must remain a power of two because lookups wrap with a mask.
71const 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
77// A normal radius-four explosion queries at most 19 positions on each axis. Two bits per position
78// fit inline, avoiding one heap allocation while unusual radii use the compatibility path.
79const 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            // The unused fourth encoding conservatively takes the exact path too.
117            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    // Each lookup reads one packed word instead of two bitsets.
133    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        // Wrapping subtraction maps every below-min coordinate to a large unsigned value. Since
185        // the configured grid is tiny, the same range checks safely reject both underflow and
186        // coordinates above the maximum without three checked-subtraction branches.
187        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
241/// Bounded cache for vanilla explosion exposure rays.
242///
243/// Missing chunks are never cached, allowing asynchronous Full publication to become visible
244/// while the synchronous gameplay callback is running. Dynamic and non-Vanilla collision shapes
245/// are resolved on every visit so live block entities and plugin callbacks retain their normal
246/// query semantics. Reuse across entities is only valid when the caller can prove that the
247/// intervening entity callbacks cannot mutate blocks; callers must clear the cache before invoking
248/// any other entity implementation.
249pub(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    /// Configures a bounded, explosion-local cache of statically clear positions.
300    ///
301    /// Bounds that exceed the inline capacity disable the optimization and retain the exact
302    /// compatibility path.
303    pub(crate) fn configure_clear_grid(&mut self, min: BlockPos, max: BlockPos) {
304        self.clear_grid.configure(min, max);
305    }
306
307    /// Drops every retained static collision result.
308    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    /// Selects the entity collision context used by subsequent exposure rays.
319    pub(crate) const fn set_collision_context(&mut self, collision_context: BlockCollisionContext) {
320        self.collision_context = collision_context;
321    }
322
323    /// Proves that an inclusive block box contains only immutable Vanilla air.
324    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    /// Keeps cached shapes only across Steel-owned Vanilla collision behavior calls.
347    ///
348    /// Plugin behavior may mutate another block while answering a shape query. Clearing after its
349    /// callback makes the next Vanilla DDA visit read the same live state Vanilla would observe.
350    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    /// Returns whether a collider-only, fluid-free exposure ray misses every block.
359    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            // Dense clear entries no longer need a large direct-cache slot. Retaining only
457            // potentially colliding shapes reduces destructive conflicts on the exact path.
458            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    // Fibonacci multiplication followed by xor folding disperses nearby packed positions into
490    // the low bits used by the power-of-two cache.
491    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
577/// Returns the inclusive block-coordinate envelope visited by one axis of Vanilla's block DDA.
578///
579/// Axis crossing counts are independent of inter-axis tie order: the traversal cannot finish
580/// until every finite `next` value is greater than one. Replaying the scalar recurrence also
581/// retains the rare terminal step caused by floating-point rounding at `next == 1`.
582pub(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    // `glam::DVec3::lerp` uses `start * (1 - alpha) + end * alpha`. Vanilla's
655    // `Mth.lerp` uses this operation order, which can round to a different block boundary.
656    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    /// Returns whether this clip missed all selected block and fluid shapes.
665    #[must_use]
666    pub const fn is_miss(self) -> bool {
667        self.miss
668    }
669}
670
671impl World {
672    /// Reads exposure state while distinguishing stable Full data from an air
673    /// fallback caused by a chunk that may still publish asynchronously.
674    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    /// Checks if a ray intersects with a block's selection box.
699    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    /// Performs a vanilla-style block/fluid clip in the world.
715    #[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    /// Returns whether a collider-only, fluid-free clip misses every block.
808    #[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    /// Performs a normal ray clip, then clamps the hit to the world border if
827    /// the ray started inside it but the hit landed outside.
828    #[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    /// Mirrors Minecraft  `AABB.getDirection` for one block-local shape box.
1166    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    /// Performs a raytrace in the world.
1275    ///
1276    /// Adapted from Pumpkin project.
1277    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            // Vanilla parity: traverseBlocks tie-breaking — Z wins on any tie.
1356            // X wins only when strictly less than both Y and Z.
1357            // Y wins only when strictly less than both X and Z.
1358            // Everything else (including all ties) goes to Z.
1359            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        // Minecraft VoxelShape.clip probes 0.001 along the segment. For a
1437        // CubeVoxelShape the resulting x=1 index is outside, while x=0 is inside.
1438        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        // Vanilla's `Mth.lerp` operation order moves the adjusted start below x = 1.
1659        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}