花 100 dollar,用 Claude 打通 EasyEDA&Fusion 双向同步

24 阅读16分钟

从 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 版本架构

EasyEDA ←—WebSocket:8766—→ FreeCAD(Python 宏)
                                ├── asyncio WebSocket 服务器
                                ├── QTimer 轮询消息队列
                                └── 主线程执行 FreeCAD API

FreeCAD 的优势在于:

  • Python 环境开放:可以自由 pip install websockets,使用标准 asyncio
  • 线程模型友好:通过 QTimer + 消息队列,后台线程和主线程可以安全通信
  • API 调用无限制:主线程操作可以通过 Qt 事件循环可靠调度

Fusion360 版本架构

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 协议实现

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 + fireCustomEventnotify() 从未触发此版本的 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 —— 未文档化的宝藏
app.executeTextCommand('Translator.Import C:\\path\\to\\file.step')

这个方法有两个神奇之处:

  1. 可以从后台线程调用,但 Fusion360 内部会在主线程执行
  2. 功能极其强大,但没有出现在官方文档中

怎么发现的?暴力搜索 Fusion360 的全部文本命令列表:

app.executeTextCommand('TextCommands.List')
# 在 6393 个命令中搜索到了 Translator.Import

Translator.Import <filepath> 会将 STEP 文件导入到当前设计,标签页可见,返回 "Import ... successfully",后续的 design.rootComponent.allOccurrences 正常获取。

这是整个项目能 work 的根基。没有它,STEP 文件导入这一步就卡死了。

挑战三:双向同步的架构抉择

FreeCAD 版本用单通道 WebSocket + QTimer 就搞定了双向同步。Fusion360 版本必须用双通道架构

为什么需要双通道?

Fusion360 的 API 事件(activeSelectionChangedselectionEvent)在本版本中不响应元件选中,无法通过事件驱动的方式感知用户操作。只能用 HTTP 轮询。

WebSocket 通道(EDA → Fusion360)
├── 文件上传(分块传输)
├── 位置同步
├── 交叉探针(cross-probe)
├── 删除同步
└── 映射构建

HTTP 轮询通道(Fusion360 → EDA)
├── 选中状态检测(每 2 秒)
├── 位置变化检测
└── 删除检测(元件消失)

轮询频率选择 2 秒是反复测试的结果 —— 更快会增加线程竞争导致崩溃,更慢则用户体验太差。

挑战四:EDA 扩展沙箱的限制

EasyEDA 专业版的扩展运行在沙箱环境中,不是标准浏览器。

我最初用标准的 fetch() 发起 HTTP 请求:

// 失败:EDA 扩展沙箱禁用了 fetch()
const response = await fetch('http://localhost:8768/poll');

解决方案:使用 EDA 专属 API

// 成功:使用 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 分块策略:

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(厘米),这是两套完全不同的坐标体系:

1 mil = 0.0254 mm = 0.00254 cm
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 计算:

_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 匹配到 C10C100。最终实现了精确匹配:

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 版本也有类似的三级匹配策略:

  1. 精确标签匹配(不区分大小写)
  2. 前缀正则匹配(防止 C2 匹配 C20
  3. 位置容差匹配(0.5mm 精度兜底)

4. 删除同步的防循环机制

删除是最容易出 bug 的操作。EDA 删除 → Fusion360 删除 → Fusion360 触发删除事件 → EDA 又删除 → 无限循环。

解决方案:suppressDeleteSync 标志位

// EDA 端
let suppressDeleteSync = false;

// 收到 Fusion360 的删除通知时
async function handleDeleteFromFusion(designator: string) {
    suppressDeleteSync = true;
    // 执行 EDA 端删除...
    suppressDeleteSync = false;
}

// EDA 原生删除事件触发时
if (suppressDeleteSync) return; // 跳过,防止循环

Fusion360 端还有引用计数保护

# 共享组件不删 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:

// 监听 EDA 鼠标选中事件
eda.pcb_Event.register('pcbMouseSelect', 'CROSS_PROBE_ID',
    async (eventType, props) => {
        const designator = props[0].parentComponentDesignator;
        sendToFusion360({ type: 'cross_probe', designator });
    });

Fusion360 端闪烁效果:

# 通过临时关闭可见性模拟闪烁
occ.isLightBulbOn = False

# 300ms 后恢复
def _restore():
    occ.isLightBulbOn = True
t = threading.Timer(0.3, _restore)
t.daemon = True
t.start()

!用闪烁代替高亮的原因 :

  1. 线程安全 : isLightBulbOn 属性设置比 selectEntity UI 操作更安全
  2. 避免崩溃 :后台线程调用 UI 操作会导致 Fusion360 闪退
  3. 简单可靠 :闪烁实现简单,不需要复杂的主线程调度 这是一个典型的 工程妥协 ——为了稳定性牺牲了部分用户体验。如果想要更好的视觉效果,可以将选中操作通过命令队列调度到主线程执行。

Fusion360 → EDA:

通过 HTTP 轮询检测选中变化,然后 EDA 端用 doCrossProbeSelect 高亮并导航:

await eda.pcb_SelectControl.doCrossProbeSelect([designator], undefined, undefined, true, true);
await eda.pcb_Document.navigateToCoordinates(x * MM_TO_MIL, y * MM_TO_MIL);

6. 位置阈值过滤

浮点数精度问题会导致微小的坐标漂移,不加过滤会形成"更新风暴":

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、事件回弹)画清楚,可以省掉两轮。

