Source code for simvx.core.physics.material

"""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()