make sure to check self-intersections

This commit is contained in:
Jan Petykiewicz 2026-03-21 00:59:29 -07:00
commit de7254f8f5
4 changed files with 154 additions and 10 deletions

View file

@ -49,7 +49,7 @@ class ComponentResult:
_offsets: list[int] | None = None,
_coords_cache: numpy.ndarray | None = None,
_base_result: ComponentResult | None = None,
_offset: list[float] | None = None,
_offset: numpy.ndarray | None = None,
snap_size: float = SEARCH_GRID_SNAP_UM,
rel_gx: int | None = None,
rel_gy: int | None = None,
@ -216,17 +216,18 @@ class ComponentResult:
base = self._base_result
if base is not None:
dx, dy = self._offset
shift = numpy.array([dx, dy, dx, dy])
# Vectorized addition is faster if we avoid creating new lists/arrays in the loop
if base._bounds is not None:
self._bounds = base._bounds + [dx, dy, dx, dy]
self._bounds = base._bounds + shift
if base._total_bounds is not None:
b = base._total_bounds
self._total_bounds = numpy.array([b[0]+dx, b[1]+dy, b[2]+dx, b[3]+dy])
self._total_bounds = b + shift
if base._dilated_bounds is not None:
self._dilated_bounds = base._dilated_bounds + [dx, dy, dx, dy]
self._dilated_bounds = base._dilated_bounds + shift
if base._total_dilated_bounds is not None:
b = base._total_dilated_bounds
self._total_dilated_bounds = numpy.array([b[0]+dx, b[1]+dy, b[2]+dx, b[3]+dy])
self._total_dilated_bounds = b + shift
self._bounds_cached = True
def translate(self, dx: float, dy: float, rel_gx: int | None = None, rel_gy: int | None = None, rel_go: int | None = None) -> ComponentResult:
@ -241,10 +242,10 @@ class ComponentResult:
if self._base_result:
base = self._base_result
current_offset = self._offset
new_offset = [current_offset[0] + dx, current_offset[1] + dy]
new_offset = numpy.array([current_offset[0] + dx, current_offset[1] + dy])
else:
base = self
new_offset = [dx, dy]
new_offset = numpy.array([dx, dy])
return ComponentResult(
end_port=new_port,
@ -607,6 +608,11 @@ class SBend:
actual_radius = abs(local_dy) / denom
# Safety Check: Reject SBends with tiny radii that would cause self-overlap
# (inner_radius becomes negative if actual_radius < width/2)
if actual_radius < width:
raise ValueError(f"SBend actual_radius {actual_radius:.3f} is too small (width={width})")
# Limit radius to prevent giant arcs
if actual_radius > 100000.0:
actual_len = numpy.sqrt(local_dx**2 + local_dy**2)

View file

@ -56,7 +56,8 @@ class AStarRouter:
"""
__slots__ = ('cost_evaluator', 'config', 'node_limit', 'visibility_manager',
'_hard_collision_set', '_congestion_cache', '_static_safe_cache',
'_move_cache', 'total_nodes_expanded', 'last_expanded_nodes', 'metrics')
'_move_cache', 'total_nodes_expanded', 'last_expanded_nodes', 'metrics',
'_self_collision_check')
def __init__(self, cost_evaluator: CostEvaluator, node_limit: int | None = None, **kwargs) -> None:
self.cost_evaluator = cost_evaluator
@ -121,11 +122,25 @@ class AStarRouter:
return_partial: bool = False,
store_expanded: bool = False,
skip_congestion: bool = False,
max_cost: float | None = None
max_cost: float | None = None,
self_collision_check: bool = False,
) -> list[ComponentResult] | None:
"""
Route a single net using A*.
Args:
start: Starting port.
target: Target port.
net_width: Waveguide width.
net_id: Identifier for the net.
bend_collision_type: Type of collision model to use for bends.
return_partial: If True, returns the best-effort path if target not reached.
store_expanded: If True, keep track of all expanded nodes for visualization.
skip_congestion: If True, ignore other nets' paths (greedy mode).
max_cost: Hard limit on f_cost to prune search.
self_collision_check: If True, prevent the net from crossing its own path.
"""
self._self_collision_check = self_collision_check
self._congestion_cache.clear()
if store_expanded:
self.last_expanded_nodes = []
@ -440,6 +455,22 @@ class AStarRouter:
total_overlaps = self.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
self._congestion_cache[cache_key] = total_overlaps
# SELF-COLLISION CHECK (Optional for performance)
if getattr(self, '_self_collision_check', False):
curr_p = parent
new_tb = result.total_bounds
while curr_p and curr_p.parent:
ancestor_res = curr_p.component_result
if ancestor_res:
anc_tb = ancestor_res.total_bounds
if (new_tb[0] < anc_tb[2] and new_tb[2] > anc_tb[0] and
new_tb[1] < anc_tb[3] and new_tb[3] > anc_tb[1]):
for p_anc in ancestor_res.geometry:
for p_new in result.geometry:
if p_new.intersects(p_anc) and not p_new.touches(p_anc):
return
curr_p = curr_p.parent
penalty = 0.0
if 'SB' in move_type: penalty = self.config.sbend_penalty
elif 'B' in move_type: penalty = self.config.bend_penalty

View file

@ -144,6 +144,31 @@ class PathFinder:
logger.info(f"PathFinder: Greedy Warm-Start finished. Seeding {len(greedy_paths)}/{len(netlist)} nets.")
return greedy_paths
def _has_self_collision(self, path: list[ComponentResult]) -> bool:
"""
Quickly check if a path intersects itself.
"""
if not path:
return False
num_components = len(path)
for i in range(num_components):
comp_i = path[i]
tb_i = comp_i.total_bounds
for j in range(i + 2, num_components): # Skip immediate neighbors
comp_j = path[j]
tb_j = comp_j.total_bounds
# AABB Check
if (tb_i[0] < tb_j[2] and tb_i[2] > tb_j[0] and
tb_i[1] < tb_j[3] and tb_i[3] > tb_j[1]):
# Real geometry check
for p_i in comp_i.geometry:
for p_j in comp_j.geometry:
if p_i.intersects(p_j) and not p_i.touches(p_j):
return True
return False
def route_all(
self,
netlist: dict[str, tuple[Port, Port]],
@ -180,6 +205,7 @@ class PathFinder:
session_timeout = max(60.0, 10.0 * num_nets * self.max_iterations)
all_net_ids = list(netlist.keys())
needs_sc = set() # Nets requiring self-collision avoidance
# Determine initial paths (Warm Start)
if initial_paths is None:
@ -249,7 +275,7 @@ class PathFinder:
original_limit = self.router.node_limit
self.router.node_limit = current_node_limit
path = self.router.route(start, target, width, net_id=net_id, bend_collision_type=coll_model, return_partial=True, store_expanded=store_expanded, skip_congestion=skip_cong)
path = self.router.route(start, target, width, net_id=net_id, bend_collision_type=coll_model, return_partial=True, store_expanded=store_expanded, skip_congestion=skip_cong, self_collision_check=(net_id in needs_sc))
if store_expanded and self.router.last_expanded_nodes:
self.accumulated_expanded_nodes.extend(self.router.last_expanded_nodes)
@ -258,6 +284,13 @@ class PathFinder:
logger.debug(f' Net {net_id} routed in {time.monotonic() - net_start:.4f}s using {coll_model}')
if path:
# Check for self-collision if not already handled by router
if net_id not in needs_sc:
if self._has_self_collision(path):
logger.info(f' Net {net_id} detected self-collision. Enabling protection for next iteration.')
needs_sc.add(net_id)
any_congestion = True
# Check if reached exactly
last_p = path[-1].end_port
reached = (abs(last_p.x - target.x) < 1e-6 and

View file

@ -0,0 +1,74 @@
import pytest
import numpy
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from inire.router.pathfinder import PathFinder
from inire.router.danger_map import DangerMap
def test_failed_net_visibility():
"""
Verifies that nets that fail to reach their target (return partial paths)
ARE added to the collision engine, making them visible to other nets
for negotiated congestion.
"""
# 1. Setup
engine = CollisionEngine(clearance=2.0)
# Create a simple danger map (bounds 0-100)
# We don't strictly need obstacles in it for this test.
dm = DangerMap(bounds=(0, 0, 100, 100))
evaluator = CostEvaluator(engine, dm)
# 2. Configure Router with low limit to FORCE failure
# node_limit=5 is extremely low, likely allowing only a few moves.
# Start (0,0) -> Target (100,0) is 100um away.
# If snap is 1.0, direct jump S100 might be tried.
# If direct jump works, it might succeed in 1 expansion.
# So we need to block the direct jump or make the limit VERY small (0?).
# Or place a static obstacle that forces a search.
# Let's add a static obstacle that blocks the direct path.
from shapely.geometry import box
obstacle = box(40, -10, 60, 10) # Wall at x=50
engine.add_static_obstacle(obstacle)
# With obstacle, direct jump fails. A* must search around.
# Limit=10 should be enough to fail to find a path around.
router = AStarRouter(evaluator, node_limit=10)
# 3. Configure PathFinder
# max_iterations=1 because we only need to check the state after the first attempt.
pf = PathFinder(router, evaluator, max_iterations=1, warm_start=None)
netlist = {
"net1": (Port(0, 0, 0), Port(100, 0, 0))
}
net_widths = {"net1": 1.0}
# 4. Route
print("\nStarting Route...")
results = pf.route_all(netlist, net_widths)
res = results["net1"]
print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}")
# 5. Verify Failure Condition
# We expect reached_target to be False because of node_limit + obstacle
assert not res.reached_target, "Test setup failed: Net reached target despite low limit!"
assert len(res.path) > 0, "Test setup failed: No partial path returned!"
# 6. Verify Visibility
# Check if net1 is in the collision engine
found_nets = set()
# CollisionEngine.dynamic_geometries: dict[obj_id, (net_id, poly)]
for obj_id, (nid, poly) in engine.dynamic_geometries.items():
found_nets.add(nid)
print(f"Nets found in engine: {found_nets}")
# The FIX Expectation: "net1" SHOULD be present
assert "net1" in found_nets, "Bug present: Net1 is invisible despite having partial path!"