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.
This commit is contained in:
2026-09-27 06:30:43 -06:00
parent b26fdd96d5
commit 3ad5c983f5
6 changed files with 115 additions and 10 deletions
+32 -3
View File
@@ -27,6 +27,7 @@ import math
from freeroute.dsn.model import DsnBoard from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex
from freeroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard from .board import BasicBoard
from .clearance import ClearanceMatrix 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] signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
for keepout in dsn.keepouts: for keepout in dsn.keepouts:
# A place_keepout only restricts where parts go, not copper.
if keepout.kind == "place_keepout":
continue
area = keepout.area area = keepout.area
if area is None or area.border is None: if area is None or area.border is None:
continue continue
tile = _area_border_tile(area.border, transform) tiles = _area_border_tiles(area.border, transform)
if tile is None: if not tiles:
continue continue
border_layer = area.border.layer border_layer = area.border.layer
if border_layer in ("signal", "pcb"): if border_layer in ("signal", "pcb"):
@@ -237,11 +241,36 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
ObstacleArea( ObstacleArea(
name=keepout.area.name, name=keepout.area.name,
layer=layer_index, 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): def _area_border_tile(shape: Shape, transform: CoordinateTransform):
"""Convert an absolute-coordinate keepout border shape to one board tile.""" """Convert an absolute-coordinate keepout border shape to one board tile."""
if isinstance(shape, Rectangle): if isinstance(shape, Rectangle):
+2 -1
View File
@@ -112,6 +112,7 @@ class Image:
outlines: list[Shape] = field(default_factory=list) outlines: list[Shape] = field(default_factory=list)
keepouts: list[Area] = field(default_factory=list) keepouts: list[Area] = field(default_factory=list)
via_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) place_keepouts: list[Area] = field(default_factory=list)
@@ -174,7 +175,7 @@ class Keepout:
"""A structure-level keepout area with its kind.""" """A structure-level keepout area with its kind."""
area: Area area: Area
kind: str = "keepout" # keepout | via_keepout | place_keepout kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout
@dataclass @dataclass
+9
View File
@@ -178,6 +178,11 @@ def _read_structure(node: SExp, board: DsnBoard) -> None:
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
board.keepouts.append(Keepout(area=area, kind="via_keepout")) 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": elif head == "place_keepout":
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
@@ -285,6 +290,10 @@ def _read_image(node: SExp) -> Image | None:
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
image.via_keepouts.append(area) 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": elif head == "place_keepout":
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
+4 -1
View File
@@ -199,7 +199,10 @@ class GridRouter:
elif isinstance(item, ObstacleArea): elif isinstance(item, ObstacleArea):
if item.layer not in self.layers: if item.layer not in self.layers:
continue continue
box = item.bounding_box() # 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(): if box.is_empty():
continue continue
for cell in self._cells_in_box(box, self.clearance): for cell in self._cells_in_box(box, self.clearance):
+58
View File
@@ -17,6 +17,7 @@ import pytest
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board
from freeroute.dsn import parse_dsn from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 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" assert routed, "oracle produced no routed nets"
# every net the reference router routed exists on our constructed board # every net the reference router routed exists on our constructed board
assert routed <= board.nets.names() 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
+6 -1
View File
@@ -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: with tempfile.NamedTemporaryFile(suffix=".ses", delete=False) as tmp:
out_path = tmp.name out_path = tmp.name
try: 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( 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, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
check=False, check=False,
env=env,
) )
text = Path(out_path).read_text() text = Path(out_path).read_text()
finally: finally: