Source code for simvx.core.physics.material
"""PhysicsMaterial: surface friction + restitution + combine modes (Stage T1d).
Role
----
A plain VALUE resource (not a Node) carried by a Property on a body, mirroring
how :class:`~simvx.core.physics.shapes.Shape` resources are carried. It groups
the four surface coefficients (friction, restitution, and an independent combine
mode for each) so a single resource serialises as one nested value and can be
shared across many bodies for a common surface.
This is the NEW system's material, deliberately SEPARATE from the OLD
``physics/_material.py`` ``PhysicsMaterial`` (which carries a ``density`` field
and defaults restitution to 0.3). The two classes coexist by design during the
staged rewrite; this one is never imported from the old module. The key default
difference: this material defaults restitution to **0.0**, so adding materials to
an existing scene does NOT silently start bouncing previously-inelastic bodies.
Combine modes (the differentiator over Godot / Jolt)
----------------------------------------------------
Friction and restitution combine INDEPENDENTLY, each with one of Unity's four
modes (AVERAGE / MIN / MAX / MULTIPLY). Godot and Jolt hardcode a single rule
for both; exposing two independent modes is the design's deliberate edge.
When the two contacting materials request DIFFERENT modes for the same
coefficient, the higher-priority mode wins (Unity's documented priority order):
``MAX > MIN > MULTIPLY > AVERAGE``. The least-surprising authoring behaviour: a
deliberately grippy / bouncy surface dominates a neutral one. See :func:`_combine`.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
__all__ = ["CombineMode", "PhysicsMaterial"]
[docs]
class CombineMode(Enum):
"""How two materials' coefficients combine at a contact (Unity's four modes).
String values (parity with :class:`~simvx.core.physics.world.BodyMode`), so it
serialises and inspects cleanly. NOT an ``IntEnum``: the differing-mode
priority is an explicit dict (:data:`_PRIORITY`), never the enum ordinal, so
reordering the members can never silently change priority.
"""
AVERAGE = "average"
MIN = "minimum"
MAX = "maximum"
MULTIPLY = "multiply"
# Differing-mode priority (Unity's order): when the two contacting materials ask
# for different combine modes for the SAME coefficient, the highest-priority mode
# wins. Explicit dict (not enum ordinal) so reordering CombineMode is safe.
_PRIORITY: dict[CombineMode, int] = {
CombineMode.MAX: 3,
CombineMode.MIN: 2,
CombineMode.MULTIPLY: 1,
CombineMode.AVERAGE: 0,
}
def _combine(x: float, y: float, mode_a: CombineMode, mode_b: CombineMode) -> float:
"""Combine two coefficients ``x`` (body a) and ``y`` (body b) into one.
The two materials may request different combine modes; the higher-priority
mode wins (``MAX > MIN > MULTIPLY > AVERAGE``), then that single mode's
function is applied:
- ``AVERAGE`` -> ``(x + y) * 0.5``
- ``MIN`` -> ``min(x, y)``
- ``MAX`` -> ``max(x, y)``
- ``MULTIPLY`` -> ``x * y``
Rationale for MAX-wins: a deliberately grippy / bouncy surface dominates a
neutral neighbour, the least-surprising authoring behaviour. Called once for
friction (each material's ``friction_combine``) and once for restitution
(each material's ``restitution_combine``), so the two combine independently.
"""
mode = max(mode_a, mode_b, key=_PRIORITY.__getitem__)
if mode is CombineMode.AVERAGE:
return (x + y) * 0.5
if mode is CombineMode.MIN:
return min(x, y)
if mode is CombineMode.MAX:
return max(x, y)
return x * y # MULTIPLY
[docs]
@dataclass(slots=True)
class PhysicsMaterial:
"""Surface material: friction + restitution + per-coefficient combine modes.
A mutable value resource (``slots``, not frozen): the inspector edits a
body's own instance in place, so each body owns its own material via the
body Property's ``default_factory`` (a shared mutable default would alias
edits across bodies). The module-level :data:`DEFAULT_PHYSICS_MATERIAL`
singleton is the never-mutated seam fallback for a body with no material set.
Attributes:
friction: Coulomb friction coefficient ``mu`` (``>= 0``; no hard upper
bound). ``0`` is frictionless; ``~0.5`` is a sensible default.
restitution: Bounciness in ``[0, 1]``. ``0`` is fully inelastic (the
engine default: adding a material never starts bouncing an existing
scene); ``1`` is a perfectly elastic bounce.
friction_combine: How this material's friction combines with a contacting
material's (see :class:`CombineMode`).
restitution_combine: How this material's restitution combines, independent
of ``friction_combine`` (the Godot / Jolt-beating differentiator).
"""
friction: float = 0.5
restitution: float = 0.0
friction_combine: CombineMode = CombineMode.AVERAGE
restitution_combine: CombineMode = CombineMode.AVERAGE
[docs]
def __post_init__(self) -> None:
assert self.friction >= 0.0, f"PhysicsMaterial.friction must be >= 0, got {self.friction}"
assert 0.0 <= self.restitution <= 1.0, f"PhysicsMaterial.restitution must be in [0, 1], got {self.restitution}"
# Shared default instance: the seam fallback for a body whose material is None.
# Frozen by convention (NEVER mutated) so it can be referenced without a per-body
# allocation, parity with ``world._DEFAULT_UP``. Bodies that DO set a material
# get their own instance via the Property's ``default_factory``.
DEFAULT_PHYSICS_MATERIAL = PhysicsMaterial()