@huaqiu/dsh-kicad 0.4.13 → 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,160 @@
1
+ #!/usr/bin/env python3
2
+ """Where a placement run puts its files: ``<project>/placement/``.
3
+
4
+ Every artefact this skill generates - geometry, config, plan, pose table, SVG view,
5
+ verification report, offline board copy - belongs in ONE directory next to the board
6
+ file, never in the skill directory and never scattered over the caller's cwd:
7
+
8
+ <project>/ <- directory that holds board.kicad_pcb
9
+ board.kicad_pcb
10
+ placement/ <- created on demand; everything lands here
11
+ geometry.json
12
+ placement_config.json
13
+ placement_plan.json
14
+ placement_pose_table.csv
15
+ placement_view.svg
16
+ verify_placement.txt
17
+
18
+ Resolution order for the output directory:
19
+
20
+ 1. an explicit ``--outdir`` (wins verbatim, absolute or relative to cwd),
21
+ 2. ``<directory of --board>/placement`` when a board path is known,
22
+ 3. the directory of the primary reference input (which already lives in
23
+ ``placement/`` once the convention is being followed),
24
+ 4. ``./placement`` as a last resort.
25
+
26
+ An explicit ``--out`` always bypasses this module: the caller asked for that exact
27
+ path, so it is honoured, and only its parent directory is created.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import atexit
32
+ import os
33
+ import sys
34
+
35
+ PLACEMENT_DIRNAME = "placement"
36
+
37
+
38
+ def placement_dir(board=None, refs=(), outdir=None, create=True):
39
+ """Resolve (and by default create) the placement output directory.
40
+
41
+ ``board`` is the .kicad_pcb path, ``refs`` the input files of this step.
42
+ """
43
+ if outdir:
44
+ d = os.path.abspath(outdir)
45
+ else:
46
+ d = None
47
+ if board:
48
+ d = os.path.join(os.path.dirname(os.path.abspath(board)), PLACEMENT_DIRNAME)
49
+ if d is None:
50
+ for r in refs:
51
+ if r:
52
+ d = os.path.dirname(os.path.abspath(r))
53
+ break
54
+ if d is None:
55
+ d = os.path.abspath(PLACEMENT_DIRNAME)
56
+ if create:
57
+ os.makedirs(d, exist_ok=True)
58
+ return d
59
+
60
+
61
+ def resolve_out(explicit, default_name, board=None, refs=(), outdir=None):
62
+ """``--out`` wins verbatim; otherwise ``<placement dir>/<default_name>``."""
63
+ if explicit:
64
+ p = os.path.abspath(explicit)
65
+ parent = os.path.dirname(p)
66
+ if parent:
67
+ os.makedirs(parent, exist_ok=True)
68
+ return p
69
+ return os.path.join(placement_dir(board=board, refs=refs, outdir=outdir),
70
+ default_name)
71
+
72
+
73
+ def add_outdir(ap):
74
+ """Attach the standard ``--outdir`` flag (and return the argparse action)."""
75
+ return ap.add_argument(
76
+ "--outdir", default=None,
77
+ help="directory for every generated file; default <directory of the board>/"
78
+ "placement/ (created if missing)")
79
+
80
+
81
+ def announce(paths, label="placement directory"):
82
+ """Print the one line that tells the reviewer where this run's files live."""
83
+ seen, out = set(), []
84
+ for p in paths:
85
+ d = os.path.dirname(os.path.abspath(p))
86
+ if d not in seen:
87
+ seen.add(d)
88
+ out.append(d)
89
+ if out:
90
+ print("%s: %s" % (label, ", ".join(out)))
91
+
92
+
93
+ class Tee:
94
+ """Mirror everything printed into a report file, so the run leaves evidence.
95
+
96
+ A verified run is only as good as the record of it: the console scrolls away,
97
+ the file stays next to the plan it describes.
98
+ """
99
+
100
+ def __init__(self, path):
101
+ self.path = path
102
+ self.fh = open(path, "w", encoding="utf-8")
103
+ self.stdout = sys.stdout
104
+ self.closed = False
105
+
106
+ def write(self, s):
107
+ self.stdout.write(s)
108
+ self.fh.write(s)
109
+ return len(s)
110
+
111
+ def flush(self):
112
+ if self.closed:
113
+ return
114
+ self.stdout.flush()
115
+ self.fh.flush()
116
+
117
+ def close(self):
118
+ if self.closed:
119
+ return
120
+ self.flush()
121
+ self.fh.close()
122
+ self.closed = True
123
+ sys.stdout = self.stdout
124
+
125
+
126
+ def make_console_safe():
127
+ """Stop a device value from killing a run on a legacy-code-page console.
128
+
129
+ A board may hold a value like ``100µF``; on Windows with a GBK/cp1252 console,
130
+ ``print()`` raises UnicodeEncodeError and the whole script dies *after* doing its
131
+ work. Reconfiguring the stream (not any file) keeps report files UTF-8 while
132
+ replacing only the characters the console cannot represent.
133
+
134
+ Called by ``start_report()``, and worth calling directly in scripts that print
135
+ without writing a report (a geometry or preflight pass, for instance).
136
+ """
137
+ for stream in (sys.stdout, sys.stderr):
138
+ try:
139
+ stream.reconfigure(encoding="utf-8", errors="replace")
140
+ except (AttributeError, ValueError):
141
+ pass
142
+
143
+
144
+ def start_report(path):
145
+ """Send stdout through a :class:`Tee` writing to ``path``; return the Tee.
146
+
147
+ The report file is always UTF-8, whatever the console encoding is: a legacy
148
+ Windows code page may garble Chinese module titles *on screen*, but the file a
149
+ reviewer opens later is correct. Deliberately not reconfiguring sys.stdout -
150
+ forcing UTF-8 helps only terminals that decode UTF-8 and breaks consoles that
151
+ render their own legacy code page correctly.
152
+
153
+ Registered with :mod:`atexit` as well, so an early ``return`` or a
154
+ ``SystemExit`` still leaves a complete, flushed report.
155
+ """
156
+ tee = Tee(path)
157
+ atexit.register(tee.close)
158
+ sys.stdout = tee
159
+ make_console_safe()
160
+ return tee
@@ -0,0 +1,180 @@
1
+ """Minimal s-expression reader for KiCad files, offset aware.
2
+
3
+ Only what the placement task needs:
4
+ * parse the file into nested lists of strings (SNode)
5
+ * locate every top-level `(footprint ...)` block and the byte span of its
6
+ depth-1 `(at x y [rot])` token, so positions can be rewritten textually
7
+ while every other byte of the file stays untouched.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ TOKEN = re.compile(r'"(?:[^"\\]|\\.)*"|\(|\)|[^\s()"]+')
14
+
15
+
16
+ class SNode:
17
+ __slots__ = ("items", "start", "end")
18
+
19
+ def __init__(self, items, start=None, end=None):
20
+ self.items = items
21
+ self.start = start
22
+ self.end = end
23
+
24
+ @property
25
+ def head(self):
26
+ return self.items[0] if self.items and isinstance(self.items[0], str) else None
27
+
28
+ def kids(self, head=None):
29
+ for it in self.items:
30
+ if isinstance(it, SNode) and (head is None or it.head == head):
31
+ yield it
32
+
33
+ def first(self, head):
34
+ for it in self.items:
35
+ if isinstance(it, SNode) and it.head == head:
36
+ return it
37
+ return None
38
+
39
+ def atoms(self):
40
+ return [it for it in self.items if isinstance(it, str)]
41
+
42
+ def nums(self):
43
+ out = []
44
+ for it in self.items[1:]:
45
+ if isinstance(it, str):
46
+ try:
47
+ out.append(float(it))
48
+ except ValueError:
49
+ break
50
+ else:
51
+ break
52
+ return out
53
+
54
+ def __repr__(self):
55
+ return f"<{self.head} {self.atoms()[1:3]}>"
56
+
57
+
58
+ def parse(text: str) -> list[SNode]:
59
+ """Parse text into a list of top-level SNode."""
60
+ stack: list[list] = [[]]
61
+ starts: list[int] = [0]
62
+ for m in TOKEN.finditer(text):
63
+ tok = m.group(0)
64
+ if tok == "(":
65
+ stack.append([])
66
+ starts.append(m.start())
67
+ elif tok == ")":
68
+ items = stack.pop()
69
+ st = starts.pop()
70
+ stack[-1].append(SNode(items, st, m.end()))
71
+ else:
72
+ stack[-1].append(tok)
73
+ return [it for it in stack[0] if isinstance(it, SNode)]
74
+
75
+
76
+ def root(text: str) -> SNode:
77
+ tops = parse(text)
78
+ for n in tops:
79
+ if n.head == "kicad_pcb":
80
+ return n
81
+ raise ValueError("no (kicad_pcb ...) root found")
82
+
83
+
84
+ def footprints(text: str) -> list[SNode]:
85
+ """All `(footprint ...)` blocks of the board, with offsets."""
86
+ return list(root(text).kids("footprint"))
87
+
88
+
89
+ def edge_cuts(text: str) -> list[SNode]:
90
+ out = []
91
+ for kind in ("gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly", "gr_curve"):
92
+ for g in root(text).kids(kind):
93
+ lay = g.first("layer")
94
+ if lay and len(lay.atoms()) > 1 and lay.atoms()[1].strip('"') == "Edge.Cuts":
95
+ out.append(g)
96
+ return out
97
+
98
+
99
+ def net_name(pad: SNode) -> str:
100
+ """Net name of a ``(pad ...)``, tolerating both board dialects.
101
+
102
+ KiCad normally writes ``(net 12 "GND")`` - a numeric code followed by the name - but
103
+ boards that went through an importer, or that were written by an older/other tool,
104
+ carry the bare name only: ``(net "GND")``. Reading position 2 unconditionally
105
+ yields ``""`` for every pad on such a board, which silently turns a 111-net design
106
+ into a 0-net design and makes every connectivity-based grouping step meaningless.
107
+ So: take the first non-numeric atom, and treat a net with no name as unnamed.
108
+ """
109
+ net = pad.first("net")
110
+ if net is None:
111
+ return ""
112
+ atoms = net.atoms()
113
+ if len(atoms) >= 3 and atoms[1].isdigit():
114
+ return atoms[2].strip('"')
115
+ for a in atoms[1:]:
116
+ s = a.strip('"')
117
+ if not s.isdigit():
118
+ return s
119
+ return ""
120
+
121
+
122
+ def property_value(fp: SNode, name: str):
123
+ """KiCad 8/9 store reference/value as (property "Reference" "R1" ...)."""
124
+ for p in fp.kids("property"):
125
+ a = p.atoms()
126
+ if len(a) >= 3 and a[1] == f'"{name}"':
127
+ return a[2].strip('"')
128
+ for t in fp.kids("fp_text"):
129
+ a = t.atoms()
130
+ if len(a) >= 3 and a[1] == name:
131
+ return a[2].strip('"')
132
+ return None
133
+
134
+
135
+ def local_bbox(fp: SNode):
136
+ """Occupied area of one footprint in its own frame (before rotation)."""
137
+ xs, ys = [], []
138
+ for pad in fp.kids("pad"):
139
+ at = pad.first("at")
140
+ if at is None:
141
+ continue
142
+ n = at.nums()
143
+ if len(n) < 2:
144
+ continue
145
+ w = h = 0.0
146
+ for key, dst in (("size", "wh"), ("drill", None)):
147
+ pass
148
+ size = pad.first("size")
149
+ if size is not None:
150
+ sn = size.nums()
151
+ if len(sn) >= 2:
152
+ w, h = sn[0], sn[1]
153
+ xs += [n[0] - w / 2, n[0] + w / 2]
154
+ ys += [n[1] - h / 2, n[1] + h / 2]
155
+ for kind in ("fp_line", "fp_rect", "fp_circle", "fp_arc", "fp_poly"):
156
+ for g in fp.kids(kind):
157
+ layer = g.first("layer")
158
+ lname = layer.atoms()[1].strip('"') if layer and len(layer.atoms()) > 1 else ""
159
+ if not (lname.endswith("CrtYd") or lname.endswith("Fab")):
160
+ continue
161
+ for key in ("start", "end", "center", "mid"):
162
+ for s in g.kids(key):
163
+ n = s.nums()
164
+ if len(n) >= 2:
165
+ xs.append(n[0])
166
+ ys.append(n[1])
167
+ for s in g.kids("xy"):
168
+ n = s.nums()
169
+ if len(n) >= 2:
170
+ xs.append(n[0])
171
+ ys.append(n[1])
172
+ for s in g.kids("pts"):
173
+ for xy in s.kids("xy"):
174
+ n = xy.nums()
175
+ if len(n) >= 2:
176
+ xs.append(n[0])
177
+ ys.append(n[1])
178
+ if not xs:
179
+ return None
180
+ return min(xs), min(ys), max(xs), max(ys)