@huaqiu/dsh-kicad 0.5.1 → 0.5.2
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.
- package/lib/index.mjs +5 -0
- package/package.json +2 -2
- package/skills/hardware-design-brief/SKILL.md +65 -31
- package/skills/hardware-design-brief/agents/openai.yaml +3 -3
- package/skills/hardware-design-brief/references/design-brief-guide.md +84 -11
- package/skills/hardware-design-brief/references/examples/hardware-solution-example.md +442 -0
- package/skills/hardware-design-brief/references/hardware-prd-guide.md +53 -10
- package/skills/hardware-design-brief/references/mrd-guide.md +45 -10
- package/skills/hardware-design-brief/references/multi-board-guide.md +51 -0
- package/skills/hardware-design-brief/references/project-controls.md +45 -0
- package/skills/hardware-design-brief/references/solution-deliverable.md +55 -0
- package/skills/hardware-design-brief/references/stage-boundaries.md +32 -0
- package/skills/pcb-initial-placement/SKILL.md +343 -0
- package/skills/pcb-initial-placement/agents/openai.yaml +4 -0
- package/skills/pcb-initial-placement/references/algorithm.md +317 -0
- package/skills/pcb-initial-placement/scripts/apply_via_ipc.py +211 -0
- package/skills/pcb-initial-placement/scripts/board_geometry.py +368 -0
- package/skills/pcb-initial-placement/scripts/kipy_common.py +80 -0
- package/skills/pcb-initial-placement/scripts/pack.py +576 -0
- package/skills/pcb-initial-placement/scripts/placement_config.example.json +116 -0
- package/skills/pcb-initial-placement/scripts/placement_view.py +340 -0
- package/skills/pcb-initial-placement/scripts/pose_table.py +86 -0
- package/skills/pcb-initial-placement/scripts/projpaths.py +160 -0
- package/skills/pcb-initial-placement/scripts/sexpr.py +180 -0
- package/skills/pcb-initial-placement/scripts/suggest_modules.py +420 -0
- package/skills/pcb-initial-placement/scripts/verify_placement.py +226 -0
- package/skills/pcb-initial-placement/scripts/write_board_file.py +109 -0
- package/src/skills.ts +11 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract the placement geometry of a .kicad_pcb: courtyard boxes + board outline.
|
|
3
|
+
|
|
4
|
+
Read-only: this never writes to the board file. It produces the two inputs the
|
|
5
|
+
packer needs, so the packer itself never has to understand KiCad file syntax.
|
|
6
|
+
|
|
7
|
+
python board_geometry.py --board board.kicad_pcb
|
|
8
|
+
# -> <board dir>/placement/geometry.json (override with --out / --outdir)
|
|
9
|
+
|
|
10
|
+
For every footprint it records ``bbox_local`` - the occupied rectangle in the
|
|
11
|
+
footprint's own coordinate system - taken from the courtyard layer when present,
|
|
12
|
+
falling back to the fabrication layer, then the pad extents, and finally the
|
|
13
|
+
silkscreen (for artwork such as logos that has none of the first three). That
|
|
14
|
+
rectangle is what "no overlap" is judged on, so the source is reported per
|
|
15
|
+
footprint (``bbox_src``) and must be sanity-checked: a footprint whose courtyard
|
|
16
|
+
is bogus will silently poison the whole placement.
|
|
17
|
+
|
|
18
|
+
The board outline is emitted as an ordered closed polygon, plus the axis-aligned
|
|
19
|
+
bounding box, so callers can do point-in-polygon without re-parsing the file.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
import projpaths # noqa: E402
|
|
32
|
+
import sexpr # noqa: E402
|
|
33
|
+
|
|
34
|
+
GRAPHIC_KINDS = ("fp_line", "fp_rect", "fp_circle", "fp_arc", "fp_poly")
|
|
35
|
+
|
|
36
|
+
#: Courtyard first, then progressively weaker stand-ins. The order matters: the
|
|
37
|
+
#: courtyard is the only rectangle "no overlap" is honestly judged on, so every step
|
|
38
|
+
#: away from it is recorded in ``bbox_src`` and reported for review. ``SilkS`` is the
|
|
39
|
+
#: last resort for silkscreen-only artwork (logos, fiducial marks) that has no
|
|
40
|
+
#: courtyard, no fab graphics and no pads - without it those footprints come back as
|
|
41
|
+
#: ``None`` and crash the summary below.
|
|
42
|
+
BBOX_CHAIN = (
|
|
43
|
+
(("CrtYd",), "courtyard"),
|
|
44
|
+
(("Fab",), "fab"),
|
|
45
|
+
((), "pads"),
|
|
46
|
+
(("SilkS",), "silk"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def footprint_bbox_local(fp, want):
|
|
51
|
+
"""Occupied rectangle of one footprint in local coordinates.
|
|
52
|
+
|
|
53
|
+
``want`` is a tuple of layer-name suffixes; graphics on those layers are
|
|
54
|
+
included. An empty tuple means "pads only".
|
|
55
|
+
"""
|
|
56
|
+
xs, ys = [], []
|
|
57
|
+
for pad in fp.kids("pad"):
|
|
58
|
+
at = pad.first("at")
|
|
59
|
+
if at is None:
|
|
60
|
+
continue
|
|
61
|
+
pn = at.nums()
|
|
62
|
+
w = h = 0.0
|
|
63
|
+
size = pad.first("size")
|
|
64
|
+
if size is not None:
|
|
65
|
+
sn = size.nums()
|
|
66
|
+
if len(sn) >= 2:
|
|
67
|
+
w, h = sn[0], sn[1]
|
|
68
|
+
xs += [pn[0] - w / 2, pn[0] + w / 2]
|
|
69
|
+
ys += [pn[1] - h / 2, pn[1] + h / 2]
|
|
70
|
+
|
|
71
|
+
for kind in GRAPHIC_KINDS:
|
|
72
|
+
for g in fp.kids(kind):
|
|
73
|
+
layer = g.first("layer")
|
|
74
|
+
if layer is None or len(layer.atoms()) < 2:
|
|
75
|
+
continue
|
|
76
|
+
name = layer.atoms()[1].strip('"')
|
|
77
|
+
if not any(name.endswith(suffix) for suffix in want):
|
|
78
|
+
continue
|
|
79
|
+
for key in ("start", "end", "center", "mid"):
|
|
80
|
+
for s in g.kids(key):
|
|
81
|
+
nn = s.nums()
|
|
82
|
+
if len(nn) >= 2:
|
|
83
|
+
xs.append(nn[0])
|
|
84
|
+
ys.append(nn[1])
|
|
85
|
+
for pts in g.kids("pts"):
|
|
86
|
+
for xy in pts.kids("xy"):
|
|
87
|
+
nn = xy.nums()
|
|
88
|
+
if len(nn) >= 2:
|
|
89
|
+
xs.append(nn[0])
|
|
90
|
+
ys.append(nn[1])
|
|
91
|
+
if kind == "fp_circle":
|
|
92
|
+
c, e = g.first("center"), g.first("end")
|
|
93
|
+
if c is not None and e is not None:
|
|
94
|
+
cn, en = c.nums(), e.nums()
|
|
95
|
+
r = math.hypot(en[0] - cn[0], en[1] - cn[1])
|
|
96
|
+
xs += [cn[0] - r, cn[0] + r]
|
|
97
|
+
ys += [cn[1] - r, cn[1] + r]
|
|
98
|
+
|
|
99
|
+
if not xs:
|
|
100
|
+
return None
|
|
101
|
+
return [min(xs), min(ys), max(xs), max(ys)]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def pick_bbox(fp):
|
|
105
|
+
"""First available occupied rectangle for one footprint, with its provenance.
|
|
106
|
+
|
|
107
|
+
Returns ``(box, src)`` walking :data:`BBOX_CHAIN`. ``src`` is ``"none"`` only when
|
|
108
|
+
the footprint carries no geometry on any of those layers - the box is then a
|
|
109
|
+
degenerate point and the part is reported by name, because a part nobody can
|
|
110
|
+
measure is a part nothing protects from overlap.
|
|
111
|
+
"""
|
|
112
|
+
for want, src in BBOX_CHAIN:
|
|
113
|
+
box = footprint_bbox_local(fp, want)
|
|
114
|
+
if box is not None:
|
|
115
|
+
return box, src
|
|
116
|
+
return [0.0, 0.0, 0.0, 0.0], "none"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _round2(seq):
|
|
120
|
+
return tuple(round(v, 4) for v in seq[:2])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def edge_cuts_segments(text):
|
|
124
|
+
"""Every Edge.Cuts primitive as a list of ``(a, b)`` vertex pairs.
|
|
125
|
+
|
|
126
|
+
``gr_line`` and the four edges of ``gr_rect`` come back as segments. The
|
|
127
|
+
single-vertex primitives (``gr_circle``, ``gr_poly``, ``gr_arc``) are *not*
|
|
128
|
+
silently dropped any more: a board may legitimately be outlined by one of them,
|
|
129
|
+
and dropping it used to make the caller believe the board had no outline at all.
|
|
130
|
+
They are returned through ``walks`` instead.
|
|
131
|
+
"""
|
|
132
|
+
segs, walks, kinds = [], [], []
|
|
133
|
+
for g in sexpr.edge_cuts(text):
|
|
134
|
+
kinds.append(g.head)
|
|
135
|
+
if g.head == "gr_rect":
|
|
136
|
+
s, e = g.first("start"), g.first("end")
|
|
137
|
+
if s is None or e is None:
|
|
138
|
+
continue
|
|
139
|
+
x0, y0 = _round2(s.nums())
|
|
140
|
+
x1, y1 = _round2(e.nums())
|
|
141
|
+
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
|
142
|
+
for i in range(4):
|
|
143
|
+
segs.append((corners[i], corners[(i + 1) % 4]))
|
|
144
|
+
continue
|
|
145
|
+
start, end = g.first("start"), g.first("end")
|
|
146
|
+
if start is not None and end is not None:
|
|
147
|
+
segs.append((_round2(start.nums()), _round2(end.nums())))
|
|
148
|
+
continue
|
|
149
|
+
pts = g.first("pts")
|
|
150
|
+
if pts is not None: # gr_poly: already an ordered ring
|
|
151
|
+
ring = [_round2(xy.nums()) for xy in pts.kids("xy")]
|
|
152
|
+
if len(ring) >= 3:
|
|
153
|
+
walks.append(ring)
|
|
154
|
+
continue
|
|
155
|
+
c, e = g.first("center"), g.first("end")
|
|
156
|
+
if c is not None and e is not None: # gr_circle: sampled ring
|
|
157
|
+
cn, en = c.nums(), e.nums()
|
|
158
|
+
r = math.hypot(en[0] - cn[0], en[1] - cn[1])
|
|
159
|
+
walks.append([(round(cn[0] + r * math.cos(t), 4),
|
|
160
|
+
round(cn[1] + r * math.sin(t), 4))
|
|
161
|
+
for t in [i * math.pi / 24 for i in range(48)]])
|
|
162
|
+
return segs, walks, kinds
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def outline_polygon(text):
|
|
166
|
+
"""Walk the Edge.Cuts segments into one ordered closed polygon."""
|
|
167
|
+
segs, walks, kinds = edge_cuts_segments(text)
|
|
168
|
+
if walks:
|
|
169
|
+
# A single self-contained ring (gr_poly / gr_circle) is already closed and
|
|
170
|
+
# ordered; take the one with the largest extents and ignore loose segments.
|
|
171
|
+
ring = max(walks, key=lambda w: (max(p[0] for p in w) - min(p[0] for p in w))
|
|
172
|
+
* (max(p[1] for p in w) - min(p[1] for p in w)))
|
|
173
|
+
return ring
|
|
174
|
+
if not segs:
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
"no Edge.Cuts geometry found - the board has no outline (%s)"
|
|
177
|
+
% (", ".join(sorted(set(kinds))) or "no Edge.Cuts primitives at all"))
|
|
178
|
+
|
|
179
|
+
adj = defaultdict(list)
|
|
180
|
+
for a, b in segs:
|
|
181
|
+
adj[a].append(b)
|
|
182
|
+
adj[b].append(a)
|
|
183
|
+
|
|
184
|
+
start = segs[0][0]
|
|
185
|
+
poly, cur, prev = [start], start, None
|
|
186
|
+
seen = {start}
|
|
187
|
+
while True:
|
|
188
|
+
nxts = [p for p in adj[cur] if p != prev and p not in seen]
|
|
189
|
+
if not nxts:
|
|
190
|
+
# at a junction, prefer an unvisited neighbour; only then stop
|
|
191
|
+
nxts = [p for p in adj[cur] if p != prev]
|
|
192
|
+
if not nxts:
|
|
193
|
+
break
|
|
194
|
+
nxt = nxts[0]
|
|
195
|
+
if nxt == start:
|
|
196
|
+
break
|
|
197
|
+
poly.append(nxt)
|
|
198
|
+
seen.add(nxt)
|
|
199
|
+
prev, cur = cur, nxt
|
|
200
|
+
if len(poly) < 3:
|
|
201
|
+
raise RuntimeError("Edge.Cuts does not form a closed polygon; "
|
|
202
|
+
"preserve the existing outline instead of drawing one")
|
|
203
|
+
return poly
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def board_area(poly):
|
|
207
|
+
"""True polygon area (shoelace), not the bounding box - a shaped board is smaller."""
|
|
208
|
+
a = 0.0
|
|
209
|
+
n = len(poly)
|
|
210
|
+
for i in range(n):
|
|
211
|
+
x0, y0 = poly[i]
|
|
212
|
+
x1, y1 = poly[(i + 1) % n]
|
|
213
|
+
a += x0 * y1 - x1 * y0
|
|
214
|
+
return abs(a) / 2.0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def point_in_poly(poly, x, y):
|
|
218
|
+
res, n = False, len(poly)
|
|
219
|
+
for i in range(n):
|
|
220
|
+
x0, y0 = poly[i]
|
|
221
|
+
x1, y1 = poly[(i + 1) % n]
|
|
222
|
+
if (y0 > y) != (y1 > y) and x < x0 + (y - y0) * (x1 - x0) / (y1 - y0):
|
|
223
|
+
res = not res
|
|
224
|
+
return res
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def preflight(text, rows, poly, board_name):
|
|
228
|
+
"""Everything step 1 has to decide, in one compact block.
|
|
229
|
+
|
|
230
|
+
This deliberately replaces "dump the whole board and read it in the agent context":
|
|
231
|
+
the board file already holds every fact step 1 needs, so the evidence costs a
|
|
232
|
+
kilobyte instead of a megabyte. ``get_pcb_board`` stays the authority for writing;
|
|
233
|
+
it is simply the wrong tool for a size/rotation/density survey.
|
|
234
|
+
"""
|
|
235
|
+
xs = [p[0] for p in poly]
|
|
236
|
+
ys = [p[1] for p in poly]
|
|
237
|
+
area = board_area(poly)
|
|
238
|
+
by_src, rot_hist, court = defaultdict(int), defaultdict(int), 0.0
|
|
239
|
+
|
|
240
|
+
# current pose of every footprint, straight from the file
|
|
241
|
+
origins = {}
|
|
242
|
+
for fp in sexpr.footprints(text):
|
|
243
|
+
at = fp.first("at")
|
|
244
|
+
if at is None:
|
|
245
|
+
continue
|
|
246
|
+
n = at.nums()
|
|
247
|
+
origins[sexpr.property_value(fp, "Reference")] = (n[0], n[1])
|
|
248
|
+
|
|
249
|
+
for r in rows:
|
|
250
|
+
by_src[r["bbox_src"]] += 1
|
|
251
|
+
rot_hist[round(r["rot"] or 0.0)] += 1
|
|
252
|
+
x0, y0, x1, y1 = r["bbox_local"]
|
|
253
|
+
w, h = x1 - x0, y1 - y0
|
|
254
|
+
t = math.radians(r["rot"] or 0.0)
|
|
255
|
+
c, s = math.cos(t), math.sin(t)
|
|
256
|
+
w2 = abs(w * c) + abs(h * s)
|
|
257
|
+
h2 = abs(w * s) + abs(h * c)
|
|
258
|
+
court += w2 * h2
|
|
259
|
+
|
|
260
|
+
inside = [ref for ref, (x, y) in origins.items() if point_in_poly(poly, x, y)]
|
|
261
|
+
outside = sorted(ref for ref in origins if ref not in set(inside))
|
|
262
|
+
oxs = [origins[r][0] for r in origins] or [0.0]
|
|
263
|
+
oys = [origins[r][1] for r in origins] or [0.0]
|
|
264
|
+
|
|
265
|
+
print("board : %s" % board_name)
|
|
266
|
+
print("footprints : %d" % len(rows))
|
|
267
|
+
print("outline : %d vertices, bbox %.1f,%.1f .. %.1f,%.1f (%.1f x %.1f mm)"
|
|
268
|
+
% (len(poly), min(xs), min(ys), max(xs), max(ys),
|
|
269
|
+
max(xs) - min(xs), max(ys) - min(ys)))
|
|
270
|
+
print("board area : %.0f mm2" % area)
|
|
271
|
+
print("courtyard total area : %.0f mm2 -> density %.1f%%%s"
|
|
272
|
+
% (court, 100.0 * court / area,
|
|
273
|
+
" (>75%: dense board, expect spill and eviction)" if court / area > 0.75 else ""))
|
|
274
|
+
print("courtyard source : %s" % dict(sorted(by_src.items())))
|
|
275
|
+
print("rotations present : %s" % dict(sorted(rot_hist.items())))
|
|
276
|
+
print("device X range : %.1f .. %.1f" % (min(oxs), max(oxs)))
|
|
277
|
+
print("device Y range : %.1f .. %.1f" % (min(oys), max(oys)))
|
|
278
|
+
print("device origins inside : %d of %d" % (len(inside), len(rows)))
|
|
279
|
+
if outside:
|
|
280
|
+
same = " (they are already outside: a from-scratch re-place is safe)" if not inside \
|
|
281
|
+
else " (mixed: consider a local adjustment instead of a full re-place)"
|
|
282
|
+
print("device origins outside: %d %s" % (len(outside), outside[:10]))
|
|
283
|
+
print(" ->%s" % same)
|
|
284
|
+
|
|
285
|
+
warns = []
|
|
286
|
+
if by_src.get("none"):
|
|
287
|
+
warns.append("! %d part(s) have NO usable geometry and are treated as zero-area "
|
|
288
|
+
"points - name them in the report and place them by hand"
|
|
289
|
+
% by_src["none"])
|
|
290
|
+
if by_src.get("silk"):
|
|
291
|
+
warns.append("! %d part(s) fall back to the SILKSCREEN outline - verify those "
|
|
292
|
+
"courtyards before trusting the overlap numbers" % by_src["silk"])
|
|
293
|
+
if set(by_src) - {"courtyard", "fab", "pads", "silk"}:
|
|
294
|
+
warns.append("! unexpected bbox sources: %s" % sorted(set(by_src) - {"courtyard"}))
|
|
295
|
+
for w in warns:
|
|
296
|
+
print(w)
|
|
297
|
+
return {"area_mm2": round(area, 1), "density": round(court / area, 4),
|
|
298
|
+
"inside": len(inside), "outside": len(outside),
|
|
299
|
+
"devices": len(rows), "bbox_src": dict(by_src),
|
|
300
|
+
"warnings": warns}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main() -> None:
|
|
304
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
305
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
306
|
+
ap.add_argument("--board", required=True, help="source .kicad_pcb (read-only)")
|
|
307
|
+
ap.add_argument("--out", help="geometry.json to write "
|
|
308
|
+
"(default <board dir>/placement/geometry.json)")
|
|
309
|
+
projpaths.add_outdir(ap)
|
|
310
|
+
ap.add_argument("--quiet", action="store_true")
|
|
311
|
+
ap.add_argument("--brief", action="store_true",
|
|
312
|
+
help="print only the step-1 preflight summary (no per-part lines); "
|
|
313
|
+
"this is the compact evidence step 1 should read")
|
|
314
|
+
args = ap.parse_args()
|
|
315
|
+
|
|
316
|
+
projpaths.make_console_safe()
|
|
317
|
+
|
|
318
|
+
out_path = projpaths.resolve_out(args.out, "geometry.json",
|
|
319
|
+
board=args.board, outdir=args.outdir)
|
|
320
|
+
|
|
321
|
+
text = open(args.board, encoding="utf-8").read()
|
|
322
|
+
fps = sexpr.footprints(text)
|
|
323
|
+
if not fps:
|
|
324
|
+
raise SystemExit("no footprints found in the board file")
|
|
325
|
+
|
|
326
|
+
rows, fallback, no_box = [], [], []
|
|
327
|
+
for fp in fps:
|
|
328
|
+
ref = sexpr.property_value(fp, "Reference")
|
|
329
|
+
val = sexpr.property_value(fp, "Value")
|
|
330
|
+
at = fp.first("at")
|
|
331
|
+
n = at.nums()
|
|
332
|
+
rot = n[2] if len(n) > 2 else 0.0
|
|
333
|
+
|
|
334
|
+
box, src = pick_bbox(fp)
|
|
335
|
+
if src == "none":
|
|
336
|
+
no_box.append(ref)
|
|
337
|
+
elif src != "courtyard":
|
|
338
|
+
fallback.append((ref, src))
|
|
339
|
+
rows.append({"ref": ref, "value": val, "libid": fp.atoms()[1].strip('"'),
|
|
340
|
+
"rot": rot, "bbox_local": box, "bbox_src": src})
|
|
341
|
+
|
|
342
|
+
poly = outline_polygon(text)
|
|
343
|
+
xs = [p[0] for p in poly]
|
|
344
|
+
ys = [p[1] for p in poly]
|
|
345
|
+
pf = preflight(text, rows, poly, os.path.basename(args.board))
|
|
346
|
+
|
|
347
|
+
json.dump({"board": os.path.basename(args.board),
|
|
348
|
+
"outline": [list(p) for p in poly],
|
|
349
|
+
"outline_bbox": [min(xs), min(ys), max(xs), max(ys)],
|
|
350
|
+
"board_area_mm2": pf["area_mm2"],
|
|
351
|
+
"courtyard_density": pf["density"],
|
|
352
|
+
"preflight": pf,
|
|
353
|
+
"footprints": rows},
|
|
354
|
+
open(out_path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
355
|
+
|
|
356
|
+
if not args.quiet:
|
|
357
|
+
if not args.brief:
|
|
358
|
+
widths = sorted((r["bbox_local"][2] - r["bbox_local"][0],
|
|
359
|
+
r["bbox_local"][3] - r["bbox_local"][1]) for r in rows)
|
|
360
|
+
print("weaker stand-ins : %d %s" % (len(fallback), fallback[:12]))
|
|
361
|
+
print("smallest / median / largest box (mm): %.2f / %.2f / %.2f"
|
|
362
|
+
% (min(w for w, _ in widths), widths[len(widths) // 2][0],
|
|
363
|
+
max(w for w, _ in widths)))
|
|
364
|
+
print("wrote %s" % out_path)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared helpers for the KiCad IPC CRUD script templates.
|
|
2
|
+
|
|
3
|
+
Run the sibling scripts with the Python environment that contains the official
|
|
4
|
+
``kicad-python`` package. This module deliberately has no pcbnew fallback.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from kipy import KiCad
|
|
10
|
+
from kipy.board_types import BoardLayer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def connect_board(timeout_ms: int = 5000):
|
|
14
|
+
"""Return a checked KiCad connection and the currently open PCB board."""
|
|
15
|
+
kicad = KiCad(timeout_ms=timeout_ms)
|
|
16
|
+
|
|
17
|
+
if not kicad.check_version():
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
"kicad-python does not match the connected KiCad's API version; use the matching official package."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
board = kicad.get_board()
|
|
23
|
+
if board is None:
|
|
24
|
+
raise RuntimeError("No PCB is open; please open a .kicad_pcb in PCB Editor first.")
|
|
25
|
+
|
|
26
|
+
return kicad, board
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def close_kicad(kicad) -> None:
|
|
30
|
+
"""Close newer clients without breaking KiCad 10 / kicad-python 0.8 clients."""
|
|
31
|
+
close = getattr(kicad, "close", None)
|
|
32
|
+
if callable(close):
|
|
33
|
+
close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_required_net(board, name: str):
|
|
37
|
+
"""Resolve an existing board net by exact name; never invent a replacement."""
|
|
38
|
+
for net in board.get_nets():
|
|
39
|
+
if net.name == name:
|
|
40
|
+
return net
|
|
41
|
+
raise ValueError(f"Net {name!r} does not exist in the PCB; please sync nets from the schematic first.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_copper_layer(board, name: str):
|
|
45
|
+
"""Resolve a current-board copper layer, with a safe F.Cu/B.Cu legacy fallback."""
|
|
46
|
+
if hasattr(board, "get_layer_by_name"):
|
|
47
|
+
layer = board.get_layer_by_name(name)
|
|
48
|
+
if layer != BoardLayer.BL_UNDEFINED:
|
|
49
|
+
if layer in board.get_enabled_layers():
|
|
50
|
+
return layer
|
|
51
|
+
raise ValueError(f"Layer {name!r} is not enabled in the current PCB.")
|
|
52
|
+
|
|
53
|
+
standard_layers = {
|
|
54
|
+
"F.Cu": BoardLayer.BL_F_Cu,
|
|
55
|
+
"B.Cu": BoardLayer.BL_B_Cu,
|
|
56
|
+
}
|
|
57
|
+
try:
|
|
58
|
+
layer = standard_layers[name]
|
|
59
|
+
except KeyError as exc:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Cannot resolve layer {name!r} in this KiCad version; use F.Cu/B.Cu or upgrade."
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
if hasattr(board, "get_enabled_layers") and layer not in board.get_enabled_layers():
|
|
65
|
+
raise ValueError(f"Layer {name!r} is not enabled in the current PCB.")
|
|
66
|
+
return layer
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def commit_or_drop(board, message: str, operation, validate=None):
|
|
70
|
+
"""Run an operation and optional result check in one KiCad undo transaction."""
|
|
71
|
+
commit = board.begin_commit()
|
|
72
|
+
try:
|
|
73
|
+
result = operation()
|
|
74
|
+
if validate is not None:
|
|
75
|
+
validate(result)
|
|
76
|
+
board.push_commit(commit, message)
|
|
77
|
+
return result
|
|
78
|
+
except Exception:
|
|
79
|
+
board.drop_commit(commit)
|
|
80
|
+
raise
|