Fix core geometry snapping, A* target lookahead, and test configurations

This commit is contained in:
Jan Petykiewicz 2026-03-15 21:14:42 -07:00
commit d438c5b7c7
88 changed files with 1463 additions and 476 deletions

View file

@ -2,12 +2,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import rtree
import numpy
from shapely.prepared import prep
from shapely.strtree import STRtree
from shapely.geometry import box
if TYPE_CHECKING:
from shapely.geometry import Polygon
from shapely.prepared import PreparedGeometry
from inire.geometry.primitives import Port
from inire.geometry.components import ComponentResult
class CollisionEngine:
@ -16,8 +20,12 @@ class CollisionEngine:
"""
__slots__ = (
'clearance', 'max_net_width', 'safety_zone_radius',
'static_index', 'static_geometries', 'static_dilated', 'static_prepared', '_static_id_counter',
'dynamic_index', 'dynamic_geometries', 'dynamic_dilated', 'dynamic_prepared', '_dynamic_id_counter'
'static_index', 'static_geometries', 'static_dilated', 'static_prepared',
'static_is_rect', 'static_tree', 'static_obj_ids', 'static_safe_cache',
'static_grid', 'grid_cell_size', '_static_id_counter',
'dynamic_index', 'dynamic_geometries', 'dynamic_dilated', 'dynamic_prepared',
'dynamic_tree', 'dynamic_obj_ids', 'dynamic_grid', '_dynamic_id_counter',
'metrics'
)
clearance: float
@ -52,16 +60,54 @@ class CollisionEngine:
self.static_geometries: dict[int, Polygon] = {} # ID -> Raw Polygon
self.static_dilated: dict[int, Polygon] = {} # ID -> Dilated Polygon (by clearance)
self.static_prepared: dict[int, PreparedGeometry] = {} # ID -> Prepared Dilated
self.static_is_rect: dict[int, bool] = {} # Optimization for ray_cast
self.static_tree: STRtree | None = None
self.static_obj_ids: list[int] = [] # Mapping from tree index to obj_id
self.static_safe_cache: set[tuple] = set() # Global cache for safe move-port combinations
self.static_grid: dict[tuple[int, int], list[int]] = {}
self.grid_cell_size = 50.0 # 50um grid cells for broad phase
self._static_id_counter = 0
# Dynamic paths for multi-net congestion
self.dynamic_index = rtree.index.Index()
# obj_id -> (net_id, raw_geometry)
self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {}
# obj_id -> dilated_geometry (by clearance/2)
self.dynamic_dilated: dict[int, Polygon] = {}
self.dynamic_prepared: dict[int, PreparedGeometry] = {}
self.dynamic_tree: STRtree | None = None
self.dynamic_obj_ids: list[int] = []
self.dynamic_grid: dict[tuple[int, int], list[int]] = {}
self._dynamic_id_counter = 0
self.metrics = {
'static_cache_hits': 0,
'static_grid_skips': 0,
'static_tree_queries': 0,
'static_straight_fast': 0,
'congestion_grid_skips': 0,
'congestion_tree_queries': 0,
'safety_zone_checks': 0
}
def reset_metrics(self) -> None:
""" Reset all performance counters. """
for k in self.metrics:
self.metrics[k] = 0
def get_metrics_summary(self) -> str:
""" Return a human-readable summary of collision performance. """
m = self.metrics
total_static = m['static_cache_hits'] + m['static_grid_skips'] + m['static_tree_queries'] + m['static_straight_fast']
static_eff = ((m['static_cache_hits'] + m['static_grid_skips'] + m['static_straight_fast']) / total_static * 100) if total_static > 0 else 0
total_cong = m['congestion_grid_skips'] + m['congestion_tree_queries']
cong_eff = (m['congestion_grid_skips'] / total_cong * 100) if total_cong > 0 else 0
return (f"Collision Performance: \n"
f" Static: {total_static} checks, {static_eff:.1f}% bypassed STRtree\n"
f" (Cache={m['static_cache_hits']}, Grid={m['static_grid_skips']}, StraightFast={m['static_straight_fast']}, Tree={m['static_tree_queries']})\n"
f" Congestion: {total_cong} checks, {cong_eff:.1f}% bypassed STRtree\n"
f" (Grid={m['congestion_grid_skips']}, Tree={m['congestion_tree_queries']})\n"
f" Safety Zone: {m['safety_zone_checks']} full intersections performed")
def add_static_obstacle(self, polygon: Polygon) -> None:
"""
@ -73,11 +119,46 @@ class CollisionEngine:
obj_id = self._static_id_counter
self._static_id_counter += 1
dilated = polygon.buffer(self.clearance)
# Use MITRE join style to preserve rectangularity of boxes
dilated = polygon.buffer(self.clearance, join_style=2)
self.static_geometries[obj_id] = polygon
self.static_dilated[obj_id] = dilated
self.static_prepared[obj_id] = prep(dilated)
self.static_index.insert(obj_id, dilated.bounds)
# Invalidate higher-level spatial data
self.static_tree = None
self.static_grid = {} # Rebuild on demand
# Check if it's an axis-aligned rectangle (approximately)
# Dilated rectangle of an axis-aligned rectangle IS an axis-aligned rectangle.
b = dilated.bounds
area = (b[2] - b[0]) * (b[3] - b[1])
if abs(dilated.area - area) < 1e-4:
self.static_is_rect[obj_id] = True
else:
self.static_is_rect[obj_id] = False
def _ensure_static_tree(self) -> None:
if self.static_tree is None and self.static_dilated:
ids = sorted(self.static_dilated.keys())
geoms = [self.static_dilated[i] for i in ids]
self.static_tree = STRtree(geoms)
self.static_obj_ids = ids
def _ensure_static_grid(self) -> None:
if not self.static_grid and self.static_dilated:
cs = self.grid_cell_size
for obj_id, poly in self.static_dilated.items():
b = poly.bounds
min_gx, max_gx = int(b[0] / cs), int(b[2] / cs)
min_gy, max_gy = int(b[1] / cs), int(b[3] / cs)
for gx in range(min_gx, max_gx + 1):
for gy in range(min_gy, max_gy + 1):
cell = (gx, gy)
if cell not in self.static_grid:
self.static_grid[cell] = []
self.static_grid[cell].append(obj_id)
def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None:
"""
@ -99,6 +180,30 @@ class CollisionEngine:
self.dynamic_dilated[obj_id] = dil
self.dynamic_prepared[obj_id] = prep(dil)
self.dynamic_index.insert(obj_id, dil.bounds)
self.dynamic_tree = None
self.dynamic_grid = {}
def _ensure_dynamic_tree(self) -> None:
if self.dynamic_tree is None and self.dynamic_dilated:
ids = sorted(self.dynamic_dilated.keys())
geoms = [self.dynamic_dilated[i] for i in ids]
self.dynamic_tree = STRtree(geoms)
self.dynamic_obj_ids = ids
def _ensure_dynamic_grid(self) -> None:
if not self.dynamic_grid and self.dynamic_dilated:
cs = self.grid_cell_size
for obj_id, poly in self.dynamic_dilated.items():
b = poly.bounds
min_gx, max_gx = int(b[0] / cs), int(b[2] / cs)
min_gy, max_gy = int(b[1] / cs), int(b[3] / cs)
for gx in range(min_gx, max_gx + 1):
for gy in range(min_gy, max_gy + 1):
cell = (gx, gy)
if cell not in self.dynamic_grid:
self.dynamic_grid[cell] = []
self.dynamic_grid[cell].append(obj_id)
def remove_path(self, net_id: str) -> None:
"""
@ -113,6 +218,10 @@ class CollisionEngine:
dilated = self.dynamic_dilated.pop(obj_id)
self.dynamic_prepared.pop(obj_id)
self.dynamic_index.delete(obj_id, dilated.bounds)
if to_remove:
self.dynamic_tree = None
self.dynamic_grid = {}
def lock_net(self, net_id: str) -> None:
"""
@ -152,6 +261,279 @@ class CollisionEngine:
res = self.check_collision(geometry, net_id, buffer_mode='congestion')
return int(res)
def check_move_straight_static(
self,
origin: Port,
length: float,
) -> bool:
"""
Specialized fast static check for Straights.
"""
self.metrics['static_straight_fast'] += 1
# FAST PATH: Grid check
self._ensure_static_grid()
cs = self.grid_cell_size
rad = numpy.radians(origin.orientation)
dx = length * numpy.cos(rad)
dy = length * numpy.sin(rad)
# Move bounds
xmin, xmax = sorted([origin.x, origin.x + dx])
ymin, ymax = sorted([origin.y, origin.y + dy])
# Inflate by clearance/2 for waveguide half-width?
# No, static obstacles are ALREADY inflated by full clearance.
# So we just check if the centerline hits an inflated obstacle.
min_gx, max_gx = int(xmin / cs), int(xmax / cs)
min_gy, max_gy = int(ymin / cs), int(ymax / cs)
static_grid = self.static_grid
static_dilated = self.static_dilated
static_is_rect = self.static_is_rect
static_prepared = self.static_prepared
inv_dx = 1.0/dx if abs(dx) > 1e-12 else 1e30
inv_dy = 1.0/dy if abs(dy) > 1e-12 else 1e30
checked_ids = set()
for gx in range(min_gx, max_gx + 1):
for gy in range(min_gy, max_gy + 1):
if (gx, gy) in static_grid:
for obj_id in static_grid[(gx, gy)]:
if obj_id in checked_ids: continue
checked_ids.add(obj_id)
b = static_dilated[obj_id].bounds
# Slab Method
if abs(dx) < 1e-12:
if origin.x < b[0] or origin.x > b[2]: continue
tx_min, tx_max = -1e30, 1e30
else:
tx_min = (b[0] - origin.x) * inv_dx
tx_max = (b[2] - origin.x) * inv_dx
if tx_min > tx_max: tx_min, tx_max = tx_max, tx_min
if abs(dy) < 1e-12:
if origin.y < b[1] or origin.y > b[3]: continue
ty_min, ty_max = -1e30, 1e30
else:
ty_min = (b[1] - origin.y) * inv_dy
ty_max = (b[3] - origin.y) * inv_dy
if ty_min > ty_max: ty_min, ty_max = ty_max, ty_min
t_min = max(tx_min, ty_min)
t_max = min(tx_max, ty_max)
if t_max < 0 or t_min > t_max or t_min > 1.0:
continue
# If rectangle, slab is exact
if static_is_rect[obj_id]:
return True
# Fallback for complex obstacles
# (We could still use ray_cast here but we want exact)
# For now, if hits AABB, check prepared
from shapely.geometry import LineString
line = LineString([(origin.x, origin.y), (origin.x+dx, origin.y+dy)])
if static_prepared[obj_id].intersects(line):
return True
return False
def check_move_static(
self,
result: ComponentResult,
start_port: Port | None = None,
end_port: Port | None = None,
) -> bool:
"""
Check if a move (ComponentResult) hits any static obstacles.
"""
# FAST PATH 1: Safety cache check
cache_key = (result.move_type,
round(start_port.x, 3) if start_port else 0,
round(start_port.y, 3) if start_port else 0,
round(end_port.x, 3) if end_port else 0,
round(end_port.y, 3) if end_port else 0)
if cache_key in self.static_safe_cache:
self.metrics['static_cache_hits'] += 1
return False
# FAST PATH 2: Spatial grid check (bypasses STRtree for empty areas)
self._ensure_static_grid()
cs = self.grid_cell_size
b = result.total_bounds
min_gx, max_gx = int(b[0] / cs), int(b[2] / cs)
min_gy, max_gy = int(b[1] / cs), int(b[3] / cs)
any_candidates = False
static_grid = self.static_grid
for gx in range(min_gx, max_gx + 1):
for gy in range(min_gy, max_gy + 1):
if (gx, gy) in static_grid:
any_candidates = True
break
if any_candidates: break
if not any_candidates:
self.metrics['static_grid_skips'] += 1
self.static_safe_cache.add(cache_key)
return False
self.metrics['static_tree_queries'] += 1
self._ensure_static_tree()
if self.static_tree is None:
return False
# Vectorized Broad phase + Narrow phase
# Pass all polygons in the move at once
res_indices, tree_indices = self.static_tree.query(result.geometry, predicate='intersects')
if tree_indices.size == 0:
self.static_safe_cache.add(cache_key)
return False
# If we have hits, we must check safety zones
static_obj_ids = self.static_obj_ids
for i in range(tree_indices.size):
poly_idx = res_indices[i]
hit_idx = tree_indices[i]
obj_id = static_obj_ids[hit_idx]
poly = result.geometry[poly_idx]
if self._is_in_safety_zone(poly, obj_id, start_port, end_port):
continue
return True
self.static_safe_cache.add(cache_key)
return False
def check_move_congestion(
self,
result: ComponentResult,
net_id: str,
) -> int:
"""
Count overlaps of a move with other dynamic paths.
"""
if result.total_dilated_bounds_box is None:
return 0
# FAST PATH: Grid check
self._ensure_dynamic_grid()
if not self.dynamic_grid:
return 0
cs = self.grid_cell_size
b = result.total_dilated_bounds
min_gx, max_gx = int(b[0] / cs), int(b[2] / cs)
min_gy, max_gy = int(b[1] / cs), int(b[3] / cs)
any_candidates = False
dynamic_grid = self.dynamic_grid
dynamic_geometries = self.dynamic_geometries
for gx in range(min_gx, max_gx + 1):
for gy in range(min_gy, max_gy + 1):
cell = (gx, gy)
if cell in dynamic_grid:
# Check if any obj_id in this cell belongs to another net
for obj_id in dynamic_grid[cell]:
other_net_id, _ = dynamic_geometries[obj_id]
if other_net_id != net_id:
any_candidates = True
break
if any_candidates: break
if any_candidates: break
if not any_candidates:
self.metrics['congestion_grid_skips'] += 1
return 0
# SLOW PATH: STRtree
self.metrics['congestion_tree_queries'] += 1
self._ensure_dynamic_tree()
if self.dynamic_tree is None:
return 0
# Vectorized query: pass the whole list of polygons
# result.dilated_geometry is list[Polygon]
# query() returns (2, M) array of [geometry_indices, tree_indices]
res_indices, tree_indices = self.dynamic_tree.query(result.dilated_geometry, predicate='intersects')
if tree_indices.size == 0:
return 0
count = 0
dynamic_geometries = self.dynamic_geometries
dynamic_obj_ids = self.dynamic_obj_ids
# We need to filter by net_id and count UNIQUE overlaps?
# Actually, if a single move polygon hits multiple other net polygons, it's multiple overlaps.
# But if multiple move polygons hit the SAME other net polygon, is it multiple overlaps?
# Usually, yes, because cost is proportional to volume of overlap.
for hit_idx in tree_indices:
obj_id = dynamic_obj_ids[hit_idx]
other_net_id, _ = dynamic_geometries[obj_id]
if other_net_id != net_id:
count += 1
return count
def _is_in_safety_zone(self, geometry: Polygon, obj_id: int, start_port: Port | None, end_port: Port | None) -> bool:
""" Helper to check if an intersection is within a port safety zone. """
sz = self.safety_zone_radius
static_dilated = self.static_dilated
# Optimization: Skip expensive intersection if neither port is near the obstacle's bounds
is_near_port = False
b = static_dilated[obj_id].bounds
if start_port:
if (b[0] - sz <= start_port.x <= b[2] + sz and
b[1] - sz <= start_port.y <= b[3] + sz):
is_near_port = True
if not is_near_port and end_port:
if (b[0] - sz <= end_port.x <= b[2] + sz and
b[1] - sz <= end_port.y <= b[3] + sz):
is_near_port = True
if not is_near_port:
return False # Collision is NOT in safety zone
# Only if near port, do the expensive check
self.metrics['safety_zone_checks'] += 1
raw_obstacle = self.static_geometries[obj_id]
intersection = geometry.intersection(raw_obstacle)
if intersection.is_empty:
return True # Not actually hitting the RAW obstacle (only the buffer)
ix_bounds = intersection.bounds
# Check start port
if start_port:
if (abs(ix_bounds[0] - start_port.x) < sz and
abs(ix_bounds[2] - start_port.x) < sz and
abs(ix_bounds[1] - start_port.y) < sz and
abs(ix_bounds[3] - start_port.y) < sz):
return True # Is safe
# Check end port
if end_port:
if (abs(ix_bounds[0] - end_port.x) < sz and
abs(ix_bounds[2] - end_port.x) < sz and
abs(ix_bounds[1] - end_port.y) < sz and
abs(ix_bounds[3] - end_port.y) < sz):
return True # Is safe
return False
def check_congestion(
self,
geometry: Polygon,
net_id: str,
dilated_geometry: Polygon | None = None,
) -> int:
"""
Alias for check_collision(buffer_mode='congestion') for backward compatibility.
"""
res = self.check_collision(geometry, net_id, buffer_mode='congestion', dilated_geometry=dilated_geometry)
return int(res)
def check_collision(
self,
geometry: Polygon,
@ -160,89 +542,42 @@ class CollisionEngine:
start_port: Port | None = None,
end_port: Port | None = None,
dilated_geometry: Polygon | None = None,
bounds: tuple[float, float, float, float] | None = None,
) -> bool | int:
"""
Check for collisions using unified dilation logic.
Args:
geometry: Raw geometry to check.
net_id: Identifier for the net.
buffer_mode: 'static' (full clearance) or 'congestion' (shared).
start_port: Optional start port for safety zone.
end_port: Optional end port for safety zone.
dilated_geometry: Optional pre-buffered geometry (clearance/2).
Returns:
Boolean if static, integer count if congestion.
"""
# Optimization: Pre-fetch some members
sz = self.safety_zone_radius
if buffer_mode == 'static':
# Use raw query against pre-dilated obstacles
bounds = geometry.bounds
candidates = self.static_index.intersection(bounds)
static_prepared = self.static_prepared
static_dilated = self.static_dilated
static_geometries = self.static_geometries
for obj_id in candidates:
if static_prepared[obj_id].intersects(geometry):
if start_port or end_port:
# Optimization: Skip expensive intersection if neither port is near the obstacle's bounds
is_near_port = False
b = static_dilated[obj_id].bounds
if start_port:
if (b[0] - sz <= start_port.x <= b[2] + sz and
b[1] - sz <= start_port.y <= b[3] + sz):
is_near_port = True
if not is_near_port and end_port:
if (b[0] - sz <= end_port.x <= b[2] + sz and
b[1] - sz <= end_port.y <= b[3] + sz):
is_near_port = True
if not is_near_port:
return True # Collision, and not near any port safety zone
# Only if near port, do the expensive check
raw_obstacle = static_geometries[obj_id]
intersection = geometry.intersection(raw_obstacle)
if not intersection.is_empty:
ix_bounds = intersection.bounds
is_safe = False
# Check start port
if start_port:
if (abs(ix_bounds[0] - start_port.x) < sz and
abs(ix_bounds[2] - start_port.x) < sz and
abs(ix_bounds[1] - start_port.y) < sz and
abs(ix_bounds[3] - start_port.y) < sz):
is_safe = True
# Check end port
if not is_safe and end_port:
if (abs(ix_bounds[0] - end_port.x) < sz and
abs(ix_bounds[2] - end_port.x) < sz and
abs(ix_bounds[1] - end_port.y) < sz and
abs(ix_bounds[3] - end_port.y) < sz):
is_safe = True
if is_safe:
continue
return True
self._ensure_static_tree()
if self.static_tree is None:
return False
hits = self.static_tree.query(geometry, predicate='intersects')
static_obj_ids = self.static_obj_ids
for hit_idx in hits:
obj_id = static_obj_ids[hit_idx]
if self._is_in_safety_zone(geometry, obj_id, start_port, end_port):
continue
return True
return False
# buffer_mode == 'congestion'
self._ensure_dynamic_tree()
if self.dynamic_tree is None:
return 0
dilation = self.clearance / 2.0
test_poly = dilated_geometry if dilated_geometry else geometry.buffer(dilation)
candidates = self.dynamic_index.intersection(test_poly.bounds)
dynamic_geometries = self.dynamic_geometries
dynamic_prepared = self.dynamic_prepared
hits = self.dynamic_tree.query(test_poly, predicate='intersects')
count = 0
for obj_id in candidates:
dynamic_geometries = self.dynamic_geometries
dynamic_obj_ids = self.dynamic_obj_ids
for hit_idx in hits:
obj_id = dynamic_obj_ids[hit_idx]
other_net_id, _ = dynamic_geometries[obj_id]
if other_net_id != net_id and dynamic_prepared[obj_id].intersects(test_poly):
if other_net_id != net_id:
count += 1
return count
@ -262,50 +597,110 @@ class CollisionEngine:
from shapely.geometry import LineString
rad = numpy.radians(angle_deg)
dx = max_dist * numpy.cos(rad)
dy = max_dist * numpy.sin(rad)
cos_val = numpy.cos(rad)
sin_val = numpy.sin(rad)
dx = max_dist * cos_val
dy = max_dist * sin_val
# Ray geometry
ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)])
# 1. Pre-calculate ray direction inverses for fast slab intersection
# Use a small epsilon to avoid divide by zero, but handle zero dx/dy properly.
if abs(dx) < 1e-12:
inv_dx = 1e30 # Represent infinity
else:
inv_dx = 1.0 / dx
if abs(dy) < 1e-12:
inv_dy = 1e30 # Represent infinity
else:
inv_dy = 1.0 / dy
# Ray AABB for initial R-Tree query
min_x, max_x = sorted([origin.x, origin.x + dx])
min_y, max_y = sorted([origin.y, origin.y + dy])
# 1. Query R-Tree
candidates = self.static_index.intersection(ray_line.bounds)
candidates = list(self.static_index.intersection((min_x, min_y, max_x, max_y)))
if not candidates:
return max_dist
min_dist = max_dist
# 2. Check Intersections
# Note: We intersect with DILATED obstacles to account for clearance
static_dilated = self.static_dilated
static_prepared = self.static_prepared
# Optimization: Sort candidates by approximate distance to origin
# (Using a simpler distance measure for speed)
def approx_dist_sq(obj_id):
b = static_dilated[obj_id].bounds
return (b[0] - origin.x)**2 + (b[1] - origin.y)**2
candidates.sort(key=approx_dist_sq)
ray_line = None # Lazy creation
for obj_id in candidates:
obstacle = self.static_dilated[obj_id]
# Fast check with prepared geom? intersects() is fast, intersection() gives point
if self.static_prepared[obj_id].intersects(ray_line):
b = static_dilated[obj_id].bounds
# Fast Ray-Box intersection (Slab Method)
# Correctly handle potential for dx=0 or dy=0
if abs(dx) < 1e-12:
if origin.x < b[0] or origin.x > b[2]:
continue
tx_min, tx_max = -1e30, 1e30
else:
tx_min = (b[0] - origin.x) * inv_dx
tx_max = (b[2] - origin.x) * inv_dx
if tx_min > tx_max: tx_min, tx_max = tx_max, tx_min
if abs(dy) < 1e-12:
if origin.y < b[1] or origin.y > b[3]:
continue
ty_min, ty_max = -1e30, 1e30
else:
ty_min = (b[1] - origin.y) * inv_dy
ty_max = (b[3] - origin.y) * inv_dy
if ty_min > ty_max: ty_min, ty_max = ty_max, ty_min
t_min = max(tx_min, ty_min)
t_max = min(tx_max, ty_max)
# Intersection if [t_min, t_max] intersects [0, 1]
if t_max < 0 or t_min > t_max or t_min >= (min_dist / max_dist) or t_min > 1.0:
continue
# Optimization: If it's a rectangle, the slab result is exact!
if self.static_is_rect[obj_id]:
min_dist = max(0.0, t_min * max_dist)
continue
# If we are here, the ray hits the AABB. Now check the actual polygon.
if ray_line is None:
ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)])
if static_prepared[obj_id].intersects(ray_line):
# Calculate exact intersection distance
intersection = ray_line.intersection(obstacle)
intersection = ray_line.intersection(static_dilated[obj_id])
if intersection.is_empty:
continue
# Intersection could be MultiLineString or LineString or Point
# We want the point closest to origin
# Helper to get dist
def get_dist(geom):
if hasattr(geom, 'geoms'): # Multi-part
return min(get_dist(g) for g in geom.geoms)
# For line string, the intersection is the segment INSIDE the obstacle.
# The distance is the distance to the start of that segment.
# Or if it's a touch (Point), distance to point.
coords = geom.coords
# Distance to the first point of the intersection geometry
# (Assuming simple overlap, first point is entry)
p1 = coords[0]
return numpy.sqrt((p1[0] - origin.x)**2 + (p1[1] - origin.y)**2)
try:
d = get_dist(intersection)
# Subtract safety margin to be safe? No, let higher level handle margins.
if d < min_dist:
min_dist = d
# Update ray_line for more aggressive pruning?
# Actually just update min_dist and we use it in the t_min check.
except Exception:
pass # Robustness
pass
return min_dist