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

从 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')

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

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

怎么发现的?暴力搜索 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 事件(activeSelectionChangedselectionEvent)在本版本中不响应元件选中,无法通过事件驱动的方式感知用户操作。只能用 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 匹配到 C10C100。最终实现了精确匹配:

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

  1. 精确标签匹配(不区分大小写)
  2. 前缀正则匹配 (防止 C2 匹配 C20
  3. 位置容差匹配(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()

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

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

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

总结

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

  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 专业版扩展开发。

相关推荐
irving同学462382 小时前
从零搭建生产级 RAG:Embedding、Chunking、Hybrid Search 与 Reranker
前端·后端
她的男孩2 小时前
从零搭一个企业后台,为什么我把能力拆成 Starter 和 Plugin
java·后端·架构
胡志辉2 小时前
本地 AI 编码助手从 0 配起来:先选模型,再接 Ollama、VS Code、Claude Code 和 Codex
前端·后端
RainCity2 小时前
Java Swing 自定义组件库分享(七)
java·笔记·后端
啷里格啷2 小时前
第二章 Fast-DDS 整体架构与分层框架
后端·架构
DolphinDB2 小时前
漫长人工,耗费存储?用 BackupRestore 模块一站式解决跨环境数据同步难题
运维·后端·架构
钟智强3 小时前
硬核自研|HunTianDB 混天DB:Rust原生工业级时序安全数据库全技术拆解
后端
_遥远的救世主_3 小时前
从一次结果集密集型查询 OOM 看 Java 服务的稳定性架构治理
java·后端
代码丰3 小时前
基于数据库字段实现可续期分布式锁:从任务抢占到心跳续约
后端