diff --git a/DOCS.md b/DOCS.md index 0c8a57a..d83b018 100644 --- a/DOCS.md +++ b/DOCS.md @@ -19,6 +19,7 @@ The `AStarContext` stores the configuration and persistent state for the A* sear | `sbend_penalty` | `float` | 500.0 | Flat cost added for every S-bend. | | `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 for clipped models. | +| `visibility_guidance` | `str` | `"tangent_corner"` | Visibility-driven straight candidate mode: `"off"`, `"exact_corner"`, or `"tangent_corner"`. | ## 2. AStarMetrics @@ -86,6 +87,12 @@ If the router produces many small bends instead of a long straight line: 2. Ensure `straight_lengths` includes larger values like `25.0` or `100.0`. 3. Decrease `greedy_h_weight` closer to `1.0`. +### Visibility Guidance +The router can bias straight stop points using static obstacle corners. +- **`"tangent_corner"`**: Default. Proposes straight lengths that set up a clean tangent bend around nearby visible corners. This helps obstacle-dense layouts more than open space. +- **`"exact_corner"`**: Only uses precomputed corner-to-corner visibility when the current search state already lands on an obstacle corner. +- **`"off"`**: Disables visibility-derived straight candidates entirely. + ### Handling Congestion In multi-net designs, if nets are overlapping: 1. Increase `congestion_penalty` in `CostEvaluator`. diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index b33328d..ffe8343 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -17,7 +17,7 @@ def main() -> None: danger_map.precompute([]) # Configure a router with high congestion penalties - evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=50.0, sbend_penalty=150.0) + evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0) context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0]) metrics = AStarMetrics() pf = PathFinder(context, metrics, base_congestion_penalty=1000.0) diff --git a/examples/03_locked_paths.png b/examples/03_locked_paths.png index fddbc02..d767df9 100644 Binary files a/examples/03_locked_paths.png and b/examples/03_locked_paths.png differ diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index af406b3..642c6f3 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -16,7 +16,7 @@ def main() -> None: danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) context = AStarContext(evaluator, bend_radii=[10.0]) metrics = AStarMetrics() pf = PathFinder(context, metrics) diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png index bdb5304..4036d0d 100644 Binary files a/examples/06_bend_collision_models.png and b/examples/06_bend_collision_models.png differ diff --git a/inire/router/astar.py b/inire/router/astar.py index 098bf1b..44e59da 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -2,6 +2,7 @@ from __future__ import annotations import heapq import logging +import math from typing import TYPE_CHECKING, Any, Literal import shapely @@ -9,7 +10,7 @@ import shapely from inire.constants import TOLERANCE_LINEAR from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.primitives import Port -from inire.router.config import RouterConfig +from inire.router.config import RouterConfig, VisibilityGuidanceMode from inire.router.visibility import VisibilityManager if TYPE_CHECKING: @@ -98,6 +99,7 @@ class AStarContext: sbend_penalty: float | None = None, bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc", bend_clip_margin: float = 10.0, + visibility_guidance: VisibilityGuidanceMode = "tangent_corner", max_cache_size: int = 1000000, ) -> None: actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty @@ -114,6 +116,7 @@ class AStarContext: sbend_penalty=actual_sbend_penalty, bend_collision_type=bend_collision_type, bend_clip_margin=bend_clip_margin, + visibility_guidance=visibility_guidance, ) self.cost_evaluator.config = self.config self.cost_evaluator._refresh_cached_config() @@ -230,6 +233,91 @@ def _sbend_forward_span(offset: float, radius: float) -> float | None: return 2.0 * radius * __import__("math").sin(theta) +def _visible_straight_candidates( + current: Port, + context: AStarContext, + max_reach: float, + cos_v: float, + sin_v: float, + net_width: float, +) -> list[float]: + mode = context.config.visibility_guidance + if mode == "off": + return [] + + if mode == "exact_corner": + max_bend_radius = max(context.config.bend_radii, default=0.0) + visibility_reach = max_reach + max_bend_radius + visible_corners = sorted( + context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach), + key=lambda corner: corner[2], + ) + if not visible_corners: + return [] + + candidates: set[int] = set() + for cx, cy, _ in visible_corners[:12]: + dx = cx - current.x + dy = cy - current.y + local_x = dx * cos_v + dy * sin_v + if local_x <= context.config.min_straight_length: + continue + candidates.add(int(round(local_x))) + return sorted(candidates, reverse=True) + + if mode != "tangent_corner": + return [] + + visibility_manager = context.visibility_manager + visibility_manager._ensure_current() + max_bend_radius = max(context.config.bend_radii, default=0.0) + if max_bend_radius <= 0 or not visibility_manager.corners: + return [] + + reach = max_reach + max_bend_radius + bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach) + candidate_ids = list(visibility_manager.corner_index.intersection(bounds)) + if not candidate_ids: + return [] + + scored: list[tuple[float, float, float, float, float]] = [] + for idx in candidate_ids: + cx, cy = visibility_manager.corners[idx] + dx = cx - current.x + dy = cy - current.y + local_x = dx * cos_v + dy * sin_v + local_y = -dx * sin_v + dy * cos_v + if local_x <= context.config.min_straight_length or local_x > reach + 0.01: + continue + + nearest_radius = min(context.config.bend_radii, key=lambda radius: abs(abs(local_y) - radius)) + tangent_error = abs(abs(local_y) - nearest_radius) + if tangent_error > 2.0: + continue + + length = local_x - nearest_radius + if length <= context.config.min_straight_length or length > max_reach + 0.01: + continue + + scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy)) + + if not scored: + return [] + + collision_engine = context.cost_evaluator.collision_engine + candidates: set[int] = set() + for _, dist, length, dx, dy in sorted(scored)[:4]: + angle = math.degrees(math.atan2(dy, dx)) + corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width) + if corner_reach < dist - 0.01: + continue + qlen = int(round(length)) + if qlen > 0: + candidates.add(qlen) + + return sorted(candidates, reverse=True) + + def _previous_move_metadata(node: AStarNode) -> tuple[str | None, float | None]: result = node.component_result if result is None: @@ -309,6 +397,17 @@ def expand_moves( for radius in context.config.bend_radii: candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius)) + candidate_lengths.extend( + _visible_straight_candidates( + cp, + context, + max_reach, + cos_v, + sin_v, + net_width, + ) + ) + if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR: for radius in context.config.sbend_radii: sbend_span = _sbend_forward_span(dy_local, radius) diff --git a/inire/router/config.py b/inire/router/config.py index 5206f36..7a49a2f 100644 --- a/inire/router/config.py +++ b/inire/router/config.py @@ -4,6 +4,9 @@ from dataclasses import dataclass, field from typing import Literal, Any +VisibilityGuidanceMode = Literal["off", "exact_corner", "tangent_corner"] + + @dataclass class RouterConfig: @@ -28,6 +31,7 @@ class RouterConfig: sbend_penalty: float = 500.0 bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc" bend_clip_margin: float = 10.0 + visibility_guidance: VisibilityGuidanceMode = "tangent_corner" @dataclass diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index 5f7d01a..30aca20 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import math import random import time from dataclasses import dataclass @@ -8,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Callable, Literal import numpy +from inire.geometry.components import Bend90, Straight from inire.router.astar import AStarMetrics, route_astar if TYPE_CHECKING: @@ -38,6 +40,7 @@ class PathFinder: "congestion_multiplier", "accumulated_expanded_nodes", "warm_start", + "refine_paths", ) def __init__( @@ -49,6 +52,7 @@ class PathFinder: congestion_multiplier: float = 1.5, use_tiered_strategy: bool = True, warm_start: Literal["shortest", "longest", "user"] | None = "shortest", + refine_paths: bool = False, ) -> None: self.context = context self.metrics = metrics if metrics is not None else AStarMetrics() @@ -57,6 +61,7 @@ class PathFinder: self.congestion_multiplier = congestion_multiplier self.use_tiered_strategy = use_tiered_strategy self.warm_start = warm_start + self.refine_paths = refine_paths self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] @property @@ -125,6 +130,139 @@ class PathFinder: return True return False + def _path_cost(self, path: list[ComponentResult]) -> float: + total = 0.0 + bend_penalty = self.context.config.bend_penalty + sbend_penalty = self.context.config.sbend_penalty + for comp in path: + total += comp.length + if comp.move_type == "Bend90": + radius = comp.length * 2.0 / math.pi if comp.length > 0 else 0.0 + if radius > 0: + total += bend_penalty * (10.0 / radius) ** 0.5 + else: + total += bend_penalty + elif comp.move_type == "SBend": + total += sbend_penalty + return total + + def _extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]: + all_geoms = [] + all_dilated = [] + for res in path: + all_geoms.extend(res.geometry) + if res.dilated_geometry: + all_dilated.extend(res.dilated_geometry) + else: + dilation = self.cost_evaluator.collision_engine.clearance / 2.0 + all_dilated.extend([p.buffer(dilation) for p in res.geometry]) + return all_geoms, all_dilated + + def _to_local(self, start: Port, point: Port) -> tuple[int, int]: + dx = point.x - start.x + dy = point.y - start.y + if start.r == 0: + return dx, dy + if start.r == 90: + return dy, -dx + if start.r == 180: + return -dx, -dy + return -dy, dx + + def _build_same_orientation_dogleg( + self, + start: Port, + target: Port, + net_width: float, + radius: float, + side_extent: float, + ) -> list[ComponentResult] | None: + local_dx, local_dy = self._to_local(start, target) + if abs(local_dy) > 0 or local_dx < 4.0 * radius - 0.01: + return None + + side_abs = abs(side_extent) + side_length = side_abs - 2.0 * radius + if side_length < self.context.config.min_straight_length - 0.01: + return None + + forward_length = local_dx - 4.0 * radius + if forward_length < -0.01: + return None + + first_dir = "CCW" if side_extent > 0 else "CW" + second_dir = "CW" if side_extent > 0 else "CCW" + dilation = self.cost_evaluator.collision_engine.clearance / 2.0 + + path: list[ComponentResult] = [] + curr = start + + for direction, straight_len in ( + (first_dir, side_length), + (second_dir, forward_length), + (second_dir, side_length), + (first_dir, None), + ): + bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation) + path.append(bend) + curr = bend.end_port + if straight_len is None: + continue + if straight_len > 0.01: + straight = Straight.generate(curr, straight_len, net_width, dilation=dilation) + path.append(straight) + curr = straight.end_port + + if curr != target: + return None + return path + + def _refine_path( + self, + net_id: str, + start: Port, + target: Port, + net_width: float, + path: list[ComponentResult], + ) -> list[ComponentResult]: + if not path or start.r != target.r: + return path + + bend_count = sum(1 for comp in path if comp.move_type == "Bend90") + if bend_count < 5: + return path + + side_extents = [] + local_points = [self._to_local(start, start)] + local_points.extend(self._to_local(start, comp.end_port) for comp in path) + min_side = min(point[1] for point in local_points) + max_side = max(point[1] for point in local_points) + if min_side < -0.01: + side_extents.append(float(min_side)) + if max_side > 0.01: + side_extents.append(float(max_side)) + if not side_extents: + return path + + best_path = path + best_cost = self._path_cost(path) + collision_engine = self.cost_evaluator.collision_engine + + for radius in self.context.config.bend_radii: + for side_extent in side_extents: + candidate = self._build_same_orientation_dogleg(start, target, net_width, radius, side_extent) + if candidate is None: + continue + is_valid, collisions = collision_engine.verify_path(net_id, candidate) + if not is_valid or collisions != 0: + continue + candidate_cost = self._path_cost(candidate) + if candidate_cost + 1e-6 < best_cost: + best_cost = candidate_cost + best_path = candidate + + return best_path + def route_all( self, netlist: dict[str, tuple[Port, Port]], @@ -244,6 +382,25 @@ class PathFinder: break self.cost_evaluator.congestion_penalty *= self.congestion_multiplier + if self.refine_paths and results: + for net_id in all_net_ids: + res = results.get(net_id) + if not res or not res.path or not res.reached_target or not res.is_valid: + continue + start, target = netlist[net_id] + width = net_widths.get(net_id, 2.0) + self.cost_evaluator.collision_engine.remove_path(net_id) + refined_path = self._refine_path(net_id, start, target, width, res.path) + all_geoms, all_dilated = self._extract_geometry(refined_path) + self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) + results[net_id] = RoutingResult( + net_id=net_id, + path=refined_path, + is_valid=res.is_valid, + collisions=res.collisions, + reached_target=res.reached_target, + ) + self.cost_evaluator.collision_engine.dynamic_tree = None self.cost_evaluator.collision_engine._ensure_dynamic_tree() return self.verify_all_nets(results, netlist) diff --git a/inire/router/visibility.py b/inire/router/visibility.py index 8acbe90..d5fa61d 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -140,3 +140,20 @@ class VisibilityManager: self._static_visibility_cache[cache_key] = visible return visible + + def get_corner_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: + """ + Return precomputed visibility only when the origin is already at a known corner. + This avoids the expensive arbitrary-point visibility scan in hot search paths. + """ + self._ensure_current() + if max_dist < 0: + return [] + + ox, oy = round(origin.x, 3), round(origin.y, 3) + nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001))) + for idx in nearby: + cx, cy = self.corners[idx] + if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4 and idx in self._corner_graph: + return [corner for corner in self._corner_graph[idx] if corner[2] <= max_dist] + return [] diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index b1521cc..85d9021 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -195,3 +195,95 @@ def test_expand_moves_adds_sbend_aligned_straight_stop_points( assert sbend_span is not None assert int(round(100.0 - sbend_span)) in straight_lengths assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths + + +def test_expand_moves_adds_exact_corner_visibility_stop_points( + basic_evaluator: CostEvaluator, + monkeypatch: pytest.MonkeyPatch, +) -> None: + context = AStarContext( + basic_evaluator, + bend_radii=[10.0], + max_straight_length=150.0, + visibility_guidance="exact_corner", + ) + current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + + monkeypatch.setattr( + astar_module.VisibilityManager, + "get_corner_visibility", + lambda self, origin, max_dist=0.0: [(40.0, 10.0, 41.23), (75.0, -15.0, 76.48)], + ) + + emitted: list[tuple[str, tuple]] = [] + + def fake_process_move(*args, **kwargs) -> None: + emitted.append((args[9], args[10])) + + monkeypatch.setattr(astar_module, "process_move", fake_process_move) + + astar_module.expand_moves( + current, + Port(120, 20, 0), + net_width=2.0, + net_id="test", + open_set=[], + closed_set={}, + context=context, + metrics=astar_module.AStarMetrics(), + congestion_cache={}, + ) + + straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} + assert 40 in straight_lengths + assert 75 in straight_lengths + + +def test_expand_moves_adds_tangent_corner_visibility_stop_points( + basic_evaluator: CostEvaluator, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class DummyCornerIndex: + def intersection(self, bounds: tuple[float, float, float, float]) -> list[int]: + return [0, 1] + + context = AStarContext( + basic_evaluator, + bend_radii=[10.0], + sbend_radii=[], + max_straight_length=150.0, + visibility_guidance="tangent_corner", + ) + current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + + monkeypatch.setattr(astar_module.VisibilityManager, "_ensure_current", lambda self: None) + context.visibility_manager.corners = [(50.0, 10.0), (80.0, -10.0)] + context.visibility_manager.corner_index = DummyCornerIndex() + monkeypatch.setattr( + type(context.cost_evaluator.collision_engine), + "ray_cast", + lambda self, origin, angle_deg, max_dist=2000.0, net_width=None: max_dist, + ) + + emitted: list[tuple[str, tuple]] = [] + + def fake_process_move(*args, **kwargs) -> None: + emitted.append((args[9], args[10])) + + monkeypatch.setattr(astar_module, "process_move", fake_process_move) + + astar_module.expand_moves( + current, + Port(120, 20, 0), + net_width=2.0, + net_id="test", + open_set=[], + closed_set={}, + context=context, + metrics=astar_module.AStarMetrics(), + congestion_cache={}, + ) + + straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} + assert 40 in straight_lengths + assert 70 in straight_lengths diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 9053e0c..252a96e 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -55,3 +55,64 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: assert not results["net2"].is_valid assert results["net1"].collisions > 0 assert results["net2"].collisions > 0 + + +def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None: + bounds = (0, -50, 100, 50) + + def build_pathfinder(*, refine_paths: bool) -> tuple[CollisionEngine, PathFinder]: + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) + context = AStarContext(evaluator, bend_radii=[10.0]) + return engine, PathFinder(context, refine_paths=refine_paths) + + base_engine, base_pf = build_pathfinder(refine_paths=False) + base_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) + base_engine.lock_net("netA") + base_result = base_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"] + + refined_engine, refined_pf = build_pathfinder(refine_paths=True) + refined_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) + refined_engine.lock_net("netA") + refined_result = refined_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"] + + base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90") + refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90") + + assert base_result.is_valid + assert refined_result.is_valid + assert refined_bends < base_bends + assert refined_pf._path_cost(refined_result.path) < base_pf._path_cost(base_result.path) + + +def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: + bounds = (0, 0, 100, 100) + netlist = { + "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), + "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), + "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), + } + net_widths = {net_id: 2.0 for net_id in netlist} + + def build_pathfinder(*, refine_paths: bool) -> PathFinder: + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0) + context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0]) + return PathFinder(context, base_congestion_penalty=1000.0, refine_paths=refine_paths) + + base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths) + refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths) + + for net_id in ("vertical_up", "vertical_down"): + base_result = base_results[net_id] + refined_result = refined_results[net_id] + base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90") + refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90") + + assert base_result.is_valid + assert refined_result.is_valid + assert refined_bends < base_bends