@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,576 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Phase-1 initial placement packer: functional modules -> legal, non-overlapping poses.
|
|
3
|
+
|
|
4
|
+
python pack.py --geometry geometry.json --board board.kicad_pcb \
|
|
5
|
+
--config placement_config.json
|
|
6
|
+
# -> <board dir>/placement/placement_plan.json (override with --out / --outdir)
|
|
7
|
+
|
|
8
|
+
Inputs
|
|
9
|
+
------
|
|
10
|
+
geometry.json from board_geometry.py - courtyard boxes + board outline
|
|
11
|
+
board.kicad_pcb read-only, only to resolve ``pad`` anchored fixed parts
|
|
12
|
+
placement_config.json the floorplan decisions a human has to make:
|
|
13
|
+
modules / anchors / fixed anchors / keep-outs / order (see the example)
|
|
14
|
+
|
|
15
|
+
What it does, in order
|
|
16
|
+
----------------------
|
|
17
|
+
1. rasterise the board polygon into a grid; a cell is usable only if its centre
|
|
18
|
+
and its four corners are on the board, so no courtyard can hang off the edge
|
|
19
|
+
2. stamp every fixed part (connectors, SIMM sockets, cores) as a non-evictable
|
|
20
|
+
obstacle, then every keep-out rectangle
|
|
21
|
+
3. give secondary cores (clock, crystals, EEPROM, trimmers) first pick of the
|
|
22
|
+
space next to their own module's anchor
|
|
23
|
+
4. pack each module outwards from its anchor: large parts first, then passives,
|
|
24
|
+
modules round-robin, nearest legal slot wins; a part that fails gets a wider
|
|
25
|
+
radius on its next turn instead of being dumped far away
|
|
26
|
+
5. repair: anything still homeless looks for the legal slot whose eviction cost
|
|
27
|
+
(total courtyard area displaced) is lowest, and swaps it out - the displaced
|
|
28
|
+
parts go back into the queue
|
|
29
|
+
6. compact: repeatedly pull each part to the nearest legal slot that is closer
|
|
30
|
+
to its own module anchor; this can only tighten grouping, never break it
|
|
31
|
+
|
|
32
|
+
The result is a pose per footprint on a fixed grid. Nothing here touches KiCad.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import json
|
|
38
|
+
import math
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
from collections import defaultdict
|
|
42
|
+
|
|
43
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
44
|
+
import projpaths # noqa: E402
|
|
45
|
+
import sexpr # noqa: E402
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------- geometry
|
|
49
|
+
def rot_pt(lx, ly, deg):
|
|
50
|
+
"""Local -> board offset. Board Y points down, so this is a clockwise turn.
|
|
51
|
+
Validated to 1e-9 mm against KiCad's own pad placement."""
|
|
52
|
+
t = math.radians(deg)
|
|
53
|
+
c, s = math.cos(t), math.sin(t)
|
|
54
|
+
return (lx * c + ly * s, -lx * s + ly * c)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Board:
|
|
58
|
+
def __init__(self, geometry, outline):
|
|
59
|
+
self.geo = {g["ref"]: g for g in geometry["footprints"]}
|
|
60
|
+
self.orig_rot = {g["ref"]: g["rot"] for g in geometry["footprints"]}
|
|
61
|
+
self.poly = [tuple(p) for p in outline]
|
|
62
|
+
xs = [p[0] for p in self.poly]
|
|
63
|
+
ys = [p[1] for p in self.poly]
|
|
64
|
+
self.bx0, self.by0, self.bxe, self.bye = min(xs), min(ys), max(xs), max(ys)
|
|
65
|
+
self._box = {}
|
|
66
|
+
|
|
67
|
+
def rot_box(self, ref, rot):
|
|
68
|
+
key = (ref, rot % 360)
|
|
69
|
+
v = self._box.get(key)
|
|
70
|
+
if v is None:
|
|
71
|
+
x0, y0, x1, y1 = self.geo[ref]["bbox_local"]
|
|
72
|
+
pts = [rot_pt(px, py, rot) for px in (x0, x1) for py in (y0, y1)]
|
|
73
|
+
v = (min(p[0] for p in pts), min(p[1] for p in pts),
|
|
74
|
+
max(p[0] for p in pts), max(p[1] for p in pts))
|
|
75
|
+
self._box[key] = v
|
|
76
|
+
return v
|
|
77
|
+
|
|
78
|
+
def rect_of(self, ref, ox, oy, rot):
|
|
79
|
+
b = self.rot_box(ref, rot)
|
|
80
|
+
return (ox + b[0], oy + b[1], ox + b[2], oy + b[3])
|
|
81
|
+
|
|
82
|
+
def wh(self, ref, rot):
|
|
83
|
+
b = self.rot_box(ref, rot)
|
|
84
|
+
return (b[2] - b[0], b[3] - b[1])
|
|
85
|
+
|
|
86
|
+
def inside(self, x, y):
|
|
87
|
+
res, n = False, len(self.poly)
|
|
88
|
+
for i in range(n):
|
|
89
|
+
x0, y0 = self.poly[i]
|
|
90
|
+
x1, y1 = self.poly[(i + 1) % n]
|
|
91
|
+
if (y0 > y) != (y1 > y) and x < x0 + (y - y0) * (x1 - x0) / (y1 - y0):
|
|
92
|
+
res = not res
|
|
93
|
+
return res
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Grid:
|
|
97
|
+
"""Occupancy raster. OCC=1 means blocked; OWNER identifies the part so it can
|
|
98
|
+
be lifted again. All index maths goes through cell() - see the note there."""
|
|
99
|
+
|
|
100
|
+
FREE = 255
|
|
101
|
+
|
|
102
|
+
def __init__(self, board, grid, gap):
|
|
103
|
+
self.b, self.g, self.gap = board, grid, gap
|
|
104
|
+
self.nx = int((board.bxe - board.bx0) / grid) + 3
|
|
105
|
+
self.ny = int((board.bye - board.by0) / grid) + 3
|
|
106
|
+
self.occ = [bytearray(self.nx) for _ in range(self.ny)]
|
|
107
|
+
self.own = [bytearray([self.FREE]) * self.nx for _ in range(self.ny)]
|
|
108
|
+
for j in range(self.ny):
|
|
109
|
+
y = board.by0 + j * grid + grid / 2
|
|
110
|
+
row = self.occ[j]
|
|
111
|
+
for i in range(self.nx):
|
|
112
|
+
x = board.bx0 + i * grid + grid / 2
|
|
113
|
+
ok = board.inside(x, y)
|
|
114
|
+
if ok:
|
|
115
|
+
for dx in (-grid / 2 - 1e-6, grid / 2 + 1e-6):
|
|
116
|
+
for dy in (-grid / 2 - 1e-6, grid / 2 + 1e-6):
|
|
117
|
+
if not board.inside(x + dx, y + dy):
|
|
118
|
+
ok = False
|
|
119
|
+
break
|
|
120
|
+
if not ok:
|
|
121
|
+
break
|
|
122
|
+
if not ok:
|
|
123
|
+
row[i] = 1
|
|
124
|
+
|
|
125
|
+
def cell(self, v, base):
|
|
126
|
+
"""Grid index with *floor* semantics. int() truncates towards zero, which
|
|
127
|
+
silently maps a slightly-off-board rectangle onto cell 0 instead of -1 and
|
|
128
|
+
lets parts hang over the edge."""
|
|
129
|
+
return math.floor((v - base) / self.g + 0.5)
|
|
130
|
+
|
|
131
|
+
def mark(self, x0, y0, x1, y1, gap=0.0, owner=FREE):
|
|
132
|
+
# identical rounding to rect_free(), so a stamped rectangle can never spill
|
|
133
|
+
# into a neighbouring part's cells
|
|
134
|
+
i0 = max(0, self.cell(x0 - gap, self.b.bx0))
|
|
135
|
+
i1 = min(self.nx - 1, self.cell(x1 + gap, self.b.bx0))
|
|
136
|
+
j0 = max(0, self.cell(y0 - gap, self.b.by0))
|
|
137
|
+
j1 = min(self.ny - 1, self.cell(y1 + gap, self.b.by0))
|
|
138
|
+
for j in range(j0, j1 + 1):
|
|
139
|
+
row, own = self.occ[j], self.own[j]
|
|
140
|
+
for i in range(i0, i1 + 1):
|
|
141
|
+
row[i] = 1
|
|
142
|
+
own[i] = owner
|
|
143
|
+
|
|
144
|
+
def unmark(self, owner):
|
|
145
|
+
for j in range(self.ny):
|
|
146
|
+
row, own = self.occ[j], self.own[j]
|
|
147
|
+
for i in range(self.nx):
|
|
148
|
+
if own[i] == owner:
|
|
149
|
+
own[i] = self.FREE
|
|
150
|
+
row[i] = 0
|
|
151
|
+
|
|
152
|
+
def rect_free(self, rect, g=None):
|
|
153
|
+
g = self.gap if g is None else g
|
|
154
|
+
i0 = self.cell(rect[0] - g, self.b.bx0)
|
|
155
|
+
i1 = self.cell(rect[2] + g, self.b.bx0)
|
|
156
|
+
j0 = self.cell(rect[1] - g, self.b.by0)
|
|
157
|
+
j1 = self.cell(rect[3] + g, self.b.by0)
|
|
158
|
+
if i0 < 0 or j0 < 0 or i1 >= self.nx or j1 >= self.ny or i1 < i0 or j1 < j0:
|
|
159
|
+
return False
|
|
160
|
+
for j in range(j0, j1 + 1):
|
|
161
|
+
if any(self.occ[j][i0:i1 + 1]):
|
|
162
|
+
return False
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def free_area(self):
|
|
166
|
+
return sum(r.count(0) for r in self.occ) * self.g * self.g
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------- packing
|
|
170
|
+
class Packer:
|
|
171
|
+
def __init__(self, board, cfg):
|
|
172
|
+
self.b = board
|
|
173
|
+
self.cfg = cfg
|
|
174
|
+
self.G = cfg["grid_mm"]
|
|
175
|
+
self.gap = cfg["gap_mm"]
|
|
176
|
+
self.g = Grid(board, self.G, self.gap)
|
|
177
|
+
self.positions = {} # ref -> (x, y, rot)
|
|
178
|
+
self.fixed = set()
|
|
179
|
+
self.idx, self.by_idx, self.own_area, self.free_ids = {}, {}, {}, []
|
|
180
|
+
self.queues, self.labels = {}, {}
|
|
181
|
+
self.module_of = {}
|
|
182
|
+
|
|
183
|
+
# -- module bookkeeping -------------------------------------------------
|
|
184
|
+
def build_modules(self, modules):
|
|
185
|
+
for mod, spec in modules.items():
|
|
186
|
+
for ref in spec["members"]:
|
|
187
|
+
if ref in self.module_of:
|
|
188
|
+
raise SystemExit("part %s listed in two modules (%s and %s)"
|
|
189
|
+
% (ref, self.module_of[ref], mod))
|
|
190
|
+
self.module_of[ref] = mod
|
|
191
|
+
unknown = sorted(set(self.module_of) - set(self.b.geo))
|
|
192
|
+
if unknown:
|
|
193
|
+
raise SystemExit("config names parts that are not on the board: %s" % unknown)
|
|
194
|
+
ungrouped = sorted(set(self.b.geo) - set(self.module_of))
|
|
195
|
+
if ungrouped:
|
|
196
|
+
raise SystemExit("every footprint needs exactly one module; ungrouped: %s"
|
|
197
|
+
% ungrouped)
|
|
198
|
+
|
|
199
|
+
# -- occupancy bookkeeping ---------------------------------------------
|
|
200
|
+
def _owner_id(self):
|
|
201
|
+
return self.free_ids.pop() if self.free_ids else len(self.own_area)
|
|
202
|
+
|
|
203
|
+
def commit(self, ref, pose):
|
|
204
|
+
if ref in self.idx:
|
|
205
|
+
self.release(ref)
|
|
206
|
+
i = self._owner_id()
|
|
207
|
+
self.positions[ref] = (pose[0], pose[1], pose[2])
|
|
208
|
+
self.idx[ref] = i
|
|
209
|
+
self.by_idx[i] = ref
|
|
210
|
+
self.own_area[i] = self.area(ref)
|
|
211
|
+
self.g.mark(*pose[3], gap=self.gap, owner=i)
|
|
212
|
+
|
|
213
|
+
def release(self, ref):
|
|
214
|
+
i = self.idx.pop(ref)
|
|
215
|
+
self.by_idx.pop(i, None)
|
|
216
|
+
self.own_area.pop(i, None)
|
|
217
|
+
self.free_ids.append(i)
|
|
218
|
+
self.g.unmark(i)
|
|
219
|
+
self.positions.pop(ref, None)
|
|
220
|
+
|
|
221
|
+
def area(self, ref):
|
|
222
|
+
w, h = self.b.wh(ref, self.b.orig_rot[ref])
|
|
223
|
+
return w * h
|
|
224
|
+
|
|
225
|
+
def longest(self, ref):
|
|
226
|
+
w, h = self.b.wh(ref, self.b.orig_rot[ref])
|
|
227
|
+
return max(w, h)
|
|
228
|
+
|
|
229
|
+
# -- placement ----------------------------------------------------------
|
|
230
|
+
def rots(self, ref, n=2):
|
|
231
|
+
o = self.b.orig_rot[ref]
|
|
232
|
+
base = [o, (o + 90) % 360]
|
|
233
|
+
return base if n == 2 else base + [(o + 180) % 360, (o + 270) % 360]
|
|
234
|
+
|
|
235
|
+
def fit_at(self, ref, cx, cy, rot, g=None):
|
|
236
|
+
b = self.b.rot_box(ref, rot)
|
|
237
|
+
ox = round((cx - (b[0] + b[2]) / 2) / self.G) * self.G
|
|
238
|
+
oy = round((cy - (b[1] + b[3]) / 2) / self.G) * self.G
|
|
239
|
+
rect = (ox + b[0], oy + b[1], ox + b[2], oy + b[3])
|
|
240
|
+
return (ox, oy, rot, rect) if self.g.rect_free(rect, g) else None
|
|
241
|
+
|
|
242
|
+
def place_nearest(self, ref, ax, ay, coarse=1.27, max_r=60.0, nrot=2, g=None):
|
|
243
|
+
"""Nearest legal pose to one anchor, searched ring by ring."""
|
|
244
|
+
R = 0.0
|
|
245
|
+
while R <= max_r:
|
|
246
|
+
if R <= 0:
|
|
247
|
+
pts = ((ax, ay),)
|
|
248
|
+
else:
|
|
249
|
+
n = max(8, int(2 * math.pi * R / coarse))
|
|
250
|
+
pts = ((ax + R * math.cos(2 * math.pi * k / n),
|
|
251
|
+
ay + R * math.sin(2 * math.pi * k / n)) for k in range(n))
|
|
252
|
+
for (cx, cy) in pts:
|
|
253
|
+
for rot in self.rots(ref, nrot):
|
|
254
|
+
hit = self.fit_at(ref, cx, cy, rot, g)
|
|
255
|
+
if hit:
|
|
256
|
+
best, bd = hit, (hit[0] - ax) ** 2 + (hit[1] - ay) ** 2
|
|
257
|
+
for dx in range(-3, 4): # polish on the grid
|
|
258
|
+
for dy in range(-3, 4):
|
|
259
|
+
f = self.fit_at(ref, cx + dx * self.G,
|
|
260
|
+
cy + dy * self.G, rot, g)
|
|
261
|
+
if f:
|
|
262
|
+
d = (f[0] - ax) ** 2 + (f[1] - ay) ** 2
|
|
263
|
+
if d < bd:
|
|
264
|
+
bd, best = d, f
|
|
265
|
+
return best
|
|
266
|
+
R += coarse
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def place_anywhere(self, ref, g=None):
|
|
270
|
+
"""Row-major sweep - finds a slot whenever one exists on the board."""
|
|
271
|
+
for rot in self.rots(ref, 4):
|
|
272
|
+
w, h = self.b.wh(ref, rot)
|
|
273
|
+
for j in range(int((self.b.bye - self.b.by0 - h) / self.G) + 1):
|
|
274
|
+
cy = self.b.by0 + j * self.G + h / 2
|
|
275
|
+
for i in range(int((self.b.bxe - self.b.bx0 - w) / self.G) + 1):
|
|
276
|
+
cx = self.b.bx0 + i * self.G + w / 2
|
|
277
|
+
p = self.fit_at(ref, cx, cy, rot, g)
|
|
278
|
+
if p:
|
|
279
|
+
return p
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def dist_to(self, anchors, x, y):
|
|
283
|
+
return min((x - ax) ** 2 + (y - ay) ** 2 for ax, ay in anchors)
|
|
284
|
+
|
|
285
|
+
def place_in_module(self, ref, anchors, max_r=32.0, nrot=2):
|
|
286
|
+
best = None
|
|
287
|
+
for (ax, ay) in anchors:
|
|
288
|
+
p = self.place_nearest(ref, ax, ay, coarse=1.27, max_r=max_r, nrot=nrot)
|
|
289
|
+
if p:
|
|
290
|
+
d = (p[0] - ax) ** 2 + (p[1] - ay) ** 2
|
|
291
|
+
if best is None or d < best[0]:
|
|
292
|
+
best = (d, p)
|
|
293
|
+
return None if best is None else best[1]
|
|
294
|
+
|
|
295
|
+
def best_swap(self, ref, anchors, g=None, step=2):
|
|
296
|
+
"""Cheapest eviction: the legal slot displacing the least courtyard area,
|
|
297
|
+
tie-broken by proximity to the module anchor."""
|
|
298
|
+
g = self.gap if g is None else g
|
|
299
|
+
best = None
|
|
300
|
+
for rot in self.rots(ref, 4):
|
|
301
|
+
b = self.b.rot_box(ref, rot)
|
|
302
|
+
w, h = b[2] - b[0], b[3] - b[1]
|
|
303
|
+
for j in range(0, int((self.b.bye - self.b.by0 - h) / self.G) + 1, step):
|
|
304
|
+
cy = self.b.by0 + j * self.G + h / 2
|
|
305
|
+
for i in range(0, int((self.b.bxe - self.b.bx0 - w) / self.G) + 1, step):
|
|
306
|
+
cx = self.b.bx0 + i * self.G + w / 2
|
|
307
|
+
ox = round((cx - (b[0] + b[2]) / 2) / self.G) * self.G
|
|
308
|
+
oy = round((cy - (b[1] + b[3]) / 2) / self.G) * self.G
|
|
309
|
+
rect = (ox + b[0], oy + b[1], ox + b[2], oy + b[3])
|
|
310
|
+
i0 = self.g.cell(rect[0] - g, self.b.bx0)
|
|
311
|
+
i1 = self.g.cell(rect[2] + g, self.b.bx0)
|
|
312
|
+
j0 = self.g.cell(rect[1] - g, self.b.by0)
|
|
313
|
+
j1 = self.g.cell(rect[3] + g, self.b.by0)
|
|
314
|
+
if i0 < 0 or j0 < 0 or i1 >= self.g.nx or j1 >= self.g.ny:
|
|
315
|
+
continue
|
|
316
|
+
owners, ok = set(), True
|
|
317
|
+
for jj in range(j0, j1 + 1):
|
|
318
|
+
row_o, row_w = self.g.occ[jj], self.g.own[jj]
|
|
319
|
+
for ii in range(i0, i1 + 1):
|
|
320
|
+
if row_o[ii]:
|
|
321
|
+
o = row_w[ii]
|
|
322
|
+
if o == Grid.FREE: # board edge or keep-out
|
|
323
|
+
ok = False
|
|
324
|
+
break
|
|
325
|
+
owners.add(o)
|
|
326
|
+
if not ok:
|
|
327
|
+
break
|
|
328
|
+
if not ok:
|
|
329
|
+
continue
|
|
330
|
+
key = (sum(self.own_area[o] for o in owners),
|
|
331
|
+
self.dist_to(anchors, ox, oy))
|
|
332
|
+
if best is None or key < best[0]:
|
|
333
|
+
best = (key, ox, oy, rot, rect, owners)
|
|
334
|
+
return best
|
|
335
|
+
|
|
336
|
+
# -- driver -------------------------------------------------------------
|
|
337
|
+
def run(self, modules, order, fixed, keepouts, priority, radius, big_area):
|
|
338
|
+
self.build_modules(modules)
|
|
339
|
+
|
|
340
|
+
for ref, spec in fixed.items():
|
|
341
|
+
if "pad" in spec:
|
|
342
|
+
x, y = self.pad_origin(ref, spec["pad"], spec["at"])
|
|
343
|
+
else:
|
|
344
|
+
x, y = spec["x"], spec["y"]
|
|
345
|
+
pose = (x, y, spec.get("rot", 0))
|
|
346
|
+
self.positions[ref] = pose
|
|
347
|
+
self.fixed.add(ref)
|
|
348
|
+
|
|
349
|
+
for rect in keepouts:
|
|
350
|
+
self.g.mark(rect["rect"][0], rect["rect"][1], rect["rect"][2],
|
|
351
|
+
rect["rect"][3], gap=0.0, owner=Grid.FREE)
|
|
352
|
+
|
|
353
|
+
for ref in fixed:
|
|
354
|
+
self.g.mark(*self.b.rect_of(ref, *self.positions[ref]), gap=self.gap)
|
|
355
|
+
|
|
356
|
+
anchors = {m: [tuple(a) for a in s["anchors"]] for m, s in modules.items()}
|
|
357
|
+
for mod in order:
|
|
358
|
+
parts = [r for r in modules[mod]["members"] if r not in self.positions]
|
|
359
|
+
big = sorted([r for r in parts if self.area(r) >= big_area],
|
|
360
|
+
key=lambda r: -self.longest(r))
|
|
361
|
+
small = sorted([r for r in parts if self.area(r) < big_area],
|
|
362
|
+
key=lambda r: -self.area(r))
|
|
363
|
+
self.queues[mod] = [big, small]
|
|
364
|
+
self.labels[mod] = modules[mod].get("title", mod)
|
|
365
|
+
|
|
366
|
+
# secondary cores get first pick next to their own module
|
|
367
|
+
for ref in priority:
|
|
368
|
+
for mod in order:
|
|
369
|
+
for phase in (0, 1):
|
|
370
|
+
if ref in self.queues[mod][phase]:
|
|
371
|
+
self.queues[mod][phase].remove(ref)
|
|
372
|
+
p = self.place_in_module(ref, anchors[mod], max_r=45.0)
|
|
373
|
+
if p:
|
|
374
|
+
self.commit(ref, p)
|
|
375
|
+
else:
|
|
376
|
+
self.queues[mod][phase].append(ref)
|
|
377
|
+
|
|
378
|
+
tries = {}
|
|
379
|
+
for phase in (0, 1):
|
|
380
|
+
progress = True
|
|
381
|
+
while progress:
|
|
382
|
+
progress = False
|
|
383
|
+
for mod in order:
|
|
384
|
+
q = self.queues[mod][phase]
|
|
385
|
+
if not q:
|
|
386
|
+
continue
|
|
387
|
+
ref = q.pop(0)
|
|
388
|
+
n = tries.get(ref, 0)
|
|
389
|
+
p = self.place_in_module(ref, anchors[mod],
|
|
390
|
+
max_r=radius[min(n, len(radius) - 1)])
|
|
391
|
+
if p:
|
|
392
|
+
self.commit(ref, p)
|
|
393
|
+
progress = True
|
|
394
|
+
else:
|
|
395
|
+
tries[ref] = n + 1
|
|
396
|
+
q.append(ref)
|
|
397
|
+
|
|
398
|
+
for mod in order: # leftovers: widen, then anywhere
|
|
399
|
+
still = []
|
|
400
|
+
for phase in (0, 1):
|
|
401
|
+
for ref in self.queues[mod][phase]:
|
|
402
|
+
p = self.place_in_module(ref, anchors[mod], max_r=200.0, nrot=4)
|
|
403
|
+
if not p:
|
|
404
|
+
p = self.place_anywhere(ref)
|
|
405
|
+
if p:
|
|
406
|
+
self.commit(ref, p)
|
|
407
|
+
else:
|
|
408
|
+
still.append(ref)
|
|
409
|
+
self.queues[mod] = [[], still]
|
|
410
|
+
|
|
411
|
+
for round_no in range(8): # eviction repair
|
|
412
|
+
unplaced = self.unplaced()
|
|
413
|
+
if not unplaced:
|
|
414
|
+
break
|
|
415
|
+
unplaced.sort(key=lambda r: -self.area(r))
|
|
416
|
+
moved = 0
|
|
417
|
+
for ref in list(unplaced):
|
|
418
|
+
mod = self.module_of[ref]
|
|
419
|
+
p = self.place_in_module(ref, anchors[mod], max_r=200.0, nrot=4)
|
|
420
|
+
if not p:
|
|
421
|
+
p = self.place_anywhere(ref)
|
|
422
|
+
if p:
|
|
423
|
+
self.commit(ref, p)
|
|
424
|
+
self.drop_from_queues(ref)
|
|
425
|
+
moved += 1
|
|
426
|
+
continue
|
|
427
|
+
sw = self.best_swap(ref, anchors[mod])
|
|
428
|
+
if sw is None:
|
|
429
|
+
continue
|
|
430
|
+
key, ox, oy, rot, rect, owners = sw
|
|
431
|
+
if key[0] > self.area(ref) * 2.0: # too destructive, leave it
|
|
432
|
+
continue
|
|
433
|
+
for o in owners: # evict the displaced parts
|
|
434
|
+
victim = self.by_idx[o]
|
|
435
|
+
vmod = self.module_of[victim]
|
|
436
|
+
self.release(victim)
|
|
437
|
+
if victim not in self.queues[vmod][0] and \
|
|
438
|
+
victim not in self.queues[vmod][1]:
|
|
439
|
+
self.queues[vmod][1].append(victim)
|
|
440
|
+
self.commit(ref, (ox, oy, rot, rect))
|
|
441
|
+
self.drop_from_queues(ref)
|
|
442
|
+
moved += 1
|
|
443
|
+
print("repair round %d: moved %d, still unplaced %d"
|
|
444
|
+
% (round_no + 1, moved, len(self.unplaced())))
|
|
445
|
+
if moved == 0:
|
|
446
|
+
break
|
|
447
|
+
|
|
448
|
+
for it in range(3): # compaction
|
|
449
|
+
order_by_far = sorted((r for r in self.positions if r not in self.fixed),
|
|
450
|
+
key=lambda r: -self.dist_to(anchors[self.module_of[r]],
|
|
451
|
+
self.positions[r][0],
|
|
452
|
+
self.positions[r][1]))
|
|
453
|
+
moved = 0
|
|
454
|
+
for ref in order_by_far:
|
|
455
|
+
mod = self.module_of[ref]
|
|
456
|
+
cur = self.positions[ref]
|
|
457
|
+
d0 = self.dist_to(anchors[mod], cur[0], cur[1])
|
|
458
|
+
self.g.unmark(self.idx[ref])
|
|
459
|
+
p = self.place_in_module(ref, anchors[mod],
|
|
460
|
+
max_r=math.sqrt(d0) + 1.0, nrot=4)
|
|
461
|
+
if p is None:
|
|
462
|
+
p = (cur[0], cur[1], cur[2],
|
|
463
|
+
self.b.rect_of(ref, cur[0], cur[1], cur[2]))
|
|
464
|
+
d1 = self.dist_to(anchors[mod], p[0], p[1])
|
|
465
|
+
self.commit(ref, p)
|
|
466
|
+
if d1 < d0 - 1e-9:
|
|
467
|
+
moved += 1
|
|
468
|
+
print("compaction %d: %d parts pulled closer to their core" % (it + 1, moved))
|
|
469
|
+
if moved == 0:
|
|
470
|
+
break
|
|
471
|
+
return self.unplaced()
|
|
472
|
+
|
|
473
|
+
def drop_from_queues(self, ref):
|
|
474
|
+
for q in self.queues[self.module_of[ref]]:
|
|
475
|
+
if ref in q:
|
|
476
|
+
q.remove(ref)
|
|
477
|
+
|
|
478
|
+
def unplaced(self):
|
|
479
|
+
return [r for q in self.queues.values() for r in q[0] + q[1]]
|
|
480
|
+
|
|
481
|
+
def pad_origin(self, ref, pad_name, target):
|
|
482
|
+
"""Position a footprint so that one named pad lands on a board coordinate."""
|
|
483
|
+
for fp in sexpr.footprints(self.cfg["_board_text"]):
|
|
484
|
+
if sexpr.property_value(fp, "Reference") != ref:
|
|
485
|
+
continue
|
|
486
|
+
for pad in fp.kids("pad"):
|
|
487
|
+
if pad.atoms()[1].strip('"') == pad_name:
|
|
488
|
+
n = pad.first("at").nums()
|
|
489
|
+
return (target[0] - n[0], target[1] - n[1])
|
|
490
|
+
raise SystemExit("fixed part %s has no pad %r" % (ref, pad_name))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def main() -> None:
|
|
494
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
495
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
496
|
+
ap.add_argument("--geometry", required=True)
|
|
497
|
+
ap.add_argument("--config", required=True)
|
|
498
|
+
ap.add_argument("--board", help="board file; required only for pad-anchored fixed parts")
|
|
499
|
+
ap.add_argument("--out", help="placement_plan.json to write "
|
|
500
|
+
"(default <placement dir>/placement_plan.json)")
|
|
501
|
+
projpaths.add_outdir(ap)
|
|
502
|
+
args = ap.parse_args()
|
|
503
|
+
|
|
504
|
+
# utf-8-sig: a BOM is what you get from Notepad / PowerShell Set-Content
|
|
505
|
+
cfg = json.load(open(args.config, encoding="utf-8-sig"))
|
|
506
|
+
geom = json.load(open(args.geometry, encoding="utf-8-sig"))
|
|
507
|
+
|
|
508
|
+
cfg.setdefault("grid_mm", 0.635)
|
|
509
|
+
cfg.setdefault("gap_mm", 0.3)
|
|
510
|
+
cfg.setdefault("big_part_area_mm2", 40.0)
|
|
511
|
+
cfg.setdefault("radius_schedule_mm", [30, 45, 65, 90, 130, 200])
|
|
512
|
+
cfg.setdefault("keepouts", [])
|
|
513
|
+
cfg.setdefault("priority", [])
|
|
514
|
+
cfg.setdefault("fixed", {})
|
|
515
|
+
order = cfg.get("module_order") or sorted(cfg["modules"])
|
|
516
|
+
|
|
517
|
+
board_file = args.board or cfg.get("board")
|
|
518
|
+
if board_file is None:
|
|
519
|
+
raise SystemExit("pass --board (or set \"board\" in the config)")
|
|
520
|
+
if not os.path.isabs(board_file):
|
|
521
|
+
if args.board:
|
|
522
|
+
# A path typed on the command line means what it means in the caller's
|
|
523
|
+
# shell: relative to the cwd. Resolving it against the config instead
|
|
524
|
+
# turns "--config placement/placement_config.json --board board.kicad_pcb"
|
|
525
|
+
# into placement/board.kicad_pcb, which is never what was meant.
|
|
526
|
+
board_file = os.path.abspath(board_file)
|
|
527
|
+
else:
|
|
528
|
+
# A path stored in the config is relative to that config file - which,
|
|
529
|
+
# under the placement/ convention, usually means "../board.kicad_pcb".
|
|
530
|
+
board_file = os.path.join(os.path.dirname(os.path.abspath(args.config)), board_file)
|
|
531
|
+
if not os.path.isfile(board_file):
|
|
532
|
+
raise SystemExit(
|
|
533
|
+
"board file not found: %s\n"
|
|
534
|
+
" --board is resolved against the current directory (%s);\n"
|
|
535
|
+
" a \"board\" entry inside the config is resolved against the config file (%s)."
|
|
536
|
+
% (board_file, os.getcwd(), os.path.dirname(os.path.abspath(args.config))))
|
|
537
|
+
cfg["_board_text"] = open(board_file, encoding="utf-8").read()
|
|
538
|
+
|
|
539
|
+
out_path = projpaths.resolve_out(args.out, "placement_plan.json",
|
|
540
|
+
board=board_file, refs=(args.config,),
|
|
541
|
+
outdir=args.outdir)
|
|
542
|
+
|
|
543
|
+
for mod in order:
|
|
544
|
+
if mod not in cfg["modules"]:
|
|
545
|
+
raise SystemExit("module_order names unknown module %r" % mod)
|
|
546
|
+
|
|
547
|
+
board = Board(geom, geom["outline"])
|
|
548
|
+
packer = Packer(board, cfg)
|
|
549
|
+
unplaced = packer.run(cfg["modules"], order, cfg["fixed"], cfg["keepouts"],
|
|
550
|
+
cfg["priority"], cfg["radius_schedule_mm"],
|
|
551
|
+
cfg["big_part_area_mm2"])
|
|
552
|
+
|
|
553
|
+
print("\nmodule packing (placed / peripheral total):")
|
|
554
|
+
for mod in order:
|
|
555
|
+
tot = len([r for r in cfg["modules"][mod]["members"] if r not in packer.fixed])
|
|
556
|
+
n = len([r for r in cfg["modules"][mod]["members"] if r in packer.positions])
|
|
557
|
+
print(" %-4s %-34s %3d/%3d" % (mod, packer.labels[mod], n, tot))
|
|
558
|
+
print("placed %d of %d, unplaced: %s"
|
|
559
|
+
% (len(packer.positions), len(board.geo), unplaced))
|
|
560
|
+
print("free area left: %.0f mm2" % packer.g.free_area())
|
|
561
|
+
|
|
562
|
+
json.dump({"positions": {k: {"x": v[0], "y": v[1], "rot": v[2]}
|
|
563
|
+
for k, v in packer.positions.items()},
|
|
564
|
+
"unplaced": unplaced,
|
|
565
|
+
"modules": {m: cfg["modules"][m]["members"] for m in order},
|
|
566
|
+
"module_titles": {m: cfg["modules"][m].get("title", m) for m in order},
|
|
567
|
+
"anchors": {m: cfg["modules"][m]["anchors"] for m in order},
|
|
568
|
+
"fixed": sorted(packer.fixed),
|
|
569
|
+
"keepouts": cfg["keepouts"],
|
|
570
|
+
"grid_mm": cfg["grid_mm"], "gap_mm": cfg["gap_mm"]},
|
|
571
|
+
open(out_path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
572
|
+
print("wrote", out_path)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
if __name__ == "__main__":
|
|
576
|
+
main()
|