Skip to content

Commit 25d2642

Browse files
committed
More exit cleanup.
Hex routing avoids board edges. Preflight checks 0.4mm GTL/GBL to edge
1 parent eeb4e1e commit 25d2642

6 files changed

Lines changed: 214 additions & 4 deletions

File tree

‎hexboard.py‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,42 @@ def hex_setup(self):
120120
shapely.points(coordinates), self.route_radius, quad_segs=16)
121121
self.route_tree = STRtree(route_disks)
122122
self.route_point_tree = STRtree(shapely.points(coordinates))
123+
self.route_outline = (
124+
self.outline_polygon if self.outline_polygon is not None
125+
else sg.box(0, 0, *self.size))
126+
outlines = self.layers['GML'].lines
127+
if outlines:
128+
self.route_outline = max(
129+
(sg.Polygon(line) for line in outlines), key=lambda p: p.area)
130+
self.edge_block_cache = {}
123131
self.blocked = {layer: self.layer_blocks(layer) for layer in ('GTL', 'GBL')}
124132
self.routes = []
125133
self.route_widths = []
126134

135+
def edge_blocks(self, width):
136+
"""Reserve trace radius and edge clearance against the perimeter.
137+
138+
Convex insets contain the segments between allowed centers. Concave
139+
outlines need an extra half-step margin to protect those segments.
140+
"""
141+
clearance = float(getattr(self, "hex_edge_clearance", 0))
142+
assert math.isfinite(clearance) and clearance >= 0
143+
key = (width, clearance)
144+
if key not in self.edge_block_cache:
145+
segment_margin = (
146+
0 if self.route_outline.equals(self.route_outline.convex_hull)
147+
else self.hr)
148+
interior = self.route_outline.buffer(
149+
-(clearance + width / 2 + segment_margin))
150+
blocked = self.gr.zeros(np.uint8) | (self.gr.valid == 0)
151+
points = shapely.points([h.to_plane() for h in self.route_hexes])
152+
inside = shapely.covers(interior, points)
153+
for h, allowed in zip(self.route_hexes, inside):
154+
if not allowed:
155+
blocked[h.q, h.r] = 1
156+
self.edge_block_cache[key] = blocked
157+
return self.edge_block_cache[key].copy()
158+
127159
def layer_blocks(
128160
self, nm, width=None, exempt_points=(),
129161
exempt_geometries=()):
@@ -155,7 +187,7 @@ def layer_blocks(
155187
layer_poly = so.unary_union(
156188
copper + drill_keepouts + self.keepouts +
157189
self.route_keepouts[nm]).buffer(0)
158-
blocked = self.gr.zeros(np.uint8) | (self.gr.valid == 0)
190+
blocked = self.edge_blocks(width)
159191
for i in self.route_tree.query(
160192
layer_poly, predicate="intersects"):
161193
h = self.route_hexes[i]
@@ -169,7 +201,7 @@ def layer_blocks(
169201
fixed_geometry = fixed_geometry.buffer(geometry_expansion)
170202
layer_poly = so.unary_union(
171203
[fixed_geometry] + drill_keepouts).buffer(0)
172-
blocked = self.gr.zeros(np.uint8) | (self.gr.valid == 0)
204+
blocked = self.edge_blocks(width)
173205
for i in self.route_tree.query(layer_poly, predicate="intersects"):
174206
h = self.route_hexes[i]
175207
blocked[h.q, h.r] = 1
@@ -247,6 +279,11 @@ def _hex_route_pad_endpoints(self, a, b):
247279
self.pad_hex_cells(b)
248280
if isinstance(b, PadEndpoint)
249281
else frozenset((tuple(Hex.from_xy(*target.xy)),)))
282+
edge_blocked = self.edge_blocks(self.trace)
283+
for endpoint, cells in ((a, source_cells), (b, target_cells)):
284+
if not isinstance(endpoint, PadEndpoint):
285+
assert all(not edge_blocked[q, r] for q, r in cells), (
286+
"Route endpoint violates board-edge clearance")
250287
exempt_geometries = tuple(
251288
endpoint.draw.boundary
252289
for endpoint in (a, b)
@@ -313,6 +350,9 @@ def hex_route(self, a, b):
313350
target = b
314351
a = Hex.from_xy(*source.xy)
315352
b = Hex.from_xy(*target.xy)
353+
edge_blocked = self.edge_blocks(self.trace)
354+
assert not edge_blocked[a.q, a.r] and not edge_blocked[b.q, b.r], (
355+
"Route endpoint violates board-edge clearance")
316356

317357
wavefront = set([tuple(a)])
318358
dirs = [Hex(dq,dr) for (dq, dr) in axial_direction_vectors]
@@ -381,6 +421,11 @@ def hex_route_net(self, terminals, width=None):
381421
else frozenset((tuple(Hex.from_xy(*draw.xy)),)))
382422
for terminal, draw in zip(terminals, draws)
383423
]
424+
edge_blocked = self.edge_blocks(route_width)
425+
for terminal, cells in zip(terminals, endpoint_cells):
426+
if not isinstance(terminal, PadEndpoint):
427+
assert all(not edge_blocked[q, r] for q, r in cells), (
428+
"Route endpoint violates board-edge clearance")
384429
exempt_geometries = tuple(
385430
terminal.draw.boundary
386431
for terminal in terminals

‎preflight/spiq_a.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"excluded_designators": ["J4", "U3"],
3131
"expected_gml_contours": 5,
3232
"expected_gml_contours_reason": "one rounded board perimeter plus J1's four plated USB-C mounting slots",
33+
"copper_edge_clearance_mm": {"GTL": 0.4, "GBL": 0.4},
3334
"topology": {
3435
"copper_layers": ["GTL", "G2L", "G3L", "GBL"],
3536
"net_clearance_mm": 0.1,

‎spiq_a.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ def spiq_a():
416416
via_space = cu.mil(5),
417417
silk = cu.mil(5))
418418
brd.hex_clearance = 0.100
419+
brd.hex_edge_clearance = 0.400
419420

420421
def spiq_logo():
421422
x0, y0 = (0.9, 0.9)
@@ -798,13 +799,13 @@ def ldo_cap(
798799
brd.hex_route(u1.s("QSPI_SCLK"), brd.pad_endpoint(u2.s("CLK")))
799800
brd.hex_route(u1.s("QSPI_SD3"), brd.pad_endpoint(u2.s("IO3")))
800801

801-
brd.hex_route(u1.s("USB_DM"), brd.pad_endpoint(r7.pads[1]))
802802
brd.hex_route(u1.s("USB_DP"), brd.pad_endpoint(r8.pads[1]))
803+
brd.hex_route(u1.s("USB_DM"), brd.pad_endpoint(r7.pads[1]))
803804
brd.hex_route(u1.s("XIN"), brd.pad_endpoint(y1.s("CLK")))
804805

805806
brd.hex_route(u1.s("SWCLK"), brd.pad_endpoint(j4.s("SWCLK")))
806-
brd.hex_route(u1.s("GPIO0"), brd.pad_endpoint(j4.s("TX")))
807807
brd.hex_route(u1.s("GPIO1"), brd.pad_endpoint(j4.s("RX")))
808+
brd.hex_route(u1.s("GPIO0"), brd.pad_endpoint(j4.s("TX")))
808809
brd.hex_route(u1.s("SWDIO"), brd.pad_endpoint(j4.s("SWDIO")))
809810

810811
for (a, b) in zip(bus, j3.pads):

‎tests/test_hexboard.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,39 @@ def terminals(self, board):
3030
result.append(terminal)
3131
return tuple(result)
3232

33+
def test_edge_blocks_follow_outline_on_both_layers(self):
34+
board = self.board()
35+
board.outline_polygon = sg.Polygon([
36+
(0, 3), (3, 0), (12, 0), (12, 12), (0, 12)])
37+
board.hex_edge_clearance = 0.4
38+
board.hex_setup()
39+
corner = Hex.from_xy_fine(1, 1)
40+
inside = Hex.from_xy_fine(6, 6)
41+
for layer in ("GTL", "GBL"):
42+
self.assertTrue(board.blocked[layer][corner.q, corner.r])
43+
self.assertFalse(board.blocked[layer][inside.q, inside.r])
44+
45+
def test_wide_routes_reserve_more_edge_space(self):
46+
board = self.board()
47+
board.hex_edge_clearance = 0.4
48+
narrow = board.edge_blocks(0.1)
49+
wide = board.edge_blocks(0.8)
50+
self.assertTrue(any(
51+
wide[h.q, h.r] and not narrow[h.q, h.r]
52+
for h in board.route_hexes))
53+
54+
def test_point_and_net_endpoints_cannot_override_edge_blocks(self):
55+
board = self.board()
56+
board.hex_edge_clearance = 0.4
57+
board.hex_setup()
58+
a, b, c = self.terminals(board)
59+
a.xy = Hex.from_xy_fine(0.1, 6).to_plane()
60+
for source, target in ((a, b), (b, a)):
61+
with self.assertRaisesRegex(AssertionError, "board-edge"):
62+
board.hex_route(source, target)
63+
with self.assertRaisesRegex(AssertionError, "board-edge"):
64+
board.hex_route_net((a, b, c))
65+
3366
def test_default_width_preserves_single_cell_occupancy(self):
3467
board = self.board()
3568
before = board.blocked["GTL"].copy()

‎tests/test_pcb_preflight_edges.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import tempfile
2+
import unittest
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
import shapely.geometry as sg
7+
8+
from tools import pcb_preflight
9+
10+
11+
def gerber(*polygons):
12+
lines = ["%MOMM*%", "%FSLAX34Y34*%", "%LPD*%"]
13+
for polygon in polygons:
14+
lines.append("G36*")
15+
for i, (x, y) in enumerate(polygon.exterior.coords):
16+
lines.append(
17+
f"X{round(x * 10000):07d}Y{round(y * 10000):07d}"
18+
f"D{2 if i == 0 else 1:02d}*")
19+
lines.append("G37*")
20+
return "\n".join(lines) + "\nM02*\n"
21+
22+
23+
class CopperEdgeTests(unittest.TestCase):
24+
def check_copper(self, copper, outline=None, slots=()):
25+
if outline is None:
26+
outline = sg.box(0, 0, 10, 10)
27+
with tempfile.TemporaryDirectory() as directory:
28+
root = Path(directory)
29+
(root / "board.GML").write_text(gerber(outline, *slots))
30+
(root / "board.GTL").write_text(gerber(*copper))
31+
audit = pcb_preflight.Audit()
32+
with patch.object(pcb_preflight, "ROOT", root):
33+
pcb_preflight.audit_copper_edges(audit, {
34+
"board": "board",
35+
"copper_edge_clearance_mm": {"GTL": 0.4},
36+
})
37+
return audit
38+
39+
def test_exact_clearance_passes(self):
40+
self.assertTrue(self.check_copper([sg.box(.4, .4, 9.6, 9.6)]).passed)
41+
42+
def test_isolated_artwork_too_close_fails(self):
43+
audit = self.check_copper([
44+
sg.box(2, 2, 8, 8), sg.box(.3, 4, .35, 4.1)])
45+
self.assertFalse(audit.passed)
46+
self.assertIn("0.3000 mm", audit.checks[0].detail)
47+
48+
def test_entirely_external_copper_fails(self):
49+
audit = self.check_copper([sg.box(11, 4, 12, 5)])
50+
self.assertFalse(audit.passed)
51+
self.assertIn("outside board", audit.checks[0].detail)
52+
53+
def test_actual_perimeter_not_bounding_box(self):
54+
# A chamfered corner cuts through this otherwise safely inset pad.
55+
outline = sg.Polygon([(0, 2), (2, 0), (10, 0), (10, 10), (0, 10)])
56+
self.assertFalse(self.check_copper(
57+
[sg.box(.5, .5, 1, 1)], outline).passed)
58+
59+
def test_internal_plated_slot_not_treated_as_outer_edge(self):
60+
self.assertTrue(self.check_copper(
61+
[sg.box(3.9, 3.9, 6.1, 6.1)],
62+
slots=[sg.box(4, 4, 6, 6)]).passed)
63+
64+
def test_missing_outline_fails_closed(self):
65+
with tempfile.TemporaryDirectory() as directory:
66+
audit = pcb_preflight.Audit()
67+
with patch.object(pcb_preflight, "ROOT", Path(directory)):
68+
pcb_preflight.audit_copper_edges(audit, {
69+
"board": "missing",
70+
"copper_edge_clearance_mm": {"GBL": 0.4},
71+
})
72+
self.assertFalse(audit.passed)

‎tools/pcb_preflight.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,63 @@ def audit_manufacturing_files(
442442
)
443443

444444

445+
def audit_copper_edges(audit: Audit, profile: dict[str, Any]) -> None:
446+
"""Check all copper, including artwork, against the outer GML perimeter."""
447+
limits = profile.get("copper_edge_clearance_mm")
448+
if not limits:
449+
return
450+
import shapely.geometry as sg
451+
from shapely.ops import nearest_points
452+
if __package__:
453+
from .pcb_topology import (
454+
TopologyFormatError, parse_cuflow_gerber, parse_cuflow_paths_text)
455+
else:
456+
from pcb_topology import (
457+
TopologyFormatError, parse_cuflow_gerber, parse_cuflow_paths_text)
458+
459+
board = profile["board"]
460+
try:
461+
paths = parse_cuflow_paths_text(
462+
(ROOT / f"{board}.GML").read_text(encoding="ascii"))
463+
if not paths or any(not path.is_ring for path in paths):
464+
raise ValueError("GML must contain closed, simple contours")
465+
# Internal slots have separate rules, particularly plated slots.
466+
perimeter = max((sg.Polygon(path) for path in paths),
467+
key=lambda polygon: polygon.area)
468+
if not perimeter.is_valid or perimeter.area <= 0:
469+
raise ValueError("invalid outer GML perimeter")
470+
except (OSError, ValueError, TopologyFormatError) as error:
471+
audit.add("Copper-to-board-edge clearance", False,
472+
f"cannot read perimeter: {error}")
473+
return
474+
475+
for layer, limit in limits.items():
476+
name = f"{layer} copper-to-board-edge clearance"
477+
try:
478+
clearance = float(limit)
479+
if not math.isfinite(clearance) or clearance < 0:
480+
raise ValueError("clearance must be finite and nonnegative")
481+
copper = parse_cuflow_gerber(ROOT / f"{board}.{layer}")
482+
if copper.is_empty:
483+
audit.add(name, True, "no copper on layer")
484+
continue
485+
outside = copper.difference(perimeter)
486+
if not outside.is_empty:
487+
point = outside.representative_point()
488+
audit.add(name, False,
489+
f"copper outside board at ({point.x:.3f}, "
490+
f"{point.y:.3f}) mm; needs {clearance:.3f} mm inset")
491+
continue
492+
distance = copper.distance(perimeter.boundary)
493+
point, _ = nearest_points(copper, perimeter.boundary)
494+
audit.add(name, distance + 1e-9 >= clearance,
495+
f"minimum {distance:.4f} mm; needs {clearance:.3f} mm; "
496+
f"nearest copper at ({point.x:.3f}, {point.y:.3f}) mm "
497+
"(includes unconnected artwork)")
498+
except (OSError, ValueError, TopologyFormatError) as error:
499+
audit.add(name, False, f"cannot check copper: {error}")
500+
501+
445502
def audit_copper_topology(
446503
audit: Audit, profile: dict[str, Any]) -> None:
447504
config = profile.get("topology")
@@ -1794,6 +1851,7 @@ def main() -> int:
17941851
catalog = load_catalog(audit, relative_path(profile["part_catalog"]))
17951852
audit_bom_and_pnp(audit, profile, catalog)
17961853
audit_manufacturing_files(audit, profile)
1854+
audit_copper_edges(audit, profile)
17971855
audit_copper_topology(audit, profile)
17981856

17991857
report_path = args.report or ROOT / f"{args.board}-preflight.html"

0 commit comments

Comments
 (0)