开源浏览器端 3D 建筑编辑器 Pascal Editor:Agent 建的模型存在哪,本地 SQLite 与版本校验约定
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
Agent 通过 MCP 建出来的三维模型,不在云端、不在内存、也不在你项目目录里,而是落在本机 home 目录下一个叫 pascal.db 的 SQLite 文件里;这个路径由四条环境变量规则按优先级算出来,你要是没搞清这条链路,最典型的症状就是“Agent 说存好了,但编辑器网页里什么都没有”。
先做个消歧:这里说的 Pascal Editor,是 pascalorg/editor 这个跑在浏览器里的开源 3D 建筑编辑器(MIT 许可,Copyright 2026 Pascal Group Inc.),跟 Pascal 编程语言、跟压强单位帕斯卡都没有关系。它的特别之处在于自带一套 MCP 服务器,让 AI Agent 能直接调工具去建墙、开门、布家具。仓库 README 这样定位这个服务器:它在 Bun 里无头运行,不需要浏览器、WebGPU、React,也不需要外部数据库服务。
站内几篇相邻的文章各管一段:Agent 自己的对话历史怎么落盘,看 pi 的会话存储机制;想要一套通用的表结构设计方法论,看 用 AI 做数据库设计;多个 Agent 抢同一个工作目录该怎么隔离,看 Agent 工作区隔离。这篇只管一件事——Pascal Editor 的三维场景从 MCP 工具到磁盘这一段。
一、先说清几个概念,再看这组工具怎么切的
后面绕不开三个词,先各用一句话钉死。
场景图(scene graph):整个建筑模型在内存里的表示,是一堆带 id 的节点加上一份根节点列表。仓库里它的形状被一个 zod schema 卡着,只允许三个字段:nodes(id 到节点的映射)、rootNodeIds(根节点 id 数组)、可选的 collections。你之后看到的 graph_json、nodeCount,说的都是它。
节点类型:墙、门、窗、楼板这些具体构件,各自是一种节点类型。在 packages/nodes/src/ 下每种类型占一个目录,你在那个目录里 ls 一下,去掉 index.ts 和 index.test.ts 两个文件,剩下 46 个目录就是当前支持的节点类型,里面有 wall(墙)、door(门)、slab(楼板,也就是一层楼的那块水平承重板)、stair(楼梯)、roof(屋顶),也有 duct-segment(风管段)这类机电构件——机电指的是建筑里的暖通、给排水、电气这套管线系统,它跟墙板柱那些承重构件一样要在模型里表达出来。
楼层(level):level 也是一种节点类型,代表建筑的一层。它在 MCP 返回里出场很频繁——packages/mcp/src/tools/scene-lifecycle/metadata.ts 里有个 currentLevelContext,把场景里所有 level 节点按楼层高低排序,产出 levelIds 和 defaultLevelId 两个字段。这组工具里 create_project、get_project_status、save_scene、load_scene 四个的返回里都带着它们(list_scenes、delete_scene、rename_scene 不带),Agent 靠它知道“我现在该往哪一层放东西”。
概念说完,看这组工具本身。Pascal Editor 的 MCP 侧没有把“存取”混在建模工具里,而是单独切了一个 scene-lifecycle 目录。看 packages/mcp/src/tools/scene-lifecycle/index.ts 的注册函数就一目了然,registerSceneLifecycleTools 依次注册了七个工具:create_project、get_project_status、save_scene、load_scene、list_scenes、delete_scene、rename_scene。
这个文件的注释还交代了为什么要这么切:所有工具都打在同一份 scene operations 上,让 MCP、REST 以及未来的 CLI 入口共用同一个存储边界。翻译成人话——存取逻辑只有一份,谁进来都走同一道门。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 生命周期工具注册 | 把七个工具挂到 MCP server 上 | packages/mcp/src/tools/scene-lifecycle/index.ts | 想知道 Agent 到底能对你的模型库做哪几件事 |
save_scene | 入参校验、把图交给存储层、翻译版本冲突 | packages/mcp/src/tools/scene-lifecycle/save-scene.ts | 排查“存不进去”和 version_conflict |
load_scene | 按 id 取回图并灌进 bridge | packages/mcp/src/tools/scene-lifecycle/load-scene.ts | 排查 scene_not_found、load_failed |
SqliteSceneStore | 解析库路径、建表、事务、版本自增 | packages/mcp/src/storage/sqlite-scene-store.ts | 找数据库文件、看表结构、理解并发 |
| 存储接口与错误类型 | 定义 SceneStore 及四个错误类 | packages/mcp/src/storage/types.ts | 想自己换一个后端,或对着错误码写重试 |
| 运行时驱动适配 | 在 bun:sqlite 和 node:sqlite 之间挑一个 | packages/mcp/src/storage/sqlite-driver.ts | 启动报 SQLite requires Bun or a Node runtime |
| id 规整 | 把任意字符串压成 slug | packages/mcp/src/storage/slug.ts | 传进去的 id 跟存下来的对不上 |
| 实时事件 | 把改动写进事件流给浏览器订阅 | packages/mcp/src/tools/live-sync.ts | 网页没跟着 Agent 一起变 |
二、库文件到底在哪:四条规则按顺序算
sqlite-scene-store.ts 里有个导出函数 resolveDefaultDatabasePath,它的 doc 注释把优先级列得很清楚,代码也是照着这个顺序短路返回的:
PASCAL_DB_PATH有值,直接当成完整文件路径用;- 否则看
PASCAL_DATA_DIR,取它下面的pascal.db; - 否则在 Windows 上取
%APPDATA%/Pascal/data/pascal.db(APPDATA为空时退回 home 目录); - 非 Windows 平台看
XDG_DATA_HOME,有就用$XDG_DATA_HOME/pascal/data/pascal.db; - 都没有,落到
$HOME/.pascal/data/pascal.db。
构造函数里还包了一层 path.resolve,所以你传相对路径进来它会按进程 cwd 展开——这是个隐藏的坑,MCP 服务器的 cwd 由 host 决定,不见得是你以为的那个目录。目录不存在也不用你手动建,database() 里会先 mkdirSync(path.dirname(this.databasePath), { recursive: true }) 再开库。
驱动是运行时挑的:sqlite-driver.ts 先看全局有没有 Bun,有就 import('bun:sqlite');没有就试 import('node:sqlite'),两条路都不通才抛错。这意味着 Node 版本太老、没有内置 node:sqlite 的环境,它是起不来的。
库开出来之后立刻执行三条 pragma,这三行决定了后面并发行为的全部底色:
db.exec('PRAGMA foreign_keys = ON')
db.exec('PRAGMA journal_mode = WAL')
db.exec('PRAGMA busy_timeout = 5000')
建表是 CREATE TABLE IF NOT EXISTS,一共三张:scenes 存当前状态(含完整 graph_json)、scene_revisions 存每个版本的快照、scene_events 存自增 id 的事件流。后两张表都对 scenes(id) 加了 ON DELETE CASCADE 外键,配合上面那条 foreign_keys = ON,删场景是连着历史一起删的。
三、版本号怎么涨,冲突怎么来
scenes 表的 version 字段带 CHECK (version >= 1),新建时从 0 加 1 变成 1,之后每次 save 都是 (existing?.version ?? 0) + 1。同一次事务里还会往 scene_revisions 插一行,author_kind 写死是 'mcp'。rename 也算一次版本变更——它同样把 version + 1,并把原来的 graph_json 原样再存一份 revision。
乐观锁靠 expectedVersion。save_scene 的入参里它是可选的正整数,落到存储层是这么判的:
if (opts.expectedVersion !== undefined) {
const currentVersion = existing?.version ?? 0
if (currentVersion !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`,
)
}
}
这个异常在 save-scene.ts 里被 catch 住,翻成 MCP 错误 version_conflict,带上 expectedVersion 和 id 两个数据字段。delete_scene 也接同一个参数,冲突时同样返回 version_conflict。
还有一条容易忽略的保护:如果你显式传了 id、这个 id 在库里已存在、但你没传 expectedVersion,存储层不会闷头覆盖,而是直接抛 SceneInvalidError,消息是让你换个 id 或者补上 expectedVersion。这条规则挡住的正是 Agent 最容易犯的错——拍脑袋编一个好记的 id,把别人的模型盖掉。
写路径全部包在 withWriteTransaction 里,它开的是 BEGIN IMMEDIATE 而不是普通 BEGIN,出错时 ROLLBACK(且吞掉回滚本身的异常,好让原始错误往上冒)。BEGIN IMMEDIATE 的意思是事务一开始就抢写锁,而不是等到第一条写语句才抢,这样“读版本号—比对—写入”三步不会被别的进程插进来。这也是 busy_timeout = 5000 存在的理由:抢不到锁的那一方会等一会儿再重试,而不是立刻炸给你看。
有一个行为差异值得单独点出来:save_scene 的入参里有 saveMode(枚举 draft / checkpoint,默认 draft)和 publish,工具描述里说 draft 用来让 Agent 反复迭代而不污染版本历史。但你去看 SqliteSceneStore.save 的实现,它压根没读 opts.saveMode 和 opts.publish —— 本地 SQLite 后端每次保存都照样 version + 1 并写一条 revision,rowToMeta 里 published 恒为 true。换句话说,草稿与检查点的区分在这个后端上是不落地的。SceneStore 接口里 backend 的类型写着 'sqlite' | 'supabase',说明这套语义是留给另一种后端的。你按草稿模式狂刷,本地库照样一版一版地涨。
四、多进程共用一个库时的约定
README 里给的开发姿势是两个终端各起一个进程,但显式指定同一个 PASCAL_DATA_DIR:一个跑编辑器,一个跑 MCP 服务器。共用之后的约定有这么几条。
共库靠环境变量对齐,不靠约定俗成。 MCP host 的配置文件(Claude Desktop、Codex 等)里都要在 env 段落显式写上 PASCAL_DATA_DIR,README 的每份示例配置都带着它。原因很实在:图形界面启动的 MCP host 继承的环境变量往往跟你终端里的不是一套,你在 shell 里 export 过的值它不见得看得到。
改动通过 scene_events 表往浏览器推。 live-sync.ts 里的 publishLiveSceneSnapshot 会先把当前图存进去,再调 appendSceneEvent 追加一条事件。README 说编辑器页面通过 /api/scenes/:id/events 以 server-sent events 订阅这个流,所以开着的网页能跟着 Agent 一起变。注意这个函数开头有个短路:当前 MCP 会话没绑定到已保存场景时,它直接返回什么都不做——Agent 在一个没保存过的场景上折腾,网页是不会有任何动静的。
冲突时谁先写谁赢,后来者负责重新加载。 publishLiveSceneSnapshot 保存时带的 expectedVersion 是它记着的 active scene 版本号,一旦浏览器或者另一个 MCP 进程先写了新版本,这里就抛 live_sync_version_conflict。README 给的处置办法只有一条:用 load_scene 重新拉一遍再继续。这套设计里没有自动合并,也没有三方 diff。
读路径不进事务。 load、list、listSceneEvents 都是直接 await this.database() 之后查,靠 WAL 模式保证读不被写阻塞。代价是你 list_scenes 拿到的列表可能在你处理的下一毫秒就过期了,真要以某个版本为准,得回头 get_project_status 确认。
五、边界与代价
这套设计明确放弃了一些东西,用之前你得知道。
它不是多人协作后端。 事务和版本校验保的是“同一台机器上多个进程别互相踩”,不是分布式一致性。库文件是本地文件,两台机器各存各的,没有同步机制。
没有清理策略。 每保存一次就往 scene_revisions 插一份完整的 graph_json,每触发一次实时同步就往 scene_events 插一份完整快照。仓库里我没找到任何裁剪、归档或者按数量截断的逻辑,listSceneEvents 只提供了 afterEventId 游标用来增量读。一个 Agent 跑一晚上密集建模,这个库会长到什么体量,你得自己盯着。
删除是硬删除。 delete 走的是 DELETE FROM scenes WHERE id = ?,加上外键级联,revisions 和 events 一起没。没有回收站,没有软删标记。Agent 手里握着 delete_scene 这个工具,你给它开这个 MCP 服务器就等于给了它删本地模型库的权限——这层账要提前算清楚,思路可以参考 MCP 的安全边界怎么划。
单条场景有体积上限。 序列化后的 UTF-8 字节数超过上限会抛 SceneTooLargeError,上限由构造参数或 PASCAL_MAX_SCENE_BYTES 决定,默认值写在 sqlite-scene-store.ts 顶部的 DEFAULT_MAX_SCENE_BYTES 常量里。这是整图 JSON 的大小,跟节点数不是线性关系。
数据是明文的。 graph_json 就是 JSON 文本躺在 SQLite 文件里,没有加密。库文件在你 home 目录下,任何能读你用户目录的进程都能拿到全部模型内容。
出网面要单独看。 save_scene 的 thumbnail 入参是 z.string().url(),这是个外部 URL。includeCurrentScene: false 那条分支里有一段安全性注释写得很明白:直接传 graph 会绕过资产 URL 加固,所以那里对每个节点都用 AnyNode schema 重新 safeParse 一遍,把错误按 nodeId / path / message 收集齐了再一次性抛 graph_invalid。这段校验是必要的,别为了图快去绕开它。另外 HTTP 传输模式下绑定非回环地址需要 PASCAL_MCP_HTTP_TOKEN,这条门槛也别拆。
六、上手与避坑清单
id 会被悄悄改写,不是你传什么就是什么。 sanitizeSlug 会转小写、空格换连字符、剥掉 [a-z0-9-] 之外的所有字符、折叠连续连字符、截到 64 字符。也就是说 My_Project 和 myproject 会撞成同一个 id。避法:要么干脆不传 id,让 generateUniqueId 生成 12 位随机 slug(它会重试最多 20 次去避开已有 id);要么传之前先自己按同样规则规整一遍,再 list_scenes 确认没撞。
“存不进去”多半是缺 expectedVersion。 传了已存在的 id 却没带版本号,会拿到 already exists 的报错而不是覆盖。避法:改已有场景之前先 get_project_status 拿 version,把它填进 expectedVersion 再 save_scene。
“存好了但网页没变”多半是两边的库不是同一个。 编辑器进程和 MCP 进程各自算各自的默认路径,任一侧的 PASCAL_DB_PATH 或 PASCAL_DATA_DIR 不一致,就会各写各的文件而且都不报错。避法:两侧都显式设同一个 PASCAL_DATA_DIR,然后去那个目录看 pascal.db 的修改时间到底动没动。
Windows 上的默认路径跟你想的不一样。 它走的是 %APPDATA%/Pascal/data/pascal.db,不是 ~/.pascal。在 Windows 上照着 macOS 文档去 home 目录找库,会得出“根本没存”的错误结论。
相对路径按进程 cwd 展开。 databasePath 过了 path.resolve,而 MCP 服务器的 cwd 由 host 进程决定。避法:配置里一律写绝对路径。
别指望 saveMode: draft 能少涨版本。 前面说过,本地 SQLite 后端不读这个参数。避法:如果你在意历史条数,就控制 save_scene 的调用频率本身,而不是靠切换模式;同时给日志加上版本号打印,跑完一轮回头数一数涨了多少。
版本冲突不要盲目重试。 version_conflict 说明有人在你之前写过,直接改 expectedVersion 再存等于把对方的改动盖掉。避法:先 load_scene 拉回最新的,确认对方改了什么,再决定继续还是放弃。这跟 Agent 的失败重试策略是同一类问题——冲突类错误和超时类错误的处置方式不能共用一套。
运行时不匹配会在开库那一刻才炸。 openSqliteDatabase 是在第一次真正用到数据库时才调的,所以 MCP 服务器可能已经“启动成功”了,直到 Agent 第一次 save_scene 才蹦出 SQLite requires Bun or a Node runtime with node:sqlite support。避法:接上 MCP 之后先手动跑一次 list_scenes 做冒烟测试。
收个尾
把这套东西装到自己机器上之前,过一遍这四项:库文件的绝对路径你能报得出来吗;编辑器和 MCP 两侧算出来的是不是同一个路径;你能接受 Agent 拥有 delete_scene 的权限吗;你打算怎么给 scene_revisions 和 scene_events 兜底。
想继续往下读,顺序建议是 packages/mcp/src/storage/types.ts(先看清 SceneStore 接口和四个错误类,后面所有行为都是这份契约的展开),然后 packages/mcp/src/storage/sqlite-scene-store.ts 的 migrate 和 withWriteTransaction 两个私有方法(表结构和事务边界),最后回到 packages/mcp/src/tools/scene-lifecycle/ 逐个看工具是怎么把存储层的异常翻译成 MCP 错误码的。这三步走完,Agent 建的模型去了哪、为什么冲突、怎么恢复,你就都能自己回答了。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的 MCP 工具全景:从建楼层到放家具 和 浏览器里的开源 3D 建筑编辑器 Pascal Editor:MCP 模板层怎样把一句需求变成户型。