Skip to main content

roxlap_scene/
collide.rs

1//! Collision query layer (stage CC.0 — see
2//! `docs/porting/PORTING-CONTROLLER.md`).
3//!
4//! World-space box-vs-voxel overlap tests over a whole [`Scene`],
5//! promoted from the three demo copies of the fly-camera collision
6//! hack (scene-demo `collision.rs`, cave-demo, cave-web) so the CC
7//! character controller — and the demos themselves — share one
8//! implementation with the demos' hard-won lessons pinned as unit
9//! tests here:
10//!
11//! - Solidity comes from [`roxlap_core::world_query::getcube`]:
12//!   [`Cube::Color`](roxlap_core::world_query::Cube::Color) *and*
13//!   [`Cube::UnexposedSolid`](roxlap_core::world_query::Cube::UnexposedSolid)
14//!   block (slab interiors are solid material),
15//!   [`Cube::Air`](roxlap_core::world_query::Cube::Air) does not.
16//! - The voxlap bedrock placeholder at chunk-local
17//!   `z = CHUNK_SIZE_Z - 1` is a *policy*, not a fact —
18//!   [`Solidity::bedrock_blocks`], default `false` to match the
19//!   demos' `treat_z_max_as_air` rendering (else an invisible wall
20//!   appears at every grid's bottom plane).
21//! - Positions outside every grid's footprint are air, so a body can
22//!   move past a grid without hitting invisible bounds.
23//!
24//! Grid placement: **axis-aligned grids are probed cell-exactly**
25//! (the box's floor-range of voxel cells); **rotated grids are
26//! probed conservatively** — the world box's 8 corners are
27//! transformed into grid-local space and their local AABB is probed,
28//! which blocks slightly early near rotated geometry but never
29//! leaks. An exact OBB-vs-voxel test is out of scope for a
30//! controller-grade query.
31
32use glam::{DQuat, DVec3, IVec3};
33use roxlap_core::world_query::{getcube, Cube};
34
35use crate::{voxel_split, Grid, Scene, CHUNK_SIZE_Z};
36
37/// What counts as solid for a collision probe.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct Solidity {
40    /// Does the voxlap bedrock placeholder (chunk-local
41    /// `z = CHUNK_SIZE_Z - 1`) block? Default `false`, matching the
42    /// demos' `treat_z_max_as_air` rendering. Set `true` for worlds
43    /// whose bottom plane is genuinely solid ground **rendered as
44    /// such** — collision and rendering must agree, or the player
45    /// either hits an invisible wall or falls through a visible
46    /// floor.
47    pub bedrock_blocks: bool,
48    /// The material hook (CC.4): a colour veto for *visible* voxels.
49    /// `Some(f)` makes a voxel whose colour `f` approves passable —
50    /// water, foliage, ladders — while everything else still blocks;
51    /// `None` (default) blocks on any voxel. A plain `fn` (not a
52    /// closure) so `Solidity` stays `Copy`; key it the way material
53    /// maps key their colours (compare `.rgb_part()`, ignore the
54    /// brightness byte — lighting bakes rewrite it).
55    ///
56    /// Limitation, inherent to the voxlap slab format: hidden run
57    /// interiors ([`Cube::UnexposedSolid`]) store no colour and
58    /// always block. Rule of thumb: a
59    /// pass-through wall/curtain works up to **2 voxels thick**
60    /// (every voxel still has an exposed face and carries its
61    /// colour); at 3+ a colourless core appears and blocks. Filled
62    /// pools are wade-through only near their visible surface. Also
63    /// keep such geometry at least 1 voxel away from chunk-local
64    /// x/y = 0 and the chunk's far edge: the slab encoder treats
65    /// out-of-chunk neighbours as solid, so edge voxels lose their
66    /// side colours (they render, but classify as UnexposedSolid —
67    /// both facts are pinned in this module's tests).
68    pub passable: Option<fn(crate::VoxColor) -> bool>,
69}
70
71impl Solidity {
72    /// Does this probe result block, under the current policy?
73    fn cube_blocks(self, cube: Cube) -> bool {
74        match cube {
75            Cube::Air => false,
76            Cube::UnexposedSolid => true,
77            Cube::Color(c) => !self
78                .passable
79                .is_some_and(|passes| passes(crate::VoxColor(c))),
80        }
81    }
82}
83
84/// `true` if the axis-aligned world-space box `[min, max]` overlaps
85/// any solid voxel of any grid in `scene`.
86///
87/// `min`/`max` are corner positions with `min[i] <= max[i]`; a face
88/// exactly on a voxel-cell boundary counts as overlapping the cell
89/// on both sides (conservative, matching the demos' `±radius`
90/// probes).
91#[must_use]
92pub fn box_overlaps_solid(scene: &Scene, min: DVec3, max: DVec3, solidity: Solidity) -> bool {
93    scene
94        .grids()
95        .any(|(_id, grid)| grid_box_overlaps_solid(grid, min, max, solidity))
96}
97
98/// Point form of [`box_overlaps_solid`]: `true` if the voxel cell
99/// containing the world-space point `p` is solid in any grid.
100#[must_use]
101pub fn point_overlaps_solid(scene: &Scene, p: DVec3, solidity: Solidity) -> bool {
102    box_overlaps_solid(scene, p, p, solidity)
103}
104
105/// Single-grid form of [`box_overlaps_solid`] — the building block
106/// the scene-level test folds over, public for hosts that manage
107/// their own grid (the cave demos collide against one grid only).
108#[must_use]
109pub fn grid_box_overlaps_solid(grid: &Grid, min: DVec3, max: DVec3, solidity: Solidity) -> bool {
110    // World box → grid-local box.
111    let (lmin, lmax) = if grid.transform.rotation == DQuat::IDENTITY {
112        // Axis-aligned: translate — the probe below is cell-exact.
113        (min - grid.transform.origin, max - grid.transform.origin)
114    } else {
115        // Rotated: local AABB of the 8 transformed corners —
116        // conservative (a fat OBB approximation), never leaky.
117        let inv = grid.transform.rotation.inverse();
118        let mut lmin = DVec3::INFINITY;
119        let mut lmax = DVec3::NEG_INFINITY;
120        for corner in 0..8 {
121            let world = DVec3::new(
122                if corner & 1 == 0 { min.x } else { max.x },
123                if corner & 2 == 0 { min.y } else { max.y },
124                if corner & 4 == 0 { min.z } else { max.z },
125            );
126            let local = inv * (world - grid.transform.origin);
127            lmin = lmin.min(local);
128            lmax = lmax.max(local);
129        }
130        (lmin, lmax)
131    };
132
133    // Every voxel cell the local box touches. `floor` of both ends:
134    // a face exactly on a cell boundary includes the far cell.
135    #[allow(clippy::cast_possible_truncation)]
136    let (lo, hi) = (lmin.floor().as_ivec3(), lmax.floor().as_ivec3());
137
138    let bedrock_z = CHUNK_SIZE_Z - 1;
139    for vz in lo.z..=hi.z {
140        for vy in lo.y..=hi.y {
141            for vx in lo.x..=hi.x {
142                let (chunk_idx, in_chunk) = voxel_split(IVec3::new(vx, vy, vz));
143                if !solidity.bedrock_blocks && in_chunk.z == bedrock_z {
144                    // Bedrock placeholder treated as air (fn docs).
145                    continue;
146                }
147                let Some(chunk) = grid.chunk(chunk_idx) else {
148                    // Implicit-air chunk.
149                    continue;
150                };
151                // rem_euclid postcondition: components in [0, chunk
152                // size), far below i32::MAX.
153                #[allow(clippy::cast_possible_wrap)]
154                let cube = getcube(
155                    &chunk.data,
156                    &chunk.column_offset,
157                    chunk.vsid,
158                    in_chunk.x as i32,
159                    in_chunk.y as i32,
160                    in_chunk.z as i32,
161                );
162                if solidity.cube_blocks(cube) {
163                    return true;
164                }
165            }
166        }
167    }
168    false
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::{GridTransform, VoxColor};
175
176    const AIR_BEDROCK: Solidity = Solidity {
177        bedrock_blocks: false,
178        passable: None,
179    };
180
181    /// Single floating voxel at grid-local (10, 10, 50) in a grid at
182    /// world (0, 0, -100) — the scene-demo `collision.rs` fixture,
183    /// ported with its tests (CC.0 regression net).
184    fn floating_voxel_scene() -> Scene {
185        let mut scene = Scene::new();
186        let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 0.0, -100.0)));
187        let grid = scene.grid_mut(id).expect("grid present");
188        grid.set_voxel(IVec3::new(10, 10, 50), Some(VoxColor(0x80_aa_bb_cc)));
189        scene
190    }
191
192    fn cube_probe(scene: &Scene, world: [f64; 3], r: f64) -> bool {
193        let p = DVec3::from(world);
194        box_overlaps_solid(scene, p - r, p + r, AIR_BEDROCK)
195    }
196
197    #[test]
198    fn visible_voxel_blocks() {
199        let scene = floating_voxel_scene();
200        // World (10, 10, -50) → grid-local (10, 10, 50): the voxel.
201        assert!(cube_probe(&scene, [10.0, 10.0, -50.0], 0.3));
202    }
203
204    #[test]
205    fn out_of_grid_position_is_air() {
206        let scene = floating_voxel_scene();
207        assert!(!cube_probe(&scene, [-500.0, -500.0, -50.0], 0.3));
208    }
209
210    #[test]
211    fn below_isolated_floating_voxel_is_air() {
212        // Sparse column: getcube past the deepest visible slab walks
213        // to the air pocket below — NOT UnexposedSolid.
214        let scene = floating_voxel_scene();
215        assert!(!cube_probe(&scene, [10.0, 10.0, 0.0], 0.3));
216    }
217
218    #[test]
219    fn slab_interior_unexposed_solid_blocks() {
220        // A thick set_rect slab: its hidden interior reports
221        // Cube::UnexposedSolid, which must block (the scene-demo
222        // saucer-interior lesson — treating it as air lets the body
223        // inside solid material).
224        let mut scene = Scene::new();
225        let id = scene.add_grid(GridTransform::identity());
226        let grid = scene.grid_mut(id).expect("grid present");
227        grid.set_rect(
228            IVec3::new(0, 0, 50),
229            IVec3::new(20, 20, 80),
230            Some(VoxColor(0x80_66_77_88)),
231        );
232        assert!(cube_probe(&scene, [10.0, 10.0, 65.0], 0.3));
233    }
234
235    #[test]
236    fn bedrock_placeholder_is_policy() {
237        // Any edit materialises a chunk whose columns keep the voxlap
238        // bedrock placeholder at chunk-local z = CHUNK_SIZE_Z - 1.
239        // Probe that plane far from the real voxel: air by default
240        // (the scene-demo invisible-wall fix), solid on request.
241        let scene = floating_voxel_scene();
242        let at_bedrock = [100.0, 100.0, -100.0 + f64::from(CHUNK_SIZE_Z) - 0.5];
243        assert!(!cube_probe(&scene, at_bedrock, 0.3));
244        let p = DVec3::from(at_bedrock);
245        assert!(box_overlaps_solid(
246            &scene,
247            p - 0.3,
248            p + 0.3,
249            Solidity {
250                bedrock_blocks: true,
251                passable: None,
252            },
253        ));
254    }
255
256    #[test]
257    fn rotated_grid_blocks_conservatively() {
258        // Grid rotated 45° about z, one voxel at local (10, 10, 50).
259        // Probing the voxel's world-space centre must block; probing
260        // far away must not. (Exactness near rotated geometry is not
261        // promised — the corner-AABB is conservative.)
262        let mut scene = Scene::new();
263        let rot = DQuat::from_rotation_z(std::f64::consts::FRAC_PI_4);
264        let id = scene.add_grid(GridTransform {
265            origin: DVec3::new(200.0, 0.0, 0.0),
266            rotation: rot,
267        });
268        let grid = scene.grid_mut(id).expect("grid present");
269        grid.set_voxel(IVec3::new(10, 10, 50), Some(VoxColor(0x80_11_22_33)));
270
271        let centre_world = DVec3::new(200.0, 0.0, 0.0) + rot * DVec3::new(10.5, 10.5, 50.5);
272        assert!(box_overlaps_solid(
273            &scene,
274            centre_world - 0.1,
275            centre_world + 0.1,
276            AIR_BEDROCK,
277        ));
278        let far = centre_world + DVec3::new(40.0, 40.0, 0.0);
279        assert!(!box_overlaps_solid(
280            &scene,
281            far - 0.1,
282            far + 0.1,
283            AIR_BEDROCK
284        ));
285    }
286
287    /// CC.4 — the material hook. Water-coloured voxels pass, any
288    /// other colour still blocks, and hidden run interiors block
289    /// regardless (the slab format stores no colour for them).
290    #[test]
291    fn passable_colour_veto() {
292        const WATER: VoxColor = VoxColor(0x80_20_60_c0);
293        const GLASS: VoxColor = VoxColor(0x80_50_c0_e0);
294        fn water_passes(c: VoxColor) -> bool {
295            c.rgb_part() == WATER.rgb_part()
296        }
297        let veto = Solidity {
298            bedrock_blocks: false,
299            passable: Some(water_passes),
300        };
301
302        let mut scene = Scene::new();
303        let id = scene.add_grid(GridTransform::identity());
304        let grid = scene.grid_mut(id).expect("grid present");
305        // A water curtain and a glass wall, 1 voxel thin (fully
306        // visible), plus a THICK water block whose interior is
307        // UnexposedSolid.
308        grid.set_rect(IVec3::new(10, 0, 40), IVec3::new(10, 20, 60), Some(WATER));
309        grid.set_rect(IVec3::new(20, 0, 40), IVec3::new(20, 20, 60), Some(GLASS));
310        grid.set_rect(IVec3::new(30, 0, 40), IVec3::new(40, 20, 60), Some(WATER));
311
312        let probe = |x: f64, z: f64, s: Solidity| {
313            let p = DVec3::new(x, 10.0, z);
314            box_overlaps_solid(&scene, p - 0.3, p + 0.3, s)
315        };
316        // Thin water: blocked without the veto, passable with it.
317        assert!(probe(10.5, 50.0, AIR_BEDROCK));
318        assert!(!probe(10.5, 50.0, veto));
319        // Glass: blocked either way — the veto is colour-selective.
320        assert!(probe(20.5, 50.0, veto));
321        // Thick water body: surface layer passes…
322        assert!(!probe(30.5, 50.0, veto));
323        // …but the hidden interior has no colour and still blocks
324        // (brightness byte differences must not matter either — the
325        // veto compares rgb_part, and bakes rewrite the high byte).
326        assert!(probe(35.5, 50.0, veto));
327    }
328
329    #[test]
330    fn point_probe_matches_degenerate_box() {
331        let scene = floating_voxel_scene();
332        assert!(point_overlaps_solid(
333            &scene,
334            DVec3::new(10.5, 10.5, -49.5),
335            AIR_BEDROCK,
336        ));
337        assert!(!point_overlaps_solid(
338            &scene,
339            DVec3::new(10.5, 10.5, -55.5),
340            AIR_BEDROCK,
341        ));
342    }
343
344    /// The passable-veto geometry rule of thumb, pinned: a wall 1 or
345    /// 2 voxels thick is fully side-exposed (every voxel carries its
346    /// colour — the veto sees it all), while 3+ has a hidden core of
347    /// colourless `UnexposedSolid` that blocks regardless of the
348    /// veto. Build pass-through curtains at most 2 voxels thick.
349    #[test]
350    fn passable_walls_must_be_at_most_two_voxels_thick() {
351        const WATER: VoxColor = VoxColor(0x80_30_60_c8);
352        fn water_passes(c: VoxColor) -> bool {
353            c.rgb_part() == WATER.rgb_part()
354        }
355        let veto = Solidity {
356            bedrock_blocks: false,
357            passable: Some(water_passes),
358        };
359        let crossing_blocked = |thick: i32| {
360            let mut scene = Scene::new();
361            let id = scene.add_grid(GridTransform::identity());
362            let g = scene.grid_mut(id).expect("grid");
363            g.set_rect(
364                IVec3::new(0, 10, 0),
365                IVec3::new(50, 10 + thick - 1, 50),
366                Some(WATER),
367            );
368            // A CharacterBody-sized box mid-wall, mid-height.
369            let p = DVec3::new(25.0, 10.0 + f64::from(thick) * 0.5, 25.0);
370            box_overlaps_solid(
371                &scene,
372                p - DVec3::new(0.4, 0.4, 0.9),
373                p + DVec3::new(0.4, 0.4, 0.9),
374                veto,
375            )
376        };
377        assert!(!crossing_blocked(1));
378        assert!(!crossing_blocked(2));
379        assert!(crossing_blocked(3), "3-thick has a hidden core");
380        assert!(crossing_blocked(4));
381
382        // Second format fact: a wall hugging grid-local y = 0 (the
383        // chunk boundary) blocks even at 2 voxels thick — the slab
384        // encoder treats the out-of-chunk neighbour as solid, so the
385        // edge layer's side colours are never stored (it RENDERS,
386        // but classifies as UnexposedSolid). Keep pass-through
387        // geometry at least 1 voxel inside the chunk.
388        let mut scene = Scene::new();
389        let id = scene.add_grid(GridTransform::identity());
390        let g = scene.grid_mut(id).expect("grid");
391        g.set_rect(IVec3::new(0, 0, 0), IVec3::new(50, 1, 50), Some(WATER));
392        let p = DVec3::new(25.0, 1.0, 25.0);
393        assert!(
394            box_overlaps_solid(
395                &scene,
396                p - DVec3::new(0.4, 0.4, 0.9),
397                p + DVec3::new(0.4, 0.4, 0.9),
398                veto,
399            ),
400            "chunk-edge wall: edge layer has no stored colour"
401        );
402    }
403}