axiolid_collide/sat.rs
1// SPDX-License-Identifier: MPL-2.0
2
3//! The separating axis test.
4//!
5//! # The degenerate-axis trap
6//!
7//! Candidate axes include cross products of edge pairs. When two edges are
8//! nearly parallel their cross product is near zero, and normalising it
9//! amplifies rounding into a direction that points essentially anywhere.
10//! Projecting onto such an axis can report a separation of ~1e-16 for two
11//! shapes that genuinely touch, which turns a clearance check into a
12//! coin flip.
13//!
14//! Those axes are skipped by squared length before normalisation. Skipping is
15//! safe: a degenerate cross product carries no separating information that
16//! the two edges' own face normals do not already carry.
17
18use axiolid_core::{Point3, Scalar, Vec3};
19
20use crate::{project, ConvexShape};
21
22/// The axis along which two shapes are furthest apart.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct Contact {
25 /// Unit direction of least overlap, pointing from `a` towards `b`.
26 pub axis: Vec3,
27 /// Gap along `axis`.
28 pub distance: Scalar,
29}
30
31/// Outcome of a separating-axis query.
32#[derive(Debug, Clone, Copy, PartialEq)]
33#[non_exhaustive]
34pub enum SeparationResult {
35 /// A separating axis exists: the shapes are disjoint.
36 Apart {
37 /// Unit direction that separates them.
38 axis: Vec3,
39 /// Gap along that axis. The largest gap found, which is the
40 /// shapes' true separation distance for convex operands.
41 distance: Scalar,
42 },
43 /// No separating axis exists: the shapes share at least one point.
44 ///
45 /// No depth is reported. See the crate docs: penetration depth and
46 /// contact extent are different measurements and this crate refuses to
47 /// conflate them.
48 Overlapping,
49}
50
51/// Find a separating axis between two convex shapes, or prove none exists.
52///
53/// `tolerance` is the gap below which two projections count as touching
54/// rather than apart, and the length below which a candidate axis is treated
55/// as degenerate and skipped.
56#[must_use]
57pub fn separation(a: &ConvexShape, b: &ConvexShape, tolerance: Scalar) -> SeparationResult {
58 if a.is_empty() || b.is_empty() {
59 // An empty shape occupies no space, so it cannot overlap anything.
60 // Reporting a direction would be inventing one.
61 return SeparationResult::Apart {
62 axis: Vec3::new(1.0, 0.0, 0.0),
63 distance: Scalar::INFINITY,
64 };
65 }
66
67 let mut best_axis = Vec3::ZERO;
68 let mut best_gap = Scalar::NEG_INFINITY;
69
70 for axis in candidate_axes(a, b, tolerance) {
71 let (a_min, a_max) = project(&a.points, axis);
72 let (b_min, b_max) = project(&b.points, axis);
73 // Gap is positive when the projections are disjoint. Take the larger
74 // of the two orderings so the sign convention is "b beyond a".
75 let gap = (b_min - a_max).max(a_min - b_max);
76 if gap > best_gap {
77 best_gap = gap;
78 best_axis = if b_min - a_max >= a_min - b_max {
79 axis
80 } else {
81 -axis
82 };
83 }
84 // A single axis with a real gap proves disjointness; no need to test
85 // the rest.
86 if gap > tolerance {
87 return SeparationResult::Apart {
88 axis: best_axis,
89 distance: gap,
90 };
91 }
92 }
93
94 if best_gap > tolerance {
95 SeparationResult::Apart {
96 axis: best_axis,
97 distance: best_gap,
98 }
99 } else {
100 SeparationResult::Overlapping
101 }
102}
103
104/// Every axis worth testing: both shapes' face normals, plus edge-pair cross
105/// products, each normalised and filtered for degeneracy.
106fn candidate_axes(a: &ConvexShape, b: &ConvexShape, tolerance: Scalar) -> Vec<Vec3> {
107 // Below this squared length a cross product is numerical noise rather
108 // than a direction.
109 let floor = (tolerance * tolerance).max(1e-24);
110 let mut axes = Vec::new();
111
112 let push = |v: Vec3, axes: &mut Vec<Vec3>| {
113 let length_squared = v.length_squared();
114 if length_squared > floor {
115 axes.push(v / length_squared.sqrt());
116 }
117 };
118
119 for &normal in a.normals.iter().chain(b.normals.iter()) {
120 push(normal, &mut axes);
121 }
122 for &edge_a in &a.edges {
123 for &edge_b in &b.edges {
124 push(edge_a.cross(edge_b), &mut axes);
125 }
126 }
127 // With no face normals and no usable edge pairs (two coincident points,
128 // say) fall back to the line between the shapes so the query still has an
129 // answer rather than silently reporting overlap.
130 if axes.is_empty() {
131 let direction = centroid(&b.points) - centroid(&a.points);
132 push(direction, &mut axes);
133 }
134 axes
135}
136
137fn centroid(points: &[Point3]) -> Point3 {
138 let mut sum = Vec3::ZERO;
139 for point in points {
140 sum += *point;
141 }
142 sum / points.len() as Scalar
143}