1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
//! Direction-dependent friction for tyres, skis, and ice-blade contacts.
//!
//! Complements [`crate::material::PhysicsMaterial`] (isotropic Coulomb friction)
//! with an orthotropic friction model — separate longitudinal and
//! transverse coefficients plus a kinematic multiplier that kicks in
//! once the tangential slip magnitude exceeds a threshold.
//!
//! For a contact whose surface tangent frame is `(t_long, t_trans)`
//! and relative tangential velocity `v_tan`, the friction force is
//!
//! ```text
//! v_long = v_tan · t_long
//! v_trans = v_tan · t_trans
//! |v_tan| = sqrt(v_long² + v_trans²)
//! F = -N · ( μ_long · v_long · t_long + μ_trans · v_trans · t_trans ) / |v_tan|
//! ```
//!
//! — the orthotropic *friction ellipse* (Zmitrowicz 1981; the same law as
//! Bullet / PhysX anisotropic friction): along a principal axis it is Coulomb
//! `−μ N t̂`, for oblique slip the magnitude is `N sqrt(μ_long² cos²θ +
//! μ_trans² sin²θ)` and the direction is the ellipse normal, and with
//! `μ_long = μ_trans` it reduces to isotropic Coulomb `−μ N v̂` for every
//! slip direction. (Before 1.2.0 the module summed `sign(v_i) μ_i t̂_i` per
//! axis — a box law that was `√2 μ N` at 45° in the isotropic case.)
//! `N` is the contact normal force; the coefficients switch from
//! `μ_static` to `μ_kinetic` once the tangential slip speed `|v_tan|`
//! exceeds the threshold (inclusive: at the threshold static applies).
use crate::math::{Fix128, Vec3Fix};
/// Orthotropic friction parameters aligned with a surface tangent frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnisotropicFriction {
/// Static friction coefficient along the longitudinal axis.
pub longitudinal_static: Fix128,
/// Kinetic friction coefficient along the longitudinal axis.
pub longitudinal_kinetic: Fix128,
/// Static friction coefficient along the transverse axis.
pub transverse_static: Fix128,
/// Kinetic friction coefficient along the transverse axis.
pub transverse_kinetic: Fix128,
/// Tangential slip speed (m/s) below which the static coefficient
/// applies. Typical: 0.05.
pub slip_threshold_m_s: Fix128,
}
impl AnisotropicFriction {
/// Tyre on asphalt — high longitudinal grip, moderate transverse.
#[must_use]
pub fn tyre_asphalt() -> Self {
Self {
longitudinal_static: Fix128::from_ratio(11, 10),
longitudinal_kinetic: Fix128::from_ratio(9, 10),
transverse_static: Fix128::from_ratio(9, 10),
transverse_kinetic: Fix128::from_ratio(7, 10),
slip_threshold_m_s: Fix128::from_ratio(5, 100),
}
}
/// Ski on packed snow — very low longitudinal, high transverse.
#[must_use]
pub fn ski_snow() -> Self {
Self {
longitudinal_static: Fix128::from_ratio(6, 100),
longitudinal_kinetic: Fix128::from_ratio(4, 100),
transverse_static: Fix128::from_ratio(9, 10),
transverse_kinetic: Fix128::from_ratio(75, 100),
slip_threshold_m_s: Fix128::from_ratio(5, 100),
}
}
/// Ice blade — near-zero longitudinal friction.
#[must_use]
pub fn skate_ice() -> Self {
Self {
longitudinal_static: Fix128::from_ratio(2, 100),
longitudinal_kinetic: Fix128::from_ratio(15, 1000),
transverse_static: Fix128::from_ratio(85, 100),
transverse_kinetic: Fix128::from_ratio(7, 10),
slip_threshold_m_s: Fix128::from_ratio(2, 100),
}
}
/// Compute the tangential friction force acting on the sliding
/// body.
///
/// Inputs are expressed in world coordinates:
///
/// - `normal_force`: contact normal load (N). Non-positive returns zero.
/// - `tangent_long`, `tangent_trans`: orthonormal tangent axes on
/// the contact surface.
/// - `relative_velocity`: sliding velocity in world space.
#[must_use]
pub fn friction_force(
&self,
normal_force: Fix128,
tangent_long: Vec3Fix,
tangent_trans: Vec3Fix,
relative_velocity: Vec3Fix,
) -> Vec3Fix {
if normal_force <= Fix128::ZERO {
return Vec3Fix::ZERO;
}
let v_long = relative_velocity.dot(tangent_long);
let v_trans = relative_velocity.dot(tangent_trans);
// |v_tan|; exact on the principal axes (no square/root round trip)
// so the Coulomb limit there is bit-exact.
let slip = if v_long.is_zero() {
v_trans.abs()
} else if v_trans.is_zero() {
v_long.abs()
} else {
// Combined magnitude without squaring-then-summing in a way
// that overflows Fix128 (|v| > ~3e9 m/s: v_long^2 + v_trans^2
// wraps). Halve v_long/v_trans (exact bit shifts, no rounding)
// until their squares — and the sum — are representable, then
// double the resulting magnitude back up by the same count.
// The true slip is representable even when the bare sum of
// squares is not (e.g. v_long = v_trans = 1e10: slip =
// 1e10*sqrt(2) ~= 1.41e10, well within range, even though
// v_long^2 + v_trans^2 ~= 2e20 is not).
let mut vl = v_long;
let mut vt = v_trans;
// The other end: below |v| ~ 2.3e-10 the squares fall under one
// ulp and the slip would read 0 (AUD-A-S2W3-001). When the larger
// component is under 2^-24, double both (exact) until it is at
// least 1/2 — squares near 1 keep the full 2^-64 resolution — and
// halve the magnitude back by the same count. Above 2^-24 nothing
// changes.
let floor = Fix128::from_raw(0, 1 << 40);
let mut up = 0u32;
if vl.abs().max(vt.abs()) < floor {
while vl.abs().max(vt.abs()) < Fix128::from_ratio(1, 2) && up < 128 {
vl = vl.double();
vt = vt.double();
up += 1;
}
}
let mut shift = 0u32;
let mut sum = None;
for _ in 0..200 {
sum = vl
.checked_mul(vl)
.zip(vt.checked_mul(vt))
.and_then(|(a, b)| {
let s = a + b;
(s >= a && s >= b).then_some(s)
});
if sum.is_some() {
break;
}
vl = vl.half();
vt = vt.half();
shift += 1;
}
let mut magnitude = sum.unwrap_or(Fix128::ZERO).sqrt();
for _ in 0..shift {
magnitude = magnitude.double();
}
for _ in 0..up {
magnitude = magnitude.half();
}
magnitude
};
if slip.is_zero() {
return Vec3Fix::ZERO;
}
let (mu_long, mu_trans) = self.select_coefficients(slip);
// Friction ellipse: each axis contributes in proportion to its share
// of the slip velocity, so the total is −N · M v̂ with M = diag(μ).
let long_force = tangent_long * (-normal_force * mu_long * v_long / slip);
let trans_force = tangent_trans * (-normal_force * mu_trans * v_trans / slip);
long_force + trans_force
}
/// `(μ_long, μ_trans)` for the given tangential slip speed: static at
/// or below the threshold, kinetic above it.
fn select_coefficients(&self, slip_speed: Fix128) -> (Fix128, Fix128) {
if slip_speed <= self.slip_threshold_m_s {
(self.longitudinal_static, self.transverse_static)
} else {
(self.longitudinal_kinetic, self.transverse_kinetic)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn axes() -> (Vec3Fix, Vec3Fix) {
(
Vec3Fix::new(Fix128::ONE, Fix128::ZERO, Fix128::ZERO),
Vec3Fix::new(Fix128::ZERO, Fix128::ZERO, Fix128::ONE),
)
}
#[test]
fn preset_tyre_high_longitudinal_grip() {
let m = AnisotropicFriction::tyre_asphalt();
assert!(m.longitudinal_static > m.transverse_static);
}
#[test]
fn preset_ski_low_longitudinal_grip() {
let m = AnisotropicFriction::ski_snow();
assert!(m.longitudinal_static < m.transverse_static);
}
#[test]
fn preset_ice_extreme_anisotropy() {
let m = AnisotropicFriction::skate_ice();
assert!(m.longitudinal_kinetic < Fix128::from_ratio(5, 100));
assert!(m.transverse_static > Fix128::from_ratio(5, 10));
}
#[test]
fn zero_normal_load_yields_zero_force() {
let m = AnisotropicFriction::tyre_asphalt();
let (tl, tt) = axes();
let f = m.friction_force(
Fix128::ZERO,
tl,
tt,
Vec3Fix::new(Fix128::from_int(10), Fix128::ZERO, Fix128::ZERO),
);
assert_eq!(f, Vec3Fix::ZERO);
}
#[test]
fn friction_opposes_pure_longitudinal_slip() {
let m = AnisotropicFriction::tyre_asphalt();
let (tl, tt) = axes();
let velocity = Vec3Fix::new(Fix128::from_int(5), Fix128::ZERO, Fix128::ZERO);
let f = m.friction_force(Fix128::from_int(100), tl, tt, velocity);
assert!(f.x < Fix128::ZERO);
assert_eq!(f.z, Fix128::ZERO);
}
#[test]
fn friction_opposes_pure_transverse_slip() {
let m = AnisotropicFriction::tyre_asphalt();
let (tl, tt) = axes();
let velocity = Vec3Fix::new(Fix128::ZERO, Fix128::ZERO, Fix128::from_int(5));
let f = m.friction_force(Fix128::from_int(100), tl, tt, velocity);
assert_eq!(f.x, Fix128::ZERO);
assert!(f.z < Fix128::ZERO);
}
#[test]
fn slow_slip_selects_static_coefficient() {
let mut m = AnisotropicFriction::tyre_asphalt();
m.longitudinal_static = Fix128::from_int(2);
m.longitudinal_kinetic = Fix128::from_int(1);
let (tl, tt) = axes();
// Below threshold — static.
let slow = Vec3Fix::new(Fix128::from_ratio(1, 100), Fix128::ZERO, Fix128::ZERO);
let f_slow = m.friction_force(Fix128::from_int(10), tl, tt, slow);
// Above threshold — kinetic.
let fast = Vec3Fix::new(Fix128::from_int(2), Fix128::ZERO, Fix128::ZERO);
let f_fast = m.friction_force(Fix128::from_int(10), tl, tt, fast);
// The static magnitude should be greater than the kinetic magnitude.
assert!(f_slow.x.abs() > f_fast.x.abs());
}
}