从 FreeCAD 到 Fusion360:EasyEDA PCB 3D 协同插件的技术演进之路
如何让 PCB 设计软件(EasyEDA)和机械设计软件(Fusion360)实时同步元件位置?本文记录了从 FreeCAD 版本到 Fusion360 版本的技术演进,以及在 Fusion360 的线程地狱中摸爬滚出的血泪经验。
背景
在 PCB 设计中,电子工程师(EE)和结构工程师(ME)之间有一道经典的沟通鸿沟:EE 在 EDA 工具中放置元件,ME 在 MCAD 工具中设计外壳和结构件。双方需要反复确认元件的物理位置、尺寸是否匹配。传统的协作方式是导出 STEP 文件 → 手动导入 → 人工比对,效率极低。
我们的目标是:让 EDA 和 MCAD 之间实现实时双向同步 ------ 在 EDA 中移动一个电阻,Fusion360 中对应的 3D 模型自动跟随移动;反过来也一样。
先前的 FreeCAD 版本已经验证了这个思路的可行性,Fusion360 版本则在此基础上遇到了全新的技术挑战。
架构对比:FreeCAD vs Fusion360
FreeCAD 版本架构
markdown
EasyEDA ←---WebSocket:8766---→ FreeCAD(Python 宏)
├── asyncio WebSocket 服务器
├── QTimer 轮询消息队列
└── 主线程执行 FreeCAD API
FreeCAD 的优势在于:
- Python 环境开放 :可以自由
pip install websockets,使用标准 asyncio - 线程模型友好:通过 QTimer + 消息队列,后台线程和主线程可以安全通信
- API 调用无限制:主线程操作可以通过 Qt 事件循环可靠调度
Fusion360 版本架构
bash
EasyEDA ←---WebSocket:8767---→ Fusion360(Add-In)
↕ ├── 自研纯 Python WebSocket 服务器(无第三方库)
↕ ├── HTTP 服务器(:8768/poll)
↕ ├── executeTextCommand(隐藏 API)
↕ └── CustomEvent + 线程锁
↕
EasyEDA ←---HTTP Poll:8768---→ Fusion360
Fusion360 的限制远比 FreeCAD 严苛,这直接导致了架构的复杂化。
核心技术挑战与解决方案
挑战一:Fusion360 不能装第三方 Python 库
FreeCAD 版本可以 pip install websockets,直接用成熟的 asyncio WebSocket 库。Fusion360 的 Python 环境是封闭的 ------ 没有 pip,不能安装任何东西。
解决方案:纯手写 WebSocket 协议实现
python
def _ws_encode_frame(payload: bytes, opcode: int = 0x1, mask: bool = True) -> bytes:
"""手写 WebSocket 帧编码,兼容 RFC 6455"""
frame = bytearray()
frame.append(0x80 | opcode) # FIN + opcode
length = len(payload)
if mask:
frame[0] |= 0x80
# 处理不同长度的帧头
if length <= 125:
frame.append((0x80 if mask else 0) | length)
elif length <= 65535:
frame.append((0x80 if mask else 0) | 126)
frame.extend(struct.pack('>H', length))
else:
frame.append((0x80 if mask else 0) | 127)
frame.extend(struct.pack('>Q', length))
# masking key + masked payload
if mask:
masking_key = os.urandom(4)
frame.extend(masking_key)
masked = bytearray(payload)
for i in range(len(masked)):
masked[i] ^= masking_key[i % 4]
frame.extend(masked)
return bytes(frame)
没有 websockets 库?那就自己实现 RFC 6455。支持 TEXT(0x1)和 BINARY(0x2)opcode,处理 PING/PONG 心跳,处理分片帧。虽然代码量多了几百行,但完全可控,不依赖任何外部因素。
挑战二:Fusion360 的线程地狱
这是整个项目中最痛苦的部分。Fusion360 的 API 只允许在主线程调用,但 WebSocket 服务器和 HTTP 服务器必须运行在后台线程。这意味着所有 Fusion360 API 操作都需要跨线程调度。
我尝试过的方案和它们的下场:
可能是我开发环境太恶劣了 (华为云办公) 过程中fusion崩溃了上百次
| 方案 | 结果 | 原因 |
|---|---|---|
documents.open(filepath) |
TypeError | 需要 DataFile 对象,不接受字符串路径 |
CustomEventHandler + fireCustomEvent |
notify() 从未触发 | 此版本的 Fusion360 该机制完全失效 |
doc.activate()(后台线程) |
Fusion360 直接崩溃 | UI 操作不能从后台线程调用 |
importToTarget(后台线程) |
NEUTRON_BUG_ALERT × 12 | 内部事务管理器检测到非主线程 |
importToNewDocument |
导入成功但文档"幽灵化" | 标签页不可见,无法交互 |
| 后台线程关闭文档 | Fusion360 卡死 | 跨线程 UI 操作导致死锁 |
os.startfile() |
无法关联 Fusion360 | 系统默认程序不是 Fusion360 |
threading.Timer 长驻线程 |
线程静默死亡 | Fusion360 吞掉了 daemon 线程 |
| HTTP handler 直接调 Fusion API | 直接闪退 | 后台线程调 API = 崩溃,无 try/except 能救 |
9 次失败后,最终的突破口是 executeTextCommand。
突破口:executeTextCommand ------ 未文档化的宝藏
python
app.executeTextCommand('Translator.Import C:\\path\\to\\file.step')
这个方法有两个神奇之处:
- 可以从后台线程调用,但 Fusion360 内部会在主线程执行
- 功能极其强大,但没有出现在官方文档中
怎么发现的?暴力搜索 Fusion360 的全部文本命令列表:
python
app.executeTextCommand('TextCommands.List')
# 在 6393 个命令中搜索到了 Translator.Import
Translator.Import <filepath> 会将 STEP 文件导入到当前设计,标签页可见,返回 "Import ... successfully",后续的 design.rootComponent.allOccurrences 正常获取。
这是整个项目能 work 的根基。没有它,STEP 文件导入这一步就卡死了。
挑战三:双向同步的架构抉择
FreeCAD 版本用单通道 WebSocket + QTimer 就搞定了双向同步。Fusion360 版本必须用双通道架构:
为什么需要双通道?
Fusion360 的 API 事件(activeSelectionChanged、selectionEvent)在本版本中不响应元件选中,无法通过事件驱动的方式感知用户操作。只能用 HTTP 轮询。
sql
WebSocket 通道(EDA → Fusion360)
├── 文件上传(分块传输)
├── 位置同步
├── 交叉探针(cross-probe)
├── 删除同步
└── 映射构建
HTTP 轮询通道(Fusion360 → EDA)
├── 选中状态检测(每 2 秒)
├── 位置变化检测
└── 删除检测(元件消失)
轮询频率选择 2 秒是反复测试的结果 ------ 更快会增加线程竞争导致崩溃,更慢则用户体验太差。
挑战四:EDA 扩展沙箱的限制
EasyEDA 专业版的扩展运行在沙箱环境中,不是标准浏览器。
我最初用标准的 fetch() 发起 HTTP 请求:
typescript
// 失败:EDA 扩展沙箱禁用了 fetch()
const response = await fetch('http://localhost:8768/poll');
解决方案:使用 EDA 专属 API
typescript
// 成功:使用 eda.sys_ClientUrl.request()
const resp = await eda.sys_ClientUrl.request({
url: `http://localhost:8768/poll?t=${Date.now()}`,
method: 'GET',
});
这个坑告诉我们:EDA 扩展环境 ≠ 浏览器环境,网络请求必须用 eda.sys_ClientUrl.request()。
关键技术实现详解
1. 大文件分块传输
PCB 的 STEP 文件通常几 MB 到几十 MB,不能一次性发送。两个版本都采用了 512KB 分块策略:
typescript
const CHUNK_SIZE = 512 * 1024; // 512KB
async function uploadFileInChunks(base64Data: string, sessionId: string) {
const totalChunks = Math.ceil(base64Data.length / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const chunk = base64Data.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
sendToFusion360({
type: 'file_upload_chunk',
sessionId,
chunkIndex: i,
totalChunks,
data: chunk,
});
// 每个分块等待确认,避免内存溢出
}
}
Base64 编码会使数据膨胀约 33%,512KB 的原始数据编码后约 683KB,配合逐块确认机制,在 WebSocket 上稳定可靠。
2. 坐标系统转换
EDA 使用 mil(密尔),Fusion360 使用 cm(厘米),这是两套完全不同的坐标体系:
ini
1 mil = 0.0254 mm = 0.00254 cm
typescript
const MIL_TO_MM = 0.0254;
const MM_TO_MIL = 1 / 0.0254;
// EDA → Fusion360
const x_mm = x_mil * MIL_TO_MM;
const y_mm = y_mil * MIL_TO_MM;
// 还要处理画布原点偏移
const origin = await eda.pcb_Document.getCanvasOrigin();
Fusion360 端存储了初始位置的偏移量,后续移动基于 delta 计算:
python
_initial_offsets[designator] = (eda_x, eda_y, fx, fy, fz)
# 后续更新:EDA 的 delta 直接映射到 Fusion360 的绝对位置
dx_mm = x_mm - init_eda_x
dy_mm = y_mm - init_eda_y
new_fx = init_fusion_x + dx_mm / 10.0 # mm → cm
new_fy = init_fusion_y + dy_mm / 10.0
3. 元件映射:从模糊匹配到精确匹配
Fusion360 中元件的命名格式是 位号~封装~尺寸~ID,例如 R1~0603~0603RES~12345:1。
最初的模糊匹配(designator in name)会导致 C1 匹配到 C10、C100。最终实现了精确匹配:
python
def _match_designator(short_name: str, designator: str) -> bool:
"""精确匹配位号,避免 C1 误匹配 C10"""
parts = short_name.split(' ')
for part in parts:
desig = part.split('~')[0].strip() # 取 '~' 前的位号
if desig == designator:
return True
return False
FreeCAD 版本也有类似的三级匹配策略:
- 精确标签匹配(不区分大小写)
- 前缀正则匹配 (防止
C2匹配C20) - 位置容差匹配(0.5mm 精度兜底)
4. 删除同步的防循环机制
删除是最容易出 bug 的操作。EDA 删除 → Fusion360 删除 → Fusion360 触发删除事件 → EDA 又删除 → 无限循环。
解决方案:suppressDeleteSync 标志位
typescript
// EDA 端
let suppressDeleteSync = false;
// 收到 Fusion360 的删除通知时
async function handleDeleteFromFusion(designator: string) {
suppressDeleteSync = true;
// 执行 EDA 端删除...
suppressDeleteSync = false;
}
// EDA 原生删除事件触发时
if (suppressDeleteSync) return; // 跳过,防止循环
Fusion360 端还有引用计数保护:
python
# 共享组件不删 occurrence,避免连带删除其他元件
ref_count = sum(1 for i in range(design.rootComponent.allOccurrences.count)
if design.rootComponent.allOccurrences.item(i).component == comp)
if ref_count <= 1:
occ.deleteMe() # 安全删除
else:
_log("跳过删除(共享组件, {}个引用)".format(ref_count))
5. 交叉探针(Cross-Probe)
Cross-probe 是 EE 和 ME 协作中最实用的功能 ------ 在 EDA 中点击一个元件,Fusion360 中对应的 3D 模型闪烁高亮。
EDA → Fusion360:
typescript
// 监听 EDA 鼠标选中事件
eda.pcb_Event.register('pcbMouseSelect', 'CROSS_PROBE_ID',
async (eventType, props) => {
const designator = props[0].parentComponentDesignator;
sendToFusion360({ type: 'cross_probe', designator });
});
Fusion360 端闪烁效果:
python
# 通过临时关闭可见性模拟闪烁
occ.isLightBulbOn = False
# 300ms 后恢复
def _restore():
occ.isLightBulbOn = True
t = threading.Timer(0.3, _restore)
t.daemon = True
t.start()
!用闪烁代替高亮的原因 :
- 线程安全 : isLightBulbOn 属性设置比 selectEntity UI 操作更安全
- 避免崩溃 :后台线程调用 UI 操作会导致 Fusion360 闪退
- 简单可靠 :闪烁实现简单,不需要复杂的主线程调度 这是一个典型的 工程妥协 ------为了稳定性牺牲了部分用户体验。如果想要更好的视觉效果,可以将选中操作通过命令队列调度到主线程执行。
Fusion360 → EDA:
通过 HTTP 轮询检测选中变化,然后 EDA 端用 doCrossProbeSelect 高亮并导航:
typescript
await eda.pcb_SelectControl.doCrossProbeSelect([designator], undefined, undefined, true, true);
await eda.pcb_Document.navigateToCoordinates(x * MM_TO_MIL, y * MM_TO_MIL);
6. 位置阈值过滤
浮点数精度问题会导致微小的坐标漂移,不加过滤会形成"更新风暴":
python
POSITION_THRESHOLD_MM = 0.1 # 0.1mm 位置阈值
ROTATION_THRESHOLD_DEG = 0.5 # 0.5° 旋转阈值
if (abs(x_mm - last[0]) > POSITION_THRESHOLD_MM or
abs(y_mm - last[1]) > POSITION_THRESHOLD_MM or
abs(rot - last[2]) > ROTATION_THRESHOLD_DEG):
# 超过阈值才触发更新
7. Z 轴锁定
PCB 是平面板,元件移动时 Z 轴不应该变化。但 EDA 的移动事件有时会产生 Z 轴漂移到 0 的问题。
解决方案:在位置更新时强制锁定 Z 轴为初始值。
两个版本的核心差异总结
| 维度 | FreeCAD 版本 | Fusion360 版本 |
|---|---|---|
| WebSocket 库 | pip install websockets(第三方) |
纯手写 RFC 6455 实现 |
| 线程通信 | QTimer + 消息队列(可靠) | CustomEvent(失效)→ HTTP 轮询 |
| 文件导入 | FreeCAD API 直接导入 | executeTextCommand('Translator.Import') |
| 反向同步 | WebSocket 双向通信 | HTTP 轮询(API 事件不可用) |
| 端口 | 单端口 8766 | 双端口 8767(WS)+ 8768(HTTP) |
| 外部依赖 | 需要安装 websockets 库 | 零外部依赖 |
| 线程安全 | Qt 事件循环原生支持 | 线程锁 + 轮询间隔 + executeTextCommand |
| 稳定性风险 | 低(Qt 框架成熟) | 中(线程竞争可能导致崩溃) |
经验教训
1. Fusion360 API 文档不可全信
customEventReceived 是官方推荐的跨线程通信方案,文档写得清清楚楚。但实际在这个版本中完全失效 ------ notify() 回调永远不会被触发。花了大量时间排查,最终确认不是用法问题,是 Fusion360 本身的 bug。
教训:当官方方案不 work 时,要敢于怀疑平台本身。
2. executeTextCommand 是隐藏的宝藏
6393 个文本命令,没有文档说明每个命令的用法。通过 TextCommands.List 拿到全量列表后逐个尝试,才找到了 Translator.Import。
教训:当正式 API 走不通时,搜索隐藏命令列表可能有意想不到的收获。
3. 逐步排除法是唯一的调试方法
Fusion360 的后台线程调 API = 闪退,没有 try/except 能救。每次只改一个变量,用日志定位到具体哪一行导致崩溃。
教训:在"调用即崩溃"的环境下,二分法 + 日志是最有效的调试手段。
4. 先搞清楚数据格式再定匹配策略
从 in 模糊匹配直接改成 == 精确匹配,结果全部映射失败 ------ 因为 Fusion360 的命名格式是 R1~0603~...,不是简单的 R1。应该先用日志打印实际数据格式,再决定匹配算法。
教训:永远先看实际数据长什么样,再写处理逻辑。
5. 动手前画清楚完整链路
删除同步的 bug 修了三轮才稳定。第一轮只加了去重,没考虑反向循环;第二轮加了标志位但编码出错;第三轮还有 parentComponentDesignator 误匹配。如果一开始就把完整的删除链路(EDA→WS→Fusion、Fusion→WS→EDA、事件回弹)画清楚,可以省掉两轮。
教训:涉及双向同步的操作,动手前先把完整的事件流画出来。
技术架构图
scss
┌─────────────────────────────────────────────────────────┐
│ EasyEDA Professional │
│ ┌─────────────────────────────────────────────────────┐│
│ │ EDA Extension (TypeScript) ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ ││
│ │ │ 事件监听 │ │ 状态管理 │ │ 文件导出(分块传输) │ ││
│ │ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ ││
│ │ │ │ │ ││
│ │ ┌────▼──────────────▼──────────────────▼─────────┐ ││
│ │ │ WebSocket Client (:8767) │ ││
│ │ └─────────────────────┬──────────────────────────┘ ││
│ │ │ ││
│ │ ┌─────────────────────▼──────────────────────────┐ ││
│ │ │ HTTP Poll Client (:8768) │ ││
│ │ └─────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────┘│
└───────────────────────────┬─────────────────────────────┘
│ WebSocket + HTTP
┌───────────────────────────▼─────────────────────────────┐
│ Fusion360 │
│ ┌─────────────────────────────────────────────────────┐│
│ │ Add-In (Python) ││
│ │ ┌──────────────────┐ ┌─────────────────────────┐ ││
│ │ │ WebSocket 服务器 │ │ HTTP 服务器 │ ││
│ │ │ (纯手写 RFC6455) │ │ (标准库 http.server) │ ││
│ │ └────────┬─────────┘ └────────────┬────────────┘ ││
│ │ │ │ ││
│ │ ┌────────▼──────────────────────────▼────────────┐ ││
│ │ │ 线程安全层 │ ││
│ │ │ executeTextCommand │ _poll_api_lock │ 阈值过滤 │ ││
│ │ └────────────────────────┬───────────────────────┘ ││
│ │ │ ││
│ │ ┌────────────────────────▼───────────────────────┐ ││
│ │ │ Fusion360 API (主线程) │ ││
│ │ │ Design │ Occurrence │ Component │ Selection │ ││
│ │ └─────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
总结
这个项目的核心难点不在于业务逻辑,而在于平台限制倒逼出的工程妥协:
- 没有第三方库? → 手写 WebSocket 协议
- CustomEvent 失效? → 用 HTTP 轮询替代
- 后台线程不能调 API? → 找到 executeTextCommand 这个隐藏后门
- API 事件不响应? → 自己做定时轮询
- 双向同步会循环? → 用标志位打断循环
- 浮点数漂移? → 阈值过滤
每一条都是"官方方案不行,自己想办法"的结果。做平台插件开发,最重要的能力不是写代码,而是在限制条件下找到可行的技术路径。
如果你要做类似的 CAD 跨平台协同工具,我的建议是:
- 先摸清目标平台的线程模型 ------ 这决定了整个通信架构
- 先验证最小可行性路径 ------ 比如先验证能不能从后台线程导入文件
- 准备好搜索隐藏 API ------ 官方文档不完整是常态
- 双向同步一定要先画事件流图 ------ 否则循环触发的 bug 会耗掉你大部分时间
本文涉及的两个项目:pcb-export-to-freeCad(FreeCAD 版本)和 pcb-export-to-fusion(Fusion360 版本),均基于 EasyEDA 专业版扩展开发。