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