@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.
Files changed (28) hide show
  1. package/lib/index.mjs +5 -0
  2. package/package.json +2 -2
  3. package/skills/hardware-design-brief/SKILL.md +65 -31
  4. package/skills/hardware-design-brief/agents/openai.yaml +3 -3
  5. package/skills/hardware-design-brief/references/design-brief-guide.md +84 -11
  6. package/skills/hardware-design-brief/references/examples/hardware-solution-example.md +442 -0
  7. package/skills/hardware-design-brief/references/hardware-prd-guide.md +53 -10
  8. package/skills/hardware-design-brief/references/mrd-guide.md +45 -10
  9. package/skills/hardware-design-brief/references/multi-board-guide.md +51 -0
  10. package/skills/hardware-design-brief/references/project-controls.md +45 -0
  11. package/skills/hardware-design-brief/references/solution-deliverable.md +55 -0
  12. package/skills/hardware-design-brief/references/stage-boundaries.md +32 -0
  13. package/skills/pcb-initial-placement/SKILL.md +343 -0
  14. package/skills/pcb-initial-placement/agents/openai.yaml +4 -0
  15. package/skills/pcb-initial-placement/references/algorithm.md +317 -0
  16. package/skills/pcb-initial-placement/scripts/apply_via_ipc.py +211 -0
  17. package/skills/pcb-initial-placement/scripts/board_geometry.py +368 -0
  18. package/skills/pcb-initial-placement/scripts/kipy_common.py +80 -0
  19. package/skills/pcb-initial-placement/scripts/pack.py +576 -0
  20. package/skills/pcb-initial-placement/scripts/placement_config.example.json +116 -0
  21. package/skills/pcb-initial-placement/scripts/placement_view.py +340 -0
  22. package/skills/pcb-initial-placement/scripts/pose_table.py +86 -0
  23. package/skills/pcb-initial-placement/scripts/projpaths.py +160 -0
  24. package/skills/pcb-initial-placement/scripts/sexpr.py +180 -0
  25. package/skills/pcb-initial-placement/scripts/suggest_modules.py +420 -0
  26. package/skills/pcb-initial-placement/scripts/verify_placement.py +226 -0
  27. package/skills/pcb-initial-placement/scripts/write_board_file.py +109 -0
  28. 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