@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,116 @@
1
+ {
2
+ "_comment": [
3
+ "Phase-1 placement config for the KiCad 'video' demo board (312x106.7 mm PCI card).",
4
+ "This is the worked example behind the pcb-initial-placement skill: every number here",
5
+ "was derived from the board's own geometry (see references/algorithm.md).",
6
+ "Copy it, replace the modules/anchors/fixed entries with your own board's facts,",
7
+ "and run: pack.py -> verify_placement.py -> apply_via_ipc.py"
8
+ ],
9
+
10
+ "board": "video.kicad_pcb",
11
+ "grid_mm": 0.635,
12
+ "gap_mm": 0.3,
13
+ "big_part_area_mm2": 40.0,
14
+ "radius_schedule_mm": [30, 45, 65, 90, 130, 200],
15
+ "module_order": ["M1", "M4", "M5", "M6", "M3", "M2"],
16
+
17
+ "fixed": {
18
+ "BUS1": {"pad": "A1", "at": [95.25, 160.195], "rot": 0,
19
+ "_comment": "PCI card-edge fingers: pin A1 sits on the board edge, so the whole connector is derived from the Edge.Cuts geometry"},
20
+
21
+ "P1": {"x": 58.0, "y": 63.0, "rot": 180, "_comment": "BNC bracket connector, body overhangs the left edge x=53.594"},
22
+ "P2": {"x": 58.0, "y": 74.4, "rot": 180},
23
+ "P3": {"x": 58.0, "y": 85.8, "rot": 180},
24
+ "P8": {"x": 58.0, "y": 97.2, "rot": 180},
25
+ "J4": {"x": 68.154, "y": 137.0, "rot": 90,
26
+ "_comment": "DB9; footprint is EdgePinOffset14.56mm so origin x = 53.594 + 14.56"},
27
+
28
+ "U10": {"x": 80.25, "y": 73.0, "rot": 0, "_comment": "video decoder, next to the BNC inputs"},
29
+ "U8": {"x": 113.9, "y": 74.3, "rot": 0, "_comment": "RAMDAC"},
30
+ "U9": {"x": 111.25, "y": 107.0, "rot": 0, "_comment": "encoder"},
31
+ "U20": {"x": 69.9, "y": 100.0, "rot": 0, "_comment": "video encoder"},
32
+
33
+ "U11": {"x": 116.25, "y": 138.5, "rot": 0, "_comment": "PCI controller, hugging the card edge"},
34
+ "U24": {"x": 152.25, "y": 138.5, "rot": 0, "_comment": "main FPGA, 47 nets to U11"},
35
+ "U23": {"x": 184.25, "y": 127.25, "rot": 90},
36
+ "U22": {"x": 205.8, "y": 124.65, "rot": 0, "_comment": "SIMM controller, 32 nets to every socket"},
37
+ "U2": {"x": 179.0, "y": 143.9, "rot": 90},
38
+
39
+ "U3": {"x": 221.5, "y": 123.0, "rot": 0, "_comment": "SIMM data-bus buffers, lined up beside the bank"},
40
+ "U4": {"x": 234.5, "y": 123.0, "rot": 0},
41
+ "U5": {"x": 247.5, "y": 123.0, "rot": 0},
42
+ "U6": {"x": 260.5, "y": 123.0, "rot": 0},
43
+
44
+ "U12": {"x": 189.494, "y": 61.2, "rot": 180, "_comment": "SIMM bank: 2 columns x 4 rows, 14.5 mm row pitch (0.571 in)"},
45
+ "U13": {"x": 189.494, "y": 74.7, "rot": 180},
46
+ "U14": {"x": 189.494, "y": 88.2, "rot": 180},
47
+ "U15": {"x": 189.494, "y": 101.7, "rot": 180},
48
+ "U16": {"x": 307.634, "y": 61.2, "rot": 180},
49
+ "U17": {"x": 307.634, "y": 74.7, "rot": 180},
50
+ "U18": {"x": 307.634, "y": 88.2, "rot": 180},
51
+ "U19": {"x": 307.634, "y": 101.7, "rot": 180}
52
+ },
53
+
54
+ "keepouts": [
55
+ {"rect": [131.594, 105.1, 247.494, 115.8],
56
+ "why": "SIMM modules lean towards +y once inserted and cover the space below row 4; keep the tilt volume clear of copper-side parts"},
57
+ {"rect": [249.734, 105.1, 365.634, 115.8],
58
+ "why": "same for the second SIMM column"}
59
+ ],
60
+
61
+ "priority": ["RR1", "RR2", "RR3", "RR4", "RR5", "RR6", "RR7", "RR8",
62
+ "X1", "X2", "U1", "U7", "U21", "CV1", "POT1", "U20"],
63
+
64
+ "modules": {
65
+ "M1": {
66
+ "title": "板边连接器 bracket connectors",
67
+ "anchors": [[79.0, 137.0], [58.0, 62.0], [58.0, 80.0], [58.0, 99.0]],
68
+ "members": ["J4", "P1", "P2", "P3", "P8",
69
+ "R22", "R23", "R24", "R39", "R40", "R41", "R42", "R43", "R44",
70
+ "R45", "R46", "R47", "C3", "C4", "C59"]
71
+ },
72
+ "M2": {
73
+ "title": "PCI 总线接口 PCI bus interface",
74
+ "anchors": [[116.25, 138.5], [158.1, 158.4], [240.0, 123.0]],
75
+ "members": ["BUS1", "U11", "U1", "U3", "U4", "U5", "U6",
76
+ "RR1", "RR2", "RR3", "RR4", "RR5", "RR6",
77
+ "R5", "R6", "R7", "R28", "R29", "C68", "C69",
78
+ "W1", "W2", "W3", "W4", "W5", "C9", "C11", "C17", "C27", "C31"]
79
+ },
80
+ "M3": {
81
+ "title": "图形逻辑 FPGA",
82
+ "anchors": [[152.25, 138.5], [179.0, 143.9], [184.25, 127.25],
83
+ [205.8, 124.65], [177.0, 120.0]],
84
+ "members": ["U24", "U23", "U22", "U21", "U2", "U7",
85
+ "RR7", "RR8", "R1", "R13", "R21", "R26", "R30", "R31", "R32",
86
+ "R33", "R34", "C23", "C49", "C50", "C63", "D6", "P4", "P5",
87
+ "P9", "P10", "P11", "P12", "X1", "C10", "C15", "C18", "C19",
88
+ "C20", "C21", "R48"]
89
+ },
90
+ "M4": {
91
+ "title": "视频输入解码 video capture",
92
+ "anchors": [[80.25, 73.0], [68.0, 100.0]],
93
+ "members": ["U10", "X2", "C1", "C2", "C5", "C6", "C7", "C8", "C12", "C13",
94
+ "C14", "C16", "C62", "C65", "C66", "D1", "D2", "D3", "D4",
95
+ "L1", "L2", "L3", "L4", "L6", "R2", "R3", "R12", "R14", "R19",
96
+ "R20", "R25", "R27", "R35", "Q1", "Q2", "Q3", "CV1", "POT1",
97
+ "C47", "C55"]
98
+ },
99
+ "M5": {
100
+ "title": "视频输出 RAMDAC+encoder",
101
+ "anchors": [[113.9, 74.3], [111.25, 107.0], [69.9, 100.0]],
102
+ "members": ["U8", "U9", "U20", "X3", "C32", "C33", "C34", "C35", "C36",
103
+ "C39", "C40", "C41", "C42", "C43", "C44", "C45", "C48", "C51",
104
+ "C52", "C54", "C58", "C60", "C61", "C64", "L5", "R4", "R8",
105
+ "R9", "R10", "R11", "R15", "R16", "R17", "R18", "R36", "R37",
106
+ "R38", "C22", "C24", "C25", "C26", "C28", "C29", "C30", "C37",
107
+ "C46", "C53"]
108
+ },
109
+ "M6": {
110
+ "title": "帧缓存 frame buffer SIMM",
111
+ "anchors": [[189.5, 83.0], [307.6, 83.0], [255.0, 125.0]],
112
+ "members": ["U12", "U13", "U14", "U15", "U16", "U17", "U18", "U19",
113
+ "C38", "C56", "C57", "C67", "C70", "C71", "C72", "C73"]
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """Render a placement plan as a module-coloured SVG plan view + evaluation panel.
3
+
4
+ python placement_view.py --geometry geometry.json --plan placement_plan.json
5
+ # -> <placement dir>/placement_view.svg
6
+
7
+ This is the picture a reviewer opens first, and it is deliberately self-contained:
8
+ standard library only, no KiCad, no browser, no rasteriser, so it can be produced on
9
+ any machine that got as far as running the packer.
10
+
11
+ What it draws
12
+ -------------
13
+ * the real board outline from Edge.Cuts, filled, so the board shape (notches, tabs,
14
+ rounded corners) is visible rather than a bounding box;
15
+ * every keep-out registered in the plan, hatched, with its reason in the panel - the
16
+ tilt volume of a socket or the antenna clear zone is a decision, not noise;
17
+ * every courtyard rectangle tinted by the module that owns it, labelled once it is
18
+ big enough on screen to carry text, with fixed/anchor parts drawn at full opacity
19
+ so a reviewer can separate *decisions* from *consequences*;
20
+ * each module's anchor points, drawn as crosses in the module colour.
21
+
22
+ The panel underneath carries the numbers that decide whether the plan is acceptable -
23
+ occupancy, per-module area, and the hard gates. The canvas is sized from the content
24
+ that actually exists, so a board with 14 modules or 5 keep-outs does not run its text
25
+ off the bottom edge.
26
+
27
+ The gates shown here are for DISPLAY: the authoritative check is
28
+ ``verify_placement.py``, which computes the same quantities from the same inputs
29
+ without sharing the packer's code. If the two ever disagree, believe
30
+ verify_placement.py.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import datetime
36
+ import json
37
+ import math
38
+ import os
39
+ import sys
40
+
41
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
42
+ import projpaths # noqa: E402
43
+
44
+ #: Palette for up to 12 modules; assigned in sorted module order so the same board
45
+ #: always produces the same colours run to run.
46
+ PALETTE = ["#2e8b57", "#c0392b", "#8e44ad", "#e67e22", "#2471a3", "#16a085",
47
+ "#7f8c8d", "#34495e", "#b7950b", "#a04000", "#1a5276", "#7d3c98"]
48
+ UNASSIGNED = "#95a5a6"
49
+ FONT = "DejaVu Sans, Segoe UI, Microsoft YaHei, Arial, sans-serif"
50
+ ROW_H = 18
51
+ LEGEND_COL_PX = 330
52
+ PANEL_HEAD_PX = 72
53
+
54
+
55
+ def esc(s):
56
+ return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
57
+
58
+
59
+ def rect_of(geo_ref, pose):
60
+ """Courtyard rectangle in board coordinates (KiCad Y down => clockwise turn)."""
61
+ x0, y0, x1, y1 = geo_ref["bbox_local"]
62
+ t = math.radians(pose["rot"])
63
+ c, s = math.cos(t), math.sin(t)
64
+ pts = [(lx * c + ly * s, -lx * s + ly * c) for lx in (x0, x1) for ly in (y0, y1)]
65
+ return (pose["x"] + min(p[0] for p in pts), pose["y"] + min(p[1] for p in pts),
66
+ pose["x"] + max(p[0] for p in pts), pose["y"] + max(p[1] for p in pts))
67
+
68
+
69
+ def poly_area(poly):
70
+ a = 0.0
71
+ n = len(poly)
72
+ for i in range(n):
73
+ x0, y0 = poly[i]
74
+ x1, y1 = poly[(i + 1) % n]
75
+ a += x0 * y1 - x1 * y0
76
+ return abs(a) / 2.0
77
+
78
+
79
+ def inside_poly(poly, x, y):
80
+ r, n = False, len(poly)
81
+ for i in range(n):
82
+ x0, y0 = poly[i]
83
+ x1, y1 = poly[(i + 1) % n]
84
+ if (y0 > y) != (y1 > y) and x < x0 + (y - y0) * (x1 - x0) / (y1 - y0):
85
+ r = not r
86
+ return r
87
+
88
+
89
+ def main() -> None:
90
+ ap = argparse.ArgumentParser(description=__doc__,
91
+ formatter_class=argparse.RawDescriptionHelpFormatter)
92
+ ap.add_argument("--geometry", required=True)
93
+ ap.add_argument("--plan", required=True)
94
+ ap.add_argument("--config", help="optional placement_config.json; only used to "
95
+ "recover module titles/keep-outs if the plan lacks them")
96
+ ap.add_argument("--out", help="SVG to write (default <placement dir>/placement_view.svg)")
97
+ ap.add_argument("--scale", type=float,
98
+ help="pixels per mm; default auto-fits the board to --target-width-px")
99
+ ap.add_argument("--title", help="override the drawing title")
100
+ ap.add_argument("--target-width-px", type=float, default=1500.0)
101
+ projpaths.add_outdir(ap)
102
+ args = ap.parse_args()
103
+
104
+ geom = json.load(open(args.geometry, encoding="utf-8-sig"))
105
+ plan = json.load(open(args.plan, encoding="utf-8-sig"))
106
+ cfg = json.load(open(args.config, encoding="utf-8-sig")) if args.config else {}
107
+
108
+ geo = {g["ref"]: g for g in geom["footprints"]}
109
+ pos = plan["positions"]
110
+ poly = [tuple(p) for p in geom["outline"]]
111
+ fixed = set(plan.get("fixed", []))
112
+ keepouts = plan.get("keepouts") or cfg.get("keepouts") or []
113
+ titles = dict(plan.get("module_titles") or {})
114
+ for m, spec in (cfg.get("modules") or {}).items():
115
+ titles.setdefault(m, spec.get("title", m))
116
+
117
+ modules = plan.get("modules") or {m: s.get("members", [])
118
+ for m, s in (cfg.get("modules") or {}).items()}
119
+ mod_of = {}
120
+ for m, members in modules.items():
121
+ for r in members:
122
+ mod_of[r] = m
123
+ order = sorted(modules)
124
+ color = {m: PALETTE[i % len(PALETTE)] for i, m in enumerate(order)}
125
+
126
+ bx0 = min(p[0] for p in poly)
127
+ bx1 = max(p[0] for p in poly)
128
+ by0 = min(p[1] for p in poly)
129
+ by1 = max(p[1] for p in poly)
130
+ bw, bh = bx1 - bx0, by1 - by0
131
+
132
+ S = args.scale or max(1.5, min(14.0, args.target_width_px / max(bw, 1e-6)))
133
+ PAD = 34
134
+ plan_w = bw * S + 2 * PAD
135
+ plan_h = bh * S + 2 * PAD
136
+
137
+ # ---- geometry of every part, and the display metrics -------------------
138
+ R = {r: rect_of(geo[r], p) for r, p in pos.items() if r in geo}
139
+ refs = sorted(R)
140
+
141
+ overlaps = []
142
+ for i in range(len(refs)):
143
+ a = refs[i]
144
+ ax0, ay0, ax1, ay1 = R[a]
145
+ for j in range(i + 1, len(refs)):
146
+ b = refs[j]
147
+ bxx0, byy0, bxx1, byy1 = R[b]
148
+ if ax0 < bxx1 and bxx0 < ax1 and ay0 < byy1 and byy0 < ay1:
149
+ overlaps.append((a, b))
150
+
151
+ off_board = []
152
+ for r in refs:
153
+ x0, y0, x1, y1 = R[r]
154
+ out = tot = 0
155
+ for k in range(12):
156
+ px = x0 + (x1 - x0) * ((k % 4) / 3.0)
157
+ py = y0 + (y1 - y0) * ((k // 4) / 2.0)
158
+ tot += 1
159
+ if not inside_poly(poly, px, py):
160
+ out += 1
161
+ if out:
162
+ off_board.append((r, out / tot))
163
+
164
+ board_area = poly_area(poly)
165
+ court_area = sum((R[r][2] - R[r][0]) * (R[r][3] - R[r][1]) for r in refs)
166
+ density = 100.0 * court_area / board_area if board_area else 0.0
167
+
168
+ mod_area, mod_count = {}, {}
169
+ for r in refs:
170
+ m = mod_of.get(r, "?")
171
+ mod_area[m] = mod_area.get(m, 0.0) + (R[r][2] - R[r][0]) * (R[r][3] - R[r][1])
172
+ mod_count[m] = mod_count.get(m, 0) + 1
173
+
174
+ weak = sorted({(g["ref"], g["bbox_src"]) for g in geom["footprints"]
175
+ if g.get("bbox_src") not in (None, "courtyard")})
176
+
177
+ # ---- panel content, counted BEFORE the canvas is sized -----------------
178
+ legend = [(m, mod_count.get(m, 0),
179
+ 100.0 * mod_area.get(m, 0.0) / board_area if board_area else 0.0)
180
+ for m in order]
181
+ unassigned = sorted(set(refs) - set(mod_of))
182
+ if unassigned:
183
+ legend.append(("(unassigned)", len(unassigned), 0.0))
184
+
185
+ rows = [
186
+ ("footprints on board / placed", "%d / %d" % (len(geo), len(pos)), "#222"),
187
+ ("fixed anchors / algorithm-placed",
188
+ "%d / %d" % (len(fixed), len(pos) - len(fixed)), "#222"),
189
+ ("board size / area", "%.1f x %.1f mm / %.0f mm2" % (bw, bh, board_area), "#222"),
190
+ ("courtyard area / density", "%.0f mm2 / %.1f%%" % (court_area, density),
191
+ "#c0392b" if density > 75 else "#1e8449"),
192
+ ("courtyard overlap pairs",
193
+ "%d%s" % (len(overlaps), " PASS" if not overlaps else " FAIL"),
194
+ "#1e8449" if not overlaps else "#c0392b"),
195
+ ("parts off the board",
196
+ "%d%s" % (len(off_board), " PASS" if not off_board else " REVIEW"),
197
+ "#1e8449" if not off_board else "#b7950b"),
198
+ ("unplaced by the packer", "%d" % len(plan.get("unplaced") or []),
199
+ "#222" if not plan.get("unplaced") else "#c0392b"),
200
+ ("keep-outs registered", "%d" % len(keepouts), "#222"),
201
+ ]
202
+ if weak:
203
+ rows.append(("courtyard stand-ins / no geometry",
204
+ "%d / %d" % (len(weak), sum(1 for w in weak if w[1] == "none")),
205
+ "#b7950b"))
206
+
207
+ notes = []
208
+ if overlaps:
209
+ notes.append(("overlapping: %s" % ", ".join("%s/%s" % p for p in overlaps[:6]),
210
+ "#c0392b"))
211
+ if off_board:
212
+ notes.append(("off-board: %s" % ", ".join("%s %.0f%%" % (r, f * 100)
213
+ for r, f in off_board[:6]), "#b7950b"))
214
+ for i, ko in enumerate(keepouts):
215
+ notes.append(("keep-out %d: %s" % (i + 1, (ko.get("why") or "")[:110]), "#c0392b"))
216
+
217
+ # legend columns, then the canvas that fits them
218
+ per_col = max(1, min(len(legend), 12))
219
+ ncol = max(1, -(-len(legend) // per_col))
220
+ legend_w = ncol * LEGEND_COL_PX
221
+ mx = PAD + legend_w + 16
222
+ body_rows = max(per_col, len(rows) + len(notes) + 1)
223
+ panel_h = PANEL_HEAD_PX + body_rows * ROW_H + 28
224
+ W = max(plan_w, mx + 520.0)
225
+ H = plan_h + panel_h
226
+
227
+ def tx(x):
228
+ return PAD + (x - bx0) * S
229
+
230
+ def ty(y):
231
+ return PAD + (y - by0) * S
232
+
233
+ title = args.title or ("%s - Phase-1 initial placement: courtyard occupancy by module"
234
+ % geom.get("board", "board"))
235
+
236
+ o = ['<?xml version="1.0" encoding="UTF-8"?>',
237
+ '<svg xmlns="http://www.w3.org/2000/svg" width="%.0f" height="%.0f" '
238
+ 'viewBox="0 0 %.0f %.0f" font-family="%s">' % (W, H, W, H, FONT),
239
+ '<defs><pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" '
240
+ 'patternTransform="rotate(45)">'
241
+ '<rect width="8" height="8" fill="#fdecea"/>'
242
+ '<line x1="0" y1="0" x2="0" y2="8" stroke="#c0392b" stroke-width="2.4"/>'
243
+ '</pattern></defs>',
244
+ '<rect width="100%" height="100%" fill="#fbfbfb"/>']
245
+
246
+ o.append('<polygon points="%s" fill="#eef3f7" stroke="#1a1a1a" stroke-width="2.5"/>'
247
+ % " ".join("%.1f,%.1f" % (tx(px), ty(py)) for px, py in poly))
248
+
249
+ for ko in keepouts:
250
+ x0, y0, x1, y1 = ko["rect"]
251
+ o.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="url(#hatch)" '
252
+ 'stroke="#c0392b" stroke-width="1.4" stroke-dasharray="5,3"/>'
253
+ % (tx(x0), ty(y0), (x1 - x0) * S, (y1 - y0) * S))
254
+
255
+ # big parts first, so a small part's label is never buried under a big rectangle
256
+ for ref in sorted(refs, key=lambda r: -((R[r][2] - R[r][0]) * (R[r][3] - R[r][1]))):
257
+ x0, y0, x1, y1 = R[ref]
258
+ fx, fy = tx(x0), ty(y0)
259
+ w, h = (x1 - x0) * S, (y1 - y0) * S
260
+ col = color.get(mod_of.get(ref), UNASSIGNED)
261
+ is_fixed = ref in fixed
262
+ o.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" '
263
+ 'fill-opacity="%.2f" stroke="%s" stroke-width="%.1f" rx="1.5"/>'
264
+ % (fx, fy, w, h, col, 0.85 if is_fixed else 0.5,
265
+ "#111" if is_fixed else col, 1.2 if is_fixed else 0.6))
266
+ if w >= 34 and h >= 15:
267
+ o.append('<text x="%.1f" y="%.1f" font-size="10" fill="#111" '
268
+ 'text-anchor="middle">%s</text>'
269
+ % (fx + w / 2, fy + h / 2 + 3.5, esc(ref)))
270
+
271
+ anchors = plan.get("anchors") or {}
272
+ for m in order:
273
+ for ax, ay in anchors.get(m, []):
274
+ o.append('<path d="M %.1f %.1f l 7 7 m 0 -7 l -7 7" stroke="%s" '
275
+ 'stroke-width="2.2" fill="none"/>'
276
+ % (tx(ax) - 3.5, ty(ay) - 3.5, color.get(m, "#111")))
277
+
278
+ bar_mm = 10.0 if bw > 60 else 20.0
279
+ while bar_mm * S < 60:
280
+ bar_mm *= 2
281
+ bxp, byp = PAD + 6, plan_h - PAD - 8
282
+ o.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#111" stroke-width="3"/>'
283
+ % (bxp, byp, bxp + bar_mm * S, byp))
284
+ o.append('<text x="%.1f" y="%.1f" font-size="12" fill="#111">%g mm</text>'
285
+ % (bxp + bar_mm * S + 8, byp + 4, bar_mm))
286
+
287
+ # ---- panel -------------------------------------------------------------
288
+ def text(x, y, s, size=12, fill="#222", weight="normal", anchor="start"):
289
+ o.append('<text x="%.1f" y="%.1f" font-size="%d" font-weight="%s" fill="%s" '
290
+ 'text-anchor="%s">%s</text>'
291
+ % (x, y, size, weight, fill, anchor, esc(s)))
292
+
293
+ head_y = plan_h + 26
294
+ o.append('<line x1="0" y1="%.1f" x2="%.0f" y2="%.1f" stroke="#ccc" stroke-width="1"/>'
295
+ % (plan_h, W, plan_h))
296
+ text(PAD, head_y, title, 17, "#111", "bold")
297
+ text(PAD, head_y + 20,
298
+ "%s | generated %s | display metrics only - the gate is verify_placement.py"
299
+ % (geom.get("board", ""), datetime.datetime.now().strftime("%Y-%m-%d %H:%M")),
300
+ 11, "#555")
301
+
302
+ top = plan_h + PANEL_HEAD_PX
303
+ text(PAD, top, "modules", 13, "#111", "bold")
304
+ for i, (m, n, share) in enumerate(legend):
305
+ col_i, row_i = divmod(i, per_col)
306
+ lx = PAD + col_i * LEGEND_COL_PX
307
+ ly = top + ROW_H * (row_i + 1)
308
+ col = color.get(m, UNASSIGNED)
309
+ o.append('<rect x="%.1f" y="%.1f" width="12" height="12" fill="%s" '
310
+ 'fill-opacity="0.6" stroke="%s"/>' % (lx, ly - 10, col, col))
311
+ text(lx + 17, ly, "%s %s (%d, %.1f%%)" % (m, titles.get(m, ""), n, share), 11)
312
+
313
+ text(mx, top, "evaluation panel", 13, "#111", "bold")
314
+ my = top + ROW_H
315
+ for label, value, col in rows:
316
+ text(mx, my, "%s:" % label, 11, "#555")
317
+ text(mx + 250, my, value, 11, col, "bold")
318
+ my += ROW_H
319
+ my += 4
320
+ for note, col in notes:
321
+ text(mx, my, note, 10, col)
322
+ my += 15
323
+
324
+ o.append("</svg>")
325
+
326
+ out_path = projpaths.resolve_out(args.out, "placement_view.svg",
327
+ refs=(args.geometry, args.plan), outdir=args.outdir)
328
+ with open(out_path, "w", encoding="utf-8") as fh:
329
+ fh.write("\n".join(o) + "\n")
330
+
331
+ print("wrote %s (%.0f x %.0f px, %.2f px/mm)" % (out_path, W, H, S))
332
+ print("modules %d | parts %d | fixed %d | density %.1f%% | overlaps %d | off-board %d"
333
+ % (len(order), len(pos), len(fixed), density, len(overlaps), len(off_board)))
334
+ if weak:
335
+ print("stand-in courtyards (name these in the report): %s"
336
+ % ", ".join("%s=%s" % w for w in weak[:12]))
337
+
338
+
339
+ if __name__ == "__main__":
340
+ main()
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a placement plan into the hand-off pose table (CSV).
3
+
4
+ python pose_table.py --geometry geometry.json --plan placement_plan.json
5
+ # -> <placement dir>/placement_pose_table.csv (override with --out / --outdir)
6
+
7
+ Columns: module, ref, value, footprint, origin_x_mm, origin_y_mm, rotation_deg,
8
+ courtyard_w_mm, courtyard_h_mm, center_x_mm, center_y_mm, source
9
+ ``source`` is ``fixed`` for anchors the config pinned and ``placed`` otherwise, so a
10
+ reviewer can tell decisions from consequences. Written UTF-8 with BOM so Excel
11
+ opens the Chinese module names correctly.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import csv
17
+ import json
18
+ import math
19
+ import os
20
+ import sys
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+ import projpaths # noqa: E402
24
+
25
+
26
+ def main() -> None:
27
+ ap = argparse.ArgumentParser(description=__doc__,
28
+ formatter_class=argparse.RawDescriptionHelpFormatter)
29
+ ap.add_argument("--geometry", required=True)
30
+ ap.add_argument("--plan", required=True)
31
+ ap.add_argument("--out", help="CSV to write "
32
+ "(default <placement dir>/placement_pose_table.csv)")
33
+ projpaths.add_outdir(ap)
34
+ args = ap.parse_args()
35
+
36
+ out_path = projpaths.resolve_out(args.out, "placement_pose_table.csv",
37
+ refs=(args.geometry, args.plan),
38
+ outdir=args.outdir)
39
+
40
+ geo = {g["ref"]: g
41
+ for g in json.load(open(args.geometry, encoding="utf-8-sig"))["footprints"]}
42
+ plan = json.load(open(args.plan, encoding="utf-8-sig"))
43
+ pos = plan["positions"]
44
+ fixed = set(plan.get("fixed", []))
45
+ order = list(plan["modules"].keys())
46
+
47
+ rows = []
48
+ for mod in order:
49
+ for ref in plan["modules"][mod]:
50
+ if ref not in pos:
51
+ continue
52
+ p = pos[ref]
53
+ x0, y0, x1, y1 = geo[ref]["bbox_local"]
54
+ t = math.radians(p["rot"])
55
+ c, s = math.cos(t), math.sin(t)
56
+ pts = [(lx * c + ly * s, -lx * s + ly * c) for lx in (x0, x1) for ly in (y0, y1)]
57
+ xs = [q[0] for q in pts]
58
+ ys = [q[1] for q in pts]
59
+ rows.append({
60
+ "module": mod, "ref": ref, "value": geo[ref]["value"],
61
+ "footprint": geo[ref]["libid"],
62
+ "origin_x_mm": "%.3f" % p["x"], "origin_y_mm": "%.3f" % p["y"],
63
+ "rotation_deg": "%.4g" % (p["rot"] % 360),
64
+ "courtyard_w_mm": "%.2f" % (max(xs) - min(xs)),
65
+ "courtyard_h_mm": "%.2f" % (max(ys) - min(ys)),
66
+ "center_x_mm": "%.2f" % (p["x"] + (min(xs) + max(xs)) / 2),
67
+ "center_y_mm": "%.2f" % (p["y"] + (min(ys) + max(ys)) / 2),
68
+ "source": "fixed" if ref in fixed else "placed",
69
+ })
70
+
71
+ cols = ["module", "ref", "value", "footprint", "origin_x_mm", "origin_y_mm",
72
+ "rotation_deg", "courtyard_w_mm", "courtyard_h_mm",
73
+ "center_x_mm", "center_y_mm", "source"]
74
+ with open(out_path, "w", encoding="utf-8-sig", newline="") as fh:
75
+ w = csv.DictWriter(fh, fieldnames=cols)
76
+ w.writeheader()
77
+ w.writerows(rows)
78
+ print("wrote %s (%d rows)" % (out_path, len(rows)))
79
+ by_module = {}
80
+ for r in rows:
81
+ by_module[r["module"]] = by_module.get(r["module"], 0) + 1
82
+ print("per module:", by_module)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()