from __future__ import annotations from dataclasses import dataclass, field from typing import Literal import numpy from shapely.affinity import rotate as shapely_rotate from shapely.affinity import scale as shapely_scale from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon, box from inire.constants import TOLERANCE_ANGULAR from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed from .primitives import Port, rotation_matrix2 MoveKind = Literal["straight", "bend90", "sbend"] BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"] BendCollisionModel = BendCollisionModelName | Polygon BendPhysicalGeometry = Literal["arc"] | Polygon def _normalize_length(value: float) -> float: return float(value) @dataclass(frozen=True, slots=True) class ComponentResult: start_port: Port collision_geometry: tuple[Polygon, ...] end_port: Port length: float move_type: MoveKind move_spec: PathSegmentSeed physical_geometry: tuple[Polygon, ...] dilated_collision_geometry: tuple[Polygon, ...] dilated_physical_geometry: tuple[Polygon, ...] _bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False) _total_bounds: tuple[float, float, float, float] = field(init=False, repr=False) _dilated_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False) _total_dilated_bounds: tuple[float, float, float, float] = field(init=False, repr=False) def __post_init__(self) -> None: collision_geometry = tuple(self.collision_geometry) physical_geometry = tuple(self.physical_geometry) dilated_collision_geometry = tuple(self.dilated_collision_geometry) dilated_physical_geometry = tuple(self.dilated_physical_geometry) object.__setattr__(self, "collision_geometry", collision_geometry) object.__setattr__(self, "physical_geometry", physical_geometry) object.__setattr__(self, "dilated_collision_geometry", dilated_collision_geometry) object.__setattr__(self, "dilated_physical_geometry", dilated_physical_geometry) object.__setattr__(self, "length", float(self.length)) bounds = tuple(poly.bounds for poly in collision_geometry) object.__setattr__(self, "_bounds", bounds) object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds))) dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry) object.__setattr__(self, "_dilated_bounds", dilated_bounds) object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds))) @property def bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._bounds @property def total_bounds(self) -> tuple[float, float, float, float]: return self._total_bounds @property def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._dilated_bounds @property def total_dilated_bounds(self) -> tuple[float, float, float, float]: return self._total_dilated_bounds def translate(self, dx: int | float, dy: int | float) -> ComponentResult: return ComponentResult( start_port=self.start_port.translate(dx, dy), collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry], end_port=self.end_port.translate(dx, dy), length=self.length, move_type=self.move_type, move_spec=self.move_spec, physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry], dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry], dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry], ) def _combine_bounds(bounds_list: list[tuple[float, float, float, float]]) -> tuple[float, float, float, float]: arr = numpy.asarray(bounds_list, dtype=numpy.float64) return ( float(arr[:, 0].min()), float(arr[:, 1].min()), float(arr[:, 2].max()), float(arr[:, 3].max()), ) def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) -> int: if radius <= 0: return 1 ratio = max(0.0, min(1.0, 1.0 - sagitta / radius)) theta_max = 2.0 * numpy.arccos(ratio) if theta_max < TOLERANCE_ANGULAR: return 16 num = int(numpy.ceil(numpy.radians(abs(angle_deg)) / theta_max)) return max(8, num) def _get_arc_polygons( cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], sagitta: float = 0.01, dilation: float = 0.0, ) -> list[Polygon]: t_start, t_end = numpy.radians(ts[0]), numpy.radians(ts[1]) num_segments = _get_num_segments(radius, abs(ts[1] - ts[0]), sagitta) angles = numpy.linspace(t_start, t_end, num_segments + 1) cx, cy = cxy inner_radius = radius - width / 2.0 - dilation outer_radius = radius + width / 2.0 + dilation cos_a = numpy.cos(angles) sin_a = numpy.sin(angles) inner_points = numpy.column_stack((cx + inner_radius * cos_a, cy + inner_radius * sin_a)) outer_points = numpy.column_stack((cx + outer_radius * cos_a[::-1], cy + outer_radius * sin_a[::-1])) return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))] def _clip_bbox_legacy( cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float, ) -> Polygon: arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0] minx, miny, maxx, maxy = arc_poly.bounds bbox_poly = box(minx, miny, maxx, maxy) shrink = min(clip_margin, max(radius, width)) return bbox_poly.buffer(-shrink, join_style=2) if shrink > 0 else bbox_poly def _clip_bbox_polygonal(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon: """Return a conservative 8-point polygonal proxy for the arc. The polygon uses 4 points along the outer edge and 4 along the inner edge. The outer edge is a circumscribed polyline and the inner edge is an inscribed polyline, so the result conservatively contains the true arc. """ cx, cy = cxy sample_count = 4 angle_span = abs(float(ts[1]) - float(ts[0])) if angle_span < TOLERANCE_ANGULAR: return box(*_get_arc_polygons(cxy, radius, width, ts)[0].bounds) segment_half_angle = numpy.radians(angle_span / (2.0 * (sample_count - 1))) cos_half = max(float(numpy.cos(segment_half_angle)), 1e-9) inner_radius = max(0.0, radius - width / 2.0) outer_radius = radius + width / 2.0 tolerance = max(1e-3, radius * 1e-4) conservative_inner_radius = max(0.0, inner_radius * cos_half - tolerance) conservative_outer_radius = outer_radius / cos_half + tolerance angles = numpy.radians(numpy.linspace(ts[0], ts[1], sample_count)) cos_a = numpy.cos(angles) sin_a = numpy.sin(angles) outer_points = numpy.column_stack((cx + conservative_outer_radius * cos_a, cy + conservative_outer_radius * sin_a)) inner_points = numpy.column_stack((cx + conservative_inner_radius * cos_a[::-1], cy + conservative_inner_radius * sin_a[::-1])) return Polygon(numpy.concatenate((outer_points, inner_points), axis=0)) def _clip_bbox( cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float | None, ) -> Polygon: if clip_margin is not None: return _clip_bbox_legacy(cxy, radius, width, ts, clip_margin) return _clip_bbox_polygonal(cxy, radius, width, ts) def _transform_custom_collision_polygon( collision_poly: Polygon, cxy: tuple[float, float], rotation_deg: float, mirror_y: bool, ) -> Polygon: poly = collision_poly if mirror_y: poly = shapely_scale(poly, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0)) if rotation_deg % 360: poly = shapely_rotate(poly, rotation_deg, origin=(0.0, 0.0), use_radians=False) return shapely_translate(poly, cxy[0], cxy[1]) def _apply_collision_model( arc_poly: Polygon, collision_type: BendCollisionModel, radius: float, width: float, cxy: tuple[float, float], ts: tuple[float, float], clip_margin: float | None = None, rotation_deg: float = 0.0, mirror_y: bool = False, ) -> list[Polygon]: if isinstance(collision_type, Polygon): return [_transform_custom_collision_polygon(collision_type, cxy, rotation_deg, mirror_y)] if collision_type == "arc": return [arc_poly] if collision_type == "clipped_bbox": clipped = _clip_bbox(cxy, radius, width, ts, clip_margin) return [clipped if not clipped.is_empty else box(*arc_poly.bounds)] return [box(*arc_poly.bounds)] class Straight: @staticmethod def generate( start_port: Port, length: float, width: float, dilation: float = 0.0, ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) length_f = _normalize_length(length) disp = rot2 @ numpy.array((length_f, 0.0)) end_port = Port(start_port.x + disp[0], start_port.y + disp[1], start_port.r) half_w = width / 2.0 pts = numpy.array(((0.0, half_w), (length_f, half_w), (length_f, -half_w), (0.0, -half_w))) poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y)) geometry = [Polygon(poly_points)] if dilation > 0: half_w_d = half_w + dilation pts_d = numpy.array( ( (-dilation, half_w_d), (length_f + dilation, half_w_d), (length_f + dilation, -half_w_d), (-dilation, -half_w_d), ) ) poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y)) dilated_geometry = [Polygon(poly_points_d)] else: dilated_geometry = geometry return ComponentResult( start_port=start_port, collision_geometry=geometry, end_port=end_port, length=abs(length_f), move_type="straight", move_spec=StraightSeed(length=length_f), physical_geometry=geometry, dilated_collision_geometry=dilated_geometry, dilated_physical_geometry=dilated_geometry, ) class Bend90: @staticmethod def generate( start_port: Port, radius: float, width: float, direction: Literal["CW", "CCW"], sagitta: float = 0.01, collision_type: BendCollisionModel = "arc", physical_geometry_type: BendPhysicalGeometry = "arc", clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) sign = 1 if direction == "CCW" else -1 center_local = numpy.array((0.0, sign * radius)) end_local = numpy.array((radius, sign * radius)) center_xy = (rot2 @ center_local) + numpy.array((start_port.x, start_port.y)) end_xy = (rot2 @ end_local) + numpy.array((start_port.x, start_port.y)) end_port = Port(end_xy[0], end_xy[1], start_port.r + sign * 90) start_theta = start_port.r - sign * 90 end_theta = start_port.r ts = (float(start_theta), float(end_theta)) arc_polys = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta) collision_polys = _apply_collision_model( arc_polys[0], collision_type, radius, width, (float(center_xy[0]), float(center_xy[1])), ts, clip_margin=clip_margin, rotation_deg=float(start_port.r), mirror_y=(sign < 0), ) if isinstance(physical_geometry_type, Polygon): physical_geometry = _apply_collision_model( arc_polys[0], physical_geometry_type, radius, width, (float(center_xy[0]), float(center_xy[1])), ts, rotation_deg=float(start_port.r), mirror_y=(sign < 0), ) uses_physical_custom_geometry = True else: physical_geometry = arc_polys uses_physical_custom_geometry = False if dilation > 0: if uses_physical_custom_geometry: dilated_physical_geometry = [poly.buffer(dilation) for poly in physical_geometry] else: dilated_physical_geometry = _get_arc_polygons( (float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta, dilation=dilation, ) dilated_collision_geometry = ( dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] ) else: dilated_physical_geometry = physical_geometry dilated_collision_geometry = collision_polys return ComponentResult( start_port=start_port, collision_geometry=collision_polys, end_port=end_port, length=abs(radius) * numpy.pi / 2.0, move_type="bend90", move_spec=Bend90Seed(radius=radius, direction=direction), physical_geometry=physical_geometry, dilated_collision_geometry=dilated_collision_geometry, dilated_physical_geometry=dilated_physical_geometry, ) class SBend: @staticmethod def generate( start_port: Port, offset: float, radius: float, width: float, sagitta: float = 0.01, collision_type: BendCollisionModel = "arc", physical_geometry_type: BendPhysicalGeometry = "arc", clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: if abs(offset) >= 2 * radius: raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}") sign = 1 if offset >= 0 else -1 theta = numpy.arccos(1.0 - abs(offset) / (2.0 * radius)) dx = 2.0 * radius * numpy.sin(theta) theta_deg = float(numpy.degrees(theta)) rot2 = rotation_matrix2(start_port.r) end_local = numpy.array((dx, offset)) end_xy = (rot2 @ end_local) + numpy.array((start_port.x, start_port.y)) end_port = Port(end_xy[0], end_xy[1], start_port.r) c1_local = numpy.array((0.0, sign * radius)) c2_local = numpy.array((dx, offset - sign * radius)) c1_xy = (rot2 @ c1_local) + numpy.array((start_port.x, start_port.y)) c2_xy = (rot2 @ c2_local) + numpy.array((start_port.x, start_port.y)) ts1 = (float(start_port.r - sign * 90), float(start_port.r - sign * 90 + sign * theta_deg)) second_base = start_port.r + (90 if sign > 0 else 270) ts2 = (float(second_base + sign * theta_deg), float(second_base)) arc1 = _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta)[0] arc2 = _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta)[0] actual_geometry = [arc1, arc2] geometry = [ _apply_collision_model( arc1, collision_type, radius, width, (float(c1_xy[0]), float(c1_xy[1])), ts1, clip_margin=clip_margin, rotation_deg=float(start_port.r), mirror_y=(sign < 0), )[0], _apply_collision_model( arc2, collision_type, radius, width, (float(c2_xy[0]), float(c2_xy[1])), ts2, clip_margin=clip_margin, rotation_deg=float(start_port.r), mirror_y=(sign > 0), )[0], ] if isinstance(physical_geometry_type, Polygon): physical_geometry = [ _apply_collision_model( arc1, physical_geometry_type, radius, width, (float(c1_xy[0]), float(c1_xy[1])), ts1, rotation_deg=float(start_port.r), mirror_y=(sign < 0), )[0], _apply_collision_model( arc2, physical_geometry_type, radius, width, (float(c2_xy[0]), float(c2_xy[1])), ts2, rotation_deg=float(start_port.r), mirror_y=(sign > 0), )[0], ] uses_physical_custom_geometry = True else: physical_geometry = actual_geometry uses_physical_custom_geometry = False if dilation > 0: if uses_physical_custom_geometry: dilated_physical_geometry = [poly.buffer(dilation) for poly in physical_geometry] else: dilated_physical_geometry = [ _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], ] dilated_collision_geometry = ( dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] ) else: dilated_physical_geometry = physical_geometry dilated_collision_geometry = geometry return ComponentResult( start_port=start_port, collision_geometry=geometry, end_port=end_port, length=2.0 * radius * theta, move_type="sbend", move_spec=SBendSeed(offset=offset, radius=radius), physical_geometry=physical_geometry, dilated_collision_geometry=dilated_collision_geometry, dilated_physical_geometry=dilated_physical_geometry, )