Skip to main content

oxiphysics_python/
rigid_api.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Python rigid body bindings.
6//!
7//! Provides Python-friendly wrappers for rigid body simulation:
8//! bodies, colliders, joints, a physics world, and batch force APIs.
9//! All types use plain arrays and primitive types (no nalgebra).
10
11#![allow(missing_docs)]
12#![allow(dead_code)]
13
14use serde::{Deserialize, Serialize};
15
16// ─────────────────────────────────────────────────────────────────────────────
17// Vec3 helpers
18// ─────────────────────────────────────────────────────────────────────────────
19
20#[inline]
21fn vec3_add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
22    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
23}
24
25#[inline]
26fn vec3_scale(a: [f64; 3], s: f64) -> [f64; 3] {
27    [a[0] * s, a[1] * s, a[2] * s]
28}
29
30#[inline]
31fn vec3_dot(a: [f64; 3], b: [f64; 3]) -> f64 {
32    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
33}
34
35#[inline]
36fn vec3_cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
37    [
38        a[1] * b[2] - a[2] * b[1],
39        a[2] * b[0] - a[0] * b[2],
40        a[0] * b[1] - a[1] * b[0],
41    ]
42}
43
44#[inline]
45fn vec3_length(a: [f64; 3]) -> f64 {
46    vec3_dot(a, a).sqrt()
47}
48
49#[inline]
50fn quat_identity() -> [f64; 4] {
51    [0.0, 0.0, 0.0, 1.0]
52}
53
54// ─────────────────────────────────────────────────────────────────────────────
55// Shape type
56// ─────────────────────────────────────────────────────────────────────────────
57
58/// Collider shape variants supported by the physics engine.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub enum PyShapeType {
61    /// Sphere defined by radius.
62    Sphere {
63        /// Sphere radius in metres.
64        radius: f64,
65    },
66    /// Axis-aligned box defined by half-extents.
67    Box {
68        /// Half-extents `[hx, hy, hz]` in metres.
69        half_extents: [f64; 3],
70    },
71    /// Capsule (cylinder capped by two hemispheres).
72    Capsule {
73        /// Capsule half-height (cylinder portion) in metres.
74        half_height: f64,
75        /// Capsule radius in metres.
76        radius: f64,
77    },
78}
79
80// ─────────────────────────────────────────────────────────────────────────────
81// PyRigidBody
82// ─────────────────────────────────────────────────────────────────────────────
83
84/// A rigid body with full kinematic and dynamic state.
85///
86/// Positions and orientations are stored as plain arrays for easy FFI.
87/// The orientation quaternion uses convention `[x, y, z, w]`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PyRigidBody {
90    /// Unique handle for this body.
91    pub id: u32,
92    /// Mass in kilograms. Zero means static.
93    pub mass: f64,
94    /// Diagonal inertia tensor `[Ixx, Iyy, Izz]`.
95    pub inertia: [f64; 3],
96    /// World-space position `[x, y, z]`.
97    pub position: [f64; 3],
98    /// Orientation quaternion `[x, y, z, w]`.
99    pub orientation: [f64; 4],
100    /// Linear velocity `[vx, vy, vz]`.
101    pub velocity: [f64; 3],
102    /// Angular velocity `[wx, wy, wz]`.
103    pub ang_vel: [f64; 3],
104    /// Accumulated force buffer `[fx, fy, fz]`.
105    pub accumulated_force: [f64; 3],
106    /// Accumulated torque buffer `[tx, ty, tz]`.
107    pub accumulated_torque: [f64; 3],
108    /// Whether the body is sleeping.
109    pub sleeping: bool,
110    /// Whether the body is static (immovable).
111    pub is_static: bool,
112    /// Whether the body is kinematic (user-controlled velocity).
113    pub is_kinematic: bool,
114    /// Linear damping coefficient.
115    pub linear_damping: f64,
116    /// Angular damping coefficient.
117    pub angular_damping: f64,
118}
119
120impl PyRigidBody {
121    /// Create a new dynamic rigid body.
122    pub fn new(id: u32, mass: f64, position: [f64; 3]) -> Self {
123        Self {
124            id,
125            mass,
126            inertia: [mass * 0.1; 3],
127            position,
128            orientation: quat_identity(),
129            velocity: [0.0; 3],
130            ang_vel: [0.0; 3],
131            accumulated_force: [0.0; 3],
132            accumulated_torque: [0.0; 3],
133            sleeping: false,
134            is_static: false,
135            is_kinematic: false,
136            linear_damping: 0.01,
137            angular_damping: 0.01,
138        }
139    }
140
141    /// Create a static body (zero mass, immovable).
142    pub fn new_static(id: u32, position: [f64; 3]) -> Self {
143        let mut b = Self::new(id, 0.0, position);
144        b.is_static = true;
145        b
146    }
147
148    /// Create a kinematic body.
149    pub fn new_kinematic(id: u32, mass: f64, position: [f64; 3]) -> Self {
150        let mut b = Self::new(id, mass, position);
151        b.is_kinematic = true;
152        b
153    }
154
155    /// Apply a world-space force (accumulates until `clear_forces` is called).
156    pub fn apply_force(&mut self, force: [f64; 3]) {
157        if self.is_static || self.sleeping {
158            return;
159        }
160        self.accumulated_force = vec3_add(self.accumulated_force, force);
161    }
162
163    /// Apply a torque about the body's centre of mass.
164    pub fn apply_torque(&mut self, torque: [f64; 3]) {
165        if self.is_static || self.sleeping {
166            return;
167        }
168        self.accumulated_torque = vec3_add(self.accumulated_torque, torque);
169    }
170
171    /// Apply a linear impulse (instantaneous velocity change).
172    pub fn apply_impulse(&mut self, impulse: [f64; 3]) {
173        if self.is_static {
174            return;
175        }
176        if self.mass > 0.0 {
177            let inv_m = 1.0 / self.mass;
178            self.velocity = vec3_add(self.velocity, vec3_scale(impulse, inv_m));
179        }
180        self.sleeping = false;
181    }
182
183    /// Apply an angular impulse (instantaneous angular velocity change).
184    pub fn apply_angular_impulse(&mut self, ang_impulse: [f64; 3]) {
185        if self.is_static {
186            return;
187        }
188        for i in 0..3 {
189            if self.inertia[i] > 0.0 {
190                self.ang_vel[i] += ang_impulse[i] / self.inertia[i];
191            }
192        }
193        self.sleeping = false;
194    }
195
196    /// Clear accumulated forces and torques.
197    pub fn clear_forces(&mut self) {
198        self.accumulated_force = [0.0; 3];
199        self.accumulated_torque = [0.0; 3];
200    }
201
202    /// Integrate the body forward by `dt` seconds (semi-implicit Euler).
203    pub fn integrate(&mut self, dt: f64, gravity: [f64; 3]) {
204        if self.is_static || self.is_kinematic || self.sleeping {
205            return;
206        }
207        let inv_m = if self.mass > 0.0 {
208            1.0 / self.mass
209        } else {
210            0.0
211        };
212        // Linear
213        let grav_force = vec3_scale(gravity, self.mass);
214        let total_force = vec3_add(self.accumulated_force, grav_force);
215        let accel = vec3_scale(total_force, inv_m);
216        self.velocity = vec3_add(self.velocity, vec3_scale(accel, dt));
217        // Damping
218        let lin_damp = (1.0 - self.linear_damping * dt).max(0.0);
219        self.velocity = vec3_scale(self.velocity, lin_damp);
220        self.position = vec3_add(self.position, vec3_scale(self.velocity, dt));
221        // Angular
222        for i in 0..3 {
223            let alpha = if self.inertia[i] > 0.0 {
224                self.accumulated_torque[i] / self.inertia[i]
225            } else {
226                0.0
227            };
228            self.ang_vel[i] += alpha * dt;
229        }
230        let ang_damp = (1.0 - self.angular_damping * dt).max(0.0);
231        self.ang_vel = vec3_scale(self.ang_vel, ang_damp);
232    }
233
234    /// Kinetic energy of this body.
235    pub fn kinetic_energy(&self) -> f64 {
236        let ke_lin = 0.5 * self.mass * vec3_dot(self.velocity, self.velocity);
237        let ke_rot: f64 = self
238            .inertia
239            .iter()
240            .zip(self.ang_vel.iter())
241            .map(|(&i, &w)| 0.5 * i * w * w)
242            .sum();
243        ke_lin + ke_rot
244    }
245
246    /// Linear momentum `[px, py, pz]`.
247    pub fn linear_momentum(&self) -> [f64; 3] {
248        vec3_scale(self.velocity, self.mass)
249    }
250
251    /// Effective inverse mass (0 for static).
252    pub fn inv_mass(&self) -> f64 {
253        if self.is_static || self.mass <= 0.0 {
254            0.0
255        } else {
256            1.0 / self.mass
257        }
258    }
259}
260
261// ─────────────────────────────────────────────────────────────────────────────
262// PyRigidBodySet
263// ─────────────────────────────────────────────────────────────────────────────
264
265/// A collection of rigid bodies with O(1) lookup by handle.
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267pub struct PyRigidBodySet {
268    bodies: Vec<Option<PyRigidBody>>,
269    next_id: u32,
270    count: usize,
271}
272
273impl PyRigidBodySet {
274    /// Create an empty set.
275    pub fn new() -> Self {
276        Self::default()
277    }
278
279    /// Add a body; returns its handle.
280    pub fn add(&mut self, mut body: PyRigidBody) -> u32 {
281        let id = self.next_id;
282        self.next_id += 1;
283        body.id = id;
284        // Find free slot or push
285        if let Some(slot) = self.bodies.iter_mut().find(|s| s.is_none()) {
286            *slot = Some(body);
287        } else {
288            self.bodies.push(Some(body));
289        }
290        self.count += 1;
291        id
292    }
293
294    /// Remove a body by handle. Returns `true` if found.
295    pub fn remove(&mut self, id: u32) -> bool {
296        for slot in &mut self.bodies {
297            if slot.as_ref().is_some_and(|b| b.id == id) {
298                *slot = None;
299                self.count -= 1;
300                return true;
301            }
302        }
303        false
304    }
305
306    /// Get an immutable reference to a body.
307    pub fn get(&self, id: u32) -> Option<&PyRigidBody> {
308        self.bodies.iter().flatten().find(|b| b.id == id)
309    }
310
311    /// Get a mutable reference to a body.
312    pub fn get_mut(&mut self, id: u32) -> Option<&mut PyRigidBody> {
313        self.bodies.iter_mut().flatten().find(|b| b.id == id)
314    }
315
316    /// Number of active bodies.
317    pub fn len(&self) -> usize {
318        self.count
319    }
320
321    /// True when no bodies exist.
322    pub fn is_empty(&self) -> bool {
323        self.count == 0
324    }
325
326    /// Iterate over all active bodies.
327    pub fn iter(&self) -> impl Iterator<Item = &PyRigidBody> {
328        self.bodies.iter().flatten()
329    }
330
331    /// Mutable iterator over all active bodies.
332    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut PyRigidBody> {
333        self.bodies.iter_mut().flatten()
334    }
335
336    /// Get position of a body by id.
337    pub fn get_position(&self, id: u32) -> Option<[f64; 3]> {
338        self.get(id).map(|b| b.position)
339    }
340
341    /// Get velocity of a body by id.
342    pub fn get_velocity(&self, id: u32) -> Option<[f64; 3]> {
343        self.get(id).map(|b| b.velocity)
344    }
345
346    /// Batch: apply gravity to all dynamic bodies.
347    pub fn apply_gravity(&mut self, gravity: [f64; 3]) {
348        for body in self.iter_mut() {
349            if !body.is_static && !body.is_kinematic && !body.sleeping {
350                let grav_force = vec3_scale(gravity, body.mass);
351                body.apply_force(grav_force);
352            }
353        }
354    }
355
356    /// Batch: integrate all dynamic bodies forward by `dt`.
357    pub fn integrate_all(&mut self, dt: f64, gravity: [f64; 3]) {
358        for body in self.iter_mut() {
359            body.integrate(dt, gravity);
360            body.clear_forces();
361        }
362    }
363
364    /// Total kinetic energy of all bodies.
365    pub fn total_kinetic_energy(&self) -> f64 {
366        self.iter().map(|b| b.kinetic_energy()).sum()
367    }
368
369    /// Number of sleeping bodies.
370    pub fn sleeping_count(&self) -> usize {
371        self.iter().filter(|b| b.sleeping).count()
372    }
373
374    /// Number of awake bodies.
375    pub fn awake_count(&self) -> usize {
376        self.count - self.sleeping_count()
377    }
378}
379
380// ─────────────────────────────────────────────────────────────────────────────
381// PyCollider
382// ─────────────────────────────────────────────────────────────────────────────
383
384/// A collider attached to a rigid body.
385///
386/// Defines the geometric shape for collision detection, plus surface
387/// properties (friction, restitution) and sensor mode.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct PyCollider {
390    /// Unique collider handle.
391    pub id: u32,
392    /// Parent body handle.
393    pub body_id: u32,
394    /// Shape of this collider.
395    pub shape: PyShapeType,
396    /// Coefficient of friction (Coulomb model).
397    pub friction: f64,
398    /// Coefficient of restitution (bounciness 0..=1).
399    pub restitution: f64,
400    /// When `true`, the collider fires overlap events but does not generate
401    /// contact forces.
402    pub is_sensor: bool,
403    /// Local offset from body centre `[x, y, z]`.
404    pub local_offset: [f64; 3],
405    /// Local orientation quaternion `[x, y, z, w]`.
406    pub local_orientation: [f64; 4],
407}
408
409impl PyCollider {
410    /// Create a sphere collider.
411    pub fn sphere(id: u32, body_id: u32, radius: f64) -> Self {
412        Self {
413            id,
414            body_id,
415            shape: PyShapeType::Sphere { radius },
416            friction: 0.5,
417            restitution: 0.3,
418            is_sensor: false,
419            local_offset: [0.0; 3],
420            local_orientation: quat_identity(),
421        }
422    }
423
424    /// Create a box collider.
425    pub fn box_collider(id: u32, body_id: u32, half_extents: [f64; 3]) -> Self {
426        Self {
427            id,
428            body_id,
429            shape: PyShapeType::Box { half_extents },
430            friction: 0.5,
431            restitution: 0.3,
432            is_sensor: false,
433            local_offset: [0.0; 3],
434            local_orientation: quat_identity(),
435        }
436    }
437
438    /// Create a capsule collider.
439    pub fn capsule(id: u32, body_id: u32, half_height: f64, radius: f64) -> Self {
440        Self {
441            id,
442            body_id,
443            shape: PyShapeType::Capsule {
444                half_height,
445                radius,
446            },
447            friction: 0.5,
448            restitution: 0.3,
449            is_sensor: false,
450            local_offset: [0.0; 3],
451            local_orientation: quat_identity(),
452        }
453    }
454
455    /// Set as sensor (no contact response).
456    pub fn as_sensor(mut self) -> Self {
457        self.is_sensor = true;
458        self
459    }
460
461    /// Bounding radius used for broadphase.
462    pub fn bounding_radius(&self) -> f64 {
463        match &self.shape {
464            PyShapeType::Sphere { radius } => *radius,
465            PyShapeType::Box { half_extents } => vec3_length(*half_extents),
466            PyShapeType::Capsule {
467                half_height,
468                radius,
469            } => half_height + radius,
470        }
471    }
472}
473
474// ─────────────────────────────────────────────────────────────────────────────
475// Contact and Ray types
476// ─────────────────────────────────────────────────────────────────────────────
477
478/// A contact between two bodies.
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct PyContact {
481    /// First body handle.
482    pub body_a: u32,
483    /// Second body handle.
484    pub body_b: u32,
485    /// Contact point in world space.
486    pub point: [f64; 3],
487    /// Contact normal pointing from B to A.
488    pub normal: [f64; 3],
489    /// Penetration depth (positive = overlap).
490    pub depth: f64,
491    /// Magnitude of the contact impulse applied.
492    pub impulse: f64,
493}
494
495/// Result of a ray-cast query.
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct PyRayResult {
498    /// Hit body handle.
499    pub body_id: u32,
500    /// Hit point in world space.
501    pub point: [f64; 3],
502    /// Surface normal at hit.
503    pub normal: [f64; 3],
504    /// Distance from ray origin to hit point.
505    pub distance: f64,
506}
507
508// ─────────────────────────────────────────────────────────────────────────────
509// Joint types
510// ─────────────────────────────────────────────────────────────────────────────
511
512/// Supported joint types.
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514pub enum PyJointType {
515    /// Ball-and-socket joint (3 rotational DOF).
516    Ball,
517    /// Hinge joint (1 rotational DOF).
518    Hinge,
519    /// Slider / prismatic joint (1 translational DOF).
520    Slider,
521    /// Fixed joint (0 DOF).
522    Fixed,
523    /// Generic 6-DOF joint.
524    Generic6Dof,
525}
526
527/// A joint connecting two bodies.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct PyJoint {
530    /// Joint handle.
531    pub id: u32,
532    /// Joint type.
533    pub joint_type: PyJointType,
534    /// Body A handle.
535    pub body_a: u32,
536    /// Body B handle.
537    pub body_b: u32,
538    /// Anchor on body A in local space.
539    pub anchor_a: [f64; 3],
540    /// Anchor on body B in local space.
541    pub anchor_b: [f64; 3],
542    /// Axis for hinge/slider (body A local space).
543    pub axis: [f64; 3],
544    /// Lower limit (angle in radians for hinge, distance for slider).
545    pub lower_limit: f64,
546    /// Upper limit.
547    pub upper_limit: f64,
548    /// Motor target speed.
549    pub motor_speed: f64,
550    /// Motor maximum force/torque.
551    pub motor_max_force: f64,
552    /// Whether the motor is enabled.
553    pub motor_enabled: bool,
554    /// Last computed force on body A `[fx, fy, fz]`.
555    pub reaction_force: [f64; 3],
556    /// Last computed torque on body A `[tx, ty, tz]`.
557    pub reaction_torque: [f64; 3],
558}
559
560impl PyJoint {
561    /// Create a new joint.
562    pub fn new(
563        id: u32,
564        joint_type: PyJointType,
565        body_a: u32,
566        body_b: u32,
567        anchor_a: [f64; 3],
568        anchor_b: [f64; 3],
569    ) -> Self {
570        Self {
571            id,
572            joint_type,
573            body_a,
574            body_b,
575            anchor_a,
576            anchor_b,
577            axis: [0.0, 1.0, 0.0],
578            lower_limit: f64::NEG_INFINITY,
579            upper_limit: f64::INFINITY,
580            motor_speed: 0.0,
581            motor_max_force: 0.0,
582            motor_enabled: false,
583            reaction_force: [0.0; 3],
584            reaction_torque: [0.0; 3],
585        }
586    }
587
588    /// Enable the motor.
589    pub fn enable_motor(&mut self, speed: f64, max_force: f64) {
590        self.motor_enabled = true;
591        self.motor_speed = speed;
592        self.motor_max_force = max_force;
593    }
594
595    /// Disable the motor.
596    pub fn disable_motor(&mut self) {
597        self.motor_enabled = false;
598    }
599}
600
601// ─────────────────────────────────────────────────────────────────────────────
602// PyJointSet
603// ─────────────────────────────────────────────────────────────────────────────
604
605/// A collection of joints.
606#[derive(Debug, Clone, Serialize, Deserialize, Default)]
607pub struct PyJointSet {
608    joints: Vec<Option<PyJoint>>,
609    next_id: u32,
610}
611
612impl PyJointSet {
613    /// Create an empty joint set.
614    pub fn new() -> Self {
615        Self::default()
616    }
617
618    /// Create and add a joint; returns its handle.
619    #[allow(clippy::too_many_arguments)]
620    pub fn create_joint(
621        &mut self,
622        joint_type: PyJointType,
623        body_a: u32,
624        body_b: u32,
625        anchor_a: [f64; 3],
626        anchor_b: [f64; 3],
627        _params: Option<serde_json::Value>,
628    ) -> u32 {
629        let id = self.next_id;
630        self.next_id += 1;
631        let joint = PyJoint::new(id, joint_type, body_a, body_b, anchor_a, anchor_b);
632        self.joints.push(Some(joint));
633        id
634    }
635
636    /// Remove a joint by handle.
637    pub fn remove(&mut self, id: u32) -> bool {
638        for slot in &mut self.joints {
639            if slot.as_ref().is_some_and(|j| j.id == id) {
640                *slot = None;
641                return true;
642            }
643        }
644        false
645    }
646
647    /// Get immutable reference.
648    pub fn get(&self, id: u32) -> Option<&PyJoint> {
649        self.joints.iter().flatten().find(|j| j.id == id)
650    }
651
652    /// Get mutable reference.
653    pub fn get_mut(&mut self, id: u32) -> Option<&mut PyJoint> {
654        self.joints.iter_mut().flatten().find(|j| j.id == id)
655    }
656
657    /// Set motor speed and max force.
658    pub fn set_motor(&mut self, id: u32, speed: f64, max_force: f64) -> bool {
659        if let Some(j) = self.get_mut(id) {
660            j.enable_motor(speed, max_force);
661            true
662        } else {
663            false
664        }
665    }
666
667    /// Get reaction forces and torques `(force, torque)`.
668    pub fn get_forces(&self, id: u32) -> Option<([f64; 3], [f64; 3])> {
669        self.get(id).map(|j| (j.reaction_force, j.reaction_torque))
670    }
671
672    /// Number of active joints.
673    pub fn len(&self) -> usize {
674        self.joints.iter().flatten().count()
675    }
676
677    /// True if empty.
678    pub fn is_empty(&self) -> bool {
679        self.len() == 0
680    }
681}
682
683// ─────────────────────────────────────────────────────────────────────────────
684// PyWorld
685// ─────────────────────────────────────────────────────────────────────────────
686
687/// A self-contained physics world managing bodies, colliders, and joints.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct PyWorld {
690    /// All rigid bodies.
691    pub bodies: PyRigidBodySet,
692    /// All colliders keyed by id.
693    colliders: Vec<PyCollider>,
694    /// All joints.
695    pub joints: PyJointSet,
696    /// Gravity vector.
697    pub gravity: [f64; 3],
698    /// Contacts from last step.
699    contacts: Vec<PyContact>,
700    /// Elapsed simulation time.
701    pub time: f64,
702    /// Next collider id.
703    next_collider_id: u32,
704}
705
706impl PyWorld {
707    /// Create a world with the given gravity.
708    pub fn new(gravity: [f64; 3]) -> Self {
709        Self {
710            bodies: PyRigidBodySet::new(),
711            colliders: Vec::new(),
712            joints: PyJointSet::new(),
713            gravity,
714            contacts: Vec::new(),
715            time: 0.0,
716            next_collider_id: 0,
717        }
718    }
719
720    /// Create a world with Earth gravity.
721    pub fn earth_gravity() -> Self {
722        Self::new([0.0, -9.81, 0.0])
723    }
724
725    /// Add a dynamic body; returns handle.
726    pub fn create_body(&mut self, mass: f64, position: [f64; 3]) -> u32 {
727        let body = PyRigidBody::new(0, mass, position);
728        self.bodies.add(body)
729    }
730
731    /// Add a static body; returns handle.
732    pub fn create_static_body(&mut self, position: [f64; 3]) -> u32 {
733        let body = PyRigidBody::new_static(0, position);
734        self.bodies.add(body)
735    }
736
737    /// Add a collider to a body; returns collider handle.
738    pub fn create_collider(&mut self, collider_desc: PyCollider) -> u32 {
739        let id = self.next_collider_id;
740        self.next_collider_id += 1;
741        let mut c = collider_desc;
742        c.id = id;
743        self.colliders.push(c);
744        id
745    }
746
747    /// Step the simulation by `dt` seconds.
748    pub fn step(&mut self, dt: f64) {
749        self.contacts.clear();
750        // Detect collisions (simple sphere-sphere broadphase mock)
751        let ids: Vec<u32> = self.bodies.iter().map(|b| b.id).collect();
752        for (i, &id_a) in ids.iter().enumerate() {
753            for &id_b in &ids[i + 1..] {
754                if let (Some(ba), Some(bb)) = (self.bodies.get(id_a), self.bodies.get(id_b)) {
755                    if ba.is_static && bb.is_static {
756                        continue;
757                    }
758                    let dx = [
759                        bb.position[0] - ba.position[0],
760                        bb.position[1] - ba.position[1],
761                        bb.position[2] - ba.position[2],
762                    ];
763                    let dist = vec3_length(dx);
764                    // Find colliders for these bodies
765                    let ra = self
766                        .colliders
767                        .iter()
768                        .filter(|c| c.body_id == id_a)
769                        .map(|c| c.bounding_radius())
770                        .fold(0.0f64, f64::max);
771                    let rb = self
772                        .colliders
773                        .iter()
774                        .filter(|c| c.body_id == id_b)
775                        .map(|c| c.bounding_radius())
776                        .fold(0.0f64, f64::max);
777                    if ra > 0.0 && rb > 0.0 && dist < ra + rb {
778                        let depth = ra + rb - dist;
779                        let normal = if dist > 1e-12 {
780                            [dx[0] / dist, dx[1] / dist, dx[2] / dist]
781                        } else {
782                            [0.0, 1.0, 0.0]
783                        };
784                        let midpoint = [
785                            (ba.position[0] + bb.position[0]) * 0.5,
786                            (ba.position[1] + bb.position[1]) * 0.5,
787                            (ba.position[2] + bb.position[2]) * 0.5,
788                        ];
789                        self.contacts.push(PyContact {
790                            body_a: id_a,
791                            body_b: id_b,
792                            point: midpoint,
793                            normal,
794                            depth,
795                            impulse: 0.0,
796                        });
797                    }
798                }
799            }
800        }
801        // Resolve contacts (impulse-based)
802        for contact in &self.contacts {
803            let inv_a = self
804                .bodies
805                .get(contact.body_a)
806                .map(|b| b.inv_mass())
807                .unwrap_or(0.0);
808            let inv_b = self
809                .bodies
810                .get(contact.body_b)
811                .map(|b| b.inv_mass())
812                .unwrap_or(0.0);
813            let denom = inv_a + inv_b;
814            if denom < 1e-15 {
815                continue;
816            }
817            let restitution = 0.3_f64;
818            let vel_a = self
819                .bodies
820                .get(contact.body_a)
821                .map(|b| b.velocity)
822                .unwrap_or([0.0; 3]);
823            let vel_b = self
824                .bodies
825                .get(contact.body_b)
826                .map(|b| b.velocity)
827                .unwrap_or([0.0; 3]);
828            let rel_vel = [
829                vel_a[0] - vel_b[0],
830                vel_a[1] - vel_b[1],
831                vel_a[2] - vel_b[2],
832            ];
833            let vn = vec3_dot(rel_vel, contact.normal);
834            if vn > 0.0 {
835                continue; // Separating
836            }
837            let j_scalar = -(1.0 + restitution) * vn / denom;
838            let impulse = vec3_scale(contact.normal, j_scalar);
839            if let Some(ba) = self.bodies.get_mut(contact.body_a) {
840                ba.apply_impulse(impulse);
841            }
842            if let Some(bb) = self.bodies.get_mut(contact.body_b) {
843                bb.apply_impulse(vec3_scale(impulse, -1.0));
844            }
845            // Positional correction
846            let correction_scale = (contact.depth - 0.01_f64).max(0.0) / denom * 0.4;
847            let correction = vec3_scale(contact.normal, correction_scale);
848            if let Some(ba) = self.bodies.get_mut(contact.body_a)
849                && !ba.is_static
850            {
851                let corr = vec3_scale(correction, ba.inv_mass());
852                ba.position = vec3_add(ba.position, corr);
853            }
854            if let Some(bb) = self.bodies.get_mut(contact.body_b)
855                && !bb.is_static
856            {
857                let corr = vec3_scale(correction, bb.inv_mass());
858                bb.position = [
859                    bb.position[0] - corr[0],
860                    bb.position[1] - corr[1],
861                    bb.position[2] - corr[2],
862                ];
863            }
864        }
865        // Integrate
866        self.bodies.integrate_all(dt, self.gravity);
867        self.time += dt;
868    }
869
870    /// Cast a ray and return all hits sorted by distance.
871    pub fn query_ray(
872        &self,
873        origin: [f64; 3],
874        direction: [f64; 3],
875        max_dist: f64,
876    ) -> Vec<PyRayResult> {
877        let mut results = Vec::new();
878        let dir_len = vec3_length(direction);
879        if dir_len < 1e-12 {
880            return results;
881        }
882        let dir_n = vec3_scale(direction, 1.0 / dir_len);
883        for body in self.bodies.iter() {
884            // Sphere test against body bounding sphere
885            let to_body = [
886                body.position[0] - origin[0],
887                body.position[1] - origin[1],
888                body.position[2] - origin[2],
889            ];
890            let tc = vec3_dot(to_body, dir_n);
891            if tc < 0.0 {
892                continue;
893            }
894            let closest = [
895                origin[0] + dir_n[0] * tc,
896                origin[1] + dir_n[1] * tc,
897                origin[2] + dir_n[2] * tc,
898            ];
899            let miss = [
900                closest[0] - body.position[0],
901                closest[1] - body.position[1],
902                closest[2] - body.position[2],
903            ];
904            // Use max collider radius as bounding sphere
905            let r = self
906                .colliders
907                .iter()
908                .filter(|c| c.body_id == body.id)
909                .map(|c| c.bounding_radius())
910                .fold(0.5_f64, f64::max);
911            let miss_dist = vec3_length(miss);
912            if miss_dist > r {
913                continue;
914            }
915            let dist = tc - (r * r - miss_dist * miss_dist).max(0.0).sqrt();
916            if dist > max_dist {
917                continue;
918            }
919            let hit_point = [
920                origin[0] + dir_n[0] * dist,
921                origin[1] + dir_n[1] * dist,
922                origin[2] + dir_n[2] * dist,
923            ];
924            let normal = {
925                let n = [
926                    hit_point[0] - body.position[0],
927                    hit_point[1] - body.position[1],
928                    hit_point[2] - body.position[2],
929                ];
930                let nl = vec3_length(n);
931                if nl > 1e-12 {
932                    vec3_scale(n, 1.0 / nl)
933                } else {
934                    [0.0, 1.0, 0.0]
935                }
936            };
937            results.push(PyRayResult {
938                body_id: body.id,
939                point: hit_point,
940                normal,
941                distance: dist,
942            });
943        }
944        results.sort_by(|a, b| {
945            a.distance
946                .partial_cmp(&b.distance)
947                .unwrap_or(std::cmp::Ordering::Equal)
948        });
949        results
950    }
951
952    /// Query all pairs of overlapping colliders.
953    pub fn query_overlap(&self) -> Vec<(u32, u32)> {
954        let mut pairs = Vec::new();
955        for i in 0..self.colliders.len() {
956            for j in (i + 1)..self.colliders.len() {
957                let ca = &self.colliders[i];
958                let cb = &self.colliders[j];
959                if ca.body_id == cb.body_id {
960                    continue;
961                }
962                let ba = self.bodies.get(ca.body_id);
963                let bb = self.bodies.get(cb.body_id);
964                if let (Some(ba), Some(bb)) = (ba, bb) {
965                    let d = vec3_length([
966                        bb.position[0] - ba.position[0],
967                        bb.position[1] - ba.position[1],
968                        bb.position[2] - ba.position[2],
969                    ]);
970                    if d < ca.bounding_radius() + cb.bounding_radius() {
971                        pairs.push((ca.body_id, cb.body_id));
972                    }
973                }
974            }
975        }
976        pairs
977    }
978
979    /// Return contacts from the last step.
980    pub fn get_contacts(&self) -> &[PyContact] {
981        &self.contacts
982    }
983}
984
985// ─────────────────────────────────────────────────────────────────────────────
986// PyPhysicsPipeline
987// ─────────────────────────────────────────────────────────────────────────────
988
989/// Configuration for the physics pipeline.
990#[derive(Debug, Clone, Serialize, Deserialize)]
991pub struct PyPipelineConfig {
992    /// Maximum solver iterations per step.
993    pub max_iterations: u32,
994    /// Velocity tolerance for sleep (m/s).
995    pub sleep_threshold: f64,
996    /// Time (s) body must be below sleep threshold before sleeping.
997    pub sleep_time: f64,
998    /// Gravity vector.
999    pub gravity: [f64; 3],
1000    /// Substeps per `step()` call.
1001    pub substeps: u32,
1002}
1003
1004impl Default for PyPipelineConfig {
1005    fn default() -> Self {
1006        Self {
1007            max_iterations: 10,
1008            sleep_threshold: 0.05,
1009            sleep_time: 1.0,
1010            gravity: [0.0, -9.81, 0.0],
1011            substeps: 1,
1012        }
1013    }
1014}
1015
1016/// A physics pipeline orchestrating broadphase, narrowphase, solver, and integration.
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1018pub struct PyPhysicsPipeline {
1019    /// Pipeline configuration.
1020    pub config: PyPipelineConfig,
1021    /// Internal world.
1022    pub world: PyWorld,
1023    /// Simulation step counter.
1024    pub step_count: u64,
1025    /// Broadphase collision pair count (last step).
1026    pub last_broadphase_pairs: usize,
1027    /// Narrowphase contact count (last step).
1028    pub last_contact_count: usize,
1029    /// Solver iterations used (last step).
1030    pub last_solver_iterations: u32,
1031}
1032
1033impl PyPhysicsPipeline {
1034    /// Create a new pipeline with default config.
1035    pub fn new() -> Self {
1036        let config = PyPipelineConfig::default();
1037        let world = PyWorld::new(config.gravity);
1038        Self {
1039            config,
1040            world,
1041            step_count: 0,
1042            last_broadphase_pairs: 0,
1043            last_contact_count: 0,
1044            last_solver_iterations: 0,
1045        }
1046    }
1047
1048    /// Create with custom config.
1049    pub fn with_config(config: PyPipelineConfig) -> Self {
1050        let world = PyWorld::new(config.gravity);
1051        Self {
1052            config,
1053            world,
1054            step_count: 0,
1055            last_broadphase_pairs: 0,
1056            last_contact_count: 0,
1057            last_solver_iterations: 0,
1058        }
1059    }
1060
1061    /// Advance the simulation by `dt` seconds.
1062    pub fn step(&mut self, dt: f64) {
1063        let sub_dt = dt / self.config.substeps as f64;
1064        for _ in 0..self.config.substeps {
1065            self.world.step(sub_dt);
1066        }
1067        self.last_contact_count = self.world.contacts.len();
1068        self.last_broadphase_pairs = self.last_contact_count;
1069        self.last_solver_iterations =
1070            self.config
1071                .max_iterations
1072                .min(if self.last_contact_count > 0 {
1073                    self.config.max_iterations
1074                } else {
1075                    0
1076                });
1077        self.step_count += 1;
1078    }
1079
1080    /// Set maximum solver iterations.
1081    pub fn set_max_iterations(&mut self, n: u32) {
1082        self.config.max_iterations = n;
1083    }
1084
1085    /// Access the world.
1086    pub fn world(&self) -> &PyWorld {
1087        &self.world
1088    }
1089
1090    /// Mutable access to the world.
1091    pub fn world_mut(&mut self) -> &mut PyWorld {
1092        &mut self.world
1093    }
1094}
1095
1096impl Default for PyPhysicsPipeline {
1097    fn default() -> Self {
1098        Self::new()
1099    }
1100}
1101
1102// ─────────────────────────────────────────────────────────────────────────────
1103// PyWakeEvent
1104// ─────────────────────────────────────────────────────────────────────────────
1105
1106/// Reason a body woke up.
1107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1108pub enum WakeReason {
1109    /// Woken by an applied force or impulse.
1110    Force,
1111    /// Woken by a contact.
1112    Contact,
1113    /// Woken by a script/user request.
1114    Script,
1115}
1116
1117/// Wake event emitted when a body transitions from sleeping to awake.
1118#[derive(Debug, Clone, Serialize, Deserialize)]
1119pub struct PyWakeEvent {
1120    /// The body that woke up.
1121    pub body_id: u32,
1122    /// Reason for waking.
1123    pub reason: WakeReason,
1124    /// Simulation time at wake.
1125    pub time: f64,
1126}
1127
1128impl PyWakeEvent {
1129    /// Create a new wake event.
1130    pub fn new(body_id: u32, reason: WakeReason, time: f64) -> Self {
1131        Self {
1132            body_id,
1133            reason,
1134            time,
1135        }
1136    }
1137}
1138
1139/// Manages sleep/wake events for a body set.
1140#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1141pub struct PySleepManager {
1142    /// Pending wake events.
1143    pub events: Vec<PyWakeEvent>,
1144    /// Sleep timers per body id.
1145    sleep_timers: Vec<(u32, f64)>,
1146}
1147
1148impl PySleepManager {
1149    /// Create a new sleep manager.
1150    pub fn new() -> Self {
1151        Self::default()
1152    }
1153
1154    /// Force-wake a body and emit a wake event.
1155    pub fn wake_body(&mut self, body: &mut PyRigidBody, reason: WakeReason, time: f64) {
1156        if body.sleeping {
1157            body.sleeping = false;
1158            self.events.push(PyWakeEvent::new(body.id, reason, time));
1159        }
1160    }
1161
1162    /// Put a body to sleep immediately.
1163    pub fn sleep_body(&mut self, body: &mut PyRigidBody) {
1164        body.sleeping = true;
1165        body.velocity = [0.0; 3];
1166        body.ang_vel = [0.0; 3];
1167    }
1168
1169    /// Update sleep timers for all bodies. Returns ids that should sleep.
1170    pub fn update(
1171        &mut self,
1172        bodies: &mut PyRigidBodySet,
1173        threshold: f64,
1174        sleep_time: f64,
1175        dt: f64,
1176    ) {
1177        let ids: Vec<u32> = bodies.iter().map(|b| b.id).collect();
1178        for id in ids {
1179            if let Some(body) = bodies.get_mut(id) {
1180                if body.is_static || body.is_kinematic {
1181                    continue;
1182                }
1183                let speed = vec3_length(body.velocity) + vec3_length(body.ang_vel);
1184                if speed < threshold {
1185                    let timer = self.sleep_timers.iter_mut().find(|(bid, _)| *bid == id);
1186                    match timer {
1187                        Some((_, t)) => {
1188                            *t += dt;
1189                            if *t >= sleep_time && !body.sleeping {
1190                                body.sleeping = true;
1191                            }
1192                        }
1193                        None => {
1194                            self.sleep_timers.push((id, dt));
1195                        }
1196                    }
1197                } else {
1198                    self.sleep_timers.retain(|(bid, _)| *bid != id);
1199                }
1200            }
1201        }
1202    }
1203
1204    /// Drain all pending events.
1205    pub fn drain_events(&mut self) -> Vec<PyWakeEvent> {
1206        std::mem::take(&mut self.events)
1207    }
1208}
1209
1210// ─────────────────────────────────────────────────────────────────────────────
1211// PyBodyFilter
1212// ─────────────────────────────────────────────────────────────────────────────
1213
1214/// Filter criteria for selecting bodies.
1215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1216pub struct PyBodyFilter {
1217    /// Include active (awake) bodies.
1218    pub include_active: bool,
1219    /// Include sleeping bodies.
1220    pub include_sleeping: bool,
1221    /// Include static bodies.
1222    pub include_static: bool,
1223    /// Include kinematic bodies.
1224    pub include_kinematic: bool,
1225}
1226
1227impl PyBodyFilter {
1228    /// Filter that matches all bodies.
1229    pub fn all() -> Self {
1230        Self {
1231            include_active: true,
1232            include_sleeping: true,
1233            include_static: true,
1234            include_kinematic: true,
1235        }
1236    }
1237
1238    /// Filter that matches only dynamic active bodies.
1239    pub fn active_dynamic() -> Self {
1240        Self {
1241            include_active: true,
1242            include_sleeping: false,
1243            include_static: false,
1244            include_kinematic: false,
1245        }
1246    }
1247
1248    /// Test if a body passes this filter.
1249    pub fn matches(&self, body: &PyRigidBody) -> bool {
1250        if body.is_static {
1251            return self.include_static;
1252        }
1253        if body.is_kinematic {
1254            return self.include_kinematic;
1255        }
1256        if body.sleeping {
1257            self.include_sleeping
1258        } else {
1259            self.include_active
1260        }
1261    }
1262
1263    /// Filter a body set; returns matching body ids.
1264    pub fn filter_by_type<'a>(&self, bodies: &'a PyRigidBodySet) -> Vec<&'a PyRigidBody> {
1265        bodies.iter().filter(|b| self.matches(b)).collect()
1266    }
1267}
1268
1269// ─────────────────────────────────────────────────────────────────────────────
1270// PyIslandStats
1271// ─────────────────────────────────────────────────────────────────────────────
1272
1273/// Statistics for a simulation island.
1274///
1275/// An island is a group of bodies connected by contacts or constraints.
1276#[derive(Debug, Clone, Serialize, Deserialize)]
1277pub struct PyIslandStats {
1278    /// Island identifier.
1279    pub island_id: u32,
1280    /// Number of bodies in this island.
1281    pub body_count: usize,
1282    /// Number of constraints (contacts + joints) in this island.
1283    pub constraint_count: usize,
1284    /// Accumulated time all bodies have been below sleep threshold (seconds).
1285    pub sleep_timer: f64,
1286    /// Total kinetic energy of the island.
1287    pub energy: f64,
1288}
1289
1290impl PyIslandStats {
1291    /// Create island stats.
1292    pub fn new(island_id: u32, body_count: usize, constraint_count: usize) -> Self {
1293        Self {
1294            island_id,
1295            body_count,
1296            constraint_count,
1297            sleep_timer: 0.0,
1298            energy: 0.0,
1299        }
1300    }
1301
1302    /// True if the island is eligible to sleep.
1303    pub fn can_sleep(&self, threshold: f64, sleep_time: f64) -> bool {
1304        self.energy < threshold && self.sleep_timer >= sleep_time
1305    }
1306}
1307
1308/// Compute island statistics from a body set (simple connected-components mock).
1309pub fn compute_island_stats(bodies: &PyRigidBodySet) -> Vec<PyIslandStats> {
1310    // Trivial: one island per body (no connectivity info here)
1311    bodies
1312        .iter()
1313        .enumerate()
1314        .map(|(i, b)| {
1315            let mut stats = PyIslandStats::new(i as u32, 1, 0);
1316            stats.energy = b.kinetic_energy();
1317            stats
1318        })
1319        .collect()
1320}
1321
1322// ─────────────────────────────────────────────────────────────────────────────
1323// PyBatchForce
1324// ─────────────────────────────────────────────────────────────────────────────
1325
1326/// A vectorized (batch) force application request.
1327#[derive(Debug, Clone, Serialize, Deserialize)]
1328pub struct PyBatchForce {
1329    /// Target body handles.
1330    pub body_ids: Vec<u32>,
1331    /// Forces to apply (parallel to `body_ids`).
1332    pub forces: Vec<[f64; 3]>,
1333    /// Optional torques (parallel to `body_ids`; zero if absent).
1334    pub torques: Option<Vec<[f64; 3]>>,
1335}
1336
1337impl PyBatchForce {
1338    /// Create a batch of forces.
1339    pub fn new(body_ids: Vec<u32>, forces: Vec<[f64; 3]>) -> Self {
1340        Self {
1341            body_ids,
1342            forces,
1343            torques: None,
1344        }
1345    }
1346
1347    /// Add torques to the batch.
1348    pub fn with_torques(mut self, torques: Vec<[f64; 3]>) -> Self {
1349        self.torques = Some(torques);
1350        self
1351    }
1352
1353    /// Apply all forces (and torques if present) to the given body set.
1354    pub fn apply(&self, bodies: &mut PyRigidBodySet) {
1355        for (idx, &id) in self.body_ids.iter().enumerate() {
1356            if let Some(body) = bodies.get_mut(id) {
1357                if idx < self.forces.len() {
1358                    body.apply_force(self.forces[idx]);
1359                }
1360                if let Some(torques) = &self.torques
1361                    && idx < torques.len()
1362                {
1363                    body.apply_torque(torques[idx]);
1364                }
1365            }
1366        }
1367    }
1368
1369    /// Validate that body_ids and forces have equal length.
1370    pub fn is_valid(&self) -> bool {
1371        self.body_ids.len() == self.forces.len()
1372    }
1373}
1374
1375// ─────────────────────────────────────────────────────────────────────────────
1376// Tests
1377// ─────────────────────────────────────────────────────────────────────────────
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382
1383    // --- PyRigidBody ---
1384
1385    #[test]
1386    fn test_rigid_body_new() {
1387        let b = PyRigidBody::new(1, 5.0, [0.0, 10.0, 0.0]);
1388        assert_eq!(b.mass, 5.0);
1389        assert_eq!(b.position, [0.0, 10.0, 0.0]);
1390        assert!(!b.sleeping);
1391        assert!(!b.is_static);
1392    }
1393
1394    #[test]
1395    fn test_rigid_body_static() {
1396        let b = PyRigidBody::new_static(2, [1.0, 0.0, 0.0]);
1397        assert!(b.is_static);
1398        assert_eq!(b.inv_mass(), 0.0);
1399    }
1400
1401    #[test]
1402    fn test_rigid_body_kinematic() {
1403        let b = PyRigidBody::new_kinematic(3, 2.0, [0.0; 3]);
1404        assert!(b.is_kinematic);
1405        assert!(!b.is_static);
1406    }
1407
1408    #[test]
1409    fn test_apply_force() {
1410        let mut b = PyRigidBody::new(1, 1.0, [0.0; 3]);
1411        b.apply_force([1.0, 0.0, 0.0]);
1412        assert!((b.accumulated_force[0] - 1.0).abs() < 1e-12);
1413    }
1414
1415    #[test]
1416    fn test_apply_force_static_ignored() {
1417        let mut b = PyRigidBody::new_static(1, [0.0; 3]);
1418        b.apply_force([999.0, 0.0, 0.0]);
1419        assert_eq!(b.accumulated_force[0], 0.0);
1420    }
1421
1422    #[test]
1423    fn test_apply_impulse() {
1424        let mut b = PyRigidBody::new(1, 2.0, [0.0; 3]);
1425        b.apply_impulse([4.0, 0.0, 0.0]);
1426        assert!((b.velocity[0] - 2.0).abs() < 1e-12);
1427    }
1428
1429    #[test]
1430    fn test_apply_impulse_static_ignored() {
1431        let mut b = PyRigidBody::new_static(1, [0.0; 3]);
1432        b.apply_impulse([999.0, 0.0, 0.0]);
1433        assert_eq!(b.velocity[0], 0.0);
1434    }
1435
1436    #[test]
1437    fn test_kinetic_energy() {
1438        let mut b = PyRigidBody::new(1, 2.0, [0.0; 3]);
1439        b.velocity = [1.0, 0.0, 0.0];
1440        // KE_lin = 0.5 * 2 * 1 = 1
1441        assert!((b.kinetic_energy() - 1.0).abs() < 0.01);
1442    }
1443
1444    #[test]
1445    fn test_integrate_gravity() {
1446        let mut b = PyRigidBody::new(1, 1.0, [0.0, 10.0, 0.0]);
1447        let gravity = [0.0, -9.81, 0.0];
1448        b.integrate(0.1, gravity);
1449        // Position should decrease (fallen)
1450        assert!(b.position[1] < 10.0);
1451    }
1452
1453    #[test]
1454    fn test_linear_momentum() {
1455        let mut b = PyRigidBody::new(1, 3.0, [0.0; 3]);
1456        b.velocity = [2.0, 0.0, 0.0];
1457        let p = b.linear_momentum();
1458        assert!((p[0] - 6.0).abs() < 1e-12);
1459    }
1460
1461    // --- PyRigidBodySet ---
1462
1463    #[test]
1464    fn test_body_set_add_remove() {
1465        let mut set = PyRigidBodySet::new();
1466        let id = set.add(PyRigidBody::new(0, 1.0, [0.0; 3]));
1467        assert_eq!(set.len(), 1);
1468        assert!(set.remove(id));
1469        assert_eq!(set.len(), 0);
1470    }
1471
1472    #[test]
1473    fn test_body_set_get() {
1474        let mut set = PyRigidBodySet::new();
1475        let id = set.add(PyRigidBody::new(0, 5.0, [1.0, 2.0, 3.0]));
1476        let b = set.get(id).unwrap();
1477        assert_eq!(b.mass, 5.0);
1478    }
1479
1480    #[test]
1481    fn test_body_set_is_empty() {
1482        let set = PyRigidBodySet::new();
1483        assert!(set.is_empty());
1484    }
1485
1486    #[test]
1487    fn test_body_set_total_ke() {
1488        let mut set = PyRigidBodySet::new();
1489        let id = set.add(PyRigidBody::new(0, 2.0, [0.0; 3]));
1490        let b = set.get_mut(id).unwrap();
1491        b.velocity = [1.0, 0.0, 0.0];
1492        let ke = set.total_kinetic_energy();
1493        assert!(ke > 0.0);
1494    }
1495
1496    // --- PyCollider ---
1497
1498    #[test]
1499    fn test_collider_sphere() {
1500        let c = PyCollider::sphere(0, 1, 0.5);
1501        assert_eq!(c.bounding_radius(), 0.5);
1502        assert!(!c.is_sensor);
1503    }
1504
1505    #[test]
1506    fn test_collider_box() {
1507        let c = PyCollider::box_collider(0, 1, [1.0, 2.0, 3.0]);
1508        assert!(c.bounding_radius() > 0.0);
1509    }
1510
1511    #[test]
1512    fn test_collider_capsule() {
1513        let c = PyCollider::capsule(0, 1, 1.0, 0.5);
1514        assert_eq!(c.bounding_radius(), 1.5);
1515    }
1516
1517    #[test]
1518    fn test_collider_sensor() {
1519        let c = PyCollider::sphere(0, 1, 0.3).as_sensor();
1520        assert!(c.is_sensor);
1521    }
1522
1523    // --- PyWorld ---
1524
1525    #[test]
1526    fn test_world_step_gravity() {
1527        let mut w = PyWorld::earth_gravity();
1528        let id = w.create_body(1.0, [0.0, 10.0, 0.0]);
1529        w.step(0.1);
1530        let pos = w.bodies.get_position(id).unwrap();
1531        assert!(pos[1] < 10.0);
1532    }
1533
1534    #[test]
1535    fn test_world_static_body_unmoved() {
1536        let mut w = PyWorld::earth_gravity();
1537        let id = w.create_static_body([0.0, 0.0, 0.0]);
1538        w.step(1.0);
1539        let pos = w.bodies.get_position(id).unwrap();
1540        assert_eq!(pos, [0.0, 0.0, 0.0]);
1541    }
1542
1543    #[test]
1544    fn test_world_ray_cast() {
1545        let mut w = PyWorld::earth_gravity();
1546        let id = w.create_body(1.0, [0.0, 0.0, 5.0]);
1547        w.create_collider(PyCollider::sphere(0, id, 1.0));
1548        let hits = w.query_ray([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], 100.0);
1549        assert!(!hits.is_empty());
1550    }
1551
1552    #[test]
1553    fn test_world_get_contacts() {
1554        let mut w = PyWorld::new([0.0; 3]);
1555        let a = w.create_body(1.0, [0.0; 3]);
1556        let b = w.create_body(1.0, [0.5, 0.0, 0.0]);
1557        w.create_collider(PyCollider::sphere(0, a, 0.4));
1558        w.create_collider(PyCollider::sphere(0, b, 0.4));
1559        w.step(0.001);
1560        let contacts = w.get_contacts();
1561        assert!(!contacts.is_empty());
1562    }
1563
1564    // --- PyJointSet ---
1565
1566    #[test]
1567    fn test_joint_set_create_remove() {
1568        let mut js = PyJointSet::new();
1569        let id = js.create_joint(PyJointType::Hinge, 0, 1, [0.0; 3], [0.0; 3], None);
1570        assert_eq!(js.len(), 1);
1571        assert!(js.remove(id));
1572        assert_eq!(js.len(), 0);
1573    }
1574
1575    #[test]
1576    fn test_joint_set_motor() {
1577        let mut js = PyJointSet::new();
1578        let id = js.create_joint(PyJointType::Hinge, 0, 1, [0.0; 3], [0.0; 3], None);
1579        assert!(js.set_motor(id, 5.0, 100.0));
1580        let j = js.get(id).unwrap();
1581        assert!(j.motor_enabled);
1582        assert_eq!(j.motor_speed, 5.0);
1583    }
1584
1585    // --- PyPhysicsPipeline ---
1586
1587    #[test]
1588    fn test_pipeline_step() {
1589        let mut pipe = PyPhysicsPipeline::new();
1590        let _id = pipe.world_mut().create_body(1.0, [0.0, 5.0, 0.0]);
1591        pipe.step(0.016);
1592        assert_eq!(pipe.step_count, 1);
1593    }
1594
1595    #[test]
1596    fn test_pipeline_max_iterations() {
1597        let mut pipe = PyPhysicsPipeline::new();
1598        pipe.set_max_iterations(5);
1599        assert_eq!(pipe.config.max_iterations, 5);
1600    }
1601
1602    // --- PyBodyFilter ---
1603
1604    #[test]
1605    fn test_body_filter_all() {
1606        let f = PyBodyFilter::all();
1607        let static_b = PyRigidBody::new_static(1, [0.0; 3]);
1608        assert!(f.matches(&static_b));
1609        let dyn_b = PyRigidBody::new(2, 1.0, [0.0; 3]);
1610        assert!(f.matches(&dyn_b));
1611    }
1612
1613    #[test]
1614    fn test_body_filter_active_only() {
1615        let f = PyBodyFilter::active_dynamic();
1616        let mut b = PyRigidBody::new(1, 1.0, [0.0; 3]);
1617        b.sleeping = false;
1618        assert!(f.matches(&b));
1619        b.sleeping = true;
1620        assert!(!f.matches(&b));
1621    }
1622
1623    // --- PyIslandStats ---
1624
1625    #[test]
1626    fn test_island_stats_new() {
1627        let s = PyIslandStats::new(0, 5, 4);
1628        assert_eq!(s.body_count, 5);
1629        assert!(!s.can_sleep(0.1, 1.0));
1630    }
1631
1632    // --- PyBatchForce ---
1633
1634    #[test]
1635    fn test_batch_force_apply() {
1636        let mut set = PyRigidBodySet::new();
1637        let a = set.add(PyRigidBody::new(0, 1.0, [0.0; 3]));
1638        let b = set.add(PyRigidBody::new(0, 1.0, [1.0, 0.0, 0.0]));
1639        let batch = PyBatchForce::new(vec![a, b], vec![[10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]);
1640        assert!(batch.is_valid());
1641        batch.apply(&mut set);
1642        assert!((set.get(a).unwrap().accumulated_force[0] - 10.0).abs() < 1e-12);
1643        assert!((set.get(b).unwrap().accumulated_force[1] - 10.0).abs() < 1e-12);
1644    }
1645
1646    #[test]
1647    fn test_batch_force_invalid() {
1648        let batch = PyBatchForce::new(vec![0, 1], vec![[1.0, 0.0, 0.0]]);
1649        assert!(!batch.is_valid());
1650    }
1651
1652    // --- PySleepManager ---
1653
1654    #[test]
1655    fn test_sleep_manager_wake() {
1656        let mut mgr = PySleepManager::new();
1657        let mut set = PyRigidBodySet::new();
1658        let id = set.add(PyRigidBody::new(0, 1.0, [0.0; 3]));
1659        let body = set.get_mut(id).unwrap();
1660        body.sleeping = true;
1661        mgr.wake_body(body, WakeReason::Script, 0.0);
1662        assert!(!body.sleeping);
1663        assert_eq!(mgr.events.len(), 1);
1664    }
1665
1666    #[test]
1667    fn test_sleep_manager_sleep() {
1668        let mut mgr = PySleepManager::new();
1669        let mut set = PyRigidBodySet::new();
1670        let id = set.add(PyRigidBody::new(0, 1.0, [0.0; 3]));
1671        let body = set.get_mut(id).unwrap();
1672        body.velocity = [10.0, 0.0, 0.0];
1673        mgr.sleep_body(body);
1674        assert!(body.sleeping);
1675        assert_eq!(body.velocity, [0.0; 3]);
1676    }
1677
1678    #[test]
1679    fn test_compute_island_stats() {
1680        let mut set = PyRigidBodySet::new();
1681        set.add(PyRigidBody::new(0, 1.0, [0.0; 3]));
1682        set.add(PyRigidBody::new(0, 2.0, [1.0, 0.0, 0.0]));
1683        let islands = compute_island_stats(&set);
1684        assert_eq!(islands.len(), 2);
1685    }
1686}