Skip to main content

steel_core/portal/
portal_shape.rs

1//! Portal shape detection for validating obsidian frames.
2
3use glam::DVec3;
4use std::sync::Arc;
5use steel_math::inverse_lerp;
6use steel_registry::blocks::BlockRef;
7use steel_registry::blocks::block_state_ext::BlockStateExt;
8use steel_registry::blocks::properties::BlockStateProperties;
9use steel_registry::entity_type::EntityDimensions;
10use steel_registry::vanilla_block_tags::BlockTag;
11use steel_registry::vanilla_blocks;
12use steel_utils::axis::Axis;
13use steel_utils::block_util::FoundRectangle;
14use steel_utils::types::UpdateFlags;
15use steel_utils::{BlockPos, Direction, WorldAabb};
16
17use crate::entity::Entity;
18use crate::physics::WorldCollisionProvider;
19use crate::world::{LevelReader, World};
20
21/// A detected portal shape with axis, position, and dimensions.
22pub struct PortalShape {
23    /// The axis of the portal (X or Z).
24    pub axis: Axis,
25    /// Bottom-left corner of the portal interior.
26    pub bottom_left: BlockPos,
27    /// Width of the interior (2-21).
28    pub width: u32,
29    /// Height of the interior (3-21).
30    pub height: u32,
31    /// The horizontal direction along which width is measured.
32    pub right_dir: Direction,
33    /// The block type of the portal.
34    pub portal: BlockRef,
35    /// Number of portal blocks found in the interior.
36    /// Used by `is_complete` to verify the portal is fully filled.
37    num_portal_blocks: u32,
38}
39
40/// Definition of a portal shape in rectangular form, like the nether portal frame.
41pub struct PortalFrameConfig {
42    /// min size of the portal in x direction
43    pub min_width: u32,
44    /// max size of the portal in x direction
45    pub max_width: u32,
46    /// min size of the portal in y direction
47    pub min_height: u32,
48    /// max size of the portal in y direction
49    pub max_height: u32,
50    /// The block type of the frame.
51    pub frame: BlockRef,
52    /// The block type of the portal.
53    pub portal: BlockRef,
54}
55
56/// Returns the standard nether portal frame configuration.
57#[must_use]
58pub fn nether_portal_config() -> PortalFrameConfig {
59    PortalFrameConfig {
60        min_width: 2,
61        max_width: 21,
62        min_height: 3,
63        max_height: 21,
64        frame: &vanilla_blocks::OBSIDIAN,
65        portal: &vanilla_blocks::NETHER_PORTAL,
66    }
67}
68
69/// Matches vanilla's `PortalShape.isEmpty`: any air variant, any block in the `fire` tag,
70/// or the portal block itself.
71fn is_empty(world: &dyn LevelReader, pos: BlockPos, config: &PortalFrameConfig) -> bool {
72    let state = world.get_block_state(pos);
73    if state.is_air() {
74        return true;
75    }
76    let block = state.get_block();
77    block.has_tag(&BlockTag::FIRE) || block == config.portal
78}
79
80const fn block_pos_axis(pos: BlockPos, axis: Axis) -> i32 {
81    match axis {
82        Axis::X => pos.x(),
83        Axis::Y => pos.y(),
84        Axis::Z => pos.z(),
85    }
86}
87
88const fn vec_axis(pos: DVec3, axis: Axis) -> f64 {
89    match axis {
90        Axis::X => pos.x,
91        Axis::Y => pos.y,
92        Axis::Z => pos.z,
93    }
94}
95
96impl PortalShape {
97    /// Finds an empty portal frame starting with X axis preferred.
98    /// Matches vanilla's `findEmptyPortalShape` as called from `BaseFireBlock.onPlace`.
99    pub fn find_empty_portal_shape(
100        world: &dyn LevelReader,
101        fire_pos: BlockPos,
102        config: &PortalFrameConfig,
103    ) -> Option<Self> {
104        Self::find_empty_portal_shape_with_axis(world, fire_pos, Axis::X, config)
105    }
106
107    /// Finds an empty portal frame trying `preferred_axis` first, then the other.
108    /// Matches vanilla's `findPortalShape` with the empty-portal predicate.
109    pub fn find_empty_portal_shape_with_axis(
110        world: &dyn LevelReader,
111        fire_pos: BlockPos,
112        preferred_axis: Axis,
113        config: &PortalFrameConfig,
114    ) -> Option<Self> {
115        let other_axis = if preferred_axis == Axis::X {
116            Axis::Z
117        } else {
118            Axis::X
119        };
120        Self::try_axis(world, fire_pos, preferred_axis, config)
121            .filter(|s| s.num_portal_blocks == 0)
122            .or_else(|| {
123                Self::try_axis(world, fire_pos, other_axis, config)
124                    .filter(|s| s.num_portal_blocks == 0)
125            })
126    }
127
128    /// Finds a portal shape on a specific axis.
129    /// Used by `update_shape` to check if the portal is still complete.
130    pub fn find_any_shape(
131        world: &dyn LevelReader,
132        pos: BlockPos,
133        axis: Axis,
134        config: &PortalFrameConfig,
135    ) -> Option<Self> {
136        Self::try_axis(world, pos, axis, config)
137    }
138
139    /// Tries to find a valid portal on a single axis, matching vanilla's detection algorithm.
140    fn try_axis(
141        world: &dyn LevelReader,
142        pos: BlockPos,
143        axis: Axis,
144        config: &PortalFrameConfig,
145    ) -> Option<Self> {
146        // Vanilla: rightDir is WEST for X-axis, SOUTH for Z-axis
147        let right_dir: Direction = match axis {
148            Axis::X => Direction::West,
149            Axis::Z => Direction::South,
150            Axis::Y => return None,
151        };
152
153        let bottom_left = Self::calculate_bottom_left(world, pos, right_dir, config)?;
154
155        let width = Self::calculate_width(world, bottom_left, right_dir, config);
156        if width == 0 {
157            return None;
158        }
159
160        let mut num_portal_blocks = 0;
161        let height = Self::calculate_height(
162            world,
163            bottom_left,
164            width,
165            right_dir,
166            config,
167            &mut num_portal_blocks,
168        );
169        if height < config.min_height {
170            return None;
171        }
172
173        if !Self::has_top_frame(world, bottom_left, height, width, right_dir, config) {
174            return None;
175        }
176
177        Some(Self {
178            axis,
179            bottom_left,
180            width,
181            height,
182            right_dir,
183            portal: config.portal,
184            num_portal_blocks,
185        })
186    }
187
188    /// Returns the number of valid interior blocks in `direction` from `pos`, matching vanilla's
189    /// `getDistanceUntilEdgeAboveFrame`. Each position must be empty and have a frame block
190    /// below it. Returns 0 if the terminating block is not a frame block.
191    fn get_distance_until_edge(
192        world: &dyn LevelReader,
193        pos: BlockPos,
194        direction: Direction,
195        config: &PortalFrameConfig,
196    ) -> u32 {
197        for i in 0..=config.max_width {
198            let next = pos.relative_n(direction, i as i32);
199            if !is_empty(world, next, config) {
200                // Edge must be a frame block, otherwise the interior is unbounded
201                return if Self::is_frame_block(world, next, config) {
202                    i
203                } else {
204                    0
205                };
206            }
207            if !Self::is_frame_block(world, next.below(), config) {
208                return 0;
209            }
210        }
211        0
212    }
213
214    /// Finds the bottom-left corner of the portal interior.
215    fn calculate_bottom_left(
216        world: &dyn LevelReader,
217        pos: BlockPos,
218        right_dir: Direction,
219        config: &PortalFrameConfig,
220    ) -> Option<BlockPos> {
221        // Scan down to find the lowest empty block above frame
222        let mut cur = pos;
223        for _ in 0..config.max_height {
224            let next = cur.below();
225            if !is_empty(world, next, config) {
226                break;
227            }
228            cur = next;
229        }
230
231        // Scan in opposite of right_dir to find the left edge
232        let left_dir = right_dir.opposite();
233        let dist = Self::get_distance_until_edge(world, cur, left_dir, config);
234        if dist == 0 {
235            return None;
236        }
237        Some(cur.relative_n(left_dir, (dist - 1) as i32))
238    }
239
240    /// Calculates the width of the portal interior from the bottom-left corner.
241    fn calculate_width(
242        world: &dyn LevelReader,
243        bottom_left: BlockPos,
244        right_dir: Direction,
245        config: &PortalFrameConfig,
246    ) -> u32 {
247        let dist = Self::get_distance_until_edge(world, bottom_left, right_dir, config);
248        if dist < config.min_width || dist > config.max_width {
249            return 0;
250        }
251        dist
252    }
253
254    /// Calculates the height while validating side columns and interior.
255    /// Also counts portal blocks in the interior via `portal_block_count`.
256    ///
257    /// Matches vanilla's `getDistanceUntilTop`: always uses `isEmpty` (air/fire/portal)
258    /// for interior validation regardless of the outer interior check strategy.
259    fn calculate_height(
260        world: &dyn LevelReader,
261        bottom_left: BlockPos,
262        width: u32,
263        right_dir: Direction,
264        config: &PortalFrameConfig,
265        portal_block_count: &mut u32,
266    ) -> u32 {
267        let mut height = 0;
268        'outer: for h in 0..config.max_height {
269            let row_start = bottom_left.above_n(h as i32);
270
271            // Check left frame column (one block left of bottom_left)
272            if !Self::is_frame_block(world, row_start.relative(right_dir.opposite()), config) {
273                break;
274            }
275            // Check right frame column (one block past the width)
276            if !Self::is_frame_block(world, row_start.relative_n(right_dir, width as i32), config) {
277                break;
278            }
279
280            // Check interior and count portal blocks
281            for w in 0..width {
282                let interior_pos = row_start.relative_n(right_dir, w as i32);
283                if !is_empty(world, interior_pos, config) {
284                    break 'outer;
285                }
286                if world.get_block_state(interior_pos).get_block() == config.portal {
287                    *portal_block_count += 1;
288                }
289            }
290            height = h + 1;
291        }
292        height
293    }
294
295    /// Checks that the top frame row is complete.
296    fn has_top_frame(
297        world: &dyn LevelReader,
298        bottom_left: BlockPos,
299        height: u32,
300        width: u32,
301        right_dir: Direction,
302        config: &PortalFrameConfig,
303    ) -> bool {
304        let top_row = bottom_left.above_n(height as i32);
305        for w in 0..width {
306            if !Self::is_frame_block(world, top_row.relative_n(right_dir, w as i32), config) {
307                return false;
308            }
309        }
310        true
311    }
312
313    fn is_frame_block(world: &dyn LevelReader, pos: BlockPos, config: &PortalFrameConfig) -> bool {
314        world.get_block_state(pos).get_block() == config.frame
315    }
316
317    /// Returns `true` if the portal interior is entirely filled with portal blocks.
318    /// Matches vanilla's `PortalShape.isComplete()`.
319    #[must_use]
320    pub const fn is_complete(&self) -> bool {
321        self.num_portal_blocks == self.width * self.height
322    }
323
324    /// Fills the interior with nether portal blocks.
325    /// Vanilla uses flag 18 (`UPDATE_CLIENTS` | `UPDATE_KNOWN_SHAPE`) to avoid redundant neighbor
326    /// updates during bulk placement.
327    pub fn place_portal_blocks(&self, world: &Arc<World>) {
328        let portal_state = self
329            .portal
330            .default_state()
331            .set_value(&BlockStateProperties::HORIZONTAL_AXIS, self.axis);
332        let flags = UpdateFlags::UPDATE_CLIENTS.union(UpdateFlags::UPDATE_KNOWN_SHAPE);
333        for w in 0..self.width {
334            for h in 0..self.height {
335                world.set_block(
336                    self.bottom_left
337                        .above_n(h as i32)
338                        .relative_n(self.right_dir, w as i32),
339                    portal_state,
340                    flags,
341                );
342            }
343        }
344    }
345
346    /// Returns vanilla `PortalShape.getRelativePosition`.
347    #[must_use]
348    pub fn get_relative_position(
349        largest_rectangle_around: FoundRectangle,
350        axis: Axis,
351        position: DVec3,
352        dimensions: EntityDimensions,
353    ) -> DVec3 {
354        let width = f64::from(largest_rectangle_around.axis1_size) - f64::from(dimensions.width);
355        let height = f64::from(largest_rectangle_around.axis2_size) - f64::from(dimensions.height);
356        let bottom_min = largest_rectangle_around.min_corner;
357        let relative_right = if width > 0.0 {
358            let bottom_start =
359                f64::from(block_pos_axis(bottom_min, axis)) + f64::from(dimensions.width) / 2.0;
360            inverse_lerp(vec_axis(position, axis) - bottom_start, 0.0, width).clamp(0.0, 1.0)
361        } else {
362            0.5
363        };
364
365        let relative_up = if height > 0.0 {
366            inverse_lerp(
367                vec_axis(position, Axis::Y) - f64::from(block_pos_axis(bottom_min, Axis::Y)),
368                0.0,
369                height,
370            )
371            .clamp(0.0, 1.0)
372        } else {
373            0.0
374        };
375
376        let forward_axis = if axis == Axis::X { Axis::Z } else { Axis::X };
377        let relative_forward = vec_axis(position, forward_axis)
378            - (f64::from(block_pos_axis(bottom_min, forward_axis)) + 0.5);
379        DVec3::new(relative_right, relative_up, relative_forward)
380    }
381
382    /// Returns vanilla `PortalShape.findCollisionFreePosition`.
383    #[must_use]
384    pub fn find_collision_free_position(
385        bottom_center: DVec3,
386        world: &Arc<World>,
387        entity: &dyn Entity,
388        dimensions: EntityDimensions,
389    ) -> DVec3 {
390        if dimensions.width > 4.0 || dimensions.height > 4.0 {
391            return bottom_center;
392        }
393
394        let width = f64::from(dimensions.width);
395        let height = f64::from(dimensions.height);
396        let half_height = height / 2.0;
397        let center = bottom_center + DVec3::new(0.0, half_height, 0.0);
398        let allowed_centers = [WorldAabb::of_size(center, width, 0.0, width)
399            .expand_towards(DVec3::Y)
400            .inflate(1.0E-6)];
401
402        WorldCollisionProvider::for_entity(world, entity)
403            .find_free_position(&allowed_centers, center, width, height, width)
404            .map_or(bottom_center, |pos| pos - DVec3::new(0.0, half_height, 0.0))
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use glam::DVec3;
411    use steel_registry::entity_type::EntityDimensions;
412    use steel_utils::BlockPos;
413    use steel_utils::axis::Axis;
414    use steel_utils::block_util::FoundRectangle;
415
416    use super::PortalShape;
417
418    #[test]
419    fn relative_portal_position_matches_vanilla_axis_math() {
420        let rectangle = FoundRectangle {
421            min_corner: BlockPos::new(10, 64, 20),
422            axis1_size: 4,
423            axis2_size: 5,
424        };
425        let position = DVec3::new(12.0, 66.0, 20.75);
426        let dimensions = EntityDimensions::new(1.0, 2.0, 1.62);
427
428        let relative = PortalShape::get_relative_position(rectangle, Axis::X, position, dimensions);
429
430        assert!((relative.x - 0.5).abs() < f64::EPSILON);
431        assert!((relative.y - (2.0 / 3.0)).abs() < f64::EPSILON);
432        assert!((relative.z - 0.25).abs() < f64::EPSILON);
433    }
434
435    #[test]
436    fn relative_portal_position_clamps_right_and_up_offsets() {
437        let rectangle = FoundRectangle {
438            min_corner: BlockPos::new(10, 64, 20),
439            axis1_size: 4,
440            axis2_size: 5,
441        };
442        let position = DVec3::new(8.0, 80.0, 20.5);
443        let dimensions = EntityDimensions::new(1.0, 2.0, 1.62);
444
445        let relative = PortalShape::get_relative_position(rectangle, Axis::X, position, dimensions);
446
447        assert!((relative.x - 0.0).abs() < f64::EPSILON);
448        assert!((relative.y - 1.0).abs() < f64::EPSILON);
449    }
450
451    #[test]
452    fn relative_portal_position_uses_vanilla_fallbacks_when_entity_fills_rectangle() {
453        let rectangle = FoundRectangle {
454            min_corner: BlockPos::new(10, 64, 20),
455            axis1_size: 2,
456            axis2_size: 3,
457        };
458        let position = DVec3::new(11.0, 65.0, 20.25);
459        let dimensions = EntityDimensions::new(3.0, 4.0, 1.62);
460
461        let relative = PortalShape::get_relative_position(rectangle, Axis::Z, position, dimensions);
462
463        assert!((relative.x - 0.5).abs() < f64::EPSILON);
464        assert!((relative.y - 0.0).abs() < f64::EPSILON);
465        assert!((relative.z - 0.5).abs() < f64::EPSILON);
466    }
467}