Skip to main content

proof_engine/physics/
mod.rs

1//! Physics simulation — rigid bodies, constraints, collision, fluid dynamics.
2//!
3//! This module provides a simplified 2D/3D physics simulation layer for
4//! Proof Engine. All physical objects are mathematical entities — their
5//! motion is derived from differential equations integrated via RK4.
6//!
7//! ## Subsystems
8//! - `RigidBody`   — point masses with velocity, acceleration, forces
9//! - `Constraint`  — distance, angle, hinge, weld constraints
10//! - `Collider`    — circle/box/capsule shapes for broad/narrow phase
11//! - `PhysicsWorld`— simulation driver, constraint solver, broadphase
12//! - `FluidSolver` — grid-based Eulerian fluid (velocity + pressure)
13//! - `SoftBody`    — mass-spring soft body meshes
14//!
15//! All positions are in world-space float coordinates.
16
17use glam::{Vec2, Vec3};
18
19pub mod fluid;
20pub mod soft_body;
21pub mod rigid_body;
22pub mod constraints;
23pub mod joints;
24pub mod fluids;
25
26pub use joints::{Joint, JointType, Ragdoll, RagdollBone, CharacterController, JointSolver};
27
28pub use fluid::FluidGrid;
29pub use soft_body::SoftBody;
30
31// ── RigidBody ─────────────────────────────────────────────────────────────────
32
33/// A simulated rigid body (treated as a point mass for simplicity).
34#[derive(Debug, Clone)]
35pub struct RigidBody {
36    pub id:              BodyId,
37    pub position:        Vec2,
38    pub velocity:        Vec2,
39    pub acceleration:    Vec2,
40    pub mass:            f32,
41    pub inv_mass:        f32,   // 1/mass, 0 = static
42    pub restitution:     f32,   // bounciness [0, 1]
43    pub friction:        f32,   // surface friction [0, 1]
44    pub damping:         f32,   // linear damping [0, 1]
45    pub angle:           f32,   // rotation in radians
46    pub angular_vel:     f32,
47    pub angular_damp:    f32,
48    pub inertia:         f32,   // moment of inertia
49    pub inv_inertia:     f32,
50    pub force_accum:     Vec2,  // accumulated forces for this step
51    pub torque_accum:    f32,
52    pub is_sleeping:     bool,
53    pub collider:        Collider,
54    /// User-provided tag for identification.
55    pub tag:             u32,
56    pub layer:           u8,
57    pub mask:            u8,    // which layers to collide with
58}
59
60/// Unique body identifier.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub struct BodyId(pub u64);
63
64impl RigidBody {
65    /// Create a new dynamic body.
66    pub fn dynamic(position: Vec2, mass: f32, collider: Collider) -> Self {
67        let inertia = collider.compute_inertia(mass);
68        Self {
69            id:           BodyId(0),
70            position,
71            velocity:     Vec2::ZERO,
72            acceleration: Vec2::ZERO,
73            mass,
74            inv_mass:     if mass > 0.0 { 1.0 / mass } else { 0.0 },
75            restitution:  0.3,
76            friction:     0.5,
77            damping:      0.01,
78            angle:        0.0,
79            angular_vel:  0.0,
80            angular_damp: 0.01,
81            inertia,
82            inv_inertia:  if inertia > 0.0 { 1.0 / inertia } else { 0.0 },
83            force_accum:  Vec2::ZERO,
84            torque_accum: 0.0,
85            is_sleeping:  false,
86            collider,
87            tag:          0,
88            layer:        1,
89            mask:         0xFF,
90        }
91    }
92
93    /// Create a static body (unmovable).
94    pub fn static_body(position: Vec2, collider: Collider) -> Self {
95        Self {
96            inv_mass:     0.0,
97            inv_inertia:  0.0,
98            mass:         f32::INFINITY,
99            inertia:      f32::INFINITY,
100            ..Self::dynamic(position, 1.0, collider)
101        }
102    }
103
104    /// Apply a force at the body's center of mass.
105    pub fn apply_force(&mut self, force: Vec2) {
106        if self.inv_mass > 0.0 {
107            self.force_accum += force;
108        }
109    }
110
111    /// Apply a force at a world-space point (generates torque if off-center).
112    pub fn apply_force_at_point(&mut self, force: Vec2, point: Vec2) {
113        self.apply_force(force);
114        let r = point - self.position;
115        self.torque_accum += r.x * force.y - r.y * force.x;
116    }
117
118    /// Apply an impulse (instantaneous velocity change).
119    pub fn apply_impulse(&mut self, impulse: Vec2) {
120        self.velocity += impulse * self.inv_mass;
121    }
122
123    /// Clear accumulated forces.
124    pub fn clear_forces(&mut self) {
125        self.force_accum  = Vec2::ZERO;
126        self.torque_accum = 0.0;
127    }
128
129    /// Returns true if the body is static (infinite mass).
130    pub fn is_static(&self) -> bool { self.inv_mass == 0.0 }
131
132    /// Kinetic energy.
133    pub fn kinetic_energy(&self) -> f32 {
134        0.5 * self.mass * self.velocity.length_squared()
135        + 0.5 * self.inertia * self.angular_vel * self.angular_vel
136    }
137
138    /// Advance the body by one semi-implicit Euler step.
139    fn integrate(&mut self, dt: f32, gravity: Vec2) {
140        if self.is_static() || self.is_sleeping { return; }
141
142        // Linear
143        let total_force = self.force_accum + gravity * self.mass;
144        self.acceleration  = total_force * self.inv_mass;
145        self.velocity     += self.acceleration * dt;
146        self.velocity     *= 1.0 - self.damping * dt;
147        self.position     += self.velocity * dt;
148
149        // Angular
150        let alpha         = self.torque_accum * self.inv_inertia;
151        self.angular_vel += alpha * dt;
152        self.angular_vel *= 1.0 - self.angular_damp * dt;
153        self.angle       += self.angular_vel * dt;
154
155        self.clear_forces();
156    }
157
158    /// Set body to sleep if velocity is very small.
159    fn try_sleep(&mut self, sleep_threshold: f32) {
160        if self.velocity.length_squared() < sleep_threshold * sleep_threshold
161            && self.angular_vel.abs() < sleep_threshold
162        {
163            self.is_sleeping = true;
164            self.velocity    = Vec2::ZERO;
165            self.angular_vel = 0.0;
166        }
167    }
168
169    /// Wake the body from sleep.
170    pub fn wake(&mut self) { self.is_sleeping = false; }
171}
172
173// ── Collider ──────────────────────────────────────────────────────────────────
174
175/// Collision shape for a body.
176#[derive(Debug, Clone, Copy)]
177pub enum Collider {
178    Circle  { radius: f32 },
179    Box     { half_w: f32, half_h: f32 },
180    Capsule { radius: f32, half_height: f32 },
181    /// Point collider (treated as infinitesimally small circle).
182    Point,
183}
184
185impl Collider {
186    pub fn circle(radius: f32) -> Self { Self::Circle { radius } }
187    pub fn box_shape(w: f32, h: f32) -> Self { Self::Box { half_w: w * 0.5, half_h: h * 0.5 } }
188    pub fn capsule(radius: f32, height: f32) -> Self { Self::Capsule { radius, half_height: height * 0.5 } }
189
190    /// Compute the moment of inertia for this shape and mass.
191    pub fn compute_inertia(&self, mass: f32) -> f32 {
192        match self {
193            Self::Circle { radius }      => 0.5 * mass * radius * radius,
194            Self::Box { half_w, half_h } => mass * (half_w * half_w + half_h * half_h) / 3.0,
195            Self::Capsule { radius, half_height } => {
196                let r2 = radius * radius;
197                let h2 = half_height * half_height;
198                mass * (r2 + h2) / 3.0
199            }
200            Self::Point                  => 0.0,
201        }
202    }
203
204    /// Approximate AABB (axis-aligned bounding box).
205    pub fn aabb(&self) -> (Vec2, Vec2) {
206        match self {
207            Self::Circle { radius }      => (Vec2::splat(-radius), Vec2::splat(*radius)),
208            Self::Box { half_w, half_h } => (Vec2::new(-half_w, -half_h), Vec2::new(*half_w, *half_h)),
209            Self::Capsule { radius, half_height } => {
210                (Vec2::new(-radius, -half_height - radius),
211                 Vec2::new(*radius,  *half_height + radius))
212            }
213            Self::Point                  => (Vec2::ZERO, Vec2::ZERO),
214        }
215    }
216
217    /// Bounding radius.
218    pub fn bounding_radius(&self) -> f32 {
219        match self {
220            Self::Circle { radius }        => *radius,
221            Self::Box { half_w, half_h }   => half_w.hypot(*half_h),
222            Self::Capsule { radius, half_height } => radius + half_height,
223            Self::Point                    => 0.0,
224        }
225    }
226}
227
228// ── Collision detection ───────────────────────────────────────────────────────
229
230/// Result of a collision test between two bodies.
231#[derive(Debug, Clone)]
232pub struct ContactManifold {
233    pub body_a:  BodyId,
234    pub body_b:  BodyId,
235    /// Penetration depth (positive = overlap).
236    pub depth:   f32,
237    /// Normal pointing from A to B.
238    pub normal:  Vec2,
239    /// Contact point in world space.
240    pub point:   Vec2,
241}
242
243/// Test circle vs circle.
244pub fn circle_circle(
245    pos_a: Vec2, ra: f32,
246    pos_b: Vec2, rb: f32,
247    id_a: BodyId, id_b: BodyId,
248) -> Option<ContactManifold> {
249    let delta  = pos_b - pos_a;
250    let dist   = delta.length();
251    let sum_r  = ra + rb;
252    if dist >= sum_r || dist < 1e-8 { return None; }
253    let normal = if dist > 0.0 { delta / dist } else { Vec2::X };
254    Some(ContactManifold {
255        body_a: id_a, body_b: id_b,
256        depth:  sum_r - dist,
257        normal,
258        point:  pos_a + normal * ra,
259    })
260}
261
262/// Test AABB vs AABB (using bounding boxes).
263pub fn aabb_aabb(
264    pos_a: Vec2, (min_a, max_a): (Vec2, Vec2),
265    pos_b: Vec2, (min_b, max_b): (Vec2, Vec2),
266    id_a: BodyId, id_b: BodyId,
267) -> Option<ContactManifold> {
268    let a_min = pos_a + min_a;
269    let a_max = pos_a + max_a;
270    let b_min = pos_b + min_b;
271    let b_max = pos_b + max_b;
272
273    if a_max.x < b_min.x || b_max.x < a_min.x { return None; }
274    if a_max.y < b_min.y || b_max.y < a_min.y { return None; }
275
276    // Find minimum penetration axis
277    let dx = (a_max.x.min(b_max.x) - a_min.x.max(b_min.x)).abs();
278    let dy = (a_max.y.min(b_max.y) - a_min.y.max(b_min.y)).abs();
279    let (depth, normal) = if dx < dy {
280        let sign = if pos_a.x < pos_b.x { -1.0 } else { 1.0 };
281        (dx, Vec2::new(sign, 0.0))
282    } else {
283        let sign = if pos_a.y < pos_b.y { -1.0 } else { 1.0 };
284        (dy, Vec2::new(0.0, sign))
285    };
286
287    Some(ContactManifold {
288        body_a: id_a, body_b: id_b, depth, normal,
289        point: (pos_a + pos_b) * 0.5,
290    })
291}
292
293/// Resolve a contact manifold by applying impulses to both bodies.
294pub fn resolve_contact(a: &mut RigidBody, b: &mut RigidBody, manifold: &ContactManifold) {
295    // Relative velocity at contact point
296    let rel_vel = b.velocity - a.velocity;
297    let vel_along_normal = rel_vel.dot(manifold.normal);
298
299    // Don't resolve if separating
300    if vel_along_normal > 0.0 { return; }
301
302    let e         = a.restitution.min(b.restitution);
303    let j_scalar  = -(1.0 + e) * vel_along_normal / (a.inv_mass + b.inv_mass);
304    let impulse   = manifold.normal * j_scalar;
305
306    a.velocity -= impulse * a.inv_mass;
307    b.velocity += impulse * b.inv_mass;
308
309    // Positional correction (Baumgarte)
310    const SLOP:  f32 = 0.01;
311    const BIAS:  f32 = 0.2;
312    let correction = (manifold.depth - SLOP).max(0.0) * BIAS
313                   / (a.inv_mass + b.inv_mass);
314    let correction_v = manifold.normal * correction;
315    if !a.is_static() { a.position -= correction_v * a.inv_mass; }
316    if !b.is_static() { b.position += correction_v * b.inv_mass; }
317}
318
319// ── Constraints ───────────────────────────────────────────────────────────────
320
321/// A physical constraint between two bodies.
322#[derive(Debug, Clone)]
323pub enum Constraint {
324    /// Maintain a fixed distance between two bodies.
325    Distance {
326        body_a: BodyId,
327        body_b: BodyId,
328        rest_length: f32,
329        stiffness:   f32,
330        damping:     f32,
331    },
332    /// Spring constraint (distance with Hooke's law).
333    Spring {
334        body_a:    BodyId,
335        body_b:    BodyId,
336        rest_len:  f32,
337        k:         f32,    // spring constant
338        c:         f32,    // damping
339    },
340    /// Pin one body to a world-space position.
341    Pin {
342        body:     BodyId,
343        target:   Vec2,
344        stiffness: f32,
345    },
346    /// Keep body within an axis-aligned box.
347    Boundary {
348        body: BodyId,
349        min:  Vec2,
350        max:  Vec2,
351    },
352    /// Angular constraint (limit rotation range).
353    AngleLimit {
354        body:       BodyId,
355        min_angle:  f32,
356        max_angle:  f32,
357        stiffness:  f32,
358    },
359}
360
361impl Constraint {
362    /// Apply the constraint by modifying body forces/velocities.
363    pub fn apply(&self, bodies: &mut [RigidBody]) {
364        match self {
365            Constraint::Spring { body_a, body_b, rest_len, k, c } => {
366                let (idx_a, idx_b) = find_pair_indices(bodies, *body_a, *body_b);
367                if let (Some(ia), Some(ib)) = (idx_a, idx_b) {
368                    // Split borrow
369                    let (left, right) = bodies.split_at_mut(ia.max(ib));
370                    let (a, b) = if ia < ib {
371                        (&mut left[ia], &mut right[0])
372                    } else {
373                        (&mut right[0], &mut left[ib])
374                    };
375                    let delta  = b.position - a.position;
376                    let dist   = delta.length();
377                    if dist < 1e-6 { return; }
378                    let dir    = delta / dist;
379                    let spring = *k * (dist - rest_len);
380                    let damp   = *c * (b.velocity - a.velocity).dot(dir);
381                    let force  = (spring + damp) * dir;
382                    a.apply_force(force);
383                    b.apply_force(-force);
384                }
385            }
386            Constraint::Distance { body_a, body_b, rest_length, stiffness, damping } => {
387                let (idx_a, idx_b) = find_pair_indices(bodies, *body_a, *body_b);
388                if let (Some(ia), Some(ib)) = (idx_a, idx_b) {
389                    let (left, right) = bodies.split_at_mut(ia.max(ib));
390                    let (a, b) = if ia < ib {
391                        (&mut left[ia], &mut right[0])
392                    } else {
393                        (&mut right[0], &mut left[ib])
394                    };
395                    let delta  = b.position - a.position;
396                    let dist   = delta.length();
397                    if dist < 1e-6 { return; }
398                    let dir    = delta / dist;
399                    let err    = dist - rest_length;
400                    let damp   = *damping * (b.velocity - a.velocity).dot(dir);
401                    let force  = (err * stiffness + damp) * dir;
402                    a.apply_force( force);
403                    b.apply_force(-force);
404                }
405            }
406            Constraint::Pin { body, target, stiffness } => {
407                if let Some(b) = bodies.iter_mut().find(|b| b.id == *body) {
408                    let delta = *target - b.position;
409                    b.apply_force(delta * *stiffness);
410                }
411            }
412            Constraint::Boundary { body, min, max } => {
413                if let Some(b) = bodies.iter_mut().find(|b| b.id == *body) {
414                    if b.position.x < min.x { b.position.x = min.x; b.velocity.x = b.velocity.x.abs(); }
415                    if b.position.x > max.x { b.position.x = max.x; b.velocity.x = -b.velocity.x.abs(); }
416                    if b.position.y < min.y { b.position.y = min.y; b.velocity.y = b.velocity.y.abs(); }
417                    if b.position.y > max.y { b.position.y = max.y; b.velocity.y = -b.velocity.y.abs(); }
418                }
419            }
420            Constraint::AngleLimit { body, min_angle, max_angle, stiffness } => {
421                if let Some(b) = bodies.iter_mut().find(|b| b.id == *body) {
422                    if b.angle < *min_angle {
423                        let err = min_angle - b.angle;
424                        b.torque_accum += err * stiffness;
425                    } else if b.angle > *max_angle {
426                        let err = max_angle - b.angle;
427                        b.torque_accum += err * stiffness;
428                    }
429                }
430            }
431        }
432    }
433}
434
435fn find_pair_indices(bodies: &[RigidBody], a: BodyId, b: BodyId) -> (Option<usize>, Option<usize>) {
436    let ia = bodies.iter().position(|bod| bod.id == a);
437    let ib = bodies.iter().position(|bod| bod.id == b);
438    (ia, ib)
439}
440
441// ── PhysicsWorld ──────────────────────────────────────────────────────────────
442
443/// The physics simulation world.
444///
445/// Maintains a list of bodies and constraints, steps them each frame,
446/// performs collision detection and resolution.
447pub struct PhysicsWorld {
448    bodies:          Vec<RigidBody>,
449    constraints:     Vec<Constraint>,
450    pub gravity:     Vec2,
451    pub substeps:    u32,
452    pub sleep_threshold: f32,
453    next_id:         u64,
454    /// Collision events from last step.
455    contacts:        Vec<ContactManifold>,
456    /// Time accumulated for fixed-step integration.
457    accumulator:     f32,
458    pub fixed_dt:    f32,
459}
460
461impl PhysicsWorld {
462    pub fn new() -> Self {
463        Self {
464            bodies:          Vec::new(),
465            constraints:     Vec::new(),
466            gravity:         Vec2::new(0.0, -9.81),
467            substeps:        4,
468            sleep_threshold: 0.01,
469            next_id:         1,
470            contacts:        Vec::new(),
471            accumulator:     0.0,
472            fixed_dt:        1.0 / 120.0,
473        }
474    }
475
476    /// Spawn a body and return its ID.
477    pub fn add_body(&mut self, mut body: RigidBody) -> BodyId {
478        let id    = BodyId(self.next_id);
479        self.next_id += 1;
480        body.id   = id;
481        self.bodies.push(body);
482        id
483    }
484
485    /// Remove a body by ID.
486    pub fn remove_body(&mut self, id: BodyId) {
487        self.bodies.retain(|b| b.id != id);
488    }
489
490    /// Add a constraint.
491    pub fn add_constraint(&mut self, c: Constraint) {
492        self.constraints.push(c);
493    }
494
495    /// Get a body by ID (shared reference).
496    pub fn body(&self, id: BodyId) -> Option<&RigidBody> {
497        self.bodies.iter().find(|b| b.id == id)
498    }
499
500    /// Get a body by ID (mutable reference).
501    pub fn body_mut(&mut self, id: BodyId) -> Option<&mut RigidBody> {
502        self.bodies.iter_mut().find(|b| b.id == id)
503    }
504
505    /// Apply gravity from Vec3 (ignores Z).
506    pub fn set_gravity_3d(&mut self, g: Vec3) {
507        self.gravity = Vec2::new(g.x, g.y);
508    }
509
510    /// Advance the simulation by `dt` seconds, using fixed sub-steps.
511    pub fn step(&mut self, dt: f32) {
512        self.accumulator += dt;
513        while self.accumulator >= self.fixed_dt {
514            let step_dt = self.fixed_dt / self.substeps as f32;
515            for _ in 0..self.substeps {
516                self.fixed_step(step_dt);
517            }
518            self.accumulator -= self.fixed_dt;
519        }
520    }
521
522    fn fixed_step(&mut self, dt: f32) {
523        // Apply constraints (generates forces)
524        let constraints = self.constraints.clone();
525        for c in &constraints {
526            c.apply(&mut self.bodies);
527        }
528
529        // Integrate bodies
530        let gravity = self.gravity;
531        for body in &mut self.bodies {
532            body.integrate(dt, gravity);
533        }
534
535        // Broad phase + narrow phase collision
536        self.contacts.clear();
537        let n = self.bodies.len();
538        for i in 0..n {
539            for j in (i + 1)..n {
540                // Layer mask check
541                if self.bodies[i].layer & self.bodies[j].mask == 0
542                && self.bodies[j].layer & self.bodies[i].mask == 0 {
543                    continue;
544                }
545                // Skip both static
546                if self.bodies[i].is_static() && self.bodies[j].is_static() { continue; }
547
548                let pa = self.bodies[i].position;
549                let pb = self.bodies[j].position;
550                let ca = self.bodies[i].collider;
551                let cb = self.bodies[j].collider;
552                let id_a = self.bodies[i].id;
553                let id_b = self.bodies[j].id;
554
555                // Circle vs Circle fast path
556                let contact = match (ca, cb) {
557                    (Collider::Circle { radius: ra }, Collider::Circle { radius: rb }) => {
558                        circle_circle(pa, ra, pb, rb, id_a, id_b)
559                    }
560                    _ => {
561                        // Fall back to AABB
562                        aabb_aabb(pa, ca.aabb(), pb, cb.aabb(), id_a, id_b)
563                    }
564                };
565
566                if let Some(manifold) = contact {
567                    self.contacts.push(manifold);
568                }
569            }
570        }
571
572        // Resolve contacts
573        let contacts = self.contacts.clone();
574        for contact in &contacts {
575            let ia = self.bodies.iter().position(|b| b.id == contact.body_a);
576            let ib = self.bodies.iter().position(|b| b.id == contact.body_b);
577            if let (Some(ia), Some(ib)) = (ia, ib) {
578                let (left, right) = self.bodies.split_at_mut(ia.max(ib));
579                let (a, b) = if ia < ib {
580                    (&mut left[ia], &mut right[0])
581                } else {
582                    (&mut right[0], &mut left[ib])
583                };
584                resolve_contact(a, b, contact);
585            }
586        }
587
588        // Sleep pass
589        let threshold = self.sleep_threshold;
590        for body in &mut self.bodies {
591            body.try_sleep(threshold);
592        }
593    }
594
595    /// Returns all contact manifolds from the last step.
596    pub fn contacts(&self) -> &[ContactManifold] {
597        &self.contacts
598    }
599
600    /// Returns all body positions.
601    pub fn positions(&self) -> Vec<(BodyId, Vec2)> {
602        self.bodies.iter().map(|b| (b.id, b.position)).collect()
603    }
604
605    pub fn body_count(&self) -> usize { self.bodies.len() }
606}
607
608impl Default for PhysicsWorld {
609    fn default() -> Self { Self::new() }
610}
611
612// ── Utility ───────────────────────────────────────────────────────────────────
613
614/// Verlet integration step (position + velocity-Verlet).
615/// More accurate than Euler for conservative forces.
616pub fn verlet_step(pos: Vec2, vel: Vec2, acc: Vec2, dt: f32) -> (Vec2, Vec2) {
617    let new_pos = pos + vel * dt + acc * (0.5 * dt * dt);
618    // acc_new would be recalculated from forces at new_pos in a full verlet
619    let new_vel = vel + acc * dt;
620    (new_pos, new_vel)
621}
622
623/// Runge-Kutta 4 for a 2D position given a force function.
624pub fn rk4_2d<F>(pos: Vec2, vel: Vec2, mass: f32, dt: f32, force_fn: F) -> (Vec2, Vec2)
625where
626    F: Fn(Vec2, Vec2) -> Vec2
627{
628    let inv_m = 1.0 / mass.max(1e-8);
629    let k1_v  = force_fn(pos, vel) * inv_m;
630    let k1_p  = vel;
631
632    let k2_v  = force_fn(pos + k1_p * (dt * 0.5), vel + k1_v * (dt * 0.5)) * inv_m;
633    let k2_p  = vel + k1_v * (dt * 0.5);
634
635    let k3_v  = force_fn(pos + k2_p * (dt * 0.5), vel + k2_v * (dt * 0.5)) * inv_m;
636    let k3_p  = vel + k2_v * (dt * 0.5);
637
638    let k4_v  = force_fn(pos + k3_p * dt, vel + k3_v * dt) * inv_m;
639    let k4_p  = vel + k3_v * dt;
640
641    let new_vel = vel + (k1_v + k2_v * 2.0 + k3_v * 2.0 + k4_v) * (dt / 6.0);
642    let new_pos = pos + (k1_p + k2_p * 2.0 + k3_p * 2.0 + k4_p) * (dt / 6.0);
643    (new_pos, new_vel)
644}
645
646// ── Tests ─────────────────────────────────────────────────────────────────────
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn body_falls_under_gravity() {
654        let mut world = PhysicsWorld::new();
655        let id = world.add_body(RigidBody::dynamic(Vec2::ZERO, 1.0, Collider::circle(0.5)));
656        world.step(0.5);
657        let body = world.body(id).unwrap();
658        assert!(body.position.y < 0.0, "body should fall: y={}", body.position.y);
659    }
660
661    #[test]
662    fn static_body_does_not_move() {
663        let mut world = PhysicsWorld::new();
664        let id = world.add_body(RigidBody::static_body(Vec2::new(0.0, -5.0), Collider::box_shape(10.0, 1.0)));
665        world.step(1.0);
666        let body = world.body(id).unwrap();
667        assert_eq!(body.position, Vec2::new(0.0, -5.0));
668    }
669
670    #[test]
671    fn spring_constraint_attracts() {
672        let mut world = PhysicsWorld::new();
673        let id_a = world.add_body(RigidBody::dynamic(Vec2::new(-2.0, 0.0), 1.0, Collider::circle(0.1)));
674        let id_b = world.add_body(RigidBody::dynamic(Vec2::new( 2.0, 0.0), 1.0, Collider::circle(0.1)));
675        world.gravity = Vec2::ZERO;
676        world.add_constraint(Constraint::Spring { body_a: id_a, body_b: id_b, rest_len: 1.0, k: 10.0, c: 1.0 });
677        let dist_before = 4.0;
678        world.step(0.1);
679        let pa = world.body(id_a).unwrap().position;
680        let pb = world.body(id_b).unwrap().position;
681        let dist_after = (pb - pa).length();
682        assert!(dist_after < dist_before, "spring should pull bodies closer");
683    }
684
685    #[test]
686    fn circle_collision_detected() {
687        let a = circle_circle(Vec2::ZERO, 1.0, Vec2::new(1.5, 0.0), 1.0, BodyId(1), BodyId(2));
688        assert!(a.is_some(), "circles should overlap");
689        let b = circle_circle(Vec2::ZERO, 1.0, Vec2::new(3.0, 0.0), 1.0, BodyId(1), BodyId(2));
690        assert!(b.is_none(), "circles should not overlap");
691    }
692}