#![allow(missing_docs)]
use oxiphysics_core::math::Vec3;
use oxiphysics_core::{Aabb, Transform};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct PyVec3 {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl PyVec3 {
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
pub fn zero() -> Self {
Self::new(0.0, 0.0, 0.0)
}
pub fn dot(&self, other: &PyVec3) -> f64 {
self.x * other.x + self.y * other.y + self.z * other.z
}
pub fn cross(&self, other: &PyVec3) -> PyVec3 {
PyVec3::new(
self.y * other.z - self.z * other.y,
self.z * other.x - self.x * other.z,
self.x * other.y - self.y * other.x,
)
}
pub fn length_squared(&self) -> f64 {
self.x * self.x + self.y * self.y + self.z * self.z
}
pub fn length(&self) -> f64 {
self.length_squared().sqrt()
}
pub fn normalized(&self) -> PyVec3 {
let len = self.length();
if len < 1e-12 {
PyVec3::zero()
} else {
PyVec3::new(self.x / len, self.y / len, self.z / len)
}
}
pub fn scale(&self, s: f64) -> PyVec3 {
PyVec3::new(self.x * s, self.y * s, self.z * s)
}
pub fn add(&self, other: &PyVec3) -> PyVec3 {
PyVec3::new(self.x + other.x, self.y + other.y, self.z + other.z)
}
pub fn sub(&self, other: &PyVec3) -> PyVec3 {
PyVec3::new(self.x - other.x, self.y - other.y, self.z - other.z)
}
pub fn to_array(&self) -> [f64; 3] {
[self.x, self.y, self.z]
}
pub fn from_array(arr: [f64; 3]) -> Self {
Self::new(arr[0], arr[1], arr[2])
}
}
impl From<Vec3> for PyVec3 {
fn from(v: Vec3) -> Self {
Self {
x: v.x,
y: v.y,
z: v.z,
}
}
}
impl From<PyVec3> for Vec3 {
fn from(v: PyVec3) -> Self {
Vec3::new(v.x, v.y, v.z)
}
}
impl From<[f64; 3]> for PyVec3 {
fn from(arr: [f64; 3]) -> Self {
Self::new(arr[0], arr[1], arr[2])
}
}
impl From<PyVec3> for [f64; 3] {
fn from(v: PyVec3) -> Self {
[v.x, v.y, v.z]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PyTransform {
pub position: PyVec3,
pub rotation: [f64; 4],
}
impl PyTransform {
pub fn identity() -> Self {
Self {
position: PyVec3::zero(),
rotation: [0.0, 0.0, 0.0, 1.0],
}
}
pub fn compose(&self, other: &PyTransform) -> PyTransform {
let (ax, ay, az, aw) = (
self.rotation[0],
self.rotation[1],
self.rotation[2],
self.rotation[3],
);
let (bx, by, bz, bw) = (
other.rotation[0],
other.rotation[1],
other.rotation[2],
other.rotation[3],
);
let rx = bw * ax + bx * aw + by * az - bz * ay;
let ry = bw * ay - bx * az + by * aw + bz * ax;
let rz = bw * az + bx * ay - by * ax + bz * aw;
let rw = bw * aw - bx * ax - by * ay - bz * az;
PyTransform {
position: PyVec3::new(
self.position.x + other.position.x,
self.position.y + other.position.y,
self.position.z + other.position.z,
),
rotation: [rx, ry, rz, rw],
}
}
pub fn inverse(&self) -> PyTransform {
let inv_rot = [
-self.rotation[0],
-self.rotation[1],
-self.rotation[2],
self.rotation[3],
];
let px = self.position.x;
let py = self.position.y;
let pz = self.position.z;
let (qx, qy, qz, qw) = (inv_rot[0], inv_rot[1], inv_rot[2], inv_rot[3]);
let t_x = 2.0 * (qy * pz - qz * py);
let t_y = 2.0 * (qz * px - qx * pz);
let t_z = 2.0 * (qx * py - qy * px);
let rx = px + qw * t_x + (qy * t_z - qz * t_y);
let ry = py + qw * t_y + (qz * t_x - qx * t_z);
let rz = pz + qw * t_z + (qx * t_y - qy * t_x);
PyTransform {
position: PyVec3::new(-rx, -ry, -rz),
rotation: inv_rot,
}
}
}
impl Default for PyTransform {
fn default() -> Self {
Self::identity()
}
}
impl From<Transform> for PyTransform {
fn from(t: Transform) -> Self {
let q = t.rotation;
Self {
position: PyVec3::from(t.position),
rotation: [q.i, q.j, q.k, q.w],
}
}
}
impl From<PyTransform> for Transform {
fn from(t: PyTransform) -> Self {
let pos: Vec3 = t.position.into();
let q = nalgebra::UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(
t.rotation[3],
t.rotation[0],
t.rotation[1],
t.rotation[2],
));
Transform::new(pos, q)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PyAabb {
pub min: PyVec3,
pub max: PyVec3,
}
impl PyAabb {
pub fn new(min: PyVec3, max: PyVec3) -> Self {
Self { min, max }
}
pub fn center(&self) -> PyVec3 {
PyVec3::new(
(self.min.x + self.max.x) * 0.5,
(self.min.y + self.max.y) * 0.5,
(self.min.z + self.max.z) * 0.5,
)
}
pub fn half_extents(&self) -> PyVec3 {
PyVec3::new(
(self.max.x - self.min.x) * 0.5,
(self.max.y - self.min.y) * 0.5,
(self.max.z - self.min.z) * 0.5,
)
}
pub fn contains_point(&self, p: PyVec3) -> bool {
p.x >= self.min.x
&& p.x <= self.max.x
&& p.y >= self.min.y
&& p.y <= self.max.y
&& p.z >= self.min.z
&& p.z <= self.max.z
}
pub fn intersects(&self, other: &PyAabb) -> bool {
self.min.x <= other.max.x
&& self.max.x >= other.min.x
&& self.min.y <= other.max.y
&& self.max.y >= other.min.y
&& self.min.z <= other.max.z
&& self.max.z >= other.min.z
}
}
impl From<Aabb> for PyAabb {
fn from(a: Aabb) -> Self {
Self {
min: PyVec3::from(a.min),
max: PyVec3::from(a.max),
}
}
}
impl From<PyAabb> for Aabb {
fn from(a: PyAabb) -> Self {
Aabb::new(a.min.into(), a.max.into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PyColliderShape {
Sphere {
radius: f64,
},
Box {
half_extents: [f64; 3],
},
Plane {
normal: [f64; 3],
distance: f64,
},
Capsule {
radius: f64,
half_height: f64,
},
Cylinder {
radius: f64,
half_height: f64,
},
Cone {
radius: f64,
half_height: f64,
},
}
impl PyColliderShape {
pub fn sphere(radius: f64) -> Self {
PyColliderShape::Sphere { radius }
}
pub fn box_shape(hx: f64, hy: f64, hz: f64) -> Self {
PyColliderShape::Box {
half_extents: [hx, hy, hz],
}
}
pub fn plane(normal: [f64; 3], distance: f64) -> Self {
PyColliderShape::Plane { normal, distance }
}
pub fn capsule(radius: f64, half_height: f64) -> Self {
PyColliderShape::Capsule {
radius,
half_height,
}
}
pub fn approximate_volume(&self) -> f64 {
const PI: f64 = std::f64::consts::PI;
match self {
PyColliderShape::Sphere { radius } => (4.0 / 3.0) * PI * radius * radius * radius,
PyColliderShape::Box { half_extents } => {
8.0 * half_extents[0] * half_extents[1] * half_extents[2]
}
PyColliderShape::Plane { .. } => f64::INFINITY,
PyColliderShape::Capsule {
radius,
half_height,
} => PI * radius * radius * (2.0 * half_height + (4.0 / 3.0) * radius),
PyColliderShape::Cylinder {
radius,
half_height,
} => PI * radius * radius * 2.0 * half_height,
PyColliderShape::Cone {
radius,
half_height,
} => (1.0 / 3.0) * PI * radius * radius * 2.0 * half_height,
}
}
pub fn is_infinite(&self) -> bool {
matches!(self, PyColliderShape::Plane { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PyRigidBodyConfig {
pub mass: f64,
pub position: [f64; 3],
pub velocity: [f64; 3],
pub orientation: [f64; 4],
pub angular_velocity: [f64; 3],
pub shapes: Vec<PyColliderShape>,
pub friction: f64,
pub restitution: f64,
pub is_static: bool,
pub is_kinematic: bool,
pub can_sleep: bool,
pub linear_damping: f64,
pub angular_damping: f64,
pub tag: Option<String>,
}
impl PyRigidBodyConfig {
pub fn dynamic(mass: f64, position: [f64; 3]) -> Self {
Self {
mass,
position,
velocity: [0.0; 3],
orientation: [0.0, 0.0, 0.0, 1.0],
angular_velocity: [0.0; 3],
shapes: vec![],
friction: 0.5,
restitution: 0.3,
is_static: false,
is_kinematic: false,
can_sleep: true,
linear_damping: 0.0,
angular_damping: 0.0,
tag: None,
}
}
pub fn static_body(position: [f64; 3]) -> Self {
Self {
mass: 0.0,
position,
velocity: [0.0; 3],
orientation: [0.0, 0.0, 0.0, 1.0],
angular_velocity: [0.0; 3],
shapes: vec![],
friction: 0.5,
restitution: 0.3,
is_static: true,
is_kinematic: false,
can_sleep: false,
linear_damping: 0.0,
angular_damping: 0.0,
tag: None,
}
}
pub fn with_shape(mut self, shape: PyColliderShape) -> Self {
self.shapes.push(shape);
self
}
pub fn with_friction(mut self, friction: f64) -> Self {
self.friction = friction;
self
}
pub fn with_restitution(mut self, restitution: f64) -> Self {
self.restitution = restitution;
self
}
pub fn with_linear_damping(mut self, damping: f64) -> Self {
self.linear_damping = damping;
self
}
pub fn with_angular_damping(mut self, damping: f64) -> Self {
self.angular_damping = damping;
self
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tag = Some(tag.into());
self
}
pub fn inverse_mass(&self) -> f64 {
if self.is_static || self.mass <= 0.0 {
0.0
} else {
1.0 / self.mass
}
}
}
impl Default for PyRigidBodyConfig {
fn default() -> Self {
Self::dynamic(1.0, [0.0; 3])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PyContactResult {
pub body_a: u32,
pub body_b: u32,
pub contact_point: [f64; 3],
pub normal: [f64; 3],
pub depth: f64,
pub friction: f64,
pub restitution: f64,
pub impulse: f64,
}
impl PyContactResult {
pub fn new(
body_a: u32,
body_b: u32,
contact_point: [f64; 3],
normal: [f64; 3],
depth: f64,
) -> Self {
Self {
body_a,
body_b,
contact_point,
normal,
depth,
friction: 0.5,
restitution: 0.3,
impulse: 0.0,
}
}
pub fn is_colliding(&self) -> bool {
self.depth > 0.0
}
pub fn separating_velocity(&self, vel_a: [f64; 3], vel_b: [f64; 3]) -> f64 {
let rel = [
vel_a[0] - vel_b[0],
vel_a[1] - vel_b[1],
vel_a[2] - vel_b[2],
];
rel[0] * self.normal[0] + rel[1] * self.normal[1] + rel[2] * self.normal[2]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PySimConfig {
pub gravity: [f64; 3],
pub solver_iterations: u32,
pub linear_sleep_threshold: f64,
pub angular_sleep_threshold: f64,
pub time_before_sleep: f64,
pub ccd_enabled: bool,
pub restitution_mixing: String,
pub friction_mixing: String,
pub max_penetration: f64,
pub baumgarte_factor: f64,
pub sleep_enabled: bool,
pub ccd_max_substeps: u32,
}
impl PySimConfig {
pub fn earth_gravity() -> Self {
Self {
gravity: [0.0, -9.81, 0.0],
..Self::default()
}
}
pub fn zero_gravity() -> Self {
Self {
gravity: [0.0, 0.0, 0.0],
..Self::default()
}
}
pub fn moon_gravity() -> Self {
Self {
gravity: [0.0, -1.62, 0.0],
..Self::default()
}
}
}
impl Default for PySimConfig {
fn default() -> Self {
Self {
gravity: [0.0, -9.81, 0.0],
solver_iterations: 8,
linear_sleep_threshold: 0.01,
angular_sleep_threshold: 0.01,
time_before_sleep: 0.5,
ccd_enabled: true,
restitution_mixing: "average".to_string(),
friction_mixing: "average".to_string(),
max_penetration: 0.001,
baumgarte_factor: 0.2,
sleep_enabled: true,
ccd_max_substeps: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PyRigidBodyDesc {
pub mass: f64,
pub position: PyVec3,
pub is_static: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PyColliderDesc {
pub shape_type: String,
pub half_extents: Option<PyVec3>,
pub radius: Option<f64>,
pub friction: f64,
pub restitution: f64,
}
impl PyColliderDesc {
pub fn sphere(radius: f64) -> Self {
Self {
shape_type: "sphere".to_string(),
half_extents: None,
radius: Some(radius),
friction: 0.5,
restitution: 0.3,
}
}
pub fn box_shape(half_extents: PyVec3) -> Self {
Self {
shape_type: "box".to_string(),
half_extents: Some(half_extents),
radius: None,
friction: 0.5,
restitution: 0.3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PyMaterial {
pub name: String,
pub density: f64,
pub friction: f64,
pub restitution: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pyvec3_roundtrip() {
let original = PyVec3::new(1.0, 2.0, 3.0);
let v3: Vec3 = original.into();
let back = PyVec3::from(v3);
assert_eq!(original, back);
}
#[test]
fn test_pytransform_conversion() {
let t = Transform::default();
let py_t = PyTransform::from(t);
assert!((py_t.position.x).abs() < 1e-10);
assert!((py_t.rotation[3] - 1.0).abs() < 1e-10);
let back: Transform = py_t.into();
assert!(back.position.norm() < 1e-10);
}
#[test]
fn test_py_vec3_arithmetic() {
let a = PyVec3::new(1.0, 2.0, 3.0);
let b = PyVec3::new(4.0, 5.0, 6.0);
let sum = a.add(&b);
assert!((sum.x - 5.0).abs() < 1e-10);
assert!((sum.y - 7.0).abs() < 1e-10);
assert!((sum.z - 9.0).abs() < 1e-10);
let diff = b.sub(&a);
assert!((diff.x - 3.0).abs() < 1e-10);
assert!((diff.y - 3.0).abs() < 1e-10);
assert!((diff.z - 3.0).abs() < 1e-10);
let scaled = a.scale(2.0);
assert!((scaled.x - 2.0).abs() < 1e-10);
assert!((scaled.y - 4.0).abs() < 1e-10);
assert!((scaled.z - 6.0).abs() < 1e-10);
}
#[test]
fn test_py_transform_compose() {
let t1 = PyTransform {
position: PyVec3::new(1.0, 0.0, 0.0),
rotation: [0.0, 0.0, 0.0, 1.0],
};
let t2 = PyTransform {
position: PyVec3::new(2.0, 0.0, 0.0),
rotation: [0.0, 0.0, 0.0, 1.0],
};
let composed = t1.compose(&t2);
assert!((composed.position.x - 3.0).abs() < 1e-10);
assert!((composed.position.y).abs() < 1e-10);
assert!((composed.position.z).abs() < 1e-10);
assert!((composed.rotation[3] - 1.0).abs() < 1e-10);
assert!((composed.rotation[0]).abs() < 1e-10);
}
#[test]
fn test_py_collider_desc_sphere() {
let desc = PyColliderDesc::sphere(0.5);
assert_eq!(desc.shape_type, "sphere");
assert!((desc.radius.unwrap() - 0.5).abs() < 1e-10);
assert!(desc.half_extents.is_none());
let json = serde_json::to_string(&desc).expect("serialize");
assert!(json.contains("0.5"));
assert!(json.contains("sphere"));
}
#[test]
fn test_py_json_roundtrip() {
let original = PyVec3::new(1.5, -2.3, 0.7);
let json = serde_json::to_string(&original).expect("serialize PyVec3");
let restored: PyVec3 = serde_json::from_str(&json).expect("deserialize PyVec3");
assert!((restored.x - original.x).abs() < 1e-10);
assert!((restored.y - original.y).abs() < 1e-10);
assert!((restored.z - original.z).abs() < 1e-10);
}
#[test]
fn test_collider_shape_sphere_volume() {
let s = PyColliderShape::sphere(1.0);
let vol = s.approximate_volume();
let expected = (4.0 / 3.0) * std::f64::consts::PI;
assert!((vol - expected).abs() < 1e-10);
}
#[test]
fn test_collider_shape_box_volume() {
let b = PyColliderShape::box_shape(1.0, 2.0, 3.0);
let vol = b.approximate_volume();
assert!((vol - 48.0).abs() < 1e-10);
}
#[test]
fn test_collider_shape_plane_is_infinite() {
let p = PyColliderShape::plane([0.0, 1.0, 0.0], 0.0);
assert!(p.is_infinite());
}
#[test]
fn test_rigid_body_config_inverse_mass() {
let cfg = PyRigidBodyConfig::dynamic(2.0, [0.0; 3]);
assert!((cfg.inverse_mass() - 0.5).abs() < 1e-10);
let static_cfg = PyRigidBodyConfig::static_body([0.0; 3]);
assert!((static_cfg.inverse_mass()).abs() < 1e-10);
}
#[test]
fn test_rigid_body_config_builder() {
let cfg = PyRigidBodyConfig::dynamic(5.0, [1.0, 2.0, 3.0])
.with_friction(0.8)
.with_restitution(0.1)
.with_tag("test_body")
.with_shape(PyColliderShape::sphere(0.5));
assert!((cfg.friction - 0.8).abs() < 1e-10);
assert!((cfg.restitution - 0.1).abs() < 1e-10);
assert_eq!(cfg.tag.as_deref(), Some("test_body"));
assert_eq!(cfg.shapes.len(), 1);
}
#[test]
fn test_sim_config_default() {
let cfg = PySimConfig::default();
assert!((cfg.gravity[1] + 9.81).abs() < 1e-10);
assert_eq!(cfg.solver_iterations, 8);
}
#[test]
fn test_sim_config_moon_gravity() {
let cfg = PySimConfig::moon_gravity();
assert!((cfg.gravity[1] + 1.62).abs() < 1e-10);
}
#[test]
fn test_contact_result_is_colliding() {
let contact = PyContactResult::new(0, 1, [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.05);
assert!(contact.is_colliding());
let no_contact = PyContactResult::new(0, 1, [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], -0.01);
assert!(!no_contact.is_colliding());
}
#[test]
fn test_pyvec3_dot_cross() {
let a = PyVec3::new(1.0, 0.0, 0.0);
let b = PyVec3::new(0.0, 1.0, 0.0);
assert!((a.dot(&b)).abs() < 1e-10);
let c = a.cross(&b);
assert!(c.x.abs() < 1e-10);
assert!(c.y.abs() < 1e-10);
assert!((c.z - 1.0).abs() < 1e-10);
}
#[test]
fn test_pyvec3_normalize() {
let v = PyVec3::new(3.0, 0.0, 4.0);
let n = v.normalized();
assert!((n.length() - 1.0).abs() < 1e-10);
let zero = PyVec3::zero();
let nz = zero.normalized();
assert!(nz.length() < 1e-10);
}
#[test]
fn test_pyaabb_contains() {
let aabb = PyAabb::new(PyVec3::new(-1.0, -1.0, -1.0), PyVec3::new(1.0, 1.0, 1.0));
assert!(aabb.contains_point(PyVec3::new(0.0, 0.0, 0.0)));
assert!(!aabb.contains_point(PyVec3::new(2.0, 0.0, 0.0)));
}
#[test]
fn test_pyaabb_intersects() {
let a = PyAabb::new(PyVec3::new(0.0, 0.0, 0.0), PyVec3::new(2.0, 2.0, 2.0));
let b = PyAabb::new(PyVec3::new(1.0, 1.0, 1.0), PyVec3::new(3.0, 3.0, 3.0));
let c = PyAabb::new(PyVec3::new(5.0, 5.0, 5.0), PyVec3::new(6.0, 6.0, 6.0));
assert!(a.intersects(&b));
assert!(!a.intersects(&c));
}
#[test]
fn test_rigid_body_config_json_roundtrip() {
let cfg = PyRigidBodyConfig::dynamic(3.0, [1.0, 2.0, 3.0])
.with_shape(PyColliderShape::sphere(0.5))
.with_friction(0.7);
let json = serde_json::to_string(&cfg).expect("serialize");
let restored: PyRigidBodyConfig = serde_json::from_str(&json).expect("deserialize");
assert!((restored.mass - 3.0).abs() < 1e-10);
assert_eq!(restored.shapes.len(), 1);
assert!((restored.friction - 0.7).abs() < 1e-10);
}
#[test]
fn test_contact_separating_velocity() {
let contact = PyContactResult::new(0, 1, [0.0; 3], [0.0, 1.0, 0.0], 0.01);
let sv = contact.separating_velocity([0.0, 1.0, 0.0], [0.0, -1.0, 0.0]);
assert!((sv - 2.0).abs() < 1e-10);
}
#[test]
fn test_collider_shape_capsule_volume() {
let c = PyColliderShape::capsule(1.0, 2.0);
let vol = c.approximate_volume();
let expected = std::f64::consts::PI * 1.0 * 1.0 * (4.0 + (4.0 / 3.0) * 1.0);
assert!((vol - expected).abs() < 1e-10);
}
#[test]
fn test_sim_config_zero_gravity() {
let cfg = PySimConfig::zero_gravity();
for &g in &cfg.gravity {
assert!(g.abs() < 1e-10);
}
}
#[test]
fn test_collider_shape_serde_roundtrip() {
let shape = PyColliderShape::box_shape(0.5, 1.0, 1.5);
let json = serde_json::to_string(&shape).expect("serialize shape");
let back: PyColliderShape = serde_json::from_str(&json).expect("deserialize shape");
assert_eq!(shape, back);
}
}