@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,317 @@
|
|
|
1
|
+
# 初始布局算法:数据流、几何、搜索与失效模式
|
|
2
|
+
|
|
3
|
+
这份文档是 `pcb-initial-placement` 技能的实现说明。所有数字都来自对 KiCad `video`
|
|
4
|
+
示范板(189 器件 / 2118 焊盘 / 589 网络 / PCI 312×106.7 mm 板)的实测。
|
|
5
|
+
|
|
6
|
+
## 1. 数据流
|
|
7
|
+
|
|
8
|
+
一次运行的所有产物都落在**项目下的 `placement/`**(与板文件同级,不存在就新建)。技能目录
|
|
9
|
+
只有代码,cwd 不留任何东西。脚本默认就写在那里:知道 `--board` 的用 `<板目录>/placement/`,
|
|
10
|
+
其余用输入文件所在目录;`--outdir` 换目录,`--out` 精确指定文件名。
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
board.kicad_pcb ──board_geometry.py(只读)──▶ placement/geometry.json
|
|
14
|
+
├ 每个器件的 courtyard 局部矩形 (+bbox_src)
|
|
15
|
+
└ 板框闭合多边形 + 包围盒
|
|
16
|
+
│
|
|
17
|
+
placement/placement_config.json ──人的决策:模块/锚点/固定件/禁布区/顺序 ──┤
|
|
18
|
+
▼
|
|
19
|
+
pack.py
|
|
20
|
+
▼
|
|
21
|
+
placement/placement_plan.json
|
|
22
|
+
│
|
|
23
|
+
┌──────────────┬──────────────┬───────────┼──────────────┐
|
|
24
|
+
▼ ▼ ▼ ▼ ▼
|
|
25
|
+
verify_placement.py placement_view.py pose_table.py apply_via_ipc.py
|
|
26
|
+
(独立校验 → (模块彩色视图 + (交接位姿表 (首选:整批一个
|
|
27
|
+
verify_placement 评估面板 → CSV) undo → apply_
|
|
28
|
+
.txt,硬门槛) placement_view.svg) via_ipc.txt)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
packer 完全不理解 KiCad 文件语法——它只吃矩形和多边形。唯一需要读板文件的地方是
|
|
32
|
+
"把某个焊盘钉在板边坐标上"(金手指),那时才用只读的 s-expression 解析器查一个焊盘。
|
|
33
|
+
|
|
34
|
+
`verify_placement.py` 与 `placement_view.py` 各自独立重算重叠/出板/密度,**不共用 packer
|
|
35
|
+
的代码**;两者都会把结果落盘。视图里的门槛数字只用于显示,判据始终是校验脚本。
|
|
36
|
+
|
|
37
|
+
## 2. 坐标与几何
|
|
38
|
+
|
|
39
|
+
### 2.1 旋转公式
|
|
40
|
+
|
|
41
|
+
KiCad 板面坐标 **Y 轴向下**,所以正角度是**顺时针**。局部坐标 `(lx, ly)` 绕原点转 `deg`
|
|
42
|
+
后在世界坐标下的偏移:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
dx = lx·cos(θ) + ly·sin(θ)
|
|
46
|
+
dy = −lx·sin(θ) + ly·cos(θ)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
这个式子在 video 板上用全部 2118 个焊盘逐点比对 KiCad 的实际落点验证过,**最大误差
|
|
50
|
+
0.000000000 mm**。它必须对,否则所有间距计算都是错的,而错误又小到看不出来。
|
|
51
|
+
|
|
52
|
+
用旋转后的四个角点做 min/max,得到该朝向下的轴对齐占用矩形:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
pts = [rot_pt(px, py, rot) for px in (x0, x1) for py in (y0, y1)]
|
|
56
|
+
box = (min(x), min(y), max(x), max(y))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2.2 占用矩形(courtyard)
|
|
60
|
+
|
|
61
|
+
重叠判据只有一个:**courtyard 层**的图形。取不到就按固定顺序退化:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
CrtYd → Fab → 焊盘外扩 → 丝印(SilkS) → 零面积点
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
每个器件的来源记在 `bbox_src` 里,必须逐件点名——一个错误的 courtyard 会安静地毁掉整个
|
|
68
|
+
布局,而且看起来一切正常。
|
|
69
|
+
|
|
70
|
+
`video` 板 189 个器件**全部**有 courtyard,没有退化情形。但退化情形是真会出现的:一块
|
|
71
|
+
LibreSolar BMS 板(208 器件)上的两个丝印 logo 既没有 courtyard、没有 Fab 图形,也没有
|
|
72
|
+
焊盘——走到"焊盘外扩"那一步拿到 `None`,旧版 `board_geometry.py` 于是在汇总行
|
|
73
|
+
`r["bbox_local"][2]` 上抛 `TypeError: 'NoneType' object is not subscriptable`,整块板提不出几何。
|
|
74
|
+
现在的链路会在丝印层兜住它,标成 `bbox_src: "silk"` 并点名。
|
|
75
|
+
|
|
76
|
+
**零面积点是最后一种情况,也是最危险的一种**:器件没有任何可测几何,谁压它都不算重叠。
|
|
77
|
+
它会被单独警告,必须人工处理——不要当它已经放好了。
|
|
78
|
+
|
|
79
|
+
丝印不是电气层,用丝印推出来的框只影响外观与排布美观,不影响电气正确性;但**它仍然是
|
|
80
|
+
参与重叠判定和占位的真实矩形**,所以别把 logo 当成可以随便压的装饰件。
|
|
81
|
+
|
|
82
|
+
### 2.3 板内判定
|
|
83
|
+
|
|
84
|
+
射线法(奇偶规则)判断点是否在板框多边形内。注意边界点:点恰好落在某条边的 x 坐标上时
|
|
85
|
+
`x < x0 + ...` 为假,该边不计数,结果由另一侧的边决定。这不是 bug,但它意味着
|
|
86
|
+
**"正好在板边"是模糊的**,所以不能依赖它做安全边界——见 2.4。
|
|
87
|
+
|
|
88
|
+
### 2.4 栅格与半格腐蚀
|
|
89
|
+
|
|
90
|
+
- 栅格 `GRID = 0.635 mm`(25 mil,PCB 常用摆放步长)。
|
|
91
|
+
- 单元格可用当且仅当它的**中心**和**四个角**都在板内(角点向外偏 `GRID/2 + 1e-6`)。
|
|
92
|
+
这把板框向内腐蚀了约半格(0.32 mm),保证任何合法位置的 courtyard 都不会压在板边外。
|
|
93
|
+
|
|
94
|
+
没有这一步会怎样:单元中心在板内、但矩形边压在板外,器件就"合法地"挂在板外。
|
|
95
|
+
实测中 R14 和 L6 就这样各挂出板外 0.2 mm 和 0.4 mm——2D 校验只查重叠,不查板框,
|
|
96
|
+
所以它们一路通过了所有检查。
|
|
97
|
+
|
|
98
|
+
## 3. 占用栅格与所有权
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
OCC[j][i] = 1 表示这一格被占
|
|
102
|
+
OWNER[j][i] = 255 固定件 / 禁布区(不可驱逐)
|
|
103
|
+
= 0..254 已摆放器件的 id(可驱逐)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
三种状态各有用途:
|
|
107
|
+
|
|
108
|
+
1. **判空**:`rect_free(rect, gap)` 检查矩形膨胀 `gap` 后覆盖的所有格子。
|
|
109
|
+
2. **登记**:`mark(rect, gap, owner)` 盖章。
|
|
110
|
+
3. **驱逐**:疏散修复需要知道"这个位置挤到了谁",靠 `OWNER` 反查;固定件返回 255,
|
|
111
|
+
表示"碰不得,换个别的位置"。
|
|
112
|
+
|
|
113
|
+
两个必须遵守的细节:
|
|
114
|
+
|
|
115
|
+
- **盖章和判空必须用同一套取整。** 曾经一边用 `round()`、一边用 `int(v/GRID + 0.5)`,
|
|
116
|
+
结果膨胀后的矩形溢出一格,覆盖了邻居的 `OWNER`,随后 `BY_IDX[owner]` 抛 `KeyError: 50`。
|
|
117
|
+
- **索引必须用 `math.floor`,不能用 `int`。** `int()` 向零截断:`int(-0.278) == 0`,
|
|
118
|
+
于是"比板边靠外一点"的矩形被判成从第 0 列开始,而不是"越界,拒绝"。
|
|
119
|
+
|
|
120
|
+
**所有权 id 要回收。** 每 commit 一次就新分配 id,很快超过 255,`bytearray` 直接抛
|
|
121
|
+
`ValueError: byte must be in range(0, 256)`。正确做法是维护一个 `free_ids` 回收池,
|
|
122
|
+
`release(ref)` 时把 id 还回去并清掉该 id 占的格子。
|
|
123
|
+
|
|
124
|
+
`unmark(owner)` 遍历全栅格清除该 owner 的格子。这是 O(NX·NY),每次调用约 3 ms
|
|
125
|
+
(337×170 栅格),在几千次调用的规模下可以接受。
|
|
126
|
+
|
|
127
|
+
## 4. 功能分块:证据链
|
|
128
|
+
|
|
129
|
+
### 4.1 连通性
|
|
130
|
+
|
|
131
|
+
从网表构造器件-器件权重矩阵。`video` 板的关键数据(互相连接的网数):
|
|
132
|
+
|
|
133
|
+
| 器件对 | 共用网络数 | 结论 |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| U11 ↔ BUS1 | 51 | PCI 总线接口核心,必须贴金手指 |
|
|
136
|
+
| U11 ↔ U24 | 47 | 总线控制器和主 FPGA 紧邻 |
|
|
137
|
+
| U24 ↔ U23 | 47 | 两个 FPGA 同区 |
|
|
138
|
+
| U11 ↔ U23 | 41 | 同上 |
|
|
139
|
+
| U22 ↔ 每个 SIMM | 32 | SIMM 控制器必须在帧缓存阵列旁 |
|
|
140
|
+
| U8 ↔ U9 | 37 | RAMDAC 与 encoder 同区 |
|
|
141
|
+
| U3–U6 ↔ SIMM | 各 8 | 总线缓冲排成一排贴着 SIMM |
|
|
142
|
+
|
|
143
|
+
这张表直接变成了 floorplan:U11 靠金手指、U24/U23/U22 靠 U11、SIMM 阵列靠 U22、
|
|
144
|
+
U3–U6 排成一列贴着 SIMM。
|
|
145
|
+
|
|
146
|
+
### 4.2 电源网络必须降权
|
|
147
|
+
|
|
148
|
+
`video` 板的电源扇出:**GND 115 个器件、+5V 70 个、+5F 5 个、+3.3V 3 个**。
|
|
149
|
+
如果按原始连通性聚类,整板会被 GND 并成一个巨大模块。
|
|
150
|
+
|
|
151
|
+
处理:把电源/地网从聚类权重里剔除;聚类完成后再把**只通过电源/地连接的 31 个器件**
|
|
152
|
+
(去耦、滤波电容)按电源域分给对应模块,并在报告里显式标注这是"归属不确定"的一类。
|
|
153
|
+
|
|
154
|
+
### 4.3 sheet 前缀
|
|
155
|
+
|
|
156
|
+
网名的原理图路径前缀是作者自己的功能划分,作为交叉验证:`/buspci.sch/` 73 条 →
|
|
157
|
+
BUS1/RR1–RR6/U11;`/graphic/` 32 条 → U2/U7/U21/U23/U24/X1 等;`/pal-ntsc.sch/` 4 条 →
|
|
158
|
+
U10 视频输入链;`/ESVIDEO-RVB/` 3 条 → U8/U9 输出链。
|
|
159
|
+
|
|
160
|
+
### 4.4 不变式
|
|
161
|
+
|
|
162
|
+
用断言守住两条,任何一条不成立就直接退出而不是继续跑:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
assert {全部模块的成员} == {板上所有器件的 ref} # 恰好一个归属,无重复无遗漏
|
|
166
|
+
assert 每个模块至少有一个核心器件 # 找不到核心说明分块有问题
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`video` 板的结果:M1 20 / M2 30 / M3 36 / M4 40 / M5 47 / M6 16 = 189 ✓
|
|
170
|
+
|
|
171
|
+
## 5. 锚点与定位件
|
|
172
|
+
|
|
173
|
+
### 5.1 从板框反推封装原点
|
|
174
|
+
|
|
175
|
+
金手指封装的原点无所谓,但焊盘必须落在板边。找一个语义明确的焊盘(PCI 的 A1),
|
|
176
|
+
用它的局部坐标反推:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
origin = 板边目标点 − 焊盘局部坐标
|
|
180
|
+
视频板:A1 局部 = (−62.865, −0.254),目标 (95.25, 160.195)
|
|
181
|
+
→ BUS1 原点 = (158.115, 160.449)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 5.2 结构偏移
|
|
185
|
+
|
|
186
|
+
`J4` 的封装名是 `DSUB-9_Female_Horizontal_P2.77x2.84mm_EdgePinOffset14.56mm_...`,
|
|
187
|
+
名字里的 14.56 就是"板边到原点"的距离 → `origin_x = 53.594 + 14.56 = 68.154`。
|
|
188
|
+
|
|
189
|
+
BNC 支架连接器同理:本体伸到板外 14.5 mm,原点压在板边内 4.4 mm。
|
|
190
|
+
|
|
191
|
+
**这些数字要来自板子和封装本身,不能凭印象填。**
|
|
192
|
+
|
|
193
|
+
### 5.3 机械禁布区
|
|
194
|
+
|
|
195
|
+
8 个 SIMM 插座排成 2 列 × 4 行,行距 14.5 mm,插座 courtyard 高 8.1 mm,行间只剩 6.4 mm。
|
|
196
|
+
模块插入后下倾约 30°,顶端水平伸出约 11 mm —— 会盖住行间区域。因此把行间登记为禁布区。
|
|
197
|
+
|
|
198
|
+
但要**同时算清代价**:`video` 板行间占用约 6.7 千 mm²,把它整块留空后剩下的板面
|
|
199
|
+
**放不下全部 189 个器件**。所以这不是"算法偷懒",是板面密度的硬约束——这种情况要
|
|
200
|
+
把数字摆出来让用户选(改变安装姿态 / 换插座 / 接受小器件被压住),而不是自己决定。
|
|
201
|
+
|
|
202
|
+
## 6. 环形搜索
|
|
203
|
+
|
|
204
|
+
### 6.1 为什么是环形而不是行扫描
|
|
205
|
+
|
|
206
|
+
行扫描(从板子左上角逐行找第一个空位)会把器件甩到离核心很远的地方,彻底破坏分组。
|
|
207
|
+
环形搜索以**模块锚点**为中心,半径按 `coarse = 1.27 mm` 增长,每圈按角度采样,返回
|
|
208
|
+
**离锚点最近的合法位置**——它天然把器件聚在核心旁边。
|
|
209
|
+
|
|
210
|
+
采样密度 `n = max(8, 2πR/coarse)`:半径 30 mm 时每圈约 148 个点,点间距 1.27 mm。
|
|
211
|
+
**采样间距必须小于最小器件的短边**,否则会跳过窄缝里的合法位置(曾经用 3.81 mm,
|
|
212
|
+
大量器件因此找不到位置,只放下 102/189)。
|
|
213
|
+
|
|
214
|
+
找到候选点后在 ±3 格内做一次网格细化,把位置吸附到栅格上并取更优者。
|
|
215
|
+
|
|
216
|
+
### 6.2 多锚点
|
|
217
|
+
|
|
218
|
+
一个模块有多个核心时,对每个锚点各搜一次,取距离最近的那个解。所以一个模块的外围
|
|
219
|
+
器件会自然聚到"离自己最近的那个核心"旁边,而不是全部挤在一个点上。
|
|
220
|
+
|
|
221
|
+
### 6.3 半径阶梯
|
|
222
|
+
|
|
223
|
+
一个器件这一轮失败,下一轮给它更大的半径:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
RADIUS = [30, 45, 65, 90, 130, 200] mm
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**不要在失败时直接跳到"板子任意位置"**。早期版本用了一个"最终溢出 pass",
|
|
230
|
+
结果没放下的器件全部被倒到板子另一头,M4 模块的距离中位数被拉到 156 mm。
|
|
231
|
+
|
|
232
|
+
## 7. 模块顺序会决定成败(实测)
|
|
233
|
+
|
|
234
|
+
同一份算法,只改模块处理顺序,`video` 板的结果:
|
|
235
|
+
|
|
236
|
+
| 顺序 | 总距离 | 最大距离 | M4 中位数 | M5 中位数 |
|
|
237
|
+
| --- | --- | --- | --- | --- |
|
|
238
|
+
| `M1,M5,M4,...` | 10612 mm | 233.5 mm | **156.0 mm** | 43.2 mm |
|
|
239
|
+
| `M1,M4,M5,...` | **10445 mm** | **192.7 mm** | 67.7 mm | 113.7 mm |
|
|
240
|
+
| 轮流先手(rotation) | 10612 mm | 233.5 mm | 156.0 mm | 43.2 mm |
|
|
241
|
+
| M4 优先 + M5 补锚点 | 10496 mm | 186.2 mm | 67.7 mm | 110.1 mm |
|
|
242
|
+
|
|
243
|
+
结论:**顺序是超参数,必须实测**。评分用"每个器件到本模块最近核心的距离"的
|
|
244
|
+
中位数、最大值和总和——中位数反映典型分散度,最大值反映最坏飞地,总和反映整体。
|
|
245
|
+
|
|
246
|
+
轮流先手(每轮换一个模块先取)在这块板上没有改善,因为两个模块的需求量差距太大,
|
|
247
|
+
先手权不足以补偿。这条也说明:**直觉方案要验证,不能想当然**。
|
|
248
|
+
|
|
249
|
+
## 8. 疏散修复
|
|
250
|
+
|
|
251
|
+
板子密到一定程度,贪心一定会剩几个器件无处可放。这时不要降低间距(会破坏设计规则),
|
|
252
|
+
而是做**驱逐**:
|
|
253
|
+
|
|
254
|
+
1. 扫描全板,找所有"只压到可驱逐器件"的合法位置。
|
|
255
|
+
2. 代价 = 被驱逐器件的 courtyard 面积之和。
|
|
256
|
+
3. 代价最小的位置胜出;被挤走的器件重新入队。
|
|
257
|
+
4. 代价超过自身面积 2 倍时放弃——太破坏性的交换不值得。
|
|
258
|
+
|
|
259
|
+
`video` 板最终 189/189 全部落地,没有走"降低间距"这条路。
|
|
260
|
+
|
|
261
|
+
## 9. 向核心压实
|
|
262
|
+
|
|
263
|
+
全部落地后,按"离核心从远到近"排序,依次尝试把每个器件挪到"更靠近自己核心的合法位置"。
|
|
264
|
+
|
|
265
|
+
这一步**不可能破坏可行性**:候选位置必须通过同一套 `rect_free` 检查;找不到更近的
|
|
266
|
+
合法位置时就原地不动。所以它只能让分组更紧。
|
|
267
|
+
|
|
268
|
+
`video` 板实测:第 1 轮移动 4 个、第 2 轮 5 个、第 3 轮 0 个——3 轮内收敛到局部最优。
|
|
269
|
+
|
|
270
|
+
## 10. 复杂度与运行时间
|
|
271
|
+
|
|
272
|
+
- 栅格:337 × 170 ≈ 57k 格;`mark`/`rect_free` 只碰矩形覆盖的格子,典型 9×5。
|
|
273
|
+
- 环形搜索:最坏 200 mm / 1.27 mm ≈ 157 圈 × 每圈最多约 990 点 × 2 朝向,
|
|
274
|
+
但绝大多器件在第 1–3 圈就命中。
|
|
275
|
+
- 疏散修复:全板扫描,`step=2` 降采样,只在剩下的少数器件上跑。
|
|
276
|
+
- **实测 `video` 板(189 器件)整个 pack 阶段约 200–220 s**(单线程 Python)。
|
|
277
|
+
其中绝大部分时间在疏散修复和压实的全板扫描上。
|
|
278
|
+
|
|
279
|
+
单线程足够:这是一个几分钟量级的离线规划任务,不是交互式工具。
|
|
280
|
+
|
|
281
|
+
## 11. 已知失效模式速查
|
|
282
|
+
|
|
283
|
+
| 症状 | 根因 | 修法 |
|
|
284
|
+
| --- | --- | --- |
|
|
285
|
+
| 只放下 100/189 | 环形采样间距 > 器件短边,跳过窄缝 | `coarse` 降到 1.27 mm 或更小 |
|
|
286
|
+
| 少数器件被甩到板子另一头 | 失败后被送进"最终溢出 pass" | 改成半径阶梯,逐轮扩大搜索 |
|
|
287
|
+
| 报 `KeyError: <owner id>` | 盖章和判空取整不一致,矩形溢出覆盖邻居所有权 | 统一取整函数 |
|
|
288
|
+
| 报 `byte must be in range(0,256)` | 所有权 id 只增不回收 | 加 `free_ids` 回收池 + `release()` |
|
|
289
|
+
| 非连接器器件挂在板外 0.2–0.4 mm | 板内判定没做半格腐蚀 + `int()` 截断索引 | 四角判定 + `math.floor` |
|
|
290
|
+
| 某个模块整体散开 | 该模块在 round-robin 里排在后面,近处空间被先手模块占完 | 调整模块顺序并实测评分 |
|
|
291
|
+
| 器件"消失"但报告写着 0 未放置 | 失败的器件被静默丢掉,没有重新入队 | 失败必须 `append` 回队列 |
|
|
292
|
+
| courtyard 面积统计正常但布局很挤 | 没有考虑间距膨胀;courtyard + 0.6 mm 后占板 78% | 把膨胀后的面积算进密度预算 |
|
|
293
|
+
| 网表聚类把整板并成一组 | GND/+5V 没降权 | 电源网剔除权重,事后按电源域分配 |
|
|
294
|
+
| `TypeError: 'NoneType' object is not subscriptable`(提取几何时) | 器件既无 courtyard、无 Fab、无焊盘(丝印 logo),退化链拿到 `None` 后仍去索引它 | 退化链补上丝印兜底,并对"什么都没有"的件单独告警 |
|
|
295
|
+
| `IndexError: list index out of range`(校验分组时) | 某个模块成员全是固定件(安装孔、板边端子),`anchors` 为空表,却仍按 `anchors[k][0][0]` 取锚点 | 做距离/离群统计前先筛掉没有锚点的模块 |
|
|
296
|
+
| 计划里 28 个定位件,板子上一个都没动 | `apply_via_ipc.py` 默认跳过 `fixed`,而这块板的定位件也在停机坪上 | 零布局的板子必须带 `--include-fixed`;提交后实时读回全部位号核对 |
|
|
297
|
+
| 报告/视图数字对不上 | 两处各算了一套密度或重叠 | 判据只认 `verify_placement.py`;视图只复述 |
|
|
298
|
+
|
|
299
|
+
## 12. 视图生成(placement_view.py)
|
|
300
|
+
|
|
301
|
+
出图只依赖标准库——不引入 cairo、不调浏览器、不要求装 KiCad。理由是这张图必须在**分析
|
|
302
|
+
流水线内部**就能产出:一块提取不出几何的板子,也不该连张图都看不到。
|
|
303
|
+
|
|
304
|
+
几个刻意的选择:
|
|
305
|
+
|
|
306
|
+
- **自动比例尺**:`S = clamp(target_width_px / 板宽, 1.5, 14)` px/mm,默认把板子铺到约
|
|
307
|
+
1500 px 宽;太小的板子不会糊,超长板也不会溢出。`--scale` 可覆盖。
|
|
308
|
+
- **画布按内容定尺寸**,不是拍一个高度。图例行数、指标行数、禁布区说明行数都先数出来,
|
|
309
|
+
再算 `panel_h`——否则 14 个模块或 5 条禁布区的板子会把文字画出下边缘。校验方式很直接:
|
|
310
|
+
解析生成的 SVG,确认**所有 `<text>` 的 y + 字号都在画布内**。
|
|
311
|
+
- **固定件实心、算法摆放半透明**:评审时要能把"我决定的"和"算法推出来的"分开看,这个
|
|
312
|
+
透明度差就是那条分界线。
|
|
313
|
+
- **禁布区画成斜线填充并写出原因**:禁布区是决策,不是背景噪声。
|
|
314
|
+
- **调色板按模块名排序分配**,同一块板每次出图颜色一致;模块多于 12 个时循环取色,同时
|
|
315
|
+
在 `bbox_src` 警告里点名,避免两个模块撞色后没人发现。
|
|
316
|
+
- 面板里的重叠对数、出板数等是**显示用**的:这里的算术只看计划文件,不读板。真正的判据
|
|
317
|
+
是 `verify_placement.py`。
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Apply a Phase-1 placement plan to the OPEN board through KiCad's IPC API.
|
|
3
|
+
|
|
4
|
+
python apply_via_ipc.py --plan placement_plan.json # preview commit
|
|
5
|
+
python apply_via_ipc.py --plan placement_plan.json --save # and persist
|
|
6
|
+
|
|
7
|
+
This is the preferred way to get a placement into KiCad. KiCad owns the objects,
|
|
8
|
+
the UUIDs, the connectivity and the undo history, and the whole placement lands as
|
|
9
|
+
ONE undo step - the user evaluates it with a single Ctrl+Z.
|
|
10
|
+
|
|
11
|
+
Read the kicad-ipc skill before changing this script. Rules that matter here:
|
|
12
|
+
|
|
13
|
+
* never edit .kicad_pcb by hand while an editor has the board open
|
|
14
|
+
* one commit for the whole batch, dropped on any validation failure
|
|
15
|
+
* re-read the updated footprints and compare them with the plan - ``update_items``
|
|
16
|
+
returning without raising is not proof that the board now matches the plan
|
|
17
|
+
* ``board.save()`` only when the user asked for it
|
|
18
|
+
|
|
19
|
+
Before running: kicad_ipc_diagnose must be ok, and DSH needs Full Access so the
|
|
20
|
+
KiCad pipe is reachable.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import csv
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
|
|
32
|
+
import projpaths # noqa: E402
|
|
33
|
+
from kipy.geometry import Angle, Vector2 # noqa: E402
|
|
34
|
+
|
|
35
|
+
from kipy_common import close_kicad, connect_board # noqa: E402
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_plan(path):
|
|
39
|
+
"""Accept pack.py's placement_plan.json or report.py's pose table CSV."""
|
|
40
|
+
if path.lower().endswith(".json"):
|
|
41
|
+
plan = json.load(open(path, encoding="utf-8-sig"))
|
|
42
|
+
target = {ref: (p["x"], p["y"], p["rot"] % 360)
|
|
43
|
+
for ref, p in plan["positions"].items()}
|
|
44
|
+
unplaced = plan.get("unplaced", [])
|
|
45
|
+
fixed = set(plan.get("fixed", []))
|
|
46
|
+
return target, unplaced, fixed
|
|
47
|
+
target, fixed = {}, set()
|
|
48
|
+
with open(path, encoding="utf-8-sig", newline="") as fh:
|
|
49
|
+
for row in csv.DictReader(fh):
|
|
50
|
+
target[row["ref"]] = (float(row["origin_x_mm"]), float(row["origin_y_mm"]),
|
|
51
|
+
float(row["rotation_deg"]) % 360)
|
|
52
|
+
if row.get("source") == "fixed":
|
|
53
|
+
fixed.add(row["ref"])
|
|
54
|
+
return target, [], fixed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def angle_delta(a, b):
|
|
58
|
+
"""Smallest signed difference between two angles, in degrees."""
|
|
59
|
+
return abs((a - b + 180.0) % 360.0 - 180.0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def connect_or_explain():
|
|
63
|
+
"""Connect to the running PCB Editor, or stop with something actionable.
|
|
64
|
+
|
|
65
|
+
A raw traceback here is useless: every cause below has a different fix, and
|
|
66
|
+
none of them is "edit the board file by hand".
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
return connect_board()
|
|
70
|
+
except Exception as exc: # noqa: BLE001 - the causes are all environment-level
|
|
71
|
+
raise SystemExit(
|
|
72
|
+
"cannot place anything: KiCad IPC is not usable (%s: %s)\n"
|
|
73
|
+
" - is the PCB Editor open, showing the board this plan was made from?\n"
|
|
74
|
+
" - is the KiCad API service enabled (Preferences > Plugins) and the\n"
|
|
75
|
+
" editor restarted after enabling it?\n"
|
|
76
|
+
" - is DSH running with Full Access? a restricted sandbox blocks KiCad's\n"
|
|
77
|
+
" named pipe even when the GUI is up.\n"
|
|
78
|
+
" - is another KiCad process (project manager) holding the socket? close\n"
|
|
79
|
+
" it and leave only the PCB Editor running.\n"
|
|
80
|
+
"Run kicad_ipc_diagnose first - it separates a broken environment from a\n"
|
|
81
|
+
"busy editor. Do not fall back to hand-editing the .kicad_pcb unless the\n"
|
|
82
|
+
"user explicitly accepts an offline file instead." % (type(exc).__name__, exc)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
88
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
89
|
+
ap.add_argument("--plan", required=True, help="placement_plan.json or pose table CSV")
|
|
90
|
+
ap.add_argument("--save", action="store_true",
|
|
91
|
+
help="save the PCB over IPC once the batch is verified")
|
|
92
|
+
ap.add_argument("--include-fixed", action="store_true",
|
|
93
|
+
help="also move parts the plan marks as fixed (off by default)")
|
|
94
|
+
ap.add_argument("--tolerance-mm", type=float, default=1e-3)
|
|
95
|
+
ap.add_argument("--tolerance-deg", type=float, default=1e-3)
|
|
96
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
97
|
+
help="connect, validate and report, but change nothing")
|
|
98
|
+
ap.add_argument("--report", help="log to write "
|
|
99
|
+
"(default <placement dir>/apply_via_ipc.txt)")
|
|
100
|
+
ap.add_argument("--no-report", action="store_true",
|
|
101
|
+
help="print only; leave no log file behind")
|
|
102
|
+
projpaths.add_outdir(ap)
|
|
103
|
+
args = ap.parse_args()
|
|
104
|
+
|
|
105
|
+
tee = None
|
|
106
|
+
if not args.no_report:
|
|
107
|
+
report = projpaths.resolve_out(args.report, "apply_via_ipc.txt",
|
|
108
|
+
refs=(args.plan,), outdir=args.outdir)
|
|
109
|
+
tee = projpaths.start_report(report)
|
|
110
|
+
print("report: %s\n" % report)
|
|
111
|
+
|
|
112
|
+
target, unplaced, fixed = load_plan(args.plan)
|
|
113
|
+
if unplaced:
|
|
114
|
+
raise SystemExit("the plan still has %d unplaced part(s): %s"
|
|
115
|
+
% (len(unplaced), unplaced))
|
|
116
|
+
|
|
117
|
+
kicad, board = connect_or_explain()
|
|
118
|
+
try:
|
|
119
|
+
footprints = list(board.get_footprints())
|
|
120
|
+
by_ref = {}
|
|
121
|
+
for fp in footprints:
|
|
122
|
+
ref = fp.reference_field.text.value
|
|
123
|
+
if ref in by_ref:
|
|
124
|
+
raise SystemExit("the open board has two footprints with reference %r" % ref)
|
|
125
|
+
by_ref[ref] = fp
|
|
126
|
+
|
|
127
|
+
# ---- 1. locate and pre-validate, before touching anything ----------
|
|
128
|
+
missing = sorted(set(target) - set(by_ref))
|
|
129
|
+
if missing:
|
|
130
|
+
raise SystemExit("the open board has no footprint(s) %s; nothing was changed.\n"
|
|
131
|
+
"Open the board this plan was made from." % missing)
|
|
132
|
+
skip = fixed if not args.include_fixed else set()
|
|
133
|
+
locked = sorted(r for r in target
|
|
134
|
+
if r not in skip and getattr(by_ref[r], "locked", False))
|
|
135
|
+
if locked:
|
|
136
|
+
raise SystemExit("refusing to move locked footprint(s) %s" % locked)
|
|
137
|
+
|
|
138
|
+
moves = []
|
|
139
|
+
for ref in sorted(target):
|
|
140
|
+
if ref in skip:
|
|
141
|
+
continue
|
|
142
|
+
x, y, rot = target[ref]
|
|
143
|
+
fp = by_ref[ref]
|
|
144
|
+
old = (fp.position.x / 1e6, fp.position.y / 1e6, fp.orientation.degrees)
|
|
145
|
+
if (abs(old[0] - x) < args.tolerance_mm and abs(old[1] - y) < args.tolerance_mm
|
|
146
|
+
and angle_delta(old[2], rot) < args.tolerance_deg):
|
|
147
|
+
continue
|
|
148
|
+
moves.append((ref, fp, x, y, rot, old))
|
|
149
|
+
|
|
150
|
+
print("footprints on the open board : %d" % len(footprints))
|
|
151
|
+
print("poses in the plan : %d" % len(target))
|
|
152
|
+
print("fixed parts left alone : %d" % len(skip))
|
|
153
|
+
print("parts to move : %d" % len(moves))
|
|
154
|
+
if args.dry_run:
|
|
155
|
+
for ref, _, x, y, rot, old in moves[:20]:
|
|
156
|
+
print(" %-8s (%8.3f,%8.3f,%6.1f) -> (%8.3f,%8.3f,%6.1f)"
|
|
157
|
+
% (ref, old[0], old[1], old[2], x, y, rot))
|
|
158
|
+
print("dry run - the board was not touched")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# ---- 2. one commit for the whole placement -------------------------
|
|
162
|
+
commit = board.begin_commit()
|
|
163
|
+
try:
|
|
164
|
+
for _, fp, x, y, rot, _ in moves:
|
|
165
|
+
fp.position = Vector2.from_xy_mm(x, y)
|
|
166
|
+
fp.orientation = Angle.from_degrees(rot)
|
|
167
|
+
updated = board.update_items([m[1] for m in moves])
|
|
168
|
+
if len(updated) != len(moves):
|
|
169
|
+
raise RuntimeError("KiCad updated %d objects, expected %d"
|
|
170
|
+
% (len(updated), len(moves)))
|
|
171
|
+
board.push_commit(commit, "Phase-1 initial placement (%d footprints)" % len(moves))
|
|
172
|
+
except Exception:
|
|
173
|
+
board.drop_commit(commit)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
# ---- 3. verify against what KiCad now reports ----------------------
|
|
177
|
+
board = kicad.get_board()
|
|
178
|
+
by_ref = {fp.reference_field.text.value: fp for fp in board.get_footprints()}
|
|
179
|
+
bad = []
|
|
180
|
+
for ref, _, x, y, rot, _ in moves:
|
|
181
|
+
fp = by_ref[ref]
|
|
182
|
+
if (abs(fp.position.x / 1e6 - x) > args.tolerance_mm
|
|
183
|
+
or abs(fp.position.y / 1e6 - y) > args.tolerance_mm
|
|
184
|
+
or angle_delta(fp.orientation.degrees, rot) > args.tolerance_deg):
|
|
185
|
+
bad.append((ref, fp.position.x / 1e6, fp.position.y / 1e6,
|
|
186
|
+
fp.orientation.degrees, x, y, rot))
|
|
187
|
+
print("read-back mismatches : %d" % len(bad))
|
|
188
|
+
for b in bad[:20]:
|
|
189
|
+
print(" %-8s KiCad (%8.3f,%8.3f,%7.1f) vs plan (%8.3f,%8.3f,%7.1f)" % b)
|
|
190
|
+
if bad:
|
|
191
|
+
print("! the commit is in KiCad's undo stack; press Ctrl+Z once to revert it")
|
|
192
|
+
|
|
193
|
+
print("\nApplied as ONE undo step. Review it in the PCB editor; Ctrl+Z reverts"
|
|
194
|
+
" the whole placement.")
|
|
195
|
+
if args.save and not bad:
|
|
196
|
+
board.save()
|
|
197
|
+
print("Saved the PCB over IPC.")
|
|
198
|
+
elif args.save:
|
|
199
|
+
print("Not saving: the read-back did not match the plan.")
|
|
200
|
+
else:
|
|
201
|
+
print("Not saved: pass --save once you are happy, or save from the GUI.")
|
|
202
|
+
finally:
|
|
203
|
+
close_kicad(kicad)
|
|
204
|
+
|
|
205
|
+
if tee is not None:
|
|
206
|
+
tee.close()
|
|
207
|
+
print("report written to %s" % tee.path)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|