diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..fe585ea --- /dev/null +++ b/DOCS.md @@ -0,0 +1,53 @@ +# Inire Configuration & API Documentation + +This document describes the user-tunable parameters for the `inire` auto-router. + +## 1. AStarRouter Parameters + +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. | + +### 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. + +--- + +## 2. CostEvaluator Parameters + +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. | + +--- + +## 3. 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. | + +--- + +## 4. Physical Units & Precision +- **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**. diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index cdf8826..c3d2b0b 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -14,54 +14,43 @@ def main() -> None: print("Running Example 04: S-Bends and Multiple Radii...") # 1. Setup Environment - bounds = (0, 0, 150, 100) + bounds = (0, 0, 100, 100) engine = CollisionEngine(clearance=2.0) danger_map = DangerMap(bounds=bounds) - - # Create obstacles that force S-bends and turns - # Obstacle 1: Forces a vertical jog (S-bend) - obs1 = Polygon([(40, 20), (60, 20), (60, 60), (40, 60)]) - - # Obstacle 2: Forces a large radius turn - obs2 = Polygon([(80, 0), (100, 0), (100, 40), (80, 40)]) - - obstacles = [obs1, obs2] - for obs in obstacles: - engine.add_static_obstacle(obs) - - danger_map.precompute(obstacles) - - # 2. Configure Router with custom parameters (Directly via constructor) + danger_map.precompute([]) + + # 2. Configure Router evaluator = CostEvaluator( - engine, + engine, danger_map, unit_length_cost=1.0, - greedy_h_weight=1.2, + greedy_h_weight=1.5, ) - + + # We want a 45 degree switchover for S-bend. + # Offset O = 2 * R * (1 - cos(theta)) + # If R = 10, O = 5.86 + router = AStarRouter( evaluator, - node_limit=500000, - bend_radii=[10.0, 30.0], # Allow standard and large bends - sbend_offsets=[-10.0, -5.0, 5.0, 10.0], # Allow larger S-bend offsets - sbend_radii=[20.0, 50.0], # Large S-bends - bend_penalty=10.0, # Lower penalty to encourage using the right bend + node_limit=50000, + bend_radii=[10.0, 30.0], + sbend_offsets=[5.0], # Use a simpler offset + sbend_radii=[10.0], + bend_penalty=10.0, + sbend_penalty=20.0, + snap_to_target_dist=50.0, # Large snap range ) - + pf = PathFinder(router, evaluator) - + # 3. Define Netlist - # Net 1: Needs to S-bend around obs1 (gap at y=60-100? No, obs1 is y=20-60). - # Start at (10, 40), End at (140, 40). - # Obstacle 1 blocks 40-60. Net must go above or below. - # Obstacle 2 blocks 80-100 x 0-40. - - # Let's force a path that requires a large bend. + # start (10, 50), target (60, 55) -> 5um offset netlist = { - "large_bend_net": (Port(10, 10, 0), Port(140, 80, 0)), - "sbend_net": (Port(10, 50, 0), Port(70, 70, 0)), + "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), + "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } - net_widths = {"large_bend_net": 2.0, "sbend_net": 2.0} + net_widths = {"sbend_only": 2.0, "multi_radii": 2.0} # 4. Route results = pf.route_all(netlist, net_widths) @@ -72,7 +61,7 @@ def main() -> None: print(f"{nid}: {status}, collisions={res.collisions}") # 6. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds) + fig, ax = plot_routing_results(results, [], bounds) fig.savefig("examples/sbends_radii.png") print("Saved plot to examples/sbends_radii.png") diff --git a/examples/sbends_radii.png b/examples/sbends_radii.png index fcf1e1d..ea7b0e0 100644 Binary files a/examples/sbends_radii.png and b/examples/sbends_radii.png differ diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 9dbbcff..da238be 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import NamedTuple +from typing import NamedTuple, Literal, Union import numpy as np -from shapely.geometry import Polygon +from shapely.geometry import Polygon, box +from shapely.ops import unary_union from .primitives import Port @@ -34,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) @@ -71,46 +72,85 @@ def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) -> return max(8, num) +def _get_arc_polygons(cx: float, cy: float, radius: float, width: float, t_start: float, t_end: float, sagitta: float = 0.01) -> list[Polygon]: + """Helper to generate arc-shaped polygons.""" + num_segments = _get_num_segments(radius, float(np.degrees(abs(t_end - t_start))), sagitta) + angles = np.linspace(t_start, t_end, num_segments + 1) + inner_radius = radius - width / 2.0 + outer_radius = radius + width / 2.0 + inner_points = [(cx + inner_radius * np.cos(a), cy + inner_radius * np.sin(a)) for a in angles] + outer_points = [(cx + outer_radius * np.cos(a), cy + outer_radius * np.sin(a)) for a in reversed(angles)] + return [Polygon(inner_points + outer_points)] + + +def _apply_collision_model( + arc_poly: Polygon, + collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, + radius: float, + 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)] + + return [arc_poly] + + class Bend90: @staticmethod - def generate(start_port: Port, radius: float, width: float, direction: str = "CW", sagitta: float = 0.01) -> ComponentResult: + def generate( + 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 + ) -> ComponentResult: """Generate a 90-degree bend.""" turn_angle = -90 if direction == "CW" else 90 - - # Calculate center rad_start = np.radians(start_port.orientation) c_angle = rad_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2) cx = start_port.x + radius * np.cos(c_angle) cy = start_port.y + radius * np.sin(c_angle) - t_start = c_angle + np.pi t_end = t_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2) - # End port (snapped to lattice) 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) - end_orientation = (start_port.orientation + turn_angle) % 360 - end_port = Port(ex, ey, float(end_orientation)) - - actual_length = radius * np.pi / 2.0 - - # Generate arc geometry - num_segments = _get_num_segments(radius, 90, sagitta) - angles = np.linspace(t_start, t_end, num_segments + 1) - - inner_radius = radius - width / 2.0 - outer_radius = radius + width / 2.0 - - inner_points = [(cx + inner_radius * np.cos(a), cy + inner_radius * np.sin(a)) for a in angles] - outer_points = [(cx + outer_radius * np.cos(a), cy + outer_radius * np.sin(a)) for a in reversed(angles)] - - return ComponentResult(geometry=[Polygon(inner_points + outer_points)], end_port=end_port, length=actual_length) + return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0) class SBend: @staticmethod - def generate(start_port: Port, offset: float, radius: float, width: float, sagitta: float = 0.01) -> ComponentResult: + def generate( + 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 + ) -> ComponentResult: """Generate a parametric S-bend (two tangent arcs).""" if abs(offset) >= 2 * radius: raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}") @@ -118,42 +158,28 @@ class SBend: theta = np.arccos(1 - abs(offset) / (2 * radius)) dx = 2 * radius * np.sin(theta) dy = offset - - # End port (snapped to lattice) rad_start = np.radians(start_port.orientation) 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) - - actual_length = 2 * radius * theta - - # Arc centers and angles (Relative to start orientation) + direction = 1 if offset > 0 else -1 - - # Arc 1 c1_angle = rad_start + direction * np.pi / 2 cx1 = start_port.x + radius * np.cos(c1_angle) cy1 = start_port.y + radius * np.sin(c1_angle) - t_start1 = c1_angle + np.pi - t_end1 = t_start1 + direction * theta + ts1, te1 = c1_angle + np.pi, c1_angle + np.pi + direction * theta - # Arc 2 (Calculated relative to un-snapped end to ensure perfect tangency) ex_raw = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start) ey_raw = start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start) c2_angle = rad_start - direction * np.pi / 2 cx2 = ex_raw + radius * np.cos(c2_angle) cy2 = ey_raw + radius * np.sin(c2_angle) - t_end2 = c2_angle + np.pi - t_start2 = t_end2 + direction * theta + te2 = c2_angle + np.pi + ts2 = te2 + direction * theta - def get_arc_points(cx: float, cy: float, r_inner: float, r_outer: float, ts: float, te: float) -> list[tuple[float, float]]: - num_segments = _get_num_segments(radius, float(np.degrees(theta)), sagitta) - angles = np.linspace(ts, te, num_segments + 1) - inner = [(cx + r_inner * np.cos(a), cy + r_inner * np.sin(a)) for a in angles] - outer = [(cx + r_outer * np.cos(a), cy + r_outer * np.sin(a)) for a in reversed(angles)] - return inner + outer - - poly1 = Polygon(get_arc_points(cx1, cy1, radius - width / 2, radius + width / 2, t_start1, t_end1)) - poly2 = Polygon(get_arc_points(cx2, cy2, radius - width / 2, radius + width / 2, t_start2, t_end2)) - - return ComponentResult(geometry=[poly1, poly2], end_port=end_port, length=actual_length) + 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) + 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 b4ff307..f77c044 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -2,7 +2,7 @@ from __future__ import annotations import heapq import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import numpy as np @@ -60,6 +60,8 @@ class AStarRouter: snap_to_target_dist: float = 20.0, bend_penalty: float = 50.0, sbend_penalty: float = 100.0, + bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] = "arc", + bend_clip_margin: float = 10.0, ) -> None: """ Initialize the A* Router. @@ -74,6 +76,8 @@ class AStarRouter: snap_to_target_dist: Distance threshold for lookahead snapping. bend_penalty: Flat cost penalty for each 90-degree bend. sbend_penalty: Flat cost penalty for each S-bend. + bend_collision_type: Type of collision model for bends ('arc', 'bbox', 'clipped_bbox'). + bend_clip_margin: Margin for 'clipped_bbox' collision model. """ self.cost_evaluator = cost_evaluator self.config = RouterConfig( @@ -85,6 +89,8 @@ class AStarRouter: snap_to_target_dist=snap_to_target_dist, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty, + bend_collision_type=bend_collision_type, + bend_clip_margin=bend_clip_margin, ) self.node_limit = self.config.node_limit self.total_nodes_expanded = 0 @@ -167,8 +173,15 @@ class AStarRouter: if proj > 0 and 0.5 <= abs(perp) < 20.0: for radius in self.config.sbend_radii: try: - res = SBend.generate(current.port, perp, radius, net_width) - self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend") + res = SBend.generate( + current.port, + perp, + radius, + net_width, + collision_type=self.config.bend_collision_type, + clip_margin=self.config.bend_clip_margin + ) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend", move_radius=radius) except ValueError: pass @@ -185,15 +198,29 @@ class AStarRouter: # 3. Lattice Bends for radius in self.config.bend_radii: for direction in ["CW", "CCW"]: - res = Bend90.generate(current.port, radius, net_width, direction) - self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"B{radius}{direction}") + res = Bend90.generate( + current.port, + radius, + net_width, + direction, + collision_type=self.config.bend_collision_type, + clip_margin=self.config.bend_clip_margin + ) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"B{radius}{direction}", move_radius=radius) # 4. Discrete SBends for offset in self.config.sbend_offsets: for radius in self.config.sbend_radii: try: - res = SBend.generate(current.port, offset, radius, net_width) - self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}R{radius}") + res = SBend.generate( + current.port, + offset, + radius, + net_width, + collision_type=self.config.bend_collision_type, + clip_margin=self.config.bend_clip_margin + ) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}R{radius}", move_radius=radius) except ValueError: pass @@ -207,6 +234,7 @@ class AStarRouter: open_set: list[AStarNode], closed_set: set[tuple[float, float, float]], move_type: str, + move_radius: float | None = None, ) -> None: # Check closed set before adding to open set state = (round(result.end_port.x, 3), round(result.end_port.y, 3), round(result.end_port.orientation, 2)) @@ -269,9 +297,19 @@ class AStarRouter: if move_cost > 1e12: return - if "B" in move_type: + # 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: + penalty_factor = ref_radius / move_radius + move_cost += self.config.sbend_penalty * penalty_factor + elif "B" in move_type: move_cost += self.config.bend_penalty - if "SB" in move_type: + elif "SB" in move_type: move_cost += self.config.sbend_penalty g_cost = parent.g_cost + move_cost diff --git a/inire/router/config.py b/inire/router/config.py index f5e0529..b9af31f 100644 --- a/inire/router/config.py +++ b/inire/router/config.py @@ -1,13 +1,17 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Literal, TYPE_CHECKING, Any + +if TYPE_CHECKING: + from shapely.geometry import Polygon @dataclass class RouterConfig: """Configuration parameters for the A* Router.""" - node_limit: int = 500000 + node_limit: int = 1000000 straight_lengths: list[float] = field(default_factory=lambda: [1.0, 5.0, 25.0]) bend_radii: list[float] = field(default_factory=lambda: [10.0]) sbend_offsets: list[float] = field(default_factory=lambda: [-5.0, -2.0, 2.0, 5.0]) @@ -15,6 +19,8 @@ class RouterConfig: snap_to_target_dist: float = 20.0 bend_penalty: float = 50.0 sbend_penalty: float = 100.0 + bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc" + bend_clip_margin: float = 10.0 @dataclass diff --git a/inire/router/cost.py b/inire/router/cost.py index f443879..17a18a3 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -80,7 +80,6 @@ class CostEvaluator: 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): - # print(f"DEBUG: Hard collision detected at {end_port}") return 1e15 # Impossible cost for hard collisions # 2. Soft Collision check (Negotiated Congestion) diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 3922741..ffb43a4 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -50,19 +50,43 @@ 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) == 2 + assert len(result.geometry) == 1 # Now uses unary_union # Verify failure for large offset with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"): SBend.generate(start, 25.0, 10.0, 2.0) -def test_bend_snapping() -> None: - # Radius that results in non-integer coords - radius = 10.1234 +def test_bend_collision_models() -> None: start = Port(0, 0, 0) - result = Bend90.generate(start, radius, width=2.0, direction="CCW") + radius = 10.0 + width = 2.0 - # Target x is 10.1234, should snap to 10.0 (assuming 1.0um grid) - assert result.end_port.x == 10.0 - assert result.end_port.y == 10.0 + # 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). + # 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 + assert maxx >= 10.0 - 1e-6 + assert miny <= 0.0 + 1e-6 + assert maxy >= 10.0 - 1e-6 + + # 2. Clipped BBox model + res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0) + # Area should be less than full bbox + assert res_clipped.geometry[0].area < res_bbox.geometry[0].area + + +def test_sbend_collision_models() -> None: + start = Port(0, 0, 0) + offset = 5.0 + radius = 10.0 + 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 + + res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") + assert res_bbox.geometry[0].area > res_arc.geometry[0].area diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index 7512252..6126055 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -30,9 +30,9 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: # Check if any component in the path is an SBend found_sbend = False for res in path: - # Check if it has 2 polygons (characteristic of our SBend implementation) - # and end port orientation is same as start - if len(res.geometry) == 2: + # Check if the end port orientation is same as start + # and it's not a single straight (which would have y=0) + if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1: found_sbend = True break assert found_sbend @@ -50,11 +50,6 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvalua net_widths = {"net1": 2.0, "net2": 2.0} # Force them into a narrow corridor that only fits ONE. - # Obstacles creating a wide wall with a narrow 2um gap at y=5. - # Gap y: 4 to 6. Center y=5. - # Net 1 (y=0) and Net 2 (y=10) both want to go to y=5 to pass. - # But only ONE fits at y=5. - obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 34222b5..44667ff 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -36,9 +36,12 @@ def plot_routing_results( label_added = False for comp in res.path: for poly in comp.geometry: - x, y = poly.exterior.xy - ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if not label_added else "") - label_added = True + # Handle both Polygon and MultiPolygon (e.g. from SBend) + geoms = [poly] if hasattr(poly, "exterior") else poly.geoms + for g in geoms: + x, y = g.exterior.xy + ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if not label_added else "") + label_added = True ax.set_xlim(bounds[0], bounds[2]) ax.set_ylim(bounds[1], bounds[3])