Pascal Editor 三维建筑编辑器的 Agent 实时同步链路
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
这条所谓的实时同步链路,本质是一次本地磁盘写入加一条服务端轮询,中间没有任何协同编辑算法。 想明白这一点,你才不会对它抱错期待:它能让 Agent 建的墙立刻出现在你屏幕上,但它不负责让你和 Agent 同时动手时不打架。
先做个命名消歧。这里说的 Pascal Editor 是一个开源的三维建筑编辑器(浏览器里画房子、摆家具、开门窗的那种),跟 Pascal 编程语言没有任何关系,也跟压强单位帕斯卡没关系。它的仓库根目录下 apps/ 有 2 个应用、packages/ 有 9 个包,其中 packages/mcp 就是本文的主角之一:一个把编辑器的场景改动能力包装成 MCP 工具的服务器。
一、这条链路要解决的问题
不接同步的情况下,Agent 通过 MCP 建模是个盲盒。它调 create_room 创建了一个房间,返回一串 id,你要看结果就得导出 JSON 再手工加载一遍。反馈周期以分钟计,Agent 也拿不到”这个布局看着不对”的信号。
Pascal 的做法是让两侧共用同一份本地数据。仓库 README 这样定位这个包:MCP 服务器在 Bun 里无头运行,不需要浏览器、WebGPU、React 或外部数据库服务,暴露的是编辑器 UI 用的同一套场景改动能力。而落盘位置,README 写得很直白——通过 MCP 保存的场景存在本地 SQLite 数据库 ~/.pascal/data/pascal.db,用 PASCAL_DATA_DIR 可以让编辑器和 MCP 服务器指向同一个目录,PASCAL_DB_PATH 则指定确切的库文件路径。
所以整条链路的前提条件只有一句话:两个进程指着同一个数据目录。剩下的就是怎么把”我改完了”这个信号从写进程传到读进程。
这里要先分清仓库里两套叫”事件”的东西。wiki/architecture/ 下有 20 份架构文档,其中 events.md 讲的是浏览器内基于 mitt 的类型化事件总线,处理的是 wall:click、grid:pointerdown 这类交互事件,跟本文说的持久化事件流不是一回事。本文讲的是存在数据库里、跨进程传递的 scene_events。
二、写入侧:一次工具调用做了哪几件事
核心是 packages/mcp/src/tools/live-sync.ts 里的 publishLiveSceneSnapshot,函数上方的注释把它的职责写得很清楚:把桥接层当前的图持久化到活动场景,并为浏览器订阅方追加一条实时事件;当 MCP 会话没有绑定到已保存场景时直接空转。
它的执行顺序值得逐步看:
第一步先调 syncDerivedStairOpenings。这一步处理的是楼梯洞口——楼梯要通到上一层,就得在上层的楼板(一层楼的地面板)上挖一个洞,否则楼梯会直接顶到上层楼板的底面。这个函数拿 syncAutoStairOpenings 算出需要更新的节点,再通过 operations.applyPatch 以 update 操作写回去。为什么单独给楼梯洞口开小灶?README 的 Limitations 一节解释了原因:墙体拼角(两面墙交汇时把接缝处理成斜接,不然两堵墙会互相穿插出一块脏边)、楼板三角化(把楼板的多边形轮廓切成一堆三角形,因为显卡只会画三角形)、CSG 挖洞(CSG 即实体布尔运算,用一个形体从另一个形体上减掉一块)、屋顶与楼梯生成这些系统跑在编辑器的 React hook 里,无头模式不会重算派生几何——派生几何指的是由节点数据现算出来、真正能被渲染的那层形体,它不存在数据里。楼梯洞口是少数被显式补上的一块。
第二步取 operations.getActiveScene(),同时检查 operations.canAppendSceneEvents。两者任一不成立就直接返回——没绑定场景、或者当前存储后端根本没有追加事件的能力,这次同步就静默跳过,不报错。
第三步 operations.exportSceneGraph() 导出整张场景图,然后调 saveScene。场景图就是这个编辑器的数据模型:把一栋房子拆成墙、门、窗、楼层、家具这类节点,按父子关系挂成一棵树,节点里存的是尺寸、位置、材质这些参数,而不是三角面片。这里的入参值得注意:expectedVersion 传的是 active.version,saveMode 是 'draft',publish 是 false,operation 传的是调用方给的 kind。也就是说,每次 Agent 改动都是一次带乐观版本检查的草稿保存,不产生版本历史。
第四步保存成功后 setActiveScene(meta) 更新本地记的版本号,再 appendSceneEvent({ sceneId, version, kind, graph }) 追加事件。注意事件里带的 version 是保存后的新版本,graph 是完整的图。
错误处理只有两种出口:撞上 SceneVersionConflictError 时抛 live_sync_version_conflict,带上 sceneId 和 expectedVersion;其它异常统一抛 live_sync_failed: 加原始消息。
这个 kind 参数是纯粹的字符串标签,由调用方传入。在 packages/mcp/src/tools/ 下 grep 一遍 publishLiveSceneSnapshot 就能看到调用点:apply_patch、create_room、add_door、add_window、furnish_room、create_wall、place_item、set_zone、cut_opening、create_level、duplicate_level、delete_node、create_story_shell、create_roof、create_stair_between_levels,以及 undo 和 redo。撤销与重做那两个还多一层判断,只有真的回退或前进了步数才发布。
同文件里还有个更轻的 appendLiveSceneEvent,它不做保存,只在存储支持时追加一条事件,用于那些已经自己完成保存的路径,比如 save_scene。
三、读出侧:一条 SSE 连接包着一个轮询
apps/editor/app/api/scenes/[id]/events/route.ts 是浏览器那头的入口,它是个 Next.js 路由,声明了 dynamic = 'force-dynamic' 和 runtime = 'nodejs'。
请求进来先过 guardSceneApiRequest,然后两道前置检查:operations.canListSceneEvents 为假返回 501 加 scene_events_unavailable;loadStoredScene 拿不到场景返回 404 加 not_found。
游标的取法有点讲究:同时读查询参数 after 和请求头 Last-Event-ID,两者取较大值,再跟 0 取较大值兜底。Last-Event-ID 是 SSE 规范里浏览器断线重连时自动带上的头,所以这条链路天然支持断点续传——不需要前端写额外逻辑。
流建立后第一件事是发 retry: 1000,告诉浏览器断线后隔多久重连。随后启动两个定时器:一个轮询,间隔是文件顶部的常量 POLL_MS(250 毫秒);一个心跳,间隔 HEARTBEAT_MS(15 秒),发的是注释行 : keepalive,用来穿过中间代理的空闲超时。
轮询体每轮调 operations.listSceneEvents(id, { afterEventId: cursor, limit: MAX_EVENTS_PER_POLL }),MAX_EVENTS_PER_POLL 是 50。取到的每条事件推三行:id: 带事件号、event: scene、data: 带整条事件的 JSON。轮询体自己 try/catch,出错时改推 event: error,并且在 finally 里无条件排下一轮——单次数据库读失败不会掐断连接。
响应头那组也是有意为之:Cache-Control: no-cache, no-transform、Connection: keep-alive、Content-Type: text/event-stream; charset=utf-8,以及 X-Accel-Buffering: no——最后这个是给 Nginx 之类的反向代理看的,不加的话代理会把流缓冲起来,你会看到改动一批批地憋着出来。
把这段读完你会发现一个反直觉的事实:外面是服务端推送,里面是 250 毫秒一次的数据库轮询。没有数据库通知、没有文件监听、没有内存总线。这是个刻意的取舍——轮询让”两个互不相识的本地进程”这个前提成立,代价是延迟下限被钉在了轮询间隔上。
底层的表结构在 packages/mcp/src/storage/sqlite-scene-store.ts 的建表语句里:scene_events 有 event_id(自增主键)、scene_id、version、kind、created_at、graph_json,外键指向 scenes(id) 并带 ON DELETE CASCADE,另有一个 (scene_id, event_id) 的复合索引给游标查询用。追加走 withWriteTransaction,里面是 BEGIN IMMEDIATE;数据库打开时设了 journal_mode = WAL 和 busy_timeout,这也正是 README 里说”独立的本地进程可以保存并打开同一个场景数据库”的依据。
四、浏览器侧的防回环
apps/editor/components/scene-loader.tsx 里那个 useEffect 是整条链路里最容易被低估的一段。它 new EventSource('/api/scenes/${meta.id}/events'),监听 scene 事件,然后连做三道过滤:
JSON 解析失败直接 return,不抛;payload.sceneId 跟当前场景对不上就丢弃;payload.version <= versionRef.current 也丢弃。第三条是关键的幂等保护——版本号单调递增,重连后重放的旧事件会被自然吃掉。
通过过滤后它做四件事:更新 versionRef,把 sceneGraphSignature(payload.graph) 记进 lastRemoteGraphJsonRef,把 suppressRemoteSaveUntilRef 设成当前时间加 2500 毫秒,最后调 applySceneGraphToEditor(payload.graph) 把整张图套进编辑器。
后两个 ref 是干什么的?编辑器本身有自动保存,图一变就会 PUT 回服务端。如果不管,远端推来的图会立刻被当成本地改动存回去,再触发一次事件——回环就成了。所以 handleSave 里先算一遍签名,跟 lastRemoteGraphJsonRef 相同就跳过并清掉标记;再看是否还在 2500 毫秒的抑制窗口内,在就直接返回。签名函数只取 nodes、rootNodeIds、collections、installedPlugins 四项做 JSON 序列化,也就是说它比对的是图内容而不是对象引用。
这套做法很朴素,用的是”时间窗 + 内容签名”双保险,不是严格的因果关系跟踪。它的可靠性依赖一个假设:远端图套进编辑器后触发的那次保存,会发生在 2500 毫秒以内。
顺带一提,SSE 的 error 事件里只在 readyState === EventSource.CLOSED 时才把提示挂出来,短暂抖动不会打扰你。而本地保存返回 409 时会弹冲突提示,让你手动刷新。
五、这条链路的零件清单
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
publishLiveSceneSnapshot | 保存草稿 + 追加事件,绝大多数改动工具的收尾动作 | packages/mcp/src/tools/live-sync.ts | 排查”Agent 说改了但页面没动” |
scene_events 表 | 事件流的落盘位置,字段含 kind 与整图 JSON | packages/mcp/src/storage/sqlite-scene-store.ts | 磁盘涨得莫名其妙,或想复盘 Agent 改了什么 |
SceneEvent 等类型与 SceneStore 接口 | 定义事件形状与存储契约(追加/列举为可选方法) | packages/mcp/src/storage/types.ts | 想换存储后端时 |
canAppendSceneEvents / canListSceneEvents | 按存储实现是否带对应方法来开关能力 | packages/mcp/src/operations/scene-operations.ts | 同步整条静默失效时第一个该查的开关 |
| SSE 路由 | 轮询数据库并以 text/event-stream 推给浏览器 | apps/editor/app/api/scenes/[id]/events/route.ts | 延迟、代理缓冲、501/404 排查 |
guardSceneApiRequest | 来源校验、令牌校验、按 IP 的频率限制 | apps/editor/lib/scene-api-security.ts | 把编辑器暴露到非 loopback 地址时 |
EventSource 订阅与防回环 | 过滤、去重、抑制窗口、整图套用 | apps/editor/components/scene-loader.tsx | 出现改动来回抖动时 |
load_scene | 把库里的场景装进 MCP 桥接层并设为活动场景 | packages/mcp/src/tools/scene-lifecycle/load-scene.ts | 每次开工的第一步,以及版本冲突后的恢复 |
六、边界与代价:它明确不管的事
它不是协同编辑。 整条链路里没有 CRDT、没有操作变换、没有按节点粒度的合并。冲突处理就一招:保存时比对版本号,对不上就抛 live_sync_version_conflict。README 说得很直接——如果浏览器或另一个 MCP 进程先保存了更新的版本,工具会返回这个错误,你得先 load_scene 重新加载再继续。你和 Agent 同时在改同一个场景,结果是其中一方被拒,不是两边的改动被智能合并。
它是单向的。 事件流从 MCP 侧流向浏览器。你在浏览器里画的墙走的是另一条路(保存接口),不产生 scene_events。Agent 想知道你刚才干了什么,得自己重新 load_scene。
它传的是全量快照,不是增量。 每条事件的 graph_json 是整张场景图。这意味着两件事:一是数据库里同一张图会被存很多份(scenes 表一份当前值,scene_events 表每次改动一份);二是 SSE 单条消息的体积随场景规模线性增长,几百个节点的房子每改一扇门就重传一次全图。
事件表没有清理逻辑。 在整个仓库里 grep scene_events,只能找到建表、建索引、插入、查询四类语句,没有任何删除或裁剪。它只在场景被删除时被级联清掉。长期跑同一个场景,pascal.db 会一直涨,这是你要自己管的事。
无头侧不重算派生几何。 README 的 Limitations 说得明白:墙体拼角、楼板三角化、CSG 挖洞、屋顶与楼梯生成跑在编辑器的 React hook 里,无头模式不会重新生成派生几何,但节点数据本身完全可操作。落到实处就是:Agent 写下的是数据,最终看到的形体是浏览器算出来的。另外 export_glb 明确返回 not_implemented,视觉类工具需要 MCP 宿主支持 sampling 能力。
它默认信任本地。 MCP 服务器会在你机器上读写那个 SQLite 文件,Agent 有权创建、修改、删除场景里的任何节点,也能 delete_node 级联删除。编辑器这侧的接口守卫在没设 PASCAL_SCENE_API_TOKEN 时,只放行 loopback 请求,非 loopback 直接返回 scene_api_token_required;MCP 服务器那侧同理,README 写明绑定非 loopback 主机需要 PASCAL_MCP_HTTP_TOKEN。这两道门是”要么本机、要么带令牌”的粗粒度控制,不是按场景、按操作的权限模型。真把端口开出去,等于把整个场景库的读写权交出去。这类边界怎么划,可以对照 MCP 服务的安全边界 和 MCP 协议本身在解决什么 一起看。
七、上手与避坑清单
数据目录必须两侧一致。 会踩是因为默认值看着”反正都是 ~/.pascal/data”,但编辑器是通过 shell 环境变量拿到 PASCAL_DATA_DIR 的,而 MCP 服务器多半由 Claude Desktop、Claude Code 或 Codex CLI 这类宿主拉起,继承的是宿主的环境。怎么避:在 MCP 宿主的配置里显式写 env 段填 PASCAL_DATA_DIR(README 的几份配置示例都是这么写的),别指望默认值对上。
先在编辑器里存出场景,再让 Agent load_scene。 会踩是因为 publishLiveSceneSnapshot 在没有活动场景时是静默返回的,不报错。Agent 会一路建得很欢,你的页面一动不动,日志里也没有失败信号。怎么避:把”打开或创建场景 → load_scene → 再跑改动工具”当成固定开场,README 的三步流程就是这个顺序。
碰到 live_sync_version_conflict 别重试同一个调用。 会踩是因为这个错误看着像瞬时故障,直觉是再调一次。但桥接层里记的 expectedVersion 已经过期了,重试只会再撞一次。怎么避:把它当成”必须重新加载”的信号,load_scene 之后重做,而不是重试。
别在同步期间手动改同一片区域。 会踩是因为浏览器的自动保存和 MCP 的草稿保存抢的是同一个版本号。怎么避:Agent 跑批量改动时你就别动鼠标;真要接管,等它那一轮工具调用结束。
上了反向代理记得关缓冲。 会踩是因为路由虽然发了 X-Accel-Buffering: no,但不是所有代理都认这个头。怎么避:部署后先看改动是不是一批批憋着出来的,是的话去代理侧关掉响应缓冲。
别把编辑器和 MCP 的 HTTP 端口随手暴露。 会踩是因为本机调试时不设令牌一切正常,搬到内网机器上你会先看到 503 而不是想当然的可用。怎么避:需要跨机时把 PASCAL_SCENE_API_TOKEN 和 PASCAL_MCP_HTTP_TOKEN 一起设上,并且认清这仍然只是入口令牌,不是细粒度授权。
收束:你该接着读哪几个文件
同类主题里,站内讲存储与状态同步的 opencode 的存储与同步机制 侧重会话数据本身怎么存,讲可观测的 Agent 可观察日志怎么设计 和 Hermes 的监控与可观测性 侧重你事后怎么复盘;本文是第三类——一条把 Agent 改动实时投到人眼前的链路,重点在同步语义与它的边界,不在日志与指标。
自检清单,按这个顺序走一遍基本能定位九成问题:两侧 PASCAL_DATA_DIR 是否一致;load_scene 是否成功且返回了场景 id;scene_events 表里是否有新行以及 kind 是什么;浏览器 Network 里那条 events 请求是不是 200 且持续在收数据;收到的 version 是否大于页面当前版本。
要往下挖,从 packages/mcp/src/tools/live-sync.ts 开始最省力,它只有几十行,却是写入侧所有工具的共同收尾。看懂它再去看 apps/editor/app/api/scenes/[id]/events/route.ts 的轮询体,最后回到 apps/editor/components/scene-loader.tsx 看那两个防回环的 ref——这三处凑齐,整条链路就没有黑箱了。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 浏览器里的 3D 建筑编辑器 Pascal Editor:Agent 建完模型后的两条导出线 和 Pascal Editor 开源 3D 建筑编辑器:MCP 服务器暴露出去前的风险面梳理。