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