Skip to main content

roxlap_render/
debris.rs

1//! DT.2 — [`DebrisSystem`]: falling voxel islands (see
2//! `docs/porting/PORTING-DESTRUCTION.md`).
3//!
4//! The crumble half of the destruction stage: a detected
5//! [`Island`] is extracted from its grid, becomes a KV6 sprite, falls
6//! under gravity with a slow cosmetic spin, and reports an impact the
7//! moment its footprint touches solid ground — where DT.3's shatter
8//! takes over. Host-owned and renderer-agnostic like
9//! [`ParticleSystem`](crate::ParticleSystem), and split the same way:
10//! [`DebrisSystem::spawn_island`] + [`DebrisSystem::update`] are pure
11//! simulation over a [`Scene`] (unit-testable, no facade calls);
12//! [`DebrisSystem::sync`] mirrors bodies into dynamic sprite
13//! instances (spawn pre-posed, batch-move, despawn);
14//! [`DebrisSystem::tick`] is the per-frame one-shot.
15//!
16//! Physics is deliberately minimal (locked in the entry doc): bodies
17//! fall **world-vertically** (+z, the voxlap z-down convention) with
18//! a terminal-speed clamp; the yaw spin is cosmetic only — collision
19//! always tests the **unrotated** world AABB via
20//! [`box_overlaps_solid`], binary-searching the contact so a landed
21//! body rests flush on the voxel plane it hit. A grid's rotation is
22//! likewise not applied to the falling sprite's pose (v1 targets
23//! axis-aligned grids; the cave demo's is identity).
24
25use std::collections::HashMap;
26
27use glam::{DVec3, IVec3};
28use roxlap_formats::material::material_for_color;
29use roxlap_scene::islands::{FracturePattern, Island};
30use roxlap_scene::{box_overlaps_solid, BakeMode, GridId, Rgb, Scene, Solidity, VoxColor};
31
32use crate::{DynSpriteTransform, SceneRenderer, SpriteInstanceId, SpriteModelId};
33
34/// One landed island, drained via [`DebrisSystem::drain_impacts`]:
35/// everything the shatter needs.
36#[derive(Debug)]
37pub struct DebrisImpact {
38    /// The island's voxels + colours (grid-local, as detected) — the
39    /// shatter burst samples these, not the sprite model.
40    pub island: Island,
41    /// The grid the island was extracted from.
42    pub grid: GridId,
43    /// World position of the island's pivot (bbox centre) at rest —
44    /// flush on the surface it hit.
45    pub pos: DVec3,
46    /// Impact speed in world units/second (≤ the terminal clamp).
47    pub speed: f64,
48    /// The source grid's world units per voxel at extraction time —
49    /// kept here so [`Self::burst_sites`] stays self-contained even if
50    /// the grid is gone by shatter time.
51    pub voxel_world_size: f64,
52}
53
54impl DebrisImpact {
55    /// DT.3 — the world-space burst sites for the shatter: every
56    /// island voxel's centre, translated to where the body **landed**
57    /// (its grid-local offset from the bbox centre, scaled by the
58    /// source grid's voxel size, applied around [`Self::pos`]; the
59    /// cosmetic yaw is ignored, matching the axis-aligned collision
60    /// box). Feed straight into
61    /// [`ParticleSystem::voxel_debris`](crate::ParticleSystem::voxel_debris)
62    /// with `from = pos` for the colour-true shatter.
63    #[must_use]
64    #[allow(clippy::cast_possible_truncation)]
65    pub fn burst_sites(&self) -> Vec<([f32; 3], Rgb)> {
66        let (lo, hi) = self.island.bbox;
67        // The bbox centre in voxel units — exactly where `world_pivot`
68        // anchored the body: lo + dims/2 = (lo + hi + 1) / 2.
69        let centre = (lo.as_dvec3() + hi.as_dvec3() + DVec3::ONE) * 0.5;
70        self.island
71            .voxels
72            .iter()
73            .map(|&(v, c)| {
74                let off = (v.as_dvec3() + DVec3::splat(0.5) - centre) * self.voxel_world_size;
75                let p = self.pos + off;
76                ([p.x as f32, p.y as f32, p.z as f32], c.rgb_part())
77            })
78            .collect()
79    }
80}
81
82/// One falling island: simulation state + the (lazily created) sprite
83/// handles `sync` manages.
84struct DebrisBody {
85    island: Island,
86    grid: GridId,
87    model: Option<SpriteModelId>,
88    inst: Option<SpriteInstanceId>,
89    /// World position of the pivot (bbox centre).
90    pos: DVec3,
91    /// Velocity, world units/s, +z down. The vertical component
92    /// integrates gravity and collides; x/y is the DT.5 fracture
93    /// drift — fragments spread apart as they fall, with **no**
94    /// horizontal collision (v1: the collision march is vertical).
95    vel: DVec3,
96    /// Cosmetic spin about the world vertical.
97    yaw: f32,
98    yaw_rate: f32,
99    /// Half-extents of the unrotated world AABB.
100    half: DVec3,
101    /// The source grid's `voxel_world_size` — the uniform basis scale
102    /// the sprite renders at.
103    scale: f32,
104}
105
106/// Host-owned falling-island simulation + facade binding. Construct
107/// once, [`Self::spawn_island`] per detected island,
108/// [`Self::tick`] per frame, [`Self::drain_impacts`] to shatter.
109pub struct DebrisSystem {
110    /// Gravity in world units/s², +z is down. Matches the particle
111    /// default so debris and dust fall together.
112    pub gravity: f64,
113    /// Terminal-speed clamp on the fall, world units/s.
114    pub terminal_speed: f64,
115    /// Cosmetic spin cap, rad/s: each island gets a deterministic
116    /// per-island rate in `[-max, max]` (hashed from its position —
117    /// no RNG state, replays are bit-identical).
118    pub max_yaw_rate: f32,
119    /// What counts as solid ground (bedrock-placeholder policy) —
120    /// the same knob [`box_overlaps_solid`] takes.
121    pub solidity: Solidity,
122    /// DT.5 — outward drift a fracture fragment inherits, world
123    /// units/s (directed from the parent island's centre to the
124    /// fragment's; zero for unsplit islands).
125    pub fracture_impulse: f64,
126    /// DT.5 — the colour→material map (the host's terrain map).
127    /// Non-empty ⇒ island/fragment models register **with** it
128    /// ([`add_sprite_model_with_materials`](SceneRenderer::add_sprite_model_with_materials)),
129    /// so e.g. a fallen crystal keeps its translucent+emissive
130    /// material and glows on the way down.
131    material_map: Vec<(Rgb, u8)>,
132    /// DT.5 — material id → fracture pattern (unmapped = `Whole`).
133    patterns: HashMap<u8, FracturePattern>,
134    bodies: Vec<DebrisBody>,
135    impacts: Vec<DebrisImpact>,
136    /// Handles of retired bodies awaiting facade removal in `sync`.
137    dead: Vec<(Option<SpriteModelId>, Option<SpriteInstanceId>)>,
138    /// Reused per-sync transform batch (no per-frame allocation).
139    move_scratch: Vec<(SpriteInstanceId, DynSpriteTransform)>,
140}
141
142impl Default for DebrisSystem {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl DebrisSystem {
149    /// A system with the stock tuning: particle-matched gravity, a
150    /// generous terminal clamp, a barely-perceptible spin.
151    #[must_use]
152    pub fn new() -> Self {
153        Self {
154            gravity: 22.0,
155            terminal_speed: 60.0,
156            max_yaw_rate: 0.6,
157            solidity: Solidity::default(),
158            fracture_impulse: 2.0,
159            material_map: Vec::new(),
160            patterns: HashMap::new(),
161            bodies: Vec::new(),
162            impacts: Vec::new(),
163            dead: Vec::new(),
164            move_scratch: Vec::new(),
165        }
166    }
167
168    /// DT.5 — install the fracture side tables: `colour_map` is the
169    /// host's colour→material terrain map (also switches island
170    /// models to material-mapped registration, so translucent /
171    /// emissive voxels keep their look in flight), `patterns` maps
172    /// material ids to [`FracturePattern`]s. An island splits **per
173    /// material group** before falling: its rock breaks per the rock
174    /// pattern, its crystal per the crystal pattern, each fragment its
175    /// own body with a small outward [`Self::fracture_impulse`].
176    /// Empty tables (the default) keep every island `Whole`.
177    pub fn set_fracture_patterns(
178        &mut self,
179        colour_map: &[(Rgb, u8)],
180        patterns: &[(u8, FracturePattern)],
181    ) {
182        self.material_map = colour_map.to_vec();
183        self.patterns = patterns.iter().copied().collect();
184    }
185
186    /// Extract `island` from `grid` (via [`Island::extract`] — carve +
187    /// `bake_bbox`; **mips are the caller's obligation**, same as any
188    /// edit), split it per the fracture tables (DT.5 —
189    /// [`Self::set_fracture_patterns`]; no tables ⇒ one piece), and
190    /// register each fragment as a falling body at the exact world
191    /// pose of the voxels it replaced, with an outward drift for
192    /// fragments of a split. Sprite models/instances appear on the
193    /// next [`Self::sync`]. Returns `false` (and does nothing) for a
194    /// missing grid or an empty island.
195    ///
196    /// **Spawn inside geometry** (an L-shaped island whose bbox wraps
197    /// a surviving support, a detach flush under a shelf): a
198    /// fragment's AABB already overlapping foreign solid cannot fall —
199    /// it is queued as an **immediate [`DebrisImpact`] at zero speed**
200    /// (the shatter replaces it in place) and never becomes a body. An
201    /// explicit policy, not an accident of the contact search: the
202    /// accepted degradation of AABB-only collision (decision 4).
203    pub fn spawn_island(
204        &mut self,
205        scene: &mut Scene,
206        grid: GridId,
207        island: Island,
208        bake: BakeMode,
209    ) -> bool {
210        if island.voxels.is_empty() {
211            return false;
212        }
213        let Some(g) = scene.grid_mut(grid) else {
214            return false;
215        };
216        let transform = g.transform;
217        island.extract(g, bake);
218        let centre = island.world_pivot(&transform);
219        let vws = transform.voxel_world_size;
220        for frag in self.fracture(island) {
221            let pos = frag.world_pivot(&transform);
222            // DT.5 — fragments drift apart: outward from the parent
223            // island's centre (zero for an unsplit island, whose pivot
224            // IS the centre).
225            let vel = (pos - centre).normalize_or_zero() * self.fracture_impulse;
226            let dims = (frag.bbox.1 - frag.bbox.0 + IVec3::ONE).as_dvec3();
227            #[allow(clippy::cast_possible_truncation)]
228            let body = DebrisBody {
229                yaw_rate: spin_from_pos(frag.bbox.0, self.max_yaw_rate),
230                island: frag,
231                grid,
232                model: None,
233                inst: None,
234                pos,
235                vel,
236                yaw: 0.0,
237                half: dims * (vws * 0.5),
238                scale: vws as f32,
239            };
240            if aabb_hits_ground(scene, &body, body.pos.z, self.solidity) {
241                self.impacts.push(DebrisImpact {
242                    island: body.island,
243                    grid: body.grid,
244                    pos: body.pos,
245                    speed: 0.0,
246                    voxel_world_size: vws,
247                });
248            } else {
249                self.bodies.push(body);
250            }
251        }
252        true
253    }
254
255    /// DT.5 — split an island per the fracture tables: voxels group by
256    /// their material's **pattern** (first-seen order — deterministic;
257    /// two materials sharing a pattern split as ONE group, so their
258    /// fragments may mix colours — intentional: the pattern describes
259    /// how matter breaks, not what it looks like), each group splits
260    /// with its pattern, seeded from the island's position (stateless;
261    /// replays bit-identical). Empty tables or all-`Whole` materials
262    /// return the island unsplit.
263    fn fracture(&self, island: Island) -> Vec<Island> {
264        if self.patterns.is_empty() {
265            return vec![island];
266        }
267        let mut order: Vec<FracturePattern> = Vec::new();
268        let mut groups: Vec<Vec<(IVec3, VoxColor)>> = Vec::new();
269        for &(v, c) in &island.voxels {
270            let mat = material_for_color(&self.material_map, c.0);
271            let pat = self
272                .patterns
273                .get(&mat)
274                .copied()
275                .unwrap_or(FracturePattern::Whole);
276            match order.iter().position(|&p| p == pat) {
277                Some(i) => groups[i].push((v, c)),
278                None => {
279                    order.push(pat);
280                    groups.push(vec![(v, c)]);
281                }
282            }
283        }
284        if order.len() == 1 && order[0] == FracturePattern::Whole {
285            return vec![island];
286        }
287        let seed = seed_from_pos(island.bbox.0);
288        let mut out = Vec::new();
289        for (i, (pat, voxels)) in order.into_iter().zip(groups).enumerate() {
290            let mut lo = IVec3::MAX;
291            let mut hi = IVec3::MIN;
292            for &(v, _) in &voxels {
293                lo = lo.min(v);
294                hi = hi.max(v);
295            }
296            let sub = Island {
297                voxels,
298                bbox: (lo, hi),
299            };
300            out.extend(sub.split(pat, seed.wrapping_add(i as u64)));
301        }
302        out
303    }
304
305    /// Advance every body by `dt` seconds: semi-implicit Euler with
306    /// the terminal clamp, cosmetic yaw, then the vertical collision
307    /// march against `scene`. The frame's displacement is walked in
308    /// **substeps of half the collision window** (`half.z + vws/2` —
309    /// the thinnest thing a voxel scene can contain is one voxel), so
310    /// a fast body cannot tunnel through a one-voxel shelf on a slow
311    /// frame (`vel·dt` is unbounded: hosts pass real dt, hitches
312    /// included). A blocked substep is binary-searched to flush
313    /// contact; the body retires as a [`DebrisImpact`]. Pure
314    /// simulation — no facade calls (see [`Self::sync`]).
315    ///
316    /// A body whose horizontal drift carries it into a wall finds
317    /// every vertical probe blocked: the contact search degenerates to
318    /// its current position and it **shatters mid-air against the
319    /// wall** — the same accepted mechanism as the
320    /// spawn-inside-geometry policy on [`Self::spawn_island`].
321    #[allow(clippy::cast_possible_truncation)]
322    pub fn update(&mut self, scene: &Scene, dt: f64) {
323        let dtf = dt as f32;
324        let mut i = 0;
325        while i < self.bodies.len() {
326            let b = &mut self.bodies[i];
327            b.vel.z = (b.vel.z + self.gravity * dt).min(self.terminal_speed);
328            b.yaw += b.yaw_rate * dtf;
329            // DT.5 — horizontal fracture drift: integrated without
330            // collision (v1 — the collision march is vertical only).
331            b.pos.x += b.vel.x * dt;
332            b.pos.y += b.vel.y * dt;
333            // Substep stride: half the overlap window of the box vs a
334            // one-voxel plate (window = 2·half.z + vws), so consecutive
335            // endpoint tests cannot straddle the thinnest obstacle.
336            let stride = b.half.z + 0.5 * f64::from(b.scale);
337            let mut remaining = b.vel.z * dt;
338            // An upward fracture kick (a fragment above the parent's
339            // centre) integrates ballistically, collision-free —
340            // mirroring the x/y drift; gravity flips it within a
341            // beat. Without this the body would hang mid-air until
342            // the sign change (maintainer review).
343            if remaining < 0.0 {
344                b.pos.z += remaining;
345                i += 1;
346                continue;
347            }
348            let mut blocked_at = None;
349            while remaining > 0.0 {
350                let step = remaining.min(stride);
351                let target = b.pos.z + step;
352                if aabb_hits_ground(scene, b, target, self.solidity) {
353                    blocked_at = Some(target);
354                    break;
355                }
356                b.pos.z = target;
357                remaining -= step;
358            }
359            let Some(target) = blocked_at else {
360                i += 1;
361                continue;
362            };
363            // Contact: binary-search z between the last free position
364            // and the blocked target — lands flush on the voxel plane
365            // (within the anti-flush epsilon the AABB is shrunk by).
366            let (mut lo, mut hi) = (b.pos.z, target);
367            for _ in 0..16 {
368                let mid = 0.5 * (lo + hi);
369                if aabb_hits_ground(scene, b, mid, self.solidity) {
370                    hi = mid;
371                } else {
372                    lo = mid;
373                }
374            }
375            b.pos.z = lo;
376            let body = self.bodies.swap_remove(i);
377            self.dead.push((body.model, body.inst));
378            self.impacts.push(DebrisImpact {
379                island: body.island,
380                grid: body.grid,
381                pos: body.pos,
382                speed: body.vel.length(),
383                voxel_world_size: f64::from(body.scale),
384            });
385        }
386    }
387
388    /// Mirror the simulation into the facade: retire handles of landed
389    /// bodies, spawn a model + pre-posed instance for each newborn
390    /// (no axis-aligned first-frame flash), and batch-move everything
391    /// alive via one
392    /// [`set_sprite_instance_transforms`](SceneRenderer::set_sprite_instance_transforms).
393    /// The facade tombstones removed models — a host shattering many
394    /// islands should call
395    /// [`compact_sprite_models`](SceneRenderer::compact_sprite_models)
396    /// periodically.
397    pub fn sync(&mut self, renderer: &mut SceneRenderer) {
398        self.sync_with(renderer);
399    }
400
401    fn sync_with<F: DebrisFacade>(&mut self, facade: &mut F) {
402        for (model, inst) in self.dead.drain(..) {
403            if let Some(inst) = inst {
404                facade.despawn(inst);
405            }
406            if let Some(model) = model {
407                facade.remove_model(model);
408            }
409        }
410        for b in &mut self.bodies {
411            let xf = debris_xf(b.pos, b.yaw, b.scale);
412            if let Some(inst) = b.inst {
413                self.move_scratch.push((inst, xf));
414            } else {
415                // DT.5 — with a colour→material map installed, models
416                // register mapped: a crystal fragment keeps its
417                // translucent+emissive material and glows in flight.
418                let model = if self.material_map.is_empty() {
419                    facade.add_model(&b.island.to_kv6())
420                } else {
421                    facade.add_model_mapped(&b.island.to_kv6(), &self.material_map)
422                };
423                if let Some(inst) = facade.spawn(model, xf) {
424                    b.model = Some(model);
425                    b.inst = Some(inst);
426                } else {
427                    // Degenerate pose (should not happen for a yaw
428                    // basis) — drop the model, retry next sync.
429                    facade.remove_model(model);
430                }
431            }
432        }
433        if !self.move_scratch.is_empty() {
434            facade.set_transforms(&self.move_scratch);
435        }
436        self.move_scratch.clear();
437    }
438
439    /// One-shot per-frame step: [`Self::update`] then [`Self::sync`].
440    pub fn tick(&mut self, renderer: &mut SceneRenderer, scene: &Scene, dt: f64) {
441        self.update(scene, dt);
442        self.sync_with(renderer);
443    }
444
445    /// Drain the impacts accumulated by [`Self::update`] — DT.3's
446    /// shatter consumes these (each carries its island's voxels).
447    pub fn drain_impacts(&mut self) -> std::vec::Drain<'_, DebrisImpact> {
448        self.impacts.drain(..)
449    }
450
451    /// Number of bodies currently falling.
452    #[must_use]
453    pub fn debris_count(&self) -> usize {
454        self.bodies.len()
455    }
456}
457
458/// The slice of [`SceneRenderer`] that [`DebrisSystem::sync`] drives —
459/// the same internal seam [`ParticleFacade`](crate::particles) uses,
460/// grown by the model half (each island is its own KV6 model), so the
461/// binding logic is unit-testable with a mock (a real backend needs a
462/// window). The facade impl is pure forwarding.
463pub(crate) trait DebrisFacade {
464    fn add_model(&mut self, kv6: &roxlap_formats::kv6::Kv6) -> SpriteModelId;
465    fn add_model_mapped(
466        &mut self,
467        kv6: &roxlap_formats::kv6::Kv6,
468        map: &[(Rgb, u8)],
469    ) -> SpriteModelId;
470    fn remove_model(&mut self, id: SpriteModelId);
471    fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId>;
472    fn despawn(&mut self, id: SpriteInstanceId);
473    fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]);
474}
475
476impl DebrisFacade for SceneRenderer {
477    fn add_model(&mut self, kv6: &roxlap_formats::kv6::Kv6) -> SpriteModelId {
478        self.add_sprite_model(kv6)
479    }
480    fn add_model_mapped(
481        &mut self,
482        kv6: &roxlap_formats::kv6::Kv6,
483        map: &[(Rgb, u8)],
484    ) -> SpriteModelId {
485        self.add_sprite_model_with_materials(kv6, map)
486    }
487    fn remove_model(&mut self, id: SpriteModelId) {
488        self.remove_sprite_model(id);
489    }
490    fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId> {
491        self.add_sprite_instance_posed(model, xf)
492    }
493    fn despawn(&mut self, id: SpriteInstanceId) {
494        self.remove_sprite_instance(id);
495    }
496    fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
497        self.set_sprite_instance_transforms(batch);
498    }
499}
500
501/// Would the body's unrotated AABB, centred at `(b.pos.x, b.pos.y,
502/// z)`, overlap solid ground? The box is shrunk by a small epsilon on
503/// every side so flush contact (an island resting exactly on a voxel
504/// plane, or cut flush against a wall) does not read as a collision —
505/// only real penetration does.
506fn aabb_hits_ground(scene: &Scene, b: &DebrisBody, z: f64, solidity: Solidity) -> bool {
507    let eps = DVec3::splat(1.0e-3 * f64::from(b.scale));
508    let c = DVec3::new(b.pos.x, b.pos.y, z);
509    box_overlaps_solid(scene, c - b.half + eps, c + b.half - eps, solidity)
510}
511
512/// The pose a debris body renders at: pivot position + a basis rotated
513/// by `yaw` about the world vertical and uniformly scaled by the
514/// source grid's `voxel_world_size` (the same scaled-basis convention
515/// as the particle pass — right × up chirality preserved).
516#[allow(clippy::cast_possible_truncation)]
517fn debris_xf(pos: DVec3, yaw: f32, k: f32) -> DynSpriteTransform {
518    let (c, s) = (yaw.cos() * k, yaw.sin() * k);
519    DynSpriteTransform {
520        pos: [pos.x as f32, pos.y as f32, pos.z as f32],
521        right: [c, s, 0.0],
522        up: [-s, c, 0.0],
523        forward: [0.0, 0.0, k],
524    }
525}
526
527/// Deterministic fracture-split seed from the island's bbox-min voxel
528/// — stateless, so identical scenes fracture identically.
529#[allow(clippy::cast_sign_loss)]
530fn seed_from_pos(p: IVec3) -> u64 {
531    (p.x as u64).wrapping_mul(0x9E37_79B9)
532        ^ (p.y as u64).wrapping_mul(0x85EB_CA6B).rotate_left(21)
533        ^ (p.z as u64).wrapping_mul(0xC2B2_AE35).rotate_left(42)
534}
535
536/// Deterministic per-island spin rate in `[-max, max]`, hashed from
537/// the island's bbox-min voxel — stateless, so identical scenes
538/// crumble identically.
539#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
540fn spin_from_pos(p: IVec3, max: f32) -> f32 {
541    let h = (p.x.wrapping_mul(73_856_093)
542        ^ p.y.wrapping_mul(19_349_663)
543        ^ p.z.wrapping_mul(83_492_791)) as u32;
544    (f64::from(h) / f64::from(u32::MAX) * 2.0 - 1.0) as f32 * max
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::SlotHandle;
551    use roxlap_scene::islands::detect_islands;
552    use roxlap_scene::{GridTransform, VoxColor};
553
554    const STONE: VoxColor = VoxColor(0x80B0_8040);
555
556    /// A scene with one identity grid: a supported pillar at (2,2)
557    /// with a beam sticking out at z=100, already cut — plus the
558    /// detected 3-voxel island (x ∈ [4,6], y=2, z=100), ready to spawn.
559    fn scene_with_island() -> (Scene, GridId, Island) {
560        let mut scene = Scene::new();
561        let grid = scene.add_grid(GridTransform::identity());
562        let g = scene.grid_mut(grid).expect("grid");
563        g.set_rect(IVec3::new(2, 2, 100), IVec3::new(2, 2, 255), Some(STONE));
564        g.set_rect(IVec3::new(3, 2, 100), IVec3::new(6, 2, 100), Some(STONE));
565        g.set_voxel(IVec3::new(3, 2, 100), None);
566        let islands = detect_islands(g, IVec3::new(3, 2, 100), IVec3::new(3, 2, 100), 4096);
567        assert_eq!(islands.len(), 1);
568        (scene, grid, islands[0].clone())
569    }
570
571    /// In empty air the body falls monotonically (z-down ⇒ z grows)
572    /// and its speed saturates at the terminal clamp.
573    #[test]
574    fn falls_monotonically_and_clamps() {
575        let (mut scene, grid, island) = scene_with_island();
576        let mut sys = DebrisSystem::new();
577        assert!(sys.spawn_island(&mut scene, grid, island, BakeMode::Directional));
578        assert_eq!(sys.debris_count(), 1);
579
580        let mut last_z = sys.bodies[0].pos.z;
581        // 5 s: saturation takes terminal/gravity ≈ 2.7 s; nothing to
582        // land on (the island's columns are placeholder bedrock only,
583        // which the default Solidity does not count as solid).
584        for _ in 0..300 {
585            sys.update(&scene, 1.0 / 60.0);
586            if sys.bodies.is_empty() {
587                break;
588            }
589            let z = sys.bodies[0].pos.z;
590            assert!(z > last_z, "falling means z strictly grows (z-down)");
591            last_z = z;
592        }
593        assert!(
594            !sys.bodies.is_empty(),
595            "nothing to land on above bedrock yet"
596        );
597        assert!(
598            sys.bodies[0].vel.z <= sys.terminal_speed + 1e-9,
599            "speed clamps at terminal"
600        );
601        // Long enough to have saturated.
602        assert!((sys.bodies[0].vel.z - sys.terminal_speed).abs() < 1e-9);
603    }
604
605    /// With a floor below, the body lands flush on the voxel plane
606    /// (no penetration), exactly one impact fires, and the body +
607    /// its voxels come back in the event.
608    #[test]
609    fn lands_flush_and_fires_once() {
610        let (mut scene, grid, island) = scene_with_island();
611        // A solid floor plate at z=140 under the island's footprint.
612        scene.grid_mut(grid).expect("grid").set_rect(
613            IVec3::new(0, 0, 140),
614            IVec3::new(12, 6, 140),
615            Some(STONE),
616        );
617
618        let mut sys = DebrisSystem::new();
619        let half_z = 0.5; // 1-voxel-tall island
620        assert!(sys.spawn_island(&mut scene, grid, island, BakeMode::Directional));
621
622        for _ in 0..600 {
623            sys.update(&scene, 1.0 / 60.0);
624        }
625        assert_eq!(sys.debris_count(), 0, "the body landed and retired");
626        let impacts: Vec<DebrisImpact> = sys.drain_impacts().collect();
627        assert_eq!(impacts.len(), 1, "exactly one impact");
628        let hit = &impacts[0];
629        assert_eq!(hit.island.voxels.len(), 3, "voxels ride along for DT.3");
630        assert!(hit.speed > 0.0);
631        // Flush: the AABB bottom rests on the floor's top plane z=140
632        // (within the anti-flush epsilon + binary-search tolerance).
633        let bottom = hit.pos.z + half_z;
634        assert!(
635            (bottom - 140.0).abs() < 5.0e-3,
636            "AABB bottom {bottom} must sit on the z=140 plane"
637        );
638        // And further updates change nothing.
639        sys.update(&scene, 1.0);
640        assert_eq!(sys.drain_impacts().count(), 0);
641    }
642
643    /// Tunneling guard (maintainer review, DT.2): a fast body's
644    /// single-frame displacement can dwarf the overlap window of a
645    /// one-voxel island over a one-voxel shelf (window = 2). Tuned so
646    /// the FIRST update moves 8 units clean across the shelf — the
647    /// endpoint-only collision test deterministically skipped this;
648    /// the substepped march must not. (Stock tuning at 30 FPS sits
649    /// right at the window boundary — same failure, just alignment-
650    /// dependent; this pins the mechanism, not the tuning.)
651    #[test]
652    fn fast_body_cannot_tunnel_thin_shelf() {
653        let (mut scene, grid, island) = scene_with_island();
654        // Shelf 4 voxels under the island (pivot z=100.5 → crossed
655        // mid-first-step).
656        scene.grid_mut(grid).expect("grid").set_rect(
657            IVec3::new(0, 0, 104),
658            IVec3::new(12, 6, 104),
659            Some(STONE),
660        );
661
662        let mut sys = DebrisSystem::new();
663        sys.gravity = 7200.0; // one tick at dt=1/30 ⇒ v = terminal
664        sys.terminal_speed = 240.0; // step = 8 units/frame ≫ window 2
665        assert!(sys.spawn_island(&mut scene, grid, island, BakeMode::Directional));
666        sys.update(&scene, 1.0 / 30.0);
667        let impacts: Vec<DebrisImpact> = sys.drain_impacts().collect();
668        assert_eq!(impacts.len(), 1, "the shelf must not be tunnelled through");
669        let bottom = impacts[0].pos.z + 0.5;
670        assert!(
671            (bottom - 104.0).abs() < 5.0e-3,
672            "flush on the shelf: bottom {bottom}"
673        );
674    }
675
676    /// Two bodies with floors at different heights land independently
677    /// — pins the `swap_remove` mid-iteration path (one body retires
678    /// while the other keeps falling; batch sizes shrink 2 → 1 → 0).
679    #[test]
680    fn two_bodies_land_independently() {
681        let mut scene = Scene::new();
682        let grid = scene.add_grid(GridTransform::identity());
683        let g = scene.grid_mut(grid).expect("grid");
684        // Two cut beams far apart, floors at different depths.
685        for (y, floor_z) in [(2, 120), (30, 200)] {
686            g.set_rect(IVec3::new(2, y, 100), IVec3::new(2, y, 255), Some(STONE));
687            g.set_rect(IVec3::new(3, y, 100), IVec3::new(6, y, 100), Some(STONE));
688            g.set_voxel(IVec3::new(3, y, 100), None);
689            g.set_rect(
690                IVec3::new(0, y - 2, floor_z),
691                IVec3::new(12, y + 2, floor_z),
692                Some(STONE),
693            );
694        }
695        let mut islands = Vec::new();
696        for y in [2, 30] {
697            islands.extend(detect_islands(
698                g,
699                IVec3::new(3, y, 100),
700                IVec3::new(3, y, 100),
701                4096,
702            ));
703        }
704        assert_eq!(islands.len(), 2);
705
706        let mut sys = DebrisSystem::new();
707        let mut f = Mock::default();
708        for isl in islands {
709            assert!(sys.spawn_island(&mut scene, grid, isl, BakeMode::Directional));
710        }
711        let mut landings = Vec::new();
712        for frame in 0..900 {
713            sys.update(&scene, 1.0 / 60.0);
714            sys.sync_with(&mut f);
715            for _ in sys.drain_impacts() {
716                landings.push(frame);
717            }
718        }
719        assert_eq!(landings.len(), 2, "both bodies land");
720        assert!(
721            landings[0] < landings[1],
722            "the shallower floor catches its body first"
723        );
724        assert_eq!(sys.debris_count(), 0);
725        assert!(f.batch_sizes.contains(&2), "both flew together");
726        assert!(
727            f.batch_sizes.contains(&1),
728            "one kept flying after the first landed (swap_remove path)"
729        );
730        assert_eq!(f.despawns.len(), 2);
731        assert_eq!(f.models_removed.len(), 2);
732    }
733
734    /// Spawn inside geometry (the island bbox wraps foreign solid):
735    /// the explicit policy — an immediate zero-speed impact, no body.
736    #[test]
737    fn spawn_inside_geometry_shatters_in_place() {
738        let mut scene = Scene::new();
739        let grid = scene.add_grid(GridTransform::identity());
740        let g = scene.grid_mut(grid).expect("grid");
741        // A supported pillar the hand-built island's bbox wraps.
742        g.set_rect(IVec3::new(5, 5, 150), IVec3::new(5, 5, 255), Some(STONE));
743        // Two voxels either side of it (solid, so extract has work).
744        g.set_voxel(IVec3::new(4, 5, 150), Some(STONE));
745        g.set_voxel(IVec3::new(6, 5, 150), Some(STONE));
746        let island = Island {
747            voxels: vec![
748                (IVec3::new(4, 5, 150), STONE),
749                (IVec3::new(6, 5, 150), STONE),
750            ],
751            bbox: (IVec3::new(4, 5, 150), IVec3::new(6, 5, 150)),
752        };
753
754        let mut sys = DebrisSystem::new();
755        assert!(sys.spawn_island(&mut scene, grid, island, BakeMode::Directional));
756        assert_eq!(sys.debris_count(), 0, "never becomes a body");
757        let impacts: Vec<DebrisImpact> = sys.drain_impacts().collect();
758        assert_eq!(impacts.len(), 1);
759        assert_eq!(impacts[0].speed, 0.0, "explicit zero-speed impact");
760        let g = scene.grid(grid).expect("grid");
761        assert!(!g.voxel_solid(IVec3::new(4, 5, 150)), "island extracted");
762        assert!(g.voxel_solid(IVec3::new(5, 5, 150)), "the pillar survives");
763    }
764
765    /// Spawn extracts the island: its voxels are air in the grid the
766    /// moment the body exists (the sprite twin replaces them).
767    #[test]
768    fn spawn_extracts_from_grid() {
769        let (mut scene, grid, island) = scene_with_island();
770        let voxels: Vec<IVec3> = island.voxels.iter().map(|&(v, _)| v).collect();
771        let mut sys = DebrisSystem::new();
772        assert!(sys.spawn_island(&mut scene, grid, island, BakeMode::Directional));
773        let g = scene.grid(grid).expect("grid");
774        for v in voxels {
775            assert!(!g.voxel_solid(v), "{v} extracted");
776        }
777    }
778
779    /// Mock facade recording the binding traffic `sync_with` emits.
780    #[derive(Default)]
781    struct Mock {
782        next: u32,
783        models_added: usize,
784        /// Map lengths of `add_model_mapped` calls (DT.5).
785        mapped_adds: Vec<usize>,
786        models_removed: Vec<SpriteModelId>,
787        spawns: Vec<(SpriteModelId, DynSpriteTransform)>,
788        despawns: Vec<SpriteInstanceId>,
789        batch_sizes: Vec<usize>,
790    }
791
792    impl DebrisFacade for Mock {
793        fn add_model(&mut self, _kv6: &roxlap_formats::kv6::Kv6) -> SpriteModelId {
794            self.models_added += 1;
795            SpriteModelId::mint(0, self.next)
796        }
797        fn add_model_mapped(
798            &mut self,
799            _kv6: &roxlap_formats::kv6::Kv6,
800            map: &[(Rgb, u8)],
801        ) -> SpriteModelId {
802            self.models_added += 1;
803            self.mapped_adds.push(map.len());
804            SpriteModelId::mint(0, self.next)
805        }
806        fn remove_model(&mut self, id: SpriteModelId) {
807            self.models_removed.push(id);
808        }
809        fn spawn(
810            &mut self,
811            model: SpriteModelId,
812            xf: DynSpriteTransform,
813        ) -> Option<SpriteInstanceId> {
814            self.spawns.push((model, xf));
815            let id = SpriteInstanceId {
816                slot: self.next,
817                gen: 0,
818            };
819            self.next += 1;
820            Some(id)
821        }
822        fn despawn(&mut self, id: SpriteInstanceId) {
823            self.despawns.push(id);
824        }
825        fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
826            self.batch_sizes.push(batch.len());
827        }
828    }
829
830    /// The full binding lifecycle through the facade seam: one model +
831    /// one pre-posed spawn at the island's world pivot with the yaw
832    /// basis (scaled), batch moves while falling, despawn + model
833    /// removal after landing — nothing leaked.
834    #[test]
835    fn sync_lifecycle_through_mock() {
836        let (mut scene, grid, island) = scene_with_island();
837        let pivot = island.world_pivot(&scene.grid(grid).expect("grid").transform);
838        scene.grid_mut(grid).expect("grid").set_rect(
839            IVec3::new(0, 0, 140),
840            IVec3::new(12, 6, 140),
841            Some(STONE),
842        );
843
844        let mut sys = DebrisSystem::new();
845        let mut f = Mock::default();
846        assert!(sys.spawn_island(&mut scene, grid, island, BakeMode::Directional));
847        sys.sync_with(&mut f);
848        assert_eq!(f.models_added, 1);
849        assert_eq!(f.spawns.len(), 1, "pre-posed spawn, no move batch yet");
850        let xf = f.spawns[0].1;
851        assert!(
852            (f64::from(xf.pos[2]) - pivot.z).abs() < 1e-4,
853            "spawned at the world pivot"
854        );
855        assert!((xf.forward[2] - 1.0).abs() < 1e-6, "identity-scale basis");
856        assert_eq!(f.batch_sizes.len(), 0);
857
858        // Fall to the floor, syncing per frame like a host would.
859        for _ in 0..600 {
860            sys.update(&scene, 1.0 / 60.0);
861            sys.sync_with(&mut f);
862        }
863        assert_eq!(sys.debris_count(), 0);
864        assert!(!f.batch_sizes.is_empty(), "live frames batch-moved");
865        assert!(f.batch_sizes.iter().all(|&n| n == 1));
866        assert_eq!(f.despawns.len(), 1, "instance retired on impact");
867        assert_eq!(f.models_removed.len(), 1, "model retired on impact");
868    }
869
870    /// DT.5 — a mixed rock+crystal island splits per material (rock →
871    /// `Chunks`, crystal → `Shards`), every fragment colour-pure per
872    /// its group, voxels conserved, fragments drifting outward, and —
873    /// with the colour map installed — every fragment model registers
874    /// **mapped** (the crystal keeps its emissive material in flight).
875    #[test]
876    fn fracture_splits_by_material_with_drift_and_mapped_models() {
877        const CRYSTAL: VoxColor = VoxColor(0x8030_C0E0);
878        const CRYSTAL_ID: u8 = 7;
879        let mut scene = Scene::new();
880        let grid = scene.add_grid(GridTransform::identity());
881        let g = scene.grid_mut(grid).expect("grid");
882        g.set_rect(IVec3::new(2, 5, 100), IVec3::new(2, 5, 255), Some(STONE));
883        for x in 3..=10 {
884            let c = if (6..=7).contains(&x) { CRYSTAL } else { STONE };
885            g.set_voxel(IVec3::new(x, 5, 100), Some(c));
886        }
887        g.set_voxel(IVec3::new(3, 5, 100), None);
888        let islands = detect_islands(g, IVec3::new(3, 5, 100), IVec3::new(3, 5, 100), 4096);
889        assert_eq!(
890            islands[0].voxels.len(),
891            7,
892            "x ∈ [4, 10]: 5 rock + 2 crystal"
893        );
894
895        let mut sys = DebrisSystem::new();
896        sys.set_fracture_patterns(
897            &[(CRYSTAL.rgb_part(), CRYSTAL_ID)],
898            &[
899                (0, FracturePattern::Chunks { cell: 2 }),
900                (CRYSTAL_ID, FracturePattern::Shards { plates: 2 }),
901            ],
902        );
903        assert!(sys.spawn_island(&mut scene, grid, islands[0].clone(), BakeMode::Directional));
904
905        assert!(sys.bodies.len() >= 2, "the island split into fragments");
906        let total: usize = sys.bodies.iter().map(|b| b.island.voxels.len()).sum();
907        assert_eq!(total, 7, "no voxel lost or duplicated by the split");
908        for b in &sys.bodies {
909            let crystal = b
910                .island
911                .voxels
912                .iter()
913                .filter(|&&(_, c)| c == CRYSTAL)
914                .count();
915            assert!(
916                crystal == 0 || crystal == b.island.voxels.len(),
917                "fragments are colour-pure per material group"
918            );
919        }
920        assert!(
921            sys.bodies
922                .iter()
923                .any(|b| b.vel.x.abs() + b.vel.y.abs() > 1e-9),
924            "fragments drift outward from the island centre"
925        );
926
927        // Same-scene determinism: an identical setup fragments identically.
928        let mut scene2 = Scene::new();
929        let grid2 = scene2.add_grid(GridTransform::identity());
930        let g2 = scene2.grid_mut(grid2).expect("grid");
931        g2.set_rect(IVec3::new(2, 5, 100), IVec3::new(2, 5, 255), Some(STONE));
932        for x in 4..=10 {
933            let c = if (6..=7).contains(&x) { CRYSTAL } else { STONE };
934            g2.set_voxel(IVec3::new(x, 5, 100), Some(c));
935        }
936        let islands2 = detect_islands(g2, IVec3::new(3, 5, 100), IVec3::new(3, 5, 100), 4096);
937        let mut sys2 = DebrisSystem::new();
938        sys2.set_fracture_patterns(
939            &[(CRYSTAL.rgb_part(), CRYSTAL_ID)],
940            &[
941                (0, FracturePattern::Chunks { cell: 2 }),
942                (CRYSTAL_ID, FracturePattern::Shards { plates: 2 }),
943            ],
944        );
945        assert!(sys2.spawn_island(
946            &mut scene2,
947            grid2,
948            islands2[0].clone(),
949            BakeMode::Directional
950        ));
951        assert_eq!(sys.bodies.len(), sys2.bodies.len(), "deterministic split");
952
953        // Mapped model registration through the facade seam.
954        let mut f = Mock::default();
955        sys.sync_with(&mut f);
956        assert_eq!(
957            f.mapped_adds.len(),
958            sys.bodies.len(),
959            "every fragment model registers with the colour→material map"
960        );
961        assert!(f.mapped_adds.iter().all(|&n| n == 1), "the map rode along");
962    }
963
964    /// An upward fracture kick integrates (ballistic, collision-free)
965    /// instead of hanging the body until gravity flips the sign: z
966    /// decreases while the kick lasts, then falling resumes.
967    #[test]
968    fn upward_kick_rises_then_falls() {
969        let (mut scene, grid, island) = scene_with_island();
970        let mut sys = DebrisSystem::new();
971        assert!(sys.spawn_island(&mut scene, grid, island, BakeMode::Directional));
972        let start = sys.bodies[0].pos.z;
973        sys.bodies[0].vel.z = -10.0; // upward (z-down world)
974        sys.update(&scene, 1.0 / 60.0);
975        assert!(
976            sys.bodies[0].pos.z < start,
977            "an upward kick moves the body up, not into a stall"
978        );
979        for _ in 0..120 {
980            sys.update(&scene, 1.0 / 60.0);
981        }
982        assert!(sys.bodies[0].pos.z > start, "gravity wins and it falls");
983    }
984
985    /// DT.3 — the shatter path end-to-end: a two-colour island falls,
986    /// lands, and its burst sites (a) sit around the LANDED position,
987    /// not the detach position, and (b) feed `voxel_debris` a
988    /// colour-true burst whose tint histogram matches the island.
989    #[test]
990    fn shatter_burst_matches_island_colours() {
991        const OTHER: VoxColor = VoxColor(0x80_20_60_A0);
992        let mut scene = Scene::new();
993        let grid = scene.add_grid(GridTransform::identity());
994        let g = scene.grid_mut(grid).expect("grid");
995        g.set_rect(IVec3::new(2, 2, 100), IVec3::new(2, 2, 255), Some(STONE));
996        for (x, c) in [(4, STONE), (5, OTHER), (6, STONE)] {
997            g.set_voxel(IVec3::new(x, 2, 100), Some(c));
998        }
999        g.set_voxel(IVec3::new(3, 2, 100), Some(STONE));
1000        g.set_voxel(IVec3::new(3, 2, 100), None);
1001        g.set_rect(IVec3::new(0, 0, 140), IVec3::new(12, 6, 140), Some(STONE));
1002        let islands = detect_islands(g, IVec3::new(3, 2, 100), IVec3::new(3, 2, 100), 4096);
1003
1004        let mut sys = DebrisSystem::new();
1005        assert!(sys.spawn_island(&mut scene, grid, islands[0].clone(), BakeMode::Directional));
1006        for _ in 0..600 {
1007            sys.update(&scene, 1.0 / 60.0);
1008        }
1009        let impacts: Vec<DebrisImpact> = sys.drain_impacts().collect();
1010        assert_eq!(impacts.len(), 1);
1011        let hit = &impacts[0];
1012
1013        let sites = hit.burst_sites();
1014        assert_eq!(sites.len(), 3);
1015        // (a) Sites surround the landed pivot, at the original
1016        // relative offsets (x spread ±1, y and z on the pivot).
1017        for (p, _) in &sites {
1018            assert!((f64::from(p[2]) - hit.pos.z).abs() < 1e-4, "landed z");
1019            assert!((f64::from(p[0]) - hit.pos.x).abs() <= 1.0 + 1e-4);
1020        }
1021        // (b) Colour-true burst: tint histogram matches the island.
1022        let mut ps = crate::ParticleSystem::new(9);
1023        let def = crate::ParticleEmitterDef {
1024            lifetime: 100.0..100.0,
1025            ..crate::ParticleEmitterDef::new(SpriteModelId::mint(0, 0))
1026        };
1027        #[allow(clippy::cast_possible_truncation)]
1028        let from = [hit.pos.x as f32, hit.pos.y as f32, hit.pos.z as f32];
1029        let n = ps.voxel_debris(&sites, from, 4.0..6.0, &def);
1030        assert_eq!(n, 3);
1031        let mut tints: Vec<Rgb> = ps.particles().iter().map(|p| p.tint).collect();
1032        tints.sort_by_key(|t| t.0);
1033        let mut expect = vec![STONE.rgb_part(), STONE.rgb_part(), OTHER.rgb_part()];
1034        expect.sort_by_key(|t| t.0);
1035        assert_eq!(tints, expect, "burst wears the island's own colours");
1036    }
1037
1038    /// DT.3 leak gate: 100 full crumble cycles (rebuild → detect →
1039    /// spawn → fall → land → shatter) reclaim every facade handle and
1040    /// every particle — nothing accumulates.
1041    #[test]
1042    fn hundred_crumble_cycles_leak_nothing() {
1043        let mut scene = Scene::new();
1044        let grid = scene.add_grid(GridTransform::identity());
1045        scene.grid_mut(grid).expect("grid").set_rect(
1046            IVec3::new(2, 2, 100),
1047            IVec3::new(2, 2, 255),
1048            Some(STONE),
1049        );
1050        scene.grid_mut(grid).expect("grid").set_rect(
1051            IVec3::new(0, 0, 120),
1052            IVec3::new(12, 6, 120),
1053            Some(STONE),
1054        );
1055
1056        let mut sys = DebrisSystem::new();
1057        let mut ps = crate::ParticleSystem::new(5);
1058        let def = crate::ParticleEmitterDef {
1059            lifetime: 0.05..0.05,
1060            ..crate::ParticleEmitterDef::new(SpriteModelId::mint(0, 0))
1061        };
1062        let mut f = Mock::default();
1063
1064        for cycle in 0..100 {
1065            let g = scene.grid_mut(grid).expect("grid");
1066            g.set_rect(IVec3::new(3, 2, 100), IVec3::new(6, 2, 100), Some(STONE));
1067            g.set_voxel(IVec3::new(3, 2, 100), None);
1068            let islands = detect_islands(g, IVec3::new(3, 2, 100), IVec3::new(3, 2, 100), 4096);
1069            assert_eq!(islands.len(), 1, "cycle {cycle} re-detects the beam");
1070            assert!(sys.spawn_island(&mut scene, grid, islands[0].clone(), BakeMode::Directional));
1071            for _ in 0..120 {
1072                sys.update(&scene, 1.0 / 30.0);
1073                sys.sync_with(&mut f);
1074            }
1075            let impacts: Vec<DebrisImpact> = sys.drain_impacts().collect();
1076            assert_eq!(impacts.len(), 1, "cycle {cycle} lands");
1077            #[allow(clippy::cast_possible_truncation)]
1078            let from = [
1079                impacts[0].pos.x as f32,
1080                impacts[0].pos.y as f32,
1081                impacts[0].pos.z as f32,
1082            ];
1083            let n = ps.voxel_debris(&impacts[0].burst_sites(), from, 4.0..6.0, &def);
1084            assert_eq!(n, 3);
1085            ps.update(1.0); // 0.05 s lifetime: the burst dies out
1086        }
1087
1088        assert_eq!(sys.debris_count(), 0);
1089        assert_eq!(f.models_added, 100, "one model per island");
1090        assert_eq!(f.models_removed.len(), 100, "every model reclaimed");
1091        assert_eq!(f.spawns.len(), 100);
1092        assert_eq!(f.despawns.len(), 100, "every instance reclaimed");
1093        assert_eq!(ps.particle_count(), 0, "every burst died out");
1094        assert_eq!(ps.dropped_spawns(), 0, "no budget pressure");
1095        // Each burst allocates a transient emitter; dead particles
1096        // alone would keep this test green if retire-drain ever
1097        // stopped freeing the slots — pin the emitter side too.
1098        assert!(
1099            ps.emitter_slots_all_free(),
1100            "every transient emitter slot reclaimed"
1101        );
1102    }
1103
1104    /// An empty island or a stale grid id is refused.
1105    #[test]
1106    fn spawn_rejects_empty_or_missing() {
1107        let (mut scene, grid, island) = scene_with_island();
1108        let mut sys = DebrisSystem::new();
1109        assert!(!sys.spawn_island(
1110            &mut scene,
1111            grid,
1112            Island {
1113                voxels: Vec::new(),
1114                bbox: (IVec3::MAX, IVec3::MIN),
1115            },
1116            BakeMode::Directional,
1117        ));
1118        let stale = {
1119            let mut other = Scene::new();
1120            // A grid id from a different scene: unknown here.
1121            other.add_grid(GridTransform::identity());
1122            other.add_grid(GridTransform::identity())
1123        };
1124        assert!(!sys.spawn_island(&mut scene, stale, island, BakeMode::Directional));
1125        assert_eq!(sys.debris_count(), 0);
1126    }
1127}