Skip to main content

oxiphysics_collision/narrowphase/
manifold.rs

1//! Contact manifold clipping for narrow-phase collision detection.
2//!
3//! Implements Sutherland-Hodgman polygon clipping in 2D and 3D, box-box
4//! manifold construction via reference/incident face identification and
5//! polygon clipping, and manifold reduction to a bounded contact set.
6
7// Copyright 2026 COOLJAPAN OU (Team KitaSan)
8// SPDX-License-Identifier: Apache-2.0
9
10// ─── ContactPoint ────────────────────────────────────────────────────────────
11
12/// A single contact point produced by narrow-phase collision detection.
13#[derive(Debug, Clone)]
14pub struct ContactPoint {
15    /// World-space position of the contact.
16    pub point: [f64; 3],
17    /// Contact normal (unit vector, from body B toward body A).
18    pub normal: [f64; 3],
19    /// Penetration depth (positive when bodies overlap).
20    pub depth: f64,
21}
22
23// ─── ContactManifold ─────────────────────────────────────────────────────────
24
25/// A set of contact points sharing a common contact normal.
26#[derive(Debug, Clone)]
27pub struct ContactManifold {
28    /// Individual contact points in this manifold.
29    pub contacts: Vec<ContactPoint>,
30    /// Shared contact normal for the manifold.
31    pub normal: [f64; 3],
32}
33
34impl ContactManifold {
35    /// Create an empty manifold with the given normal.
36    pub fn new(normal: [f64; 3]) -> Self {
37        Self {
38            contacts: Vec::new(),
39            normal,
40        }
41    }
42}
43
44// ─── Math helpers ─────────────────────────────────────────────────────────────
45
46#[inline]
47fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
48    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
49}
50
51#[inline]
52fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
53    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
54}
55
56#[inline]
57fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
58    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
59}
60
61#[inline]
62fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
63    [a[0] * s, a[1] * s, a[2] * s]
64}
65
66#[inline]
67fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
68    [
69        a[1] * b[2] - a[2] * b[1],
70        a[2] * b[0] - a[0] * b[2],
71        a[0] * b[1] - a[1] * b[0],
72    ]
73}
74
75#[inline]
76fn norm3(a: [f64; 3]) -> f64 {
77    dot3(a, a).sqrt()
78}
79
80#[inline]
81fn normalize3(a: [f64; 3]) -> [f64; 3] {
82    let n = norm3(a);
83    if n < 1e-15 { a } else { scale3(a, 1.0 / n) }
84}
85
86// ─── Sutherland-Hodgman 2-D ───────────────────────────────────────────────────
87
88/// Clip a 2-D `subject` polygon against a convex `clip` polygon using the
89/// Sutherland–Hodgman algorithm.
90///
91/// Both polygons are given as ordered vertex lists (clockwise or
92/// counter-clockwise, but both in the same winding order). An empty `clip`
93/// polygon returns an empty result.
94pub fn sutherland_hodgman_2d(subject: &[[f64; 2]], clip: &[[f64; 2]]) -> Vec<[f64; 2]> {
95    if clip.is_empty() || subject.is_empty() {
96        return Vec::new();
97    }
98
99    let mut output: Vec<[f64; 2]> = subject.to_vec();
100
101    let n = clip.len();
102    for i in 0..n {
103        if output.is_empty() {
104            return Vec::new();
105        }
106        let input = output.clone();
107        output.clear();
108
109        let edge_start = clip[i];
110        let edge_end = clip[(i + 1) % n];
111
112        // Edge direction and its left-hand normal (inside half-space normal).
113        let ex = edge_end[0] - edge_start[0];
114        let ey = edge_end[1] - edge_start[1];
115
116        // inside(p): positive when p is on the left (inside) of the directed edge.
117        let inside = |p: [f64; 2]| -> bool {
118            let dx = p[0] - edge_start[0];
119            let dy = p[1] - edge_start[1];
120            // cross product (edge × dp); positive → left side
121            ex * dy - ey * dx >= 0.0
122        };
123
124        // Intersection of segment S→E with the current clip edge.
125        // We solve for t in: S + t*(E-S) = edge_start + u*(edge_end-edge_start)
126        // Using 2-D cross products:
127        //   (E-S) × (edge_end-edge_start) * t = (edge_start-S) × (edge_end-edge_start)
128        let intersect = |s: [f64; 2], e: [f64; 2]| -> [f64; 2] {
129            let dx = e[0] - s[0]; // seg dir
130            let dy = e[1] - s[1];
131            // 2-D cross: seg_dir × clip_dir
132            let denom = dx * ey - dy * ex;
133            if denom.abs() < 1e-15 {
134                return s;
135            }
136            // (clip_start - S) × clip_dir
137            let t = ((edge_start[0] - s[0]) * ey - (edge_start[1] - s[1]) * ex) / denom;
138            [s[0] + t * dx, s[1] + t * dy]
139        };
140
141        let k = input.len();
142        for j in 0..k {
143            let current = input[j];
144            let previous = input[(j + k - 1) % k];
145
146            if inside(current) {
147                if !inside(previous) {
148                    output.push(intersect(previous, current));
149                }
150                output.push(current);
151            } else if inside(previous) {
152                output.push(intersect(previous, current));
153            }
154        }
155    }
156
157    output
158}
159
160// ─── Half-space clip (3-D) ────────────────────────────────────────────────────
161
162/// Clip a 3-D polygon (`poly`) against a single half-space defined by
163/// `plane_normal · x ≥ plane_d`.
164///
165/// Vertices on the positive side (inside) are kept; vertices on the negative
166/// side are removed, with new vertices inserted at the intersection with the
167/// plane boundary.
168pub fn clip_polygon_by_halfspace(
169    poly: &[[f64; 3]],
170    plane_normal: [f64; 3],
171    plane_d: f64,
172) -> Vec<[f64; 3]> {
173    if poly.is_empty() {
174        return Vec::new();
175    }
176
177    let mut output = Vec::with_capacity(poly.len() + 1);
178    let n = poly.len();
179
180    for i in 0..n {
181        let current = poly[i];
182        let previous = poly[(i + n - 1) % n];
183
184        let d_cur = dot3(plane_normal, current) - plane_d;
185        let d_prev = dot3(plane_normal, previous) - plane_d;
186
187        if d_cur >= 0.0 {
188            // current is inside
189            if d_prev < 0.0 {
190                // previous was outside → insert intersection
191                let t = d_prev / (d_prev - d_cur);
192                output.push(add3(previous, scale3(sub3(current, previous), t)));
193            }
194            output.push(current);
195        } else if d_prev >= 0.0 {
196            // current is outside, previous was inside → insert intersection
197            let t = d_prev / (d_prev - d_cur);
198            output.push(add3(previous, scale3(sub3(current, previous), t)));
199        }
200    }
201
202    output
203}
204
205// ─── Plane projection helpers ────────────────────────────────────────────────
206
207/// Project a 3-D point `pt` onto the 2-D coordinate system spanned by `u`
208/// and `v` at `plane_origin`.
209pub fn project_point_to_plane(
210    pt: [f64; 3],
211    plane_origin: [f64; 3],
212    u: [f64; 3],
213    v: [f64; 3],
214) -> [f64; 2] {
215    let d = sub3(pt, plane_origin);
216    [dot3(d, u), dot3(d, v)]
217}
218
219/// Reconstruct a 3-D point from its 2-D projection `pt2d` on the plane
220/// defined by `plane_origin`, `u`, and `v`.
221pub fn unproject_point_from_plane(
222    pt2d: [f64; 2],
223    plane_origin: [f64; 3],
224    u: [f64; 3],
225    v: [f64; 3],
226) -> [f64; 3] {
227    add3(plane_origin, add3(scale3(u, pt2d[0]), scale3(v, pt2d[1])))
228}
229
230// ─── Box manifold construction ────────────────────────────────────────────────
231
232/// Extract the 3×3 rotation sub-matrix from a column-major 4×4 homogeneous
233/// transform as three column vectors (the local X, Y, Z axes in world space).
234fn rotation_axes(t: [[f64; 4]; 4]) -> [[f64; 3]; 3] {
235    [
236        [t[0][0], t[1][0], t[2][0]],
237        [t[0][1], t[1][1], t[2][1]],
238        [t[0][2], t[1][2], t[2][2]],
239    ]
240}
241
242/// Extract the translation vector from a 4×4 homogeneous transform.
243fn translation(t: [[f64; 4]; 4]) -> [f64; 3] {
244    [t[0][3], t[1][3], t[2][3]]
245}
246
247/// Return the face of a box (given its world axes and half-extents) whose
248/// outward normal is most aligned with `dir`.  Returns the face normal and
249/// the four world-space corner vertices.
250fn best_face(
251    center: [f64; 3],
252    axes: [[f64; 3]; 3],
253    half: [f64; 3],
254    dir: [f64; 3],
255) -> ([f64; 3], Vec<[f64; 3]>) {
256    // Find the axis most aligned with `dir`
257    let mut best_dot = -f64::INFINITY;
258    let mut best_axis = 0usize;
259    let mut best_sign = 1.0_f64;
260    for (i, axis) in axes.iter().enumerate() {
261        let d = dot3(*axis, dir);
262        if d.abs() > best_dot {
263            best_dot = d.abs();
264            best_axis = i;
265            best_sign = d.signum();
266        }
267    }
268
269    let face_normal = scale3(axes[best_axis], best_sign);
270
271    // The two tangent axes
272    let u_axis = best_axis;
273    let v_axis = (best_axis + 1) % 3;
274    let w_axis = (best_axis + 2) % 3;
275
276    // Face center
277    let fc = add3(center, scale3(face_normal, half[best_axis]));
278
279    // Four corners
280    let hu = scale3(axes[u_axis], half[u_axis]);
281    let hv = scale3(axes[w_axis], half[w_axis]);
282    let _ = v_axis; // suppress unused warning
283    let corners = vec![
284        add3(add3(fc, hu), hv),
285        add3(sub3(fc, hu), hv),
286        sub3(sub3(fc, hu), hv),
287        add3(sub3(fc, hv), hu),
288    ];
289
290    (face_normal, corners)
291}
292
293/// Build a contact manifold for a box–box collision by:
294///
295/// 1. Identifying the reference face (on box A, most facing the contact normal)
296///    and the incident face (on box B, most facing −normal).
297/// 2. Clipping the incident face polygon against the four side planes of the
298///    reference face.
299/// 3. Projecting clipped points onto the reference plane and keeping those at
300///    or below it (i.e., penetrating).
301///
302/// The transform matrices are **row-major** 4×4 homogeneous matrices where
303/// `transform[row][col]`.  Translation is stored in the last column
304/// (`transform[row][3]`).
305pub fn build_box_manifold(
306    half_extents_a: [f64; 3],
307    transform_a: [[f64; 4]; 4],
308    half_extents_b: [f64; 3],
309    transform_b: [[f64; 4]; 4],
310    contact_normal: [f64; 3],
311    penetration_depth: f64,
312) -> ContactManifold {
313    let center_a = translation(transform_a);
314    let center_b = translation(transform_b);
315    let axes_a = rotation_axes(transform_a);
316    let axes_b = rotation_axes(transform_b);
317
318    // Reference face: box A face whose normal is most aligned with contact_normal
319    let (ref_normal, _ref_corners) = best_face(center_a, axes_a, half_extents_a, contact_normal);
320
321    // Incident face: box B face whose normal is most aligned with -contact_normal
322    let neg_normal = scale3(contact_normal, -1.0);
323    let (_inc_normal, inc_corners) = best_face(center_b, axes_b, half_extents_b, neg_normal);
324
325    // Build 2-D coordinate system on the reference plane
326    // Choose a tangent vector perpendicular to ref_normal
327    let tangent_u = {
328        let candidate = if ref_normal[0].abs() < 0.9 {
329            [1.0_f64, 0.0, 0.0]
330        } else {
331            [0.0_f64, 1.0, 0.0]
332        };
333        normalize3(cross3(candidate, ref_normal))
334    };
335    let tangent_v = cross3(ref_normal, tangent_u);
336
337    // Project incident face corners onto 2-D
338    let inc_2d: Vec<[f64; 2]> = inc_corners
339        .iter()
340        .map(|&p| project_point_to_plane(p, center_a, tangent_u, tangent_v))
341        .collect();
342
343    // Build 2-D reference face clipping polygon (a quad in 2-D)
344    // We compute the half-extents of A projected onto the reference plane.
345    // For simplicity we use the reference face corners projected into 2-D.
346    let ref_face_normal_world = ref_normal;
347    // Recompute reference face corners for clipping
348    let best_axis_idx = axes_a
349        .iter()
350        .enumerate()
351        .max_by(|(_, a), (_, b)| {
352            dot3(**a, ref_face_normal_world)
353                .abs()
354                .partial_cmp(&dot3(**b, ref_face_normal_world).abs())
355                .unwrap_or(std::cmp::Ordering::Equal)
356        })
357        .map(|(i, _)| i)
358        .unwrap_or(0);
359    let u_idx = (best_axis_idx + 1) % 3;
360    let w_idx = (best_axis_idx + 2) % 3;
361
362    // Reference face in 2-D (clipping polygon)
363    let hu = half_extents_a[u_idx];
364    let hv = half_extents_a[w_idx];
365    let ref_clip_2d: Vec<[f64; 2]> = vec![[hu, hv], [-hu, hv], [-hu, -hv], [hu, -hv]];
366
367    // Sutherland-Hodgman clip in 2-D
368    let clipped_2d = sutherland_hodgman_2d(&inc_2d, &ref_clip_2d);
369
370    // Unproject back to 3-D and keep points below (or on) the reference plane
371    let ref_plane_d = dot3(ref_normal, center_a) + half_extents_a[best_axis_idx];
372
373    let mut manifold = ContactManifold::new(contact_normal);
374    for pt2d in &clipped_2d {
375        let pt3d = unproject_point_from_plane(*pt2d, center_a, tangent_u, tangent_v);
376        let signed_dist = dot3(ref_normal, pt3d) - ref_plane_d;
377        if signed_dist <= 1e-4 {
378            // depth is how far below the reference plane the point is
379            let depth = (-signed_dist).max(0.0).max(penetration_depth * 0.01);
380            manifold.contacts.push(ContactPoint {
381                point: pt3d,
382                normal: contact_normal,
383                depth,
384            });
385        }
386    }
387
388    manifold
389}
390
391// ─── Manifold reduction ───────────────────────────────────────────────────────
392
393/// Reduce a manifold to at most `max_contacts` points using a farthest-point
394/// heuristic:
395///
396/// 1. Keep the contact with the greatest penetration depth.
397/// 2. Iteratively add the contact that is farthest (in 3-D) from the already
398///    selected set.
399pub fn reduce_manifold(manifold: &mut ContactManifold, max_contacts: usize) {
400    if manifold.contacts.len() <= max_contacts {
401        return;
402    }
403
404    let contacts = &manifold.contacts;
405    let n = contacts.len();
406    let mut selected: Vec<usize> = Vec::with_capacity(max_contacts);
407
408    // Seed with the deepest point
409    let deepest = contacts
410        .iter()
411        .enumerate()
412        .max_by(|(_, a), (_, b)| {
413            a.depth
414                .partial_cmp(&b.depth)
415                .unwrap_or(std::cmp::Ordering::Equal)
416        })
417        .map(|(i, _)| i)
418        .unwrap_or(0);
419    selected.push(deepest);
420
421    while selected.len() < max_contacts {
422        let mut best_idx = 0usize;
423        let mut best_dist = -1.0_f64;
424
425        for i in 0..n {
426            if selected.contains(&i) {
427                continue;
428            }
429            // Minimum distance from this candidate to the already-selected set
430            let min_d = selected
431                .iter()
432                .map(|&j| {
433                    let d = sub3(contacts[i].point, contacts[j].point);
434                    dot3(d, d)
435                })
436                .fold(f64::INFINITY, f64::min);
437
438            if min_d > best_dist {
439                best_dist = min_d;
440                best_idx = i;
441            }
442        }
443
444        selected.push(best_idx);
445    }
446
447    // Retain only the selected contacts
448    let kept: Vec<ContactPoint> = selected.iter().map(|&i| contacts[i].clone()).collect();
449    manifold.contacts = kept;
450}
451
452// ─── Warm-start impulse data ──────────────────────────────────────────────────
453
454/// Warm-start impulse data stored per contact point across simulation frames.
455///
456/// These values are transferred from the previous frame's solver to seed the
457/// current frame's iterative solver (warm-starting), which dramatically
458/// improves convergence speed and stability.
459#[derive(Debug, Clone, Default)]
460pub struct WarmStartData {
461    /// Accumulated normal impulse from the previous frame (N·s).
462    pub normal_impulse: f64,
463    /// Accumulated tangential impulse (friction) in the contact plane (N·s).
464    /// Stores impulses along both tangent axes.
465    pub tangent_impulse: [f64; 2],
466    /// Number of consecutive simulation frames this contact has been active.
467    pub age: u32,
468}
469
470impl WarmStartData {
471    /// Create new zero-initialised warm-start data.
472    pub fn new() -> Self {
473        Self::default()
474    }
475
476    /// Scale all impulses by `factor` (used for time-step ratio correction).
477    pub fn scale(&mut self, factor: f64) {
478        self.normal_impulse *= factor;
479        self.tangent_impulse[0] *= factor;
480        self.tangent_impulse[1] *= factor;
481    }
482
483    /// Reset impulses to zero (contact broken or new contact).
484    pub fn reset(&mut self) {
485        self.normal_impulse = 0.0;
486        self.tangent_impulse = [0.0, 0.0];
487        self.age = 0;
488    }
489
490    /// Increment age counter (called once per frame when contact persists).
491    pub fn tick(&mut self) {
492        self.age = self.age.saturating_add(1);
493    }
494
495    /// Whether this contact is considered "warm" (has valid cached impulses).
496    pub fn is_warm(&self) -> bool {
497        self.age > 0
498    }
499}
500
501// ─── WarmContactPoint ─────────────────────────────────────────────────────────
502
503/// A contact point with embedded warm-start data for solver efficiency.
504#[derive(Debug, Clone)]
505pub struct WarmContactPoint {
506    /// Geometric contact data.
507    pub contact: ContactPoint,
508    /// Warm-start impulse data from the previous frame.
509    pub warm: WarmStartData,
510}
511
512impl WarmContactPoint {
513    /// Create a new `WarmContactPoint` with zero warm-start data.
514    pub fn new(contact: ContactPoint) -> Self {
515        Self {
516            contact,
517            warm: WarmStartData::new(),
518        }
519    }
520
521    /// Create a new point, copying warm-start data from a previous contact.
522    pub fn with_warm(contact: ContactPoint, warm: WarmStartData) -> Self {
523        Self { contact, warm }
524    }
525}
526
527// ─── 4-point manifold builder ────────────────────────────────────────────────
528
529/// Reduce a set of contact points to exactly four representatives that
530/// maximise the covered area in the contact plane.
531///
532/// This is the standard algorithm used in production rigid-body engines:
533/// 1. Keep the deepest contact point.
534/// 2. Keep the point farthest from point 1.
535/// 3. Keep the point that maximises the triangle area (p1, p2, candidate).
536/// 4. Keep the point that maximises the quadrilateral area (p1…p3, candidate).
537///
538/// If fewer than 4 points are provided they are returned unchanged.
539pub fn build_4point_manifold(points: &[ContactPoint]) -> Vec<ContactPoint> {
540    if points.len() <= 4 {
541        return points.to_vec();
542    }
543
544    // Step 1: deepest penetration.
545    let i0 = points
546        .iter()
547        .enumerate()
548        .max_by(|(_, a), (_, b)| {
549            a.depth
550                .partial_cmp(&b.depth)
551                .unwrap_or(std::cmp::Ordering::Equal)
552        })
553        .map(|(i, _)| i)
554        .unwrap_or(0);
555
556    // Step 2: farthest from p0.
557    let p0 = points[i0].point;
558    let i1 = points
559        .iter()
560        .enumerate()
561        .filter(|(i, _)| *i != i0)
562        .max_by(|(_, a), (_, b)| {
563            dist_sq3(a.point, p0)
564                .partial_cmp(&dist_sq3(b.point, p0))
565                .unwrap_or(std::cmp::Ordering::Equal)
566        })
567        .map(|(i, _)| i)
568        .unwrap_or(if i0 == 0 { 1 } else { 0 });
569
570    // Step 3: maximise triangle area with p0, p1.
571    let p1 = points[i1].point;
572    let i2 = points
573        .iter()
574        .enumerate()
575        .filter(|(i, _)| *i != i0 && *i != i1)
576        .max_by(|(_, a), (_, b)| {
577            triangle_area_sq(p0, p1, a.point)
578                .partial_cmp(&triangle_area_sq(p0, p1, b.point))
579                .unwrap_or(std::cmp::Ordering::Equal)
580        })
581        .map(|(i, _)| i);
582
583    let i2 = match i2 {
584        Some(idx) => idx,
585        None => return vec![points[i0].clone(), points[i1].clone()],
586    };
587
588    // Step 4: maximise quadrilateral area.
589    let p2 = points[i2].point;
590    let i3 = points
591        .iter()
592        .enumerate()
593        .filter(|(i, _)| *i != i0 && *i != i1 && *i != i2)
594        .max_by(|(_, a), (_, b)| {
595            quad_area_sq(p0, p1, p2, a.point)
596                .partial_cmp(&quad_area_sq(p0, p1, p2, b.point))
597                .unwrap_or(std::cmp::Ordering::Equal)
598        })
599        .map(|(i, _)| i);
600
601    let mut result = vec![points[i0].clone(), points[i1].clone(), points[i2].clone()];
602    if let Some(idx) = i3 {
603        result.push(points[idx].clone());
604    }
605    result
606}
607
608// ─── Frame-to-frame contact persistence ──────────────────────────────────────
609
610/// Threshold (squared) for matching contact points across frames.
611const MATCH_DIST_SQ_MANIFOLD: f64 = 5e-4; // (2.2 cm)²
612
613/// Transfer warm-start data from a previous-frame manifold to a newly computed
614/// manifold for the same body pair.
615///
616/// For each new contact point the algorithm searches the old manifold for a
617/// spatially close match (within `MATCH_DIST_SQ_MANIFOLD`). When a match is
618/// found the old [`WarmStartData`] is transferred; the age counter is
619/// incremented. Unmatched new points receive zero-initialised warm data.
620pub fn transfer_warm_start(
621    new_contacts: &[ContactPoint],
622    old_warm: &[WarmContactPoint],
623) -> Vec<WarmContactPoint> {
624    new_contacts
625        .iter()
626        .map(|new_pt| {
627            // Find the closest point in the old manifold.
628            let best = old_warm.iter().min_by(|a, b| {
629                let da = dist_sq3(a.contact.point, new_pt.point);
630                let db = dist_sq3(b.contact.point, new_pt.point);
631                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
632            });
633
634            if let Some(old_pt) = best {
635                let d_sq = dist_sq3(old_pt.contact.point, new_pt.point);
636                if d_sq < MATCH_DIST_SQ_MANIFOLD {
637                    let mut warm = old_pt.warm.clone();
638                    warm.tick();
639                    return WarmContactPoint::with_warm(new_pt.clone(), warm);
640                }
641            }
642
643            WarmContactPoint::new(new_pt.clone())
644        })
645        .collect()
646}
647
648// ─── WarmManifold ─────────────────────────────────────────────────────────────
649
650/// A contact manifold with per-point warm-start impulse data.
651///
652/// Wraps a [`ContactManifold`] and adds the frame-to-frame persistence state
653/// needed to warm-start the iterative constraint solver.
654#[derive(Debug, Clone)]
655pub struct WarmManifold {
656    /// Contact normal for this manifold.
657    pub normal: [f64; 3],
658    /// Contact points with warm-start data.
659    pub points: Vec<WarmContactPoint>,
660}
661
662impl WarmManifold {
663    /// Create an empty `WarmManifold`.
664    pub fn new(normal: [f64; 3]) -> Self {
665        Self {
666            normal,
667            points: Vec::new(),
668        }
669    }
670
671    /// Build a `WarmManifold` from a [`ContactManifold`], transferring
672    /// warm-start data from the previous frame (`prev`).
673    ///
674    /// Reduces the contact set to at most 4 representative points first.
675    pub fn from_manifold(manifold: &ContactManifold, prev: Option<&WarmManifold>) -> Self {
676        let reduced = build_4point_manifold(&manifold.contacts);
677
678        let points = if let Some(old) = prev {
679            transfer_warm_start(&reduced, &old.points)
680        } else {
681            reduced
682                .iter()
683                .map(|c| WarmContactPoint::new(c.clone()))
684                .collect()
685        };
686
687        Self {
688            normal: manifold.normal,
689            points,
690        }
691    }
692
693    /// Number of contact points.
694    pub fn len(&self) -> usize {
695        self.points.len()
696    }
697
698    /// Whether this manifold has no contact points.
699    pub fn is_empty(&self) -> bool {
700        self.points.is_empty()
701    }
702
703    /// Iterate over the warm contact points.
704    pub fn iter(&self) -> std::slice::Iter<'_, WarmContactPoint> {
705        self.points.iter()
706    }
707
708    /// Mutable access to warm contact points.
709    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, WarmContactPoint> {
710        self.points.iter_mut()
711    }
712
713    /// Apply an impulse scale factor to all warm-start data.
714    ///
715    /// Used when the simulation timestep changes between frames.
716    pub fn scale_impulses(&mut self, factor: f64) {
717        for pt in &mut self.points {
718            pt.warm.scale(factor);
719        }
720    }
721
722    /// Reset all warm-start data (e.g., after a large time gap or teleport).
723    pub fn reset_warm_start(&mut self) {
724        for pt in &mut self.points {
725            pt.warm.reset();
726        }
727    }
728
729    /// Average contact normal recomputed from all points.
730    pub fn recompute_normal(&mut self) {
731        if self.points.is_empty() {
732            return;
733        }
734        let mut avg = [0.0f64; 3];
735        for pt in &self.points {
736            avg = add3(avg, pt.contact.normal);
737        }
738        let n = self.points.len() as f64;
739        self.normal = normalize3(scale3(avg, 1.0 / n));
740    }
741}
742
743// ─── Geometry helpers for 4-point manifold ───────────────────────────────────
744
745#[inline]
746fn dist_sq3(a: [f64; 3], b: [f64; 3]) -> f64 {
747    let d = sub3(a, b);
748    dot3(d, d)
749}
750
751#[inline]
752fn triangle_area_sq(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
753    let e1 = sub3(b, a);
754    let e2 = sub3(c, a);
755    let cx = cross3(e1, e2);
756    dot3(cx, cx)
757}
758
759#[inline]
760fn quad_area_sq(a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) -> f64 {
761    triangle_area_sq(a, b, c) + triangle_area_sq(a, c, d)
762}
763
764// ─── Persistent patch tracking ───────────────────────────────────────────────
765
766/// A contact patch that persists across frames, tracking history of contacts.
767///
768/// Useful for integrating contact forces and detecting sustained contact.
769#[derive(Debug, Clone)]
770pub struct PersistentPatch {
771    /// Shared contact normal.
772    pub normal: [f64; 3],
773    /// Warm contact points (max 4).
774    pub points: Vec<WarmContactPoint>,
775    /// Number of consecutive frames this patch has been active.
776    pub frame_count: u32,
777    /// Whether this patch was active in the most recent frame.
778    pub active: bool,
779}
780
781impl PersistentPatch {
782    /// Create a new empty persistent patch.
783    pub fn new(normal: [f64; 3]) -> Self {
784        Self {
785            normal,
786            points: Vec::new(),
787            frame_count: 0,
788            active: true,
789        }
790    }
791
792    /// Update the patch from a new [`ContactManifold`].
793    ///
794    /// Transfers warm-start data from matching previous points, increments
795    /// `frame_count`, and reduces to at most 4 points.
796    pub fn update(&mut self, manifold: &ContactManifold) {
797        let reduced = build_4point_manifold(&manifold.contacts);
798        let new_warm = transfer_warm_start(&reduced, &self.points);
799        self.points = new_warm;
800        self.normal = manifold.normal;
801        self.frame_count = self.frame_count.saturating_add(1);
802        self.active = true;
803    }
804
805    /// Mark the patch as inactive (no collision detected this frame).
806    pub fn deactivate(&mut self) {
807        self.active = false;
808    }
809
810    /// Whether the patch has been active for at least `n` consecutive frames.
811    pub fn is_sustained(&self, n: u32) -> bool {
812        self.active && self.frame_count >= n
813    }
814
815    /// Maximum penetration depth across all contact points.
816    pub fn max_depth(&self) -> f64 {
817        self.points
818            .iter()
819            .map(|p| p.contact.depth)
820            .fold(0.0f64, f64::max)
821    }
822
823    /// Average contact position across all points.
824    pub fn average_position(&self) -> Option<[f64; 3]> {
825        if self.points.is_empty() {
826            return None;
827        }
828        let n = self.points.len() as f64;
829        let mut avg = [0.0f64; 3];
830        for p in &self.points {
831            avg = add3(avg, p.contact.point);
832        }
833        Some(scale3(avg, 1.0 / n))
834    }
835}
836
837// ─── Contact normal smoothing ────────────────────────────────────────────────
838
839/// Smoothly blend a new contact normal with a previous one.
840///
841/// Interpolates between `prev_normal` and `new_normal` by `alpha` ∈ \[0,1\]
842/// and re-normalises. `alpha = 1.0` returns `new_normal` unchanged.
843pub fn smooth_contact_normal(prev_normal: [f64; 3], new_normal: [f64; 3], alpha: f64) -> [f64; 3] {
844    let blended = add3(scale3(prev_normal, 1.0 - alpha), scale3(new_normal, alpha));
845    normalize3(blended)
846}
847
848/// Apply exponential moving average smoothing to contact normals in a manifold.
849///
850/// Iterates over all contact points and blends their normals with the manifold
851/// normal using factor `alpha`.
852pub fn smooth_manifold_normals(manifold: &mut ContactManifold, alpha: f64) {
853    let base = manifold.normal;
854    for pt in &mut manifold.contacts {
855        pt.normal = smooth_contact_normal(base, pt.normal, alpha);
856    }
857}
858
859// ─── Speculative contacts ─────────────────────────────────────────────────────
860
861/// A speculative contact used for predictive collision response.
862///
863/// Generated before penetration occurs, based on the distance between shapes
864/// and their relative velocities.
865#[derive(Debug, Clone)]
866pub struct SpeculativeContactPoint {
867    /// World-space position of the speculative contact.
868    pub point: [f64; 3],
869    /// Contact normal (from B toward A).
870    pub normal: [f64; 3],
871    /// Signed separation distance (negative = overlapping, positive = gap).
872    pub separation: f64,
873    /// Relative velocity along the normal (closing speed).
874    pub closing_speed: f64,
875}
876
877impl SpeculativeContactPoint {
878    /// Create a speculative contact point.
879    pub fn new(point: [f64; 3], normal: [f64; 3], separation: f64, closing_speed: f64) -> Self {
880        Self {
881            point,
882            normal,
883            separation,
884            closing_speed,
885        }
886    }
887
888    /// Whether the speculative contact will produce a collision within `dt`.
889    pub fn will_collide(&self, dt: f64) -> bool {
890        self.separation - self.closing_speed * dt < 0.0
891    }
892
893    /// Predicted penetration depth at end of `dt`.
894    pub fn predicted_depth(&self, dt: f64) -> f64 {
895        -(self.separation - self.closing_speed * dt)
896    }
897}
898
899/// Generate speculative contacts for a contact manifold given relative velocity.
900///
901/// Returns contacts where the closing speed times `dt` exceeds the separation.
902pub fn generate_speculative_contacts(
903    manifold: &ContactManifold,
904    normal: [f64; 3],
905    closing_speed: f64,
906    separation: f64,
907    dt: f64,
908) -> Vec<SpeculativeContactPoint> {
909    manifold
910        .contacts
911        .iter()
912        .filter_map(|pt| {
913            let spec = SpeculativeContactPoint::new(pt.point, normal, separation, closing_speed);
914            if spec.will_collide(dt) {
915                Some(spec)
916            } else {
917                None
918            }
919        })
920        .collect()
921}
922
923// ─── Manifold frame rotation ──────────────────────────────────────────────────
924
925/// Rotate all contact points in a manifold by a 3×3 rotation matrix.
926///
927/// The rotation matrix is given in row-major format `[[row0], [row1], [row2]]`.
928pub fn rotate_manifold_points(manifold: &mut ContactManifold, rot: [[f64; 3]; 3]) {
929    for pt in &mut manifold.contacts {
930        pt.point = mat3_mul_vec3(rot, pt.point);
931        pt.normal = mat3_mul_vec3(rot, pt.normal);
932    }
933    manifold.normal = mat3_mul_vec3(rot, manifold.normal);
934}
935
936/// Multiply a 3×3 rotation matrix by a 3-vector.
937#[inline]
938fn mat3_mul_vec3(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
939    [
940        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
941        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
942        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
943    ]
944}
945
946// ─── Friction basis vectors ───────────────────────────────────────────────────
947
948/// Compute an orthonormal friction basis `(t1, t2)` from a contact normal.
949///
950/// Returns two vectors tangent to the contact plane such that
951/// `{normal, t1, t2}` form a right-handed frame.
952pub fn friction_basis(normal: [f64; 3]) -> ([f64; 3], [f64; 3]) {
953    // Pick a vector not parallel to normal
954    let candidate = if normal[0].abs() < 0.9 {
955        [1.0f64, 0.0, 0.0]
956    } else {
957        [0.0f64, 1.0, 0.0]
958    };
959    let t1 = normalize3(cross3(normal, candidate));
960    let t2 = cross3(normal, t1);
961    (t1, t2)
962}
963
964/// Decompose a velocity vector into normal and tangential components.
965///
966/// Returns `(v_normal, v_tangential)` where `v_normal` is along the contact
967/// normal and `v_tangential` is in the contact plane.
968pub fn decompose_velocity(vel: [f64; 3], normal: [f64; 3]) -> ([f64; 3], [f64; 3]) {
969    let vn = dot3(vel, normal);
970    let v_normal = scale3(normal, vn);
971    let v_tangential = sub3(vel, v_normal);
972    (v_normal, v_tangential)
973}
974
975/// Compute the friction impulse magnitude using Coulomb friction.
976///
977/// Given the normal impulse magnitude `lambda_n` and friction coefficient `mu`,
978/// returns the maximum tangential impulse magnitude.
979pub fn coulomb_friction_limit(lambda_n: f64, mu: f64) -> f64 {
980    (lambda_n * mu).max(0.0)
981}
982
983// ─── Manifold quality metrics ─────────────────────────────────────────────────
984
985/// Compute the quality score of a manifold based on point spread and depths.
986///
987/// Returns a value in `[0, 1]` where 1.0 means well-distributed, deep contacts.
988/// Uses the ratio of the actual spread to the maximum possible spread.
989pub fn manifold_quality(manifold: &ContactManifold) -> f64 {
990    let n = manifold.contacts.len();
991    if n == 0 {
992        return 0.0;
993    }
994
995    // Average depth score
996    let avg_depth = manifold.contacts.iter().map(|p| p.depth).sum::<f64>() / n as f64;
997    let depth_score = (avg_depth / (avg_depth + 1.0)).min(1.0);
998
999    if n < 2 {
1000        return depth_score * 0.5;
1001    }
1002
1003    // Spread score: max pairwise distance among contact points
1004    let max_dist_sq = manifold
1005        .contacts
1006        .iter()
1007        .enumerate()
1008        .flat_map(|(i, a)| {
1009            manifold.contacts[i + 1..].iter().map(move |b| {
1010                let d = sub3(a.point, b.point);
1011                dot3(d, d)
1012            })
1013        })
1014        .fold(0.0f64, f64::max);
1015    let spread_score = (max_dist_sq.sqrt() / (max_dist_sq.sqrt() + 1.0)).min(1.0);
1016
1017    (depth_score + spread_score) * 0.5
1018}
1019
1020// ─── Tests ────────────────────────────────────────────────────────────────────
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025
1026    // ── Sutherland-Hodgman 2D ──────────────────────────────────────────────────
1027
1028    #[test]
1029    fn test_sh_clip_square_against_x_ge_0() {
1030        // Subject: unit square centred at origin
1031        let subject = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
1032        // Clip polygon for x ≥ 0: a large right half-plane represented as a
1033        // convex polygon (a tall vertical strip)
1034        let clip = [[0.0, -2.0], [2.0, -2.0], [2.0, 2.0], [0.0, 2.0]];
1035
1036        let result = sutherland_hodgman_2d(&subject, &clip);
1037
1038        assert!(!result.is_empty(), "clipped polygon must not be empty");
1039        // All result vertices must have x ≥ 0
1040        for v in &result {
1041            assert!(
1042                v[0] >= -1e-10,
1043                "vertex {:?} has negative x after clipping against x≥0",
1044                v
1045            );
1046        }
1047        // The result should have 4 vertices (the right half of the square)
1048        assert_eq!(
1049            result.len(),
1050            4,
1051            "right half of square should have 4 vertices, got {:?}",
1052            result
1053        );
1054        // Area check: the right half of the unit square has area 2.0
1055        let area = polygon_area_2d(&result);
1056        assert!(
1057            (area - 2.0).abs() < 1e-10,
1058            "expected area 2.0, got {}",
1059            area
1060        );
1061    }
1062
1063    #[test]
1064    fn test_sh_no_clipping_needed() {
1065        // Subject fully inside the clip polygon
1066        let subject = [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]];
1067        let clip = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1068        let result = sutherland_hodgman_2d(&subject, &clip);
1069        assert_eq!(result.len(), 4);
1070    }
1071
1072    #[test]
1073    fn test_sh_fully_outside() {
1074        let subject = [[-3.0, -3.0], [-2.0, -3.0], [-2.0, -2.0], [-3.0, -2.0]];
1075        let clip = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1076        let result = sutherland_hodgman_2d(&subject, &clip);
1077        assert!(
1078            result.is_empty(),
1079            "fully outside polygon should be clipped to empty"
1080        );
1081    }
1082
1083    // ── clip_polygon_by_halfspace ──────────────────────────────────────────────
1084
1085    #[test]
1086    fn test_clip_polygon_above_plane() {
1087        // A quad entirely above z = 0 plane (normal = [0,0,1], d = 0)
1088        let quad = [
1089            [1.0, 1.0, 1.0],
1090            [-1.0, 1.0, 1.0],
1091            [-1.0, -1.0, 1.0],
1092            [1.0, -1.0, 1.0],
1093        ];
1094        let result = clip_polygon_by_halfspace(&quad, [0.0, 0.0, 1.0], 0.0);
1095        // All 4 vertices are above (inside) the plane so the polygon is unchanged.
1096        assert_eq!(
1097            result.len(),
1098            4,
1099            "quad fully above plane must be unchanged, got {:?}",
1100            result
1101        );
1102    }
1103
1104    #[test]
1105    fn test_clip_polygon_straddles_plane() {
1106        // A quad that straddles z = 0: two vertices above, two below.
1107        let quad = [
1108            [1.0, 0.0, 1.0],
1109            [-1.0, 0.0, 1.0],
1110            [-1.0, 0.0, -1.0],
1111            [1.0, 0.0, -1.0],
1112        ];
1113        let result = clip_polygon_by_halfspace(&quad, [0.0, 0.0, 1.0], 0.0);
1114        // Should produce a 4-vertex polygon (the top half)
1115        assert!(
1116            result.len() >= 3,
1117            "straddling quad must produce at least a triangle, got {:?}",
1118            result
1119        );
1120        for v in &result {
1121            assert!(
1122                v[2] >= -1e-10,
1123                "all clipped vertices must have z≥0, got {:?}",
1124                v
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn test_clip_polygon_below_plane() {
1131        // All vertices below z=0 → empty result
1132        let quad = [
1133            [1.0, 0.0, -1.0],
1134            [-1.0, 0.0, -1.0],
1135            [-1.0, 0.0, -2.0],
1136            [1.0, 0.0, -2.0],
1137        ];
1138        let result = clip_polygon_by_halfspace(&quad, [0.0, 0.0, 1.0], 0.0);
1139        assert!(
1140            result.is_empty(),
1141            "quad fully below plane must be clipped to empty"
1142        );
1143    }
1144
1145    // ── reduce_manifold ───────────────────────────────────────────────────────
1146
1147    #[test]
1148    fn test_reduce_manifold_8_to_4() {
1149        // Create 8 contacts spread around a unit circle in the XY plane.
1150        let mut manifold = ContactManifold::new([0.0, 0.0, 1.0]);
1151        for i in 0..8 {
1152            let angle = (i as f64) * std::f64::consts::TAU / 8.0;
1153            manifold.contacts.push(ContactPoint {
1154                point: [angle.cos(), angle.sin(), 0.0],
1155                normal: [0.0, 0.0, 1.0],
1156                depth: 0.1 + (i as f64) * 0.01, // slightly varying depths
1157            });
1158        }
1159
1160        reduce_manifold(&mut manifold, 4);
1161
1162        assert_eq!(
1163            manifold.contacts.len(),
1164            4,
1165            "manifold should be reduced to 4 contacts"
1166        );
1167    }
1168
1169    #[test]
1170    fn test_reduce_manifold_already_small() {
1171        let mut manifold = ContactManifold::new([0.0, 1.0, 0.0]);
1172        manifold.contacts.push(ContactPoint {
1173            point: [0.0, 0.0, 0.0],
1174            normal: [0.0, 1.0, 0.0],
1175            depth: 0.05,
1176        });
1177        reduce_manifold(&mut manifold, 4);
1178        assert_eq!(manifold.contacts.len(), 1);
1179    }
1180
1181    // ── project/unproject round-trip ─────────────────────────────────────────
1182
1183    #[test]
1184    fn test_project_unproject_roundtrip() {
1185        let origin = [1.0, 2.0, 3.0];
1186        let u = normalize3([1.0, 0.0, 0.0]);
1187        let v = normalize3([0.0, 1.0, 0.0]);
1188        let pt = [2.5, 3.5, 3.0]; // lies on the plane (z == origin.z)
1189        let pt2d = project_point_to_plane(pt, origin, u, v);
1190        let pt3d = unproject_point_from_plane(pt2d, origin, u, v);
1191        for i in 0..3 {
1192            assert!(
1193                (pt3d[i] - pt[i]).abs() < 1e-12,
1194                "round-trip mismatch at component {}: expected {}, got {}",
1195                i,
1196                pt[i],
1197                pt3d[i]
1198            );
1199        }
1200    }
1201
1202    // ── Helper ────────────────────────────────────────────────────────────────
1203
1204    /// Signed area of a 2-D polygon (positive for CCW winding).
1205    fn polygon_area_2d(poly: &[[f64; 2]]) -> f64 {
1206        let n = poly.len();
1207        let mut area = 0.0_f64;
1208        for i in 0..n {
1209            let j = (i + 1) % n;
1210            area += poly[i][0] * poly[j][1];
1211            area -= poly[j][0] * poly[i][1];
1212        }
1213        (area / 2.0).abs()
1214    }
1215
1216    // ── WarmStartData ─────────────────────────────────────────────────────────
1217
1218    #[test]
1219    fn test_warm_start_data_default_zero() {
1220        let w = WarmStartData::new();
1221        assert_eq!(w.normal_impulse, 0.0);
1222        assert_eq!(w.tangent_impulse, [0.0, 0.0]);
1223        assert_eq!(w.age, 0);
1224        assert!(!w.is_warm());
1225    }
1226
1227    #[test]
1228    fn test_warm_start_data_tick_increments_age() {
1229        let mut w = WarmStartData::new();
1230        w.tick();
1231        assert_eq!(w.age, 1);
1232        assert!(w.is_warm());
1233        w.tick();
1234        assert_eq!(w.age, 2);
1235    }
1236
1237    #[test]
1238    fn test_warm_start_data_scale() {
1239        let mut w = WarmStartData::new();
1240        w.normal_impulse = 10.0;
1241        w.tangent_impulse = [4.0, -2.0];
1242        w.scale(0.5);
1243        assert!((w.normal_impulse - 5.0).abs() < 1e-12);
1244        assert!((w.tangent_impulse[0] - 2.0).abs() < 1e-12);
1245        assert!((w.tangent_impulse[1] - (-1.0)).abs() < 1e-12);
1246    }
1247
1248    #[test]
1249    fn test_warm_start_data_reset() {
1250        let mut w = WarmStartData::new();
1251        w.normal_impulse = 5.0;
1252        w.tangent_impulse = [1.0, 2.0];
1253        w.age = 10;
1254        w.reset();
1255        assert_eq!(w.normal_impulse, 0.0);
1256        assert_eq!(w.tangent_impulse, [0.0, 0.0]);
1257        assert_eq!(w.age, 0);
1258    }
1259
1260    // ── build_4point_manifold ─────────────────────────────────────────────────
1261
1262    fn make_cp(x: f64, z: f64, depth: f64) -> ContactPoint {
1263        ContactPoint {
1264            point: [x, 0.0, z],
1265            normal: [0.0, 1.0, 0.0],
1266            depth,
1267        }
1268    }
1269
1270    #[test]
1271    fn test_build_4point_manifold_reduces_to_4() {
1272        // 8 points spread on a 2×2 grid, depths vary.
1273        let pts: Vec<ContactPoint> = vec![
1274            make_cp(-1.0, -1.0, 0.1),
1275            make_cp(1.0, -1.0, 0.2),
1276            make_cp(1.0, 1.0, 0.15),
1277            make_cp(-1.0, 1.0, 0.05),
1278            make_cp(0.0, 0.0, 0.25), // deepest — seed
1279            make_cp(0.5, -0.5, 0.08),
1280            make_cp(-0.5, 0.5, 0.12),
1281            make_cp(0.0, 1.0, 0.07),
1282        ];
1283        let result = build_4point_manifold(&pts);
1284        assert_eq!(result.len(), 4, "should reduce to exactly 4 points");
1285    }
1286
1287    #[test]
1288    fn test_build_4point_manifold_fewer_than_4_unchanged() {
1289        let pts: Vec<ContactPoint> = vec![make_cp(0.0, 0.0, 0.1), make_cp(1.0, 0.0, 0.1)];
1290        let result = build_4point_manifold(&pts);
1291        assert_eq!(result.len(), 2, "fewer than 4 points returned unchanged");
1292    }
1293
1294    #[test]
1295    fn test_build_4point_manifold_deepest_included() {
1296        let pts: Vec<ContactPoint> = vec![
1297            make_cp(-1.0, 0.0, 0.01),
1298            make_cp(0.0, 0.0, 0.99), // deepest
1299            make_cp(1.0, 0.0, 0.01),
1300            make_cp(0.0, 1.0, 0.01),
1301            make_cp(0.0, -1.0, 0.01),
1302        ];
1303        let result = build_4point_manifold(&pts);
1304        // The deepest point ([0,0,0], depth=0.99) must be in the result.
1305        let has_deepest = result.iter().any(|p| (p.depth - 0.99).abs() < 1e-9);
1306        assert!(
1307            has_deepest,
1308            "deepest point must be included in 4-point manifold"
1309        );
1310    }
1311
1312    // ── transfer_warm_start ───────────────────────────────────────────────────
1313
1314    #[test]
1315    fn test_transfer_warm_start_matching_point() {
1316        // Old manifold: one warm contact at origin.
1317        let old_pt = {
1318            let mut cp = WarmContactPoint::new(ContactPoint {
1319                point: [0.0, 0.0, 0.0],
1320                normal: [0.0, 1.0, 0.0],
1321                depth: 0.05,
1322            });
1323            cp.warm.normal_impulse = 42.0;
1324            cp.warm.tick();
1325            cp
1326        };
1327        let old_warm = vec![old_pt];
1328
1329        // New contact very close to the old one.
1330        let new_contacts = vec![ContactPoint {
1331            point: [0.001, 0.0, 0.001], // within threshold
1332            normal: [0.0, 1.0, 0.0],
1333            depth: 0.04,
1334        }];
1335
1336        let result = transfer_warm_start(&new_contacts, &old_warm);
1337        assert_eq!(result.len(), 1);
1338        assert!(
1339            result[0].warm.is_warm(),
1340            "warm-start should transfer to close contact"
1341        );
1342        assert!(
1343            (result[0].warm.normal_impulse - 42.0).abs() < 1e-9,
1344            "normal_impulse should be transferred"
1345        );
1346        assert_eq!(result[0].warm.age, 2, "age should be incremented");
1347    }
1348
1349    #[test]
1350    fn test_transfer_warm_start_distant_point_gets_zero() {
1351        let old_pt = {
1352            let mut cp = WarmContactPoint::new(ContactPoint {
1353                point: [0.0, 0.0, 0.0],
1354                normal: [0.0, 1.0, 0.0],
1355                depth: 0.05,
1356            });
1357            cp.warm.normal_impulse = 10.0;
1358            cp.warm.tick();
1359            cp
1360        };
1361        let old_warm = vec![old_pt];
1362
1363        // New contact far away.
1364        let new_contacts = vec![ContactPoint {
1365            point: [100.0, 0.0, 0.0],
1366            normal: [0.0, 1.0, 0.0],
1367            depth: 0.04,
1368        }];
1369
1370        let result = transfer_warm_start(&new_contacts, &old_warm);
1371        assert_eq!(result.len(), 1);
1372        assert!(
1373            !result[0].warm.is_warm(),
1374            "distant contact should get no warm-start"
1375        );
1376        assert_eq!(result[0].warm.normal_impulse, 0.0);
1377    }
1378
1379    // ── WarmManifold ──────────────────────────────────────────────────────────
1380
1381    #[test]
1382    fn test_warm_manifold_from_manifold_no_prev() {
1383        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1384        for i in 0..4 {
1385            m.contacts.push(make_cp(i as f64, 0.0, 0.1));
1386        }
1387        let wm = WarmManifold::from_manifold(&m, None);
1388        assert_eq!(wm.len(), 4);
1389        for pt in wm.iter() {
1390            assert!(!pt.warm.is_warm(), "first frame: no warm data");
1391        }
1392    }
1393
1394    #[test]
1395    fn test_warm_manifold_scale_impulses() {
1396        let mut wm = WarmManifold::new([0.0, 1.0, 0.0]);
1397        let mut wcp = WarmContactPoint::new(make_cp(0.0, 0.0, 0.1));
1398        wcp.warm.normal_impulse = 8.0;
1399        wm.points.push(wcp);
1400
1401        wm.scale_impulses(0.25);
1402        assert!((wm.points[0].warm.normal_impulse - 2.0).abs() < 1e-12);
1403    }
1404
1405    #[test]
1406    fn test_warm_manifold_reset_warm_start() {
1407        let mut wm = WarmManifold::new([0.0, 1.0, 0.0]);
1408        let mut wcp = WarmContactPoint::new(make_cp(0.0, 0.0, 0.1));
1409        wcp.warm.normal_impulse = 5.0;
1410        wcp.warm.age = 3;
1411        wm.points.push(wcp);
1412
1413        wm.reset_warm_start();
1414        assert_eq!(wm.points[0].warm.normal_impulse, 0.0);
1415        assert_eq!(wm.points[0].warm.age, 0);
1416    }
1417
1418    #[test]
1419    fn test_warm_manifold_recompute_normal() {
1420        let mut wm = WarmManifold::new([1.0, 0.0, 0.0]);
1421        let normals = [[0.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.8, 0.6]];
1422        for n in normals {
1423            let mut wcp = WarmContactPoint::new(ContactPoint {
1424                point: [0.0, 0.0, 0.0],
1425                normal: n,
1426                depth: 0.1,
1427            });
1428            wcp.warm = WarmStartData::new();
1429            wm.points.push(wcp);
1430        }
1431        wm.recompute_normal();
1432        let nl = (wm.normal[0] * wm.normal[0]
1433            + wm.normal[1] * wm.normal[1]
1434            + wm.normal[2] * wm.normal[2])
1435            .sqrt();
1436        assert!(
1437            (nl - 1.0).abs() < 1e-9,
1438            "recomputed normal should be unit length"
1439        );
1440    }
1441
1442    // ── PersistentPatch ───────────────────────────────────────────────────────
1443
1444    #[test]
1445    fn test_persistent_patch_new_empty() {
1446        let patch = PersistentPatch::new([0.0, 1.0, 0.0]);
1447        assert!(patch.points.is_empty());
1448        assert_eq!(patch.frame_count, 0);
1449        assert!(patch.active);
1450    }
1451
1452    #[test]
1453    fn test_persistent_patch_update_increments_frame() {
1454        let mut patch = PersistentPatch::new([0.0, 1.0, 0.0]);
1455        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1456        m.contacts.push(make_cp(0.0, 0.0, 0.1));
1457        patch.update(&m);
1458        assert_eq!(patch.frame_count, 1);
1459    }
1460
1461    #[test]
1462    fn test_persistent_patch_update_twice() {
1463        let mut patch = PersistentPatch::new([0.0, 1.0, 0.0]);
1464        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1465        m.contacts.push(make_cp(0.0, 0.0, 0.1));
1466        patch.update(&m);
1467        patch.update(&m);
1468        assert_eq!(patch.frame_count, 2);
1469    }
1470
1471    #[test]
1472    fn test_persistent_patch_deactivate() {
1473        let mut patch = PersistentPatch::new([0.0, 1.0, 0.0]);
1474        patch.deactivate();
1475        assert!(!patch.active);
1476    }
1477
1478    #[test]
1479    fn test_persistent_patch_is_sustained() {
1480        let mut patch = PersistentPatch::new([0.0, 1.0, 0.0]);
1481        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1482        m.contacts.push(make_cp(0.0, 0.0, 0.1));
1483        patch.update(&m);
1484        patch.update(&m);
1485        assert!(patch.is_sustained(2), "should be sustained after 2 frames");
1486        assert!(
1487            !patch.is_sustained(3),
1488            "should not be sustained for 3 frames yet"
1489        );
1490    }
1491
1492    #[test]
1493    fn test_persistent_patch_max_depth() {
1494        let mut patch = PersistentPatch::new([0.0, 1.0, 0.0]);
1495        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1496        m.contacts.push(make_cp(0.0, 0.0, 0.05));
1497        m.contacts.push(make_cp(1.0, 0.0, 0.10));
1498        patch.update(&m);
1499        assert!(
1500            (patch.max_depth() - 0.10).abs() < 1e-10,
1501            "max_depth should be 0.10"
1502        );
1503    }
1504
1505    #[test]
1506    fn test_persistent_patch_average_position() {
1507        let mut patch = PersistentPatch::new([0.0, 1.0, 0.0]);
1508        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1509        m.contacts.push(make_cp(0.0, 0.0, 0.1));
1510        m.contacts.push(make_cp(2.0, 0.0, 0.1));
1511        patch.update(&m);
1512        let avg = patch.average_position().unwrap();
1513        assert!(
1514            (avg[0] - 1.0).abs() < 0.01,
1515            "average x should be ~1.0, got {}",
1516            avg[0]
1517        );
1518    }
1519
1520    // ── smooth_contact_normal ─────────────────────────────────────────────────
1521
1522    #[test]
1523    fn test_smooth_contact_normal_alpha_one() {
1524        let prev = [1.0f64, 0.0, 0.0];
1525        let new = [0.0, 1.0, 0.0];
1526        let result = smooth_contact_normal(prev, new, 1.0);
1527        assert!((result[0]).abs() < 1e-10, "alpha=1 should give new normal");
1528        assert!((result[1] - 1.0).abs() < 1e-10);
1529    }
1530
1531    #[test]
1532    fn test_smooth_contact_normal_alpha_zero() {
1533        let prev = [1.0f64, 0.0, 0.0];
1534        let new = [0.0, 1.0, 0.0];
1535        let result = smooth_contact_normal(prev, new, 0.0);
1536        assert!(
1537            (result[0] - 1.0).abs() < 1e-10,
1538            "alpha=0 should give prev normal"
1539        );
1540        assert!((result[1]).abs() < 1e-10);
1541    }
1542
1543    #[test]
1544    fn test_smooth_contact_normal_midpoint_unit_length() {
1545        let prev = [1.0f64, 0.0, 0.0];
1546        let new = [0.0, 1.0, 0.0];
1547        let result = smooth_contact_normal(prev, new, 0.5);
1548        let l = (result[0] * result[0] + result[1] * result[1] + result[2] * result[2]).sqrt();
1549        assert!(
1550            (l - 1.0).abs() < 1e-10,
1551            "smoothed normal should be unit length, got {l}"
1552        );
1553    }
1554
1555    #[test]
1556    fn test_smooth_manifold_normals() {
1557        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1558        m.contacts.push(ContactPoint {
1559            point: [0.0, 0.0, 0.0],
1560            normal: [1.0, 0.0, 0.0],
1561            depth: 0.1,
1562        });
1563        smooth_manifold_normals(&mut m, 0.5);
1564        // The contact point normal should be blended
1565        let n = m.contacts[0].normal;
1566        let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1567        assert!(
1568            (l - 1.0).abs() < 1e-10,
1569            "smoothed normal should be unit length"
1570        );
1571    }
1572
1573    // ── SpeculativeContactPoint ───────────────────────────────────────────────
1574
1575    #[test]
1576    fn test_speculative_contact_will_collide() {
1577        // separation=0.1, closing_speed=1.0, dt=0.2 → after dt: 0.1 - 0.2 = -0.1 < 0 → collide
1578        let spec = SpeculativeContactPoint::new([0.0; 3], [0.0, 1.0, 0.0], 0.1, 1.0);
1579        assert!(spec.will_collide(0.2));
1580    }
1581
1582    #[test]
1583    fn test_speculative_contact_will_not_collide() {
1584        // separation=1.0, closing_speed=0.1, dt=0.1 → after dt: 1.0 - 0.01 > 0 → no collide
1585        let spec = SpeculativeContactPoint::new([0.0; 3], [0.0, 1.0, 0.0], 1.0, 0.1);
1586        assert!(!spec.will_collide(0.1));
1587    }
1588
1589    #[test]
1590    fn test_speculative_contact_predicted_depth() {
1591        let spec = SpeculativeContactPoint::new([0.0; 3], [0.0, 1.0, 0.0], 0.1, 1.0);
1592        let depth = spec.predicted_depth(0.2);
1593        assert!((depth - 0.1).abs() < 1e-10, "Expected 0.1, got {depth}");
1594    }
1595
1596    #[test]
1597    fn test_generate_speculative_contacts() {
1598        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1599        m.contacts.push(ContactPoint {
1600            point: [0.0, 0.0, 0.0],
1601            normal: [0.0, 1.0, 0.0],
1602            depth: 0.05,
1603        });
1604        m.contacts.push(ContactPoint {
1605            point: [1.0, 0.0, 0.0],
1606            normal: [0.0, 1.0, 0.0],
1607            depth: 0.05,
1608        });
1609        let speculative = generate_speculative_contacts(&m, [0.0, 1.0, 0.0], 5.0, 0.1, 0.1);
1610        // 0.1 - 5.0*0.1 = -0.4 < 0 → both should collide
1611        assert_eq!(
1612            speculative.len(),
1613            2,
1614            "Both speculative contacts should be generated"
1615        );
1616    }
1617
1618    #[test]
1619    fn test_generate_speculative_contacts_none_collide() {
1620        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1621        m.contacts.push(ContactPoint {
1622            point: [0.0, 0.0, 0.0],
1623            normal: [0.0, 1.0, 0.0],
1624            depth: 0.05,
1625        });
1626        // separation huge, slow closing speed, small dt → won't collide
1627        let speculative = generate_speculative_contacts(&m, [0.0, 1.0, 0.0], 0.001, 100.0, 0.0001);
1628        assert_eq!(
1629            speculative.len(),
1630            0,
1631            "No speculative contacts should be generated"
1632        );
1633    }
1634
1635    // ── rotate_manifold_points ────────────────────────────────────────────────
1636
1637    #[test]
1638    fn test_rotate_manifold_points_identity() {
1639        let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1640        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1641        m.contacts.push(ContactPoint {
1642            point: [1.0, 2.0, 3.0],
1643            normal: [0.0, 1.0, 0.0],
1644            depth: 0.1,
1645        });
1646        rotate_manifold_points(&mut m, identity);
1647        assert!((m.contacts[0].point[0] - 1.0).abs() < 1e-10);
1648        assert!((m.contacts[0].point[1] - 2.0).abs() < 1e-10);
1649        assert!((m.contacts[0].point[2] - 3.0).abs() < 1e-10);
1650    }
1651
1652    #[test]
1653    fn test_rotate_manifold_points_90_about_z() {
1654        // 90° rotation about Z: (1,0,0) → (0,1,0)
1655        let rot_z_90 = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
1656        let mut m = ContactManifold::new([1.0, 0.0, 0.0]);
1657        m.contacts.push(ContactPoint {
1658            point: [1.0, 0.0, 0.0],
1659            normal: [1.0, 0.0, 0.0],
1660            depth: 0.1,
1661        });
1662        rotate_manifold_points(&mut m, rot_z_90);
1663        assert!(
1664            (m.contacts[0].point[0]).abs() < 1e-10,
1665            "x should be 0 after rotation"
1666        );
1667        assert!(
1668            (m.contacts[0].point[1] - 1.0).abs() < 1e-10,
1669            "y should be 1 after rotation"
1670        );
1671    }
1672
1673    // ── friction_basis ────────────────────────────────────────────────────────
1674
1675    #[test]
1676    fn test_friction_basis_orthogonal_to_normal() {
1677        let normals: [[f64; 3]; 4] = [
1678            [1.0, 0.0, 0.0],
1679            [0.0, 1.0, 0.0],
1680            [0.0, 0.0, 1.0],
1681            normalize3([1.0, 1.0, 1.0]),
1682        ];
1683        for n in normals {
1684            let (t1, t2) = friction_basis(n);
1685            assert!(
1686                dot3(n, t1).abs() < 1e-9,
1687                "t1 should be perpendicular to normal"
1688            );
1689            assert!(
1690                dot3(n, t2).abs() < 1e-9,
1691                "t2 should be perpendicular to normal"
1692            );
1693            assert!(
1694                dot3(t1, t2).abs() < 1e-9,
1695                "t1 and t2 should be perpendicular"
1696            );
1697            let l1 = (t1[0] * t1[0] + t1[1] * t1[1] + t1[2] * t1[2]).sqrt();
1698            let l2 = (t2[0] * t2[0] + t2[1] * t2[1] + t2[2] * t2[2]).sqrt();
1699            assert!((l1 - 1.0).abs() < 1e-9, "t1 should be unit length");
1700            assert!((l2 - 1.0).abs() < 1e-9, "t2 should be unit length");
1701        }
1702    }
1703
1704    // ── decompose_velocity ────────────────────────────────────────────────────
1705
1706    #[test]
1707    fn test_decompose_velocity_normal_component() {
1708        let vel = [0.0, -3.0, 0.0];
1709        let normal = [0.0, 1.0, 0.0];
1710        let (vn, vt) = decompose_velocity(vel, normal);
1711        assert!(
1712            (vn[1] - (-3.0)).abs() < 1e-10,
1713            "normal component should be [0,-3,0]"
1714        );
1715        assert!(
1716            vt[0].abs() < 1e-10 && vt[1].abs() < 1e-10 && vt[2].abs() < 1e-10,
1717            "tangential component should be zero for pure normal velocity"
1718        );
1719    }
1720
1721    #[test]
1722    fn test_decompose_velocity_tangential_component() {
1723        let vel = [2.0, 0.0, 0.0];
1724        let normal = [0.0, 1.0, 0.0];
1725        let (vn, vt) = decompose_velocity(vel, normal);
1726        assert!(
1727            vn[0].abs() < 1e-10 && vn[1].abs() < 1e-10,
1728            "normal component should be zero"
1729        );
1730        assert!(
1731            (vt[0] - 2.0).abs() < 1e-10,
1732            "tangential component should be [2,0,0]"
1733        );
1734    }
1735
1736    // ── coulomb_friction_limit ────────────────────────────────────────────────
1737
1738    #[test]
1739    fn test_coulomb_friction_limit_basic() {
1740        let limit = coulomb_friction_limit(10.0, 0.5);
1741        assert!((limit - 5.0).abs() < 1e-10, "Expected 5.0, got {limit}");
1742    }
1743
1744    #[test]
1745    fn test_coulomb_friction_limit_negative_normal() {
1746        // Negative normal impulse should clamp to zero
1747        let limit = coulomb_friction_limit(-1.0, 0.5);
1748        assert_eq!(
1749            limit, 0.0,
1750            "Negative normal impulse should give 0 friction limit"
1751        );
1752    }
1753
1754    // ── manifold_quality ──────────────────────────────────────────────────────
1755
1756    #[test]
1757    fn test_manifold_quality_empty() {
1758        let m = ContactManifold::new([0.0, 1.0, 0.0]);
1759        let q = manifold_quality(&m);
1760        assert_eq!(q, 0.0, "Empty manifold should have quality 0");
1761    }
1762
1763    #[test]
1764    fn test_manifold_quality_single_point() {
1765        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1766        m.contacts.push(ContactPoint {
1767            point: [0.0; 3],
1768            normal: [0.0, 1.0, 0.0],
1769            depth: 0.1,
1770        });
1771        let q = manifold_quality(&m);
1772        assert!(q > 0.0 && q <= 1.0, "Quality should be in (0,1], got {q}");
1773    }
1774
1775    #[test]
1776    fn test_manifold_quality_four_wide_points() {
1777        let mut m = ContactManifold::new([0.0, 1.0, 0.0]);
1778        for &x in &[-1.0f64, 1.0, 0.0, 0.0] {
1779            m.contacts.push(ContactPoint {
1780                point: [x, 0.0, 0.0],
1781                normal: [0.0, 1.0, 0.0],
1782                depth: 0.5,
1783            });
1784        }
1785        let q = manifold_quality(&m);
1786        assert!(q > 0.0 && q <= 1.0, "Quality should be in (0,1], got {q}");
1787    }
1788}