1use 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#[derive(Debug)]
37pub struct DebrisImpact {
38 pub island: Island,
41 pub grid: GridId,
43 pub pos: DVec3,
46 pub speed: f64,
48 pub voxel_world_size: f64,
52}
53
54impl DebrisImpact {
55 #[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 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
82struct DebrisBody {
85 island: Island,
86 grid: GridId,
87 model: Option<SpriteModelId>,
88 inst: Option<SpriteInstanceId>,
89 pos: DVec3,
91 vel: DVec3,
96 yaw: f32,
98 yaw_rate: f32,
99 half: DVec3,
101 scale: f32,
104}
105
106pub struct DebrisSystem {
110 pub gravity: f64,
113 pub terminal_speed: f64,
115 pub max_yaw_rate: f32,
119 pub solidity: Solidity,
122 pub fracture_impulse: f64,
126 material_map: Vec<(Rgb, u8)>,
132 patterns: HashMap<u8, FracturePattern>,
134 bodies: Vec<DebrisBody>,
135 impacts: Vec<DebrisImpact>,
136 dead: Vec<(Option<SpriteModelId>, Option<SpriteInstanceId>)>,
138 move_scratch: Vec<(SpriteInstanceId, DynSpriteTransform)>,
140}
141
142impl Default for DebrisSystem {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl DebrisSystem {
149 #[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 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 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 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 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 #[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 b.pos.x += b.vel.x * dt;
332 b.pos.y += b.vel.y * dt;
333 let stride = b.half.z + 0.5 * f64::from(b.scale);
337 let mut remaining = b.vel.z * dt;
338 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 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 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 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 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 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 pub fn drain_impacts(&mut self) -> std::vec::Drain<'_, DebrisImpact> {
448 self.impacts.drain(..)
449 }
450
451 #[must_use]
453 pub fn debris_count(&self) -> usize {
454 self.bodies.len()
455 }
456}
457
458pub(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
501fn 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#[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#[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#[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 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 #[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 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 assert!((sys.bodies[0].vel.z - sys.terminal_speed).abs() < 1e-9);
603 }
604
605 #[test]
609 fn lands_flush_and_fires_once() {
610 let (mut scene, grid, island) = scene_with_island();
611 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; 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 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 sys.update(&scene, 1.0);
640 assert_eq!(sys.drain_impacts().count(), 0);
641 }
642
643 #[test]
652 fn fast_body_cannot_tunnel_thin_shelf() {
653 let (mut scene, grid, island) = scene_with_island();
654 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; sys.terminal_speed = 240.0; 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 #[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 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 #[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 g.set_rect(IVec3::new(5, 5, 150), IVec3::new(5, 5, 255), Some(STONE));
743 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 #[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 #[derive(Default)]
781 struct Mock {
782 next: u32,
783 models_added: usize,
784 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 #[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 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 #[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 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 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 #[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; 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 #[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 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 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 #[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); }
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 assert!(
1099 ps.emitter_slots_all_free(),
1100 "every transient emitter slot reclaimed"
1101 );
1102 }
1103
1104 #[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 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}