Skip to main content

alice_physics/
material.rs

1//! Material System with Pair-wise Combine Rules
2//!
3//! Provides friction and restitution lookup per body-pair, with configurable
4//! combine rules (Average, Min, Max, Multiply).
5//!
6//! # AAA Features
7//!
8//! - **Material IDs**: Assign material types to rigid bodies
9//! - **Combine Rules**: Average, Min, Max, Multiply for friction/restitution
10//! - **Pair Overrides**: Custom friction/restitution for specific material pairs
11//! - **Default Materials**: Predefined materials (Metal, Wood, Rubber, Ice, etc.)
12
13use crate::error::PhysicsError;
14use crate::math::Fix128;
15
16#[cfg(not(feature = "std"))]
17use alloc::vec::Vec;
18
19/// Material ID (u16 for compact storage)
20pub type MaterialId = u16;
21
22/// Default material ID
23pub const DEFAULT_MATERIAL: MaterialId = 0;
24
25/// Number of distinct `MaterialId` values (`u16::MAX as usize + 1`).
26pub(crate) const MATERIAL_ID_CAPACITY: usize = MaterialId::MAX as usize + 1;
27
28/// Combine rule for friction/restitution when two materials interact
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30pub enum CombineRule {
31    /// Average of two values
32    #[default]
33    Average,
34    /// Minimum of two values
35    Min,
36    /// Maximum of two values
37    Max,
38    /// Multiply two values
39    Multiply,
40}
41
42impl CombineRule {
43    /// Apply the combine rule to two values
44    #[inline]
45    #[must_use]
46    pub fn apply(&self, a: Fix128, b: Fix128) -> Fix128 {
47        match self {
48            Self::Average => (a + b).half(),
49            Self::Min => {
50                if a < b {
51                    a
52                } else {
53                    b
54                }
55            }
56            Self::Max => {
57                if a > b {
58                    a
59                } else {
60                    b
61                }
62            }
63            Self::Multiply => a * b,
64        }
65    }
66}
67
68/// Physics material definition
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub struct PhysicsMaterial {
71    /// Material identifier
72    pub id: MaterialId,
73    /// Static friction coefficient
74    pub static_friction: Fix128,
75    /// Dynamic friction coefficient
76    pub dynamic_friction: Fix128,
77    /// Restitution (bounciness)
78    pub restitution: Fix128,
79    /// Friction combine rule
80    pub friction_combine: CombineRule,
81    /// Restitution combine rule
82    pub restitution_combine: CombineRule,
83}
84
85impl PhysicsMaterial {
86    /// Create a new material with given properties
87    #[must_use]
88    pub const fn new(id: MaterialId, friction: Fix128, restitution: Fix128) -> Self {
89        Self {
90            id,
91            static_friction: friction,
92            dynamic_friction: friction,
93            restitution,
94            friction_combine: CombineRule::Average,
95            restitution_combine: CombineRule::Average,
96        }
97    }
98
99    /// Set combine rules
100    #[must_use]
101    pub const fn with_combine_rules(
102        mut self,
103        friction: CombineRule,
104        restitution: CombineRule,
105    ) -> Self {
106        self.friction_combine = friction;
107        self.restitution_combine = restitution;
108        self
109    }
110
111    /// Set separate static/dynamic friction
112    #[must_use]
113    pub const fn with_static_friction(mut self, static_friction: Fix128) -> Self {
114        self.static_friction = static_friction;
115        self
116    }
117}
118
119impl Default for PhysicsMaterial {
120    fn default() -> Self {
121        Self::new(
122            DEFAULT_MATERIAL,
123            Fix128::from_ratio(5, 10),
124            Fix128::from_ratio(3, 10),
125        )
126    }
127}
128
129/// Pair override entry
130#[derive(Clone, Copy, Debug, PartialEq)]
131pub(crate) struct PairOverride {
132    pub(crate) mat_a: MaterialId,
133    pub(crate) mat_b: MaterialId,
134    pub(crate) friction: Fix128,
135    pub(crate) restitution: Fix128,
136}
137
138/// Combined material result for a contact pair
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub struct CombinedMaterial {
141    /// Combined friction
142    pub friction: Fix128,
143    /// Combined restitution
144    pub restitution: Fix128,
145}
146
147/// Material pair lookup table
148pub struct MaterialTable {
149    /// Registered materials (indexed by `MaterialId`)
150    pub(crate) materials: Vec<PhysicsMaterial>,
151    /// Pair-specific overrides
152    pub(crate) pair_overrides: Vec<PairOverride>,
153    /// Global friction combine rule (fallback)
154    pub default_friction_combine: CombineRule,
155    /// Global restitution combine rule (fallback)
156    pub default_restitution_combine: CombineRule,
157}
158
159impl MaterialTable {
160    /// Create a new material table with a default material
161    #[must_use]
162    pub fn new() -> Self {
163        let mut table = Self {
164            materials: Vec::new(),
165            pair_overrides: Vec::new(),
166            default_friction_combine: CombineRule::Average,
167            default_restitution_combine: CombineRule::Average,
168        };
169        // Register default material at index 0
170        table.register(PhysicsMaterial::default());
171        table
172    }
173
174    /// Register a material, returns its ID
175    ///
176    /// The material's `id` field is overwritten with the assigned ID.
177    ///
178    /// # Panics
179    ///
180    /// Panics when the table already holds `u16::MAX as usize + 1` (65,536)
181    /// materials, i.e. every `MaterialId` is in use (the default material
182    /// created by [`MaterialTable::new`] occupies ID 0, so at most 65,535
183    /// materials can be added after construction). Use
184    /// [`MaterialTable::try_register`] to handle that case without panicking.
185    pub fn register(&mut self, material: PhysicsMaterial) -> MaterialId {
186        match self.try_register(material) {
187            Ok(id) => id,
188            Err(e) => panic!("MaterialTable::register: {e}; every MaterialId is in use"),
189        }
190    }
191
192    /// Register a material, returns its ID, or an error when every
193    /// `MaterialId` is already in use.
194    ///
195    /// IDs are assigned in registration order, so a table can hold at most
196    /// `u16::MAX as usize + 1` (65,536) materials, including the default
197    /// material at ID 0. A successful call never reuses or overwrites an
198    /// existing ID.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`PhysicsError::CapacityExceeded`] with `limit = 65_536` when
203    /// the table already holds 65,536 materials. The table is left unchanged.
204    pub fn try_register(&mut self, material: PhysicsMaterial) -> Result<MaterialId, PhysicsError> {
205        let len = self.materials.len();
206        if len >= MATERIAL_ID_CAPACITY {
207            return Err(PhysicsError::CapacityExceeded {
208                resource: "materials",
209                limit: MATERIAL_ID_CAPACITY,
210            });
211        }
212        // `len < 65_536`, so the cast is exact.
213        let id = len as MaterialId;
214        let mut mat = material;
215        mat.id = id;
216        self.materials.push(mat);
217        Ok(id)
218    }
219
220    /// Get material by ID
221    #[must_use]
222    pub fn get(&self, id: MaterialId) -> &PhysicsMaterial {
223        self.materials
224            .get(id as usize)
225            .unwrap_or(&self.materials[0])
226    }
227
228    /// Set a pair-specific override
229    pub fn set_pair_override(
230        &mut self,
231        mat_a: MaterialId,
232        mat_b: MaterialId,
233        friction: Fix128,
234        restitution: Fix128,
235    ) {
236        let (a, b) = if mat_a <= mat_b {
237            (mat_a, mat_b)
238        } else {
239            (mat_b, mat_a)
240        };
241
242        // Update existing or add new
243        if let Some(p) = self
244            .pair_overrides
245            .iter_mut()
246            .find(|p| p.mat_a == a && p.mat_b == b)
247        {
248            p.friction = friction;
249            p.restitution = restitution;
250        } else {
251            self.pair_overrides.push(PairOverride {
252                mat_a: a,
253                mat_b: b,
254                friction,
255                restitution,
256            });
257        }
258    }
259
260    /// Combine materials for a contact pair
261    #[must_use]
262    pub fn combine(&self, mat_a: MaterialId, mat_b: MaterialId) -> CombinedMaterial {
263        self.combine_with_surface(mat_a, None, mat_b, None)
264    }
265
266    /// Combine materials for a contact pair where either side may carry its
267    /// own surface coefficients `(friction, restitution)` that stand in for the
268    /// material's `dynamic_friction` / `restitution`.
269    ///
270    /// # Claims
271    ///
272    /// - With `None` on both sides the result equals `combine`, bit for bit.
273    /// - A pair override registered for `(mat_a, mat_b)` wins over the surface
274    ///   coefficients (it is the most specific entry).
275    /// - Otherwise the friction / restitution rule is chosen from the two materials
276    ///   exactly as in `combine` and applied to the two effective values:
277    ///   `Some((f, e))` for a side, the material's own value for a `None` side.
278    #[must_use]
279    pub(crate) fn combine_with_surface(
280        &self,
281        mat_a: MaterialId,
282        surface_a: Option<(Fix128, Fix128)>,
283        mat_b: MaterialId,
284        surface_b: Option<(Fix128, Fix128)>,
285    ) -> CombinedMaterial {
286        let (a, b, surface_a, surface_b) = if mat_a <= mat_b {
287            (mat_a, mat_b, surface_a, surface_b)
288        } else {
289            (mat_b, mat_a, surface_b, surface_a)
290        };
291
292        // Check pair overrides first
293        if let Some(p) = self
294            .pair_overrides
295            .iter()
296            .find(|p| p.mat_a == a && p.mat_b == b)
297        {
298            return CombinedMaterial {
299                friction: p.friction,
300                restitution: p.restitution,
301            };
302        }
303
304        // Use combine rules
305        let mat_a = self.get(a);
306        let mat_b = self.get(b);
307
308        // Use the higher-priority combine rule
309        let friction_rule = combine_rule_priority(mat_a.friction_combine, mat_b.friction_combine);
310        let restitution_rule =
311            combine_rule_priority(mat_a.restitution_combine, mat_b.restitution_combine);
312
313        let (fa, ra) = surface_a.unwrap_or((mat_a.dynamic_friction, mat_a.restitution));
314        let (fb, rb) = surface_b.unwrap_or((mat_b.dynamic_friction, mat_b.restitution));
315        CombinedMaterial {
316            friction: friction_rule.apply(fa, fb),
317            restitution: restitution_rule.apply(ra, rb),
318        }
319    }
320
321    /// Number of registered materials
322    #[inline]
323    #[must_use]
324    pub fn len(&self) -> usize {
325        self.materials.len()
326    }
327
328    /// Check if empty
329    #[inline]
330    #[must_use]
331    pub fn is_empty(&self) -> bool {
332        self.materials.is_empty()
333    }
334
335    // ===== Predefined materials =====
336
337    /// Register a "Metal" material
338    pub fn register_metal(&mut self) -> MaterialId {
339        self.register(PhysicsMaterial::new(
340            0,
341            Fix128::from_ratio(4, 10),
342            Fix128::from_ratio(1, 10),
343        ))
344    }
345
346    /// Register a "Wood" material
347    pub fn register_wood(&mut self) -> MaterialId {
348        self.register(PhysicsMaterial::new(
349            0,
350            Fix128::from_ratio(5, 10),
351            Fix128::from_ratio(3, 10),
352        ))
353    }
354
355    /// Register a "Rubber" material
356    pub fn register_rubber(&mut self) -> MaterialId {
357        self.register(
358            PhysicsMaterial::new(0, Fix128::from_ratio(8, 10), Fix128::from_ratio(8, 10))
359                .with_combine_rules(CombineRule::Max, CombineRule::Max),
360        )
361    }
362
363    /// Register an "Ice" material
364    pub fn register_ice(&mut self) -> MaterialId {
365        self.register(
366            PhysicsMaterial::new(0, Fix128::from_ratio(5, 100), Fix128::from_ratio(1, 10))
367                .with_combine_rules(CombineRule::Min, CombineRule::Min),
368        )
369    }
370
371    /// Register a "Concrete" material
372    pub fn register_concrete(&mut self) -> MaterialId {
373        self.register(PhysicsMaterial::new(
374            0,
375            Fix128::from_ratio(6, 10),
376            Fix128::from_ratio(2, 10),
377        ))
378    }
379}
380
381impl Default for MaterialTable {
382    fn default() -> Self {
383        Self::new()
384    }
385}
386
387/// Priority: Max > Multiply > Average > Min
388const fn combine_rule_priority(a: CombineRule, b: CombineRule) -> CombineRule {
389    const fn priority(r: CombineRule) -> u8 {
390        match r {
391            CombineRule::Min => 0,
392            CombineRule::Average => 1,
393            CombineRule::Multiply => 2,
394            CombineRule::Max => 3,
395        }
396    }
397
398    if priority(a) >= priority(b) {
399        a
400    } else {
401        b
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_combine_rules() {
411        let a = Fix128::from_ratio(4, 10);
412        let b = Fix128::from_ratio(8, 10);
413
414        let avg = CombineRule::Average.apply(a, b);
415        assert_eq!(avg.hi, 0);
416        // Average of 0.4 and 0.8 = 0.6
417
418        let min = CombineRule::Min.apply(a, b);
419        assert!(min <= a);
420
421        let max = CombineRule::Max.apply(a, b);
422        assert!(max >= b);
423    }
424
425    #[test]
426    fn test_material_table() {
427        let mut table = MaterialTable::new();
428        let metal = table.register_metal();
429        let rubber = table.register_rubber();
430
431        let combined = table.combine(metal, rubber);
432        // Rubber uses Max combine → should pick highest friction
433        assert!(combined.friction > Fix128::ZERO);
434    }
435
436    #[test]
437    fn test_pair_override() {
438        let mut table = MaterialTable::new();
439        let a = table.register(PhysicsMaterial::new(
440            0,
441            Fix128::from_ratio(5, 10),
442            Fix128::from_ratio(5, 10),
443        ));
444        let b = table.register(PhysicsMaterial::new(
445            0,
446            Fix128::from_ratio(5, 10),
447            Fix128::from_ratio(5, 10),
448        ));
449
450        // Override: ice-on-ice → near-zero friction
451        table.set_pair_override(a, b, Fix128::from_ratio(1, 100), Fix128::ZERO);
452
453        let combined = table.combine(a, b);
454        assert!(combined.friction < Fix128::from_ratio(1, 10));
455    }
456
457    #[test]
458    fn test_default_material() {
459        let table = MaterialTable::new();
460        let mat = table.get(DEFAULT_MATERIAL);
461        assert!(mat.dynamic_friction > Fix128::ZERO);
462        assert!(mat.restitution > Fix128::ZERO);
463    }
464
465    #[test]
466    fn test_combine_rule_priority() {
467        let result = combine_rule_priority(CombineRule::Min, CombineRule::Max);
468        assert_eq!(result, CombineRule::Max);
469
470        let result = combine_rule_priority(CombineRule::Average, CombineRule::Multiply);
471        assert_eq!(result, CombineRule::Multiply);
472    }
473
474    /// 非 2 冪の有理数同士の平均は Fix128 で 1 ulp 丸まり得る
475    fn near(a: Fix128, b: Fix128) -> bool {
476        (a - b).abs() < Fix128 { hi: 0, lo: 1 << 8 }
477    }
478
479    #[test]
480    fn register_wood_ice_concrete_store_documented_coefficients_and_rules() {
481        let mut table = MaterialTable::new();
482        let wood = table.register_wood();
483        let ice = table.register_ice();
484        let concrete = table.register_concrete();
485        // id は登録順 (default が 0)、struct 側 id も同じ値に書き換わる
486        assert_eq!((wood, ice, concrete), (1, 2, 3));
487        assert_eq!(table.len(), 4);
488        assert_eq!(table.get(wood).id, wood);
489        assert_eq!(table.get(ice).id, ice);
490        assert_eq!(table.get(concrete).id, concrete);
491
492        let w = table.get(wood);
493        assert_eq!(w.dynamic_friction, Fix128::from_ratio(5, 10));
494        assert_eq!(w.static_friction, Fix128::from_ratio(5, 10));
495        assert_eq!(w.restitution, Fix128::from_ratio(3, 10));
496        assert_eq!(w.friction_combine, CombineRule::Average);
497        assert_eq!(w.restitution_combine, CombineRule::Average);
498
499        let i = table.get(ice);
500        assert_eq!(i.dynamic_friction, Fix128::from_ratio(5, 100));
501        assert_eq!(i.restitution, Fix128::from_ratio(1, 10));
502        assert_eq!(i.friction_combine, CombineRule::Min);
503        assert_eq!(i.restitution_combine, CombineRule::Min);
504
505        let c = table.get(concrete);
506        assert_eq!(c.dynamic_friction, Fix128::from_ratio(6, 10));
507        assert_eq!(c.restitution, Fix128::from_ratio(2, 10));
508        assert_eq!(c.friction_combine, CombineRule::Average);
509        assert_eq!(c.restitution_combine, CombineRule::Average);
510
511        // wood × concrete: Average → friction (0.5+0.6)/2 = 0.55、restitution (0.3+0.2)/2 = 0.25
512        let wc = table.combine(wood, concrete);
513        assert!(near(wc.friction, Fix128::from_ratio(55, 100)), "{wc:?}");
514        assert!(near(wc.restitution, Fix128::from_ratio(25, 100)), "{wc:?}");
515        // ice × concrete: Min は Average より低優先 → Average 採用 → (0.05+0.6)/2 = 0.325
516        let ic = table.combine(ice, concrete);
517        assert!(near(ic.friction, Fix128::from_ratio(325, 1000)), "{ic:?}");
518        assert!(near(ic.restitution, Fix128::from_ratio(15, 100)), "{ic:?}");
519        // ice × ice: 両方 Min → 0.05 / 0.1
520        let ii = table.combine(ice, ice);
521        assert_eq!(ii.friction, Fix128::from_ratio(5, 100));
522        assert_eq!(ii.restitution, Fix128::from_ratio(1, 10));
523        // 対称
524        assert_eq!(table.combine(concrete, ice), ic);
525    }
526
527    #[test]
528    fn with_static_friction_only_changes_static_coefficient() {
529        let base = PhysicsMaterial::new(7, Fix128::from_ratio(3, 10), Fix128::from_ratio(2, 10));
530        let m = base.with_static_friction(Fix128::from_ratio(9, 10));
531        assert_eq!(m.static_friction, Fix128::from_ratio(9, 10));
532        assert_eq!(m.dynamic_friction, Fix128::from_ratio(3, 10));
533        assert_eq!(m.restitution, Fix128::from_ratio(2, 10));
534        assert_eq!(m.id, 7);
535        assert_eq!(m.friction_combine, base.friction_combine);
536        assert_eq!(m.restitution_combine, base.restitution_combine);
537        // static_friction 以外は元と同一
538        let mut expected = base;
539        expected.static_friction = Fix128::from_ratio(9, 10);
540        assert_eq!(m, expected);
541
542        // combine は dynamic_friction を使う: static を変えても pair 結果は不変
543        let mut table = MaterialTable::new();
544        let plain = table.register(base);
545        let sticky = table.register(m);
546        let plain_pair = table.combine(plain, DEFAULT_MATERIAL);
547        let sticky_pair = table.combine(sticky, DEFAULT_MATERIAL);
548        assert_eq!(plain_pair, sticky_pair);
549        // (0.3 + 0.5) / 2 = 0.4
550        assert!(
551            near(sticky_pair.friction, Fix128::from_ratio(4, 10)),
552            "{sticky_pair:?}"
553        );
554    }
555}