@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.
- 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,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)
|