Skip to main content

alice_physics/
compound.rs

1//! Compound Shape
2//!
3//! Combines multiple collision shapes into a single rigid body.
4//! Each child shape has a local offset (position + rotation) relative to the body.
5//!
6//! # Usage
7//!
8//! ```
9//! use alice_physics::compound::CompoundShape;
10//! use alice_physics::{Vec3Fix, Fix128};
11//! use alice_physics::collider::Sphere;
12//! use alice_physics::math::QuatFix;
13//!
14//! let mut compound = CompoundShape::new();
15//! compound.add_sphere(Sphere::new(Vec3Fix::ZERO, Fix128::ONE), Vec3Fix::ZERO, QuatFix::IDENTITY);
16//! compound.add_sphere(Sphere::new(Vec3Fix::ZERO, Fix128::ONE), Vec3Fix::from_int(3, 0, 0), QuatFix::IDENTITY);
17//! ```
18
19use crate::box_collider::OrientedBox;
20use crate::collider::{Capsule, ConvexHull, Sphere, Support, AABB};
21use crate::mass_properties::{
22    box_mass_properties, capsule_mass_properties, convex_hull_mass_properties,
23    sphere_mass_properties, translate_inertia, MassProperties,
24};
25use crate::math::{Fix128, Mat3Fix, QuatFix, Vec3Fix};
26
27#[cfg(not(feature = "std"))]
28use alloc::vec::Vec;
29
30/// Enumeration of supported child shape types
31#[derive(Clone, Debug)]
32pub enum ShapeRef {
33    /// Sphere child
34    Sphere(Sphere),
35    /// Capsule child
36    Capsule(Capsule),
37    /// Oriented box child
38    Box(OrientedBox),
39    /// Convex hull child
40    ConvexHull(ConvexHull),
41}
42
43impl ShapeRef {
44    /// Compute AABB of this shape
45    #[must_use]
46    pub fn aabb(&self) -> AABB {
47        match self {
48            Self::Sphere(s) => {
49                let r = Vec3Fix::new(s.radius, s.radius, s.radius);
50                AABB::new(s.center - r, s.center + r)
51            }
52            Self::Capsule(c) => {
53                let r = Vec3Fix::new(c.radius, c.radius, c.radius);
54                let min = Vec3Fix::new(
55                    if c.a.x < c.b.x { c.a.x } else { c.b.x },
56                    if c.a.y < c.b.y { c.a.y } else { c.b.y },
57                    if c.a.z < c.b.z { c.a.z } else { c.b.z },
58                );
59                let max = Vec3Fix::new(
60                    if c.a.x > c.b.x { c.a.x } else { c.b.x },
61                    if c.a.y > c.b.y { c.a.y } else { c.b.y },
62                    if c.a.z > c.b.z { c.a.z } else { c.b.z },
63                );
64                AABB::new(min - r, max + r)
65            }
66            Self::Box(b) => b.aabb(),
67            Self::ConvexHull(hull) => {
68                let first = hull.vertices[0];
69                let mut min = first;
70                let mut max = first;
71                for &v in &hull.vertices[1..] {
72                    if v.x < min.x {
73                        min.x = v.x;
74                    }
75                    if v.y < min.y {
76                        min.y = v.y;
77                    }
78                    if v.z < min.z {
79                        min.z = v.z;
80                    }
81                    if v.x > max.x {
82                        max.x = v.x;
83                    }
84                    if v.y > max.y {
85                        max.y = v.y;
86                    }
87                    if v.z > max.z {
88                        max.z = v.z;
89                    }
90                }
91                AABB::new(min, max)
92            }
93        }
94    }
95}
96
97/// A child shape within a compound, with local transform
98#[derive(Clone, Debug)]
99pub struct CompoundChild {
100    /// The shape
101    pub shape: ShapeRef,
102    /// Local position offset from compound center
103    pub local_position: Vec3Fix,
104    /// Local rotation offset
105    pub local_rotation: QuatFix,
106}
107
108/// Compound shape: multiple child shapes combined into one body
109#[derive(Clone, Debug)]
110pub struct CompoundShape {
111    /// Child shapes with their local transforms
112    pub children: Vec<CompoundChild>,
113    /// Cached world-space AABB (recomputed on transform)
114    pub(crate) cached_aabb: AABB,
115    /// Whether the cached AABB needs recomputation
116    pub(crate) dirty: bool,
117}
118
119impl CompoundShape {
120    /// Create an empty compound shape
121    #[must_use]
122    pub const fn new() -> Self {
123        Self {
124            children: Vec::new(),
125            cached_aabb: AABB::new(Vec3Fix::ZERO, Vec3Fix::ZERO),
126            dirty: true,
127        }
128    }
129
130    /// Add a sphere child
131    pub fn add_sphere(&mut self, sphere: Sphere, position: Vec3Fix, rotation: QuatFix) {
132        self.children.push(CompoundChild {
133            shape: ShapeRef::Sphere(sphere),
134            local_position: position,
135            local_rotation: rotation,
136        });
137        self.dirty = true;
138    }
139
140    /// Add a capsule child
141    pub fn add_capsule(&mut self, capsule: Capsule, position: Vec3Fix, rotation: QuatFix) {
142        self.children.push(CompoundChild {
143            shape: ShapeRef::Capsule(capsule),
144            local_position: position,
145            local_rotation: rotation,
146        });
147        self.dirty = true;
148    }
149
150    /// Add a convex hull child
151    pub fn add_convex_hull(&mut self, hull: ConvexHull, position: Vec3Fix, rotation: QuatFix) {
152        self.children.push(CompoundChild {
153            shape: ShapeRef::ConvexHull(hull),
154            local_position: position,
155            local_rotation: rotation,
156        });
157        self.dirty = true;
158    }
159
160    /// A compound of the convex pieces of a decomposed SDF: one hull child per
161    /// piece, at the origin, so the compound is in the SDF's own coordinates.
162    ///
163    /// Together the hulls are the *concave* solid the SDF describes, up to the
164    /// grid's resolution (a hull is smaller than the solid by up to a cell per
165    /// side), where a single convex hull would fill every dent.
166    #[cfg(feature = "std")]
167    #[must_use]
168    pub fn from_decomposition(result: &crate::convex_decompose::DecompositionResult) -> Self {
169        let mut compound = Self::new();
170        for hull in &result.hulls {
171            compound.add_convex_hull(hull.clone(), Vec3Fix::ZERO, QuatFix::IDENTITY);
172        }
173        compound
174    }
175
176    /// Decompose `sdf` inside `[min, max]` ([`crate::convex_decompose::decompose_sdf`])
177    /// and build the compound of its convex pieces
178    /// ([`from_decomposition`](Self::from_decomposition)).
179    #[cfg(feature = "std")]
180    #[must_use]
181    pub fn from_sdf(
182        sdf: &dyn crate::sdf_collider::SdfField,
183        min: Vec3Fix,
184        max: Vec3Fix,
185        config: &crate::convex_decompose::DecomposeConfig,
186    ) -> Self {
187        Self::from_decomposition(&crate::convex_decompose::decompose_sdf(
188            sdf, min, max, config,
189        ))
190    }
191
192    /// Add a box child
193    pub fn add_box(&mut self, obox: OrientedBox, position: Vec3Fix, rotation: QuatFix) {
194        self.children.push(CompoundChild {
195            shape: ShapeRef::Box(obox),
196            local_position: position,
197            local_rotation: rotation,
198        });
199        self.dirty = true;
200    }
201
202    /// Number of children
203    #[inline]
204    #[must_use]
205    pub fn len(&self) -> usize {
206        self.children.len()
207    }
208
209    /// Check if empty
210    #[inline]
211    #[must_use]
212    pub fn is_empty(&self) -> bool {
213        self.children.is_empty()
214    }
215
216    /// Compute the AABB enclosing all children (in local space)
217    pub fn compute_aabb(&mut self) -> AABB {
218        if self.children.is_empty() {
219            // an empty compound has a valid (degenerate) box: cache it like any other
220            self.cached_aabb = AABB::new(Vec3Fix::ZERO, Vec3Fix::ZERO);
221            self.dirty = false;
222            return self.cached_aabb;
223        }
224
225        let first = self.child_world_aabb(0, Vec3Fix::ZERO, QuatFix::IDENTITY);
226        let mut result = first;
227
228        for i in 1..self.children.len() {
229            let child_aabb = self.child_world_aabb(i, Vec3Fix::ZERO, QuatFix::IDENTITY);
230            result = result.union(&child_aabb);
231        }
232
233        self.cached_aabb = result;
234        self.dirty = false;
235        result
236    }
237
238    /// Get world-space AABB for a specific child, given body transform
239    #[must_use]
240    pub fn child_world_aabb(&self, child_idx: usize, body_pos: Vec3Fix, body_rot: QuatFix) -> AABB {
241        let child = &self.children[child_idx];
242        let body_rot = body_rot.unit_rotation();
243        let world_pos = body_pos + body_rot.rotate_vec(child.local_position);
244
245        match &child.shape {
246            ShapeRef::Sphere(s) => {
247                let r = Vec3Fix::new(s.radius, s.radius, s.radius);
248                // The sphere's own offset is turned by the child's rotation (composed
249                // with the body's), like every other child type's, and as
250                // `support_world` places it.
251                let child_rot = body_rot.mul(child.local_rotation);
252                let center = world_pos + child_rot.rotate_vec(s.center);
253                AABB::new(center - r, center + r)
254            }
255            ShapeRef::Capsule(c) => {
256                let child_rot = body_rot.mul(child.local_rotation);
257                let a = world_pos + child_rot.rotate_vec(c.a);
258                let b = world_pos + child_rot.rotate_vec(c.b);
259                let r = Vec3Fix::new(c.radius, c.radius, c.radius);
260                let min = Vec3Fix::new(
261                    if a.x < b.x { a.x } else { b.x },
262                    if a.y < b.y { a.y } else { b.y },
263                    if a.z < b.z { a.z } else { b.z },
264                );
265                let max = Vec3Fix::new(
266                    if a.x > b.x { a.x } else { b.x },
267                    if a.y > b.y { a.y } else { b.y },
268                    if a.z > b.z { a.z } else { b.z },
269                );
270                AABB::new(min - r, max + r)
271            }
272            ShapeRef::Box(ob) => {
273                let child_rot = body_rot.mul(child.local_rotation);
274                let transformed = OrientedBox::new(
275                    world_pos + child_rot.rotate_vec(ob.center),
276                    ob.half_extents,
277                    child_rot.mul(ob.rotation),
278                );
279                transformed.aabb()
280            }
281            ShapeRef::ConvexHull(hull) => {
282                let child_rot = body_rot.mul(child.local_rotation);
283                let first = world_pos + child_rot.rotate_vec(hull.vertices[0]);
284                let mut min = first;
285                let mut max = first;
286                for &v in &hull.vertices[1..] {
287                    let wv = world_pos + child_rot.rotate_vec(v);
288                    if wv.x < min.x {
289                        min.x = wv.x;
290                    }
291                    if wv.y < min.y {
292                        min.y = wv.y;
293                    }
294                    if wv.z < min.z {
295                        min.z = wv.z;
296                    }
297                    if wv.x > max.x {
298                        max.x = wv.x;
299                    }
300                    if wv.y > max.y {
301                        max.y = wv.y;
302                    }
303                    if wv.z > max.z {
304                        max.z = wv.z;
305                    }
306                }
307                AABB::new(min, max)
308            }
309        }
310    }
311
312    /// Get world-space AABB for entire compound given body transform
313    #[must_use]
314    pub fn world_aabb(&self, body_pos: Vec3Fix, body_rot: QuatFix) -> AABB {
315        if self.children.is_empty() {
316            return AABB::new(body_pos, body_pos);
317        }
318
319        let mut result = self.child_world_aabb(0, body_pos, body_rot);
320        for i in 1..self.children.len() {
321            let child_aabb = self.child_world_aabb(i, body_pos, body_rot);
322            result = result.union(&child_aabb);
323        }
324        result
325    }
326
327    /// AABB-based closest pair pruning: 対象AABBと重なる子のみを返す。
328    /// ブロードフェーズで不要なナローフェーズ判定を除外する。
329    #[must_use]
330    pub fn overlapping_children(
331        &self,
332        target_aabb: &AABB,
333        body_pos: Vec3Fix,
334        body_rot: QuatFix,
335    ) -> Vec<usize> {
336        let mut result = Vec::new();
337        for i in 0..self.children.len() {
338            let child_aabb = self.child_world_aabb(i, body_pos, body_rot);
339            if child_aabb.intersects(target_aabb) {
340                result.push(i);
341            }
342        }
343        result
344    }
345
346    /// Support function for the compound shape (selects the child with maximum support)
347    #[must_use]
348    pub fn support_world(
349        &self,
350        direction: Vec3Fix,
351        body_pos: Vec3Fix,
352        body_rot: QuatFix,
353    ) -> Vec3Fix {
354        if self.children.is_empty() {
355            return body_pos;
356        }
357        // The best so far; `None` until the first child answers, so the choice
358        // does not depend on how large the dot products are (a fixed sentinel floor
359        // returned the origin for a compound farther than it from the origin).
360        let mut best: Option<(Fix128, Vec3Fix)> = None;
361
362        for child in &self.children {
363            let s = child.support_world(direction, body_pos, body_rot);
364            let d = s.dot(direction);
365            if best.is_none_or(|(best_dot, _)| d > best_dot) {
366                best = Some((d, s));
367            }
368        }
369
370        best.map_or(body_pos, |(_, point)| point)
371    }
372
373    /// Mass, centre of mass and the inertia about it, for the compound made of
374    /// `density`, in the compound's own frame.
375    ///
376    /// Each child contributes its shape's mass properties (a capsule's along its
377    /// own axis, a box's in its own frame, a hull's by the exact integral over its
378    /// mesh), carried to where the child is, and the tensors are summed about the
379    /// common centre of mass with the parallel-axis theorem
380    /// ([`crate::mass_properties::translate_inertia`]). Overlapping children are
381    /// counted twice where they overlap: a compound is a union of solids, not a
382    /// boolean one.
383    ///
384    /// [`MassProperties::ZERO`] for an empty compound, a non-positive density, or
385    /// children with no volume.
386    #[must_use]
387    pub fn mass_properties(&self, density: Fix128) -> MassProperties {
388        if density <= Fix128::ZERO {
389            return MassProperties::ZERO;
390        }
391        // (mass, centre of mass, inertia about it), all in the compound's frame.
392        let parts: Vec<MassProperties> = self
393            .children
394            .iter()
395            .map(|child| child.mass_properties(density))
396            .collect();
397        let mass = parts.iter().fold(Fix128::ZERO, |acc, p| acc + p.mass);
398        if mass <= Fix128::ZERO {
399            return MassProperties::ZERO;
400        }
401        let first_moment = parts
402            .iter()
403            .fold(Vec3Fix::ZERO, |acc, p| acc + p.center_of_mass * p.mass);
404        let com = first_moment / mass;
405        let mut inertia = Mat3Fix::ZERO;
406        for p in &parts {
407            let about_com = translate_inertia(p, com - p.center_of_mass);
408            inertia = Mat3Fix::from_cols(
409                inertia.col0 + about_com.col0,
410                inertia.col1 + about_com.col1,
411                inertia.col2 + about_com.col2,
412            );
413        }
414        MassProperties {
415            mass,
416            center_of_mass: com,
417            inertia_tensor: inertia,
418        }
419    }
420}
421
422impl CompoundChild {
423    /// The farthest point of this child along `direction`, for a body at
424    /// `body_pos` turned by `body_rot`.
425    #[must_use]
426    pub fn support_world(
427        &self,
428        direction: Vec3Fix,
429        body_pos: Vec3Fix,
430        body_rot: QuatFix,
431    ) -> Vec3Fix {
432        let body_rot = body_rot.unit_rotation();
433        let child_rot = body_rot.mul(self.local_rotation);
434        let child_pos = body_pos + body_rot.rotate_vec(self.local_position);
435        match &self.shape {
436            ShapeRef::Sphere(sphere) => {
437                let shifted = Sphere::new(
438                    child_pos + child_rot.rotate_vec(sphere.center),
439                    sphere.radius,
440                );
441                shifted.support(direction)
442            }
443            ShapeRef::Capsule(cap) => {
444                let a = child_pos + child_rot.rotate_vec(cap.a);
445                let b = child_pos + child_rot.rotate_vec(cap.b);
446                let shifted = Capsule::new(a, b, cap.radius);
447                shifted.support(direction)
448            }
449            ShapeRef::Box(ob) => {
450                let shifted = OrientedBox::new(
451                    child_pos + child_rot.rotate_vec(ob.center),
452                    ob.half_extents,
453                    child_rot.mul(ob.rotation),
454                );
455                shifted.support(direction)
456            }
457            ShapeRef::ConvexHull(hull) => {
458                // Transform each vertex to world space and find support
459                let mut best_v = child_pos + child_rot.rotate_vec(hull.vertices[0]);
460                let mut best_d = best_v.dot(direction);
461                for &v in &hull.vertices[1..] {
462                    let wv = child_pos + child_rot.rotate_vec(v);
463                    let dd = wv.dot(direction);
464                    if dd > best_d {
465                        best_v = wv;
466                        best_d = dd;
467                    }
468                }
469                best_v
470            }
471        }
472    }
473
474    /// Mass, centre of mass and the inertia about it of this child, in the
475    /// compound's frame (the child's own offset and rotation applied).
476    fn mass_properties(&self, density: Fix128) -> MassProperties {
477        let rot = self.local_rotation;
478        // The child's solid in its own frame: (props, frame rotation of the tensor).
479        let (own, frame) = match &self.shape {
480            ShapeRef::Sphere(s) => {
481                let mut p = sphere_mass_properties(s.radius, density);
482                p.center_of_mass = s.center;
483                (p, QuatFix::IDENTITY)
484            }
485            ShapeRef::Box(ob) => {
486                let mut p = box_mass_properties(ob.half_extents, density);
487                p.center_of_mass = ob.center;
488                (p, ob.rotation)
489            }
490            ShapeRef::Capsule(cap) => {
491                let axis = cap.b - cap.a;
492                let length = axis.length();
493                let middle = (cap.a + cap.b) * Fix128::from_ratio(1, 2);
494                if length.is_zero() {
495                    let mut p = sphere_mass_properties(cap.radius, density);
496                    p.center_of_mass = middle;
497                    (p, QuatFix::IDENTITY)
498                } else {
499                    // The capsule solid is Y-aligned in `capsule_mass_properties`:
500                    // `I = I⊥ (E − u uᵀ) + I∥ u uᵀ` for the axis `u`, which needs no
501                    // rotation.
502                    let p = capsule_mass_properties(cap.radius, length.half(), density);
503                    let u = axis / length;
504                    let d = p.inertia_tensor;
505                    let (i_perp, i_axis) = (d.col0.x, d.col1.y);
506                    let outer = Mat3Fix::from_cols(u * u.x, u * u.y, u * u.z);
507                    let e = Mat3Fix::IDENTITY;
508                    let diff = i_axis - i_perp;
509                    let tensor = Mat3Fix::from_cols(
510                        e.col0 * i_perp + outer.col0 * diff,
511                        e.col1 * i_perp + outer.col1 * diff,
512                        e.col2 * i_perp + outer.col2 * diff,
513                    );
514                    (
515                        MassProperties {
516                            mass: p.mass,
517                            center_of_mass: middle,
518                            inertia_tensor: tensor,
519                        },
520                        QuatFix::IDENTITY,
521                    )
522                }
523            }
524            ShapeRef::ConvexHull(hull) => (
525                convex_hull_mass_properties(&hull.vertices, density),
526                QuatFix::IDENTITY,
527            ),
528        };
529        // Into the compound's frame: the tensor turned by (child rotation ∘ frame),
530        // the centre of mass offset by the child's own turning and position.
531        let turn = rot.mul(frame);
532        let r = rotation_matrix(turn);
533        let inertia = r.mul_mat(own.inertia_tensor).mul_mat(r.transpose());
534        MassProperties {
535            mass: own.mass,
536            center_of_mass: self.local_position + rot.rotate_vec(own.center_of_mass),
537            inertia_tensor: inertia,
538        }
539    }
540}
541
542/// The rotation matrix of a unit quaternion: the images of the basis vectors as
543/// columns.
544fn rotation_matrix(q: QuatFix) -> Mat3Fix {
545    Mat3Fix::from_cols(
546        q.rotate_vec(Vec3Fix::UNIT_X),
547        q.rotate_vec(Vec3Fix::UNIT_Y),
548        q.rotate_vec(Vec3Fix::UNIT_Z),
549    )
550}
551
552impl Default for CompoundShape {
553    fn default() -> Self {
554        Self::new()
555    }
556}
557
558/// Wrapper for using `CompoundShape` with GJK (needs body transform context)
559pub struct TransformedCompound<'a> {
560    /// Reference to the compound shape
561    pub compound: &'a CompoundShape,
562    /// Body position
563    pub position: Vec3Fix,
564    /// Body rotation
565    pub rotation: QuatFix,
566}
567
568impl Support for TransformedCompound<'_> {
569    fn support(&self, direction: Vec3Fix) -> Vec3Fix {
570        self.compound
571            .support_world(direction, self.position, self.rotation)
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    #[cfg(not(feature = "std"))]
579    use alloc::vec;
580
581    #[test]
582    fn test_compound_basic() {
583        let mut compound = CompoundShape::new();
584        assert!(compound.is_empty());
585
586        compound.add_sphere(
587            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
588            Vec3Fix::ZERO,
589            QuatFix::IDENTITY,
590        );
591        compound.add_sphere(
592            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
593            Vec3Fix::from_int(5, 0, 0),
594            QuatFix::IDENTITY,
595        );
596
597        assert_eq!(compound.len(), 2);
598    }
599
600    #[test]
601    fn test_compound_aabb() {
602        let mut compound = CompoundShape::new();
603        compound.add_sphere(
604            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
605            Vec3Fix::ZERO,
606            QuatFix::IDENTITY,
607        );
608        compound.add_sphere(
609            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
610            Vec3Fix::from_int(10, 0, 0),
611            QuatFix::IDENTITY,
612        );
613
614        let aabb = compound.world_aabb(Vec3Fix::ZERO, QuatFix::IDENTITY);
615        // Should span from -1 to 11 on X
616        assert!(aabb.min.x <= -Fix128::ONE);
617        assert!(aabb.max.x >= Fix128::from_int(11));
618    }
619
620    #[test]
621    fn test_compound_support() {
622        let mut compound = CompoundShape::new();
623        compound.add_sphere(
624            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
625            Vec3Fix::ZERO,
626            QuatFix::IDENTITY,
627        );
628        compound.add_sphere(
629            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
630            Vec3Fix::from_int(10, 0, 0),
631            QuatFix::IDENTITY,
632        );
633
634        // Support in +X should come from the far sphere (center=10, radius=1 → 11)
635        let s = compound.support_world(Vec3Fix::UNIT_X, Vec3Fix::ZERO, QuatFix::IDENTITY);
636        assert!(
637            s.x >= Fix128::from_int(10),
638            "Support should be from far sphere"
639        );
640    }
641
642    #[test]
643    fn test_compound_with_box() {
644        let mut compound = CompoundShape::new();
645        compound.add_box(
646            OrientedBox::axis_aligned(Vec3Fix::ZERO, Vec3Fix::from_int(1, 1, 1)),
647            Vec3Fix::ZERO,
648            QuatFix::IDENTITY,
649        );
650        compound.add_capsule(
651            Capsule::new(
652                Vec3Fix::from_int(0, -2, 0),
653                Vec3Fix::from_int(0, 2, 0),
654                Fix128::from_ratio(5, 10),
655            ),
656            Vec3Fix::from_int(3, 0, 0),
657            QuatFix::IDENTITY,
658        );
659
660        assert_eq!(compound.len(), 2);
661
662        let aabb = compound.world_aabb(Vec3Fix::ZERO, QuatFix::IDENTITY);
663        assert!(aabb.min.y <= -Fix128::from_int(2));
664    }
665
666    #[test]
667    fn test_transformed_compound_support() {
668        let mut compound = CompoundShape::new();
669        compound.add_sphere(
670            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
671            Vec3Fix::from_int(5, 0, 0),
672            QuatFix::IDENTITY,
673        );
674
675        let tc = TransformedCompound {
676            compound: &compound,
677            position: Vec3Fix::from_int(10, 0, 0),
678            rotation: QuatFix::IDENTITY,
679        };
680
681        let s = tc.support(Vec3Fix::UNIT_X);
682        // Body at 10, child offset 5, sphere radius 1 → 16
683        assert!(s.x >= Fix128::from_int(15));
684    }
685
686    #[test]
687    fn test_compound_convex_hull() {
688        let mut compound = CompoundShape::new();
689
690        // 三角形の凸包
691        let hull = ConvexHull::new(vec![
692            Vec3Fix::from_int(0, 0, 0),
693            Vec3Fix::from_int(2, 0, 0),
694            Vec3Fix::from_int(1, 2, 0),
695        ]);
696        compound.add_convex_hull(hull, Vec3Fix::ZERO, QuatFix::IDENTITY);
697
698        assert_eq!(compound.len(), 1);
699
700        let aabb = compound.world_aabb(Vec3Fix::ZERO, QuatFix::IDENTITY);
701        assert!(aabb.min.x <= Fix128::ZERO);
702        assert!(aabb.max.x >= Fix128::from_int(2));
703        assert!(aabb.max.y >= Fix128::from_int(2));
704    }
705
706    #[test]
707    fn test_compound_convex_hull_support() {
708        let mut compound = CompoundShape::new();
709
710        let hull = ConvexHull::new(vec![
711            Vec3Fix::from_int(0, 0, 0),
712            Vec3Fix::from_int(5, 0, 0),
713            Vec3Fix::from_int(0, 5, 0),
714        ]);
715        compound.add_convex_hull(hull, Vec3Fix::from_int(10, 0, 0), QuatFix::IDENTITY);
716
717        // +X方向のサポート点は (10+5, 0, 0) = (15, 0, 0)
718        let s = compound.support_world(Vec3Fix::UNIT_X, Vec3Fix::ZERO, QuatFix::IDENTITY);
719        assert!(
720            s.x >= Fix128::from_int(14),
721            "Support in +X should be at x=15, got {}",
722            s.x.to_f32()
723        );
724    }
725
726    #[test]
727    fn test_overlapping_children() {
728        let mut compound = CompoundShape::new();
729        // 左側の球 (x=-10)
730        compound.add_sphere(
731            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
732            Vec3Fix::from_int(-10, 0, 0),
733            QuatFix::IDENTITY,
734        );
735        // 右側の球 (x=10)
736        compound.add_sphere(
737            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
738            Vec3Fix::from_int(10, 0, 0),
739            QuatFix::IDENTITY,
740        );
741
742        // x=9..12のAABBは右側の球のみと重なる
743        let query = AABB::new(Vec3Fix::from_int(9, -1, -1), Vec3Fix::from_int(12, 1, 1));
744        let result = compound.overlapping_children(&query, Vec3Fix::ZERO, QuatFix::IDENTITY);
745        assert_eq!(result.len(), 1);
746        assert_eq!(result[0], 1); // 右側の球(インデックス1)
747
748        // 両方にまたがるAABB
749        let query_all = AABB::new(Vec3Fix::from_int(-11, -1, -1), Vec3Fix::from_int(11, 1, 1));
750        let result_all =
751            compound.overlapping_children(&query_all, Vec3Fix::ZERO, QuatFix::IDENTITY);
752        assert_eq!(result_all.len(), 2);
753    }
754
755    #[test]
756    fn compute_aabb_is_exact_union_of_children_in_local_space_and_clears_dirty() {
757        let mut compound = CompoundShape::new();
758        // 空: 退化 AABB (原点) を返し、他の case と同じく cache して dirty を下ろす (1.2.0)
759        assert!(compound.dirty);
760        assert_eq!(
761            compound.compute_aabb(),
762            AABB::new(Vec3Fix::ZERO, Vec3Fix::ZERO)
763        );
764        assert!(!compound.dirty);
765        assert_eq!(
766            compound.cached_aabb,
767            AABB::new(Vec3Fix::ZERO, Vec3Fix::ZERO)
768        );
769
770        // 球 r=1 at (10, 0, 0): [9, 11] × [-1, 1] × [-1, 1]
771        compound.add_sphere(
772            Sphere::new(Vec3Fix::ZERO, Fix128::ONE),
773            Vec3Fix::from_int(10, 0, 0),
774            QuatFix::IDENTITY,
775        );
776        assert!(compound.dirty, "add_* は dirty を立てる");
777        let one = compound.compute_aabb();
778        assert_eq!(one.min, Vec3Fix::from_int(9, -1, -1));
779        assert_eq!(one.max, Vec3Fix::from_int(11, 1, 1));
780        assert_eq!(compound.cached_aabb, one);
781        assert!(!compound.dirty);
782
783        // capsule (0,-3,0)-(0,3,0) r=2 at (-4, 0, 5): x [-6, -2]、y [-5, 5]、z [3, 7]
784        compound.add_capsule(
785            Capsule::new(
786                Vec3Fix::from_int(0, -3, 0),
787                Vec3Fix::from_int(0, 3, 0),
788                Fix128::from_int(2),
789            ),
790            Vec3Fix::from_int(-4, 0, 5),
791            QuatFix::IDENTITY,
792        );
793        // 球 center offset (0, 0, -8) r=1 at 原点: z [-9, -7]
794        compound.add_sphere(
795            Sphere::new(Vec3Fix::from_int(0, 0, -8), Fix128::ONE),
796            Vec3Fix::ZERO,
797            QuatFix::IDENTITY,
798        );
799        let all = compound.compute_aabb();
800        // union: x [-6, 11]、y [-5, 5]、z [-9, 7]
801        assert_eq!(all.min, Vec3Fix::from_int(-6, -5, -9));
802        assert_eq!(all.max, Vec3Fix::from_int(11, 5, 7));
803        assert_eq!(compound.cached_aabb, all);
804        assert!(!compound.dirty);
805        // world_aabb(identity) と同一、各 child AABB を全て包む
806        assert_eq!(all, compound.world_aabb(Vec3Fix::ZERO, QuatFix::IDENTITY));
807        for i in 0..compound.len() {
808            let c = compound.child_world_aabb(i, Vec3Fix::ZERO, QuatFix::IDENTITY);
809            assert_eq!(all.union(&c), all, "child {i} not enclosed");
810        }
811        // 冪等
812        assert_eq!(compound.compute_aabb(), all);
813        // body transform には依存しない (local space)、world_aabb は平行移動で追従する
814        let shifted = compound.world_aabb(Vec3Fix::from_int(100, 0, 0), QuatFix::IDENTITY);
815        assert_eq!(shifted.min, Vec3Fix::from_int(94, -5, -9));
816        assert_eq!(shifted.max, Vec3Fix::from_int(111, 5, 7));
817        assert_eq!(compound.compute_aabb(), all);
818    }
819}