Skip to main content

oxiphysics_python/world_api/
extensions.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Energy, Gravity Field, Contact List, and Inertia Extensions.
5
6#![allow(missing_docs)]
7
8use super::PyPhysicsWorld;
9
10// ===========================================================================
11// Energy, Gravity Field, Contact List, and Inertia Extensions
12// ===========================================================================
13
14/// Contact pair returned by `get_contact_list`.
15#[derive(Debug, Clone, PartialEq)]
16#[allow(dead_code)]
17pub struct ContactPair {
18    /// Handle of body A.
19    pub body_a: u32,
20    /// Handle of body B.
21    pub body_b: u32,
22    /// World-space contact point.
23    pub contact_point: [f64; 3],
24    /// Contact normal (from B toward A).
25    pub normal: [f64; 3],
26    /// Penetration depth.
27    pub depth: f64,
28    /// Impulse magnitude applied.
29    pub impulse: f64,
30}
31
32/// Inertia tensor (3×3 matrix stored as 9 elements, row-major).
33#[derive(Debug, Clone, PartialEq)]
34#[allow(dead_code)]
35pub struct InertiaTensor {
36    /// Row-major 3×3 inertia tensor elements.
37    pub elements: [f64; 9],
38}
39
40impl InertiaTensor {
41    /// Create from diagonal elements (assumes principal axes aligned).
42    pub fn from_diagonal(ix: f64, iy: f64, iz: f64) -> Self {
43        #[allow(clippy::zero_prefixed_literal)]
44        let elements = [ix, 0.0, 0.0, 0.0, iy, 0.0, 0.0, 0.0, iz];
45        Self { elements }
46    }
47
48    /// Return diagonal elements `[Ixx, Iyy, Izz]`.
49    pub fn diagonal(&self) -> [f64; 3] {
50        [self.elements[0], self.elements[4], self.elements[8]]
51    }
52
53    /// Trace (sum of diagonal).
54    pub fn trace(&self) -> f64 {
55        self.elements[0] + self.elements[4] + self.elements[8]
56    }
57}
58
59/// `PyRigidBody` is a lightweight handle-based wrapper that exposes
60/// body-level computations (moment of inertia, etc.) without owning state.
61#[allow(dead_code)]
62pub struct PyRigidBody {
63    /// Mass of the body (kg).
64    pub mass: f64,
65    /// Half-extents `[hx, hy, hz]` for box-shaped bodies.
66    pub half_extents: [f64; 3],
67}
68
69impl PyRigidBody {
70    /// Create a new `PyRigidBody` with the given mass and box half-extents.
71    pub fn new(mass: f64, half_extents: [f64; 3]) -> Self {
72        Self { mass, half_extents }
73    }
74
75    /// Compute the solid box inertia tensor for this body.
76    ///
77    /// For a solid box with mass `m` and half-extents `(hx, hy, hz)`:
78    /// - `Ixx = m/3 * (hy² + hz²)`
79    /// - `Iyy = m/3 * (hx² + hz²)`
80    /// - `Izz = m/3 * (hx² + hy²)`
81    ///
82    /// Returns an `InertiaTensor` with the principal-axis diagonal.
83    pub fn compute_moment_of_inertia_box(&self) -> InertiaTensor {
84        let m = self.mass;
85        let hx = self.half_extents[0];
86        let hy = self.half_extents[1];
87        let hz = self.half_extents[2];
88        // Full-extent dimensions: lx=2*hx, ly=2*hy, lz=2*hz
89        // I_xx = m/12 * (ly² + lz²) = m/12 * (4hy² + 4hz²) = m/3 * (hy² + hz²)
90        let ixx = (m / 3.0) * (hy * hy + hz * hz);
91        let iyy = (m / 3.0) * (hx * hx + hz * hz);
92        let izz = (m / 3.0) * (hx * hx + hy * hy);
93        InertiaTensor::from_diagonal(ixx, iyy, izz)
94    }
95
96    /// Compute moment of inertia for a solid sphere.
97    ///
98    /// `I = 2/5 * m * r²` for each axis (isotropic).
99    pub fn compute_moment_of_inertia_sphere(&self, radius: f64) -> InertiaTensor {
100        let i = 0.4 * self.mass * radius * radius;
101        InertiaTensor::from_diagonal(i, i, i)
102    }
103}
104
105impl PyPhysicsWorld {
106    // -----------------------------------------------------------------------
107    // Total energy
108    // -----------------------------------------------------------------------
109
110    /// Compute total mechanical energy: kinetic energy + gravitational potential energy.
111    ///
112    /// KE = Σ ½ m v²  (summed over dynamic bodies)
113    /// PE = Σ m * |g| * h  (height measured along gravity direction; uses Y component of gravity)
114    ///
115    /// Returns `(kinetic, potential, total)`.
116    pub fn compute_total_energy(&self) -> (f64, f64, f64) {
117        let g = self.config.gravity;
118        let g_mag = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
119        // Gravity direction unit vector (pointing downward)
120        let g_dir = if g_mag > 1e-10 {
121            [g[0] / g_mag, g[1] / g_mag, g[2] / g_mag]
122        } else {
123            [0.0, -1.0, 0.0]
124        };
125
126        let mut ke = 0.0_f64;
127        let mut pe = 0.0_f64;
128
129        for slot in &self.slots {
130            let body = match slot.body.as_ref() {
131                Some(b) if !b.is_static && !b.is_kinematic => b,
132                _ => continue,
133            };
134
135            let v = body.velocity;
136            let v2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
137            ke += 0.5 * body.mass * v2;
138
139            // Height above origin along negative-gravity direction
140            // h = -dot(position, g_dir)  (positive when above ground)
141            let h = -(body.position[0] * g_dir[0]
142                + body.position[1] * g_dir[1]
143                + body.position[2] * g_dir[2]);
144            pe += body.mass * g_mag * h;
145        }
146
147        (ke, pe, ke + pe)
148    }
149
150    // -----------------------------------------------------------------------
151    // Spatially varying gravity
152    // -----------------------------------------------------------------------
153
154    /// Apply a spatially varying gravity field to all dynamic bodies for one step.
155    ///
156    /// The field is specified as a closure `f(position) -> [f64; 3]` mapping
157    /// a body's world-space position to a gravitational acceleration vector.
158    ///
159    /// This method accumulates forces based on the field and does **not**
160    /// step the simulation; call `step()` afterwards.
161    ///
162    /// # Example
163    ///
164    /// Radial gravity toward the origin:
165    /// ```ignore
166    /// world.apply_gravity_field(|p| {
167    ///     let r2 = p[0]*p[0] + p[1]*p[1] + p[2]*p[2] + 1e-6;
168    ///     let r = r2.sqrt();
169    ///     [-9.81 * p[0] / r, -9.81 * p[1] / r, -9.81 * p[2] / r]
170    /// });
171    /// ```
172    pub fn apply_gravity_field<F>(&mut self, field: F)
173    where
174        F: Fn([f64; 3]) -> [f64; 3],
175    {
176        for slot in &mut self.slots {
177            let body = match slot.body.as_mut() {
178                Some(b) if !b.is_static && !b.is_kinematic => b,
179                _ => continue,
180            };
181            let g_local = field(body.position);
182            // F = m * g
183            body.accumulated_force[0] += body.mass * g_local[0];
184            body.accumulated_force[1] += body.mass * g_local[1];
185            body.accumulated_force[2] += body.mass * g_local[2];
186        }
187    }
188
189    // -----------------------------------------------------------------------
190    // Contact list
191    // -----------------------------------------------------------------------
192
193    /// Return the list of contact pairs from the most recent simulation step.
194    ///
195    /// Each entry contains the two body handles and contact geometry.
196    pub fn get_contact_list(&self) -> Vec<ContactPair> {
197        self.contacts
198            .iter()
199            .map(|c| ContactPair {
200                body_a: c.body_a,
201                body_b: c.body_b,
202                contact_point: c.contact_point,
203                normal: c.normal,
204                depth: c.depth,
205                impulse: c.impulse,
206            })
207            .collect()
208    }
209}