Source code for simvx.core.physics.shapes2d

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