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}