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}