开源浏览器端 3D 建筑编辑器 Pascal Editor:Agent 建的模型存在哪,本地 SQLite 与版本校验约定

2026-08-05

本文基于 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_jsonnodeCount,说的都是它。

节点类型:墙、门、窗、楼板这些具体构件,各自是一种节点类型。在 packages/nodes/src/ 下每种类型占一个目录,你在那个目录里 ls 一下,去掉 index.tsindex.test.ts 两个文件,剩下 46 个目录就是当前支持的节点类型,里面有 wall(墙)、door(门)、slab(楼板,也就是一层楼的那块水平承重板)、stair(楼梯)、roof(屋顶),也有 duct-segment(风管段)这类机电构件——机电指的是建筑里的暖通、给排水、电气这套管线系统,它跟墙板柱那些承重构件一样要在模型里表达出来。

楼层(level)level 也是一种节点类型,代表建筑的一层。它在 MCP 返回里出场很频繁——packages/mcp/src/tools/scene-lifecycle/metadata.ts 里有个 currentLevelContext,把场景里所有 level 节点按楼层高低排序,产出 levelIdsdefaultLevelId 两个字段。这组工具里 create_projectget_project_statussave_sceneload_scene 四个的返回里都带着它们(list_scenesdelete_scenerename_scene 不带),Agent 靠它知道“我现在该往哪一层放东西”。

概念说完,看这组工具本身。Pascal Editor 的 MCP 侧没有把“存取”混在建模工具里,而是单独切了一个 scene-lifecycle 目录。看 packages/mcp/src/tools/scene-lifecycle/index.ts 的注册函数就一目了然,registerSceneLifecycleTools 依次注册了七个工具:create_projectget_project_statussave_sceneload_scenelist_scenesdelete_scenerename_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 取回图并灌进 bridgepackages/mcp/src/tools/scene-lifecycle/load-scene.ts排查 scene_not_foundload_failed
SqliteSceneStore解析库路径、建表、事务、版本自增packages/mcp/src/storage/sqlite-scene-store.ts找数据库文件、看表结构、理解并发
存储接口与错误类型定义 SceneStore 及四个错误类packages/mcp/src/storage/types.ts想自己换一个后端,或对着错误码写重试
运行时驱动适配bun:sqlitenode:sqlite 之间挑一个packages/mcp/src/storage/sqlite-driver.ts启动报 SQLite requires Bun or a Node runtime
id 规整把任意字符串压成 slugpackages/mcp/src/storage/slug.ts传进去的 id 跟存下来的对不上
实时事件把改动写进事件流给浏览器订阅packages/mcp/src/tools/live-sync.ts网页没跟着 Agent 一起变

二、库文件到底在哪:四条规则按顺序算

sqlite-scene-store.ts 里有个导出函数 resolveDefaultDatabasePath,它的 doc 注释把优先级列得很清楚,代码也是照着这个顺序短路返回的:

  1. PASCAL_DB_PATH 有值,直接当成完整文件路径用;
  2. 否则看 PASCAL_DATA_DIR,取它下面的 pascal.db
  3. 否则在 Windows 上取 %APPDATA%/Pascal/data/pascal.dbAPPDATA 为空时退回 home 目录);
  4. 非 Windows 平台看 XDG_DATA_HOME,有就用 $XDG_DATA_HOME/pascal/data/pascal.db
  5. 都没有,落到 $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。

乐观锁靠 expectedVersionsave_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,带上 expectedVersionid 两个数据字段。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.saveModeopts.publish —— 本地 SQLite 后端每次保存都照样 version + 1 并写一条 revision,rowToMetapublished 恒为 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。

读路径不进事务。 loadlistlistSceneEvents 都是直接 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_scenethumbnail 入参是 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_Projectmyproject 会撞成同一个 id。避法:要么干脆不传 id,让 generateUniqueId 生成 12 位随机 slug(它会重试最多 20 次去避开已有 id);要么传之前先自己按同样规则规整一遍,再 list_scenes 确认没撞。

“存不进去”多半是缺 expectedVersion 传了已存在的 id 却没带版本号,会拿到 already exists 的报错而不是覆盖。避法:改已有场景之前先 get_project_statusversion,把它填进 expectedVersionsave_scene

“存好了但网页没变”多半是两边的库不是同一个。 编辑器进程和 MCP 进程各自算各自的默认路径,任一侧的 PASCAL_DB_PATHPASCAL_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_revisionsscene_events 兜底。

想继续往下读,顺序建议是 packages/mcp/src/storage/types.ts(先看清 SceneStore 接口和四个错误类,后面所有行为都是这份契约的展开),然后 packages/mcp/src/storage/sqlite-scene-store.tsmigratewithWriteTransaction 两个私有方法(表结构和事务边界),最后回到 packages/mcp/src/tools/scene-lifecycle/ 逐个看工具是怎么把存储层的异常翻译成 MCP 错误码的。这三步走完,Agent 建的模型去了哪、为什么冲突、怎么恢复,你就都能自己回答了。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的 MCP 工具全景:从建楼层到放家具浏览器里的开源 3D 建筑编辑器 Pascal Editor:MCP 模板层怎样把一句需求变成户型

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。