教训:涉及双向同步的操作,动手前先把完整的事件流画出来。


技术架构图

┌─────────────────────────────────────────────────────────┐
│                    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    │ ││
│  │  └─────────────────────────────────────────────────┘ ││
│  └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘

总结

这个项目的核心难点不在于业务逻辑,而在于平台限制倒逼出的工程妥协

  1. 没有第三方库? → 手写 WebSocket 协议
  2. CustomEvent 失效? → 用 HTTP 轮询替代
  3. 后台线程不能调 API? → 找到 executeTextCommand 这个隐藏后门
  4. API 事件不响应? → 自己做定时轮询
  5. 双向同步会循环? → 用标志位打断循环
  6. 浮点数漂移? → 阈值过滤

每一条都是"官方方案不行,自己想办法"的结果。做平台插件开发,最重要的能力不是写代码,而是在限制条件下找到可行的技术路径。

如果你要做类似的 CAD 跨平台协同工具,我的建议是:

  1. 先摸清目标平台的线程模型 —— 这决定了整个通信架构
  2. 先验证最小可行性路径 —— 比如先验证能不能从后台线程导入文件
  3. 准备好搜索隐藏 API —— 官方文档不完整是常态
  4. 双向同步一定要先画事件流图 —— 否则循环触发的 bug 会耗掉你大部分时间

本文涉及的两个项目:pcb-export-to-freeCad(FreeCAD 版本)和 pcb-export-to-fusion(Fusion360 版本),均基于 EasyEDA 专业版扩展开发。

后记:从"能用"到"稳定"—— v1.1.0 架构重构

距离上篇文章发布,这个项目经历了一次彻底的架构重构。核心问题只有一个:Fusion360 后台线程调 API = 随机闪退。之前的方案是"能用 但不稳定 没有api支持 有些功能无法实现",现在终于做到了"稳定可靠" 并加入了想要的功能。

问题根源

之前的代码里,WebSocket 线程和 HTTP 线程都在直接调用 Fusion360 API

# 之前:后台线程直接操作 Fusion 对象(危险!)
def _handle_message(raw_message):
    occ.transform2 = transform  # 从 WebSocket 线程直接写
    occ.isLightBulbOn = False   # 同上

# HTTP 轮询也是直接读
class _PollHandler:
    def do_GET(self):
        sel = app.userInterface.activeSelections  # 从 HTTP 线程直接读

偶尔能用,但线程竞争时就崩。Autodesk 的 API 专家 Brian Ekins 说的很明确:

"Fusion 应被视为一个单线程应用程序。UI 和 API 都在 Fusion 主线程中运行。"

解法:CustomEvent + TaskManager

Autodesk 官方示例 FusionMCPSample 使用的模式:

后台线程 → queue.Queue.put(task) → fireCustomEvent()
                                        ↓
                              notify() [主线程执行]
                                        ↓
                              调用 Fusion API
                                        ↓
                              结果通过 Event 返回后台线程

