diff --git a/DOCS.md b/DOCS.md index ea6292c..b6c8a1b 100644 --- a/DOCS.md +++ b/DOCS.md @@ -6,23 +6,23 @@ This document describes the user-tunable parameters for the `inire` auto-router. The `AStarRouter` is the core pathfinding engine. It can be configured directly through its constructor. -| Parameter | Type | Default | Description | -| :--- | :--- | :--- | :--- | -| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | -| `straight_lengths` | `list[float]` | `[1.0, 5.0, 25.0]` | Discrete step sizes for straight waveguides (µm). Larger steps speed up search in open space. | -| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow the router to pick the best fit. | -| `sbend_offsets` | `list[float]` | `[-5, -2, 2, 5]` | Lateral offsets for parametric S-bends (µm). | -| `sbend_radii` | `list[float]` | `[10.0]` | Available radii for S-bends (µm). | -| `snap_to_target_dist`| `float` | 20.0 | Distance (µm) at which the router attempts an exact bridge to the target port. | -| `bend_penalty` | `float` | 50.0 | Flat cost added for every 90-degree bend. Higher values favor straight lines. | -| `sbend_penalty` | `float` | 100.0 | Flat cost added for every S-bend. Usually higher than `bend_penalty`. | -| `bend_collision_type`| `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. | -| `bend_clip_margin` | `float` | 10.0 | Margin (µm) for the `"clipped_bbox"` collision model. | +| Parameter | Type | Default | Description | +| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ | +| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | +| `straight_lengths` | `list[float]` | `[1.0, 5.0, 25.0]` | Discrete step sizes for straight waveguides (µm). Larger steps speed up search. | +| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow best-fit selection. | +| `sbend_offsets` | `list[float]` | `[-5, -2, 2, 5]` | Lateral offsets for parametric S-bends (µm). | +| `sbend_radii` | `list[float]` | `[10.0]` | Available radii for S-bends (µm). | +| `snap_to_target_dist` | `float` | 20.0 | Distance (µm) at which the router attempts an exact bridge to the target port. | +| `bend_penalty` | `float` | 50.0 | Flat cost added for every 90-degree bend. Higher values favor straight lines. | +| `sbend_penalty` | `float` | 100.0 | Flat cost added for every S-bend. Usually higher than `bend_penalty`. | +| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. | +| `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide before the bounding box corners are clipped. | ### Bend Collision Models * `"arc"`: High-fidelity model following the exact curved waveguide geometry. * `"bbox"`: Conservative model using the axis-aligned bounding box of the bend. Fast but blocks more space. -* `"clipped_bbox"`: A middle ground that uses the bounding box but clips corners that are far from the waveguide. +* `"clipped_bbox"`: A middle ground that starts with the bounding box but applies 45-degree linear cuts to the inner and outer corners. The `bend_clip_margin` defines the extra safety distance from the waveguide edge to the cut line. --- @@ -30,11 +30,11 @@ The `AStarRouter` is the core pathfinding engine. It can be configured directly The `CostEvaluator` defines the "goodness" of a path. -| Parameter | Type | Default | Description | -| :--- | :--- | :--- | :--- | -| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | -| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g., `1.5`) are faster but may produce longer paths. | -| `congestion_penalty`| `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | +| Parameter | Type | Default | Description | +| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- | +| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | +| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. | +| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | --- @@ -42,19 +42,19 @@ The `CostEvaluator` defines the "goodness" of a path. The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. -| Parameter | Type | Default | Description | -| :--- | :--- | :--- | :--- | -| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | -| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. This value is multiplied by `1.5` each iteration if congestion persists. | +| Parameter | Type | Default | Description | +| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- | +| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | +| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.| --- ## 4. CollisionEngine Parameters -| Parameter | Type | Default | Description | -| :--- | :--- | :--- | :--- | -| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | -| `safety_zone_radius`| `float` | 0.0021 | Radius (µm) around ports where collisions are ignored to allow PDK boundary incidence. | +| Parameter | Type | Default | Description | +| :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ | +| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | +| `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. | --- @@ -62,6 +62,7 @@ The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion - **Coordinates**: Micrometers (µm). - **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves. - **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**. +- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost. --- diff --git a/README.md b/README.md index af1915f..dffee6d 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,17 @@ Check the `examples/` directory for ready-to-run scripts demonstrating core feat * **`examples/03_locked_paths.py`**: Incremental workflow using `lock_net()` to route around previously fixed paths. Generates `03_locked_paths.png`. * **`examples/04_sbends_and_radii.py`**: Complex paths using parametric S-bends and multiple bend radii. Generates `04_sbends_and_radii.png`. * **`examples/05_orientation_stress.py`**: Stress test for various port orientation combinations (U-turns, opposite directions). Generates `05_orientation_stress.png`. +* **`examples/06_bend_collision_models.py`**: Comparison of different collision models for bends (Arc vs. BBox vs. Clipped BBox). Generates `06_bend_collision_models.png`. Run an example: ```bash python3 examples/01_simple_route.py ``` +## Documentation + +Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. + ## Architecture `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index 34e39f5..3485eb9 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -28,7 +28,7 @@ def main() -> None: "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), } - net_widths = {nid: 2.0 for nid in netlist} + net_widths = dict.fromkeys(netlist, 2.0) # 3. Route with Negotiated Congestion # We increase the base penalty to encourage faster divergence diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index ad630ed..4937783 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -28,7 +28,7 @@ def main() -> None: "bus_2": (Port(10, 60, 0), Port(110, 65, 0)), } print("Phase 1: Routing bus (3 nets)...") - results_p1 = pf.route_all(netlist_p1, {nid: 2.0 for nid in netlist_p1}) + results_p1 = pf.route_all(netlist_p1, dict.fromkeys(netlist_p1, 2.0)) # Lock all Phase 1 nets path_polys = [] @@ -50,10 +50,10 @@ def main() -> None: "cross_left": (Port(30, 10, 90), Port(30, 110, 90)), "cross_right": (Port(80, 110, 270), Port(80, 10, 270)), # Top to bottom } - + print("Phase 2: Routing crossing nets around locked bus...") # We use a slightly different width for variety - results_p2 = pf.route_all(netlist_p2, {nid: 1.5 for nid in netlist_p2}) + results_p2 = pf.route_all(netlist_p2, dict.fromkeys(netlist_p2, 1.5)) # 4. Check Results for nid, res in results_p2.items(): diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index 3b8fa28..8a7402e 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -1,4 +1,3 @@ -from shapely.geometry import Polygon from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port diff --git a/examples/05_orientation_stress.py b/examples/05_orientation_stress.py index a84306d..ddff8b9 100644 --- a/examples/05_orientation_stress.py +++ b/examples/05_orientation_stress.py @@ -16,26 +16,26 @@ def main() -> None: engine = CollisionEngine(clearance=2.0) danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) - + evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.1) router = AStarRouter(evaluator, node_limit=100000) pf = PathFinder(router, evaluator) - + # 2. Define Netlist with various orientation challenges netlist = { # Opposite directions: requires two 90-degree bends to flip orientation "opposite": (Port(10, 80, 0), Port(90, 80, 180)), - + # 90-degree turn: standard L-shape "turn_90": (Port(10, 60, 0), Port(40, 90, 90)), - + # Output behind input: requires a full U-turn "behind": (Port(80, 40, 0), Port(20, 40, 0)), - + # Sharp return: output is behind and oriented towards the input "return_loop": (Port(80, 20, 0), Port(40, 10, 180)), } - net_widths = {nid: 2.0 for nid in netlist} + net_widths = dict.fromkeys(netlist, 2.0) # 3. Route results = pf.route_all(netlist, net_widths) diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png new file mode 100644 index 0000000..9088508 Binary files /dev/null and b/examples/06_bend_collision_models.png differ diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py new file mode 100644 index 0000000..30fc4b5 --- /dev/null +++ b/examples/06_bend_collision_models.py @@ -0,0 +1,70 @@ +from shapely.geometry import Polygon + +from inire.geometry.collision import CollisionEngine +from inire.geometry.primitives import Port +from inire.router.astar import AStarRouter +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap +from inire.router.pathfinder import PathFinder +from inire.utils.visualization import plot_routing_results + + +def main() -> None: + print("Running Example 06: Bend Collision Models...") + + # 1. Setup Environment + # Give room for 10um bends near the edges + bounds = (-20, -20, 170, 170) + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + + # Create three scenarios with identical obstacles + # We'll space them out vertically + obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]) + obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]) + obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]) + + obstacles = [obs_arc, obs_bbox, obs_clipped] + for obs in obstacles: + engine.add_static_obstacle(obs) + danger_map.precompute(obstacles) + + # We'll run three separate routers since collision_type is a router-level config + evaluator = CostEvaluator(engine, danger_map) + + # Scenario 1: Standard 'arc' model (High fidelity) + router_arc = AStarRouter(evaluator, bend_collision_type="arc") + netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))} + + # Scenario 2: 'bbox' model (Conservative axis-aligned box) + router_bbox = AStarRouter(evaluator, bend_collision_type="bbox") + netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} + + # Scenario 3: 'clipped_bbox' model (Balanced) + router_clipped = AStarRouter(evaluator, bend_collision_type="clipped_bbox", bend_clip_margin=1.0) + netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} + + # 2. Route each scenario + print("Routing Scenario 1 (Arc)...") + res_arc = PathFinder(router_arc, evaluator).route_all(netlist_arc, {"arc_model": 2.0}) + + print("Routing Scenario 2 (BBox)...") + res_bbox = PathFinder(router_bbox, evaluator).route_all(netlist_bbox, {"bbox_model": 2.0}) + + print("Routing Scenario 3 (Clipped BBox)...") + res_clipped = PathFinder(router_clipped, evaluator).route_all(netlist_clipped, {"clipped_model": 2.0}) + + # 3. Combine results for visualization + all_results = {**res_arc, **res_bbox, **res_clipped} + all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} + + # 4. Visualize + # Note: plot_routing_results will show the 'collision geometry' used by the router + # since that's what's stored in res.path[i].geometry + fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) + fig.savefig("examples/06_bend_collision_models.png") + print("Saved plot to examples/06_bend_collision_models.png") + + +if __name__ == "__main__": + main() diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 1faf6e2..908a9f6 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,149 +1,141 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import rtree from shapely.geometry import Point, Polygon -from shapely.ops import unary_union from shapely.prepared import prep if TYPE_CHECKING: from shapely.prepared import PreparedGeometry - from inire.geometry.primitives import Port class CollisionEngine: - """Manages spatial queries for collision detection.""" + """Manages spatial queries for collision detection with unified dilation logic.""" def __init__(self, clearance: float, max_net_width: float = 2.0, safety_zone_radius: float = 0.0021) -> None: self.clearance = clearance self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius - self.static_obstacles = rtree.index.Index() - # To store geometries for precise checks - self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon - self.prepared_obstacles: dict[int, PreparedGeometry] = {} # ID -> PreparedGeometry - self._id_counter = 0 + + # Static obstacles: store raw geometries to avoid double-dilation + self.static_index = rtree.index.Index() + self.static_geometries: dict[int, Polygon] = {} # ID -> Polygon + self.static_prepared: dict[int, PreparedGeometry] = {} # ID -> PreparedGeometry + self._static_id_counter = 0 # Dynamic paths for multi-net congestion - self.dynamic_paths = rtree.index.Index() - # obj_id -> (net_id, geometry) - self.path_geometries: dict[int, tuple[str, Polygon]] = {} + self.dynamic_index = rtree.index.Index() + # obj_id -> (net_id, raw_geometry) + self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {} self._dynamic_id_counter = 0 - def add_static_obstacle(self, polygon: Polygon, pre_dilate: bool = True) -> None: - """Add a static obstacle to the engine.""" - _ = pre_dilate # Keep for API compatibility - obj_id = self._id_counter - self._id_counter += 1 + def add_static_obstacle(self, polygon: Polygon) -> None: + """Add a static obstacle (raw geometry) to the engine.""" + obj_id = self._static_id_counter + self._static_id_counter += 1 - self.obstacle_geometries[obj_id] = polygon - self.prepared_obstacles[obj_id] = prep(polygon) - - # Index the bounding box of the original polygon - # We query with dilated moves, so original bounds are enough - self.static_obstacles.insert(obj_id, polygon.bounds) + self.static_geometries[obj_id] = polygon + self.static_prepared[obj_id] = prep(polygon) + self.static_index.insert(obj_id, polygon.bounds) def add_path(self, net_id: str, geometry: list[Polygon]) -> None: - """Add a net's routed path to the dynamic R-Tree.""" - # Dilate by clearance/2 for congestion - dilation = self.clearance / 2.0 + """Add a net's routed path (raw geometry) to the dynamic index.""" for poly in geometry: - dilated = poly.buffer(dilation) obj_id = self._dynamic_id_counter self._dynamic_id_counter += 1 - self.path_geometries[obj_id] = (net_id, dilated) - self.dynamic_paths.insert(obj_id, dilated.bounds) + self.dynamic_geometries[obj_id] = (net_id, poly) + self.dynamic_index.insert(obj_id, poly.bounds) def remove_path(self, net_id: str) -> None: - """Remove a net's path from the dynamic R-Tree.""" - to_remove = [obj_id for obj_id, (nid, _) in self.path_geometries.items() if nid == net_id] + """Remove a net's path from the dynamic index.""" + to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] for obj_id in to_remove: - nid, dilated = self.path_geometries.pop(obj_id) - self.dynamic_paths.delete(obj_id, dilated.bounds) + nid, poly = self.dynamic_geometries.pop(obj_id) + self.dynamic_index.delete(obj_id, poly.bounds) def lock_net(self, net_id: str) -> None: """Move a net's dynamic path to static obstacles permanently.""" - to_move = [obj_id for obj_id, (nid, _) in self.path_geometries.items() if nid == net_id] + to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] for obj_id in to_move: - nid, dilated = self.path_geometries.pop(obj_id) - self.dynamic_paths.delete(obj_id, dilated.bounds) - - # Add to static (already dilated for clearance) - new_static_id = self._id_counter - self._id_counter += 1 - self.obstacle_geometries[new_static_id] = dilated - self.prepared_obstacles[new_static_id] = prep(dilated) - self.static_obstacles.insert(new_static_id, dilated.bounds) - - def count_congestion(self, geometry: Polygon, net_id: str) -> int: - """Count how many other nets collide with this geometry.""" - dilation = self.clearance / 2.0 - test_poly = geometry.buffer(dilation) - return self.count_congestion_prebuffered(test_poly, net_id) - - def count_congestion_prebuffered(self, dilated_geometry: Polygon, net_id: str) -> int: - """Count how many other nets collide with this pre-dilated geometry.""" - candidates = self.dynamic_paths.intersection(dilated_geometry.bounds) - count = 0 - for obj_id in candidates: - other_net_id, other_poly = self.path_geometries[obj_id] - if other_net_id != net_id and dilated_geometry.intersects(other_poly): - count += 1 - return count + nid, poly = self.dynamic_geometries.pop(obj_id) + self.dynamic_index.delete(obj_id, poly.bounds) + self.add_static_obstacle(poly) def is_collision( - self, - geometry: Polygon, - net_width: float, - start_port: Port | None = None, - end_port: Port | None = None, + self, + geometry: Polygon, + net_width: float = 2.0, + start_port: Port | None = None, + end_port: Port | None = None ) -> bool: - """Check if a geometry (e.g. a Move) collides with static obstacles.""" - _ = net_width # Width is already integrated into engine dilation settings - dilation = self.clearance / 2.0 - test_poly = geometry.buffer(dilation) - return self.is_collision_prebuffered(test_poly, start_port=start_port, end_port=end_port) + """Alias for check_collision(buffer_mode='static') for backward compatibility.""" + _ = net_width + res = self.check_collision(geometry, "default", buffer_mode="static", start_port=start_port, end_port=end_port) + return bool(res) - def is_collision_prebuffered( - self, - dilated_geometry: Polygon, + def count_congestion(self, geometry: Polygon, net_id: str) -> int: + """Alias for check_collision(buffer_mode='congestion') for backward compatibility.""" + res = self.check_collision(geometry, net_id, buffer_mode="congestion") + return int(res) + + def check_collision( + self, + geometry: Polygon, + net_id: str, + buffer_mode: Literal["static", "congestion"] = "static", start_port: Port | None = None, - end_port: Port | None = None, - ) -> bool: - """Check if a pre-dilated geometry collides with static obstacles.""" - # Query R-Tree using the bounds of the dilated move - candidates = self.static_obstacles.intersection(dilated_geometry.bounds) + end_port: Port | None = None + ) -> bool | int: + """ + Check for collisions using unified dilation logic. + + If buffer_mode == "static": + Returns True if geometry collides with static obstacles (buffered by full clearance). + If buffer_mode == "congestion": + Returns count of other nets colliding with geometry (both buffered by clearance/2). + """ + if buffer_mode == "static": + # Buffered move vs raw static obstacle + # Distance must be >= clearance + test_poly = geometry.buffer(self.clearance) + candidates = self.static_index.intersection(test_poly.bounds) + + for obj_id in candidates: + if self.static_prepared[obj_id].intersects(test_poly): + # Safety zone check (using exact intersection area/bounds) + if start_port or end_port: + intersection = test_poly.intersection(self.static_geometries[obj_id]) + if intersection.is_empty: + continue + + ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds + + is_safe = False + for p in [start_port, end_port]: + if p and (abs(ix_minx - p.x) < self.safety_zone_radius and + abs(ix_maxx - p.x) < self.safety_zone_radius and + abs(ix_miny - p.y) < self.safety_zone_radius and + abs(ix_maxy - p.y) < self.safety_zone_radius): + is_safe = True + break + if is_safe: + continue + return True + return False - for obj_id in candidates: - # Use prepared geometry for fast intersection - if self.prepared_obstacles[obj_id].intersects(dilated_geometry): - # Check safety zone (2nm radius) - if start_port or end_port: - obstacle = self.obstacle_geometries[obj_id] - intersection = dilated_geometry.intersection(obstacle) - - if intersection.is_empty: - continue - - # Precise check: is every point in the intersection close to either port? - ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds - - is_near_start = False - if start_port: - if (abs(ix_minx - start_port.x) < self.safety_zone_radius and abs(ix_maxx - start_port.x) < self.safety_zone_radius and - abs(ix_miny - start_port.y) < self.safety_zone_radius and abs(ix_maxy - start_port.y) < self.safety_zone_radius): - is_near_start = True - - is_near_end = False - if end_port: - if (abs(ix_minx - end_port.x) < self.safety_zone_radius and abs(ix_maxx - end_port.x) < self.safety_zone_radius and - abs(ix_miny - end_port.y) < self.safety_zone_radius and abs(ix_maxy - end_port.y) < self.safety_zone_radius): - is_near_end = True - - if is_near_start or is_near_end: - continue - - return True - return False + else: # buffer_mode == "congestion" + # Both paths buffered by clearance/2 => Total separation = clearance + dilation = self.clearance / 2.0 + test_poly = 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: + # Buffer the other path segment too + if test_poly.intersects(other_poly.buffer(dilation)): + count += 1 + return count diff --git a/inire/geometry/components.py b/inire/geometry/components.py index da238be..82df9b5 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import NamedTuple, Literal, Union +from typing import NamedTuple, Literal, Any import numpy as np from shapely.geometry import Polygon, box @@ -35,7 +35,7 @@ class Straight: ex = start_port.x + dx ey = start_port.y + dy - + if snap_to_grid: ex = snap_search_grid(ex) ey = snap_search_grid(ey) @@ -84,39 +84,83 @@ def _get_arc_polygons(cx: float, cy: float, radius: float, width: float, t_start def _apply_collision_model( - arc_poly: Polygon, + arc_poly: Polygon, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, radius: float, + width: float, + cx: float = 0.0, + cy: float = 0.0, clip_margin: float = 10.0 ) -> list[Polygon]: """Applies the specified collision model to an arc geometry.""" if isinstance(collision_type, Polygon): return [collision_type] - + if collision_type == "arc": return [arc_poly] - + # Get bounding box minx, miny, maxx, maxy = arc_poly.bounds bbox = box(minx, miny, maxx, maxy) - + if collision_type == "bbox": return [bbox] - + if collision_type == "clipped_bbox": - safe_zone = arc_poly.buffer(clip_margin) - return [bbox.intersection(safe_zone)] - + res_poly = bbox + + # Determine quadrant signs from arc centroid relative to center + # This ensures we always cut 'into' the box correctly + ac = arc_poly.centroid + sx = 1.0 if ac.x >= cx else -1.0 + sy = 1.0 if ac.y >= cy else -1.0 + + r_out_cut = radius + width / 2.0 + clip_margin + r_in_cut = radius - width / 2.0 - clip_margin + + corners = [(minx, miny), (minx, maxy), (maxx, miny), (maxx, maxy)] + for px, py in corners: + dx, dy = px - cx, py - cy + dist = np.sqrt(dx**2 + dy**2) + + if dist > r_out_cut: + # Outer corner: remove part furthest from center + # We want minimum distance to line to be r_out_cut + d_cut = r_out_cut * np.sqrt(2) + elif r_in_cut > 0 and dist < r_in_cut: + # Inner corner: remove part closest to center + # We want maximum distance to line to be r_in_cut + d_cut = r_in_cut + else: + continue + + # The cut line is sx*(x-cx) + sy*(y-cy) = d_cut + # sx*x + sy*y = sx*cx + sy*cy + d_cut + val = cx * sx + cy * sy + d_cut + + try: + p1 = (px, py) + p2 = (px, (val - sx * px) / sy) + p3 = ((val - sy * py) / sx, py) + + triangle = Polygon([p1, p2, p3]) + if triangle.is_valid and triangle.area > 1e-9: + res_poly = res_poly.difference(triangle) + except ZeroDivisionError: + continue + + return [res_poly] + return [arc_poly] class Bend90: @staticmethod def generate( - start_port: Port, - radius: float, - width: float, - direction: str = "CW", + start_port: Port, + radius: float, + width: float, + direction: str = "CW", sagitta: float = 0.01, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", clip_margin: float = 10.0 @@ -133,9 +177,11 @@ class Bend90: ex = snap_search_grid(cx + radius * np.cos(t_end)) ey = snap_search_grid(cy + radius * np.sin(t_end)) end_port = Port(ex, ey, float((start_port.orientation + turn_angle) % 360)) - + arc_polys = _get_arc_polygons(cx, cy, radius, width, t_start, t_end, sagitta) - collision_polys = _apply_collision_model(arc_polys[0], collision_type, radius, clip_margin) + collision_polys = _apply_collision_model( + arc_polys[0], collision_type, radius, width, cx, cy, clip_margin + ) return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0) @@ -143,10 +189,10 @@ class Bend90: class SBend: @staticmethod def generate( - start_port: Port, - offset: float, - radius: float, - width: float, + start_port: Port, + offset: float, + radius: float, + width: float, sagitta: float = 0.01, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", clip_margin: float = 10.0 @@ -162,7 +208,7 @@ class SBend: ex = snap_search_grid(start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)) ey = snap_search_grid(start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)) end_port = Port(ex, ey, start_port.orientation) - + direction = 1 if offset > 0 else -1 c1_angle = rad_start + direction * np.pi / 2 cx1 = start_port.x + radius * np.cos(c1_angle) @@ -180,6 +226,14 @@ class SBend: arc1 = _get_arc_polygons(cx1, cy1, radius, width, ts1, te1, sagitta)[0] arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0] combined_arc = unary_union([arc1, arc2]) - - collision_polys = _apply_collision_model(combined_arc, collision_type, radius, clip_margin) + + 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 = [unary_union(col1 + col2)] + else: + collision_polys = _apply_collision_model( + combined_arc, collision_type, radius, width, 0, 0, clip_margin + ) + return ComponentResult(geometry=collision_polys, end_port=end_port, length=2 * radius * theta) diff --git a/inire/router/astar.py b/inire/router/astar.py index f77c044..93df12e 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -189,7 +189,7 @@ class AStarRouter: lengths = self.config.straight_lengths if dist < 5.0: fine_steps = [0.1, 0.5] - lengths = sorted(list(set(lengths + fine_steps))) + lengths = sorted(set(lengths + fine_steps)) for length in lengths: res = Straight.generate(current.port, length, net_width) @@ -255,7 +255,9 @@ class AStarRouter: else: hard_coll = False for poly in result.geometry: - if self.cost_evaluator.collision_engine.is_collision(poly, net_width, start_port=parent.port, end_port=result.end_port): + if self.cost_evaluator.collision_engine.check_collision( + poly, net_id, buffer_mode="static", start_port=parent.port, end_port=result.end_port + ): hard_coll = True break self._collision_cache[cache_key] = hard_coll @@ -300,8 +302,6 @@ class AStarRouter: # Turn penalties scaled by radius to favor larger turns ref_radius = 10.0 if "B" in move_type and move_radius is not None: - # Scale penalty: larger radius -> smaller penalty - # e.g. radius 10 -> factor 1.0, radius 30 -> factor 0.33 penalty_factor = ref_radius / move_radius move_cost += self.config.bend_penalty * penalty_factor elif "SB" in move_type and move_radius is not None: diff --git a/inire/router/config.py b/inire/router/config.py index b9af31f..0a9e115 100644 --- a/inire/router/config.py +++ b/inire/router/config.py @@ -1,10 +1,8 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal, TYPE_CHECKING, Any +from typing import Literal, Any -if TYPE_CHECKING: - from shapely.geometry import Polygon @dataclass diff --git a/inire/router/cost.py b/inire/router/cost.py index 17a18a3..996e135 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -40,7 +40,7 @@ class CostEvaluator: greedy_h_weight=greedy_h_weight, congestion_penalty=congestion_penalty, ) - + # Use config values self.unit_length_cost = self.config.unit_length_cost self.greedy_h_weight = self.config.greedy_h_weight @@ -73,25 +73,24 @@ class CostEvaluator: """Calculate the cost of a single move (Straight, Bend, SBend).""" _ = net_width # Unused total_cost = length * self.unit_length_cost - - # 1. Hard Collision check (Static obstacles) - # We buffer by the full clearance to ensure distance >= clearance - hard_dilation = self.collision_engine.clearance - for poly in geometry: - dilated_poly = poly.buffer(hard_dilation) - if self.collision_engine.is_collision_prebuffered(dilated_poly, start_port=start_port, end_port=end_port): - return 1e15 # Impossible cost for hard collisions - # 2. Soft Collision check (Negotiated Congestion) - # We buffer by clearance/2 because both paths are buffered by clearance/2 - soft_dilation = self.collision_engine.clearance / 2.0 + # 1. Boundary Check (Centerline based for compatibility) + if not self.danger_map.is_within_bounds(end_port.x, end_port.y): + return 1e15 + + # 2. Collision Check for poly in geometry: - dilated_poly = poly.buffer(soft_dilation) - overlaps = self.collision_engine.count_congestion_prebuffered(dilated_poly, net_id) - if overlaps > 0: + # Hard Collision (Static obstacles) + if self.collision_engine.check_collision( + poly, net_id, buffer_mode="static", start_port=start_port, end_port=end_port + ): + return 1e15 + + # Soft Collision (Negotiated Congestion) + overlaps = self.collision_engine.check_collision(poly, net_id, buffer_mode="congestion") + if isinstance(overlaps, int) and overlaps > 0: total_cost += overlaps * self.congestion_penalty # 3. Proximity cost from Danger Map total_cost += self.g_proximity(end_port.x, end_port.y) return total_cost - diff --git a/inire/router/danger_map.py b/inire/router/danger_map.py index 1ebf617..209edf0 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -3,13 +3,14 @@ from __future__ import annotations from typing import TYPE_CHECKING import numpy as np +import shapely if TYPE_CHECKING: from shapely.geometry import Polygon class DangerMap: - """A pre-computed grid for heuristic proximity costs.""" + """A pre-computed grid for heuristic proximity costs, vectorized for performance.""" def __init__( self, @@ -28,47 +29,36 @@ class DangerMap: self.width_cells = int(np.ceil((self.maxx - self.minx) / self.resolution)) self.height_cells = int(np.ceil((self.maxy - self.miny) / self.resolution)) - # Use uint8 for memory efficiency if normalized, or float16/float32. - # Let's use float32 for simplicity and precision in the prototype. - # For a 1000x1000 grid, this is only 4MB. - # For 20000x20000, it's 1.6GB. self.grid = np.zeros((self.width_cells, self.height_cells), dtype=np.float32) def precompute(self, obstacles: list[Polygon]) -> None: - """Pre-compute the proximity costs for the entire grid.""" - # For each cell, find distance to nearest obstacle. - # This is a distance transform problem. - # For the prototype, we can use a simpler approach or scipy.ndimage.distance_transform_edt. + """Pre-compute the proximity costs for the entire grid using vectorized operations.""" from scipy.ndimage import distance_transform_edt - # Create a binary mask of obstacles + # 1. Create a binary mask of obstacles mask = np.ones((self.width_cells, self.height_cells), dtype=bool) - # Rasterize obstacles (simplified: mark cells whose center is inside an obstacle) - # This is slow for many obstacles; in a real engine, we'd use a faster rasterizer. - from shapely.geometry import Point + + # Create coordinate grids + x_coords = np.linspace(self.minx + self.resolution/2, self.maxx - self.resolution/2, self.width_cells) + y_coords = np.linspace(self.miny + self.resolution/2, self.maxy - self.resolution/2, self.height_cells) + xv, yv = np.meshgrid(x_coords, y_coords, indexing='ij') for poly in obstacles: - # Get bounding box in grid coordinates - p_minx, p_miny, p_maxx, p_maxy = poly.bounds - x_start = max(0, int((p_minx - self.minx) / self.resolution)) - x_end = min(self.width_cells, int((p_maxx - self.minx) / self.resolution) + 1) - y_start = max(0, int((p_miny - self.miny) / self.resolution)) - y_end = min(self.height_cells, int((p_maxy - self.miny) / self.resolution) + 1) + # Use shapely.contains_xy for fast vectorized point-in-polygon check + in_poly = shapely.contains_xy(poly, xv, yv) + mask[in_poly] = False - for ix in range(x_start, x_end): - cx = self.minx + (ix + 0.5) * self.resolution - for iy in range(y_start, y_end): - cy = self.miny + (iy + 0.5) * self.resolution - if poly.contains(Point(cx, cy)): - mask[ix, iy] = False - - # Distance transform (mask=True for empty space) + # 2. Distance transform (mask=True for empty space) distances = distance_transform_edt(mask) * self.resolution - # Proximity cost: k / d^2 if d < threshold, else 0 - # To avoid division by zero, we cap distances at a small epsilon (e.g. 0.1um) + # 3. Proximity cost: k / d^2 if d < threshold, else 0 + # Cap distances at a small epsilon (e.g. 0.1um) to avoid division by zero safe_distances = np.maximum(distances, 0.1) - self.grid = np.where(distances < self.safety_threshold, self.k / (safe_distances**2), 0.0).astype(np.float32) + self.grid = np.where( + distances < self.safety_threshold, + self.k / (safe_distances**2), + 0.0 + ).astype(np.float32) def is_within_bounds(self, x: float, y: float) -> bool: """Check if a coordinate is within the design bounds.""" diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index a61993a..19ef9fd 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -79,7 +79,7 @@ class PathFinder: logger.debug(f" Net {net_id} routed in {time.monotonic() - net_start:.4f}s") if path: - # 3. Add to R-Tree + # 3. Add to index all_geoms = [] for res in path: all_geoms.extend(res.geometry) @@ -88,7 +88,11 @@ class PathFinder: # Check if this new path has any congestion collision_count = 0 for poly in all_geoms: - collision_count += self.cost_evaluator.collision_engine.count_congestion(poly, net_id) + overlaps = self.cost_evaluator.collision_engine.check_collision( + poly, net_id, buffer_mode="congestion" + ) + if isinstance(overlaps, int): + collision_count += overlaps if collision_count > 0: any_congestion = True @@ -120,9 +124,12 @@ class PathFinder: collision_count = 0 for comp in res.path: for poly in comp.geometry: - collision_count += self.cost_evaluator.collision_engine.count_congestion(poly, net_id) + overlaps = self.cost_evaluator.collision_engine.check_collision( + poly, net_id, buffer_mode="congestion" + ) + if isinstance(overlaps, int): + collision_count += overlaps final_results[net_id] = RoutingResult(net_id, res.path, collision_count == 0, collision_count) return final_results - diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 37da884..3ce6b18 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,6 +1,4 @@ -import numpy as np import pytest -from shapely.geometry import Point from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.primitives import Port, rotate_port, translate_port @@ -66,7 +64,7 @@ def test_bend_collision_models() -> None: # 1. BBox model res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox") - # Arc CCW R=10 from (0,0,0) ends at (10,10,90). + # Arc CCW R=10 from (0,0,0) ends at (10,10,90). # Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10) minx, miny, maxx, maxy = res_bbox.geometry[0].bounds assert minx <= 0.0 + 1e-6 @@ -89,7 +87,7 @@ def test_sbend_collision_models() -> None: 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 - + res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") assert res_bbox.geometry[0].area > res_arc.geometry[0].area @@ -100,15 +98,15 @@ def test_sbend_continuity() -> None: offset = 4.0 radius = 20.0 width = 1.0 - + res = SBend.generate(start, offset, radius, width) - + # Target orientation should be same as start assert abs(res.end_port.orientation - 90.0) < 1e-6 - + # 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 @@ -119,17 +117,17 @@ def test_arc_sagitta_precision() -> None: start = Port(0, 0, 0) radius = 100.0 # Large radius to make sagitta significant width = 2.0 - + # Coarse: 1um sagitta res_coarse = Bend90.generate(start, radius, width, sagitta=1.0) # Fine: 0.01um (10nm) sagitta res_fine = Bend90.generate(start, radius, width, sagitta=0.01) - + # Number of segments should be significantly higher for fine # Exterior points = (segments + 1) * 2 pts_coarse = len(res_coarse.geometry[0].exterior.coords) pts_fine = len(res_fine.geometry[0].exterior.coords) - + assert pts_fine > pts_coarse * 2 @@ -139,20 +137,20 @@ def test_component_transform_invariance() -> None: start0 = Port(0, 0, 0) radius = 10.0 width = 2.0 - + res0 = Bend90.generate(start0, radius, width, direction="CCW") - + # Transform: Translate (10, 10) then Rotate 90 dx, dy = 10.0, 5.0 angle = 90.0 - + # 1. Transform the generated geometry p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) - + # 2. Generate at transformed start start_transformed = rotate_port(translate_port(start0, dx, dy), angle) res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW") - + assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6 assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6 assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 diff --git a/inire/utils/validation.py b/inire/utils/validation.py index 6dd8986..662fd81 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -3,8 +3,7 @@ from __future__ import annotations import numpy as np from typing import TYPE_CHECKING, Any -from shapely.geometry import Point, Polygon -from shapely.ops import unary_union +from shapely.geometry import Polygon if TYPE_CHECKING: from inire.geometry.primitives import Port @@ -28,7 +27,7 @@ def validate_routing_result( obstacle_collision_geoms = [] self_intersection_geoms = [] connectivity_errors = [] - + # 1. Connectivity Check total_length = 0.0 for i, comp in enumerate(result.path): @@ -38,7 +37,7 @@ def validate_routing_result( if expected_end: last_port = result.path[-1].end_port dist_to_end = np.sqrt((last_port.x - expected_end.x)**2 + (last_port.y - expected_end.y)**2) - if dist_to_end > 0.005: + if dist_to_end > 0.005: connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") if abs(last_port.orientation - expected_end.orientation) > 0.1: connectivity_errors.append(f"Final port orientation mismatch: {last_port.orientation} vs {expected_end.orientation}") @@ -46,9 +45,9 @@ def validate_routing_result( # 2. Geometry Buffering dilation_half = clearance / 2.0 dilation_full = clearance - + dilated_for_self = [] - + for i, comp in enumerate(result.path): for poly in comp.geometry: # Check against obstacles @@ -58,7 +57,7 @@ def validate_routing_result( intersection = d_full.intersection(obs) if intersection.area > 1e-9: obstacle_collision_geoms.append(intersection) - + # Save for self-intersection check dilated_for_self.append(poly.buffer(dilation_half)) @@ -68,13 +67,13 @@ def validate_routing_result( if j > i + 1: # Non-adjacent if seg_i.intersects(seg_j): overlap = seg_i.intersection(seg_j) - if overlap.area > 1e-6: + if overlap.area > 1e-6: self_intersection_geoms.append((i, j, overlap)) - is_valid = (len(obstacle_collision_geoms) == 0 and - len(self_intersection_geoms) == 0 and + is_valid = (len(obstacle_collision_geoms) == 0 and + len(self_intersection_geoms) == 0 and len(connectivity_errors) == 0) - + reasons = [] if obstacle_collision_geoms: reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.")