from abc import ABC, abstractmethod
from typing import ClassVar
import numpy as np
import shapely as shp
from shapely import (
LineString,
MultiLineString,
Point,
Polygon,
bounds,
envelope,
is_valid,
make_valid,
minimum_bounding_radius,
)
from shapely.ops import transform, unary_union
from irsim.lib import random_generate_polygon
from irsim.util.random import rng
from irsim.util.util import (
gen_inequal_from_vertex,
geometry_transform,
get_transform,
is_convex_and_ordered,
log_warning,
)
[docs]
class geometry_handler(ABC):
"""
This class is used to handle the geometry of the object. It reads the shape parameters from yaml file and constructs the geometry of the object.
"""
def __init__(self, name: str, **kwargs):
self.name = name
self._original_geometry = self.construct_original_geometry(**kwargs)
self.geometry = self._original_geometry
self.wheelbase = kwargs.get("wheelbase")
self.length, self.width = self.cal_length_width(self._original_geometry)
[docs]
@abstractmethod
def construct_original_geometry(self, **kwargs):
"""
Construct the original geometry of the object when the state is in the origin of coordinates.
Args:
**kwargs: shape parameters
Returns: Geometry of the object
"""
pass
[docs]
def step(self, state):
"""
Transform geometry to the new state.
Args:
state (np.ndarray): State vector [x, y, theta].
Returns:
shapely.geometry.base.BaseGeometry: Transformed geometry.
"""
self.geometry = geometry_transform(self._original_geometry, state)
return self.geometry
[docs]
def get_init_Gh(self):
"""
Generate initial G and h for convex object.
Returns:
tuple[np.ndarray, np.ndarray, str | None, bool | None]:
- G matrix: (N, 2)
- h vector: (N, 1)
- cone_type (str): "norm2" for circle or "Rpositive" for polygon
- convex_flag (bool): whether convex constraints are valid
"""
if self.name == "circle":
# The circle may be offset from the body origin (center/wheelbase).
G = np.array([[1, 0], [0, 1], [0, 0]])
h = np.vstack((self.original_centroid, [[-self.radius]]))
cone_type = "norm2"
convex_flag = True
elif self.name == "polygon" or self.name == "rectangle":
convex_flag, _ = is_convex_and_ordered(self.original_vertices)
if convex_flag:
G, h = gen_inequal_from_vertex(self.original_vertices)
cone_type = "Rpositive"
else:
G, h, cone_type = None, None, None
elif self.name == "compound":
G, h, cone_type, convex_flag = None, None, None, False
else:
G, h, cone_type, convex_flag = None, None, None, None
return G, h, cone_type, convex_flag
[docs]
def get_Gh(self, **kwargs):
"""Generate convex constraint matrices for the current shape type.
Args:
**kwargs: Shape-specific data such as ``vertices``, ``center``, or
``radius``. If omitted, callers should use :meth:`get_init_Gh`
for constraints from the original geometry.
Returns:
tuple: ``(G, h, cone_type, convex_flag)`` where unavailable
constraints are returned as ``None`` values.
"""
G, h, cone_type, convex_flag = None, None, None, None
if self.name == "polygon" or self.name == "rectangle":
G, h, cone_type, convex_flag = self.get_polygon_Gh(kwargs.get("vertices"))
elif self.name == "circle":
G, h, cone_type, convex_flag = self.get_circle_Gh(
kwargs.get("center"), kwargs.get("radius")
)
elif self.name == "compound":
convex_flag = False
return G, h, cone_type, convex_flag
[docs]
def get_polygon_Gh(self, vertices: np.ndarray | None = None):
"""
Generate G and h for convex polygon.
Args:
vertices: (2, N), N: Edge number of the object
Returns:
tuple[np.ndarray | None, np.ndarray | None, str | None, bool]:
- G matrix: (N, 2)
- h vector: (N, 1)
- cone_type (str): "Rpositive" for polygon
- convex_flag (bool): whether convex constraints are valid
"""
if self.name == "polygon" or self.name == "rectangle":
if vertices is None:
return None, None, None, False
convex_flag, _ = is_convex_and_ordered(vertices)
if convex_flag:
G, h = gen_inequal_from_vertex(vertices)
cone_type = "Rpositive"
else:
G, h, cone_type = None, None, None
else:
G, h, cone_type = None, None, None
return G, h, cone_type, convex_flag
[docs]
def get_circle_Gh(self, center: np.ndarray, radius: float):
"""
Generate G and h for circle.
Args:
center: (2, 1) array of center
radius: float of radius
Returns:
tuple[np.ndarray, np.ndarray, str, bool]:
- G matrix: (3, 2)
- h vector: (3, 1)
- cone_type (str): "norm2"
- convex_flag (bool): True
"""
assert self.name == "circle"
G = np.array([[1, 0], [0, 1], [0, 0]])
h = np.vstack((center, -radius * np.ones((1, 1))))
cone_type = "norm2"
convex_flag = True
return G, h, cone_type, convex_flag
[docs]
def cal_length_width(self, geometry):
"""Calculate axis-aligned bounding-box length and width.
Args:
geometry: Shapely geometry.
Returns:
tuple[float, float]: Length in x and width in y.
"""
min_x, min_y, max_x, max_y = bounds(geometry).tolist()
length = max_x - min_x
width = max_y - min_y
return length, width
@property
def vertices(self):
"""Current geometry vertices as a ``(2, N)`` array."""
if self.name == "linestring":
x = self.geometry.xy[0]
y = self.geometry.xy[1]
return np.c_[x, y].T
return self.geometry.exterior.coords._coords.T[:, :-1]
@property
def init_vertices(self):
"""
return original_vertices: [[x1, y1], [x2, y2].... [[x1, y1]]]; [x1, y1] will repeat twice
"""
assert "this property is renamed to be original_vertices"
@property
def original_vertices(self) -> np.ndarray | None:
"""
Get the original vertices of the geometry.
"""
if self.name == "linestring":
x = self._original_geometry.xy[0]
y = self._original_geometry.xy[1]
return np.c_[x, y].T
if self.name == "map":
return None
return self._original_geometry.exterior.coords._coords.T[:, :-1]
@property
def original_centroid(self) -> np.ndarray:
"""
Get the original centroid of the geometry.
Returns:
np.ndarray: The original centroid of the geometry.
"""
return np.array(self._original_geometry.centroid.xy)
_radius_of = None # geometry the cached radius belongs to
@property
def radius(self):
"""Minimum bounding radius of the original geometry (cached per geometry)."""
if self._radius_of is not self._original_geometry:
self._radius = minimum_bounding_radius(self._original_geometry)
self._radius_of = self._original_geometry
return self._radius
[docs]
class CircleGeometry(geometry_handler):
"""Circular geometry handler for ``shape: {name: circle}``."""
def __init__(self, name: str = "circle", **kwargs):
super().__init__(name, **kwargs)
[docs]
def construct_original_geometry(
self,
radius: float = 0.2,
center: list | None = None,
random_shape: bool = False,
radius_range: list | None = None,
wheelbase: float | None = None,
):
"""Construct a circle at the local origin.
Args:
radius: Circle radius.
center: Local center ``[x, y]``.
random_shape: If True, sample radius from ``radius_range``.
radius_range: ``[min, max]`` radius range for random shapes.
wheelbase: Optional Ackermann wheelbase offset for car-like bodies.
Returns:
shapely.geometry.Polygon: Buffered point representing the circle.
"""
if radius_range is None:
radius_range = [0.1, 1.0]
if center is None:
center = [0, 0]
if random_shape:
radius = rng.uniform(*radius_range)
if wheelbase is None:
return Point(center).buffer(radius)
return Point([center[0] + wheelbase / 2, center[1]]).buffer(radius)
[docs]
class PolygonGeometry(geometry_handler):
"""Polygon geometry handler for ``shape: {name: polygon}``."""
def __init__(self, name: str = "polygon", **kwargs):
super().__init__(name, **kwargs)
[docs]
def construct_original_geometry(
self,
vertices=None,
random_shape: bool = False,
is_convex: bool = False,
**kwargs,
):
"""
Construct a polygon geometry.
Args:
vertices: [[x1, y1], [x2, y2]..]
random_shape: whether to generate random shape, default is False
is_convex: whether to generate convex shape, default is False
**kwargs: see random_generate_polygon()
Returns:
Polygon object
"""
if random_shape:
if is_convex:
kwargs.setdefault("spikeyness_range", [0, 0])
vertices = random_generate_polygon(**kwargs)
else:
vertices = random_generate_polygon(**kwargs)
elif vertices is None:
log_warning("No vertices provided for polygon. Using default square.")
vertices = [
(-1, -1),
(1, -1),
(1, 1),
(-1, 1),
]
polygon = Polygon(vertices)
if is_valid(polygon):
return polygon
log_warning("Invalid polygon. Making it valid.")
valid_polygons = make_valid(polygon)
polygon = envelope(valid_polygons)
return make_valid(polygon)
[docs]
class RectangleGeometry(geometry_handler):
"""Rectangle geometry handler for ``shape: {name: rectangle}``."""
def __init__(self, name: str = "rectangle", **kwargs):
super().__init__(name, **kwargs)
[docs]
def construct_original_geometry(
self, length: float = 1.0, width: float = 1.0, wheelbase: float | None = None
):
"""
Construct a rectangle around the local origin.
Args:
length: Rectangle length along x.
width: Rectangle width along y.
wheelbase: Optional Ackermann wheelbase used to offset the rear axle.
Returns:
shapely.geometry.Polygon: Rectangle polygon.
"""
if wheelbase is None:
vertices = [
(-length / 2, -width / 2),
(length / 2, -width / 2),
(length / 2, width / 2),
(-length / 2, width / 2),
]
else:
start_x = -(length - wheelbase) / 2
start_y = -width / 2
vertices = [
(start_x, start_y),
(start_x + length, start_y),
(start_x + length, start_y + width),
(start_x, start_y + width),
]
return Polygon(vertices)
[docs]
class CompoundGeometry(geometry_handler):
"""Compound geometry assembled from fixed solid parts.
Each part is defined in the owning object's body frame. Its optional
``pose`` is ``[x, y, theta]`` relative to that frame and defaults to the
identity pose. The exact collision geometry is the union of all transformed
parts, so overlapping parts do not introduce internal collision boundaries.
"""
_SUPPORTED_PARTS: ClassVar[set[str]] = {"circle", "polygon", "rectangle"}
def __init__(self, name: str = "compound", **kwargs):
super().__init__(name, **kwargs)
[docs]
def construct_original_geometry(self, parts: list[dict] | None = None, **kwargs):
"""Construct a compound geometry in the owning object's body frame.
Args:
parts: Non-empty list of shape dictionaries. Each dictionary uses
the existing primitive shape parameters and may include a local
``pose`` of ``[x, y, theta]``.
**kwargs: Reserved for future compound-level options.
Returns:
shapely.geometry.Polygon | shapely.geometry.MultiPolygon: Union of
all locally transformed part geometries.
Raises:
ValueError: If parts are missing, unsupported, empty, or have an
invalid pose.
TypeError: If a part is not a dictionary.
"""
self.part_handlers = []
self.part_poses = []
self._original_part_geometries = []
for part_config, pose_array in self._validate_parts(parts):
handler = GeometryFactory.create_geometry(**part_config)
local_geometry = geometry_transform(
handler._original_geometry, pose_array.reshape(3, 1)
)
self.part_handlers.append(handler)
self.part_poses.append(pose_array)
self._original_part_geometries.append(local_geometry)
geometry = unary_union(self._original_part_geometries)
if geometry.is_empty or geometry.geom_type not in {"Polygon", "MultiPolygon"}:
raise ValueError(
"compound parts must produce a non-empty Polygon or MultiPolygon"
)
self.part_geometries = list(self._original_part_geometries)
return geometry
@classmethod
def _validate_parts(cls, parts: list[dict] | None) -> list[tuple[dict, np.ndarray]]:
"""Validate and normalize compound part configurations."""
if not isinstance(parts, list) or not parts:
raise ValueError("compound shape requires a non-empty 'parts' list")
validated = []
for index, part in enumerate(parts):
if not isinstance(part, dict):
raise TypeError(f"compound parts[{index}] must be a dictionary")
part_config = part.copy()
if "color" in part_config:
raise ValueError(
"compound parts do not support individual colors; "
"set color on the owning object"
)
try:
pose = np.asarray(part_config.pop("pose", [0, 0, 0]), dtype=float)
except (TypeError, ValueError) as exc:
raise ValueError(
f"compound parts[{index}].pose must contain three numbers"
) from exc
if pose.shape != (3,) or not np.all(np.isfinite(pose)):
raise ValueError(
f"compound parts[{index}].pose must be finite [x, y, theta]"
)
name = part_config.get("name")
if not isinstance(name, str):
raise ValueError(f"compound parts[{index}] requires a shape name")
name = name.lower()
if name not in cls._SUPPORTED_PARTS:
supported = ", ".join(sorted(cls._SUPPORTED_PARTS))
raise ValueError(
f"unsupported compound part '{name}' at parts[{index}]; "
f"supported parts are: {supported}"
)
part_config["name"] = name
validated.append((part_config, pose))
return validated
[docs]
def step(self, state):
"""Transform the compound and its individual parts to ``state``."""
self.geometry = geometry_transform(self._original_geometry, state)
self.part_geometries = [
geometry_transform(geometry, state)
for geometry in self._original_part_geometries
]
return self.geometry
@property
def vertices(self) -> None:
"""A compound has no single exact vertex matrix."""
return None
@property
def original_vertices(self) -> None:
"""A compound has no single exact original vertex matrix."""
return None
@property
def part_vertices(self) -> list[np.ndarray]:
"""Current exterior vertices for each part as ``(2, N)`` arrays."""
return [
geometry.exterior.coords._coords.T[:, :-1]
for geometry in self.part_geometries
]
@property
def original_part_vertices(self) -> list[np.ndarray]:
"""Body-frame exterior vertices for each part as ``(2, N)`` arrays."""
return [
geometry.exterior.coords._coords.T[:, :-1]
for geometry in self._original_part_geometries
]
[docs]
class LinestringGeometry(geometry_handler):
"""LineString geometry handler for wall or boundary segments."""
def __init__(self, name: str = "linestring", **kwargs):
super().__init__(name, **kwargs)
[docs]
def construct_original_geometry(
self, vertices, random_shape: bool = False, is_convex: bool = True, **kwargs
):
"""
Construct a LineString object.
Args:
vertices: [[x1, y1], [x2, y2]..]
random_shape: whether to generate random shape, default is False
is_convex: whether to generate convex shape, default is False
**kwargs: see random_generate_polygon()
Returns:
LineString object
"""
if random_shape:
if is_convex:
vertices = random_generate_polygon(spikeyness_range=[0, 0], **kwargs)
else:
vertices = random_generate_polygon(**kwargs)
return LineString(vertices)
[docs]
class PointsGeometry(geometry_handler):
"""Map-cell geometry handler that converts occupied points to line segments."""
def __init__(self, name: str = "map", **kwargs):
super().__init__(name, **kwargs)
[docs]
def construct_original_geometry(
self,
points: np.ndarray,
reso: np.ndarray | float = 0.1,
**kwargs,
):
"""
Construct boundary lines around occupied map cells.
Args:
points: (2, N) array of points
reso: resolution, either a (2, 1) array [x_reso, y_reso]
or a scalar applied to both axes
Returns:
shapely.geometry.MultiLineString: Boundary of occupied map cells.
"""
reso = np.atleast_2d(reso)
half_x = float(reso[0, 0]) / 2
half_y = float(reso[-1, -1]) / 2
boxes = shp.box(
points[0] - half_x,
points[1] - half_y,
points[0] + half_x,
points[1] + half_y,
)
boundary = unary_union(boxes).boundary
if hasattr(boundary, "geoms"):
return boundary
return MultiLineString([LineString(boundary.coords)])
########################################3D Geometry Handler #############################################################
[docs]
class geometry_handler3d(ABC):
"""
This class is used to handle the 3D geometry of the object. It reads the shape parameters from yaml file and constructs the geometry of the object.
"""
def __init__(self, name: str, **kwargs):
self.name = name
self._original_geometry = self.construct_original_geometry(**kwargs)
self.geometry = self._original_geometry
self.wheelbase = kwargs.get("wheelbase")
self.length, self.width, self.depth = self.cal_length_width(
self._original_geometry
)
[docs]
@abstractmethod
def construct_original_geometry(self, **kwargs):
"""Construct the local-frame 3D geometry before any state transform."""
pass
[docs]
def step(self, state):
"""
Transform geometry to the new state.
Args:
state (np.ndarray 6*1): [x, y, z, roll, pitch, yaw].
Returns:
Transformed geometry.
"""
def transform_with_state(x, y):
trans, rot = get_transform(state)
points = np.array([x, y])
new_points = rot @ points + trans
return (new_points[0, :], new_points[1, :])
new_geometry = transform(transform_with_state, self._original_geometry)
self.geometry = new_geometry
return new_geometry
[docs]
class GeometryFactory:
"""
Factory class to create geometry handlers.
"""
[docs]
@staticmethod
def create_geometry(name: str = "circle", **kwargs) -> geometry_handler:
"""Create a geometry handler from a YAML ``shape`` name.
Args:
name: Shape name. Supported values are ``circle``, ``polygon``,
``rectangle``, ``compound``, ``linestring``, and ``map``.
**kwargs: Shape-specific parameters forwarded to the handler.
Returns:
geometry_handler: Concrete geometry handler.
Raises:
ValueError: If ``name`` is not supported.
"""
name = name.lower()
if name == "circle":
return CircleGeometry(name, **kwargs)
if name == "polygon":
return PolygonGeometry(name, **kwargs)
if name == "rectangle":
return RectangleGeometry(name, **kwargs)
if name == "compound":
return CompoundGeometry(name, **kwargs)
if name == "linestring":
return LinestringGeometry(name, **kwargs)
if name == "map":
return PointsGeometry(name, **kwargs)
raise ValueError(f"Invalid geometry name: {name}")