Source code for simvx.core.physics.shapes2d

"""Shape resources for 2D physics.

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 world'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`` keeps
the 2D-facing name but dispatches to the world's ``create_box`` factory.

All of these are exported from ``simvx.core``.
"""

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
from ._shape_owner import OwnedShapeHandles, build_owned

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 that OWNS its backend shape. The 2D sibling of :class:`~simvx.core.physics.shapes.Shape`, with the same ownership contract: :meth:`build` hands back the one backend handle this resource owns in a given world, building it on the first ask, and every handle it owns is released when the resource is collected. Geometry is immutable by contract and not by enforcement (see the 3D class for what a write after :meth:`build` does and does not change), and per-node scale travels with the BODY rather than being baked into the shared resource. """ #: 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: Physics2DWorld) -> 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'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. ``CircleShape2D(0.5).build(world)`` as a throwaway expression hands back a handle whose owner is already gone. 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``. """ return build_owned(self, world, lambda: self.create(world))
[docs] @abstractmethod def create(self, world: Physics2DWorld) -> 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] 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 create(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. The 2D world's factory for this geometry 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 create(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 create(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 create(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 create(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 by the physics world, 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 create(self, world: Physics2DWorld) -> ShapeHandle: return world.create_concave_polygon(self.segments)