从零实现嘉立创 EDA 与 FreeCAD 的 PCB 双向实时协同
一个不到 2500 行的轻量级方案,让 EDA 和 FreeCAD 的元件能互相拖动、互相定位、互相删除。
先说故事
做 PCB 的人大概都经历过这个场景------
硬件工程师在 EDA 里布完了一块板子,得意洋洋地把 STEP 文件甩给结构工程师。结构工程师导入 SolidWorks 一看:"这个连接器跟外壳干涉了,往左挪 2mm。"硬件工程师打开 EDA,改完重新导出,再甩回去。"不是左,是右。"
一次干涉检查,半小时没了。
我在用嘉立创 EDA 专业版做项目的时候,每天都重复这个流程。有一天我突然想:为什么不能直接在 EDA 里拖动那个电阻,FreeCAD 里的 3D 模型跟着动呢?为什么不能在 FreeCAD 里点一下外壳,EDA 自动跳到对应的元件?
然后我就动手了。
最终效果
先说结果。装好这个扩展之后:
- 一键导出:EDA 里点一下「导出3D到FreeCAD」,PCB 的完整 3D 模型(元件、丝印、铜线)出现在 FreeCAD 里,自动居中
- 双向拖动:开启双向交互后,EDA 里拖元件,FreeCAD 里跟着动;FreeCAD 里拖 3D 对象,EDA 里也跟着动
- 交叉定位:EDA 里点一个元件,FreeCAD 自动聚焦;FreeCAD 里点一个对象,EDA 自动跳转
- 删除同步:EDA 删了一个元件,FreeCAD 里的 3D 模型立刻消失
- 位号重命名:在 EDA 里改了位号,映射关系自动更新
整个交互延迟在毫秒级,操作体验非常接近"同一个软件"。
架构:一条 WebSocket 串起两个世界
vbscript
┌─────────────────────┐ ┌──────────────────────┐
│ 嘉立创 EDA 专业版 │ WebSocket │ FreeCAD + Python │
│ │◄────────────►│ WebSocket Server │
│ TypeScript 扩展 │ ws://8766 │ │
│ │ │ │
│ - 分片上传 STEP │ │ - 分片接收 & 重组 │
│ - 事件监听 │ │ - STEP 导入 & 居中 │
│ - 位置同步 │ │ - 导入心跳保活 │
│ - 交叉定位 │ │ - 元件映射 │
│ │ │ - 位置监听 (QTimer) │
└─────────────────────┘ └──────────────────────┘
选型思路很简单:
- EDA 那边只能写 TypeScript 扩展,API 提供了 WebSocket 支持
- FreeCAD 那边内置 Python,跑个 websockets 服务就行
- 两边通过 JSON 消息通信,定义好协议就完事了
听起来很美,但实际踩的坑......往下看。
技术实现详解
1. 文件传输:从"一发就断"到分片 Base64
第一步就是把 PCB 的 3D 模型从 EDA 发到 FreeCAD。EDA 提供了 get3DFile() API,能导出 STEP 格式的 3D 文件。
最初的实现特别天真------把整个文件转成 JSON 数组发过去:
typescript
// 我的第一版代码,天真到令人感动
eda.sys_WebSocket.send(id, JSON.stringify({
type: 'file_upload',
data: Array.from(new Uint8Array(arrayBuffer)) // 每个字节变一个数字
}));
问题在于:一个 20MB 的 STEP 文件,每个字节变成 72,101,108,... 这样的十进制数字加逗号,体积膨胀了 3-5 倍。20MB 的文件变成约 80MB 的 JSON 字符串,直接超过了 WebSocket 的 100MB 帧限制。
第一次测试:导入一个 30MB 的板子------咔,连接直接断了。连错误提示都没有,因为消息太大,WebSocket 库直接拒绝处理。
解决方案:分片 Base64 传输。
把文件切成 512KB 的小块,每块用 Base64 编码后单独发送。Base64 只有 33% 的体积膨胀(对比之前的 300%),而且每条消息体积可控:
typescript
const CHUNK_SIZE = 512 * 1024; // 512KB per chunk
async function sendFileChunked(buffer: ArrayBuffer, filename: string) {
const sessionId = Date.now().toString(36) + Math.random().toString(36).substring(2);
const totalChunks = Math.ceil(buffer.byteLength / CHUNK_SIZE);
// 1. 告诉服务端:我要传文件了,一共这么多片
sendToFreeCAD({ type: 'file_upload_start', sessionId, filename, totalSize: buffer.byteLength, totalChunks });
// 2. 一片一片地发,不等 ACK(localhost 延迟极低,没必要等)
for (let i = 0; i < totalChunks; i++) {
const chunk = buffer.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, buffer.byteLength));
sendToFreeCAD({ type: 'file_upload_chunk', sessionId, index: i, data: arrayBufferToBase64(chunk) });
}
}
FreeCAD 那边为每次上传创建一个 ChunkedUploadSession,收完所有分片后校验文件大小、写入磁盘、丢给主线程去导入。
一个小细节:arrayBufferToBase64 不能直接 String.fromCharCode.apply(null, bytes),超过 65536 个参数调用栈会溢出。得分块处理:
typescript
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 8192) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, Math.min(i + 8192, bytes.length)));
}
return btoa(binary);
}
2. 导入大文件时 FreeCAD 卡住了怎么办
STEP 文件导进 FreeCAD 要调用 ImportGui.open(),这是个同步阻塞方法。80 个对象的 PCB 大概要等几分钟,期间 FreeCAD 整个界面冻住,动不了。
这带来了一个隐蔽的问题:EDA 客户端以为连接断了。
WebSocket 服务跑在独立线程,FreeCAD GUI 冻住不影响消息收发。但客户端那边好几分钟收不到任何消息,心跳超时就主动断开了。
解决方案是一个 后台心跳线程------主线程冻住了没关系,心跳线程照常跑,每 2 秒给 EDA 发一次"我还活着":
python
def _import_heartbeat(self, session_id):
while self.import_in_progress:
elapsed = int((time.time() - self.import_start_time) * 1000)
self.send_to_clients({"type": "import_progress", "elapsed_ms": elapsed})
time.sleep(2)
关键点在 send_to_clients() 内部------它用 asyncio.run_coroutine_threadsafe() 把消息提交到 asyncio 事件循环,完全不经过主线程 。所以就算 ImportGui.open() 把 Qt 事件循环冻得死死的,心跳照样能发出去。
这个设计让我想到一个比喻:FreeCAD 主线程是"前台",在吭哧吭哧解析 STEP 文件;心跳线程是"后台",每隔一会儿就探出头跟 EDA 说"前台还在忙,别挂电话"。
3. 坐标系:两个世界的"语言不通"
位置同步是整个项目最核心的部分,也是 bug 最多的部分。
EDA 和 FreeCAD 用着完全不同的坐标"语言":
语言障碍一:单位不同。 EDA 用 mil(密尔),FreeCAD 用 mm(毫米)。1 mil = 0.0254 mm。
typescript
const MIL_TO_MM = 0.0254; // EDA → FreeCAD
const MM_TO_MIL = 1 / 0.0254; // FreeCAD → EDA
看起来简单?我曾经在这里踩过一个绝妙的 bug:EDA 已经把 mil 转成 mm 发过来了,但 FreeCAD 那边又乘了一次 0.0254。结果坐标缩小了 40 倍,位置匹配全军覆没。
语言障碍二:原点不同。 STEP 文件导入后模型中心通常不在原点,我们做了居中处理(计算包围盒中心,所有对象平移到原点)。但这意味着 EDA 发来的"绝对坐标"到 FreeCAD 那边要减去偏移量,反过来 FreeCAD 的坐标要加回偏移量才能给 EDA 用。
python
# EDA → FreeCAD:减去居中偏移
fc_x = eda_x - self.center_offset['x']
# FreeCAD → EDA:加回居中偏移
eda_x = fc_x + self.center_offset['x']
这个偏移量忘了补偿的话,bug 会非常隐蔽------位置同步不是完全不对,而是有一个固定偏移。第一次调的时候我盯着屏幕看了半小时才发现所有元件都偏移了同一个方向同一个距离......
语言障碍三:循环更新。 EDA 拖动元件 → 通知 FreeCAD → FreeCAD 位置变了 → 又通知 EDA → EDA 又通知 FreeCAD → 无限循环......
解法是用一个 last_update_source 标记"这次更新是谁发起的":
python
def do_position_update(self, designator, x, y, rotation):
self.last_update_source = 'eda' # 打上标记
# ... 更新 FreeCAD 对象位置 ...
self.last_update_source = None # 清除标记
def check_position_changes(self):
if self.last_update_source is not None:
return # 不是用户操作引起的,是回传,跳过
4. 元件映射:让两个世界认识彼此
要让 EDA 和 FreeCAD 互相操作对方的东西,首先得知道"EDA 里的 R5"对应"FreeCAD 里的哪个对象"。
EDA 里有位号(designator),比如 R5、C10、U1。FreeCAD 里每个对象有个 Label,比如 R5、R5~R0402XX、C10~CAPC2012X100。问题在于两边不一定完全一致。
我设计了三轮渐进式匹配:
arduino
第一轮:精确匹配 "R5" == "R5" → 直接命中
第二轮:前缀边界匹配 "R5" 匹配 "R5~R0402XX" → 不匹配 "R50"
第三轮:位置匹配 坐标距离 < 0.5mm → 兜底策略
第二轮是最关键的,也是最坑的。
想象一下:如果你用 "R1" in "R10" 来做子串匹配------Python 会告诉你 True。于是 R1 把 R10、R11、R12 的对象全部吞走了。移动 R1 的时候,四个元件一起飞到了错误位置。
我曾经亲眼看着 64 个对象在一瞬间全部偏移到坐标 (9999, 9999)。 那一刻的绝望感至今难忘。
正确的做法是用正则表达式的负向前瞻:
python
# R1 后面不能跟数字,这样就不会匹配 R10
pattern = re.compile(r'^' + re.escape(designator) + r'(?![0-9])', re.IGNORECASE)
第三轮位置匹配是兜底策略,但也有讲究------FreeCAD 对象被居中偏移过,要加回偏移量才能和 EDA 原始坐标比较。而且容差要设得合适:0.5mm 太大了会误匹配相邻元件,太小了浮点误差又会导致漏匹配。
5. 对象分组:一个电阻其实有三四个对象
STEP 文件里,一个贴片电阻不只是一个 3D 模型------还有丝印文字、焊盘铜箔等,每个都是独立的 FreeCAD 对象。移动的时候这些对象必须一起动。
分组策略:同一个位号下的、同一位置(0.5mm 容差内)的非主对象归入该元件的组。
这里有个很关键的约束------不能把其他元件的主对象归到当前元件的组里。早期版本没有做这个排除,而且容差设成了 1mm。结果你猜怎么着?有几个元件贴得比较近,分组的时候被错误地归入了同一个组。移动其中一个,另一个也跟着飞了。
6. 线程安全:FreeCAD 开发的"地下城"
这是 FreeCAD 扩展开发中最容易被忽视的陷阱。
FreeCAD 的 GUI 操作必须在主线程执行。 修改对象位置、添加/删除对象、选中对象------全都必须在主线程。但 WebSocket 的消息回调跑在独立线程里。直接在回调里操作 FreeCAD 对象?轻则无效操作,重则直接崩溃。
解决方案是经典的 消息队列 + 主线程轮询:
scss
WebSocket 线程 主线程(FreeCAD GUI)
│ │
收到消息 ──► queue ──► QTimer 轮询 ──► 执行操作
│ (线程安全) (100ms 一次)
│ │
│ 修改 Placement ✓
│ 删除对象 ✓
│ 选中对象 ✓
python
# WebSocket 线程:收到消息扔进队列
async def handle_message(self, websocket, message):
data = json.loads(message)
self.message_queue.put({'type': 'position_update', ...})
# 主线程:QTimer 每 100ms 捞一批消息出来处理
def process_message_queue(self):
for msg in self.message_queue.get_all():
if msg['type'] == 'position_update':
self.do_position_update(...) # 安全!在主线程里
QTimer 是 Qt 的定时器,它的回调在主线程执行。WebSocket 收到消息后不直接操作 FreeCAD,而是放进线程安全的队列。主线程每隔 100ms 捞一批消息出来处理。简单、可靠、不会崩。
换个更形象的说法
-
为什么 WebSocket 回调不能直接操作 FreeCAD?
你可以把 FreeCAD 主线程 想象成:
一个正在专心画画的画家
WebSocket 线程 是:
一个从外面跑进来、随时喊你做事的快递员
错误做法:快递员(WebSocket)直接抢过画家的画笔,乱涂乱画。
→ 画家懵了 → 画毁了、软件崩了
画家正在画画,你突然打断他、改他的东西 → 崩溃!
-
正确做法:消息队列(你给画家递纸条) 正确流程是这样: 快递员(WebSocket)不碰画笔 他把要做的事写在纸条上 把纸条放进盒子(队列) 画家(主线程)每隔一小会儿自己看一眼盒子 画家自己按照纸条内容画画 这样永远不会乱、永远不会崩。
-
那 QTimer 是什么? QTimer 就是:让画家每隔 100 毫秒自动看一眼盒子 它是 Qt 提供的定时器,百分百运行在主线程,所以它执行的代码绝对安全。
-
完整流程(超级简单版) WebSocket 线程(后台) 收到消息 → 不操作 FreeCAD → 塞进队列(纸条进盒子) 主线程(FreeCAD GUI) QTimer 每 100ms 跑一次 → 从队列拿纸条 → 在主线程安全执行
7. 交叉定位:点击一边,另一边自动跳转
这个功能是用户体验的加分项。
在 EDA 里点击一个元件 → 发消息告诉 FreeCAD {"type": "cross_probe", "designator": "R5"} → FreeCAD 那边查映射表找到对象 → 选中它 → ViewFit 聚焦。
反过来,在 FreeCAD 里选中一个对象 → QTimer 检测到选中变化 → 发消息给 EDA → EDA 调用 doCrossProbeSelect + navigateToCoordinates。
实现不难,但有个小坑:FreeCAD 的 Selection API。选中对象的正确方式是 FreeCADGui.Selection.addSelection(obj),不是通过 document 对象。我用错了方式,报了一堆 AttributeError。
踩坑实录(心酸血泪史)
坑 1:文档说拖动触发 move,实际触发的是 modify
嘉立创 EDA 的事件类型文档明明白白写着 move。我写完代码,测试------完全没反应。加了日志一看,拖动元件触发的是 modify。文档和实现不一致......
typescript
// 两个都得监听,安全第一
if (eventType === 'move' || eventType === 'modify') {
syncPositionToFreecad(...);
}
坑 2:拖动元件时 designator 全是 undefined
移动操作触发事件的时候,事件里的 designator 和 parentComponentDesignator 都是 undefined。因为移动操作实际触发的是子图元(焊盘、丝印等),不是 Component 本身。
解决方案:在启动双向交互时构建一个 primitiveId → designator 的反查表,事件回调里通过图元 ID 反查位号:
typescript
// 启动时构建
for (const comp of await eda.pcb_PrimitiveComponent.getAll()) {
primitiveIdToDesignator.set(comp.getState_PrimitiveId(), comp.getState_Designator());
}
// 事件回调里反查
const designator =
prop.designator ||
prop.parentComponentDesignator ||
primitiveIdToDesignator.get(prop.primitiveId) ||
primitiveIdToDesignator.get(prop.parentComponentPrimitiveId);
坑 3:comp.modify() 不存在
嘉立创 EDA 的 IPCB_PrimitiveComponent 没有 modify() 方法。看了半天 API 才发现,修改元件要用 setState_X()、setState_Y()、setState_Rotation(),然后 必须 调用 done() 提交。不调用 done() 的话,修改不会生效,也不会报错。静默失败,最难调试的那种。
坑 4:Array.from(new Uint8Array()) 让大文件传输直接断连
前面详细讲过了。一个字节变三个字符,20MB 膨胀成 80MB,直接炸。这是整个项目第一个让我"为啥不动了"的 bug,也是解决后提升最大的一个优化。
坑 5:64 个对象集体偏移到 (9999, 9999)
前面也讲了。子串匹配 "R1" in "R10" == True,导致 R1 把 R10、R11、R12 的对象全吞走了。移动 R1 的时候,所有被错误分组的对象一起飞到了天边。
那天晚上的调试经历让我深刻理解了什么叫"边界条件"。
坑 6:坐标缩小了 40 倍
EDA 已经把 mil 转成 mm 了,FreeCAD 那边又乘了一次 0.0254。位置匹配全部失效,第三轮匹配一个都匹配不上。加上居中偏移没补偿,整个映射系统形同虚设。
这个 bug 最坑的地方在于它不会报错,只是"匹配数量特别少"。一开始我以为是匹配算法的问题,调了半天算法才发现是单位转换做了两次。
坑 7:居中偏移忘了补偿
EDA 发来的坐标是原始绝对坐标,但 FreeCAD 里的对象已经被居中偏移了。位置同步的时候忘了减去偏移量,导致所有同步过来的位置都有一个固定偏差。
这个 bug 的特征是:位置同步"有反应"但"总是偏一点"。不像完全不动那样一眼就能看出来,特别阴险。
完整交互流程
以"启用双向交互"为例,从点击到运行的完整数据流:
scss
用户点击「启用双向交互」
│
▼
┌─ 检查 WebSocket 连接,没连就自动连
│
├─ 获取所有元件 → 构建 designator ↔ primitiveId 映射表
│
├─ 注册事件监听
│ ├─ addPrimitiveEventListener('all', onPcbPrimitiveChange) ← 监听移动/删除
│ └─ addMouseEventListener('selected', onPcbMouseSelect) ← 监听选中
│
├─ 获取每个元件的坐标 → 发送给 FreeCAD 做映射
│
├─ FreeCAD 三轮匹配 → 返回匹配结果
│
├─ 发送 enable_monitor → FreeCAD 启动位置监听
│
└─ Toast: "双向交互已启动"
│
▼
┌─────────────────────────────────────────┐
│ 实时双向同步循环 │
│ │
│ EDA 拖动元件 → position_update → FC 移动 │
│ FC 拖动对象 → QTimer 检测 → EDA 移动 │
│ EDA 点击元件 → cross_probe → FC 聚焦 │
│ FC 点击对象 → QTimer 检测 → EDA 定位 │
│ EDA 删除元件 → delete_object → FC 删除 │
│ EDA 改位号 → rename_designator → FC 更名│
└─────────────────────────────────────────┘
WebSocket 消息协议
完整的消息类型一览:
| 方向 | type | 数据字段 | 说明 |
|---|---|---|---|
| EDA→FC | file_upload_start |
sessionId, filename, totalSize, totalChunks | 分片上传开始 |
| EDA→FC | file_upload_chunk |
sessionId, index, data(base64) | 单个分片数据 |
| FC→EDA | upload_started |
sessionId | 服务端确认接收上传 |
| FC→EDA | chunk_received |
sessionId, index, received, total | 分片确认+进度 |
| FC→EDA | upload_complete |
sessionId, message | 全部分片接收完成 |
| FC→EDA | import_started |
sessionId | 开始导入 STEP |
| FC→EDA | import_progress |
sessionId, elapsed_ms | 导入进行中心跳 |
| FC→EDA | import_complete |
sessionId, details | 导入完成 |
| EDA→FC | build_mapping |
components[{designator,x,y,rotation}] | 请求建立映射 |
| FC→EDA | mapping_result |
mapping[{designator,freecadLabel}] | 返回映射结果 |
| EDA→FC | position_update |
designator, x, y, rotation | EDA 元件移动 |
| FC→EDA | position_update_from_freecad |
designator, x, y, rotation | FreeCAD 对象移动 |
| EDA→FC | delete_object |
designator | EDA 元件删除 |
| FC→EDA | delete_from_freecad |
designator | FreeCAD 删除同步 |
| EDA→FC | cross_probe |
designator | EDA→FC 交叉定位 |
| FC→EDA | cross_probe_from_freecad |
designator, x, y | FC→EDA 交叉定位 |
| EDA→FC | rename_designator |
old, new | 位号重命名 |
| EDA→FC | enable_monitor |
(空) | 启动 FC 端位置监听 |
| EDA→FC | disable_monitor |
(空) | 停止 FC 端位置监听 |
| 双向 | ping / pong |
(空) | 心跳 |
已知限制(诚实地聊聊做不到的事)
1. Board 复合体的"幽灵残影"
STEP 文件里 board、topcopper、topsilkscreen 是包含所有元件形状的复合几何体。移动某个元件后,这些复合体中该元件的旧位置仍然可见。这是 STEP 几何格式的本质限制------复合体是"烘焙"好的几何,不能单独修改其中一部分。
只能靠重新全量导出来解决。
2. 位置监听有 500ms 延迟
FreeCAD 没有原生的"对象属性变化"回调。只能用 QTimer 每 500ms 轮询一次所有对象的位置。对于缓慢拖拽影响不大,但快速甩一下的话 FreeCAD→EDA 方向会有轻微延迟。
3. STEP 不保留元件层级
STEP 是纯几何格式,不包含"哪个面属于哪个元件"这种层级信息。所以匹配只能靠 Label 文本和坐标距离,本质上是个"猜"的过程。如果 STEP 导出时 Label 格式变了,匹配可能出问题。
4. 大文件导入慢
FreeCAD 的 STEP 解析用的是 OpenCASCADE 内核,单线程的。80 个对象大概要等几分钟。这个只能靠硬件------CPU 单核性能越高越快。
技术栈
| 组件 | 技术 | 说明 |
|---|---|---|
| EDA 扩展 | TypeScript | 嘉立创 EDA 专业版扩展 |
| 通信协议 | WebSocket (JSON) | 双向实时通信 |
| 3D 格式 | STEP | PCB 3D 模型行业标准 |
| FreeCAD 脚本 | Python | WebSocket 服务器 + 3D 对象操作 |
| 线程同步 | QTimer + Queue | 跨线程安全的 FreeCAD 操作 |
| 国际化 | JSON i18n | 中英双语 |
项目结构
csharp
pcb-export-to-freeCad/
├── src/
│ └── index.ts # EDA 扩展主逻辑(TypeScript)
├── script/
│ ├── Interactive-with-easyeda.py # FreeCAD WebSocket 服务器(Python 宏)
│ ├── eda_api_reference.md # EDA 扩展 API 参考手册
│ └── PCB-FreeCAD双向协同实践.md # 就是这篇文章
├── locales/
│ ├── zh-Hans.json # 中文翻译
│ └── en.json # 英文翻译
├── extension.json # 扩展配置清单
└── dist/ # 构建输出
写在最后
这个项目总共不到 2500 行代码,但覆盖了 WebSocket 通信、跨语言坐标转换、线程安全、正则匹配、分片传输......每个模块都不复杂,但组合在一起解决了一个真实的工程痛点。
从"导出-导入-检查-反馈"的半小时循环,变成"拖一下看看"的即时体验。 这是我做这个项目最满意的地方。
如果你也在做 ECAD-MCAD 协同相关的工作,或者对嘉立创 EDA / FreeCAD 的扩展开发感兴趣,欢迎交流。踩坑记录应该能帮你省不少时间。
项目已开源(Apache 2.0)。 可在嘉立创拓展里查看
如果觉得有用,欢迎点赞收藏。有问题欢迎评论区讨论。