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:
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -199,11 +199,14 @@ 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
|
||||||
if box.is_empty():
|
# no-track border along the board edge) is the whole board.
|
||||||
continue
|
for tile in item.tiles:
|
||||||
for cell in self._cells_in_box(box, self.clearance):
|
box = tile.bounding_box()
|
||||||
self._keepout.add((*cell, item.layer))
|
if box.is_empty():
|
||||||
|
continue
|
||||||
|
for cell in self._cells_in_box(box, self.clearance):
|
||||||
|
self._keepout.add((*cell, item.layer))
|
||||||
|
|
||||||
# --- occupancy cost -----------------------------------------------------
|
# --- occupancy cost -----------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user