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