Source code for simvx.core.physics.shapes

"""Shape resources for 3D physics.

A ``Shape`` is a plain collision-geometry *resource* (a value), not a scene
node. It is held by a :class:`~simvx.core.physics.nodes.CollisionShape3D`
node via a ``Property``. A body never branches on shape kind: it calls
``shape.build(world)`` and each subclass dispatches to the backend's matching
``world.create_*`` factory. This keeps body nodes open/closed over new shape
kinds.

The concrete classes are dimension-suffixed ``SphereShape3D`` / ``BoxShape3D``
(the abstract ``Shape`` base stays un-suffixed, being dimensionless). The 2D
siblings live in ``shapes2d.py``. All of these are exported from ``simvx.core``.
"""

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING

import numpy as np

from ..math import Vec3
from ._shape_owner import OwnedShapeHandles, build_owned

if TYPE_CHECKING:
    from .world import PhysicsWorld, ShapeHandle

__all__ = [
    "Shape",
    "SphereShape3D",
    "BoxShape3D",
    "CapsuleShape3D",
    "CylinderShape3D",
    "ConvexHullShape3D",
    "ConcaveMeshShape3D",
]


[docs] class Shape(ABC): """Abstract collision-geometry resource that OWNS its backend shape. A ``Shape`` is the unit of collision geometry and the unit of its lifetime, the same model meshes and materials follow. :meth:`build` hands back the one backend handle this resource owns in a given world, building it on the first ask; when the resource is collected, every handle it owns is released. A resource shared by a thousand bodies is therefore one backend record, and a collider rebuilt every frame from fresh geometry costs one record at a time rather than one per distinct size for the life of the world. Bodies call ``shape.build(world)`` and never inspect the concrete kind, so adding a new shape requires no body changes: subclasses implement :meth:`create` and dispatch to the matching ``world.create_*`` factory. Geometry is IMMUTABLE, by contract rather than by enforcement. Nothing here reads a radius or a half-extent twice: :meth:`build` runs once per world and the handle is remembered, so writing to one of those attributes after the fact changes the Python object and nothing else. The world keeps the geometry it was given, a later :meth:`build` hands back the same handle, and only :attr:`bounding_radius` (which is computed on each read, and drives CPU picking) follows the new number -- a collider that picks at one size and collides at another. A resource is shared, so there is no other answer available: a setter would have to re-enter every world the resource is live in and change the collider under every body holding it. Changing a collider means assigning a NEW resource, which is what the node layer does. Per-node SCALE is not part of a shape. A resource is shared, so it cannot bake in one node's scale: scale travels with the BODY (``create_body(scale=...)`` and the pose write), and each backend applies it to its own instance of this geometry. """ #: Backend handles this resource owns, one per world, created on first #: :meth:`build`. Class-level ``None`` so a subclass need not call #: ``super().__init__()`` for ownership to work. _owned_handles: OwnedShapeHandles | None = None
[docs] def build(self, world: PhysicsWorld) -> ShapeHandle: """Return this resource's backend shape handle in ``world``. Built once per world by :meth:`create` and remembered, so asking twice hands back the same handle and every body built from this resource shares one backend record. The handle is released when this resource is collected; a body already built on it keeps its geometry (see :meth:`~simvx.core.physics.world.PhysicsWorld.destroy_shape`). The handle's lifetime is THIS RESOURCE's lifetime, so a caller holds the resource, not the handle: a node keeps the resource it built from, and a query takes the resource as its argument. ``SphereShape3D(0.5).build(world)`` as a throwaway expression hands back a handle whose owner is already gone. Args: world: The :class:`~simvx.core.physics.world.PhysicsWorld` whose shape factory builds the opaque handle. Returns: An opaque ``ShapeHandle`` for use with ``world.create_body``. """ return build_owned(self, world, lambda: self.create(world))
[docs] @abstractmethod def create(self, world: PhysicsWorld) -> ShapeHandle: """Build a fresh backend shape for this geometry (called once per world). Subclasses dispatch to the matching ``world.create_*`` factory. Callers want :meth:`build`, which memoises this per world and owns the result; calling ``create`` directly mints a handle nothing will ever release. """
[docs] @property @abstractmethod def bounding_radius(self) -> float: """Radius of the shape's origin-centred bounding sphere, in local units. A conservative, rotation-invariant size used for cheap broad-phase / CPU picking (a sphere test against the shape's extent). Each subclass returns the radius of the smallest sphere centred on the shape's local origin that contains it. """
[docs] class SphereShape3D(Shape): """A sphere collision shape of a given radius. Args: radius: Sphere radius in world units (must be > 0). """ def __init__(self, radius: float = 0.5) -> None: radius = float(radius) if radius <= 0.0: raise ValueError(f"SphereShape3D radius must be > 0, got {radius}") self.radius: float = radius
[docs] def create(self, world: PhysicsWorld) -> ShapeHandle: return world.create_sphere(self.radius)
[docs] @property def bounding_radius(self) -> float: return self.radius
[docs] class BoxShape3D(Shape): """An axis-aligned box collision shape centred at the origin. Args: half_extents: Half-sizes along x/y/z. Coerced to ``Vec3`` (float32); every component must be > 0. """ def __init__(self, half_extents: Vec3 | tuple[float, float, float] = (0.5, 0.5, 0.5)) -> None: he = Vec3(*half_extents) if float(he.x) <= 0.0 or float(he.y) <= 0.0 or float(he.z) <= 0.0: raise ValueError(f"BoxShape3D half_extents must all be > 0, got {tuple(float(c) for c in he)}") self.half_extents: Vec3 = he
[docs] def create(self, world: PhysicsWorld) -> ShapeHandle: return world.create_box(self.half_extents)
[docs] @property def bounding_radius(self) -> float: return float(np.linalg.norm(self.half_extents))
[docs] class CapsuleShape3D(Shape): """A Y-axis capsule collision shape (a segment swept by a sphere). ``height`` is the TOTAL extent along Y, including the two hemispherical caps, so the central segment half-length is ``max(0.0, height / 2 - radius)`` and the segment endpoints are ``centre +- [0, half_len, 0]``. When ``height <= 2 * radius`` the segment collapses to a point and the capsule degenerates to a sphere of the given radius: that is a valid, documented case (matching Godot's capsule and the segment-reduction maths in the builtin backend), NOT an error. Accordingly ``height >= 2 * radius`` is NOT validated. Args: radius: Capsule radius in world units (must be > 0). height: Total extent along Y including both caps (must be > 0). """ def __init__(self, radius: float = 0.5, height: float = 2.0) -> None: radius = float(radius) height = float(height) if radius <= 0.0: raise ValueError(f"CapsuleShape3D radius must be > 0, got {radius}") if height <= 0.0: raise ValueError(f"CapsuleShape3D height must be > 0, got {height}") self.radius: float = radius self.height: float = height
[docs] def create(self, world: PhysicsWorld) -> ShapeHandle: return world.create_capsule(self.radius, self.height)
[docs] @property def bounding_radius(self) -> float: # Farthest surface point is a hemispherical cap centre +- half_height, # offset by radius: that pole sits at height/2 from the origin (the cap # sphere already has radius r and its centre is at height/2 - r). return max(self.radius, self.height * 0.5)
[docs] class CylinderShape3D(Shape): """A Y-axis cylinder collision shape. ``height`` is the TOTAL extent along Y, with flat circular top/bottom caps at ``+-height / 2``. Args: radius: Cylinder radius in world units (must be > 0). height: Total extent along Y (must be > 0). """ def __init__(self, radius: float = 0.5, height: float = 2.0) -> None: radius = float(radius) height = float(height) if radius <= 0.0: raise ValueError(f"CylinderShape3D radius must be > 0, got {radius}") if height <= 0.0: raise ValueError(f"CylinderShape3D height must be > 0, got {height}") self.radius: float = radius self.height: float = height
[docs] def create(self, world: PhysicsWorld) -> ShapeHandle: return world.create_cylinder(self.radius, self.height)
[docs] @property def bounding_radius(self) -> float: # Farthest point is a rim corner: sqrt(radius^2 + (height/2)^2). return float(math.hypot(self.radius, self.height * 0.5))
[docs] class ConvexHullShape3D(Shape): """A convex-hull collision shape defined by a point cloud (Tier-1). The hull is the convex hull of ``points``; the resource stores the raw cloud and the backend computes its own representation. At least 4 points are required (a 3D hull needs a tetrahedron). A coplanar / collinear cloud is accepted (it is finite and has >= 4 points) but yields a degenerate hull whose penetration depth is approximate: a full coplanarity test is deliberately NOT done at the resource layer (the backend tolerates degenerate clouds). Basic-tier honesty (see ``builtin/world.py``): the BuiltinPhysics backend does GJK for overlap (exact) and EPA-lite for penetration depth/normal (bounded iteration, documented approximation); hull rotation is ignored. The Jolt backend does a proper hull. Args: points: Iterable of >= 4 points (``Vec3`` or ``(x, y, z)`` tuples). Coerced to a single ``(N, 3)`` float32 array. Must be finite. """ def __init__(self, points: Sequence[Vec3 | tuple[float, float, float]]) -> None: pts = np.asarray(points, dtype=np.float32).reshape(-1, 3) if pts.shape[0] < 4: raise ValueError(f"ConvexHullShape3D needs >= 4 points (a tetrahedron), got {pts.shape[0]}") if not bool(np.all(np.isfinite(pts))): raise ValueError("ConvexHullShape3D points must all be finite (no NaN / inf)") self.points: np.ndarray = pts.copy()
[docs] @classmethod def from_mesh( cls, vertices: Sequence[Vec3 | tuple[float, float, float]], indices: Sequence[int] | None = None, ) -> ConvexHullShape3D: """Build a hull from a mesh's VERTEX CLOUD (opt-in). The convex hull of a mesh equals the convex hull of its vertex cloud, so ``indices`` are ignored: this is a cheap, honest hull-from-mesh with no convex decomposition. Pass the mesh vertices and the topology is irrelevant to the result. Args: vertices: The mesh vertex cloud (``(N, 3)`` after coercion). indices: Ignored (the hull is independent of triangle topology). Returns: A ``ConvexHullShape3D`` over the vertex cloud. """ del indices # the hull of a mesh is the hull of its vertices; topology is irrelevant return cls(vertices)
[docs] def create(self, world: PhysicsWorld) -> ShapeHandle: return world.create_convex_hull(self.points)
[docs] @property def bounding_radius(self) -> float: return float(np.linalg.norm(self.points, axis=1).max())
[docs] class ConcaveMeshShape3D(Shape): """A static triangle-mesh collision shape (level geometry, Tier-1 static-only). Holds a triangle-list mesh as a vertex array plus a flat index array (three indices per triangle). This collider is **STATIC-ONLY**: placing it on a non-STATIC body is an error raised at body creation (every serious engine enforces this, including Jolt). It has no inertia / mass. The ``Shape`` resource itself is a pure value with no static-mode awareness; the static-only contract is enforced by the physics world (``create_body`` / ``set_body_mode``), not here. Args: vertices: Vertex positions, coerced to a ``(N, 3)`` float32 array; finite. indices: Flat triangle-list indices, coerced to a ``(3 * T,)`` int64 array. ``len(indices)`` must be a non-zero multiple of 3 and every index in ``[0, N)``. """ def __init__( self, vertices: Sequence[Vec3 | tuple[float, float, float]], indices: Sequence[int], ) -> None: verts = np.asarray(vertices, dtype=np.float32).reshape(-1, 3) idx = np.asarray(indices, dtype=np.int64).reshape(-1) if not bool(np.all(np.isfinite(verts))): raise ValueError("ConcaveMeshShape3D vertices must all be finite (no NaN / inf)") if idx.size == 0 or idx.size % 3 != 0: raise ValueError(f"ConcaveMeshShape3D indices must be a non-zero multiple of 3, got {idx.size}") n = verts.shape[0] lo = int(idx.min()) hi = int(idx.max()) if lo < 0: raise ValueError(f"ConcaveMeshShape3D index out of range: {lo} < 0") if hi >= n: raise ValueError(f"ConcaveMeshShape3D index out of range: {hi} >= vertex count {n}") self.vertices: np.ndarray = verts.copy() self.indices: np.ndarray = idx.copy()
[docs] def create(self, world: PhysicsWorld) -> ShapeHandle: return world.create_mesh(self.vertices, self.indices)
[docs] @property def bounding_radius(self) -> float: return float(np.linalg.norm(self.vertices, axis=1).max())