"""Shape resources for the 2D physics seam (Stage T2f).
Role
----
The 2D sibling of :mod:`~simvx.core.physics.shapes`. A ``Shape2D`` is a plain
collision-geometry *resource* (a value), not a scene node. It is held by a
:class:`~simvx.core.physics.nodes2d.CollisionShape2D` node (via a ``Property``)
or set directly on a body as a convenience collider. A body never branches on
shape kind: it calls ``shape.build(world)`` and each subclass dispatches to the
2D seam's matching ``world.create_*`` factory, keeping body nodes open/closed
over new shape kinds.
Mirrors ``shapes.py`` (naming/structure/validation), carrying ``Vec2`` geometry
and 2D-only kinds (segment, convex/concave polygon). ``RectangleShape2D`` uses
the design's node name but dispatches to the seam's ``create_box`` factory.
These resources are NOT exported via any facade this stage (parity with the 3D
``shapes.py``); tests import them via this module path. The facade flip is a
later stage.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING
import numpy as np
from ..math import Vec2
if TYPE_CHECKING:
from .world2d import Physics2DWorld, ShapeHandle
__all__ = [
"Shape2D",
"CircleShape2D",
"RectangleShape2D",
"CapsuleShape2D",
"SegmentShape2D",
"ConvexPolygonShape2D",
"ConcavePolygonShape2D",
]
[docs]
class Shape2D(ABC):
"""Abstract 2D collision-geometry resource.
A ``Shape2D`` 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: Physics2DWorld) -> 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.world2d.Physics2DWorld` whose
shape factory builds the opaque handle.
Returns:
An opaque ``ShapeHandle`` for use with ``world.create_body``.
"""
[docs]
class CircleShape2D(Shape2D):
"""A circle collision shape of a given radius (2D sibling of ``SphereShape3D``).
Args:
radius: Circle 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"CircleShape2D radius must be > 0, got {radius}")
self.radius: float = radius
[docs]
def build(self, world: Physics2DWorld) -> ShapeHandle:
return world.create_circle(self.radius)
[docs]
class RectangleShape2D(Shape2D):
"""An axis-aligned (body-local) box collision shape centred at the origin.
Named per the design node taxonomy (``RectangleShape2D``); the 2D seam factory
is ``create_box``, so :meth:`build` dispatches there.
Args:
half_extents: Half-sizes along x/y. Coerced to ``Vec2`` (float32); every
component must be > 0.
"""
def __init__(self, half_extents: Vec2 | tuple[float, float] = (0.5, 0.5)) -> None:
he = Vec2(*half_extents)
if float(he.x) <= 0.0 or float(he.y) <= 0.0:
raise ValueError(f"RectangleShape2D half_extents must all be > 0, got {tuple(float(c) for c in he)}")
self.half_extents: Vec2 = he
[docs]
def build(self, world: Physics2DWorld) -> ShapeHandle:
return world.create_box(self.half_extents)
[docs]
class CapsuleShape2D(Shape2D):
"""A Y-axis capsule collision shape (a segment swept by a circle).
``height`` is the TOTAL extent along Y, including the two semicircular caps,
so the central segment half-length is ``max(0.0, height / 2 - radius)``. When
``height <= 2 * radius`` the segment collapses to a point and the capsule
degenerates to a circle of the given radius: a valid, documented case (parity
with ``CapsuleShape3D`` 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"CapsuleShape2D radius must be > 0, got {radius}")
if height <= 0.0:
raise ValueError(f"CapsuleShape2D height must be > 0, got {height}")
self.radius: float = radius
self.height: float = height
[docs]
def build(self, world: Physics2DWorld) -> ShapeHandle:
return world.create_capsule(self.radius, self.height)
[docs]
class SegmentShape2D(Shape2D):
"""A thick line-segment collision shape (2D-only, no 3D equivalent).
A "beam" from ``a`` to ``b`` (body-local), useful for thin static walls /
floors and one-way platforms.
Args:
a: Segment start, body-local (``Vec2``).
b: Segment end, body-local (``Vec2``). Must differ from ``a`` (a
zero-length segment is degenerate).
radius: Segment thickness radius (>= 0); 0 is an infinitely thin line.
"""
def __init__(
self,
a: Vec2 | tuple[float, float] = (-0.5, 0.0),
b: Vec2 | tuple[float, float] = (0.5, 0.0),
radius: float = 0.0,
) -> None:
av = Vec2(*a)
bv = Vec2(*b)
radius = float(radius)
if radius < 0.0:
raise ValueError(f"SegmentShape2D radius must be >= 0, got {radius}")
if float((bv - av).length()) <= 0.0:
raise ValueError("SegmentShape2D endpoints a and b must differ (zero-length segment)")
if not bool(np.all(np.isfinite(np.asarray([av, bv], dtype=np.float32)))):
raise ValueError("SegmentShape2D endpoints must be finite (no NaN / inf)")
self.a: Vec2 = av
self.b: Vec2 = bv
self.radius: float = radius
[docs]
def build(self, world: Physics2DWorld) -> ShapeHandle:
return world.create_segment(self.a, self.b, self.radius)
[docs]
class ConvexPolygonShape2D(Shape2D):
"""A convex polygon collision shape from counter-clockwise points.
The 2D analogue of ``ConvexHullShape3D``. At least 3 points are required (a
polygon needs a triangle). Convexity / winding is NOT re-checked at the
resource layer (the backend tolerates the cloud); pass CCW points.
Args:
points: Iterable of >= 3 points (``Vec2`` or ``(x, y)`` tuples), coerced
to a single ``(N, 2)`` float32 array. Must be finite.
"""
def __init__(self, points: Sequence[Vec2 | tuple[float, float]]) -> None:
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] < 3:
raise ValueError(f"ConvexPolygonShape2D needs >= 3 points (a triangle), got {pts.shape[0]}")
if not bool(np.all(np.isfinite(pts))):
raise ValueError("ConvexPolygonShape2D points must all be finite (no NaN / inf)")
self.points: np.ndarray = pts.copy()
[docs]
def build(self, world: Physics2DWorld) -> ShapeHandle:
return world.create_convex_polygon(self.points)
[docs]
class ConcavePolygonShape2D(Shape2D):
"""A STATIC edge-soup collision shape (2D analogue of ``ConcaveMeshShape3D``).
Holds N line segments (an ``[start, end]`` pair of ``Vec2`` each), body-local.
The 2D analogue of a static triangle mesh: **STATIC-ONLY** level geometry with
no inertia / mass. Placing it on a non-STATIC body is an error raised at body
creation (the static-only contract is enforced at the world seam, not here).
Args:
segments: ``(N, 2, 2)`` float32 array (or an iterable coercible to it) of
N segments, each an ``[start, end]`` pair of ``Vec2`` points. ``N``
must be >= 1 and every coordinate finite.
"""
def __init__(self, segments: Sequence[object] | np.ndarray) -> None:
segs = np.asarray(segments, dtype=np.float32).reshape(-1, 2, 2)
if segs.shape[0] < 1:
raise ValueError(f"ConcavePolygonShape2D needs >= 1 segment, got {segs.shape[0]}")
if not bool(np.all(np.isfinite(segs))):
raise ValueError("ConcavePolygonShape2D segments must all be finite (no NaN / inf)")
self.segments: np.ndarray = segs.copy()
[docs]
def build(self, world: Physics2DWorld) -> ShapeHandle:
return world.create_concave_polygon(self.segments)