三个调度方法:

方法用途阻塞
_call_main()HTTP 轮询(需要返回值)阻塞调用线程
_call_main_await()WebSocket 处理(async)不阻塞事件循环
_call_main_fire()位置更新/删除(不需要返回值)即发即忘

踩过的最大的坑

CustomEvent 写好了,notify() 就是不触发。

排查过程:

  1. fireCustomEvent() 调了,没报错
  2. notify() 的断点/日志从不触发
  3. 试了主线程 fire、后台线程 fire、带 additionalInfo、不带 —— 全不行
  4. 甚至怀疑过试用版 API 限制

最终发现:以"脚本"模式运行 vs 以"加载项"模式加载的区别

脚本模式:run() 返回 → Fusion 立刻调 stop() → handler 销毁 → notify() 永远不触发
加载项模式:run() 返回 → handler 持久存在 → notify() 正常触发

日志里 run() 之后立刻出现 stopping... 就是因为脚本模式。改成 Add-In 方式加载后,日志只有 started,不再有 stopping

另外还有 Python GC 的坑:handler 引用必须存在全局列表里,否则 Python 垃圾回收会静默销毁它:

_handlers = []  # 全局列表,防 GC

def run(context):
    handler = _TaskEventHandler()
    evt.add(handler)
    _handlers.append(handler)  # 不加这行,notify() 静默失效

从双端口到单端口

之前用 HTTP 轮询做 Fusion→EDA 方向的同步,���为 Fusion360 的 API 事件(activeSelectionChanged)在此版本不响应。

现在改成 Fusion 主线程定时推送

def _do_poll_and_push():
    """主线程:每 2 秒执行一次"""
    updates = _check_selection()
    updates.extend(_do_poll_positions())
    for u in updates:
        _push_to_client(u)  # 通过 WebSocket 推送给 EDA

去掉了 HTTP 服务器(http.server:8768 端口),EDA 端也不再需要 setInterval 轮询。单端口 WebSocket 全搞定。

其他修复

90° 翻转:之前 setToIdentity() 清空 STEP 模型的原始旋转,用 EDA 绝对旋转覆盖。改成 Delta 旋转(初始Fusion旋转 + EDA旋转增量),保留模型的原始朝向。

自动新建文档:每次导出前 app.documents.add() 创建新设计,不再需要手动新建。

撤回被覆盖:Fusion 中 Ctrl+Z 后位置被回环更新覆盖。加了 suppressPositionSync 标志位 —— Fusion→EDA 的位置更新期间,EDA→Fusion 方向被抑制。

交叉定位:从"灭灯闪烁 0.3s"改成 activeSelections.add(occ) 选中高亮 + 相机导航,效果直观得多。

最终架构

┌─────────────────────────────────────────────┐
│             EasyEDA (TypeScript)             │
│  WebSocket Client ←─────→ 后台线程(网络 I/O)│
└──────────────────────┬──────────────────────┘
                       │ WebSocket :8767(唯一端口)
┌──────────────────────▼──────────────────────┐
│              Fusion360 (Python Add-In)       │
│  ┌──────────────────────────────────────┐    │
│  │         TaskManager                  │    │
│  │  queue + CustomEvent + fire          │    │
│  └──────────────┬───────────────────────┘    │
│                 ↓ notify()                    │
│  ┌──────────────────────────────────────┐    │
│  │         主线程执行                    │    │
│  │  导入 STEP / 位置更新 / 选中检测     │    │
│  │  删除元件 / 相机导航 / 定时推送       │    │
│  └──────────────────────────────────────┘    │
└─────────────────────────────────────────────┘

经验教训

  1. Fusion360 插件必须以 Add-In 方式加载,Script 模式无法使用 CustomEvent
  2. Handler 引用必须保存在全局列表,Python GC 不了解 Fusion 内部的引用关系
  3. executeTextCommand 是隐藏宝藏Translator.Import 可从后台线程安全调用
  4. 架构选型要看目标平台的能力,不能只看"别人怎么做的"—— CustomEvent 是 Autodesk 官方推荐,但前提是正确使用 Add-In 模式
  5. Delta 同步比绝对同步更安全—— 保留原始状态 + 增量更新,避免覆盖初始数据