1#![allow(missing_docs)]
13
14use oxiphysics_core::math::Vec3;
15use oxiphysics_core::{Aabb, Transform};
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
26pub struct PyVec3 {
27 pub x: f64,
29 pub y: f64,
31 pub z: f64,
33}
34
35impl PyVec3 {
36 pub fn new(x: f64, y: f64, z: f64) -> Self {
38 Self { x, y, z }
39 }
40
41 pub fn zero() -> Self {
43 Self::new(0.0, 0.0, 0.0)
44 }
45
46 pub fn dot(&self, other: &PyVec3) -> f64 {
48 self.x * other.x + self.y * other.y + self.z * other.z
49 }
50
51 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 pub fn length_squared(&self) -> f64 {
62 self.x * self.x + self.y * self.y + self.z * self.z
63 }
64
65 pub fn length(&self) -> f64 {
67 self.length_squared().sqrt()
68 }
69
70 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 pub fn scale(&self, s: f64) -> PyVec3 {
82 PyVec3::new(self.x * s, self.y * s, self.z * s)
83 }
84
85 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 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 pub fn to_array(&self) -> [f64; 3] {
97 [self.x, self.y, self.z]
98 }
99
100 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#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct PyTransform {
143 pub position: PyVec3,
145 pub rotation: [f64; 4],
147}
148
149impl PyTransform {
150 pub fn identity() -> Self {
152 Self {
153 position: PyVec3::zero(),
154 rotation: [0.0, 0.0, 0.0, 1.0],
155 }
156 }
157
158 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 pub fn inverse(&self) -> PyTransform {
190 let inv_rot = [
193 -self.rotation[0],
194 -self.rotation[1],
195 -self.rotation[2],
196 self.rotation[3],
197 ];
198 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct PyAabb {
253 pub min: PyVec3,
255 pub max: PyVec3,
257}
258
259impl PyAabb {
260 pub fn new(min: PyVec3, max: PyVec3) -> Self {
262 Self { min, max }
263 }
264
265 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
327#[serde(tag = "type", rename_all = "snake_case")]
328pub enum PyColliderShape {
329 Sphere {
331 radius: f64,
333 },
334 Box {
336 half_extents: [f64; 3],
338 },
339 Plane {
341 normal: [f64; 3],
343 distance: f64,
345 },
346 Capsule {
348 radius: f64,
350 half_height: f64,
352 },
353 Cylinder {
355 radius: f64,
357 half_height: f64,
359 },
360 Cone {
362 radius: f64,
364 half_height: f64,
366 },
367}
368
369impl PyColliderShape {
370 pub fn sphere(radius: f64) -> Self {
372 PyColliderShape::Sphere { radius }
373 }
374
375 pub fn box_shape(hx: f64, hy: f64, hz: f64) -> Self {
377 PyColliderShape::Box {
378 half_extents: [hx, hy, hz],
379 }
380 }
381
382 pub fn plane(normal: [f64; 3], distance: f64) -> Self {
384 PyColliderShape::Plane { normal, distance }
385 }
386
387 pub fn capsule(radius: f64, half_height: f64) -> Self {
389 PyColliderShape::Capsule {
390 radius,
391 half_height,
392 }
393 }
394
395 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 pub fn is_infinite(&self) -> bool {
421 matches!(self, PyColliderShape::Plane { .. })
422 }
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct PyRigidBodyConfig {
434 pub mass: f64,
436 pub position: [f64; 3],
438 pub velocity: [f64; 3],
440 pub orientation: [f64; 4],
442 pub angular_velocity: [f64; 3],
444 pub shapes: Vec<PyColliderShape>,
446 pub friction: f64,
448 pub restitution: f64,
450 pub is_static: bool,
452 pub is_kinematic: bool,
454 pub can_sleep: bool,
456 pub linear_damping: f64,
458 pub angular_damping: f64,
460 pub tag: Option<String>,
462}
463
464impl PyRigidBodyConfig {
465 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 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 pub fn with_shape(mut self, shape: PyColliderShape) -> Self {
507 self.shapes.push(shape);
508 self
509 }
510
511 pub fn with_friction(mut self, friction: f64) -> Self {
513 self.friction = friction;
514 self
515 }
516
517 pub fn with_restitution(mut self, restitution: f64) -> Self {
519 self.restitution = restitution;
520 self
521 }
522
523 pub fn with_linear_damping(mut self, damping: f64) -> Self {
525 self.linear_damping = damping;
526 self
527 }
528
529 pub fn with_angular_damping(mut self, damping: f64) -> Self {
531 self.angular_damping = damping;
532 self
533 }
534
535 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
537 self.tag = Some(tag.into());
538 self
539 }
540
541 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#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct PyContactResult {
564 pub body_a: u32,
566 pub body_b: u32,
568 pub contact_point: [f64; 3],
570 pub normal: [f64; 3],
572 pub depth: f64,
574 pub friction: f64,
576 pub restitution: f64,
578 pub impulse: f64,
580}
581
582impl PyContactResult {
583 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 pub fn is_colliding(&self) -> bool {
605 self.depth > 0.0
606 }
607
608 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#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct PySimConfig {
629 pub gravity: [f64; 3],
631 pub solver_iterations: u32,
633 pub linear_sleep_threshold: f64,
635 pub angular_sleep_threshold: f64,
637 pub time_before_sleep: f64,
639 pub ccd_enabled: bool,
641 pub restitution_mixing: String,
643 pub friction_mixing: String,
645 pub max_penetration: f64,
647 pub baumgarte_factor: f64,
649 pub sleep_enabled: bool,
651 pub ccd_max_substeps: u32,
653}
654
655impl PySimConfig {
656 pub fn earth_gravity() -> Self {
658 Self {
659 gravity: [0.0, -9.81, 0.0],
660 ..Self::default()
661 }
662 }
663
664 pub fn zero_gravity() -> Self {
666 Self {
667 gravity: [0.0, 0.0, 0.0],
668 ..Self::default()
669 }
670 }
671
672 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#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct PyRigidBodyDesc {
709 pub mass: f64,
711 pub position: PyVec3,
713 pub is_static: bool,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct PyColliderDesc {
722 pub shape_type: String,
724 pub half_extents: Option<PyVec3>,
726 pub radius: Option<f64>,
728 pub friction: f64,
730 pub restitution: f64,
732}
733
734impl PyColliderDesc {
735 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct PyMaterial {
761 pub name: String,
763 pub density: f64,
765 pub friction: f64,
767 pub restitution: f64,
769}
770
771#[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); 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 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 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 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 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 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 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 let sv = contact.separating_velocity([0.0, 1.0, 0.0], [0.0, -1.0, 0.0]);
987 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}