Skip to main content

oxiphysics_python/
types.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Python-friendly wrapper types with serde support.
5//!
6//! All types in this module are designed for easy FFI/Python interop:
7//! - No lifetimes
8//! - Public fields only
9//! - Serialize/Deserialize support
10//! - Arrays instead of nalgebra types where applicable
11
12#![allow(missing_docs)]
13
14use oxiphysics_core::math::Vec3;
15use oxiphysics_core::{Aabb, Transform};
16use serde::{Deserialize, Serialize};
17
18// ---------------------------------------------------------------------------
19// PyVec3
20// ---------------------------------------------------------------------------
21
22/// A 3-component vector suitable for Python interop.
23///
24/// Uses `f64` components and maps directly to/from `oxiphysics_core::math::Vec3`.
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
26pub struct PyVec3 {
27    /// X component.
28    pub x: f64,
29    /// Y component.
30    pub y: f64,
31    /// Z component.
32    pub z: f64,
33}
34
35impl PyVec3 {
36    /// Create a new `PyVec3`.
37    pub fn new(x: f64, y: f64, z: f64) -> Self {
38        Self { x, y, z }
39    }
40
41    /// Zero vector.
42    pub fn zero() -> Self {
43        Self::new(0.0, 0.0, 0.0)
44    }
45
46    /// Dot product with another vector.
47    pub fn dot(&self, other: &PyVec3) -> f64 {
48        self.x * other.x + self.y * other.y + self.z * other.z
49    }
50
51    /// Cross product with another vector.
52    pub fn cross(&self, other: &PyVec3) -> PyVec3 {
53        PyVec3::new(
54            self.y * other.z - self.z * other.y,
55            self.z * other.x - self.x * other.z,
56            self.x * other.y - self.y * other.x,
57        )
58    }
59
60    /// Squared length.
61    pub fn length_squared(&self) -> f64 {
62        self.x * self.x + self.y * self.y + self.z * self.z
63    }
64
65    /// Euclidean length.
66    pub fn length(&self) -> f64 {
67        self.length_squared().sqrt()
68    }
69
70    /// Normalized (unit) vector; returns zero vector if length is near zero.
71    pub fn normalized(&self) -> PyVec3 {
72        let len = self.length();
73        if len < 1e-12 {
74            PyVec3::zero()
75        } else {
76            PyVec3::new(self.x / len, self.y / len, self.z / len)
77        }
78    }
79
80    /// Scale all components by `s`.
81    pub fn scale(&self, s: f64) -> PyVec3 {
82        PyVec3::new(self.x * s, self.y * s, self.z * s)
83    }
84
85    /// Add another vector.
86    pub fn add(&self, other: &PyVec3) -> PyVec3 {
87        PyVec3::new(self.x + other.x, self.y + other.y, self.z + other.z)
88    }
89
90    /// Subtract another vector.
91    pub fn sub(&self, other: &PyVec3) -> PyVec3 {
92        PyVec3::new(self.x - other.x, self.y - other.y, self.z - other.z)
93    }
94
95    /// Convert to a plain `[f64; 3]` array.
96    pub fn to_array(&self) -> [f64; 3] {
97        [self.x, self.y, self.z]
98    }
99
100    /// Create from a `[f64; 3]` array.
101    pub fn from_array(arr: [f64; 3]) -> Self {
102        Self::new(arr[0], arr[1], arr[2])
103    }
104}
105
106impl From<Vec3> for PyVec3 {
107    fn from(v: Vec3) -> Self {
108        Self {
109            x: v.x,
110            y: v.y,
111            z: v.z,
112        }
113    }
114}
115
116impl From<PyVec3> for Vec3 {
117    fn from(v: PyVec3) -> Self {
118        Vec3::new(v.x, v.y, v.z)
119    }
120}
121
122impl From<[f64; 3]> for PyVec3 {
123    fn from(arr: [f64; 3]) -> Self {
124        Self::new(arr[0], arr[1], arr[2])
125    }
126}
127
128impl From<PyVec3> for [f64; 3] {
129    fn from(v: PyVec3) -> Self {
130        [v.x, v.y, v.z]
131    }
132}
133
134// ---------------------------------------------------------------------------
135// PyTransform
136// ---------------------------------------------------------------------------
137
138/// A transform (position + quaternion rotation) suitable for Python interop.
139///
140/// Rotation is stored as `[x, y, z, w]` quaternion components.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct PyTransform {
143    /// Position in world space.
144    pub position: PyVec3,
145    /// Rotation as a quaternion `[x, y, z, w]`.
146    pub rotation: [f64; 4],
147}
148
149impl PyTransform {
150    /// Create an identity transform (origin, no rotation).
151    pub fn identity() -> Self {
152        Self {
153            position: PyVec3::zero(),
154            rotation: [0.0, 0.0, 0.0, 1.0],
155        }
156    }
157
158    /// Compose two transforms: `self` applied first, then `other`.
159    ///
160    /// Position is summed; rotation is the quaternion product `other * self`.
161    pub fn compose(&self, other: &PyTransform) -> PyTransform {
162        let (ax, ay, az, aw) = (
163            self.rotation[0],
164            self.rotation[1],
165            self.rotation[2],
166            self.rotation[3],
167        );
168        let (bx, by, bz, bw) = (
169            other.rotation[0],
170            other.rotation[1],
171            other.rotation[2],
172            other.rotation[3],
173        );
174        let rx = bw * ax + bx * aw + by * az - bz * ay;
175        let ry = bw * ay - bx * az + by * aw + bz * ax;
176        let rz = bw * az + bx * ay - by * ax + bz * aw;
177        let rw = bw * aw - bx * ax - by * ay - bz * az;
178        PyTransform {
179            position: PyVec3::new(
180                self.position.x + other.position.x,
181                self.position.y + other.position.y,
182                self.position.z + other.position.z,
183            ),
184            rotation: [rx, ry, rz, rw],
185        }
186    }
187
188    /// Invert the transform.
189    pub fn inverse(&self) -> PyTransform {
190        // Invert quaternion: conjugate (negated xyz, same w) / norm^2
191        // For unit quaternion conjugate == inverse
192        let inv_rot = [
193            -self.rotation[0],
194            -self.rotation[1],
195            -self.rotation[2],
196            self.rotation[3],
197        ];
198        // Inverse position: -inv_rot * position
199        let px = self.position.x;
200        let py = self.position.y;
201        let pz = self.position.z;
202        let (qx, qy, qz, qw) = (inv_rot[0], inv_rot[1], inv_rot[2], inv_rot[3]);
203        // Rotate position by inv_rot
204        let t_x = 2.0 * (qy * pz - qz * py);
205        let t_y = 2.0 * (qz * px - qx * pz);
206        let t_z = 2.0 * (qx * py - qy * px);
207        let rx = px + qw * t_x + (qy * t_z - qz * t_y);
208        let ry = py + qw * t_y + (qz * t_x - qx * t_z);
209        let rz = pz + qw * t_z + (qx * t_y - qy * t_x);
210        PyTransform {
211            position: PyVec3::new(-rx, -ry, -rz),
212            rotation: inv_rot,
213        }
214    }
215}
216
217impl Default for PyTransform {
218    fn default() -> Self {
219        Self::identity()
220    }
221}
222
223impl From<Transform> for PyTransform {
224    fn from(t: Transform) -> Self {
225        let q = t.rotation;
226        Self {
227            position: PyVec3::from(t.position),
228            rotation: [q.i, q.j, q.k, q.w],
229        }
230    }
231}
232
233impl From<PyTransform> for Transform {
234    fn from(t: PyTransform) -> Self {
235        let pos: Vec3 = t.position.into();
236        let q = nalgebra::UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(
237            t.rotation[3],
238            t.rotation[0],
239            t.rotation[1],
240            t.rotation[2],
241        ));
242        Transform::new(pos, q)
243    }
244}
245
246// ---------------------------------------------------------------------------
247// PyAabb
248// ---------------------------------------------------------------------------
249
250/// An axis-aligned bounding box suitable for Python interop.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct PyAabb {
253    /// Minimum corner.
254    pub min: PyVec3,
255    /// Maximum corner.
256    pub max: PyVec3,
257}
258
259impl PyAabb {
260    /// Create a new AABB from min and max corners.
261    pub fn new(min: PyVec3, max: PyVec3) -> Self {
262        Self { min, max }
263    }
264
265    /// Center of the AABB.
266    pub fn center(&self) -> PyVec3 {
267        PyVec3::new(
268            (self.min.x + self.max.x) * 0.5,
269            (self.min.y + self.max.y) * 0.5,
270            (self.min.z + self.max.z) * 0.5,
271        )
272    }
273
274    /// Half-extents of the AABB.
275    pub fn half_extents(&self) -> PyVec3 {
276        PyVec3::new(
277            (self.max.x - self.min.x) * 0.5,
278            (self.max.y - self.min.y) * 0.5,
279            (self.max.z - self.min.z) * 0.5,
280        )
281    }
282
283    /// Whether the given point is inside this AABB.
284    pub fn contains_point(&self, p: PyVec3) -> bool {
285        p.x >= self.min.x
286            && p.x <= self.max.x
287            && p.y >= self.min.y
288            && p.y <= self.max.y
289            && p.z >= self.min.z
290            && p.z <= self.max.z
291    }
292
293    /// Whether this AABB intersects another.
294    pub fn intersects(&self, other: &PyAabb) -> bool {
295        self.min.x <= other.max.x
296            && self.max.x >= other.min.x
297            && self.min.y <= other.max.y
298            && self.max.y >= other.min.y
299            && self.min.z <= other.max.z
300            && self.max.z >= other.min.z
301    }
302}
303
304impl From<Aabb> for PyAabb {
305    fn from(a: Aabb) -> Self {
306        Self {
307            min: PyVec3::from(a.min),
308            max: PyVec3::from(a.max),
309        }
310    }
311}
312
313impl From<PyAabb> for Aabb {
314    fn from(a: PyAabb) -> Self {
315        Aabb::new(a.min.into(), a.max.into())
316    }
317}
318
319// ---------------------------------------------------------------------------
320// PyColliderShape
321// ---------------------------------------------------------------------------
322
323/// Shape variant for a rigid body collider.
324///
325/// Only primitive shapes are supported; they map to underlying collision shapes.
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
327#[serde(tag = "type", rename_all = "snake_case")]
328pub enum PyColliderShape {
329    /// A sphere with the given radius.
330    Sphere {
331        /// Radius in meters.
332        radius: f64,
333    },
334    /// An axis-aligned box with given half-extents.
335    Box {
336        /// Half-extents `[hx, hy, hz]` in meters.
337        half_extents: [f64; 3],
338    },
339    /// An infinite plane defined by a normal and distance from origin.
340    Plane {
341        /// Outward normal (should be unit length).
342        normal: [f64; 3],
343        /// Signed distance from origin along the normal.
344        distance: f64,
345    },
346    /// A capsule (cylinder + two hemispheres) along the Y axis.
347    Capsule {
348        /// Radius of the cylinder and end hemispheres.
349        radius: f64,
350        /// Half-height of the cylindrical portion (total height = 2 * half_height + 2 * radius).
351        half_height: f64,
352    },
353    /// A cylinder aligned with the Y axis.
354    Cylinder {
355        /// Radius of the cylinder.
356        radius: f64,
357        /// Half-height of the cylinder.
358        half_height: f64,
359    },
360    /// A cone aligned with the Y axis, apex pointing up.
361    Cone {
362        /// Radius of the base.
363        radius: f64,
364        /// Half-height of the cone.
365        half_height: f64,
366    },
367}
368
369impl PyColliderShape {
370    /// Create a sphere shape.
371    pub fn sphere(radius: f64) -> Self {
372        PyColliderShape::Sphere { radius }
373    }
374
375    /// Create a box shape from half-extents.
376    pub fn box_shape(hx: f64, hy: f64, hz: f64) -> Self {
377        PyColliderShape::Box {
378            half_extents: [hx, hy, hz],
379        }
380    }
381
382    /// Create a plane shape.
383    pub fn plane(normal: [f64; 3], distance: f64) -> Self {
384        PyColliderShape::Plane { normal, distance }
385    }
386
387    /// Create a capsule shape.
388    pub fn capsule(radius: f64, half_height: f64) -> Self {
389        PyColliderShape::Capsule {
390            radius,
391            half_height,
392        }
393    }
394
395    /// Compute an approximate volume for mass/inertia estimation.
396    pub fn approximate_volume(&self) -> f64 {
397        const PI: f64 = std::f64::consts::PI;
398        match self {
399            PyColliderShape::Sphere { radius } => (4.0 / 3.0) * PI * radius * radius * radius,
400            PyColliderShape::Box { half_extents } => {
401                8.0 * half_extents[0] * half_extents[1] * half_extents[2]
402            }
403            PyColliderShape::Plane { .. } => f64::INFINITY,
404            PyColliderShape::Capsule {
405                radius,
406                half_height,
407            } => PI * radius * radius * (2.0 * half_height + (4.0 / 3.0) * radius),
408            PyColliderShape::Cylinder {
409                radius,
410                half_height,
411            } => PI * radius * radius * 2.0 * half_height,
412            PyColliderShape::Cone {
413                radius,
414                half_height,
415            } => (1.0 / 3.0) * PI * radius * radius * 2.0 * half_height,
416        }
417    }
418
419    /// Whether this shape is a plane (infinite extents).
420    pub fn is_infinite(&self) -> bool {
421        matches!(self, PyColliderShape::Plane { .. })
422    }
423}
424
425// ---------------------------------------------------------------------------
426// PyRigidBodyConfig
427// ---------------------------------------------------------------------------
428
429/// Configuration for creating a new rigid body.
430///
431/// Passed to `PyPhysicsWorld::add_rigid_body`.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct PyRigidBodyConfig {
434    /// Mass in kilograms. Use 0.0 or `f64::INFINITY` for a static/kinematic body.
435    pub mass: f64,
436    /// Initial position `[x, y, z]`.
437    pub position: [f64; 3],
438    /// Initial linear velocity `[vx, vy, vz]`. Defaults to zero.
439    pub velocity: [f64; 3],
440    /// Initial orientation as quaternion `[x, y, z, w]`. Defaults to identity.
441    pub orientation: [f64; 4],
442    /// Initial angular velocity `[wx, wy, wz]`. Defaults to zero.
443    pub angular_velocity: [f64; 3],
444    /// Collider shapes attached to this body.
445    pub shapes: Vec<PyColliderShape>,
446    /// Friction coefficient (0 = frictionless, 1 = high friction).
447    pub friction: f64,
448    /// Restitution/bounciness (0 = perfectly inelastic, 1 = perfectly elastic).
449    pub restitution: f64,
450    /// Whether this body is static (immovable, infinite mass).
451    pub is_static: bool,
452    /// Whether this body is kinematic (moved manually, not by forces).
453    pub is_kinematic: bool,
454    /// Whether this body can go to sleep when motion is negligible.
455    pub can_sleep: bool,
456    /// Linear damping coefficient (drag).
457    pub linear_damping: f64,
458    /// Angular damping coefficient (rotational drag).
459    pub angular_damping: f64,
460    /// Optional user-defined tag/name for identification.
461    pub tag: Option<String>,
462}
463
464impl PyRigidBodyConfig {
465    /// Create a dynamic body at the given position with default settings.
466    pub fn dynamic(mass: f64, position: [f64; 3]) -> Self {
467        Self {
468            mass,
469            position,
470            velocity: [0.0; 3],
471            orientation: [0.0, 0.0, 0.0, 1.0],
472            angular_velocity: [0.0; 3],
473            shapes: vec![],
474            friction: 0.5,
475            restitution: 0.3,
476            is_static: false,
477            is_kinematic: false,
478            can_sleep: true,
479            linear_damping: 0.0,
480            angular_damping: 0.0,
481            tag: None,
482        }
483    }
484
485    /// Create a static body at the given position with default settings.
486    pub fn static_body(position: [f64; 3]) -> Self {
487        Self {
488            mass: 0.0,
489            position,
490            velocity: [0.0; 3],
491            orientation: [0.0, 0.0, 0.0, 1.0],
492            angular_velocity: [0.0; 3],
493            shapes: vec![],
494            friction: 0.5,
495            restitution: 0.3,
496            is_static: true,
497            is_kinematic: false,
498            can_sleep: false,
499            linear_damping: 0.0,
500            angular_damping: 0.0,
501            tag: None,
502        }
503    }
504
505    /// Add a collider shape to this body config and return `self` for chaining.
506    pub fn with_shape(mut self, shape: PyColliderShape) -> Self {
507        self.shapes.push(shape);
508        self
509    }
510
511    /// Set friction and return `self` for chaining.
512    pub fn with_friction(mut self, friction: f64) -> Self {
513        self.friction = friction;
514        self
515    }
516
517    /// Set restitution and return `self` for chaining.
518    pub fn with_restitution(mut self, restitution: f64) -> Self {
519        self.restitution = restitution;
520        self
521    }
522
523    /// Set linear damping and return `self` for chaining.
524    pub fn with_linear_damping(mut self, damping: f64) -> Self {
525        self.linear_damping = damping;
526        self
527    }
528
529    /// Set angular damping and return `self` for chaining.
530    pub fn with_angular_damping(mut self, damping: f64) -> Self {
531        self.angular_damping = damping;
532        self
533    }
534
535    /// Set tag and return `self` for chaining.
536    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
537        self.tag = Some(tag.into());
538        self
539    }
540
541    /// Effective inverse mass (0 for static/infinite).
542    pub fn inverse_mass(&self) -> f64 {
543        if self.is_static || self.mass <= 0.0 {
544            0.0
545        } else {
546            1.0 / self.mass
547        }
548    }
549}
550
551impl Default for PyRigidBodyConfig {
552    fn default() -> Self {
553        Self::dynamic(1.0, [0.0; 3])
554    }
555}
556
557// ---------------------------------------------------------------------------
558// PyContactResult
559// ---------------------------------------------------------------------------
560
561/// Result of a contact/collision between two bodies.
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct PyContactResult {
564    /// Handle of the first body involved.
565    pub body_a: u32,
566    /// Handle of the second body involved.
567    pub body_b: u32,
568    /// Contact point in world space.
569    pub contact_point: [f64; 3],
570    /// Contact normal (from body_a to body_b).
571    pub normal: [f64; 3],
572    /// Penetration depth (positive = overlapping).
573    pub depth: f64,
574    /// Combined friction coefficient for this contact.
575    pub friction: f64,
576    /// Combined restitution coefficient for this contact.
577    pub restitution: f64,
578    /// Impulse applied to resolve this contact.
579    pub impulse: f64,
580}
581
582impl PyContactResult {
583    /// Create a new contact result.
584    pub fn new(
585        body_a: u32,
586        body_b: u32,
587        contact_point: [f64; 3],
588        normal: [f64; 3],
589        depth: f64,
590    ) -> Self {
591        Self {
592            body_a,
593            body_b,
594            contact_point,
595            normal,
596            depth,
597            friction: 0.5,
598            restitution: 0.3,
599            impulse: 0.0,
600        }
601    }
602
603    /// Whether this contact represents a genuine collision (positive depth).
604    pub fn is_colliding(&self) -> bool {
605        self.depth > 0.0
606    }
607
608    /// Relative velocity of body_a with respect to body_b projected onto the normal.
609    /// A negative value means separating; positive means approaching.
610    pub fn separating_velocity(&self, vel_a: [f64; 3], vel_b: [f64; 3]) -> f64 {
611        let rel = [
612            vel_a[0] - vel_b[0],
613            vel_a[1] - vel_b[1],
614            vel_a[2] - vel_b[2],
615        ];
616        rel[0] * self.normal[0] + rel[1] * self.normal[1] + rel[2] * self.normal[2]
617    }
618}
619
620// ---------------------------------------------------------------------------
621// PySimConfig
622// ---------------------------------------------------------------------------
623
624/// Top-level simulation configuration.
625///
626/// Passed to `PyPhysicsWorld::new` to configure global simulation parameters.
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct PySimConfig {
629    /// Gravity vector `[gx, gy, gz]`.
630    pub gravity: [f64; 3],
631    /// Number of constraint solver iterations per step.
632    pub solver_iterations: u32,
633    /// Linear velocity threshold for sleep.
634    pub linear_sleep_threshold: f64,
635    /// Angular velocity threshold for sleep.
636    pub angular_sleep_threshold: f64,
637    /// Time (in seconds) a body must be below thresholds before sleeping.
638    pub time_before_sleep: f64,
639    /// Enable continuous collision detection.
640    pub ccd_enabled: bool,
641    /// Coefficient of restitution mixing method: "average", "min", or "max".
642    pub restitution_mixing: String,
643    /// Friction mixing method: "average", "min", or "max".
644    pub friction_mixing: String,
645    /// Maximum allowed penetration depth before a contact is ignored.
646    pub max_penetration: f64,
647    /// Baumgarte stabilization factor (0..1, typical 0.1–0.3).
648    pub baumgarte_factor: f64,
649    /// Whether sleeping is enabled globally.
650    pub sleep_enabled: bool,
651    /// Maximum substeps for CCD.
652    pub ccd_max_substeps: u32,
653}
654
655impl PySimConfig {
656    /// Standard Earth-gravity configuration.
657    pub fn earth_gravity() -> Self {
658        Self {
659            gravity: [0.0, -9.81, 0.0],
660            ..Self::default()
661        }
662    }
663
664    /// Zero-gravity (space) configuration.
665    pub fn zero_gravity() -> Self {
666        Self {
667            gravity: [0.0, 0.0, 0.0],
668            ..Self::default()
669        }
670    }
671
672    /// Moon-gravity configuration (~1/6 of Earth).
673    pub fn moon_gravity() -> Self {
674        Self {
675            gravity: [0.0, -1.62, 0.0],
676            ..Self::default()
677        }
678    }
679}
680
681impl Default for PySimConfig {
682    fn default() -> Self {
683        Self {
684            gravity: [0.0, -9.81, 0.0],
685            solver_iterations: 8,
686            linear_sleep_threshold: 0.01,
687            angular_sleep_threshold: 0.01,
688            time_before_sleep: 0.5,
689            ccd_enabled: true,
690            restitution_mixing: "average".to_string(),
691            friction_mixing: "average".to_string(),
692            max_penetration: 0.001,
693            baumgarte_factor: 0.2,
694            sleep_enabled: true,
695            ccd_max_substeps: 10,
696        }
697    }
698}
699
700// ---------------------------------------------------------------------------
701// Legacy types kept for backward compatibility
702// ---------------------------------------------------------------------------
703
704/// Description for creating a rigid body from Python (legacy).
705///
706/// Prefer `PyRigidBodyConfig` for new code.
707#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct PyRigidBodyDesc {
709    /// Mass in kilograms.
710    pub mass: f64,
711    /// Initial position.
712    pub position: PyVec3,
713    /// Whether this body is static (immovable).
714    pub is_static: bool,
715}
716
717/// Description for creating a collider from Python (legacy).
718///
719/// Prefer `PyColliderShape` for new code.
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct PyColliderDesc {
722    /// Shape type name (e.g. "sphere", "box").
723    pub shape_type: String,
724    /// Half-extents for box shapes.
725    pub half_extents: Option<PyVec3>,
726    /// Radius for sphere shapes.
727    pub radius: Option<f64>,
728    /// Friction coefficient.
729    pub friction: f64,
730    /// Restitution (bounciness) coefficient.
731    pub restitution: f64,
732}
733
734impl PyColliderDesc {
735    /// Create a sphere collider description with default material properties.
736    pub fn sphere(radius: f64) -> Self {
737        Self {
738            shape_type: "sphere".to_string(),
739            half_extents: None,
740            radius: Some(radius),
741            friction: 0.5,
742            restitution: 0.3,
743        }
744    }
745
746    /// Create a box collider description with default material properties.
747    pub fn box_shape(half_extents: PyVec3) -> Self {
748        Self {
749            shape_type: "box".to_string(),
750            half_extents: Some(half_extents),
751            radius: None,
752            friction: 0.5,
753            restitution: 0.3,
754        }
755    }
756}
757
758/// Material properties for Python interop.
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct PyMaterial {
761    /// Material name.
762    pub name: String,
763    /// Density in kg/m^3.
764    pub density: f64,
765    /// Friction coefficient.
766    pub friction: f64,
767    /// Restitution coefficient.
768    pub restitution: f64,
769}
770
771// ---------------------------------------------------------------------------
772// Tests
773// ---------------------------------------------------------------------------
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778
779    #[test]
780    fn test_pyvec3_roundtrip() {
781        let original = PyVec3::new(1.0, 2.0, 3.0);
782        let v3: Vec3 = original.into();
783        let back = PyVec3::from(v3);
784        assert_eq!(original, back);
785    }
786
787    #[test]
788    fn test_pytransform_conversion() {
789        let t = Transform::default();
790        let py_t = PyTransform::from(t);
791        assert!((py_t.position.x).abs() < 1e-10);
792        assert!((py_t.rotation[3] - 1.0).abs() < 1e-10); // w=1 for identity
793
794        let back: Transform = py_t.into();
795        assert!(back.position.norm() < 1e-10);
796    }
797
798    #[test]
799    fn test_py_vec3_arithmetic() {
800        let a = PyVec3::new(1.0, 2.0, 3.0);
801        let b = PyVec3::new(4.0, 5.0, 6.0);
802
803        // add
804        let sum = a.add(&b);
805        assert!((sum.x - 5.0).abs() < 1e-10);
806        assert!((sum.y - 7.0).abs() < 1e-10);
807        assert!((sum.z - 9.0).abs() < 1e-10);
808
809        // sub
810        let diff = b.sub(&a);
811        assert!((diff.x - 3.0).abs() < 1e-10);
812        assert!((diff.y - 3.0).abs() < 1e-10);
813        assert!((diff.z - 3.0).abs() < 1e-10);
814
815        // scale
816        let scaled = a.scale(2.0);
817        assert!((scaled.x - 2.0).abs() < 1e-10);
818        assert!((scaled.y - 4.0).abs() < 1e-10);
819        assert!((scaled.z - 6.0).abs() < 1e-10);
820    }
821
822    #[test]
823    fn test_py_transform_compose() {
824        let t1 = PyTransform {
825            position: PyVec3::new(1.0, 0.0, 0.0),
826            rotation: [0.0, 0.0, 0.0, 1.0],
827        };
828        let t2 = PyTransform {
829            position: PyVec3::new(2.0, 0.0, 0.0),
830            rotation: [0.0, 0.0, 0.0, 1.0],
831        };
832        let composed = t1.compose(&t2);
833        assert!((composed.position.x - 3.0).abs() < 1e-10);
834        assert!((composed.position.y).abs() < 1e-10);
835        assert!((composed.position.z).abs() < 1e-10);
836        // identity * identity = identity quaternion
837        assert!((composed.rotation[3] - 1.0).abs() < 1e-10);
838        assert!((composed.rotation[0]).abs() < 1e-10);
839    }
840
841    #[test]
842    fn test_py_collider_desc_sphere() {
843        let desc = PyColliderDesc::sphere(0.5);
844        assert_eq!(desc.shape_type, "sphere");
845        assert!((desc.radius.unwrap() - 0.5).abs() < 1e-10);
846        assert!(desc.half_extents.is_none());
847
848        let json = serde_json::to_string(&desc).expect("serialize");
849        assert!(json.contains("0.5"));
850        assert!(json.contains("sphere"));
851    }
852
853    #[test]
854    fn test_py_json_roundtrip() {
855        let original = PyVec3::new(1.5, -2.3, 0.7);
856        let json = serde_json::to_string(&original).expect("serialize PyVec3");
857        let restored: PyVec3 = serde_json::from_str(&json).expect("deserialize PyVec3");
858        assert!((restored.x - original.x).abs() < 1e-10);
859        assert!((restored.y - original.y).abs() < 1e-10);
860        assert!((restored.z - original.z).abs() < 1e-10);
861    }
862
863    #[test]
864    fn test_collider_shape_sphere_volume() {
865        let s = PyColliderShape::sphere(1.0);
866        let vol = s.approximate_volume();
867        let expected = (4.0 / 3.0) * std::f64::consts::PI;
868        assert!((vol - expected).abs() < 1e-10);
869    }
870
871    #[test]
872    fn test_collider_shape_box_volume() {
873        let b = PyColliderShape::box_shape(1.0, 2.0, 3.0);
874        let vol = b.approximate_volume();
875        // 8 * 1 * 2 * 3 = 48
876        assert!((vol - 48.0).abs() < 1e-10);
877    }
878
879    #[test]
880    fn test_collider_shape_plane_is_infinite() {
881        let p = PyColliderShape::plane([0.0, 1.0, 0.0], 0.0);
882        assert!(p.is_infinite());
883    }
884
885    #[test]
886    fn test_rigid_body_config_inverse_mass() {
887        let cfg = PyRigidBodyConfig::dynamic(2.0, [0.0; 3]);
888        assert!((cfg.inverse_mass() - 0.5).abs() < 1e-10);
889
890        let static_cfg = PyRigidBodyConfig::static_body([0.0; 3]);
891        assert!((static_cfg.inverse_mass()).abs() < 1e-10);
892    }
893
894    #[test]
895    fn test_rigid_body_config_builder() {
896        let cfg = PyRigidBodyConfig::dynamic(5.0, [1.0, 2.0, 3.0])
897            .with_friction(0.8)
898            .with_restitution(0.1)
899            .with_tag("test_body")
900            .with_shape(PyColliderShape::sphere(0.5));
901
902        assert!((cfg.friction - 0.8).abs() < 1e-10);
903        assert!((cfg.restitution - 0.1).abs() < 1e-10);
904        assert_eq!(cfg.tag.as_deref(), Some("test_body"));
905        assert_eq!(cfg.shapes.len(), 1);
906    }
907
908    #[test]
909    fn test_sim_config_default() {
910        let cfg = PySimConfig::default();
911        assert!((cfg.gravity[1] + 9.81).abs() < 1e-10);
912        assert_eq!(cfg.solver_iterations, 8);
913    }
914
915    #[test]
916    fn test_sim_config_moon_gravity() {
917        let cfg = PySimConfig::moon_gravity();
918        assert!((cfg.gravity[1] + 1.62).abs() < 1e-10);
919    }
920
921    #[test]
922    fn test_contact_result_is_colliding() {
923        let contact = PyContactResult::new(0, 1, [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.05);
924        assert!(contact.is_colliding());
925
926        let no_contact = PyContactResult::new(0, 1, [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], -0.01);
927        assert!(!no_contact.is_colliding());
928    }
929
930    #[test]
931    fn test_pyvec3_dot_cross() {
932        let a = PyVec3::new(1.0, 0.0, 0.0);
933        let b = PyVec3::new(0.0, 1.0, 0.0);
934        assert!((a.dot(&b)).abs() < 1e-10);
935
936        let c = a.cross(&b);
937        // (1,0,0) x (0,1,0) = (0,0,1)
938        assert!(c.x.abs() < 1e-10);
939        assert!(c.y.abs() < 1e-10);
940        assert!((c.z - 1.0).abs() < 1e-10);
941    }
942
943    #[test]
944    fn test_pyvec3_normalize() {
945        let v = PyVec3::new(3.0, 0.0, 4.0);
946        let n = v.normalized();
947        assert!((n.length() - 1.0).abs() < 1e-10);
948
949        let zero = PyVec3::zero();
950        let nz = zero.normalized();
951        assert!(nz.length() < 1e-10);
952    }
953
954    #[test]
955    fn test_pyaabb_contains() {
956        let aabb = PyAabb::new(PyVec3::new(-1.0, -1.0, -1.0), PyVec3::new(1.0, 1.0, 1.0));
957        assert!(aabb.contains_point(PyVec3::new(0.0, 0.0, 0.0)));
958        assert!(!aabb.contains_point(PyVec3::new(2.0, 0.0, 0.0)));
959    }
960
961    #[test]
962    fn test_pyaabb_intersects() {
963        let a = PyAabb::new(PyVec3::new(0.0, 0.0, 0.0), PyVec3::new(2.0, 2.0, 2.0));
964        let b = PyAabb::new(PyVec3::new(1.0, 1.0, 1.0), PyVec3::new(3.0, 3.0, 3.0));
965        let c = PyAabb::new(PyVec3::new(5.0, 5.0, 5.0), PyVec3::new(6.0, 6.0, 6.0));
966        assert!(a.intersects(&b));
967        assert!(!a.intersects(&c));
968    }
969
970    #[test]
971    fn test_rigid_body_config_json_roundtrip() {
972        let cfg = PyRigidBodyConfig::dynamic(3.0, [1.0, 2.0, 3.0])
973            .with_shape(PyColliderShape::sphere(0.5))
974            .with_friction(0.7);
975        let json = serde_json::to_string(&cfg).expect("serialize");
976        let restored: PyRigidBodyConfig = serde_json::from_str(&json).expect("deserialize");
977        assert!((restored.mass - 3.0).abs() < 1e-10);
978        assert_eq!(restored.shapes.len(), 1);
979        assert!((restored.friction - 0.7).abs() < 1e-10);
980    }
981
982    #[test]
983    fn test_contact_separating_velocity() {
984        let contact = PyContactResult::new(0, 1, [0.0; 3], [0.0, 1.0, 0.0], 0.01);
985        // Bodies moving apart: vel_a = (0,1,0), vel_b = (0,-1,0)
986        let sv = contact.separating_velocity([0.0, 1.0, 0.0], [0.0, -1.0, 0.0]);
987        // relative = (0,2,0), dot with normal (0,1,0) = 2
988        assert!((sv - 2.0).abs() < 1e-10);
989    }
990
991    #[test]
992    fn test_collider_shape_capsule_volume() {
993        let c = PyColliderShape::capsule(1.0, 2.0);
994        let vol = c.approximate_volume();
995        let expected = std::f64::consts::PI * 1.0 * 1.0 * (4.0 + (4.0 / 3.0) * 1.0);
996        assert!((vol - expected).abs() < 1e-10);
997    }
998
999    #[test]
1000    fn test_sim_config_zero_gravity() {
1001        let cfg = PySimConfig::zero_gravity();
1002        for &g in &cfg.gravity {
1003            assert!(g.abs() < 1e-10);
1004        }
1005    }
1006
1007    #[test]
1008    fn test_collider_shape_serde_roundtrip() {
1009        let shape = PyColliderShape::box_shape(0.5, 1.0, 1.5);
1010        let json = serde_json::to_string(&shape).expect("serialize shape");
1011        let back: PyColliderShape = serde_json::from_str(&json).expect("deserialize shape");
1012        assert_eq!(shape, back);
1013    }
1014}