boardwright 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boardwright/__init__.py +1 -0
- boardwright/__main__.py +5 -0
- boardwright/board.py +64 -0
- boardwright/build.py +425 -0
- boardwright/check.py +60 -0
- boardwright/circuit.py +208 -0
- boardwright/cli.py +99 -0
- boardwright/copper.py +211 -0
- boardwright/edits.py +110 -0
- boardwright/env.py +90 -0
- boardwright/existing.py +377 -0
- boardwright/live.py +257 -0
- boardwright/model.py +258 -0
- boardwright/pins.py +48 -0
- boardwright/placement.py +91 -0
- boardwright/preview.py +135 -0
- boardwright/project.py +186 -0
- boardwright/review.py +252 -0
- boardwright/route.py +343 -0
- boardwright/sch.py +381 -0
- boardwright/selection.py +67 -0
- boardwright/si.py +110 -0
- boardwright/verify.py +42 -0
- boardwright-0.1.0.dist-info/METADATA +163 -0
- boardwright-0.1.0.dist-info/RECORD +28 -0
- boardwright-0.1.0.dist-info/WHEEL +4 -0
- boardwright-0.1.0.dist-info/entry_points.txt +2 -0
- boardwright-0.1.0.dist-info/licenses/LICENSE +201 -0
boardwright/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
boardwright/__main__.py
ADDED
boardwright/board.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Board-side helpers: explicit placement, outline, GND pours, routing glue.
|
|
2
|
+
|
|
3
|
+
Coordinates are board-relative mm (0,0 = top-left of the outline), as
|
|
4
|
+
kicad-tools' update_footprint_position takes them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from kicad_tools.schema.pcb import PCB
|
|
13
|
+
|
|
14
|
+
from . import env
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def kct(*args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
18
|
+
r = subprocess.run([*env.kct(), *args], capture_output=True, text=True)
|
|
19
|
+
if check and r.returncode != 0:
|
|
20
|
+
raise RuntimeError(f"kct {' '.join(args)} -> {r.returncode}\n{r.stdout[-3000:]}\n{r.stderr[-3000:]}")
|
|
21
|
+
return r
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create(sch: Path, pcb: Path, w: float, h: float, layers: int = 2) -> None:
|
|
25
|
+
kct("create-pcb", str(sch), "-o", str(pcb), "--width", str(w), "--height", str(h), "--layers", str(layers))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def place(pcb_path: Path, placements: dict[str, tuple], circuit=None, board_dir: Path | None = None) -> None:
|
|
29
|
+
"""placements: ref -> (x, y, rot) or (x, y, rot, 'B') for bottom side."""
|
|
30
|
+
pcb = PCB.load(pcb_path)
|
|
31
|
+
refs = {fp.reference for fp in pcb.footprints}
|
|
32
|
+
missing = refs - set(placements)
|
|
33
|
+
if missing:
|
|
34
|
+
raise ValueError(f"unplaced: {sorted(missing)}")
|
|
35
|
+
parts = {p.ref: p for p in circuit.parts} if circuit else {}
|
|
36
|
+
for ref, spec in placements.items():
|
|
37
|
+
x, y, rot = spec[:3]
|
|
38
|
+
if len(spec) > 3 and spec[3] == "B":
|
|
39
|
+
fp = pcb.get_footprint(ref)
|
|
40
|
+
nets = {pad.number: pad.net_name for pad in fp.pads if pad.net_name}
|
|
41
|
+
p = parts[ref]
|
|
42
|
+
nick, name = p.footprint.split(":")
|
|
43
|
+
pcb.remove_footprint(ref)
|
|
44
|
+
pcb.add_footprint_from_file(board_dir / "libs" / f"{nick}.pretty" / f"{name}.kicad_mod",
|
|
45
|
+
ref, x, y, rot, layer="B.Cu", value=p.value,
|
|
46
|
+
library_id=p.footprint)
|
|
47
|
+
for num, net in nets.items():
|
|
48
|
+
pcb.assign_net_to_footprint_pad(ref, num, net)
|
|
49
|
+
elif not pcb.update_footprint_position(ref, x, y, rot):
|
|
50
|
+
raise ValueError(f"no footprint {ref}")
|
|
51
|
+
pcb.save(pcb_path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def gnd_pour(pcb_path: Path, net: str = "GND", layers=("F.Cu", "B.Cu"), clearance=0.3, solid=False) -> None:
|
|
55
|
+
"""Whole-board pour. solid=True connects pads without thermal reliefs (power boards)."""
|
|
56
|
+
from . import copper
|
|
57
|
+
from .model import Board
|
|
58
|
+
t = pcb_path.read_text()
|
|
59
|
+
bd = Board(pcb_path)
|
|
60
|
+
ox, oy = bd.origin
|
|
61
|
+
x0, y0, x1, y1 = bd.bbox
|
|
62
|
+
rect = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
|
63
|
+
copper.append(pcb_path, [copper.zone(copper._net(t, net, True), layer, rect, ox, oy, 0, clearance, solid, 0.25)
|
|
64
|
+
for layer in layers])
|
boardwright/build.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Build driver.
|
|
2
|
+
|
|
3
|
+
boardwright gen <board dir> # sch + pcb + place + pour + route
|
|
4
|
+
boardwright check <board dir> # the KiCad gate (ERC, DRC, exports)
|
|
5
|
+
boardwright verify <board dir> # parse the reports, gate
|
|
6
|
+
|
|
7
|
+
A board dir holds board.py defining: NAME, CIRCUIT, W, H, PLACE, and optionally
|
|
8
|
+
PREROUTE(pcb) for hand-routed traces, ROUTE_SKIP (nets left to pours),
|
|
9
|
+
POURS (list of (net, layers)).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from kicad_tools.schema.pcb import PCB
|
|
22
|
+
|
|
23
|
+
from . import board as B
|
|
24
|
+
from . import copper, env
|
|
25
|
+
from .circuit import write_schematic
|
|
26
|
+
from .project import vendor_libs, write_project
|
|
27
|
+
from .review import review
|
|
28
|
+
from .verify import compare
|
|
29
|
+
|
|
30
|
+
SCH_OUT = ("erc.json", "netlist.xml", "bom.csv", "schematic.pdf")
|
|
31
|
+
KEEP = {"board.py", ".libmerge", "libs", "sym-lib-table", "fp-lib-table", "notes.md"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load(board_dir: Path):
|
|
35
|
+
spec = importlib.util.spec_from_file_location("board_def", board_dir / "board.py")
|
|
36
|
+
m = importlib.util.module_from_spec(spec)
|
|
37
|
+
spec.loader.exec_module(m)
|
|
38
|
+
if getattr(m, "LIBS", None):
|
|
39
|
+
from .project import merge_libs
|
|
40
|
+
merge_libs([(board_dir / p).resolve() for p in m.LIBS], board_dir)
|
|
41
|
+
return m
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clean(d: Path) -> None:
|
|
45
|
+
for p in d.iterdir():
|
|
46
|
+
if p.name in KEEP:
|
|
47
|
+
continue
|
|
48
|
+
if p.is_dir():
|
|
49
|
+
shutil.rmtree(p)
|
|
50
|
+
else:
|
|
51
|
+
p.unlink()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def stage_place(d: Path, m) -> Path:
|
|
55
|
+
"""Everything up to a placed, unrouted board: seconds, no autorouter."""
|
|
56
|
+
# Schematic-side gate outputs survive a regeneration that leaves the
|
|
57
|
+
# schematic byte-identical (the usual case: only the layout changed).
|
|
58
|
+
old_sch = d / f"{m.NAME}.kicad_sch"
|
|
59
|
+
old_text = old_sch.read_text() if old_sch.exists() else None
|
|
60
|
+
keep = {n: (d / "out" / n).read_bytes() for n in SCH_OUT if (d / "out" / n).exists()}
|
|
61
|
+
clean(d)
|
|
62
|
+
out = d / "out"
|
|
63
|
+
out.mkdir()
|
|
64
|
+
vendor_libs(m.CIRCUIT, d)
|
|
65
|
+
write_project(d, m.NAME, getattr(m, "RULES", None), getattr(m, "CLASSES", None), getattr(m, "SEVERITIES", None))
|
|
66
|
+
sch, pcb = d / f"{m.NAME}.kicad_sch", d / f"{m.NAME}.kicad_pcb"
|
|
67
|
+
print("schematic:", write_schematic(m.CIRCUIT, sch))
|
|
68
|
+
if old_text is not None and sch.read_text() == old_text and len(keep) == len(SCH_OUT):
|
|
69
|
+
for n, data in keep.items():
|
|
70
|
+
(out / n).write_bytes(data)
|
|
71
|
+
print("schematic unchanged: kept", ", ".join(SCH_OUT))
|
|
72
|
+
check_parts(m.CIRCUIT, d)
|
|
73
|
+
B.create(sch, pcb, m.W, m.H, getattr(m, "LAYERS", 2))
|
|
74
|
+
from .placement import resolve
|
|
75
|
+
place = resolve(pcb, m.PLACE, m.CIRCUIT, d)
|
|
76
|
+
(out / "placement.json").write_text(json.dumps(place, indent=1))
|
|
77
|
+
B.place(pcb, place, m.CIRCUIT, d)
|
|
78
|
+
p = PCB.load(pcb)
|
|
79
|
+
for ref, num, name in m.CIRCUIT.nc_nets: # KiCad's own name for a no-connect pad
|
|
80
|
+
p.assign_net_to_footprint_pad(ref, num, name)
|
|
81
|
+
if hasattr(m, "PREROUTE"):
|
|
82
|
+
m.PREROUTE(p)
|
|
83
|
+
p.save(pcb)
|
|
84
|
+
copper.apply(pcb, m, "pre") # VIAS, TRACKS, KEEPOUTS: fixed before routing
|
|
85
|
+
return pcb
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def place_only(d: Path) -> int:
|
|
89
|
+
"""`place` step: placed board + hand copper + instant preview, no routing."""
|
|
90
|
+
m = load(d)
|
|
91
|
+
pcb = stage_place(d, m)
|
|
92
|
+
from .preview import write
|
|
93
|
+
write(pcb, d / "out" / "preview.png")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def gen(d: Path) -> None:
|
|
98
|
+
m = load(d)
|
|
99
|
+
pcb = stage_place(d, m)
|
|
100
|
+
pours = getattr(m, "POURS", [("GND", ("F.Cu", "B.Cu"))])
|
|
101
|
+
if getattr(m, "POUR_FIRST", False): # let Freerouting treat pours as planes
|
|
102
|
+
for net, layers, *solid in pours:
|
|
103
|
+
B.gnd_pour(pcb, net, layers, solid=bool(solid and solid[0]))
|
|
104
|
+
route(d, m, pcb)
|
|
105
|
+
copper.apply(pcb, m, "post") # ZONES (local power polygons), TEXT
|
|
106
|
+
if not getattr(m, "POUR_FIRST", False): # route every net as traces, then pour
|
|
107
|
+
for net, layers, *solid in pours:
|
|
108
|
+
B.gnd_pour(pcb, net, layers, solid=bool(solid and solid[0]))
|
|
109
|
+
for layer, net in getattr(m, "PLANES", {}).items(): # inner planes, after routing: see AGENTS.md
|
|
110
|
+
B.gnd_pour(pcb, net, (layer,), solid=False)
|
|
111
|
+
# kct writes its own .kicad_pro/.kicad_dru sidecars; ours wins.
|
|
112
|
+
write_project(d, m.NAME, getattr(m, "RULES", None), getattr(m, "CLASSES", None), getattr(m, "SEVERITIES", None))
|
|
113
|
+
for junk in d.glob(f"{m.NAME}.*.json"):
|
|
114
|
+
junk.unlink()
|
|
115
|
+
from .model import Board
|
|
116
|
+
from .review import crossings
|
|
117
|
+
for c in crossings(Board(pcb), m):
|
|
118
|
+
print("REVIEW:", c)
|
|
119
|
+
print("generated", pcb)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def route(d: Path, m, pcb: Path) -> None:
|
|
123
|
+
"""Freerouting, clean up, then finish what it left open.
|
|
124
|
+
|
|
125
|
+
Freerouting abandons a net or two on some runs of a dense board, and which
|
|
126
|
+
ones changes with any small change to the input. So: route, let the
|
|
127
|
+
pad-to-pad router complete the open connections, and if some are boxed in
|
|
128
|
+
by the autorouter's own tracks, start again from the unrouted board with a
|
|
129
|
+
slightly different bottom-layer cost. The first attempt with nothing open
|
|
130
|
+
is kept; otherwise the best one, with a warning."""
|
|
131
|
+
from .model import Board
|
|
132
|
+
from .preview import ratsnest
|
|
133
|
+
poured = ({p[0] for p in getattr(m, "POURS", [("GND",)])} | {z[0] for z in getattr(m, "ZONES", [])}
|
|
134
|
+
| set(getattr(m, "PLANES", {}).values()))
|
|
135
|
+
base = getattr(m, "FR_BOTTOM_COST", None)
|
|
136
|
+
costs = [base] if not base else [base, round(base * 0.8, 1), round(base * 1.25, 1), round(base * 0.6, 1)]
|
|
137
|
+
costs = costs[:getattr(m, "FR_ATTEMPTS", 3)] if base else [None, None][:1]
|
|
138
|
+
unrouted = pcb.read_text()
|
|
139
|
+
best = None
|
|
140
|
+
for n, cost in enumerate(costs):
|
|
141
|
+
if n:
|
|
142
|
+
pcb.write_text(unrouted)
|
|
143
|
+
print(f"attempt {n + 1}: bottom cost {cost}")
|
|
144
|
+
_route_once(d, m, pcb, "", cost, str(getattr(m, "FR_PASSES", 100)))
|
|
145
|
+
left = sorted({x for *_p, x in ratsnest(Board(pcb), poured)})
|
|
146
|
+
print("after the autorouter, nets still open (poured nets not checked):", left or "none")
|
|
147
|
+
if left:
|
|
148
|
+
left = complete(pcb, m, poured)
|
|
149
|
+
if best is None or len(left) < len(best[0]):
|
|
150
|
+
best = (left, pcb.read_text())
|
|
151
|
+
if not left:
|
|
152
|
+
break
|
|
153
|
+
if best[1] != pcb.read_text():
|
|
154
|
+
pcb.write_text(best[1])
|
|
155
|
+
if best[0]:
|
|
156
|
+
print("WARNING: still open after every attempt, hand-route these:", best[0])
|
|
157
|
+
B.kct("place-silk-refs", str(pcb), "--allow-rotate", "-o", str(pcb), check=False)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def complete(pcb: Path, m, poured: set) -> list[str]:
|
|
161
|
+
"""Route what the autorouter left open, one connection at a time, with the
|
|
162
|
+
pad-to-pad router. Freerouting abandons a net or two on some runs of a
|
|
163
|
+
dense board; this makes the outcome independent of that."""
|
|
164
|
+
from .model import Board
|
|
165
|
+
from .preview import ratsnest
|
|
166
|
+
from .route import route
|
|
167
|
+
classes = getattr(m, "CLASSES", {})
|
|
168
|
+
default = classes.get("Default", (0.2, 0.25, 0.6, 0.3))
|
|
169
|
+
by_net = {n: spec for name, spec in classes.items() if name != "Default" for n in spec[4]}
|
|
170
|
+
planes = set(getattr(m, "PLANES", {}))
|
|
171
|
+
import time
|
|
172
|
+
deadline = time.time() + getattr(m, "COMPLETE_SECONDS", 90)
|
|
173
|
+
for _round in range(3):
|
|
174
|
+
b = Board(pcb)
|
|
175
|
+
todo = ratsnest(b, poured)
|
|
176
|
+
if not todo:
|
|
177
|
+
return []
|
|
178
|
+
if len(todo) > 12: # this is not a board with a stray net or two: do not grind through it
|
|
179
|
+
print(f"{len(todo)} connections open: too many for the completion pass")
|
|
180
|
+
break
|
|
181
|
+
chunks, extra = [], []
|
|
182
|
+
ox, oy = b.origin
|
|
183
|
+
t = pcb.read_text()
|
|
184
|
+
for o in todo:
|
|
185
|
+
net = o[2]
|
|
186
|
+
clr, width, via_d, via_drill = (by_net.get(net) or default)[:4]
|
|
187
|
+
if time.time() > deadline:
|
|
188
|
+
break
|
|
189
|
+
r = route(b, o.a, o.b, width=width, clearance=clr, via=(via_d, via_drill),
|
|
190
|
+
layers=[l for l in b.layers if l not in planes], extra=extra,
|
|
191
|
+
max_nodes=200_000, grow=1)
|
|
192
|
+
if not r:
|
|
193
|
+
continue
|
|
194
|
+
for tr in r.tracks:
|
|
195
|
+
chunks.append(copper.track(copper._net(t, net), tr[1], ox, oy, tr[2], tr[3]))
|
|
196
|
+
extra.append(("t", net, tr[1], tr[2], tr[3]))
|
|
197
|
+
for v in r.vias:
|
|
198
|
+
chunks.append(copper.via(copper._net(t, net), v[1], v[2], ox, oy, v[3], v[4]))
|
|
199
|
+
extra.append(("v", net, v[1], v[2], v[3]))
|
|
200
|
+
print(f"completed {o.a} -> {o.b} ({net}): {r.length:.1f} mm, {len(r.vias)} via(s)")
|
|
201
|
+
if not chunks:
|
|
202
|
+
break
|
|
203
|
+
copper.append(pcb, chunks)
|
|
204
|
+
return sorted({n for *_p, n in ratsnest(Board(pcb), poured)})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _route_once(d: Path, m, pcb: Path, tag: str, bcost, passes: str) -> None:
|
|
208
|
+
dsn, ses = d / "out" / f"{m.NAME}{tag}.dsn", d / "out" / f"{m.NAME}{tag}.ses"
|
|
209
|
+
B.kct("pcb", "export-dsn", str(pcb), "-o", str(dsn))
|
|
210
|
+
# FR_MARGIN (mm) routes wider than the DRC clearance. Costs routability on
|
|
211
|
+
# fine-pitch parts, so it is off unless a board asks for it.
|
|
212
|
+
margin = getattr(m, "FR_MARGIN", 0) * 1000
|
|
213
|
+
if margin:
|
|
214
|
+
dsn.write_text(re.sub(r"\(clearance ([\d.]+)", lambda mm: f"(clearance {float(mm.group(1)) + margin}", dsn.read_text()))
|
|
215
|
+
cmd = [*env.freerouting(), "-de", str(dsn), "-do", str(ses), "-mp", passes]
|
|
216
|
+
planes = getattr(m, "PLANES", {})
|
|
217
|
+
if bcost or planes:
|
|
218
|
+
# A Freerouting rules file. Only the layer rules act in 2.5 (fanout, via
|
|
219
|
+
# and rip-up lines are ignored). Measured on stm32f103_min: no file =
|
|
220
|
+
# 798 mm of bottom track, bottom cost 20 = 195 mm with everything
|
|
221
|
+
# routed, cost 60 = 7 nets abandoned. The top layer's against-direction
|
|
222
|
+
# cost must stay 2.5; at 1.0 nets were abandoned on every run.
|
|
223
|
+
# Plane layers are switched off for routing so the planes stay whole.
|
|
224
|
+
from .model import Board
|
|
225
|
+
rules = d / "out" / f"{m.NAME}.rules"
|
|
226
|
+
body = ""
|
|
227
|
+
for i, layer in enumerate(Board(pcb).layers):
|
|
228
|
+
if layer in planes:
|
|
229
|
+
body += f" (layer_rule {layer}\n (active off)\n )\n"
|
|
230
|
+
continue
|
|
231
|
+
cost = (bcost or 1.0) if layer == "B.Cu" else 1.0
|
|
232
|
+
against = cost if layer == "B.Cu" and bcost else 2.5
|
|
233
|
+
body += (f" (layer_rule {layer}\n (active on)\n"
|
|
234
|
+
f" (preferred_direction {'horizontal' if i % 2 == 0 else 'vertical'})\n"
|
|
235
|
+
f" (preferred_direction_trace_costs {cost})\n"
|
|
236
|
+
f" (against_preferred_direction_trace_costs {against})\n )\n")
|
|
237
|
+
rules.write_text(f"(rules PCB {m.NAME}\n (autoroute_settings\n{body} )\n)\n")
|
|
238
|
+
cmd += ["-dr", str(rules)]
|
|
239
|
+
r = subprocess.run(cmd,
|
|
240
|
+
capture_output=True, text=True, timeout=getattr(m, "FR_TIMEOUT", 900))
|
|
241
|
+
(d / "out" / f"freerouting{tag}.log").write_text(r.stdout + r.stderr)
|
|
242
|
+
if not ses.exists():
|
|
243
|
+
raise RuntimeError("freerouting produced no SES; see out/freerouting.log")
|
|
244
|
+
B.kct("pcb", "import-ses", str(pcb), str(ses), "-o", str(pcb))
|
|
245
|
+
# Freerouting necks traces down to squeeze between pads; never below the fab minimum.
|
|
246
|
+
minw = (getattr(m, "RULES", None) or {}).get("min_track_width", 0.15)
|
|
247
|
+
text = pcb.read_text()
|
|
248
|
+
seg = re.compile(r'(\(segment\s+\(start [^)]*\)\s+\(end [^)]*\)\s+\(width )([0-9.]+)\)')
|
|
249
|
+
text, n = seg.subn(lambda mm: mm.group(1) + (f"{minw})" if float(mm.group(2)) < minw else mm.group(2) + ")"), text)
|
|
250
|
+
# import-ses re-adds the protected vias we placed before routing: drop exact duplicates
|
|
251
|
+
seen, dup = set(), 0
|
|
252
|
+
def _dedupe(mm):
|
|
253
|
+
nonlocal dup
|
|
254
|
+
x, y = re.search(r"\(at ([-\d.]+) ([-\d.]+)", mm.group(0)).groups()
|
|
255
|
+
key = (round(float(x), 3), round(float(y), 3))
|
|
256
|
+
if key in seen:
|
|
257
|
+
dup += 1
|
|
258
|
+
return ""
|
|
259
|
+
seen.add(key)
|
|
260
|
+
return mm.group(0)
|
|
261
|
+
text = re.sub(r'\n\s*\(via\b(?:[^()]|\([^()]*\))*\)', _dedupe, text)
|
|
262
|
+
# ...and the protected hand-routed segments too (they double every measured length)
|
|
263
|
+
sseen = set()
|
|
264
|
+
def _sdedupe(mm):
|
|
265
|
+
f = [round(float(v), 3) for v in re.findall(r"\((?:start|end) ([-\d.]+) ([-\d.]+)\)", mm.group(0))[0]
|
|
266
|
+
+ re.findall(r"\((?:start|end) ([-\d.]+) ([-\d.]+)\)", mm.group(0))[1]]
|
|
267
|
+
key = (frozenset([(f[0], f[1]), (f[2], f[3])]), re.search(r'\(layer "([^"]+)"', mm.group(0)).group(1))
|
|
268
|
+
if key in sseen:
|
|
269
|
+
return ""
|
|
270
|
+
sseen.add(key)
|
|
271
|
+
return mm.group(0)
|
|
272
|
+
text = re.sub(r'\n\s*\(segment\b(?:[^()]|\([^()]*\))*\)', _sdedupe, text)
|
|
273
|
+
m._pcb = pcb
|
|
274
|
+
text = _drop_dangling_vias(text, m)
|
|
275
|
+
pcb.write_text(text)
|
|
276
|
+
text = _drop_dangling_tracks(pcb, text, m)
|
|
277
|
+
pcb.write_text(text)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _drop_dangling_vias(text: str, m) -> str:
|
|
281
|
+
"""Freerouting leaves vias from abandoned attempts: no track on one side.
|
|
282
|
+
|
|
283
|
+
Vias on poured nets and hand-placed VIAS are kept (a pour connects them).
|
|
284
|
+
"""
|
|
285
|
+
from . import copper
|
|
286
|
+
poured = {p[0] for p in getattr(m, "POURS", [("GND",)])} | {z[0] for z in getattr(m, "ZONES", [])}
|
|
287
|
+
ends: dict[tuple[float, float], set[str]] = {}
|
|
288
|
+
for x1, y1, x2, y2, _w, layer, _net in copper.segments(text):
|
|
289
|
+
for x, y in ((x1, y1), (x2, y2)):
|
|
290
|
+
ends.setdefault((round(x, 2), round(y, 2)), set()).add(layer)
|
|
291
|
+
names = dict(re.findall(r'\n\s*\(net (\d+) "([^"]*)"\)', text))
|
|
292
|
+
ox, oy = copper.origin(m._pcb)
|
|
293
|
+
placed = {(round(v[1] + ox, 2), round(v[2] + oy, 2)) for v in getattr(m, "VIAS", [])}
|
|
294
|
+
def keep(mm):
|
|
295
|
+
b = mm.group(0)
|
|
296
|
+
x, y = re.search(r"\(at ([-\d.]+) ([-\d.]+)", b).groups()
|
|
297
|
+
if (round(float(x), 2), round(float(y), 2)) in placed:
|
|
298
|
+
return b
|
|
299
|
+
n = re.search(r'\(net (?:(\d+)|"([^"]*)")', b)
|
|
300
|
+
net = n.group(2) if n.group(2) is not None else names.get(n.group(1), "")
|
|
301
|
+
if net in poured or len(ends.get((round(float(x), 2), round(float(y), 2)), ())) >= 2:
|
|
302
|
+
return b
|
|
303
|
+
return ""
|
|
304
|
+
return re.sub(r'\n\s*\(via\b(?:[^()]|\([^()]*\))*\)', keep, text)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _drop_dangling_tracks(pcb: Path, text: str, m) -> str:
|
|
308
|
+
"""Remove autorouter stubs: a track end touching no pad, via or other track.
|
|
309
|
+
|
|
310
|
+
Poured nets are left alone (a track may legitimately end in the pour).
|
|
311
|
+
"""
|
|
312
|
+
import math
|
|
313
|
+
|
|
314
|
+
from . import copper
|
|
315
|
+
poured = {p[0] for p in getattr(m, "POURS", [("GND",)])} | {z[0] for z in getattr(m, "ZONES", [])}
|
|
316
|
+
ox, oy = copper.origin(pcb)
|
|
317
|
+
padlist = [(p[3] + ox, p[4] + oy, p.reach + 0.05) for p in copper.pads(pcb)]
|
|
318
|
+
vias = {(round(float(x), 2), round(float(y), 2)) for x, y in re.findall(r"\(via\s+\(at ([-\d.]+) ([-\d.]+)", text)}
|
|
319
|
+
seg_re = re.compile(r'\n\s*\(segment\b(?:[^()]|\([^()]*\))*\)')
|
|
320
|
+
names = dict(re.findall(r'\n\s*\(net (\d+) "([^"]*)"\)', text))
|
|
321
|
+
removed = 0
|
|
322
|
+
while True:
|
|
323
|
+
segs = []
|
|
324
|
+
for mm in seg_re.finditer(text):
|
|
325
|
+
b = mm.group(0)
|
|
326
|
+
(x1, y1), (x2, y2) = [(round(float(a), 2), round(float(c), 2))
|
|
327
|
+
for a, c in re.findall(r"\((?:start|end) ([-\d.]+) ([-\d.]+)\)", b)]
|
|
328
|
+
n = re.search(r'\(net (?:(\d+)|"([^"]*)")', b)
|
|
329
|
+
net = n.group(2) if n.group(2) is not None else names.get(n.group(1), "")
|
|
330
|
+
segs.append((mm.span(), (x1, y1), (x2, y2), re.search(r'\(layer "([^"]+)"', b).group(1), net))
|
|
331
|
+
count: dict = {}
|
|
332
|
+
for _sp, a, b, layer, _n in segs:
|
|
333
|
+
for pt in (a, b):
|
|
334
|
+
count[(pt, layer)] = count.get((pt, layer), 0) + 1
|
|
335
|
+
def held(pt, layer):
|
|
336
|
+
return (count[(pt, layer)] > 1 or pt in vias
|
|
337
|
+
or any(math.hypot(pt[0] - px, pt[1] - py) <= r for px, py, r in padlist))
|
|
338
|
+
drop = [sp for sp, a, b, layer, net in segs
|
|
339
|
+
if net not in poured and not (held(a, layer) and held(b, layer))]
|
|
340
|
+
if not drop:
|
|
341
|
+
break
|
|
342
|
+
for s0, e0 in sorted(drop, reverse=True):
|
|
343
|
+
text = text[:s0] + text[e0:]
|
|
344
|
+
removed += len(drop)
|
|
345
|
+
if removed:
|
|
346
|
+
print(f"removed {removed} dangling autorouter stub segment(s)")
|
|
347
|
+
return text
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _violations(report: Path) -> list[dict]:
|
|
351
|
+
d = json.loads(report.read_text())
|
|
352
|
+
if "sheets" in d:
|
|
353
|
+
return [v for s in d["sheets"] for v in s["violations"]]
|
|
354
|
+
return d.get("violations", []) + d.get("unconnected_items", []) + d.get("schematic_parity", [])
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def verify(d: Path) -> int:
|
|
358
|
+
m = load(d)
|
|
359
|
+
out = d / "out"
|
|
360
|
+
ok = True
|
|
361
|
+
for name in ("erc.json", "drc.json"):
|
|
362
|
+
f = out / name
|
|
363
|
+
if not f.exists():
|
|
364
|
+
print(f"FAIL {name} missing -- run the KiCad gate (boardwright check)"); ok = False; continue
|
|
365
|
+
vs = [v for v in _violations(f) if v.get("severity") != "exclusion"]
|
|
366
|
+
print(f"{'OK ' if not vs else 'FAIL'} {name}: {len(vs)} violations")
|
|
367
|
+
for v in vs[:40]:
|
|
368
|
+
print(" ", v.get("severity"), v.get("type"), v.get("description"),
|
|
369
|
+
[i.get("description") for i in v.get("items", [])][:2])
|
|
370
|
+
ok &= not vs
|
|
371
|
+
nl = out / "netlist.xml"
|
|
372
|
+
if nl.exists():
|
|
373
|
+
probs = compare(m.CIRCUIT, nl)
|
|
374
|
+
print(f"{'OK ' if not probs else 'FAIL'} netlist == intent", *probs, sep="\n ")
|
|
375
|
+
ok &= not probs
|
|
376
|
+
else:
|
|
377
|
+
print("FAIL netlist.xml missing"); ok = False
|
|
378
|
+
pcb = d / f"{m.NAME}.kicad_pcb"
|
|
379
|
+
if (out / "drc.json").exists() and (out / "drc.json").stat().st_mtime < pcb.stat().st_mtime - 2:
|
|
380
|
+
print("FAIL drc.json is older than the board -- rerun the gate"); ok = False
|
|
381
|
+
print("review:")
|
|
382
|
+
rf = review(pcb, m)
|
|
383
|
+
print(f"{'OK ' if not rf else 'FAIL'} review", *rf, sep="\n ")
|
|
384
|
+
ok &= not rf
|
|
385
|
+
gerbers = list((out / "gerbers").glob("*")) if (out / "gerbers").exists() else []
|
|
386
|
+
print(f"{'OK ' if gerbers else 'FAIL'} gerbers+drill: {len(gerbers)} files")
|
|
387
|
+
ok &= bool(gerbers)
|
|
388
|
+
for svg in out.glob("*_Cu.svg"): # copper plots: the only view that shows pours and polygons
|
|
389
|
+
subprocess.run(["rsvg-convert", "-w", "1500", "-b", "white", str(svg), "-o", str(svg.with_suffix(".png"))], check=False)
|
|
390
|
+
print("PASS" if ok else "NOT DONE")
|
|
391
|
+
return 0 if ok else 1
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def check_parts(c, d: Path) -> None:
|
|
395
|
+
"""Every pin a part uses must exist as a pad on its footprint (catches a wrong
|
|
396
|
+
footprint or a custom symbol/footprint pair that disagree)."""
|
|
397
|
+
problems = []
|
|
398
|
+
for p in c.parts:
|
|
399
|
+
if not p.footprint or not p.pins:
|
|
400
|
+
continue
|
|
401
|
+
nick, name = p.footprint.split(":")
|
|
402
|
+
mod = (d / "libs" / f"{nick}.pretty" / f"{name}.kicad_mod").read_text()
|
|
403
|
+
padnames = set(re.findall(r'\(pad "([^"]*)"', mod))
|
|
404
|
+
missing = sorted(set(p.pins) - padnames)
|
|
405
|
+
if missing:
|
|
406
|
+
problems.append(f"{p.ref}: {p.lib_id} pins {missing} have no pad on {p.footprint}")
|
|
407
|
+
unused = sorted(padnames - set(p.pins) - {""})
|
|
408
|
+
if unused:
|
|
409
|
+
print(f"note: {p.ref} footprint pads {unused} have no symbol pin")
|
|
410
|
+
if problems:
|
|
411
|
+
raise ValueError("symbol/footprint mismatch:\n " + "\n ".join(problems))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def pads(d: Path) -> int:
|
|
415
|
+
m = load(d)
|
|
416
|
+
for ref, num, net, x, y, layer in sorted(copper.pads(d / f"{m.NAME}.kicad_pcb")):
|
|
417
|
+
print(f"{ref:<6}{num:<5}{net:<28}{x:>8.3f}{y:>8.3f} {layer}")
|
|
418
|
+
return 0
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def cli(argv: list[str]) -> int:
|
|
422
|
+
argv = ["boardwright", *argv]
|
|
423
|
+
d, step = Path(argv[1]).resolve(), argv[2]
|
|
424
|
+
return ({"gen": lambda: gen(d) or 0, "verify": lambda: verify(d), "pads": lambda: pads(d),
|
|
425
|
+
"place": lambda: place_only(d)}[step]())
|
boardwright/check.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The KiCad gate for a generated board: ERC, netlist, DRC, fab outputs, plots.
|
|
2
|
+
|
|
3
|
+
boardwright check <board dir> every step
|
|
4
|
+
boardwright check <board dir> --quick DRC and copper plots only
|
|
5
|
+
|
|
6
|
+
Writes into <board dir>/out/. DRC runs at every severity with schematic parity,
|
|
7
|
+
refills the zones and saves the board, so the board file holds filled pours.
|
|
8
|
+
`boardwright verify` then reads what this wrote.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import env
|
|
17
|
+
|
|
18
|
+
_SVG = ["pcb", "export", "svg", "--exclude-drawing-sheet", "--page-size-mode", "2", "--mode-single"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def steps(d: Path, quick: bool = False) -> list[list[str]]:
|
|
22
|
+
n = d.name
|
|
23
|
+
pcb, sch, o = str(d / f"{n}.kicad_pcb"), str(d / f"{n}.kicad_sch"), d / "out"
|
|
24
|
+
s = [
|
|
25
|
+
["pcb", "drc", "--format", "json", "--severity-all", "--schematic-parity",
|
|
26
|
+
"--refill-zones", "--save-board", "-o", str(o / "drc.json"), pcb],
|
|
27
|
+
[*_SVG, "--layers", "F.Cu,Edge.Cuts", "-o", str(o / "F_Cu.svg"), pcb],
|
|
28
|
+
[*_SVG, "--layers", "B.Cu,Edge.Cuts", "-o", str(o / "B_Cu.svg"), pcb],
|
|
29
|
+
]
|
|
30
|
+
if not quick:
|
|
31
|
+
s += [
|
|
32
|
+
["sch", "erc", "--format", "json", "--severity-all", "-o", str(o / "erc.json"), sch],
|
|
33
|
+
["sch", "export", "netlist", "--format", "kicadxml", "-o", str(o / "netlist.xml"), sch],
|
|
34
|
+
["pcb", "export", "gerbers", "-o", f"{o / 'gerbers'}/", pcb],
|
|
35
|
+
["pcb", "export", "drill", "-o", f"{o / 'gerbers'}/", pcb],
|
|
36
|
+
["sch", "export", "bom", "-o", str(o / "bom.csv"), sch],
|
|
37
|
+
["pcb", "export", "pos", "--format", "csv", "--units", "mm", "-o", str(o / "cpl.csv"), pcb],
|
|
38
|
+
["sch", "export", "pdf", "-o", str(o / "schematic.pdf"), sch],
|
|
39
|
+
["pcb", "render", "--side", "top", "--width", "1600", "--height", "1250",
|
|
40
|
+
"-o", str(o / "top.png"), pcb],
|
|
41
|
+
]
|
|
42
|
+
return s
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check(d: Path, quick: bool = False) -> int:
|
|
46
|
+
cli = env.kicad_cli()
|
|
47
|
+
(d / "out" / "gerbers").mkdir(parents=True, exist_ok=True)
|
|
48
|
+
for s in steps(d, quick):
|
|
49
|
+
r = subprocess.run([*cli, *s], capture_output=True, text=True)
|
|
50
|
+
if r.returncode != 0:
|
|
51
|
+
print(f"FAILED: {' '.join([*cli, *s])}\n{r.stdout[-2000:]}{r.stderr[-2000:]}")
|
|
52
|
+
return 1
|
|
53
|
+
print("ok ", " ".join(s[:3]))
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cli(argv: list[str]) -> int:
|
|
58
|
+
if not argv:
|
|
59
|
+
raise SystemExit(__doc__)
|
|
60
|
+
return check(Path(argv[0]).resolve(), "--quick" in argv[1:])
|