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 VOXEL box. SC.3 — the grid-local offset is in
111 // world units; divide by `voxel_world_size` to reach voxel indices (in
112 // which the cell probe below lives). `vws == 1.0` is byte-identical.
113 let vws = grid.transform.voxel_world_size;
114 let (lmin, lmax) = if grid.transform.rotation == DQuat::IDENTITY {
115 // Axis-aligned: translate + scale — the probe below is cell-exact.
116 (
117 (min - grid.transform.origin) / vws,
118 (max - grid.transform.origin) / vws,
119 )
120 } else {
121 // Rotated: local AABB of the 8 transformed corners —
122 // conservative (a fat OBB approximation), never leaky.
123 let inv = grid.transform.rotation.inverse();
124 let mut lmin = DVec3::INFINITY;
125 let mut lmax = DVec3::NEG_INFINITY;
126 for corner in 0..8 {
127 let world = DVec3::new(
128 if corner & 1 == 0 { min.x } else { max.x },
129 if corner & 2 == 0 { min.y } else { max.y },
130 if corner & 4 == 0 { min.z } else { max.z },
131 );
132 let local = inv * (world - grid.transform.origin) / vws;
133 lmin = lmin.min(local);
134 lmax = lmax.max(local);
135 }
136 (lmin, lmax)
137 };
138
139 // Every voxel cell the local box touches. `floor` of both ends:
140 // a face exactly on a cell boundary includes the far cell.
141 #[allow(clippy::cast_possible_truncation)]
142 let (lo, hi) = (lmin.floor().as_ivec3(), lmax.floor().as_ivec3());
143
144 let bedrock_z = CHUNK_SIZE_Z - 1;
145 for vz in lo.z..=hi.z {
146 for vy in lo.y..=hi.y {
147 for vx in lo.x..=hi.x {
148 let (chunk_idx, in_chunk) = voxel_split(IVec3::new(vx, vy, vz));
149 if !solidity.bedrock_blocks && in_chunk.z == bedrock_z {
150 // Bedrock placeholder treated as air (fn docs).
151 continue;
152 }
153 let Some(chunk) = grid.chunk(chunk_idx) else {
154 // Implicit-air chunk.
155 continue;
156 };
157 // rem_euclid postcondition: components in [0, chunk
158 // size), far below i32::MAX.
159 #[allow(clippy::cast_possible_wrap)]
160 let cube = getcube(
161 &chunk.data,
162 &chunk.column_offset,
163 chunk.vsid,
164 in_chunk.x as i32,
165 in_chunk.y as i32,
166 in_chunk.z as i32,
167 );
168 if solidity.cube_blocks(cube) {
169 return true;
170 }
171 }
172 }
173 }
174 false
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::{GridTransform, VoxColor};
181
182 const AIR_BEDROCK: Solidity = Solidity {
183 bedrock_blocks: false,
184 passable: None,
185 };
186
187 /// Single floating voxel at grid-local (10, 10, 50) in a grid at
188 /// world (0, 0, -100) — the scene-demo `collision.rs` fixture,
189 /// ported with its tests (CC.0 regression net).
190 fn floating_voxel_scene() -> Scene {
191 let mut scene = Scene::new();
192 let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 0.0, -100.0)));
193 let grid = scene.grid_mut(id).expect("grid present");
194 grid.set_voxel(IVec3::new(10, 10, 50), Some(VoxColor(0x80_aa_bb_cc)));
195 scene
196 }
197
198 fn cube_probe(scene: &Scene, world: [f64; 3], r: f64) -> bool {
199 let p = DVec3::from(world);
200 box_overlaps_solid(scene, p - r, p + r, AIR_BEDROCK)
201 }
202
203 #[test]
204 fn visible_voxel_blocks() {
205 let scene = floating_voxel_scene();
206 // World (10, 10, -50) → grid-local (10, 10, 50): the voxel.
207 assert!(cube_probe(&scene, [10.0, 10.0, -50.0], 0.3));
208 }
209
210 #[test]
211 fn sc3_scaled_grid_collides_at_world_position() {
212 // SC.3 — a vws=2.0 grid with a voxel at grid-local (10,10,50) puts it
213 // at WORLD (20,20,100); the collision box probe must hit there, not at
214 // the unscaled world (10,10,50). Without the /vws in
215 // `grid_box_overlaps_solid` the world box would map to grid-local
216 // (20,20,100) — a different, empty voxel — and miss.
217 let mut scene = Scene::new();
218 let id = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
219 scene
220 .grid_mut(id)
221 .expect("grid present")
222 .set_voxel(IVec3::new(10, 10, 50), Some(VoxColor(0x80_aa_bb_cc)));
223 // The voxel's world cell is [20,22)×[20,22)×[100,102) (local·vws).
224 assert!(
225 cube_probe(&scene, [21.0, 21.0, 101.0], 0.3),
226 "scaled voxel must block at its WORLD position (20,20,100)"
227 );
228 // The unscaled position (where a missing /vws would place it) is air.
229 assert!(
230 !cube_probe(&scene, [10.5, 10.5, 50.5], 0.3),
231 "nothing at the un-scaled world position"
232 );
233 }
234
235 #[test]
236 fn out_of_grid_position_is_air() {
237 let scene = floating_voxel_scene();
238 assert!(!cube_probe(&scene, [-500.0, -500.0, -50.0], 0.3));
239 }
240
241 #[test]
242 fn below_isolated_floating_voxel_is_air() {
243 // Sparse column: getcube past the deepest visible slab walks
244 // to the air pocket below — NOT UnexposedSolid.
245 let scene = floating_voxel_scene();
246 assert!(!cube_probe(&scene, [10.0, 10.0, 0.0], 0.3));
247 }
248
249 #[test]
250 fn slab_interior_unexposed_solid_blocks() {
251 // A thick set_rect slab: its hidden interior reports
252 // Cube::UnexposedSolid, which must block (the scene-demo
253 // saucer-interior lesson — treating it as air lets the body
254 // inside solid material).
255 let mut scene = Scene::new();
256 let id = scene.add_grid(GridTransform::identity());
257 let grid = scene.grid_mut(id).expect("grid present");
258 grid.set_rect(
259 IVec3::new(0, 0, 50),
260 IVec3::new(20, 20, 80),
261 Some(VoxColor(0x80_66_77_88)),
262 );
263 assert!(cube_probe(&scene, [10.0, 10.0, 65.0], 0.3));
264 }
265
266 #[test]
267 fn bedrock_placeholder_is_policy() {
268 // Any edit materialises a chunk whose columns keep the voxlap
269 // bedrock placeholder at chunk-local z = CHUNK_SIZE_Z - 1.
270 // Probe that plane far from the real voxel: air by default
271 // (the scene-demo invisible-wall fix), solid on request.
272 let scene = floating_voxel_scene();
273 let at_bedrock = [100.0, 100.0, -100.0 + f64::from(CHUNK_SIZE_Z) - 0.5];
274 assert!(!cube_probe(&scene, at_bedrock, 0.3));
275 let p = DVec3::from(at_bedrock);
276 assert!(box_overlaps_solid(
277 &scene,
278 p - 0.3,
279 p + 0.3,
280 Solidity {
281 bedrock_blocks: true,
282 passable: None,
283 },
284 ));
285 }
286
287 #[test]
288 fn rotated_grid_blocks_conservatively() {
289 // Grid rotated 45° about z, one voxel at local (10, 10, 50).
290 // Probing the voxel's world-space centre must block; probing
291 // far away must not. (Exactness near rotated geometry is not
292 // promised — the corner-AABB is conservative.)
293 let mut scene = Scene::new();
294 let rot = DQuat::from_rotation_z(std::f64::consts::FRAC_PI_4);
295 let id = scene.add_grid(GridTransform {
296 origin: DVec3::new(200.0, 0.0, 0.0),
297 rotation: rot,
298 voxel_world_size: 1.0,
299 });
300 let grid = scene.grid_mut(id).expect("grid present");
301 grid.set_voxel(IVec3::new(10, 10, 50), Some(VoxColor(0x80_11_22_33)));
302
303 let centre_world = DVec3::new(200.0, 0.0, 0.0) + rot * DVec3::new(10.5, 10.5, 50.5);
304 assert!(box_overlaps_solid(
305 &scene,
306 centre_world - 0.1,
307 centre_world + 0.1,
308 AIR_BEDROCK,
309 ));
310 let far = centre_world + DVec3::new(40.0, 40.0, 0.0);
311 assert!(!box_overlaps_solid(
312 &scene,
313 far - 0.1,
314 far + 0.1,
315 AIR_BEDROCK
316 ));
317 }
318
319 /// CC.4 — the material hook. Water-coloured voxels pass, any
320 /// other colour still blocks, and hidden run interiors block
321 /// regardless (the slab format stores no colour for them).
322 #[test]
323 fn passable_colour_veto() {
324 const WATER: VoxColor = VoxColor(0x80_20_60_c0);
325 const GLASS: VoxColor = VoxColor(0x80_50_c0_e0);
326 fn water_passes(c: VoxColor) -> bool {
327 c.rgb_part() == WATER.rgb_part()
328 }
329 let veto = Solidity {
330 bedrock_blocks: false,
331 passable: Some(water_passes),
332 };
333
334 let mut scene = Scene::new();
335 let id = scene.add_grid(GridTransform::identity());
336 let grid = scene.grid_mut(id).expect("grid present");
337 // A water curtain and a glass wall, 1 voxel thin (fully
338 // visible), plus a THICK water block whose interior is
339 // UnexposedSolid.
340 grid.set_rect(IVec3::new(10, 0, 40), IVec3::new(10, 20, 60), Some(WATER));
341 grid.set_rect(IVec3::new(20, 0, 40), IVec3::new(20, 20, 60), Some(GLASS));
342 grid.set_rect(IVec3::new(30, 0, 40), IVec3::new(40, 20, 60), Some(WATER));
343
344 let probe = |x: f64, z: f64, s: Solidity| {
345 let p = DVec3::new(x, 10.0, z);
346 box_overlaps_solid(&scene, p - 0.3, p + 0.3, s)
347 };
348 // Thin water: blocked without the veto, passable with it.
349 assert!(probe(10.5, 50.0, AIR_BEDROCK));
350 assert!(!probe(10.5, 50.0, veto));
351 // Glass: blocked either way — the veto is colour-selective.
352 assert!(probe(20.5, 50.0, veto));
353 // Thick water body: surface layer passes…
354 assert!(!probe(30.5, 50.0, veto));
355 // …but the hidden interior has no colour and still blocks
356 // (brightness byte differences must not matter either — the
357 // veto compares rgb_part, and bakes rewrite the high byte).
358 assert!(probe(35.5, 50.0, veto));
359 }
360
361 #[test]
362 fn point_probe_matches_degenerate_box() {
363 let scene = floating_voxel_scene();
364 assert!(point_overlaps_solid(
365 &scene,
366 DVec3::new(10.5, 10.5, -49.5),
367 AIR_BEDROCK,
368 ));
369 assert!(!point_overlaps_solid(
370 &scene,
371 DVec3::new(10.5, 10.5, -55.5),
372 AIR_BEDROCK,
373 ));
374 }
375
376 /// The passable-veto geometry rule of thumb, pinned: a wall 1 or
377 /// 2 voxels thick is fully side-exposed (every voxel carries its
378 /// colour — the veto sees it all), while 3+ has a hidden core of
379 /// colourless `UnexposedSolid` that blocks regardless of the
380 /// veto. Build pass-through curtains at most 2 voxels thick.
381 #[test]
382 fn passable_walls_must_be_at_most_two_voxels_thick() {
383 const WATER: VoxColor = VoxColor(0x80_30_60_c8);
384 fn water_passes(c: VoxColor) -> bool {
385 c.rgb_part() == WATER.rgb_part()
386 }
387 let veto = Solidity {
388 bedrock_blocks: false,
389 passable: Some(water_passes),
390 };
391 let crossing_blocked = |thick: i32| {
392 let mut scene = Scene::new();
393 let id = scene.add_grid(GridTransform::identity());
394 let g = scene.grid_mut(id).expect("grid");
395 g.set_rect(
396 IVec3::new(0, 10, 0),
397 IVec3::new(50, 10 + thick - 1, 50),
398 Some(WATER),
399 );
400 // A CharacterBody-sized box mid-wall, mid-height.
401 let p = DVec3::new(25.0, 10.0 + f64::from(thick) * 0.5, 25.0);
402 box_overlaps_solid(
403 &scene,
404 p - DVec3::new(0.4, 0.4, 0.9),
405 p + DVec3::new(0.4, 0.4, 0.9),
406 veto,
407 )
408 };
409 assert!(!crossing_blocked(1));
410 assert!(!crossing_blocked(2));
411 assert!(crossing_blocked(3), "3-thick has a hidden core");
412 assert!(crossing_blocked(4));
413
414 // Second format fact: a wall hugging grid-local y = 0 (the
415 // chunk boundary) blocks even at 2 voxels thick — the slab
416 // encoder treats the out-of-chunk neighbour as solid, so the
417 // edge layer's side colours are never stored (it RENDERS,
418 // but classifies as UnexposedSolid). Keep pass-through
419 // geometry at least 1 voxel inside the chunk.
420 let mut scene = Scene::new();
421 let id = scene.add_grid(GridTransform::identity());
422 let g = scene.grid_mut(id).expect("grid");
423 g.set_rect(IVec3::new(0, 0, 0), IVec3::new(50, 1, 50), Some(WATER));
424 let p = DVec3::new(25.0, 1.0, 25.0);
425 assert!(
426 box_overlaps_solid(
427 &scene,
428 p - DVec3::new(0.4, 0.4, 0.9),
429 p + DVec3::new(0.4, 0.4, 0.9),
430 veto,
431 ),
432 "chunk-edge wall: edge layer has no stored colour"
433 );
434 }
435}