开源浏览器 3D 建筑编辑器 Pascal Editor:场景状态的撤销层与落盘层怎么拆
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
在 Pascal Editor 这个跑在浏览器里的开源 3D 建筑编辑器里,Agent 通过 MCP 建的墙、开的洞、删的楼层,和你用鼠标拖出来的改动进的是同一条撤销栈——这不是顺手做出来的效果,而是它刻意把「怎么改场景」这件事收敛到一个 store 的结果。 一旦承认这个前提,Agent 建模就从「你不敢让它碰的黑盒」变成「一个可以随时按 Ctrl+Z 退回去的普通操作」,可控性的差别是量级上的。
先说清楚名字:Pascal Editor 指的是这个开源的三维建筑编辑器项目(仓库 pascalorg/editor,MIT 许可证,Copyright 2026 Pascal Group Inc.),和 Pascal 编程语言没有任何关系,也和压强单位帕斯卡无关。仓库 README 把自己定位成「用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器」——React Three Fiber 是把 Three.js 三维渲染包成 React 组件的适配层,WebGPU 是浏览器里比 WebGL 更新一代的图形接口。这句是它的自我定位,不是本文的评价;本文只对着代码谈状态这一层。它另外自带一套 MCP 服务器,让 AI Agent 可以直接在场景里建模。
本篇只讲它的场景状态这一层。如果你想比较「让 Agent 走固定状态机还是给它自由发挥」这类框架级取舍,看 Agent 状态机与自由发挥的取舍;如果关心「同一个改动被重复执行会不会出事」,看 重试与幂等设计;如果关心「AI 已经把文件删了怎么捞回来」,看 AI 删文件后的恢复路径。这三篇讲的是通用方法论,本篇讲的是一份真实开源代码里这套方法论具体长什么样。
一、场景状态长什么样:一张扁平字典加一份根 id 列表
packages/core/src/store/use-scene.ts 里定义的 SceneState 是整个编辑器的数据中心。它的形状很朴素:
nodes:一张扁平字典,key 是节点 id,value 是节点本身。整棵建筑树不是嵌套对象,而是靠每个节点自己的parentId和children互相指认。rootNodeIds:哪些节点是顶层的。dirtyNodes:一个脏节点集合,供墙体、物理这类系统知道该重算谁。collections、materials、installedPlugins:关系型元数据——分组、场景材质、已装插件。readOnly:只读锁,打开后所有增删改直接空转。
节点类型有 45 种(packages/nodes/src/ 下 46 个子目录,其中 shared/ 不是节点类型)。对不做建筑的读者,几个高频名词先解释一句:level 是楼层,一栋楼里的一层;slab 是楼板,一层楼的水平承重面,也就是你踩着的那块板;wall 是墙;roof-segment 是屋面段,一片有坡度的屋顶;stair 是楼梯;site 是场地,整个项目的最外层容器。层级大致是场地 → 建筑 → 楼层 → 楼层里的墙板门窗。
真正执行写入的代码不在 store 定义里,而在 packages/core/src/store/actions/node-actions.ts:createNodesAction、updateNodesAction、deleteNodesAction、applyNodeChangesAction。这四个函数是整个编辑器唯一的写入口。它们做的事比「改个字段」重的多:更新父节点的 children 数组、处理改父级时的双向摘挂、删除时递归收集子孙、把删掉的节点从分组里摘掉、用 zod schema 重新校验并把越界数字夹回合法区间(sanitizeNumericValue 那一大段就是干这个的,遇到 NaN 或超界值会打印 [Scene] Sanitized invalid numeric node create/update 的告警而不是让场景炸掉)。
把写入收敛到一处,是后面所有分层能成立的前提。
二、撤销重做这一层:它是一个中间件,而且只包了场景 store
场景 store 用 zustand 的 create 创建,外面包了 zundo 的 temporal 中间件。整个配置只有四行,但每一行都决定了「一步撤销是什么」:
{
partialize: (state: SceneState) => sceneHistorySnapshotFromState(state),
equality: (pastState, currentState) => areSceneSnapshotsEqual(pastState, currentState),
onSave: (pastState, currentState) => {
notifySceneCommit({
origin: 'local',
before: sceneHistorySnapshotFromState(pastState),
current: sceneHistorySnapshotFromState(currentState),
})
},
limit: 50, // Limit to last 50 actions
}
四件事分别是:
partialize 圈定了进历史的字段——只有 nodes、rootNodeIds、collections、materials、installedPlugins 这五项。dirtyNodes 和 readOnly 不进快照,撤销不会把脏标记或只读锁一起回滚。这是个正确的边界:脏集合是渲染派生状态,回滚它只会造成幽灵重算。
equality 用的是 areSceneSnapshotsEqual,落到 history-control.ts 里的 areSemanticValuesEqual 做递归语义比较。意思是:只要新旧快照语义等价,这次写入就不产生历史条目。很多「拖了但没动」「改了个值又改回去」的操作因此不会污染撤销栈。
onSave 把每次进历史的写入广播出去,origin 标成 'local'。订阅者用 subscribeSceneCommits 注册,拿到 { origin, before, current } 三件套。这是编辑器之外的系统(比如版本记录)观察改动的口子。
limit: 50 是硬上限,最近 50 步。
比配置更值得看的是 history-control.ts 里那几个工具函数,它们解决的是「一步的粒度」这个真问题:
pauseSceneHistory/resumeSceneHistory是一对带深度计数的开关。嵌套调用时只有最外层真正 pause/resume,中间层只加减计数。runAsSingleSceneHistoryStep把一段可能产生 N 条历史的操作压成一条。它的做法不是暴力清栈,而是先记下调用前的pastStates,跑完后用retainedPastStateCount逐项做引用比对,算出这段代码新增了几条,然后只保留第一条。如果整段跑下来语义上什么都没变(pendingSceneCommitIsNoOp),新增的历史条目会被整体丢掉。- 同时它开了一个提交事务:区间内的多次
notifySceneCommit会被合并成一条(保留最早的before和最新的current),事务结束才真正发出去。
谁在用它?拖拽手柄(packages/editor/src/components/editor/handles/use-handle-drag.ts)、画墙(packages/editor/src/components/tools/wall/wall-drafting.ts)、地形笔刷(packages/editor/src/lib/terrain-sculpt.ts)、墙端点拖动(packages/nodes/src/wall/move-endpoint-tool.tsx)——全是「一次交互产生几十上百次 set」的场景。没有这一层,一次拖墙就能把 50 步的历史额度用光。
三、落盘这一层:它其实不是中间件
这是本文最想纠正的一个直觉。「一层中间件落 localStorage、一层中间件做撤销重做」听着对称,但仓库里不是这么写的:场景 store 上只有 zundo 一层中间件,落盘走的是另一条完全不同的路——一条订阅链。
packages/editor/src/hooks/use-auto-save.ts 里的 useAutoSave 直接 useScene.subscribe(...),然后:
- 用
JSON.stringify(state.nodes)和上一次的字符串比对判断节点有没有变;collections、materials、installedPlugins则按引用比对(zustand 每次改动都换新对象)。 - 变了就打上脏标记,
setSaveStatus('pending'),起一个 1000 毫秒(AUTOSAVE_DEBOUNCE_MS)的防抖定时器。 - 定时器到点执行
executeSave:如果调用方传了onSave就走它(通常是网络保存),没传就saveSceneToLocalStorage,落到packages/editor/src/lib/scene.ts里pascal-editor-scene这个 localStorage 键,并且吞掉配额异常。 - 同时监听
beforeunload和pagehide做退出前冲刷。注释写得很直白:网络保存必须带keepalive,否则页面一卸载请求就被浏览器取消,编辑完立刻刷新就会丢改动;而pagehide覆盖了移动端 Safari 和 bfcache 这些beforeunload不触发的情况。
真正用了 zustand persist 中间件的,是 UI 偏好 store:packages/editor/src/store/use-editor.tsx,键名 pascal-editor-ui-preferences,partialize 里一条条列清楚了哪些偏好可以持久化(阶段、模式、工具、视图模式、侧栏面板、栅格吸附步长等等),并且开了 skipHydration。
这个分家是有道理的:UI 偏好是「你这个人的习惯」,跨项目通用,直接进中间件自动存取最省事;场景数据是「这个项目的内容」,需要防抖、需要状态机(idle/pending/saving/saved/paused/error)、需要退出冲刷、需要守卫,塞进中间件反而会被中间件的生命周期绑住手脚。
四、一张分层地图
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 场景 store | 扁平节点字典、根 id、脏集合、只读锁 | packages/core/src/store/use-scene.ts | 读任何场景数据、加载旧文件(migrateNodes 迁移全在这) |
zundo temporal 中间件 | 快照式撤销重做,50 步上限 | 同上文件末尾的 temporal(...) 配置 | 定义「一步」的粒度、决定哪些字段进历史 |
| 历史控制工具 | 暂停/恢复、合并成一步、提交广播 | packages/core/src/store/history-control.ts | 写任何连续交互(拖拽、笔刷、画墙) |
| 节点动作 | 真正的增删改、父子摘挂、数值夹取 | packages/core/src/store/actions/node-actions.ts | 加新节点类型、排查「改了没生效」 |
| 自动存盘订阅 | 1 秒防抖落盘、退出冲刷、误删守卫 | packages/editor/src/hooks/use-auto-save.ts | 接自己的后端保存、排查保存状态卡住 |
| localStorage 读写 | pascal-editor-scene 兜底存储 | packages/editor/src/lib/scene.ts | 无后端的纯前端跑法、清缓存前 |
| UI 偏好 persist | pascal-editor-ui-preferences | packages/editor/src/store/use-editor.tsx | 加一个需要记住的界面开关 |
| MCP 无头桥 | 在 Node 里驱动同一个 store | packages/mcp/src/bridge/scene-bridge.ts | 让 Agent 建模、写自定义 MCP 工具 |
| undo / redo 工具 | 把撤销重做暴露给 Agent | packages/mcp/src/tools/undo.ts、redo.ts | Agent 自己发现改错了要退回 |
五、Agent 的改动为什么能被撤销
关键在 SceneBridge 这个类的定位。它的注释写的是「headless bridge to the @pascal-app/core Zustand store」,并说明所有变更都流经真正的 core store,所以撤销重做经由 zundo 生效。它没有另起炉灶做一套服务端数据结构,而是把浏览器那个 store 原样搬进 Node 进程跑。
要做到这点需要垫一块东西:packages/mcp/src/bridge/node-shims.ts。core store 在 updateNodesAction 里用 requestAnimationFrame 批量做脏标记,撤销重做的订阅回调里也用了它,而这个订阅在模块导入时就注册。Node 里没有 requestAnimationFrame,所以这个 shim 用 setTimeout 顶上,并且注释强调它必须第一个被导入,否则 core 在导入阶段就会抛。
批量补丁那一段也值得看:SceneBridge 的补丁应用是先做一遍 dry-run 校验(id 不存在、删除有子孙却没传 cascade 都在这一步拒掉),通过后再按原顺序应用,并把相邻的同类操作攒成一批调 createNodes / updateNodes / deleteNodes——注释直接写明这么做是为了让 zundo 把它们归拢得紧一些。
undo 工具本身很薄,输入是可选的 steps,输出是 undone,工具描述写的是:Undo the most recent N steps in the scene history (default 1). Returns the number of steps actually undone. 桥里的实现是比对 pastStates 长度前后差值,所以返回的是实际撤销了几步,不是你要求了几步。撤完还会调 publishLiveSceneSnapshot,把结果存回场景并追加一条实时事件,让浏览器端的订阅者看到。
六、边界与代价:这个设计明确不管什么
- 历史只在内存里,刷新即清零。 50 步上限之外,还有一条更硬的:加载场景时
applySceneGraphToEditor会调clearSceneHistory。注释解释得很清楚——加载进来的场景是撤销的地板,加载过程本身也会产生历史条目,不清掉的话按几次 Ctrl+Z 就能退到加载前那个(往往是空的)状态,把整个项目抹掉。代价是:你撤销不到「上次打开之前」。 - 快照式历史,不是操作日志。 每一步存的是整份场景引用快照。好处是回滚逻辑简单到不会错,代价是内存占用随场景规模和步数线性走,也没法做「只撤销某个节点上的那一步」这种细粒度操作。
- 不是 CRDT,不做冲突合并。 这套东西解决的是单会话内的时间线,不是多人同时编辑。
applySceneSnapshot在交互进行中被调用会直接抛错(Cannot replace the scene snapshot during an active interaction),也就是说它宁愿失败也不合并。 - 宿主直接下发的补丁不进撤销栈。
applySceneOperationPatch在历史正在跟踪时会主动 pause,写完再 resume,改动只通过notifySceneCommit以origin: 'host'广播。想让宿主下发也可撤销,要自己在上层记录。 - 落盘会静默失败。
saveSceneToLocalStorage用 try/catch 吞掉配额异常。大场景加浏览器存储配额,是能悄无声息丢数据的组合。真上生产要接onSave走自己的后端。 - 数据落在你自己的机器上。 MCP 侧的场景存储是本地 SQLite。仓库文档写明默认写到
~/.pascal/data/pascal.db,并支持PASCAL_DATA_DIR、PASCAL_DB_PATH、PASCAL_MAX_SCENE_BYTES三个环境变量。编辑器和 MCP 服务器共享同一个数据目录,才能看到彼此的改动。 - 暴露端口意味着什么要想清楚。 MCP 的 HTTP transport 默认绑回环地址,绑非回环主机前要求
PASCAL_MCP_HTTP_TOKEN(或--auth-token);另有PASCAL_MCP_HTTP_ORIGINS控制来源。资产抓取这条出网路径由PASCAL_ALLOWED_ASSET_ORIGINS收窄。一旦你把它绑到 0.0.0.0 又没配 token,等于把「改这台机器上所有场景文件」的权限交给了同网段。 - Agent 的权限范围是整棵场景树。 它能建、能改、能删任意节点,能整图替换,能触发落盘。这不是「读一读给点建议」的集成,把它接进自动化流水线前,先把改动边界写成明确约定,参考 给 Agent 划改动边界的约定写法 和 MCP 的安全边界。
七、上手与避坑清单
一、连续交互不包 runAsSingleSceneHistoryStep,一次拖拽就吃光历史。
为什么会踩:拖手柄、刷地形、连续画墙每帧都在 updateNodes,每次 set 都是一条历史。50 步额度几秒钟就没了。怎么避:拖拽开始到松手这整段包进 runAsSingleSceneHistoryStep(useScene, () => {...}),仓库里上面那几处调用点可以直接抄(去掉测试文件,生产代码里的调用点有八处,写法基本一致)。
二、pause 忘了配对 resume,之后所有编辑都不进历史,而且没有任何报错。
为什么会踩:暂停深度是模块级计数器,异常路径提前 return 就漏了减一。怎么避:pause/resume 一律放 try/finally;clearSceneHistory 里那句无条件 resume() 就是为这个兜底的——注释写明,加载时若正处于暂停窗口,只重置计数不 resume 会让 store 永远停在 isTracking: false。
三、Agent 批量删完东西,保存直接报 error 而不是保存。
为什么会踩:use-auto-save.ts 有个防误删守卫,节点数从大于 4(STRUCTURAL_NODE_COUNT)掉到小于等于 4 就判定为「populated 场景被清成了空骨架」,拒绝写入并把状态置成 error:
export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
}
怎么避:看控制台有没有 [autosave] Blocked 开头的告警。这是保护不是 bug——真想清空,走正规的清场景路径让基线跟着更新。
四、把 undo 工具的返回值当成「一定撤销了 N 步」。
为什么会踩:历史可能不足 N 步,也可能刚被 clearSceneHistory 清过。怎么避:读返回里的 undone 字段再决定下一步,别照着自己请求的 steps 往下推。
五、在 Node 侧引 core store 却没先引 shim。
为什么会踩:core 的撤销订阅在模块导入时就注册,里面用到 requestAnimationFrame,Node 里没有,导入阶段直接抛。怎么避:任何间接加载 @pascal-app/core/store 的模块,第一行先 import './node-shims',顺序不能调。
六、以为改了 store 里的字段就会被存下来。
为什么会踩:自动存盘只看 nodes 的序列化结果和另外三项的引用是否变化。往 store 里加了新的文档级字段却没进这个判断,改动就是存不下去。怎么避:新增文档级状态时,同步改 useAutoSave 里的比对逻辑和 sceneHistorySnapshotFromState 的字段清单。
七、编辑器和 MCP 各跑各的数据目录,然后奇怪为什么 Agent 建的墙看不见。
为什么会踩:两侧默认都指向同一个默认路径,但只要有一边设了 PASCAL_DATA_DIR 或 PASCAL_DB_PATH,另一边没设,就分家了。怎么避:启动两侧时显式传同一个 PASCAL_DATA_DIR。
八、收尾
这套设计的可借鉴点不在 zundo 本身,而在两个判断:写入口只留一个,以及让 Agent 和人走同一个写入口。前者让撤销、落盘、广播这些横切关注点各自只需要接一处;后者让「Agent 改的东西能不能回退」这个问题在架构层就有了答案,不用靠外挂快照去补。
留三条自检,套到你自己的 Agent 集成上:Agent 的写路径和人的写路径是不是同一条?如果不是,你的回滚机制是不是要写两套?一次 Agent 操作在你的历史里算几步——它自己知道吗?
接着往下读的话,按这个顺序:先看 packages/core/src/store/history-control.ts(两百来行,是整套设计的浓缩),再看 packages/mcp/src/bridge/scene-bridge.ts 理解无头驱动怎么接,最后翻 wiki/architecture/ 下那 20 份架构文档(1 份 README 索引加 19 篇分主题),把单点认知拼成全局。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 3D 建筑编辑器 Pascal Editor 的节点模型为何是扁平字典 和 Pascal Editor 场景注册表:3D 建筑编辑器如何绕开树遍历。