"""PhysicsWorld: the backend transport seam (Stage 1 additive scaffolding).
Role
----
This module defines ``PhysicsWorld``, the abstract interface every physics
backend (``BuiltinPhysics`` now, ``JoltPhysics`` later) implements. It is a
**transport** abstraction, not a semantics one: all backends run the same kind
of rigid-body simulation, so the contract is about *moving body state across
the Python<->native boundary efficiently*, not about defining solver behaviour.
The load-bearing part of the contract is the **bulk-array transfer**: per-frame
body state is exchanged as a single numpy buffer (one transfer per world per
frame), in a fixed body->row order, by filling a caller-preallocated array
**in place**. Per-body crossings are reserved for setup/teardown and are never
used on the hot path. See :meth:`PhysicsWorld.register_bodies`,
:meth:`PhysicsWorld.read_transforms`, and :meth:`PhysicsWorld.read_velocities`.
Stage 1 status
--------------
This is **additive, non-breaking scaffolding**. It defines the interface only
(full signatures, docstrings, ``@abstractmethod``); there is no implementation
here, and nothing in this module is wired into ``SceneTree`` or the old
``simvx.core.physics`` system. The seam is testable standalone by constructing
a concrete backend and calling :meth:`step` manually.
"""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Any
import numpy as np
from ..math import Quat, Vec3
from .capability import Capability
from .material import CombineMode
# Opaque handle aliases. Backends choose the concrete representation; callers
# treat these as opaque tokens and never inspect them.
BodyHandle = int
ShapeHandle = Any
# A character controller token. DISTINCT type from BodyHandle so a backend can
# back it with a Jolt CharacterVirtual (not a rigid Body); callers treat it as
# opaque and never inspect it. Character handles live in their own namespace and
# never appear in register_bodies / read_transforms.
CharacterHandle = Any
# A joint / constraint token (Stage T1c). DISTINCT namespace from BodyHandle /
# CharacterHandle so a backend can back it with a Jolt Constraint (not a Body);
# callers treat it as opaque and never inspect it. Joints are node-agnostic and
# keyed only by the two BodyHandles they constrain.
JointHandle = Any
# Default world up (Y-up). A module-level singleton so it is not constructed in
# argument defaults (Vec3 is a mutable ndarray subclass); callers never mutate it.
_DEFAULT_UP = Vec3(0.0, 1.0, 0.0)
[docs]
class BodyMode(Enum):
"""Motion mode of a body, mirroring every serious solver's seam.
- ``STATIC``: immovable collider. Never integrated; infinite mass.
- ``DYNAMIC``: force-simulated; responds to gravity, impulses, contacts.
- ``KINEMATIC``: code-moved; pushes dynamic bodies, immune to forces.
"""
STATIC = "static"
DYNAMIC = "dynamic"
KINEMATIC = "kinematic"
[docs]
@dataclass(slots=True, frozen=True)
class RaycastHit:
"""Result of a successful raycast against the world.
Attributes:
body: Handle of the body the ray hit.
point: World-space contact point (``Vec3``).
normal: World-space surface normal at the hit (``Vec3``, unit length).
distance: Distance from the ray origin to ``point`` along the ray.
"""
body: BodyHandle
point: Vec3
normal: Vec3
distance: float
[docs]
@dataclass(slots=True, frozen=True)
class OverlapEvent:
"""A node-agnostic sensor-overlap event emitted by the seam.
A SECOND, independent edge-diffed stream, parallel to :class:`ContactEvent`
but never mixed with it: a sensor pair produces NO collision response and NO
manifold, so there is no point / normal / impulse / rel_velocity to carry.
Keyed by body HANDLES only (node-agnostic, like :class:`ContactEvent`), but
DIRECTED ``sensor -> other`` rather than a canonical unordered pair: the
detection is one-directional (the observing sensor decides via its mask), so
a sensor-vs-sensor overlap can fire on one side without the other. The tree
maps both handles to nodes and routes ``body_entered`` vs ``area_entered`` by
the OTHER node's type.
Attributes:
sensor: Handle of the detecting sensor body (the observer).
other: Handle of the detected body (a normal body OR another sensor).
phase: :class:`ContactPhase` (``ENTER`` / ``EXIT``); reused, no second
phase enum.
"""
sensor: BodyHandle
other: BodyHandle
phase: ContactPhase
[docs]
@dataclass(slots=True, frozen=True)
class CharacterMoveResult:
"""Returned struct of a character collide-and-slide move.
Returning a struct (rather than mutating caller state) keeps the seam
node-agnostic and gives Jolt a clean place to surface
``CharacterVirtual::GetGroundState()`` / ``GetGroundNormal()``.
Floor/wall/ceiling are computed seam-side from contact normals vs ``up`` (and
the character's ``slope_limit``) so every backend exposes identical semantics.
Attributes:
velocity: Post-slide velocity (deflected along contact normals); the
caller writes this back as its new velocity.
on_floor: True if a contact this move was classified as floor.
on_wall: True if a contact this move was classified as wall.
on_ceiling: True if a contact this move was classified as ceiling.
floor_normal: Normal of the floor contact this move (unit), or ``+up`` if
there was no floor contact.
"""
velocity: Vec3
on_floor: bool
on_wall: bool
on_ceiling: bool
floor_normal: Vec3
[docs]
class PhysicsWorld(ABC):
"""Abstract backend seam: one isolated simulation world.
A ``PhysicsWorld`` owns a set of bodies, advances them as a unit at a fixed
timestep via :meth:`step`, and exchanges per-frame state in bulk. Concrete
backends (``BuiltinPhysics``, later ``JoltPhysics``) implement every method.
Bulk-array contract (the keystone)
----------------------------------
1. Call :meth:`register_bodies` once (or whenever membership changes) to fix
the body->row order used by the bulk readers.
2. Each frame, after :meth:`step`, call :meth:`read_transforms` and/or
:meth:`read_velocities`, passing a caller-preallocated, C-contiguous
``float32`` numpy array of the documented shape. The backend fills it
**in place**; it must not allocate or return a new array on the hot path.
The array shapes/dtypes/contiguity are part of the contract and MUST be
asserted by subclasses (see ``_check_transforms_out`` / ``_check_velocities_out``).
"""
def __init__(self, *, gravity: Vec3) -> None:
"""Initialise the world.
Args:
gravity: World gravity acceleration vector (``Vec3``), metres/s^2.
"""
self._gravity: Vec3 = Vec3(*gravity)
# -- configuration ------------------------------------------------------
@property
def gravity(self) -> Vec3:
"""World gravity acceleration vector (``Vec3``), metres/s^2."""
return self._gravity
[docs]
@gravity.setter
def gravity(self, value: Vec3) -> None:
self._gravity = Vec3(*value)
# -- capability gate ----------------------------------------------------
[docs]
def capabilities(self) -> frozenset[Capability]:
"""Return the set of Tier-3 :class:`Capability` features this backend honours.
The strict Tier-1 rigid-body surface is the parity contract every backend
implements identically; this method is the ONE place backends advertise
the few features that genuinely cannot be faked (cross-platform
determinism, vehicles, soft bodies). A Tier-3 node checks
``Capability.X in world.capabilities()`` and degrades / refuses if absent.
The default (this base implementation) is the empty set: a backend
advertises nothing it cannot honour. Backends override to list only the
capabilities they actually support. Not abstract: the empty default is the
honest answer for any plain rigid-body backend.
"""
return frozenset()
# -- shapes (opaque handles) -------------------------------------------
[docs]
@abstractmethod
def create_sphere(self, radius: float) -> ShapeHandle:
"""Create a sphere collision shape and return an opaque handle.
Args:
radius: Sphere radius, world units (> 0).
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_box(self, half_extents: Vec3) -> ShapeHandle:
"""Create an axis-aligned box collision shape (centred at the origin).
Args:
half_extents: Half-sizes along x/y/z (``Vec3``, all > 0).
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_capsule(self, radius: float, height: float) -> ShapeHandle:
"""Create a Y-axis capsule collision shape and return an opaque handle.
Args:
radius: Capsule radius, world units (> 0).
height: Total extent along Y including the two hemispherical caps
(> 0). The central segment half-length is
``max(0, height / 2 - radius)``; when ``height <= 2 * radius``
the segment collapses to a point and the capsule behaves as a
sphere of ``radius``.
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_cylinder(self, radius: float, height: float) -> ShapeHandle:
"""Create a Y-axis cylinder collision shape and return an opaque handle.
Args:
radius: Cylinder radius, world units (> 0).
height: Total extent along Y with flat caps at ``+-height / 2``
(> 0).
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_convex_hull(self, points: np.ndarray) -> ShapeHandle:
"""Create a convex-hull collision shape from a point cloud.
Args:
points: ``(N, 3)`` float32 array of >= 4 finite points. The backend
computes its own internal hull representation from the cloud.
Orientation is supported via the body transform like other shapes,
EXCEPT the basic ``BuiltinPhysics`` backend, which IGNORES hull
rotation (the cloud is treated in world axes offset by the body
position); the Jolt backend rotates properly.
Returns:
An opaque shape handle for use with :meth:`create_body`. The basic
backend's penetration depth/normal for a hull is an EPA-lite
approximation (GJK overlap is exact); see ``builtin/world.py``.
"""
[docs]
@abstractmethod
def create_mesh(self, vertices: np.ndarray, indices: np.ndarray) -> ShapeHandle:
"""Create a STATIC triangle-mesh collision shape (level geometry).
Args:
vertices: ``(N, 3)`` float32 vertex positions.
indices: ``(3 * T,)`` int64 flat triangle-list indices (three per
triangle), each in ``[0, N)``.
Returns:
An opaque shape handle for use with :meth:`create_body`. A mesh shape
is a **STATIC-ONLY** collider: placing it on a non-STATIC body is an
error (rejected at :meth:`create_body` and :meth:`set_body_mode`).
It carries no inertia / mass and cannot be used as a moving query
shape (:meth:`shapecast` / :meth:`overlap` reject a mesh probe).
"""
# -- bodies -------------------------------------------------------------
[docs]
@abstractmethod
def create_body(
self,
shape: ShapeHandle,
body_type: BodyMode,
transform: Any,
*,
mass: float = 1.0,
collision_layer: int = 1,
collision_mask: int = 0xFFFFFFFF,
is_sensor: bool = False,
friction: float = 0.5,
restitution: float = 0.0,
friction_combine: CombineMode = CombineMode.AVERAGE,
restitution_combine: CombineMode = CombineMode.AVERAGE,
continuous: bool = False,
) -> BodyHandle:
"""Create a body in the world and return its handle.
Args:
shape: An opaque shape handle from :meth:`create_sphere`,
:meth:`create_box`, :meth:`create_capsule`,
:meth:`create_cylinder`, :meth:`create_convex_hull`, or
:meth:`create_mesh`. A mesh shape on a non-STATIC body is an
error (mesh colliders are STATIC-only), rejected here and in
:meth:`set_body_mode`.
body_type: One of :class:`BodyMode`.
transform: Initial world transform (a ``Transform3D`` or anything a
backend accepts as an initial pose; position + orientation).
mass: Body mass in kg, used only for ``DYNAMIC`` bodies. Ignored
for ``STATIC`` / ``KINEMATIC`` (treated as infinite).
collision_layer: 32-bit layer membership of this body (which layers
it lives on). Stored verbatim; defaults to layer 1.
collision_mask: 32-bit mask of layers this body scans for collisions.
Defaults to all (``0xFFFFFFFF``) so the bare API collides every
pair (non-breaking). A pair (a, b) collides iff
``(a.mask & b.layer) or (b.mask & a.layer)`` (bidirectional OR).
is_sensor: When True, this body is a SENSOR (trigger). It is
created / destroyed / teleported exactly like a normal body and
participates in the broadphase, but is EXCLUDED from collision
resolution (it skips the solver, applies no impulse, and never
appears in the contact-event stream) and instead generates a
SEPARATE overlap-event stream (see :meth:`drain_overlap_events`)
using the ONE-DIRECTIONAL filter ``sensor.mask & other.layer``
(the observer decides; the other body's mask is irrelevant),
never the AND body-body rule. A sensor is an ordinary body with a
flag, not a separate kind of handle.
friction: Coulomb friction coefficient ``mu`` (``>= 0``). The contact
solver clamps the tangential impulse by ``mu`` times the normal
impulse. Defaults to ``0.5`` (matching :class:`PhysicsMaterial`).
Node-agnostic primitive: the node unpacks its ``PhysicsMaterial``
resource into this and the next three params (the seam never sees
the resource or the node).
restitution: Bounciness in ``[0, 1]`` (``0`` = inelastic, the default,
so the bare seam API does not bounce). Combined per-contact and
gated by a small velocity rest-threshold (see the builtin solver).
friction_combine: How this body's ``friction`` combines with the other
body's at a contact (:class:`CombineMode`); defaults to AVERAGE.
restitution_combine: How this body's ``restitution`` combines with the
other body's, INDEPENDENT of ``friction_combine`` (defaults to
AVERAGE).
continuous: When True, this body uses continuous collision detection:
each step its centre displacement is swept against STATIC geometry
and clamped to the time-of-impact so a fast small body cannot tunnel
through thin static colliders. Defaults False (discrete). Basic-tier
honesty: a CENTRE ray / shapecast sweep vs STATIC bodies only, no
rotational sweep and no dynamic-vs-dynamic CCD; the Jolt backend
honours the flag faithfully via ``EMotionQuality::LinearCast``.
Returns:
An opaque body handle, stable until :meth:`destroy_body`.
"""
[docs]
@abstractmethod
def destroy_body(self, handle: BodyHandle) -> None:
"""Remove a body from the world.
After destruction the handle is invalid. Callers that use the bulk
readers must re-call :meth:`register_bodies` to re-establish row order.
Args:
handle: A handle previously returned by :meth:`create_body`.
"""
[docs]
@abstractmethod
def set_body_transform(self, handle: BodyHandle, transform: Any) -> None:
"""Teleport a body to a new world transform.
Args:
handle: Body handle.
transform: New world transform (position + orientation).
"""
[docs]
@abstractmethod
def set_body_velocity(
self,
handle: BodyHandle,
linear: Vec3,
angular: Vec3 | None = None,
) -> None:
"""Set a body's linear and angular velocity directly.
Args:
handle: Body handle.
linear: Linear velocity (``Vec3``), world units/s.
angular: Angular velocity (``Vec3``), radians/s about each axis.
``None`` (default) means zero angular velocity.
"""
[docs]
@abstractmethod
def set_body_mode(self, handle: BodyHandle, mode: BodyMode) -> None:
"""Change a live body's motion mode in place (no destroy/recreate).
Flips the body between STATIC / KINEMATIC / DYNAMIC, updating its
effective (inverse) mass: STATIC and KINEMATIC are infinite-mass
(inv_mass 0), DYNAMIC uses the body's stored mass. Maps onto Jolt's
Body::SetMotionType; the builtin backend flips body_type + inverse_mass.
"""
[docs]
@abstractmethod
def body_velocity(self, handle: BodyHandle) -> tuple[Vec3, Vec3]:
"""Read a body's current ``(linear, angular)`` velocity, per-body.
Cold per-body read parallel to :meth:`body_transform`. The bulk
:meth:`read_velocities` stays the hot scatter path; this is the
accessor used by ``PhysicsBody3D.velocity`` / ``.spin`` for a single
synchronous read-back (e.g. ``self.velocity += dv``).
Args:
handle: Body handle.
Returns:
``(linear, angular)`` velocity (``Vec3``, ``Vec3``); angular in
radians/s. Returns zero velocities for an infinite-mass body that
was never moved.
"""
[docs]
@abstractmethod
def sleeping(self, handle: BodyHandle) -> bool:
"""True if the body is asleep (skipped by integrate + solve until woken).
STATIC / KINEMATIC bodies are never 'asleep' (they were never awake):
returns False for them. A sleeping body stays a full collider and still
reads back its (frozen) transform / velocity through the bulk readers.
"""
# -- forces -------------------------------------------------------------
[docs]
@abstractmethod
def apply_impulse(
self,
handle: BodyHandle,
impulse: Vec3,
*,
at: Vec3 | None = None,
angular: Vec3 | None = None,
) -> None:
"""Apply an instantaneous velocity change to a body NOW.
Unlike :meth:`apply_force` this takes effect immediately (it mutates
velocity, not an accumulator) and is NOT cleared by :meth:`step`. Inert
on non-DYNAMIC bodies (inverse mass 0).
Args:
handle: Body handle.
impulse: Linear impulse (``Vec3``), N*s. Adds ``impulse * inv_mass``
to the linear velocity.
at: Optional world-space application point. When given, the offset
``r = at - position`` contributes an angular impulse
``cross(r, impulse)`` (basic tier scales it by ``inv_mass`` as a
stand-in for the inverse inertia tensor, which the basic backend
does not model). ``None`` applies the impulse purely through the
centre of mass (no torque).
angular: Optional explicit angular impulse (``Vec3``), for
``spin_up``. Adds ``angular * inv_mass`` (basic-tier inverse
inertia stand-in) to the angular velocity, independent of ``at``.
"""
[docs]
@abstractmethod
def apply_force(self, handle: BodyHandle, force: Vec3, *, at: Vec3 | None = None) -> None:
"""Accumulate a continuous force, applied during the NEXT :meth:`step`.
The force is integrated as acceleration (``force * inv_mass``) before
position integration, then **auto-cleared** at the end of the step. To
sustain a force the caller must re-add it every fixed step (per design
S7); a single call affects exactly one step. Inert on non-DYNAMIC.
Args:
handle: Body handle.
force: Linear force (``Vec3``), N.
at: Optional world-space application point. When given, the offset
``r = at - position`` adds a torque ``cross(r, force)`` to the
torque accumulator. ``None`` applies the force through the COM.
"""
[docs]
@abstractmethod
def apply_torque(self, handle: BodyHandle, torque: Vec3) -> None:
"""Accumulate a continuous torque, applied during the NEXT :meth:`step`.
Auto-cleared after the step like :meth:`apply_force`: re-add each fixed
step to sustain it. Inert on non-DYNAMIC. Basic tier applies it as
``torque * inv_mass`` (inverse inertia stand-in).
Args:
handle: Body handle.
torque: Torque (``Vec3``), N*m.
"""
# -- joints / constraints (Stage T1c, sequential-impulse basic tier) ----
[docs]
@abstractmethod
def create_fixed_joint(self, a: BodyHandle, b: BodyHandle) -> JointHandle:
"""Weld two bodies: lock their full relative transform.
Captures the CURRENT relative pose of ``b`` in ``a``'s frame at create
time (relative position AND relative orientation) and holds it: the two
bodies thereafter move as one rigid assembly.
Basic-tier honesty: the builtin backend has NO inertia tensor, so the
angular lock uses ``inverse_mass`` as the inverse-inertia scalar (per
T1a); a long thin body or an off-centre weld will rotate too easily
versus a real solver. Convergence is a few sequential-impulse iterations,
so a long weld chain sags slightly. Precise articulated mechanisms are a
Jolt concern.
Args:
a: First body handle (the reference frame).
b: Second body handle (welded into ``a``'s frame).
Returns:
An opaque :data:`JointHandle`, valid until :meth:`remove_joint` (or
until either body is destroyed, which silently drops the joint).
"""
[docs]
@abstractmethod
def create_pin_joint(self, a: BodyHandle, b: BodyHandle, anchor: Vec3) -> JointHandle:
"""Pin two bodies at a single world-space point (ball / point-to-point).
Constrains the two bodies so the world point ``anchor`` stays coincident
on both (they cannot separate there) while leaving all three rotational
DOF free. The anchor is stored as a per-body offset
(``r_a = anchor - pos_a``, ``r_b = anchor - pos_b``) captured at create.
Basic-tier honesty: the stored anchor offset does NOT rotate with the
body (re-derived each step as ``pos + r`` in WORLD axes, same limitation
as the hull-rotation-ignored narrowphase), so a pin on a spinning body
drifts. Angular cross-coupling uses ``inverse_mass`` as the
inverse-inertia scalar. A few iterations of convergence.
Args:
a: First body handle.
b: Second body handle.
anchor: World-space pivot point shared by both bodies (``Vec3``).
Returns:
An opaque :data:`JointHandle`.
"""
[docs]
@abstractmethod
def create_hinge_joint(self, a: BodyHandle, b: BodyHandle, anchor: Vec3, axis: Vec3) -> JointHandle:
"""Hinge two bodies: pin at ``anchor`` + one free rotational DOF about ``axis``.
A point constraint at ``anchor`` (like :meth:`create_pin_joint`) PLUS an
angular constraint that locks the two off-axis rotational DOF, leaving
free rotation only about the world-space ``axis`` (normalised and
captured at create). Motors and angular limits are explicitly OUT of
scope this stage (a deliberate follow-on).
Basic-tier honesty: same anchor-does-not-rotate and ``inverse_mass``
inverse-inertia-scalar caveats as :meth:`create_pin_joint`; a few
iterations of convergence.
Args:
a: First body handle.
b: Second body handle.
anchor: World-space hinge pivot point (``Vec3``).
axis: World-space hinge axis (``Vec3``, normalised at create).
Returns:
An opaque :data:`JointHandle`.
"""
[docs]
@abstractmethod
def create_spring_joint(
self, a: BodyHandle, b: BodyHandle, rest_length: float, stiffness: float, damping: float
) -> JointHandle:
"""Soft distance-spring between the two body centres (compliant, not rigid).
A soft constraint that pulls the two body centres of mass toward
``rest_length`` apart with spring constant ``stiffness`` (N/m) and
damping ``damping`` (N*s/m). Unlike the rigid joints it is intentionally
compliant: it applies a soft velocity impulse with a ``k*x`` bias and a
``c*v`` damping term, and is NEVER position-corrected.
Basic-tier honesty: the builtin backend uses the two COMs, NOT per-body
anchors (Pin / Hinge use anchors, Spring uses centres for simplicity).
The explicit soft-impulse form can oscillate or overshoot when
``stiffness`` is large relative to the fixed ``dt``; a stiff spring needs
a smaller ``dt`` or the Jolt backend. Nothing is silently clamped.
Args:
a: First body handle.
b: Second body handle.
rest_length: Target centre-to-centre separation (world units, >= 0).
stiffness: Spring constant k (N/m).
damping: Damping coefficient c (N*s/m).
Returns:
An opaque :data:`JointHandle`.
"""
[docs]
@abstractmethod
def remove_joint(self, handle: JointHandle) -> None:
"""Remove a constraint; the handle is invalid afterwards.
A no-op if ``handle`` is unknown (already removed, or silently dropped
because one of its bodies was destroyed): this is the SAME silent-drop
contract :meth:`destroy_body` already uses for touching / overlap pairs,
not an error-swallowing shim. A joint whose body was freed is the
expected case, so removing it twice (once by the body-purge, once by the
joint node's own teardown) must be safe in either teardown order.
Args:
handle: A handle previously returned by a ``create_*_joint`` call.
"""
# -- introspection ------------------------------------------------------
[docs]
@property
@abstractmethod
def body_count(self) -> int:
"""Number of bodies currently in the world.
Read-only. Used by ``SceneTree.physics_tick`` to skip stepping empty
worlds for zero overhead, mirroring ``PhysicsServer.body_count``.
"""
[docs]
@abstractmethod
def clear(self) -> None:
"""Remove every body, character, and joint, emptying the world.
The seam equivalent of the old global ``PhysicsServer.reset()``: a level
teardown / restart-the-scene primitive that returns the world to an empty
state (``body_count == 0``) WITHOUT discarding the world object, its
configured :attr:`gravity`, or its backend. Per-step edge-diff buffers
(contacts / overlaps) and any warm-start cache are reset so the next step
starts from a clean broadphase. Cached shape handles stay valid (shapes are
reusable resources), and handle counters keep advancing so a freed handle is
never re-issued to a new body. After :meth:`clear`, callers using the bulk
readers must re-:meth:`register_bodies`.
"""
# -- stepping -----------------------------------------------------------
[docs]
@abstractmethod
def step(self, dt: float) -> None:
"""Advance the whole world once by a fixed timestep.
This integrates every body, resolves collisions, and updates internal
state for the entire world as a unit. It is designed to be driven by a
fixed-step accumulator (Stage 2 wires it to ``SceneTree.physics_tick``);
in Stage 1 it is called manually by tests.
Args:
dt: Fixed timestep in seconds. Callers must pass a constant value.
"""
# -- collision events (broadphase-diffed edges) ------------------------
[docs]
@abstractmethod
def drain_overlap_events(self) -> list[OverlapEvent]:
"""Return and CLEAR this step's buffered sensor-overlap enter/exit events.
Edge-only, broadphase-driven, DIRECTED (keyed ``sensor -> other``),
filtered by the one-directional sensor rule (``sensor.mask &
other.layer``), node-agnostic. Distinct from
:meth:`drain_contact_events`: a sensor pair produces NO collision
response and NO manifold, so the event carries only the two handles plus
the :class:`ContactPhase`. Returns ``[]`` when nothing changed.
"""
# -- bulk transfer (the keystone) --------------------------------------
[docs]
@abstractmethod
def register_bodies(self, handles: list[BodyHandle]) -> None:
"""Fix the body->row order used by the bulk readers.
Establishes the mapping from each body handle to a row index. After
this call, :meth:`read_transforms` / :meth:`read_velocities` fill row
``i`` with the state of ``handles[i]``. Call again whenever membership
or desired ordering changes.
Args:
handles: Ordered list of body handles. ``len(handles)`` is the row
count ``N`` expected by the bulk readers.
"""
[docs]
@abstractmethod
def read_velocities(self, out: np.ndarray) -> None:
"""Fill ``out`` with current body velocities, in place.
Bulk hot-path read. The backend writes into the caller-owned buffer and
allocates nothing.
Args:
out: Pre-allocated array of shape ``(N, 6)``, dtype ``float32``,
C-contiguous, where ``N`` matches the most recent
:meth:`register_bodies`. Each row is
``[lx, ly, lz, ax, ay, az]``: linear velocity xyz followed by
angular velocity xyz (radians/s). Row ``i`` corresponds to
``handles[i]``.
"""
# -- queries (minimal) --------------------------------------------------
[docs]
@abstractmethod
def raycast(
self,
origin: Vec3,
direction: Vec3,
max_dist: float,
*,
mask: int = 0xFFFFFFFF,
) -> RaycastHit | None:
"""Cast a ray and return the nearest hit, or ``None``.
Args:
origin: Ray origin in world space (``Vec3``).
direction: Ray direction (``Vec3``); need not be normalised.
max_dist: Maximum distance along ``direction`` to test.
mask: Query layer mask. Only bodies whose
``collision_layer & mask`` is non-zero are considered. Defaults
to all layers. This is the single query-mask convention (one
query mask vs each body's layer), distinct from the bidirectional
body-pair rule used by the simulation.
Returns:
A :class:`RaycastHit` for the closest body intersected within
``max_dist`` whose layer matches ``mask``, or ``None`` if the ray
hits nothing.
"""
[docs]
@abstractmethod
def raycast_all(
self,
origin: Vec3,
direction: Vec3,
max_dist: float,
*,
mask: int = 0xFFFFFFFF,
) -> list[RaycastHit]:
"""Cast a ray and return EVERY hit within ``max_dist``, sorted by distance.
Like :meth:`raycast` but collects all intersected bodies (whose
``collision_layer & mask`` is set) instead of only the nearest, returned
ascending by :attr:`RaycastHit.distance`. Empty list on no hit. Backs
``PhysicsQuery.raycast_all`` and the ``exclude=`` filter path of
``raycast`` (which must skip excluded nearer hits).
Args:
origin: Ray origin in world space (``Vec3``).
direction: Ray direction (``Vec3``); need not be normalised.
max_dist: Maximum distance along ``direction`` to test.
mask: Query layer mask (single query-mask vs body-layer convention).
Returns:
All :class:`RaycastHit`\\ s within ``max_dist``, sorted ascending by
distance (empty if the ray hits nothing).
"""
[docs]
@abstractmethod
def shapecast(
self,
shape: ShapeHandle,
origin: Vec3,
direction: Vec3,
max_dist: float,
*,
mask: int = 0xFFFFFFFF,
) -> Contact | None:
"""Sweep a shape along a ray and return the earliest-TOI contact, or ``None``.
Sweeps ``shape`` from ``origin`` along ``direction`` (need not be
normalised) up to ``max_dist`` against world bodies, returning the
earliest time-of-impact contact among bodies whose
``collision_layer & mask`` is set, else ``None``. Reuses
:class:`Contact`: the swept shape is the implicit caller, ``body`` is the
hit body, ``normal`` is the separating normal pointing back toward the
cast origin, and ``distance`` is the TOI distance along ``direction``.
Basic-tier honesty: this is a substepped sweep, not a true continuous
cast, so fast casts vs very thin colliders can tunnel and box orientation
is ignored (AABB), matching :meth:`move_and_collide`.
Args:
shape: An opaque shape handle from :meth:`create_sphere`,
:meth:`create_box`, :meth:`create_capsule`, or
:meth:`create_cylinder`.
origin: Cast origin in world space (``Vec3``).
direction: Cast direction (``Vec3``); need not be normalised.
max_dist: Maximum sweep distance along ``direction``.
mask: Query layer mask (single query-mask vs body-layer convention).
Returns:
The earliest-TOI :class:`Contact`, or ``None`` if nothing was hit.
"""
[docs]
@abstractmethod
def overlap(self, shape: ShapeHandle, transform: Any, *, mask: int = 0xFFFFFFFF) -> list[BodyHandle]:
"""Return all bodies a static shape overlaps at ``transform``.
Places ``shape`` at ``transform`` (same flexible forms as
:meth:`create_body`) and returns the handles of every body it overlaps
whose ``collision_layer & mask`` is set, sorted by handle for
determinism. Basic-tier honesty: AABB-ish narrowphase, box orientation
ignored, like :meth:`move_and_collide`.
Args:
shape: An opaque shape handle from :meth:`create_sphere`,
:meth:`create_box`, :meth:`create_capsule`, or
:meth:`create_cylinder`.
transform: World pose to place the shape at (same flexible forms as
:meth:`create_body`).
mask: Query layer mask (single query-mask vs body-layer convention).
Returns:
Sorted list of overlapping body handles (empty if none).
"""
# -- kinematic sweep ----------------------------------------------------
[docs]
@abstractmethod
def move_and_collide(self, handle: BodyHandle, motion: Vec3) -> Contact | None:
"""Move a kinematic body by ``motion``, stopping at the first contact.
Shape-casts the body's shape from its current pose along world-space
``motion`` (already ``velocity * dt``: the seam takes a displacement, not
a velocity, so ``dt`` lives in the node) against every OTHER body and
finds the earliest time-of-impact. Sets the body's stored transform to
the position it actually reached (``origin + motion * toi`` minus a tiny
safe margin). Does NOT slide and does NOT integrate gravity: one sweep,
stop at first contact.
Args:
handle: A body created with :attr:`BodyMode.KINEMATIC`.
motion: World-space displacement (``Vec3``).
Returns:
A :class:`Contact` (other body, world point, separating normal,
distance travelled) if the sweep stopped early, else ``None`` after
moving the full ``motion``.
"""
# -- body read-back -----------------------------------------------------
[docs]
@abstractmethod
def body_transform(self, handle: BodyHandle) -> tuple[Vec3, Quat]:
"""Read a body's current pose as ``(position, orientation)``.
Parallel to :meth:`character_transform`; returns plain ``Vec3`` / ``Quat``
so callers (e.g. ``KinematicBody3D``) get a clean synchronous read-back
after a user-driven :meth:`move_and_collide` without the bulk path.
Args:
handle: Body handle.
Returns:
``(position, orientation)``.
"""
# -- character controller (CharacterVirtual-style) ---------------------
[docs]
@abstractmethod
def create_character(
self,
shape: ShapeHandle,
transform: Any,
*,
up: Vec3 = _DEFAULT_UP,
slope_limit: float = math.radians(45.0),
step_height: float = 0.0,
skin_width: float = 0.001,
collision_layer: int = 1,
collision_mask: int = 0xFFFFFFFF,
) -> CharacterHandle:
"""Create a character controller bound to this world.
The character collides against world bodies but is NOT added to the
dynamic-body set: it is never force-integrated and never appears in
:meth:`register_bodies` / :meth:`read_transforms`. This mirrors a Jolt
``CharacterVirtual`` (distinct from a rigid ``Body``).
Args:
shape: An opaque shape handle from :meth:`create_sphere`,
:meth:`create_box`, :meth:`create_capsule`, or
:meth:`create_cylinder`.
transform: Initial world pose (position + orientation), same flexible
forms accepted by :meth:`create_body`.
up: World up vector (``Vec3``), engine Y-up convention.
slope_limit: Maximum walkable slope, in RADIANS (engine convention).
step_height: Maximum step-up height in world units (0 disables).
skin_width: Collision safe margin in world units.
collision_layer: 32-bit layer membership of this character.
Defaults to layer 1.
collision_mask: 32-bit mask of layers the character collides with.
Defaults to all (``0xFFFFFFFF``). Character sweeps use the same
bidirectional OR rule as bodies (``char.mask & body.layer`` or
``body.mask & char.layer``).
Returns:
An opaque :data:`CharacterHandle`, distinct from any body handle.
"""
[docs]
@abstractmethod
def destroy_character(self, handle: CharacterHandle) -> None:
"""Remove a character controller from the world.
Args:
handle: A handle previously returned by :meth:`create_character`.
"""
[docs]
@abstractmethod
def character_move_and_slide(
self,
handle: CharacterHandle,
velocity: Vec3,
dt: float,
*,
up: Vec3,
max_slides: int = 4,
) -> CharacterMoveResult:
"""Collide-and-slide a character by ``velocity * dt`` against the world.
Deflects velocity along contact normals over up to ``max_slides``
iterations, classifies floor/wall/ceiling from contact normals vs ``up``
(and the character's ``slope_limit``), and updates the character's stored
pose. ``up`` is passed per-call (the node owns the up vector); the fixed
collider config (``slope_limit`` / ``step_height`` / ``skin_width``) is
set at create time, like Jolt's ``CharacterVirtualSettings``.
Floor/wall/ceiling state is returned per-move in
:class:`CharacterMoveResult` (not a separate query method), so it is
valid only right after this call, matching Jolt's ``GetGroundState()``.
Args:
handle: Character handle.
velocity: Desired world-space velocity (``Vec3``), units/s.
dt: Timestep in seconds.
up: World up vector (``Vec3``).
max_slides: Maximum collide-and-slide iterations.
Returns:
A :class:`CharacterMoveResult`.
"""
# -- subclass helpers (contract enforcement) ---------------------------
@staticmethod
def _check_transforms_out(out: np.ndarray, n: int) -> None:
"""Assert ``out`` meets the :meth:`read_transforms` contract.
Subclasses call this at the top of ``read_transforms`` so the bulk
contract (shape ``(N, 7)``, ``float32``, C-contiguous) is enforced
uniformly across backends.
"""
assert out.shape == (n, 7), f"read_transforms out must be ({n}, 7), got {out.shape}"
assert out.dtype == np.float32, f"read_transforms out must be float32, got {out.dtype}"
assert out.flags["C_CONTIGUOUS"], "read_transforms out must be C-contiguous"
@staticmethod
def _check_velocities_out(out: np.ndarray, n: int) -> None:
"""Assert ``out`` meets the :meth:`read_velocities` contract.
Subclasses call this at the top of ``read_velocities`` so the bulk
contract (shape ``(N, 6)``, ``float32``, C-contiguous) is enforced
uniformly across backends.
"""
assert out.shape == (n, 6), f"read_velocities out must be ({n}, 6), got {out.shape}"
assert out.dtype == np.float32, f"read_velocities out must be float32, got {out.dtype}"
assert out.flags["C_CONTIGUOUS"], "read_velocities out must be C-contiguous"
__all__ = [
"BodyMode",
"Capability",
"CombineMode",
"RaycastHit",
"Contact",
"ContactPhase",
"ContactEvent",
"OverlapEvent",
"CharacterMoveResult",
"PhysicsWorld",
"BodyHandle",
"ShapeHandle",
"CharacterHandle",
"JointHandle",
]
# Quat is imported for the orientation convention documented above (xyzw,
# scalar-last) and to keep the math-type dependency explicit for Stage 2.
_ = Quat