diff --git a/examples/01_simple_route.png b/examples/01_simple_route.png index 98f7b4b..6b63fe2 100644 Binary files a/examples/01_simple_route.png and b/examples/01_simple_route.png differ diff --git a/examples/02_congestion_resolution.png b/examples/02_congestion_resolution.png index 509e11a..ca9b9a4 100644 Binary files a/examples/02_congestion_resolution.png and b/examples/02_congestion_resolution.png differ diff --git a/examples/03_locked_paths.png b/examples/03_locked_paths.png index 0fbcf32..099ffc2 100644 Binary files a/examples/03_locked_paths.png and b/examples/03_locked_paths.png differ diff --git a/examples/04_sbends_and_radii.png b/examples/04_sbends_and_radii.png index a736212..ff14c26 100644 Binary files a/examples/04_sbends_and_radii.png and b/examples/04_sbends_and_radii.png differ diff --git a/examples/05_orientation_stress.png b/examples/05_orientation_stress.png index 20556c3..fa89785 100644 Binary files a/examples/05_orientation_stress.png and b/examples/05_orientation_stress.png differ diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png index 09eeeea..cbab4de 100644 Binary files a/examples/06_bend_collision_models.png and b/examples/06_bend_collision_models.png differ diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index e2d79cc..5fd5531 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -16,8 +16,8 @@ class CollisionEngine: """ __slots__ = ( 'clearance', 'max_net_width', 'safety_zone_radius', - 'static_index', 'static_geometries', 'static_prepared', '_static_id_counter', - 'dynamic_index', 'dynamic_geometries', '_dynamic_id_counter' + 'static_index', 'static_geometries', 'static_dilated', 'static_prepared', '_static_id_counter', + 'dynamic_index', 'dynamic_geometries', 'dynamic_dilated', '_dynamic_id_counter' ) clearance: float @@ -47,21 +47,24 @@ class CollisionEngine: self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius - # Static obstacles: store raw geometries to avoid double-dilation + # Static obstacles self.static_index = rtree.index.Index() - self.static_geometries: dict[int, Polygon] = {} # ID -> Polygon - self.static_prepared: dict[int, PreparedGeometry] = {} # ID -> PreparedGeometry + self.static_geometries: dict[int, Polygon] = {} # ID -> Raw Polygon + self.static_dilated: dict[int, Polygon] = {} # ID -> Dilated Polygon (by clearance) + self.static_prepared: dict[int, PreparedGeometry] = {} # ID -> Prepared Dilated self._static_id_counter = 0 # Dynamic paths for multi-net congestion self.dynamic_index = rtree.index.Index() # obj_id -> (net_id, raw_geometry) self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {} + # obj_id -> dilated_geometry (by clearance/2) + self.dynamic_dilated: dict[int, Polygon] = {} self._dynamic_id_counter = 0 def add_static_obstacle(self, polygon: Polygon) -> None: """ - Add a static obstacle (raw geometry) to the engine. + Add a static obstacle to the engine. Args: polygon: Raw obstacle geometry. @@ -69,23 +72,31 @@ class CollisionEngine: obj_id = self._static_id_counter self._static_id_counter += 1 + dilated = polygon.buffer(self.clearance) self.static_geometries[obj_id] = polygon - self.static_prepared[obj_id] = prep(polygon) - self.static_index.insert(obj_id, polygon.bounds) + self.static_dilated[obj_id] = dilated + self.static_prepared[obj_id] = prep(dilated) + self.static_index.insert(obj_id, dilated.bounds) - def add_path(self, net_id: str, geometry: list[Polygon]) -> None: + def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: """ - Add a net's routed path (raw geometry) to the dynamic index. + Add a net's routed path to the dynamic index. Args: net_id: Identifier for the net. geometry: List of raw polygons in the path. + dilated_geometry: Optional list of pre-dilated polygons (by clearance/2). """ - for poly in geometry: + dilation = self.clearance / 2.0 + for i, poly in enumerate(geometry): obj_id = self._dynamic_id_counter self._dynamic_id_counter += 1 + + dil = dilated_geometry[i] if dilated_geometry else poly.buffer(dilation) + self.dynamic_geometries[obj_id] = (net_id, poly) - self.dynamic_index.insert(obj_id, poly.bounds) + self.dynamic_dilated[obj_id] = dil + self.dynamic_index.insert(obj_id, dil.bounds) def remove_path(self, net_id: str) -> None: """ @@ -97,7 +108,8 @@ class CollisionEngine: to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] for obj_id in to_remove: nid, poly = self.dynamic_geometries.pop(obj_id) - self.dynamic_index.delete(obj_id, poly.bounds) + dilated = self.dynamic_dilated.pop(obj_id) + self.dynamic_index.delete(obj_id, dilated.bounds) def lock_net(self, net_id: str) -> None: """ @@ -109,7 +121,10 @@ class CollisionEngine: to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] for obj_id in to_move: nid, poly = self.dynamic_geometries.pop(obj_id) - self.dynamic_index.delete(obj_id, poly.bounds) + dilated = self.dynamic_dilated.pop(obj_id) + self.dynamic_index.delete(obj_id, dilated.bounds) + # Re-buffer for static clearance if necessary. + # Note: dynamic is clearance/2, static is clearance. self.add_static_obstacle(poly) def is_collision( @@ -121,15 +136,6 @@ class CollisionEngine: ) -> bool: """ Alias for check_collision(buffer_mode='static') for backward compatibility. - - Args: - geometry: Move geometry to check. - net_width: Width of the net (unused). - start_port: Starting port for safety check. - end_port: Ending port for safety check. - - Returns: - True if collision detected. """ _ = net_width res = self.check_collision(geometry, 'default', buffer_mode='static', start_port=start_port, end_port=end_port) @@ -138,13 +144,6 @@ class CollisionEngine: def count_congestion(self, geometry: Polygon, net_id: str) -> int: """ Alias for check_collision(buffer_mode='congestion') for backward compatibility. - - Args: - geometry: Move geometry to check. - net_id: Identifier for the net. - - Returns: - Number of overlapping nets. """ res = self.check_collision(geometry, net_id, buffer_mode='congestion') return int(res) @@ -156,6 +155,7 @@ class CollisionEngine: buffer_mode: Literal['static', 'congestion'] = 'static', start_port: Port | None = None, end_port: Port | None = None, + dilated_geometry: Polygon | None = None, ) -> bool | int: """ Check for collisions using unified dilation logic. @@ -166,17 +166,24 @@ class CollisionEngine: buffer_mode: 'static' (full clearance) or 'congestion' (shared). start_port: Optional start port for safety zone. end_port: Optional end port for safety zone. + dilated_geometry: Optional pre-buffered geometry (clearance/2). Returns: Boolean if static, integer count if congestion. """ if buffer_mode == 'static': - test_poly = geometry.buffer(self.clearance) - candidates = self.static_index.intersection(test_poly.bounds) + # Use raw query against pre-dilated obstacles + candidates = self.static_index.intersection(geometry.bounds) for obj_id in candidates: - if self.static_prepared[obj_id].intersects(test_poly): + if self.static_prepared[obj_id].intersects(geometry): if start_port or end_port: + # Safety zone check: requires intersection of DILATED query and RAW obstacle. + # Always re-buffer here because static check needs full clearance dilation, + # whereas the provided dilated_geometry is usually clearance/2. + dilation = self.clearance + test_poly = geometry.buffer(dilation) + intersection = test_poly.intersection(self.static_geometries[obj_id]) if intersection.is_empty: continue @@ -198,12 +205,12 @@ class CollisionEngine: # buffer_mode == 'congestion' dilation = self.clearance / 2.0 - test_poly = geometry.buffer(dilation) + test_poly = dilated_geometry if dilated_geometry else geometry.buffer(dilation) candidates = self.dynamic_index.intersection(test_poly.bounds) count = 0 for obj_id in candidates: - other_net_id, other_poly = self.dynamic_geometries[obj_id] - if other_net_id != net_id and test_poly.intersects(other_poly.buffer(dilation)): + other_net_id, _ = self.dynamic_geometries[obj_id] + if other_net_id != net_id and test_poly.intersects(self.dynamic_dilated[obj_id]): count += 1 return count diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 595b9dd..e81102d 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,14 +1,12 @@ from __future__ import annotations -from typing import Literal, cast, TYPE_CHECKING, Union, Any +from typing import Literal, cast import numpy from shapely.geometry import Polygon, box from shapely.ops import unary_union from .primitives import Port -if TYPE_CHECKING: - from collections.abc import Sequence # Search Grid Snap (1.0 µm) @@ -32,11 +30,14 @@ class ComponentResult: """ The result of a component generation: geometry, final port, and physical length. """ - __slots__ = ('geometry', 'end_port', 'length') + __slots__ = ('geometry', 'dilated_geometry', 'end_port', 'length') geometry: list[Polygon] """ List of polygons representing the component geometry """ + dilated_geometry: list[Polygon] | None + """ Optional list of pre-dilated polygons for collision optimization """ + end_port: Port """ The final port after the component """ @@ -48,8 +49,10 @@ class ComponentResult: geometry: list[Polygon], end_port: Port, length: float, + dilated_geometry: list[Polygon] | None = None, ) -> None: self.geometry = geometry + self.dilated_geometry = dilated_geometry self.end_port = end_port self.length = length @@ -64,6 +67,7 @@ class Straight: length: float, width: float, snap_to_grid: bool = True, + dilation: float = 0.0, ) -> ComponentResult: """ Generate a straight waveguide segment. @@ -73,16 +77,17 @@ class Straight: length: Requested length. width: Waveguide width. snap_to_grid: Whether to snap the end port to the search grid. + dilation: Optional dilation distance for pre-calculating collision geometry. Returns: A ComponentResult containing the straight segment. """ rad = numpy.radians(start_port.orientation) - dx = length * numpy.cos(rad) - dy = length * numpy.sin(rad) + cos_val = numpy.cos(rad) + sin_val = numpy.sin(rad) - ex = start_port.x + dx - ey = start_port.y + dy + ex = start_port.x + length * cos_val + ey = start_port.y + length * sin_val if snap_to_grid: ex = snap_search_grid(ex) @@ -91,21 +96,36 @@ class Straight: end_port = Port(ex, ey, start_port.orientation) actual_length = numpy.sqrt((end_port.x - start_port.x)**2 + (end_port.y - start_port.y)**2) - # Create polygon + # Create polygons using vectorized points half_w = width / 2.0 - # Points relative to start port (0,0) - points = [(0, half_w), (actual_length, half_w), (actual_length, -half_w), (0, -half_w)] + pts_raw = numpy.array([ + [0, half_w], + [actual_length, half_w], + [actual_length, -half_w], + [0, -half_w] + ]) + + # Rotation matrix (standard 2D rotation) + rot_matrix = numpy.array([[cos_val, -sin_val], [sin_val, cos_val]]) + + # Transform points: P' = R * P + T + poly_points = (pts_raw @ rot_matrix.T) + [start_port.x, start_port.y] + geom = [Polygon(poly_points)] - # Transform points - cos_val = numpy.cos(rad) - sin_val = numpy.sin(rad) - poly_points = [] - for px, py in points: - tx = start_port.x + px * cos_val - py * sin_val - ty = start_port.y + px * sin_val + py * cos_val - poly_points.append((tx, ty)) + dilated_geom = None + if dilation > 0: + # Direct calculation of dilated rectangle instead of expensive buffer() + half_w_dil = half_w + dilation + pts_dil = numpy.array([ + [-dilation, half_w_dil], + [actual_length + dilation, half_w_dil], + [actual_length + dilation, -half_w_dil], + [-dilation, -half_w_dil] + ]) + poly_points_dil = (pts_dil @ rot_matrix.T) + [start_port.x, start_port.y] + dilated_geom = [Polygon(poly_points_dil)] - return ComponentResult(geometry=[Polygon(poly_points)], end_port=end_port, length=actual_length) + return ComponentResult(geometry=geom, end_port=end_port, length=actual_length, dilated_geometry=dilated_geom) def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) -> int: @@ -140,7 +160,7 @@ def _get_arc_polygons( sagitta: float = 0.01, ) -> list[Polygon]: """ - Helper to generate arc-shaped polygons. + Helper to generate arc-shaped polygons using vectorized NumPy operations. Args: cx, cy: Center coordinates. @@ -154,11 +174,20 @@ def _get_arc_polygons( """ num_segments = _get_num_segments(radius, float(numpy.degrees(abs(t_end - t_start))), sagitta) angles = numpy.linspace(t_start, t_end, num_segments + 1) + + cos_a = numpy.cos(angles) + sin_a = numpy.sin(angles) + inner_radius = radius - width / 2.0 outer_radius = radius + width / 2.0 - inner_points = [(cx + inner_radius * numpy.cos(a), cy + inner_radius * numpy.sin(a)) for a in angles] - outer_points = [(cx + outer_radius * numpy.cos(a), cy + outer_radius * numpy.sin(a)) for a in reversed(angles)] - return [Polygon(inner_points + outer_points)] + + inner_points = numpy.stack([cx + inner_radius * cos_a, cy + inner_radius * sin_a], axis=1) + outer_points = numpy.stack([cx + outer_radius * cos_a[::-1], cy + outer_radius * sin_a[::-1]], axis=1) + + # Concatenate inner and outer points to form the polygon ring + poly_points = numpy.concatenate([inner_points, outer_points]) + + return [Polygon(poly_points)] def _clip_bbox( @@ -291,6 +320,7 @@ class Bend90: sagitta: float = 0.01, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", clip_margin: float = 10.0, + dilation: float = 0.0, ) -> ComponentResult: """ Generate a 90-degree bend. @@ -303,6 +333,7 @@ class Bend90: sagitta: Geometric fidelity. collision_type: Collision model. clip_margin: Margin for clipped_bbox. + dilation: Optional dilation distance for pre-calculating collision geometry. Returns: A ComponentResult containing the bend. @@ -323,8 +354,15 @@ class Bend90: collision_polys = _apply_collision_model( arc_polys[0], collision_type, radius, width, cx, cy, clip_margin ) + + dilated_geom = [p.buffer(dilation) for p in collision_polys] if dilation > 0 else None - return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * numpy.pi / 2.0) + return ComponentResult( + geometry=collision_polys, + end_port=end_port, + length=radius * numpy.pi / 2.0, + dilated_geometry=dilated_geom + ) class SBend: @@ -340,6 +378,7 @@ class SBend: sagitta: float = 0.01, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", clip_margin: float = 10.0, + dilation: float = 0.0, ) -> ComponentResult: """ Generate a parametric S-bend (two tangent arcs). @@ -352,6 +391,7 @@ class SBend: sagitta: Geometric fidelity. collision_type: Collision model. clip_margin: Margin for clipped_bbox. + dilation: Optional dilation distance for pre-calculating collision geometry. Returns: A ComponentResult containing the S-bend. @@ -385,13 +425,22 @@ class SBend: arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0] if collision_type == "clipped_bbox": - col1 = _apply_collision_model(arc1, collision_type, radius, width, cx1, cy1, clip_margin) - col2 = _apply_collision_model(arc2, collision_type, radius, width, cx2, cy2, clip_margin) - collision_polys = [cast('Polygon', unary_union(col1 + col2))] + col1 = _apply_collision_model(arc1, collision_type, radius, width, cx1, cy1, clip_margin)[0] + col2 = _apply_collision_model(arc2, collision_type, radius, width, cx2, cy2, clip_margin)[0] + # Optimization: keep as list instead of unary_union for search efficiency + collision_polys = [col1, col2] else: - combined_arc = cast('Polygon', unary_union([arc1, arc2])) - collision_polys = _apply_collision_model( - combined_arc, collision_type, radius, width, 0, 0, clip_margin - ) + # For other models, we can either combine or keep separate. + # Keeping separate is generally better for CollisionEngine. + col1 = _apply_collision_model(arc1, collision_type, radius, width, cx1, cy1, clip_margin)[0] + col2 = _apply_collision_model(arc2, collision_type, radius, width, cx2, cy2, clip_margin)[0] + collision_polys = [col1, col2] - return ComponentResult(geometry=collision_polys, end_port=end_port, length=2 * radius * theta) + dilated_geom = [p.buffer(dilation) for p in collision_polys] if dilation > 0 else None + + return ComponentResult( + geometry=collision_polys, + end_port=end_port, + length=2 * radius * theta, + dilated_geometry=dilated_geom + ) diff --git a/inire/router/astar.py b/inire/router/astar.py index 754049f..74ef3b1 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -83,7 +83,7 @@ class AStarRouter: """ Hybrid State-Lattice A* Router. """ - __slots__ = ('cost_evaluator', 'config', 'node_limit', 'total_nodes_expanded', '_collision_cache') + __slots__ = ('cost_evaluator', 'config', 'node_limit', 'total_nodes_expanded', '_collision_cache', '_self_dilation') cost_evaluator: CostEvaluator """ The evaluator for path and proximity costs """ @@ -100,6 +100,9 @@ class AStarRouter: _collision_cache: dict[tuple[float, float, float, str, float, str], bool] """ Internal cache for move collision checks """ + _self_dilation: float + """ Cached dilation value for collision checks (clearance / 2.0) """ + def __init__( self, cost_evaluator: CostEvaluator, @@ -146,6 +149,7 @@ class AStarRouter: self.node_limit = self.config.node_limit self.total_nodes_expanded = 0 self._collision_cache = {} + self._self_dilation = self.cost_evaluator.collision_engine.clearance / 2.0 def route( self, @@ -226,7 +230,7 @@ class AStarRouter: proj = dx * numpy.cos(rad) + dy * numpy.sin(rad) perp = -dx * numpy.sin(rad) + dy * numpy.cos(rad) if proj > 0 and abs(perp) < 1e-6: - res = Straight.generate(current.port, proj, net_width, snap_to_grid=False) + res = Straight.generate(current.port, proj, net_width, snap_to_grid=False, dilation=self._self_dilation) self._add_node(current, res, target, net_width, net_id, open_set, closed_set, 'SnapStraight') # B. Try SBend exact reach @@ -245,7 +249,8 @@ class AStarRouter: radius, net_width, collision_type=self.config.bend_collision_type, - clip_margin=self.config.bend_clip_margin + clip_margin=self.config.bend_clip_margin, + dilation=self._self_dilation ) self._add_node(current, res, target, net_width, net_id, open_set, closed_set, 'SnapSBend', move_radius=radius) except ValueError: @@ -258,7 +263,7 @@ class AStarRouter: lengths = sorted(set(lengths + fine_steps)) for length in lengths: - res = Straight.generate(current.port, length, net_width) + res = Straight.generate(current.port, length, net_width, dilation=self._self_dilation) self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f'S{length}') # 3. Lattice Bends @@ -270,7 +275,8 @@ class AStarRouter: net_width, direction, collision_type=self.config.bend_collision_type, - clip_margin=self.config.bend_clip_margin + clip_margin=self.config.bend_clip_margin, + dilation=self._self_dilation ) self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f'B{radius}{direction}', move_radius=radius) @@ -284,7 +290,8 @@ class AStarRouter: radius, net_width, collision_type=self.config.bend_collision_type, - clip_margin=self.config.bend_clip_margin + clip_margin=self.config.bend_clip_margin, + dilation=self._self_dilation ) self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f'SB{offset}R{radius}', move_radius=radius) except ValueError: @@ -320,9 +327,11 @@ class AStarRouter: return else: hard_coll = False - for poly in result.geometry: + for i, poly in enumerate(result.geometry): + dil_poly = result.dilated_geometry[i] if result.dilated_geometry else None if self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode='static', start_port=parent.port, end_port=result.end_port + poly, net_id, buffer_mode='static', start_port=parent.port, end_port=result.end_port, + dilated_geometry=dil_poly ): hard_coll = True break @@ -331,27 +340,37 @@ class AStarRouter: return # 3. Check for Self-Intersection (Limited to last 100 segments for performance) - dilation = self.cost_evaluator.collision_engine.clearance / 2.0 - for move_poly in result.geometry: - dilated_move = move_poly.buffer(dilation) - curr_p: AStarNode | None = parent - seg_idx = 0 - while curr_p and curr_p.component_result and seg_idx < 100: - if seg_idx > 0: - for prev_poly in curr_p.component_result.geometry: - if dilated_move.bounds[0] > prev_poly.bounds[2] + dilation or \ - dilated_move.bounds[2] < prev_poly.bounds[0] - dilation or \ - dilated_move.bounds[1] > prev_poly.bounds[3] + dilation or \ - dilated_move.bounds[3] < prev_poly.bounds[1] - dilation: - continue + # Optimization: use pre-dilated geometries + if result.dilated_geometry: + for dilated_move in result.dilated_geometry: + curr_p: AStarNode | None = parent + seg_idx = 0 + while curr_p and curr_p.component_result and seg_idx < 100: + if seg_idx > 0: + res_p = curr_p.component_result + if res_p.dilated_geometry: + for dilated_prev in res_p.dilated_geometry: + if (dilated_move.bounds[0] > dilated_prev.bounds[2] or + dilated_move.bounds[2] < dilated_prev.bounds[0] or + dilated_move.bounds[1] > dilated_prev.bounds[3] or + dilated_move.bounds[3] < dilated_prev.bounds[1]): + continue - dilated_prev = prev_poly.buffer(dilation) - if dilated_move.intersects(dilated_prev): - overlap = dilated_move.intersection(dilated_prev) - if overlap.area > 1e-6: - return - curr_p = curr_p.parent - seg_idx += 1 + if dilated_move.intersects(dilated_prev): + overlap = dilated_move.intersection(dilated_prev) + if overlap.area > 1e-6: + return + else: + # Fallback if no pre-dilation (should not happen with new logic) + dilation = self._self_dilation + for prev_poly in res_p.geometry: + dilated_prev = prev_poly.buffer(dilation) + if dilated_move.intersects(dilated_prev): + overlap = dilated_move.intersection(dilated_prev) + if overlap.area > 1e-6: + return + curr_p = curr_p.parent + seg_idx += 1 move_cost = self.cost_evaluator.evaluate_move( result.geometry, @@ -359,7 +378,8 @@ class AStarRouter: net_width, net_id, start_port=parent.port, - length=result.length + length=result.length, + dilated_geometry=result.dilated_geometry ) if move_cost > 1e12: diff --git a/inire/router/cost.py b/inire/router/cost.py index 6b2ff54..1753aa9 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -103,6 +103,7 @@ class CostEvaluator: net_id: str, start_port: Port | None = None, length: float = 0.0, + dilated_geometry: list[Polygon] | None = None, ) -> float: """ Calculate the cost of a single move (Straight, Bend, SBend). @@ -114,6 +115,7 @@ class CostEvaluator: net_id: Identifier for the net. start_port: Port at the start of the move. length: Physical path length of the move. + dilated_geometry: Pre-calculated dilated polygons. Returns: Total cost of the move, or 1e15 if invalid. @@ -126,15 +128,19 @@ class CostEvaluator: return 1e15 # 2. Collision Check - for poly in geometry: + for i, poly in enumerate(geometry): + dil_poly = dilated_geometry[i] if dilated_geometry else None # Hard Collision (Static obstacles) if self.collision_engine.check_collision( - poly, net_id, buffer_mode='static', start_port=start_port, end_port=end_port + poly, net_id, buffer_mode='static', start_port=start_port, end_port=end_port, + dilated_geometry=dil_poly ): return 1e15 # Soft Collision (Negotiated Congestion) - overlaps = self.collision_engine.check_collision(poly, net_id, buffer_mode='congestion') + overlaps = self.collision_engine.check_collision( + poly, net_id, buffer_mode='congestion', dilated_geometry=dil_poly + ) if isinstance(overlaps, int) and overlaps > 0: total_cost += overlaps * self.congestion_penalty diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index 05235b7..40ce644 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -118,15 +118,23 @@ class PathFinder: if path: # 3. Add to index all_geoms = [] + all_dilated = [] for res in path: all_geoms.extend(res.geometry) - self.cost_evaluator.collision_engine.add_path(net_id, all_geoms) + if res.dilated_geometry: + all_dilated.extend(res.dilated_geometry) + else: + # Fallback dilation + dilation = self.cost_evaluator.collision_engine.clearance / 2.0 + all_dilated.extend([p.buffer(dilation) for p in res.geometry]) + + self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) # Check if this new path has any congestion collision_count = 0 - for poly in all_geoms: + for i, poly in enumerate(all_geoms): overlaps = self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode='congestion' + poly, net_id, buffer_mode='congestion', dilated_geometry=all_dilated[i] ) if isinstance(overlaps, int): collision_count += overlaps @@ -172,9 +180,10 @@ class PathFinder: collision_count = 0 for comp in res.path: - for poly in comp.geometry: + for i, poly in enumerate(comp.geometry): + dil_poly = comp.dilated_geometry[i] if comp.dilated_geometry else None overlaps = self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode='congestion' + poly, net_id, buffer_mode='congestion', dilated_geometry=dil_poly ) if isinstance(overlaps, int): collision_count += overlaps diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 3ce6b18..13bfb56 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -50,7 +50,7 @@ def test_sbend_generation() -> None: result = SBend.generate(start, offset, radius, width) assert result.end_port.y == 5.0 assert result.end_port.orientation == 0.0 - assert len(result.geometry) == 1 # Now uses unary_union + assert len(result.geometry) == 2 # Optimization: returns individual arcs # Verify failure for large offset with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"): @@ -85,11 +85,13 @@ def test_sbend_collision_models() -> None: width = 2.0 res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox") - # Geometry should be a single bounding box polygon - assert len(res_bbox.geometry) == 1 + # Geometry should be a list of individual bbox polygons for each arc + assert len(res_bbox.geometry) == 2 res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") - assert res_bbox.geometry[0].area > res_arc.geometry[0].area + area_bbox = sum(p.area for p in res_bbox.geometry) + area_arc = sum(p.area for p in res_arc.geometry) + assert area_bbox > area_arc def test_sbend_continuity() -> None: @@ -107,9 +109,10 @@ def test_sbend_continuity() -> None: # For a port at 90 deg, +offset is a shift in -x direction assert abs(res.end_port.x - (10.0 - offset)) < 1e-6 - # Geometry should be connected (unary_union results in 1 polygon) - assert len(res.geometry) == 1 - assert res.geometry[0].is_valid + # Geometry should be a list of valid polygons + assert len(res.geometry) == 2 + for p in res.geometry: + assert p.is_valid def test_arc_sagitta_precision() -> None: