"""PhysicsMaterial: the surface half of a body's physics, as a shareable resource.
A body's knobs live in three homes, and this module is one of them:
- **Material** (here), per CONTACT: friction, restitution, and how two of them
combine. It describes a SURFACE, so one FROZEN instance is shared by every body
made of that surface -- ice, rubber, wood -- and is serialised into a ``.py``
scene as one nested value rather than as four loose numbers per body.
- **Body**, per body: linear and angular damping, gravity scale, mass, and
``can_sleep``. See :meth:`~simvx.core.physics.world.PhysicsWorld.create_body`.
- **World**: gravity, solver iterations and the sleep thresholds. See
:class:`~simvx.core.physics.world.PhysicsWorld`.
Restitution defaults to **0.0**, not to a bouncy value: giving a body a material
must never silently start bouncing a scene that was inelastic before.
Combine modes
-------------
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; two independent modes is the deliberate difference.
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`.
Every place a mode is accepted also accepts its NAME, so a scene file and a call
site can both say ``friction_combine="max"`` and never import the enum.
"""
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.
A mode is accepted by NAME anywhere it is accepted at all --
``CombineMode("max")``, ``PhysicsMaterial(friction_combine="max")`` -- because
a scene file and a quick call site should not have to import an enum to say
something this small. The canonical spellings are Unity's full words
(``"average"``, ``"minimum"``, ``"maximum"``, ``"multiply"``), which are what
a serialised scene carries; ``"min"`` / ``"max"`` are accepted as the obvious
short forms, and matching is case-insensitive.
"""
AVERAGE = "average"
MIN = "minimum"
MAX = "maximum"
MULTIPLY = "multiply"
@classmethod
def _missing_(cls, value: object) -> CombineMode | None:
"""Accept the short spellings and any casing; reject everything else."""
if not isinstance(value, str):
return None
return _ALIASES.get(value.strip().lower())
# Accepted spellings beyond the canonical member values, resolved by
# ``CombineMode._missing_``. The canonical values are listed too so a
# differently-cased canonical spelling ("MAXIMUM") resolves through the same map.
_ALIASES: dict[str, CombineMode] = {
"average": CombineMode.AVERAGE,
"minimum": CombineMode.MIN,
"maximum": CombineMode.MAX,
"multiply": CombineMode.MULTIPLY,
"min": CombineMode.MIN,
"max": CombineMode.MAX,
}
# 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, frozen=True)
class PhysicsMaterial:
"""Surface material: friction + restitution + per-coefficient combine modes.
An IMMUTABLE value resource, and being frozen is what makes sharing it safe:
one ``ICE`` instance assigned to fifty bodies is one surface, and no body can
edit what the other forty-nine are made of. It is a VALUE -- two materials
holding the same four numbers describe the same surface -- so giving a live
body a different surface means assigning it a material whose VALUES differ::
from dataclasses import replace
body.material = replace(body.material, friction=0.1)
Assigning an equal-valued material is a no-op, which is the honest answer:
the surface did not change. Nothing in the seam holds a reference to the
resource -- a world is handed the four numbers, never the object -- so a
shared material stays a plain Python value with no backend lifetime.
Immutability also makes it hashable, so it can key a dict or sit in a set.
:data:`DEFAULT_PHYSICS_MATERIAL` is the shared fallback for a body with no
material set.
Both combine modes accept a NAME as well as a member
(``PhysicsMaterial(friction_combine="max")``); the stored value is always the
:class:`CombineMode` member, so identity comparisons downstream hold.
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).
Raises:
ValueError: If ``friction`` is negative, ``restitution`` is outside
``[0, 1]``, or either combine mode is not a recognised spelling.
Caller input is validated with a real exception rather than an
``assert``, which ``python -O`` deletes.
dataclasses.FrozenInstanceError: On any attribute assignment after
construction.
"""
friction: float = 0.5
restitution: float = 0.0
friction_combine: CombineMode = CombineMode.AVERAGE
restitution_combine: CombineMode = CombineMode.AVERAGE
[docs]
def __post_init__(self) -> None:
# ``object.__setattr__`` because the dataclass is frozen: coercion and
# validation are part of construction, and are the only writes there are.
friction = float(self.friction)
restitution = float(self.restitution)
if not friction >= 0.0: # NaN fails this too
raise ValueError(f"PhysicsMaterial.friction must be >= 0, got {friction}")
if not 0.0 <= restitution <= 1.0:
raise ValueError(f"PhysicsMaterial.restitution must be in [0, 1], got {restitution}")
object.__setattr__(self, "friction", friction)
object.__setattr__(self, "restitution", restitution)
object.__setattr__(self, "friction_combine", CombineMode(self.friction_combine))
object.__setattr__(self, "restitution_combine", CombineMode(self.restitution_combine))
# Shared default instance: the fallback for a body whose material is None. Safe
# to reference from anywhere without a per-body allocation because the resource
# is frozen, parity with ``world._DEFAULT_UP``.
DEFAULT_PHYSICS_MATERIAL = PhysicsMaterial()