Source code for simvx.core.physics.shapes

"""Shape resources for the new physics seam (Stage 3a).

Role
----
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``) the way the old bodies held a ``radius``/``extents``
field. A body never branches on shape kind: it calls ``shape.build(world)`` and
each subclass dispatches to the seam's matching ``world.create_*`` factory. This
keeps body nodes open/closed over new shape kinds.

Stage 3a status
---------------
Additive and non-breaking. The concrete classes are dimension-suffixed
``SphereShape3D`` / ``BoxShape3D`` (the abstract ``Shape`` base stays
un-suffixed, being dimensionless), distinct from the GJK ``CollisionShape``
subclasses in ``collision.py``. They live ONLY here. They are NOT exported via
``physics/__init__.py`` or ``core/__init__.py``: tests import them via this
module path. The facade flip + removal of the old shapes is Stage 4.
"""

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

if TYPE_CHECKING:
    from .world import PhysicsWorld, ShapeHandle

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


[docs] class Shape(ABC): """Abstract collision-geometry resource. A ``Shape`` knows how to turn itself into an opaque seam shape handle via :meth:`build`. Bodies call ``shape.build(world)`` and never inspect the concrete kind, so adding a new shape requires no body changes. """
[docs] @abstractmethod def build(self, world: PhysicsWorld) -> ShapeHandle: """Create the backend shape for this resource and return its handle. Each subclass dispatches to the matching ``world.create_*`` factory. 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``. """
[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 build(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 build(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 build(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 build(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, per design S3). 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 build(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 at the world seam (``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 build(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())