"""The board outline as a polygon, and clearance tests against its edge. The DSN boundary is the real Edge.Cuts outline, rounded corners and all (arcs come as short segments). Routing inside its bounding box instead lets copper cut across a rounded corner closer to the edge than the board allows. """ from __future__ import annotations import math from freeroute.geometry import IntBox, IntPoint class BoardOutline: """A simple polygon in board units, with point and box clearance tests.""" def __init__(self, points: list[IntPoint]) -> None: pts = list(points) if len(pts) > 1 and pts[0] == pts[-1]: pts.pop() self.points = pts self._edges = [(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))] def contains(self, p: IntPoint) -> bool: """Even-odd point in polygon; points on the edge count as outside.""" inside = False x, y = p.x, p.y for a, b in self._edges: if (a.y > y) != (b.y > y): t = (y - a.y) / (b.y - a.y) if x < a.x + t * (b.x - a.x): inside = not inside return inside def row_inside_spans(self, y: float) -> list[tuple[float, float]]: """The x intervals inside the polygon along the horizontal line at ``y``.""" xs = sorted( a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y) for a, b in self._edges if (a.y > y) != (b.y > y) ) # an odd count (a line grazing a vertex) drops the unpaired crossing return list(zip(xs[0::2], xs[1::2], strict=False)) def edges(self): return self._edges def edge_distance(self, p: IntPoint) -> float: return min(_point_segment(p, a, b) for a, b in self._edges) def point_clear(self, p: IntPoint, reach: float) -> bool: """Copper reaching ``reach`` around ``p`` stays inside, clear of the edge.""" return self.contains(p) and self.edge_distance(p) >= reach def box_clear(self, box: IntBox, clearance: float) -> bool: """``box`` lies inside the outline at least ``clearance`` from its edge.""" corners = [ IntPoint(box.ll.x, box.ll.y), IntPoint(box.ur.x, box.ll.y), IntPoint(box.ur.x, box.ur.y), IntPoint(box.ll.x, box.ur.y), ] if not all(self.contains(c) for c in corners): return False return all(_segment_box(a, b, box) >= clearance for a, b in self._edges) def _point_segment(p: IntPoint, a: IntPoint, b: IntPoint) -> float: dx, dy = b.x - a.x, b.y - a.y length2 = dx * dx + dy * dy if length2 == 0: return math.hypot(p.x - a.x, p.y - a.y) t = max(0.0, min(1.0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2)) return math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)) def _point_box(p: IntPoint, box: IntBox) -> float: dx = max(box.ll.x - p.x, 0, p.x - box.ur.x) dy = max(box.ll.y - p.y, 0, p.y - box.ur.y) return math.hypot(dx, dy) def _segment_box(a: IntPoint, b: IntPoint, box: IntBox) -> float: """Distance between segment ``ab`` and an axis-aligned box (0 if they touch).""" if _segments_cross_box(a, b, box): return 0.0 corners = [ IntPoint(box.ll.x, box.ll.y), IntPoint(box.ur.x, box.ll.y), IntPoint(box.ur.x, box.ur.y), IntPoint(box.ll.x, box.ur.y), ] return min( _point_box(a, box), _point_box(b, box), *(_point_segment(c, a, b) for c in corners), ) def _segments_cross_box(a: IntPoint, b: IntPoint, box: IntBox) -> bool: """Liang-Barsky clip: does segment ``ab`` enter the box?""" t0, t1 = 0.0, 1.0 dx, dy = b.x - a.x, b.y - a.y for p, q in ( (-dx, a.x - box.ll.x), (dx, box.ur.x - a.x), (-dy, a.y - box.ll.y), (dy, box.ur.y - a.y), ): if p == 0: if q < 0: return False else: r = q / p if p < 0: t0 = max(t0, r) else: t1 = min(t1, r) if t0 > t1: return False return True