Skip to main content

oxiphysics_python/world_api/
mod.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! High-level physics world API designed for Python consumers.
5//!
6//! `PyPhysicsWorld` provides the primary interface: add/remove bodies, apply
7//! forces and impulses, step the simulation, and query state. Handles are
8//! `u32` integers for easy FFI transmission. All state uses plain arrays and
9//! primitive types; no nalgebra types are exposed through the public API.
10
11#![allow(missing_docs)]
12
13// Sub-modules
14mod constraints;
15mod extensions;
16mod fem;
17mod geometry;
18mod lbm;
19mod materials;
20mod math_helpers;
21mod py_bindings;
22mod sph;
23mod stats;
24
25#[cfg(test)]
26mod tests;
27
28// Re-exports
29pub use constraints::{ConstraintType, PyConstraint};
30pub use extensions::{ContactPair, InertiaTensor, PyRigidBody};
31pub use fem::{FemBarElement, PyFemAssembly};
32pub use geometry::{PyAabb, PyConvexHull, PySphere};
33pub use lbm::{PyLbmConfig, PyLbmGrid};
34pub use materials::{MaterialClass, PyMaterial};
35pub use math_helpers::{
36    array_to_vec3, quat_conjugate, quat_from_axis_angle, quat_mul, quat_normalize, quat_rotate_vec,
37    vec3_to_array,
38};
39pub use py_bindings::{
40    MaterialProperties, PyFemBinding, PyLbmBinding, PyMdBinding, PySphBinding, py_p_wave_speed,
41    py_query_material, py_s_wave_speed,
42};
43pub use sph::{PySphConfig, PySphSim};
44pub use stats::SimStats;
45
46use crate::types::{
47    PyColliderDesc, PyColliderShape, PyContactResult, PyRigidBodyConfig, PyRigidBodyDesc,
48    PySimConfig, PyVec3,
49};
50use serde::{Deserialize, Serialize};
51
52// ---------------------------------------------------------------------------
53// Internal body state
54// ---------------------------------------------------------------------------
55
56/// Full internal state of a single rigid body.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct InternalBody {
59    /// Position `[x, y, z]`.
60    position: [f64; 3],
61    /// Linear velocity `[vx, vy, vz]`.
62    velocity: [f64; 3],
63    /// Orientation quaternion `[x, y, z, w]`.
64    orientation: [f64; 4],
65    /// Angular velocity `[wx, wy, wz]`.
66    angular_velocity: [f64; 3],
67    /// Mass in kilograms.
68    mass: f64,
69    /// Whether this body is static (immovable).
70    is_static: bool,
71    /// Whether this body is kinematic (moved manually).
72    is_kinematic: bool,
73    /// Whether this body is currently sleeping.
74    is_sleeping: bool,
75    /// Whether this body participates in sleeping.
76    can_sleep: bool,
77    /// Friction coefficient.
78    friction: f64,
79    /// Restitution coefficient.
80    restitution: f64,
81    /// Linear damping.
82    linear_damping: f64,
83    /// Angular damping.
84    angular_damping: f64,
85    /// Accumulated force for this step `[fx, fy, fz]`.
86    accumulated_force: [f64; 3],
87    /// Accumulated torque for this step `[tx, ty, tz]`.
88    accumulated_torque: [f64; 3],
89    /// Velocity-below-threshold counter (for sleep detection).
90    sleep_timer: f64,
91    /// Attached collider shapes.
92    shapes: Vec<PyColliderShape>,
93    /// Optional tag.
94    tag: Option<String>,
95}
96
97impl InternalBody {
98    fn from_config(config: &PyRigidBodyConfig) -> Self {
99        Self {
100            position: config.position,
101            velocity: config.velocity,
102            orientation: config.orientation,
103            angular_velocity: config.angular_velocity,
104            mass: config.mass,
105            is_static: config.is_static,
106            is_kinematic: config.is_kinematic,
107            is_sleeping: false,
108            can_sleep: config.can_sleep,
109            friction: config.friction,
110            restitution: config.restitution,
111            linear_damping: config.linear_damping,
112            angular_damping: config.angular_damping,
113            accumulated_force: [0.0; 3],
114            accumulated_torque: [0.0; 3],
115            sleep_timer: 0.0,
116            shapes: config.shapes.clone(),
117            tag: config.tag.clone(),
118        }
119    }
120
121    /// Effective inverse mass (0 for static or infinite-mass).
122    fn inv_mass(&self) -> f64 {
123        if self.is_static || self.is_kinematic || self.mass <= 0.0 {
124            0.0
125        } else {
126            1.0 / self.mass
127        }
128    }
129
130    /// Whether this body is mobile (can be accelerated by forces).
131    fn is_dynamic(&self) -> bool {
132        !self.is_static && !self.is_kinematic
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Slot-based storage with generation counters
138// ---------------------------------------------------------------------------
139
140#[derive(Debug, Clone)]
141struct Slot {
142    body: Option<InternalBody>,
143    generation: u32,
144}
145
146impl Slot {
147    #[allow(dead_code)]
148    fn empty() -> Self {
149        Self {
150            body: None,
151            generation: 0,
152        }
153    }
154}
155
156// ---------------------------------------------------------------------------
157// PyPhysicsWorld
158// ---------------------------------------------------------------------------
159
160/// A self-contained physics simulation world.
161///
162/// Provides Python-friendly methods using integer handles (`u32`) and plain
163/// array types. Internally uses a slot-based arena with generation counters
164/// so that stale handles are detected as absent bodies.
165#[derive(Debug, Clone)]
166pub struct PyPhysicsWorld {
167    /// Body storage slots.
168    slots: Vec<Slot>,
169    /// Free-list of slot indices available for reuse.
170    free_list: Vec<u32>,
171    /// Global simulation configuration.
172    config: PySimConfig,
173    /// Accumulated simulation time.
174    time: f64,
175    /// Contacts detected in the most recent step.
176    contacts: Vec<PyContactResult>,
177    /// Active constraints.
178    constraints: Vec<PyConstraint>,
179}
180
181impl PyPhysicsWorld {
182    // -----------------------------------------------------------------------
183    // Construction
184    // -----------------------------------------------------------------------
185
186    /// Create a new physics world with the given simulation config.
187    pub fn new(config: PySimConfig) -> Self {
188        Self {
189            slots: Vec::new(),
190            free_list: Vec::new(),
191            config,
192            time: 0.0,
193            contacts: Vec::new(),
194            constraints: Vec::new(),
195        }
196    }
197
198    // -----------------------------------------------------------------------
199    // Body management
200    // -----------------------------------------------------------------------
201
202    /// Add a new rigid body described by `config`. Returns a u32 handle.
203    pub fn add_rigid_body(&mut self, config: PyRigidBodyConfig) -> u32 {
204        let body = InternalBody::from_config(&config);
205        if let Some(idx) = self.free_list.pop() {
206            let slot = &mut self.slots[idx as usize];
207            slot.body = Some(body);
208            // generation already bumped at removal time
209            idx
210        } else {
211            let idx = self.slots.len() as u32;
212            self.slots.push(Slot {
213                body: Some(body),
214                generation: 0,
215            });
216            idx
217        }
218    }
219
220    /// Add a rigid body using the legacy `PyRigidBodyDesc` interface.
221    pub fn add_body_legacy(&mut self, desc: &PyRigidBodyDesc) -> u32 {
222        let config = PyRigidBodyConfig {
223            mass: desc.mass,
224            position: [desc.position.x, desc.position.y, desc.position.z],
225            velocity: [0.0; 3],
226            orientation: [0.0, 0.0, 0.0, 1.0],
227            angular_velocity: [0.0; 3],
228            shapes: vec![],
229            friction: 0.5,
230            restitution: 0.3,
231            is_static: desc.is_static,
232            is_kinematic: false,
233            can_sleep: true,
234            linear_damping: 0.0,
235            angular_damping: 0.0,
236            tag: None,
237        };
238        self.add_rigid_body(config)
239    }
240
241    /// Add a collider shape to the body with the given handle.
242    ///
243    /// Does nothing if the handle is invalid.
244    pub fn add_collider(&mut self, handle: u32, desc: &PyColliderDesc) {
245        if let Some(body) = self.get_body_mut(handle) {
246            let shape = match desc.shape_type.as_str() {
247                "sphere" => {
248                    let r = desc.radius.unwrap_or(0.5);
249                    PyColliderShape::Sphere { radius: r }
250                }
251                "box" => {
252                    let he = desc.half_extents.unwrap_or(PyVec3::new(0.5, 0.5, 0.5));
253                    PyColliderShape::Box {
254                        half_extents: [he.x, he.y, he.z],
255                    }
256                }
257                _ => PyColliderShape::Sphere { radius: 0.5 },
258            };
259            body.shapes.push(shape);
260        }
261    }
262
263    /// Remove a body by handle. Returns `true` if the body existed.
264    pub fn remove_body(&mut self, handle: u32) -> bool {
265        if let Some(slot) = self.slots.get_mut(handle as usize)
266            && slot.body.is_some()
267        {
268            slot.body = None;
269            slot.generation = slot.generation.wrapping_add(1);
270            self.free_list.push(handle);
271            return true;
272        }
273        false
274    }
275
276    // -----------------------------------------------------------------------
277    // Querying body state
278    // -----------------------------------------------------------------------
279
280    /// Get the position of a body, or `None` if the handle is invalid.
281    pub fn get_position(&self, handle: u32) -> Option<[f64; 3]> {
282        self.get_body(handle).map(|b| b.position)
283    }
284
285    /// Get the linear velocity of a body, or `None` if the handle is invalid.
286    pub fn get_velocity(&self, handle: u32) -> Option<[f64; 3]> {
287        self.get_body(handle).map(|b| b.velocity)
288    }
289
290    /// Get the orientation quaternion `[x, y, z, w]`, or `None`.
291    pub fn get_orientation(&self, handle: u32) -> Option<[f64; 4]> {
292        self.get_body(handle).map(|b| b.orientation)
293    }
294
295    /// Get the angular velocity, or `None`.
296    pub fn get_angular_velocity(&self, handle: u32) -> Option<[f64; 3]> {
297        self.get_body(handle).map(|b| b.angular_velocity)
298    }
299
300    /// Whether the body with `handle` is currently sleeping.
301    pub fn is_sleeping(&self, handle: u32) -> bool {
302        self.get_body(handle)
303            .map(|b| b.is_sleeping)
304            .unwrap_or(false)
305    }
306
307    /// Get the tag of a body, or `None`.
308    pub fn get_tag(&self, handle: u32) -> Option<String> {
309        self.get_body(handle).and_then(|b| b.tag.clone())
310    }
311
312    // -----------------------------------------------------------------------
313    // Mutating body state
314    // -----------------------------------------------------------------------
315
316    /// Set the position of a body.
317    pub fn set_position(&mut self, handle: u32, pos: [f64; 3]) {
318        if let Some(body) = self.get_body_mut(handle) {
319            body.position = pos;
320            body.is_sleeping = false;
321        }
322    }
323
324    /// Set the linear velocity of a body.
325    pub fn set_velocity(&mut self, handle: u32, vel: [f64; 3]) {
326        if let Some(body) = self.get_body_mut(handle) {
327            body.velocity = vel;
328            body.is_sleeping = false;
329        }
330    }
331
332    /// Set the orientation (quaternion `[x, y, z, w]`) of a body.
333    pub fn set_orientation(&mut self, handle: u32, orientation: [f64; 4]) {
334        if let Some(body) = self.get_body_mut(handle) {
335            body.orientation = normalize_quat(orientation);
336            body.is_sleeping = false;
337        }
338    }
339
340    /// Set the angular velocity of a body.
341    pub fn set_angular_velocity(&mut self, handle: u32, omega: [f64; 3]) {
342        if let Some(body) = self.get_body_mut(handle) {
343            body.angular_velocity = omega;
344            body.is_sleeping = false;
345        }
346    }
347
348    // -----------------------------------------------------------------------
349    // Force / impulse API
350    // -----------------------------------------------------------------------
351
352    /// Apply a force `[fx, fy, fz]` to a body, optionally at a world-space point.
353    ///
354    /// If `point` is `None`, the force is applied at the center of mass.
355    /// Force accumulates until the next `step()`.
356    pub fn apply_force(&mut self, handle: u32, force: [f64; 3], point: Option<[f64; 3]>) {
357        if let Some(body) = self.get_body_mut(handle) {
358            if !body.is_dynamic() {
359                return;
360            }
361            body.accumulated_force[0] += force[0];
362            body.accumulated_force[1] += force[1];
363            body.accumulated_force[2] += force[2];
364
365            if let Some(pt) = point {
366                // Torque = (pt - center) x force
367                let r = [
368                    pt[0] - body.position[0],
369                    pt[1] - body.position[1],
370                    pt[2] - body.position[2],
371                ];
372                let torque = cross3(r, force);
373                body.accumulated_torque[0] += torque[0];
374                body.accumulated_torque[1] += torque[1];
375                body.accumulated_torque[2] += torque[2];
376            }
377            body.is_sleeping = false;
378        }
379    }
380
381    /// Apply an impulse `[ix, iy, iz]` directly to a body's velocity.
382    ///
383    /// An impulse is a instantaneous change in momentum: `Δv = impulse / mass`.
384    /// If `point` is provided, an angular impulse is also applied.
385    pub fn apply_impulse(&mut self, handle: u32, impulse: [f64; 3], point: Option<[f64; 3]>) {
386        if let Some(body) = self.get_body_mut(handle) {
387            if !body.is_dynamic() {
388                return;
389            }
390            let inv_m = body.inv_mass();
391            body.velocity[0] += impulse[0] * inv_m;
392            body.velocity[1] += impulse[1] * inv_m;
393            body.velocity[2] += impulse[2] * inv_m;
394
395            if let Some(pt) = point {
396                // Angular impulse approximation using scalar inertia
397                let r = [
398                    pt[0] - body.position[0],
399                    pt[1] - body.position[1],
400                    pt[2] - body.position[2],
401                ];
402                let ang_impulse = cross3(r, impulse);
403                // Approximate moment of inertia: 0.4 * m * r^2 (solid sphere)
404                // Better approximation from shape if available
405                let approx_inertia = approximate_inertia(body);
406                let inv_i = if approx_inertia > 1e-12 {
407                    1.0 / approx_inertia
408                } else {
409                    0.0
410                };
411                body.angular_velocity[0] += ang_impulse[0] * inv_i;
412                body.angular_velocity[1] += ang_impulse[1] * inv_i;
413                body.angular_velocity[2] += ang_impulse[2] * inv_i;
414            }
415            body.is_sleeping = false;
416        }
417    }
418
419    /// Apply a torque `[tx, ty, tz]` to a body (accumulates until next step).
420    pub fn apply_torque(&mut self, handle: u32, torque: [f64; 3]) {
421        if let Some(body) = self.get_body_mut(handle) {
422            if !body.is_dynamic() {
423                return;
424            }
425            body.accumulated_torque[0] += torque[0];
426            body.accumulated_torque[1] += torque[1];
427            body.accumulated_torque[2] += torque[2];
428            body.is_sleeping = false;
429        }
430    }
431
432    /// Wake up a sleeping body.
433    pub fn wake_body(&mut self, handle: u32) {
434        if let Some(body) = self.get_body_mut(handle) {
435            body.is_sleeping = false;
436            body.sleep_timer = 0.0;
437        }
438    }
439
440    /// Put a body to sleep.
441    pub fn sleep_body(&mut self, handle: u32) {
442        if let Some(body) = self.get_body_mut(handle) {
443            body.is_sleeping = true;
444            body.velocity = [0.0; 3];
445            body.angular_velocity = [0.0; 3];
446        }
447    }
448
449    // -----------------------------------------------------------------------
450    // Global configuration
451    // -----------------------------------------------------------------------
452
453    /// Set the gravity vector.
454    pub fn set_gravity(&mut self, g: [f64; 3]) {
455        self.config.gravity = g;
456    }
457
458    /// Get the current gravity vector.
459    pub fn gravity(&self) -> [f64; 3] {
460        self.config.gravity
461    }
462
463    /// Get the current simulation configuration.
464    pub fn config(&self) -> &PySimConfig {
465        &self.config
466    }
467
468    // -----------------------------------------------------------------------
469    // Statistics
470    // -----------------------------------------------------------------------
471
472    /// Total number of allocated body slots (includes removed ones until reuse).
473    pub fn body_count(&self) -> usize {
474        self.slots.iter().filter(|s| s.body.is_some()).count()
475    }
476
477    /// Number of bodies currently sleeping.
478    pub fn sleeping_count(&self) -> usize {
479        self.slots
480            .iter()
481            .filter_map(|s| s.body.as_ref())
482            .filter(|b| b.is_sleeping)
483            .count()
484    }
485
486    /// Accumulated simulation time in seconds.
487    pub fn time(&self) -> f64 {
488        self.time
489    }
490
491    /// Contacts from the most recent simulation step.
492    pub fn get_contacts(&self) -> Vec<PyContactResult> {
493        self.contacts.clone()
494    }
495
496    // -----------------------------------------------------------------------
497    // Positions / velocities bulk query
498    // -----------------------------------------------------------------------
499
500    /// Return all body positions as a flat `Vec<[f64;3]>` in handle order (skipping removed).
501    pub fn all_positions(&self) -> Vec<[f64; 3]> {
502        self.slots
503            .iter()
504            .filter_map(|s| s.body.as_ref().map(|b| b.position))
505            .collect()
506    }
507
508    /// Return all body velocities in handle order (skipping removed).
509    pub fn all_velocities(&self) -> Vec<[f64; 3]> {
510        self.slots
511            .iter()
512            .filter_map(|s| s.body.as_ref().map(|b| b.velocity))
513            .collect()
514    }
515
516    /// Return all active (non-removed) handles.
517    pub fn active_handles(&self) -> Vec<u32> {
518        self.slots
519            .iter()
520            .enumerate()
521            .filter(|(_, s)| s.body.is_some())
522            .map(|(i, _)| i as u32)
523            .collect()
524    }
525
526    // -----------------------------------------------------------------------
527    // Simulation step
528    // -----------------------------------------------------------------------
529
530    /// Advance the simulation by `dt` seconds.
531    ///
532    /// Uses symplectic Euler integration:
533    /// 1. Accumulate gravity + applied forces.
534    /// 2. Update velocity from forces.
535    /// 3. Apply damping.
536    /// 4. Update position from velocity.
537    /// 5. Integrate orientation from angular velocity.
538    /// 6. Run a simple sphere-sphere narrow-phase to detect contacts.
539    /// 7. Resolve contacts with impulse-based collision response.
540    /// 8. Update sleep state.
541    /// 9. Clear accumulated forces.
542    pub fn step(&mut self, dt: f64) {
543        let g = self.config.gravity;
544
545        // --- Integrate velocities and positions ---
546        for slot in &mut self.slots {
547            let body = match slot.body.as_mut() {
548                Some(b) => b,
549                None => continue,
550            };
551            if body.is_static || body.is_kinematic || body.is_sleeping {
552                // Still clear forces for static/sleeping bodies
553                body.accumulated_force = [0.0; 3];
554                body.accumulated_torque = [0.0; 3];
555                continue;
556            }
557
558            let inv_m = body.inv_mass();
559
560            // Gravity
561            let total_force = [
562                body.accumulated_force[0] + g[0] * body.mass,
563                body.accumulated_force[1] + g[1] * body.mass,
564                body.accumulated_force[2] + g[2] * body.mass,
565            ];
566
567            // Velocity update (symplectic Euler)
568            body.velocity[0] += total_force[0] * inv_m * dt;
569            body.velocity[1] += total_force[1] * inv_m * dt;
570            body.velocity[2] += total_force[2] * inv_m * dt;
571
572            // Linear damping
573            let lin_damp = (1.0 - body.linear_damping * dt).max(0.0);
574            body.velocity[0] *= lin_damp;
575            body.velocity[1] *= lin_damp;
576            body.velocity[2] *= lin_damp;
577
578            // Position update
579            body.position[0] += body.velocity[0] * dt;
580            body.position[1] += body.velocity[1] * dt;
581            body.position[2] += body.velocity[2] * dt;
582
583            // Angular velocity update from torque
584            let approx_i = approximate_inertia(body);
585            let inv_i = if approx_i > 1e-12 {
586                1.0 / approx_i
587            } else {
588                0.0
589            };
590            body.angular_velocity[0] += body.accumulated_torque[0] * inv_i * dt;
591            body.angular_velocity[1] += body.accumulated_torque[1] * inv_i * dt;
592            body.angular_velocity[2] += body.accumulated_torque[2] * inv_i * dt;
593
594            // Angular damping
595            let ang_damp = (1.0 - body.angular_damping * dt).max(0.0);
596            body.angular_velocity[0] *= ang_damp;
597            body.angular_velocity[1] *= ang_damp;
598            body.angular_velocity[2] *= ang_damp;
599
600            // Integrate orientation via quaternion derivative
601            body.orientation = integrate_orientation(body.orientation, body.angular_velocity, dt);
602
603            // Clear accumulated forces
604            body.accumulated_force = [0.0; 3];
605            body.accumulated_torque = [0.0; 3];
606        }
607
608        // --- Narrow-phase collision detection + response ---
609        self.contacts.clear();
610        self.detect_and_resolve_contacts(dt);
611
612        // --- Constraint resolution ---
613        self.resolve_constraints();
614
615        // --- Sleep detection ---
616        if self.config.sleep_enabled {
617            self.update_sleep(dt);
618        }
619
620        self.time += dt;
621    }
622
623    /// Reset the world: remove all bodies, clear contacts, constraints, reset time.
624    pub fn reset(&mut self) {
625        self.slots.clear();
626        self.free_list.clear();
627        self.contacts.clear();
628        self.constraints.clear();
629        self.time = 0.0;
630    }
631
632    // -----------------------------------------------------------------------
633    // Substep simulation control
634    // -----------------------------------------------------------------------
635
636    /// Advance the simulation by `dt` seconds using `substeps` equal sub-steps.
637    ///
638    /// This is useful when CCD is needed for fast-moving objects, or when
639    /// a larger integration step must be subdivided for stability.
640    ///
641    /// # Example
642    /// ```ignore
643    /// world.step_substeps(1.0 / 60.0, 4); // 4 sub-steps of 1/240 s each
644    /// ```
645    #[allow(clippy::too_many_arguments)]
646    pub fn step_substeps(&mut self, dt: f64, substeps: u32) {
647        let substeps = substeps.max(1);
648        let sub_dt = dt / substeps as f64;
649        for _ in 0..substeps {
650            self.step(sub_dt);
651        }
652    }
653
654    // -----------------------------------------------------------------------
655    // Query functions
656    // -----------------------------------------------------------------------
657
658    /// Return the handles of all bodies whose centre-of-mass lies within `aabb`.
659    ///
660    /// `aabb` is given as `[xmin, ymin, zmin, xmax, ymax, zmax]`.
661    pub fn bodies_in_aabb(&self, aabb: [f64; 6]) -> Vec<u32> {
662        let [xmin, ymin, zmin, xmax, ymax, zmax] = aabb;
663        self.slots
664            .iter()
665            .enumerate()
666            .filter_map(|(i, slot)| {
667                let body = slot.body.as_ref()?;
668                let p = body.position;
669                if p[0] >= xmin
670                    && p[0] <= xmax
671                    && p[1] >= ymin
672                    && p[1] <= ymax
673                    && p[2] >= zmin
674                    && p[2] <= zmax
675                {
676                    Some(i as u32)
677                } else {
678                    None
679                }
680            })
681            .collect()
682    }
683
684    /// Cast a ray from `origin` in direction `dir` and return the handle and
685    /// hit distance of the nearest body whose sphere collider is struck.
686    ///
687    /// Returns `None` if no body is hit within `max_dist`.
688    ///
689    /// Direction `dir` need not be normalized; it is normalised internally.
690    pub fn raycast(&self, origin: [f64; 3], dir: [f64; 3], max_dist: f64) -> Option<(u32, f64)> {
691        let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
692        if len < 1e-12 {
693            return None;
694        }
695        let d = [dir[0] / len, dir[1] / len, dir[2] / len];
696
697        let mut best_handle: Option<u32> = None;
698        let mut best_t = max_dist;
699
700        for (i, slot) in self.slots.iter().enumerate() {
701            let body = match slot.body.as_ref() {
702                Some(b) => b,
703                None => continue,
704            };
705            let radius = first_sphere_radius(&body.shapes);
706            if radius <= 0.0 {
707                continue;
708            }
709            // Ray-sphere intersection
710            let oc = [
711                origin[0] - body.position[0],
712                origin[1] - body.position[1],
713                origin[2] - body.position[2],
714            ];
715            let b_coeff = oc[0] * d[0] + oc[1] * d[1] + oc[2] * d[2];
716            let c_coeff = oc[0] * oc[0] + oc[1] * oc[1] + oc[2] * oc[2] - radius * radius;
717            let discriminant = b_coeff * b_coeff - c_coeff;
718            if discriminant < 0.0 {
719                continue;
720            }
721            let sqrt_disc = discriminant.sqrt();
722            let t = -b_coeff - sqrt_disc;
723            let t = if t >= 0.0 { t } else { -b_coeff + sqrt_disc };
724            if t >= 0.0 && t < best_t {
725                best_t = t;
726                best_handle = Some(i as u32);
727            }
728        }
729
730        best_handle.map(|h| (h, best_t))
731    }
732
733    /// Return the total number of contacts from the most recent step.
734    pub fn contact_count(&self) -> usize {
735        self.contacts.len()
736    }
737
738    // -----------------------------------------------------------------------
739    // Legacy compatibility helpers
740    // -----------------------------------------------------------------------
741
742    /// Legacy: get body position as `PyVec3`.
743    pub fn get_body_position(&self, handle: usize) -> Option<PyVec3> {
744        self.get_position(handle as u32).map(PyVec3::from_array)
745    }
746
747    /// Legacy: get body velocity as `PyVec3`.
748    pub fn get_body_velocity(&self, handle: usize) -> Option<PyVec3> {
749        self.get_velocity(handle as u32).map(PyVec3::from_array)
750    }
751
752    /// Legacy: set body velocity from `PyVec3`.
753    pub fn set_body_velocity(&mut self, handle: usize, vel: PyVec3) {
754        self.set_velocity(handle as u32, vel.to_array());
755    }
756
757    /// Legacy: return body count (same as `body_count`).
758    pub fn num_bodies(&self) -> usize {
759        self.body_count()
760    }
761
762    /// Legacy: return gravity as `PyVec3`.
763    pub fn gravity_vec3(&self) -> PyVec3 {
764        PyVec3::from_array(self.config.gravity)
765    }
766
767    /// Legacy: return all positions as `Vec`PyVec3`.
768    pub fn all_positions_vec3(&self) -> Vec<PyVec3> {
769        self.all_positions()
770            .into_iter()
771            .map(PyVec3::from_array)
772            .collect()
773    }
774
775    // -----------------------------------------------------------------------
776    // Private helpers
777    // -----------------------------------------------------------------------
778
779    fn get_body(&self, handle: u32) -> Option<&InternalBody> {
780        self.slots.get(handle as usize)?.body.as_ref()
781    }
782
783    fn get_body_mut(&mut self, handle: u32) -> Option<&mut InternalBody> {
784        self.slots.get_mut(handle as usize)?.body.as_mut()
785    }
786
787    /// Very simple sphere-sphere broad/narrow phase.
788    ///
789    /// For each pair of bodies that both have sphere shapes, compute penetration
790    /// depth. If penetrating, record a contact and apply an impulse-based
791    /// resolution (one iteration).
792    fn detect_and_resolve_contacts(&mut self, dt: f64) {
793        let n = self.slots.len();
794        for i in 0..n {
795            for j in (i + 1)..n {
796                // Collect needed data without borrow issues
797                let (pos_a, vel_a, mass_a, static_a, sleeping_a, friction_a, rest_a, radius_a) = {
798                    let body = match self.slots[i].body.as_ref() {
799                        Some(b) => b,
800                        None => continue,
801                    };
802                    let radius = first_sphere_radius(&body.shapes);
803                    if radius <= 0.0 {
804                        continue;
805                    }
806                    (
807                        body.position,
808                        body.velocity,
809                        body.mass,
810                        body.is_static,
811                        body.is_sleeping,
812                        body.friction,
813                        body.restitution,
814                        radius,
815                    )
816                };
817
818                let (pos_b, vel_b, mass_b, static_b, sleeping_b, friction_b, rest_b, radius_b) = {
819                    let body = match self.slots[j].body.as_ref() {
820                        Some(b) => b,
821                        None => continue,
822                    };
823                    let radius = first_sphere_radius(&body.shapes);
824                    if radius <= 0.0 {
825                        continue;
826                    }
827                    (
828                        body.position,
829                        body.velocity,
830                        body.mass,
831                        body.is_static,
832                        body.is_sleeping,
833                        body.friction,
834                        body.restitution,
835                        radius,
836                    )
837                };
838
839                // Distance between centers
840                let diff = [
841                    pos_b[0] - pos_a[0],
842                    pos_b[1] - pos_a[1],
843                    pos_b[2] - pos_a[2],
844                ];
845                let dist_sq = diff[0] * diff[0] + diff[1] * diff[1] + diff[2] * diff[2];
846                let min_dist = radius_a + radius_b;
847                if dist_sq >= min_dist * min_dist {
848                    continue;
849                }
850
851                let dist = dist_sq.sqrt();
852                let depth = min_dist - dist;
853                let normal = if dist > 1e-12 {
854                    [diff[0] / dist, diff[1] / dist, diff[2] / dist]
855                } else {
856                    [0.0, 1.0, 0.0]
857                };
858
859                // Contact point (midpoint on surface of A towards B)
860                let cp = [
861                    pos_a[0] + normal[0] * radius_a,
862                    pos_a[1] + normal[1] * radius_a,
863                    pos_a[2] + normal[2] * radius_a,
864                ];
865
866                // Combined coefficients
867                let combined_friction = (friction_a + friction_b) * 0.5;
868                let combined_rest = (rest_a + rest_b) * 0.5;
869
870                // Relative velocity along normal
871                let rel_vel = [
872                    vel_a[0] - vel_b[0],
873                    vel_a[1] - vel_b[1],
874                    vel_a[2] - vel_b[2],
875                ];
876                let rel_vel_normal =
877                    rel_vel[0] * normal[0] + rel_vel[1] * normal[1] + rel_vel[2] * normal[2];
878
879                // Impulse magnitude (only if approaching)
880                let impulse_mag = if rel_vel_normal < 0.0 {
881                    let inv_m_a = if static_a || mass_a <= 0.0 {
882                        0.0
883                    } else {
884                        1.0 / mass_a
885                    };
886                    let inv_m_b = if static_b || mass_b <= 0.0 {
887                        0.0
888                    } else {
889                        1.0 / mass_b
890                    };
891                    let denom = inv_m_a + inv_m_b;
892                    if denom > 1e-12 {
893                        -(1.0 + combined_rest) * rel_vel_normal / denom
894                    } else {
895                        0.0
896                    }
897                } else {
898                    0.0
899                };
900
901                // Baumgarte positional correction
902                let correction_mag = (depth * self.config.baumgarte_factor) / dt.max(1e-6);
903
904                let contact = PyContactResult {
905                    body_a: i as u32,
906                    body_b: j as u32,
907                    contact_point: cp,
908                    normal,
909                    depth,
910                    friction: combined_friction,
911                    restitution: combined_rest,
912                    impulse: impulse_mag,
913                };
914                self.contacts.push(contact);
915
916                // Apply impulse to body A (not static/sleeping)
917                if !static_a && !sleeping_a && mass_a > 0.0 {
918                    let inv_m_a = 1.0 / mass_a;
919                    if let Some(ba) = self.slots[i].body.as_mut() {
920                        ba.velocity[0] += impulse_mag * normal[0] * inv_m_a;
921                        ba.velocity[1] += impulse_mag * normal[1] * inv_m_a;
922                        ba.velocity[2] += impulse_mag * normal[2] * inv_m_a;
923                        // Positional correction
924                        ba.position[0] -= normal[0] * correction_mag * inv_m_a * dt;
925                        ba.position[1] -= normal[1] * correction_mag * inv_m_a * dt;
926                        ba.position[2] -= normal[2] * correction_mag * inv_m_a * dt;
927                    }
928                }
929                // Apply impulse to body B (opposite direction)
930                if !static_b && !sleeping_b && mass_b > 0.0 {
931                    let inv_m_b = 1.0 / mass_b;
932                    if let Some(bb) = self.slots[j].body.as_mut() {
933                        bb.velocity[0] -= impulse_mag * normal[0] * inv_m_b;
934                        bb.velocity[1] -= impulse_mag * normal[1] * inv_m_b;
935                        bb.velocity[2] -= impulse_mag * normal[2] * inv_m_b;
936                        // Positional correction
937                        bb.position[0] += normal[0] * correction_mag * inv_m_b * dt;
938                        bb.position[1] += normal[1] * correction_mag * inv_m_b * dt;
939                        bb.position[2] += normal[2] * correction_mag * inv_m_b * dt;
940                    }
941                }
942            }
943        }
944    }
945
946    fn update_sleep(&mut self, dt: f64) {
947        let lin_thresh = self.config.linear_sleep_threshold;
948        let ang_thresh = self.config.angular_sleep_threshold;
949        let time_thresh = self.config.time_before_sleep;
950
951        for slot in &mut self.slots {
952            let body = match slot.body.as_mut() {
953                Some(b) => b,
954                None => continue,
955            };
956            if !body.can_sleep || body.is_static || body.is_kinematic {
957                continue;
958            }
959            if body.is_sleeping {
960                continue;
961            }
962
963            let lin_speed = (body.velocity[0] * body.velocity[0]
964                + body.velocity[1] * body.velocity[1]
965                + body.velocity[2] * body.velocity[2])
966                .sqrt();
967            let ang_speed = (body.angular_velocity[0] * body.angular_velocity[0]
968                + body.angular_velocity[1] * body.angular_velocity[1]
969                + body.angular_velocity[2] * body.angular_velocity[2])
970                .sqrt();
971
972            if lin_speed < lin_thresh && ang_speed < ang_thresh {
973                body.sleep_timer += dt;
974                if body.sleep_timer >= time_thresh {
975                    body.is_sleeping = true;
976                    body.velocity = [0.0; 3];
977                    body.angular_velocity = [0.0; 3];
978                }
979            } else {
980                body.sleep_timer = 0.0;
981            }
982        }
983    }
984}
985
986// ---------------------------------------------------------------------------
987// Pure helper functions (no `self` borrow issues)
988// ---------------------------------------------------------------------------
989
990fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
991    [
992        a[1] * b[2] - a[2] * b[1],
993        a[2] * b[0] - a[0] * b[2],
994        a[0] * b[1] - a[1] * b[0],
995    ]
996}
997
998fn normalize_quat(q: [f64; 4]) -> [f64; 4] {
999    let norm = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
1000    if norm < 1e-12 {
1001        [0.0, 0.0, 0.0, 1.0]
1002    } else {
1003        [q[0] / norm, q[1] / norm, q[2] / norm, q[3] / norm]
1004    }
1005}
1006
1007/// Integrate an orientation quaternion by angular velocity over dt.
1008///
1009/// Uses the quaternion derivative: dq/dt = 0.5 * \[wx,wy,wz,0\] * q
1010fn integrate_orientation(q: [f64; 4], omega: [f64; 3], dt: f64) -> [f64; 4] {
1011    let (qx, qy, qz, qw) = (q[0], q[1], q[2], q[3]);
1012    let (wx, wy, wz) = (omega[0], omega[1], omega[2]);
1013    let half_dt = 0.5 * dt;
1014    let dqx = half_dt * (qw * wx + qy * wz - qz * wy);
1015    let dqy = half_dt * (qw * wy - qx * wz + qz * wx);
1016    let dqz = half_dt * (qw * wz + qx * wy - qy * wx);
1017    let dqw = half_dt * (-qx * wx - qy * wy - qz * wz);
1018    normalize_quat([qx + dqx, qy + dqy, qz + dqz, qw + dqw])
1019}
1020
1021/// Compute an approximate scalar moment of inertia for a body.
1022///
1023/// Uses the first sphere shape if present; otherwise falls back to a point-mass
1024/// approximation.
1025fn approximate_inertia(body: &InternalBody) -> f64 {
1026    if body.mass <= 0.0 {
1027        return 0.0;
1028    }
1029    for shape in &body.shapes {
1030        if let PyColliderShape::Sphere { radius } = shape {
1031            // Solid sphere: I = 2/5 * m * r^2
1032            return 0.4 * body.mass * radius * radius;
1033        }
1034        if let PyColliderShape::Box { half_extents } = shape {
1035            // Box: I_xx = 1/12 * m * (hy^2 + hz^2), use average
1036            let hx = half_extents[0];
1037            let hy = half_extents[1];
1038            let hz = half_extents[2];
1039            let ixx = (1.0 / 3.0) * body.mass * (hy * hy + hz * hz);
1040            let iyy = (1.0 / 3.0) * body.mass * (hx * hx + hz * hz);
1041            let izz = (1.0 / 3.0) * body.mass * (hx * hx + hy * hy);
1042            return (ixx + iyy + izz) / 3.0;
1043        }
1044    }
1045    // Point mass fallback: use a unit sphere assumption
1046    0.4 * body.mass
1047}
1048
1049/// Return the radius of the first sphere shape in the list, or 0.0.
1050fn first_sphere_radius(shapes: &[PyColliderShape]) -> f64 {
1051    for shape in shapes {
1052        if let PyColliderShape::Sphere { radius } = shape {
1053            return *radius;
1054        }
1055    }
1056    0.0
1057}