From 3ad5c983f5dbb68650666a4e2f69d61e943314bd Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 27 Sep 2026 06:30:43 -0600 Subject: [PATCH] Honour KiCad's keepouts: keyhole rings, wire_keepout, per-tile rasterizing On the TerraHawk rc4 power stage every engine misread the board's rule areas: - A no-track ring along the board edge arrives as one keyhole polygon, and keepout polygons were wrapped in a single convex tile, so the ring became the whole board. Split keepout polygons into convex pieces instead. - The grid engine rasterized an area by its overall bounding box, which for that ring is again the whole board; it routed nothing. Rasterize per tile. - "Keep router off" rule areas arrive as wire_keepout, which the reader did not know, so tracks ran through them (172 DRC items_not_allowed). Read it at board and image level. For now it blocks vias too, which is stricter than KiCad and cannot create a violation. - place_keepout only restricts part placement; stop treating it as copper. The oracle now runs the JAR headless; it was opening FreeRouting's GUI on the desktop for every oracle test. --- src/freeroute/board/build.py | 35 ++++++++++++++++-- src/freeroute/dsn/model.py | 3 +- src/freeroute/dsn/reader.py | 9 +++++ src/freeroute/route/grid_router.py | 13 ++++--- tests/board/test_build.py | 58 ++++++++++++++++++++++++++++++ tests/oracle.py | 7 +++- 6 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/freeroute/board/build.py b/src/freeroute/board/build.py index 38f17ca..c85e133 100644 --- a/src/freeroute/board/build.py +++ b/src/freeroute/board/build.py @@ -27,6 +27,7 @@ import math from freeroute.dsn.model import DsnBoard from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex +from freeroute.geometry.polygon_shape import PolygonShape from .board import BasicBoard from .clearance import ClearanceMatrix @@ -219,11 +220,14 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal] for keepout in dsn.keepouts: + # A place_keepout only restricts where parts go, not copper. + if keepout.kind == "place_keepout": + continue area = keepout.area if area is None or area.border is None: continue - tile = _area_border_tile(area.border, transform) - if tile is None: + tiles = _area_border_tiles(area.border, transform) + if not tiles: continue border_layer = area.border.layer if border_layer in ("signal", "pcb"): @@ -237,11 +241,36 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran ObstacleArea( name=keepout.area.name, layer=layer_index, - tiles=[tile], + tiles=tiles, ) ) +def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list: + """Convex board tiles covering a keepout border shape. + + Polygons are split into convex pieces rather than wrapped in one convex + tile: KiCad exports a rule area with a hole (a no-track ring along the + board edge, say) as a single keyhole polygon, and its convex hull is the + whole board. + """ + if isinstance(shape, (Polygon, Path)): + coords = shape.coords + pts = [ + IntPoint( + round(transform.dsn_to_board(coords[i])), + round(transform.dsn_to_board(coords[i + 1])), + ) + for i in range(0, len(coords) - 1, 2) + ] + if len(pts) >= 3: + pieces = [t for t in PolygonShape(pts).split_to_convex() if not t.is_empty()] + if pieces: + return pieces + tile = _area_border_tile(shape, transform) + return [tile] if tile is not None else [] + + def _area_border_tile(shape: Shape, transform: CoordinateTransform): """Convert an absolute-coordinate keepout border shape to one board tile.""" if isinstance(shape, Rectangle): diff --git a/src/freeroute/dsn/model.py b/src/freeroute/dsn/model.py index 782271c..531f710 100644 --- a/src/freeroute/dsn/model.py +++ b/src/freeroute/dsn/model.py @@ -112,6 +112,7 @@ class Image: outlines: list[Shape] = field(default_factory=list) keepouts: list[Area] = field(default_factory=list) via_keepouts: list[Area] = field(default_factory=list) + wire_keepouts: list[Area] = field(default_factory=list) place_keepouts: list[Area] = field(default_factory=list) @@ -174,7 +175,7 @@ class Keepout: """A structure-level keepout area with its kind.""" area: Area - kind: str = "keepout" # keepout | via_keepout | place_keepout + kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout @dataclass diff --git a/src/freeroute/dsn/reader.py b/src/freeroute/dsn/reader.py index 81940b7..679139e 100644 --- a/src/freeroute/dsn/reader.py +++ b/src/freeroute/dsn/reader.py @@ -178,6 +178,11 @@ def _read_structure(node: SExp, board: DsnBoard) -> None: area = read_area(it) if area is not None: board.keepouts.append(Keepout(area=area, kind="via_keepout")) + elif head == "wire_keepout": + # KiCad writes a "no tracks" rule area this way (vias allowed). + area = read_area(it) + if area is not None: + board.keepouts.append(Keepout(area=area, kind="wire_keepout")) elif head == "place_keepout": area = read_area(it) if area is not None: @@ -285,6 +290,10 @@ def _read_image(node: SExp) -> Image | None: area = read_area(it) if area is not None: image.via_keepouts.append(area) + elif head == "wire_keepout": + area = read_area(it) + if area is not None: + image.wire_keepouts.append(area) elif head == "place_keepout": area = read_area(it) if area is not None: diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 00b4fec..354ede9 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -199,11 +199,14 @@ class GridRouter: elif isinstance(item, ObstacleArea): if item.layer not in self.layers: continue - box = item.bounding_box() - if box.is_empty(): - continue - for cell in self._cells_in_box(box, self.clearance): - self._keepout.add((*cell, item.layer)) + # Tile by tile: the area's own bounding box of a ring (a + # no-track border along the board edge) is the whole board. + for tile in item.tiles: + box = tile.bounding_box() + if box.is_empty(): + continue + for cell in self._cells_in_box(box, self.clearance): + self._keepout.add((*cell, item.layer)) # --- occupancy cost ----------------------------------------------------- diff --git a/tests/board/test_build.py b/tests/board/test_build.py index e36f0d2..adddd6d 100644 --- a/tests/board/test_build.py +++ b/tests/board/test_build.py @@ -17,6 +17,7 @@ import pytest from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board from freeroute.dsn import parse_dsn +from freeroute.geometry import IntPoint sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -129,3 +130,60 @@ def test_board_net_set_covers_oracle_routed_nets(board): assert routed, "oracle produced no routed nets" # every net the reference router routed exists on our constructed board assert routed <= board.nets.names() + + +# --- keepouts as KiCad exports them ------------------------------------------ +# Found on the TerraHawk charge-node boards (2026-09-27): a no-track ring along +# the board edge arrives as one keyhole polygon, "keep router off" rule areas +# arrive as wire_keepout, and neither may block the board's interior. + +_RING_DSN = """(pcb ring (parser) (resolution um 10) (unit um) + (structure + (layer F.Cu (type signal)) (layer B.Cu (type signal)) + (boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0)) + (keepout "" (polygon F.Cu 0 0 0 40000 0 40000 -30000 0 -30000 0 0 + 500 -500 500 -29500 39500 -29500 39500 -500 500 -500 0 0)) + (wire_keepout "pour" (polygon F.Cu 0 10000 -10000 20000 -10000 20000 -20000 10000 -20000)) + (place_keepout "courtyard" (polygon F.Cu 0 30000 -10000 35000 -10000 35000 -20000 30000 -20000)) + (rule (width 250) (clearance 150))) + (placement) (library) (network))""" + + +def _areas(dsn_text): + return build_board(parse_dsn(dsn_text)).get_obstacle_areas() + + +def _blocked(areas, x_um, y_um): + p = IntPoint(x_um * 10, y_um * 10) + return any(t.contains(p) for a in areas for t in a.tiles) + + +def test_keyhole_ring_keepout_blocks_only_the_border(): + areas = _areas(_RING_DSN) + assert _blocked(areas, 200, -15000) # inside the 0.5 mm edge strip + assert not _blocked(areas, 5000, -5000) # open board interior + + +def test_wire_keepout_is_read_and_blocks_its_area(): + dsn = parse_dsn(_RING_DSN) + assert [k.kind for k in dsn.keepouts].count("wire_keepout") == 1 + assert _blocked(_areas(_RING_DSN), 15000, -15000) + + +def test_place_keepout_does_not_block_routing(): + assert not _blocked(_areas(_RING_DSN), 32500, -15000) + + +def test_grid_router_leaves_the_interior_of_a_ring_keepout_open(): + from freeroute.route.pipeline import route_dsn_board + + ring_only = "\n".join( + line for line in _RING_DSN.splitlines() if "wire_keepout" not in line + ).replace( + "(placement) (library) (network)", + "(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))\n" + " (library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))\n" + " (network (net N (pins P1-1 P2-1)))", + ) + result, _, _ = route_dsn_board(parse_dsn(ring_only), max_passes=2) + assert result.routed_net_numbers diff --git a/tests/oracle.py b/tests/oracle.py index e526f0d..74bdb56 100644 --- a/tests/oracle.py +++ b/tests/oracle.py @@ -111,12 +111,17 @@ def route_dsn(dsn_path: str | Path, *, max_passes: int = 6, timeout: int = 180) with tempfile.NamedTemporaryFile(suffix=".ses", delete=False) as tmp: out_path = tmp.name try: + # Headless: without these the JAR opens its GUI on the developer's + # desktop for every oracle run. + env = {k: v for k, v in os.environ.items() if k not in ("DISPLAY", "WAYLAND_DISPLAY")} subprocess.run( - [JAVA, "-jar", JAR, "-de", str(dsn_path), "-do", out_path, "-mp", str(max_passes)], + [JAVA, "-Djava.awt.headless=true", "-jar", JAR, "-de", str(dsn_path), + "-do", out_path, "-mp", str(max_passes), "--gui.enabled=false"], capture_output=True, text=True, timeout=timeout, check=False, + env=env, ) text = Path(out_path).read_text() finally: