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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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:])