# Phase 5-1: Collision Detection Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Sphere + Box 콜라이더 충돌 감지 시스템 구현 (BVH broad phase + 전용 narrow phase) **Architecture:** `voltex_math`에 AABB 추가, `voltex_physics` crate 신규 생성. Broad phase는 BVH 트리(중앙값 분할), narrow phase는 Sphere/Box 조합별 전용 함수. ECS 통합으로 `detect_collisions(world)` API 제공. **Tech Stack:** Rust, voltex_math (Vec3), voltex_ecs (World, Entity, Transform, SparseSet) **Spec:** `docs/superpowers/specs/2026-03-25-phase5-1-collision-detection.md` --- ## File Structure ### voltex_math (수정) - `crates/voltex_math/src/aabb.rs` — AABB 타입 (Create) - `crates/voltex_math/src/lib.rs` — AABB 모듈 등록 (Modify) ### voltex_physics (신규) - `crates/voltex_physics/Cargo.toml` — crate 설정 (Create) - `crates/voltex_physics/src/lib.rs` — public exports (Create) - `crates/voltex_physics/src/collider.rs` — Collider enum + aabb() (Create) - `crates/voltex_physics/src/contact.rs` — ContactPoint (Create) - `crates/voltex_physics/src/narrow.rs` — 충돌 테스트 함수 3개 (Create) - `crates/voltex_physics/src/bvh.rs` — BvhTree (Create) - `crates/voltex_physics/src/collision.rs` — detect_collisions ECS 통합 (Create) ### Workspace (수정) - `Cargo.toml` — workspace members에 voltex_physics 추가 (Modify) --- ## Task 1: AABB 타입 (voltex_math) **Files:** - Create: `crates/voltex_math/src/aabb.rs` - Modify: `crates/voltex_math/src/lib.rs` - [ ] **Step 1: aabb.rs 생성 — 테스트부터 작성** ```rust // crates/voltex_math/src/aabb.rs use crate::Vec3; #[derive(Debug, Clone, Copy, PartialEq)] pub struct AABB { pub min: Vec3, pub max: Vec3, } impl AABB { pub fn new(min: Vec3, max: Vec3) -> Self { Self { min, max } } pub fn from_center_half_extents(center: Vec3, half: Vec3) -> Self { Self { min: center - half, max: center + half, } } pub fn center(&self) -> Vec3 { (self.min + self.max) * 0.5 } pub fn half_extents(&self) -> Vec3 { (self.max - self.min) * 0.5 } pub fn contains_point(&self, p: Vec3) -> bool { p.x >= self.min.x && p.x <= self.max.x && p.y >= self.min.y && p.y <= self.max.y && p.z >= self.min.z && p.z <= self.max.z } pub fn intersects(&self, other: &AABB) -> bool { self.min.x <= other.max.x && self.max.x >= other.min.x && self.min.y <= other.max.y && self.max.y >= other.min.y && self.min.z <= other.max.z && self.max.z >= other.min.z } pub fn merged(&self, other: &AABB) -> AABB { AABB { min: Vec3::new( self.min.x.min(other.min.x), self.min.y.min(other.min.y), self.min.z.min(other.min.z), ), max: Vec3::new( self.max.x.max(other.max.x), self.max.y.max(other.max.y), self.max.z.max(other.max.z), ), } } pub fn surface_area(&self) -> f32 { let d = self.max - self.min; 2.0 * (d.x * d.y + d.y * d.z + d.z * d.x) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_new_and_accessors() { let a = AABB::new(Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0)); let c = a.center(); assert!((c.x).abs() < 1e-6); assert!((c.y).abs() < 1e-6); assert!((c.z).abs() < 1e-6); let h = a.half_extents(); assert!((h.x - 1.0).abs() < 1e-6); assert!((h.y - 2.0).abs() < 1e-6); assert!((h.z - 3.0).abs() < 1e-6); } #[test] fn test_from_center_half_extents() { let a = AABB::from_center_half_extents(Vec3::new(5.0, 5.0, 5.0), Vec3::new(1.0, 1.0, 1.0)); assert_eq!(a.min, Vec3::new(4.0, 4.0, 4.0)); assert_eq!(a.max, Vec3::new(6.0, 6.0, 6.0)); } #[test] fn test_contains_point() { let a = AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0)); assert!(a.contains_point(Vec3::new(1.0, 1.0, 1.0))); // inside assert!(a.contains_point(Vec3::ZERO)); // boundary assert!(!a.contains_point(Vec3::new(3.0, 1.0, 1.0))); // outside } #[test] fn test_intersects() { let a = AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0)); let b = AABB::new(Vec3::new(1.0, 1.0, 1.0), Vec3::new(3.0, 3.0, 3.0)); assert!(a.intersects(&b)); // overlap let c = AABB::new(Vec3::new(5.0, 5.0, 5.0), Vec3::new(6.0, 6.0, 6.0)); assert!(!a.intersects(&c)); // separated let d = AABB::new(Vec3::new(2.0, 0.0, 0.0), Vec3::new(3.0, 2.0, 2.0)); assert!(a.intersects(&d)); // touching edge } #[test] fn test_merged() { let a = AABB::new(Vec3::ZERO, Vec3::ONE); let b = AABB::new(Vec3::new(2.0, 2.0, 2.0), Vec3::new(3.0, 3.0, 3.0)); let m = a.merged(&b); assert_eq!(m.min, Vec3::ZERO); assert_eq!(m.max, Vec3::new(3.0, 3.0, 3.0)); } #[test] fn test_surface_area() { // 2x2x2 box: area = 2*(4+4+4) = 24 let a = AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0)); assert!((a.surface_area() - 24.0).abs() < 1e-6); } } ``` - [ ] **Step 2: lib.rs에 aabb 모듈 등록** `crates/voltex_math/src/lib.rs`에 추가: ```rust pub mod aabb; pub use aabb::AABB; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_math` Expected: 기존 29개 + AABB 6개 = 35개 PASS - [ ] **Step 4: 커밋** ```bash git add crates/voltex_math/src/aabb.rs crates/voltex_math/src/lib.rs git commit -m "feat(math): add AABB type with intersection, merge, and containment" ``` --- ## Task 2: voltex_physics crate 설정 + Collider 타입 **Files:** - Create: `crates/voltex_physics/Cargo.toml` - Create: `crates/voltex_physics/src/lib.rs` - Create: `crates/voltex_physics/src/contact.rs` - Create: `crates/voltex_physics/src/collider.rs` - Modify: `Cargo.toml` (workspace) - [ ] **Step 1: Cargo.toml 생성** ```toml # crates/voltex_physics/Cargo.toml [package] name = "voltex_physics" version = "0.1.0" edition = "2021" [dependencies] voltex_math.workspace = true voltex_ecs.workspace = true ``` - [ ] **Step 2: workspace에 추가** `Cargo.toml` workspace members에 추가: ```toml "crates/voltex_physics", ``` workspace.dependencies에 추가: ```toml voltex_physics = { path = "crates/voltex_physics" } ``` - [ ] **Step 3: contact.rs 작성** ```rust // crates/voltex_physics/src/contact.rs use voltex_ecs::Entity; use voltex_math::Vec3; #[derive(Debug, Clone, Copy)] pub struct ContactPoint { pub entity_a: Entity, pub entity_b: Entity, pub normal: Vec3, pub depth: f32, pub point_on_a: Vec3, pub point_on_b: Vec3, } ``` - [ ] **Step 4: collider.rs 작성 (테스트 포함)** ```rust // crates/voltex_physics/src/collider.rs use voltex_math::{Vec3, AABB}; #[derive(Debug, Clone, Copy)] pub enum Collider { Sphere { radius: f32 }, Box { half_extents: Vec3 }, } impl Collider { pub fn aabb(&self, position: Vec3) -> AABB { match self { Collider::Sphere { radius } => { let r = Vec3::new(*radius, *radius, *radius); AABB::new(position - r, position + r) } Collider::Box { half_extents } => { AABB::new(position - *half_extents, position + *half_extents) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_sphere_aabb() { let c = Collider::Sphere { radius: 2.0 }; let aabb = c.aabb(Vec3::new(1.0, 0.0, 0.0)); assert_eq!(aabb.min, Vec3::new(-1.0, -2.0, -2.0)); assert_eq!(aabb.max, Vec3::new(3.0, 2.0, 2.0)); } #[test] fn test_box_aabb() { let c = Collider::Box { half_extents: Vec3::new(1.0, 2.0, 3.0) }; let aabb = c.aabb(Vec3::ZERO); assert_eq!(aabb.min, Vec3::new(-1.0, -2.0, -3.0)); assert_eq!(aabb.max, Vec3::new(1.0, 2.0, 3.0)); } } ``` - [ ] **Step 5: lib.rs 작성** ```rust // crates/voltex_physics/src/lib.rs pub mod collider; pub mod contact; pub use collider::Collider; pub use contact::ContactPoint; ``` - [ ] **Step 6: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 2개 PASS (sphere_aabb, box_aabb) - [ ] **Step 7: 커밋** ```bash git add crates/voltex_physics/ Cargo.toml git commit -m "feat(physics): add voltex_physics crate with Collider and ContactPoint" ``` --- ## Task 3: Narrow Phase — sphere_vs_sphere **Files:** - Create: `crates/voltex_physics/src/narrow.rs` - Modify: `crates/voltex_physics/src/lib.rs` - [ ] **Step 1: narrow.rs 작성 — 테스트부터** ```rust // crates/voltex_physics/src/narrow.rs use voltex_math::Vec3; /// Returns (normal A→B, depth, point_on_a, point_on_b) or None if no collision. pub fn sphere_vs_sphere( pos_a: Vec3, radius_a: f32, pos_b: Vec3, radius_b: f32, ) -> Option<(Vec3, f32, Vec3, Vec3)> { let diff = pos_b - pos_a; let dist_sq = diff.length_squared(); let sum_r = radius_a + radius_b; if dist_sq > sum_r * sum_r { return None; } let dist = dist_sq.sqrt(); let normal = if dist > 1e-8 { diff * (1.0 / dist) } else { Vec3::Y // arbitrary fallback for coincident centers }; let depth = sum_r - dist; let point_on_a = pos_a + normal * radius_a; let point_on_b = pos_b - normal * radius_b; Some((normal, depth, point_on_a, point_on_b)) } #[cfg(test)] mod tests { use super::*; fn approx(a: f32, b: f32) -> bool { (a - b).abs() < 1e-5 } fn approx_vec(a: Vec3, b: Vec3) -> bool { approx(a.x, b.x) && approx(a.y, b.y) && approx(a.z, b.z) } #[test] fn test_sphere_sphere_separated() { let r = sphere_vs_sphere( Vec3::ZERO, 1.0, Vec3::new(5.0, 0.0, 0.0), 1.0, ); assert!(r.is_none()); } #[test] fn test_sphere_sphere_overlapping() { let r = sphere_vs_sphere( Vec3::ZERO, 1.0, Vec3::new(1.5, 0.0, 0.0), 1.0, ); let (normal, depth, pa, pb) = r.unwrap(); assert!(approx_vec(normal, Vec3::X)); assert!(approx(depth, 0.5)); assert!(approx_vec(pa, Vec3::new(1.0, 0.0, 0.0))); assert!(approx_vec(pb, Vec3::new(0.5, 0.0, 0.0))); } #[test] fn test_sphere_sphere_touching() { let r = sphere_vs_sphere( Vec3::ZERO, 1.0, Vec3::new(2.0, 0.0, 0.0), 1.0, ); let (normal, depth, _pa, _pb) = r.unwrap(); assert!(approx_vec(normal, Vec3::X)); assert!(approx(depth, 0.0)); } #[test] fn test_sphere_sphere_coincident() { let r = sphere_vs_sphere( Vec3::ZERO, 1.0, Vec3::ZERO, 1.0, ); let (_normal, depth, _pa, _pb) = r.unwrap(); assert!(approx(depth, 2.0)); } } ``` - [ ] **Step 2: lib.rs에 narrow 모듈 등록** ```rust pub mod narrow; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 6개 PASS (collider 2 + narrow 4) - [ ] **Step 4: 커밋** ```bash git add crates/voltex_physics/src/narrow.rs crates/voltex_physics/src/lib.rs git commit -m "feat(physics): add sphere_vs_sphere narrow phase" ``` --- ## Task 4: Narrow Phase — sphere_vs_box **Files:** - Modify: `crates/voltex_physics/src/narrow.rs` - [ ] **Step 1: sphere_vs_box 함수 + 테스트 추가** `narrow.rs`에 추가: ```rust pub fn sphere_vs_box( sphere_pos: Vec3, radius: f32, box_pos: Vec3, half_extents: Vec3, ) -> Option<(Vec3, f32, Vec3, Vec3)> { // Closest point on box to sphere center let bmin = box_pos - half_extents; let bmax = box_pos + half_extents; let closest = Vec3::new( sphere_pos.x.clamp(bmin.x, bmax.x), sphere_pos.y.clamp(bmin.y, bmax.y), sphere_pos.z.clamp(bmin.z, bmax.z), ); let diff = sphere_pos - closest; let dist_sq = diff.length_squared(); // Sphere center outside box if dist_sq > 1e-8 { let dist = dist_sq.sqrt(); if dist > radius { return None; } let normal = diff * (-1.0 / dist); // points from sphere toward box // Convention: normal A→B means sphere→box let depth = radius - dist; let point_on_a = sphere_pos - normal * radius; // sphere surface (note: normal is sphere→box, so subtract) // Actually: normal should be A(sphere)→B(box) // normal = (box - sphere) direction = -diff.normalize() let point_on_b = closest; return Some((normal, depth, point_on_a, point_on_b)); } // Sphere center inside box — find nearest face let dx_min = sphere_pos.x - bmin.x; let dx_max = bmax.x - sphere_pos.x; let dy_min = sphere_pos.y - bmin.y; let dy_max = bmax.y - sphere_pos.y; let dz_min = sphere_pos.z - bmin.z; let dz_max = bmax.z - sphere_pos.z; let mut min_dist = dx_min; let mut normal = Vec3::new(-1.0, 0.0, 0.0); let mut closest_face = Vec3::new(bmin.x, sphere_pos.y, sphere_pos.z); if dx_max < min_dist { min_dist = dx_max; normal = Vec3::new(1.0, 0.0, 0.0); closest_face = Vec3::new(bmax.x, sphere_pos.y, sphere_pos.z); } if dy_min < min_dist { min_dist = dy_min; normal = Vec3::new(0.0, -1.0, 0.0); closest_face = Vec3::new(sphere_pos.x, bmin.y, sphere_pos.z); } if dy_max < min_dist { min_dist = dy_max; normal = Vec3::new(0.0, 1.0, 0.0); closest_face = Vec3::new(sphere_pos.x, bmax.y, sphere_pos.z); } if dz_min < min_dist { min_dist = dz_min; normal = Vec3::new(0.0, 0.0, -1.0); closest_face = Vec3::new(sphere_pos.x, sphere_pos.y, bmin.z); } if dz_max < min_dist { min_dist = dz_max; normal = Vec3::new(0.0, 0.0, 1.0); closest_face = Vec3::new(sphere_pos.x, sphere_pos.y, bmax.z); } let depth = min_dist + radius; let point_on_a = sphere_pos + normal * radius; let point_on_b = closest_face; Some((normal, depth, point_on_a, point_on_b)) } ``` 테스트 추가 (tests 모듈 안): ```rust #[test] fn test_sphere_box_separated() { let r = sphere_vs_box( Vec3::new(5.0, 0.0, 0.0), 1.0, Vec3::ZERO, Vec3::ONE, ); assert!(r.is_none()); } #[test] fn test_sphere_box_face_overlap() { // sphere at x=1.5, radius=1, box at origin half=1 → face contact on +x let r = sphere_vs_box( Vec3::new(1.5, 0.0, 0.0), 1.0, Vec3::ZERO, Vec3::ONE, ); let (normal, depth, _pa, pb) = r.unwrap(); assert!(approx(normal.x, -1.0)); // sphere→box: -X direction assert!(approx(depth, 0.5)); assert!(approx(pb.x, 1.0)); } #[test] fn test_sphere_box_center_inside() { // sphere center at box center let r = sphere_vs_box( Vec3::ZERO, 0.5, Vec3::ZERO, Vec3::ONE, ); assert!(r.is_some()); let (_normal, depth, _pa, _pb) = r.unwrap(); assert!(depth > 0.0); } ``` - [ ] **Step 2: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 9개 PASS - [ ] **Step 3: 커밋** ```bash git add crates/voltex_physics/src/narrow.rs git commit -m "feat(physics): add sphere_vs_box narrow phase" ``` --- ## Task 5: Narrow Phase — box_vs_box (SAT) **Files:** - Modify: `crates/voltex_physics/src/narrow.rs` - [ ] **Step 1: box_vs_box 함수 + 테스트 추가** `narrow.rs`에 추가: ```rust pub fn box_vs_box( pos_a: Vec3, half_a: Vec3, pos_b: Vec3, half_b: Vec3, ) -> Option<(Vec3, f32, Vec3, Vec3)> { // AABB vs AABB using SAT on 3 axes let diff = pos_b - pos_a; let overlap_x = (half_a.x + half_b.x) - diff.x.abs(); if overlap_x < 0.0 { return None; } let overlap_y = (half_a.y + half_b.y) - diff.y.abs(); if overlap_y < 0.0 { return None; } let overlap_z = (half_a.z + half_b.z) - diff.z.abs(); if overlap_z < 0.0 { return None; } // Find minimum overlap axis let (normal, depth) = if overlap_x <= overlap_y && overlap_x <= overlap_z { let sign = if diff.x >= 0.0 { 1.0 } else { -1.0 }; (Vec3::new(sign, 0.0, 0.0), overlap_x) } else if overlap_y <= overlap_z { let sign = if diff.y >= 0.0 { 1.0 } else { -1.0 }; (Vec3::new(0.0, sign, 0.0), overlap_y) } else { let sign = if diff.z >= 0.0 { 1.0 } else { -1.0 }; (Vec3::new(0.0, 0.0, sign), overlap_z) }; let point_on_a = pos_a + Vec3::new( normal.x * half_a.x, normal.y * half_a.y, normal.z * half_a.z, ); let point_on_b = pos_b - Vec3::new( normal.x * half_b.x, normal.y * half_b.y, normal.z * half_b.z, ); Some((normal, depth, point_on_a, point_on_b)) } ``` 테스트 추가: ```rust #[test] fn test_box_box_separated() { let r = box_vs_box( Vec3::ZERO, Vec3::ONE, Vec3::new(5.0, 0.0, 0.0), Vec3::ONE, ); assert!(r.is_none()); } #[test] fn test_box_box_overlapping() { let r = box_vs_box( Vec3::ZERO, Vec3::ONE, Vec3::new(1.5, 0.0, 0.0), Vec3::ONE, ); let (normal, depth, _pa, _pb) = r.unwrap(); assert!(approx_vec(normal, Vec3::X)); assert!(approx(depth, 0.5)); } #[test] fn test_box_box_touching() { let r = box_vs_box( Vec3::ZERO, Vec3::ONE, Vec3::new(2.0, 0.0, 0.0), Vec3::ONE, ); let (_normal, depth, _pa, _pb) = r.unwrap(); assert!(approx(depth, 0.0)); } #[test] fn test_box_box_y_axis() { let r = box_vs_box( Vec3::ZERO, Vec3::ONE, Vec3::new(0.0, 1.5, 0.0), Vec3::ONE, ); let (normal, depth, _pa, _pb) = r.unwrap(); assert!(approx_vec(normal, Vec3::Y)); assert!(approx(depth, 0.5)); } ``` - [ ] **Step 2: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 13개 PASS - [ ] **Step 3: 커밋** ```bash git add crates/voltex_physics/src/narrow.rs git commit -m "feat(physics): add box_vs_box narrow phase (SAT)" ``` --- ## Task 6: BVH Tree (Broad Phase) **Files:** - Create: `crates/voltex_physics/src/bvh.rs` - Modify: `crates/voltex_physics/src/lib.rs` - [ ] **Step 1: bvh.rs 작성** ```rust // crates/voltex_physics/src/bvh.rs use voltex_ecs::Entity; use voltex_math::AABB; #[derive(Debug)] enum BvhNode { Leaf { entity: Entity, aabb: AABB }, Internal { aabb: AABB, left: usize, right: usize }, } #[derive(Debug)] pub struct BvhTree { nodes: Vec, } impl BvhTree { /// Build BVH from entity-AABB pairs using median split on longest axis. pub fn build(entries: &[(Entity, AABB)]) -> Self { let mut tree = BvhTree { nodes: Vec::new() }; if !entries.is_empty() { let mut sorted: Vec<(Entity, AABB)> = entries.to_vec(); tree.build_recursive(&mut sorted); } tree } fn build_recursive(&mut self, entries: &mut [(Entity, AABB)]) -> usize { if entries.len() == 1 { let idx = self.nodes.len(); self.nodes.push(BvhNode::Leaf { entity: entries[0].0, aabb: entries[0].1, }); return idx; } // Compute bounding AABB let mut combined = entries[0].1; for e in entries.iter().skip(1) { combined = combined.merged(&e.1); } // Find longest axis let extent = combined.max - combined.min; let axis = if extent.x >= extent.y && extent.x >= extent.z { 0 // x } else if extent.y >= extent.z { 1 // y } else { 2 // z }; // Sort by axis center entries.sort_by(|a, b| { let ca = a.1.center(); let cb = b.1.center(); let va = match axis { 0 => ca.x, 1 => ca.y, _ => ca.z }; let vb = match axis { 0 => cb.x, 1 => cb.y, _ => cb.z }; va.partial_cmp(&vb).unwrap() }); let mid = entries.len() / 2; let (left_entries, right_entries) = entries.split_at_mut(mid); let left = self.build_recursive(left_entries); let right = self.build_recursive(right_entries); let idx = self.nodes.len(); self.nodes.push(BvhNode::Internal { aabb: combined, left, right, }); idx } /// Return all pairs of entities whose AABBs overlap. pub fn query_pairs(&self) -> Vec<(Entity, Entity)> { let mut pairs = Vec::new(); if self.nodes.is_empty() { return pairs; } let root = self.nodes.len() - 1; self.collect_leaves_and_test(root, &mut pairs); pairs } fn collect_leaves_and_test(&self, node_idx: usize, pairs: &mut Vec<(Entity, Entity)>) { // Collect all leaves, then test all pairs. // For small counts this is fine; optimize later if needed. let mut leaves = Vec::new(); self.collect_leaves(node_idx, &mut leaves); for i in 0..leaves.len() { for j in (i + 1)..leaves.len() { let (ea, aabb_a) = leaves[i]; let (eb, aabb_b) = leaves[j]; if aabb_a.intersects(&aabb_b) { pairs.push((ea, eb)); } } } } fn collect_leaves(&self, node_idx: usize, out: &mut Vec<(Entity, AABB)>) { match &self.nodes[node_idx] { BvhNode::Leaf { entity, aabb } => { out.push((*entity, *aabb)); } BvhNode::Internal { left, right, .. } => { self.collect_leaves(*left, out); self.collect_leaves(*right, out); } } } } #[cfg(test)] mod tests { use super::*; use voltex_math::Vec3; fn make_entity(id: u32) -> Entity { Entity { id, generation: 0 } } #[test] fn test_build_empty() { let tree = BvhTree::build(&[]); assert!(tree.query_pairs().is_empty()); } #[test] fn test_build_single() { let entries = vec![ (make_entity(0), AABB::new(Vec3::ZERO, Vec3::ONE)), ]; let tree = BvhTree::build(&entries); assert!(tree.query_pairs().is_empty()); } #[test] fn test_overlapping_pair() { let entries = vec![ (make_entity(0), AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0))), (make_entity(1), AABB::new(Vec3::ONE, Vec3::new(3.0, 3.0, 3.0))), ]; let tree = BvhTree::build(&entries); let pairs = tree.query_pairs(); assert_eq!(pairs.len(), 1); } #[test] fn test_separated_pair() { let entries = vec![ (make_entity(0), AABB::new(Vec3::ZERO, Vec3::ONE)), (make_entity(1), AABB::new(Vec3::new(5.0, 5.0, 5.0), Vec3::new(6.0, 6.0, 6.0))), ]; let tree = BvhTree::build(&entries); assert!(tree.query_pairs().is_empty()); } #[test] fn test_multiple_entities() { let entries = vec![ (make_entity(0), AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0))), (make_entity(1), AABB::new(Vec3::ONE, Vec3::new(3.0, 3.0, 3.0))), (make_entity(2), AABB::new(Vec3::new(10.0, 10.0, 10.0), Vec3::new(11.0, 11.0, 11.0))), ]; let tree = BvhTree::build(&entries); let pairs = tree.query_pairs(); // Only 0-1 overlap, 2 is far away assert_eq!(pairs.len(), 1); let (a, b) = pairs[0]; assert!((a.id == 0 && b.id == 1) || (a.id == 1 && b.id == 0)); } } ``` - [ ] **Step 2: lib.rs에 bvh 모듈 등록** ```rust pub mod bvh; pub use bvh::BvhTree; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 18개 PASS (collider 2 + narrow 11 + bvh 5) - [ ] **Step 4: 커밋** ```bash git add crates/voltex_physics/src/bvh.rs crates/voltex_physics/src/lib.rs git commit -m "feat(physics): add BVH tree for broad phase collision detection" ``` --- ## Task 7: ECS Integration — detect_collisions **Files:** - Create: `crates/voltex_physics/src/collision.rs` - Modify: `crates/voltex_physics/src/lib.rs` - [ ] **Step 1: collision.rs 작성** ```rust // crates/voltex_physics/src/collision.rs use voltex_ecs::{World, Entity}; use voltex_ecs::Transform; use voltex_math::Vec3; use crate::collider::Collider; use crate::contact::ContactPoint; use crate::bvh::BvhTree; use crate::narrow; /// Detect all collisions among entities that have Transform + Collider. pub fn detect_collisions(world: &World) -> Vec { // 1. Gather entities with Transform + Collider let pairs_data: Vec<(Entity, Vec3, Collider)> = world .query2::() .iter() .map(|(e, t, c)| (*e, t.position, *c)) .collect(); if pairs_data.len() < 2 { return Vec::new(); } // 2. Build AABBs let entries: Vec<(Entity, voltex_math::AABB)> = pairs_data .iter() .map(|(e, pos, col)| (*e, col.aabb(*pos))) .collect(); // 3. Broad phase let bvh = BvhTree::build(&entries); let broad_pairs = bvh.query_pairs(); // 4. Narrow phase let mut contacts = Vec::new(); // Build a lookup: entity_id -> (position, collider) // Using a simple linear scan since entity count is expected to be moderate let lookup = |entity: Entity| -> Option<(Vec3, Collider)> { pairs_data.iter().find(|(e, _, _)| *e == entity).map(|(_, p, c)| (*p, *c)) }; for (ea, eb) in broad_pairs { let (pos_a, col_a) = match lookup(ea) { Some(v) => v, None => continue }; let (pos_b, col_b) = match lookup(eb) { Some(v) => v, None => continue }; let result = match (&col_a, &col_b) { (Collider::Sphere { radius: ra }, Collider::Sphere { radius: rb }) => { narrow::sphere_vs_sphere(pos_a, *ra, pos_b, *rb) } (Collider::Sphere { radius }, Collider::Box { half_extents }) => { narrow::sphere_vs_box(pos_a, *radius, pos_b, *half_extents) } (Collider::Box { half_extents }, Collider::Sphere { radius }) => { // Swap order: sphere is A narrow::sphere_vs_box(pos_b, *radius, pos_a, *half_extents) .map(|(n, d, pa, pb)| (-n, d, pb, pa)) // flip normal and points } (Collider::Box { half_extents: ha }, Collider::Box { half_extents: hb }) => { narrow::box_vs_box(pos_a, *ha, pos_b, *hb) } }; if let Some((normal, depth, point_on_a, point_on_b)) = result { contacts.push(ContactPoint { entity_a: ea, entity_b: eb, normal, depth, point_on_a, point_on_b, }); } } contacts } #[cfg(test)] mod tests { use super::*; use voltex_ecs::World; use voltex_ecs::Transform; use voltex_math::Vec3; use crate::Collider; #[test] fn test_no_colliders() { let world = World::new(); let contacts = detect_collisions(&world); assert!(contacts.is_empty()); } #[test] fn test_single_entity() { let mut world = World::new(); let e = world.spawn(); world.add(e, Transform::from_position(Vec3::ZERO)); world.add(e, Collider::Sphere { radius: 1.0 }); let contacts = detect_collisions(&world); assert!(contacts.is_empty()); } #[test] fn test_two_spheres_colliding() { let mut world = World::new(); let a = world.spawn(); world.add(a, Transform::from_position(Vec3::ZERO)); world.add(a, Collider::Sphere { radius: 1.0 }); let b = world.spawn(); world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0))); world.add(b, Collider::Sphere { radius: 1.0 }); let contacts = detect_collisions(&world); assert_eq!(contacts.len(), 1); assert!((contacts[0].depth - 0.5).abs() < 1e-5); } #[test] fn test_two_spheres_separated() { let mut world = World::new(); let a = world.spawn(); world.add(a, Transform::from_position(Vec3::ZERO)); world.add(a, Collider::Sphere { radius: 1.0 }); let b = world.spawn(); world.add(b, Transform::from_position(Vec3::new(10.0, 0.0, 0.0))); world.add(b, Collider::Sphere { radius: 1.0 }); let contacts = detect_collisions(&world); assert!(contacts.is_empty()); } #[test] fn test_sphere_vs_box_collision() { let mut world = World::new(); let a = world.spawn(); world.add(a, Transform::from_position(Vec3::ZERO)); world.add(a, Collider::Sphere { radius: 1.0 }); let b = world.spawn(); world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0))); world.add(b, Collider::Box { half_extents: Vec3::ONE }); let contacts = detect_collisions(&world); assert_eq!(contacts.len(), 1); assert!(contacts[0].depth > 0.0); } #[test] fn test_box_vs_box_collision() { let mut world = World::new(); let a = world.spawn(); world.add(a, Transform::from_position(Vec3::ZERO)); world.add(a, Collider::Box { half_extents: Vec3::ONE }); let b = world.spawn(); world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0))); world.add(b, Collider::Box { half_extents: Vec3::ONE }); let contacts = detect_collisions(&world); assert_eq!(contacts.len(), 1); assert!((contacts[0].depth - 0.5).abs() < 1e-5); } #[test] fn test_three_entities_mixed() { let mut world = World::new(); let a = world.spawn(); world.add(a, Transform::from_position(Vec3::ZERO)); world.add(a, Collider::Sphere { radius: 1.0 }); let b = world.spawn(); world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0))); world.add(b, Collider::Sphere { radius: 1.0 }); let c = world.spawn(); world.add(c, Transform::from_position(Vec3::new(100.0, 0.0, 0.0))); world.add(c, Collider::Box { half_extents: Vec3::ONE }); let contacts = detect_collisions(&world); assert_eq!(contacts.len(), 1); // only a-b collide } } ``` - [ ] **Step 2: lib.rs에 collision 모듈 등록** ```rust pub mod collision; pub use collision::detect_collisions; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 25개 PASS (collider 2 + narrow 11 + bvh 5 + collision 7) - [ ] **Step 4: 전체 workspace 테스트** Run: `cargo test --workspace` Expected: 기존 105 + AABB 6 + physics 25 = 136개 전부 PASS - [ ] **Step 5: 커밋** ```bash git add crates/voltex_physics/src/collision.rs crates/voltex_physics/src/lib.rs git commit -m "feat(physics): add detect_collisions ECS integration" ``` --- ## Task 8: 문서 업데이트 + 최종 커밋 **Files:** - Modify: `docs/STATUS.md` - Modify: `docs/DEFERRED.md` - Modify: `CLAUDE.md` - [ ] **Step 1: STATUS.md에 Phase 5-1 추가** Phase 4c 아래에 추가: ```markdown ### Phase 5-1: Collision Detection - voltex_math: AABB type - voltex_physics: Collider (Sphere, Box), ContactPoint - voltex_physics: BVH broad phase (median split) - voltex_physics: Narrow phase (sphere-sphere, sphere-box, box-box SAT) - voltex_physics: detect_collisions ECS integration ``` crate 구조에 `voltex_physics` 추가. 테스트 수 업데이트. 다음 항목을 Phase 5-2 (리지드바디)로 변경. - [ ] **Step 2: DEFERRED.md에 Phase 5-1 미뤄진 항목 추가** ```markdown ## Phase 5-1 - **Capsule, Convex Hull 콜라이더** — Sphere + Box만 구현. 추후 GJK/EPA와 함께 추가. - **OBB (회전된 박스) 충돌** — 축 정렬 AABB만 지원. OBB는 GJK/EPA로 대체 예정. - **Incremental BVH 업데이트** — 매 프레임 전체 rebuild. 성능 이슈 시 incremental update 적용. - **연속 충돌 감지 (CCD)** — 이산 충돌만. 빠른 물체의 터널링 미처리. ``` - [ ] **Step 3: 커밋** ```bash git add docs/STATUS.md docs/DEFERRED.md CLAUDE.md git commit -m "docs: add Phase 5-1 collision detection status and deferred items" ```