3D 建筑编辑器 Pascal Editor 的节点模型为何是扁平字典
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
把整个三维建筑场景存成一个扁平字典、而不是一棵嵌套树,是这个项目为「Agent 能不能可靠地改它」预先交的定金。 嵌套树对人写的交互代码友好,但对一个只能靠 id 说话的模型很不友好:模型要改一堵墙,就得先在树里找到它的路径,再把那一段结构原样吐回来。扁平字典把这件事压成了「给我 id、给我要改的那几个字段」。代价也很实在,下面会逐条摊开。
先做个消歧:这里说的 Pascal Editor,是一个跑在浏览器里的开源 3D 建筑编辑器(仓库 pascalorg/editor,MIT 许可,Copyright 2026 Pascal Group Inc.),和 Pascal 编程语言、和压强单位帕斯卡都没有关系。README 第一句就是这么定位自己的:「一个用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器」。仓库里另外带了一个 MCP 服务器包,让 AI Agent 可以直接读写场景。
一、这块在解决什么问题
在三维编辑器里,「场景」就是屏幕上那一整个世界的数据描述。Pascal Editor 把场景里的每一样东西都叫作节点(node):一块地、一栋楼、一个楼层、一堵墙、一扇门、一块楼板,都是节点。
有几个建筑侧的名词先说清楚,后面才跟得上:
- 楼板(slab):楼层的水平承重面,你脚下踩的那一层。在这个项目里它是一个多边形,schema 里就是
polygon(一串[x, z]点)加上holes(挖洞用的多边形数组,比如楼梯井)。 - 楼层(level):一栋建筑里的一层,是墙、板、天花板的挂载点。
- 净空(clearance):门前后必须空出来、不许摆家具的通行空间,用来保证门能开、人能过。这个项目在 MCP 侧有一个专门查门净空的工具,源码里的默认深度常量
DEFAULT_DOOR_CLEAR_DEPTH是 0.65(米),注释写明两个面都要留,这样门朝哪边开都有余量。 - 实体布尔运算(CSG):用一个形状去减另一个形状。墙上开门窗洞就是这么来的——墙体几何减掉门洞的方块。
README 里给出的节点层级是这样一条链:Site(场地)→ Building(建筑)→ Level(楼层)→ 底下挂 Wall、Slab、Ceiling、Roof、Zone、Scan、Guide,墙和天花板底下再挂 Item(门、窗、灯具这类)。
层级是层级,存储不是。README 这句话写得很直白:节点存放在一个扁平字典(Record<id, Node>)里,不是嵌套树;父子关系由 parentId 和 children 数组来定义。也就是说,一栋三层小楼的几百个节点,在内存里是几百个平铺的键值对,谁是谁的爹全靠字段说了算。
二、字段长什么样
所有节点共享同一个基类。这是 packages/core/src/schema/base.ts 里的原文:
export const BaseNode = z.object({
object: z.literal('node').default('node'),
id: z.string(),
type: nodeType('node'),
name: z.string().optional(),
parentId: z.string().nullable().default(null),
visible: z.boolean().optional().default(true),
camera: CameraSchema.optional(),
metadata: z.json().optional().default({}),
})
同一个文件里还有两个小工具函数,它们决定了 id 的样子:
const customId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
export const generateId = <T extends string>(prefix: T): `${T}_${string}` =>
`${prefix}_${customId()}` as `${T}_${string}`
于是墙的 id 长成 wall_ 加十六位小写字母数字。这个细节对 Agent 特别友好:id 自带类型前缀,模型拿到一串 id 也能一眼分辨类型,不必再回查一遍字典。nodeType('wall') 则把 type 字段钉成字面量,作为判别式。
具体节点在 packages/core/src/schema/nodes/ 下各占一个文件,用 BaseNode.extend 往上加几何字段。墙的核心几行在 wall.ts:
export const WallNode = BaseNode.extend({
id: objectId('wall'),
type: nodeType('wall'),
children: z
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
.default([]),
...
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
注意 children 装的是 id 的数组,不是子节点对象的数组——这正是「扁平」二字的落点。墙本身只记两个二维端点 start 和 end,厚度高度是可选字段。
所有节点类型最后汇进 packages/core/src/schema/types.ts 里的 AnyNode,它是一个以 type 为判别键的 z.discriminatedUnion。数一下那个数组的成员,从 SiteNode 到 PipeTrapNode 一共 46 个。里头除了墙板门窗,还有屋面构件、风管、水管、太阳能板这些偏工程的类型。packages/nodes/src/ 下也是 46 个子目录,但两个 46 不是同一件事:那边有一个 shared/ 放公共代码,而 CabinetModuleNode 没有独立目录、和 cabinet/ 合在一起。想核对类型有没有落地实现,只能逐个目录看,不能拿数字对数字。
挂在 WallNode 定义末尾的那段 .describe() 文本也值得留意:它逐字段写了「thickness 是米、curveOffset 是把墙弯成弧的中点矢高」这类说明。这段文字会跟着 schema 一起走,人和模型读的是同一份。
三、父子关系有两处真相
parentId 记一遍,父节点的 children 数组再记一遍——同一件事写在两个地方,就会有对不上的时候。这不是猜测,仓库里为此写了一大堆兜底代码。
packages/mcp/src/bridge/scene-bridge.ts 的 getChildren 方法,注释开门见山说「用了三条兜底路径,因为这个代码库的父子追踪并不统一」:先扫全字典找 parentId 等于目标的节点;再读父节点自己的 children(字符串 id 形式);最后还要处理 children 里装着节点对象的历史遗留形状。注释里还点了一个很要命的场景:它记着「默认场景的装配路径绕过了 store 的写入方法,导致默认的 site/building/level 那棵树上每个节点的 parentId 都是 null,层级只体现在 children 数组里」。这句话值得你自己回去核一遍——use-scene.ts 里现在的 loadScene() 已经显式给 building 和 level 写了 parentId,那段代码的注释还专门解释了为什么必须显式写:schema 把 parentId 默认成 null,而渲染器顺着 children 照样能走通,于是父子不对称可以一路潜伏不被发现。换句话说,桥里那条注释描述的是修复前的状态,兜底逻辑却没有撤——它防的是历史存档,以及任何别的绕过 store 写入路径拼出来的场景。往上走的 getAncestry 同样在 parentId 断链时反向扫 children。注释比代码老,是读开源项目时的常态;把注释当结论抄,是这类文章最容易出的错。
存储层也在持续做清理。packages/core/src/store/use-scene.ts 里那套加载期迁移逻辑,处理的都是这类账:parentId 指向不存在节点的孤儿要删掉;楼层的 children 要做一次归一化,把指向不存在节点的 id 过滤出去;曾经把嵌套 BuildingNode 对象塞进 site.children 的旧场景,要拍平成 id 再吸收进扁平字典。
这对你意味着一件事:别自己从字典里推父子关系。你以为遍历一遍 parentId 就够了,碰上一份 parentId 整片缺失的旧存档,直接扫出零个子节点。走 MCP 暴露的查询工具,让那三条兜底路径替你兜。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
BaseNode 公共字段 | id、type、parentId、visible、metadata 的定义与默认值 | packages/core/src/schema/base.ts | 读懂任何一个节点的 JSON 时 |
| 单个节点 schema | 墙的端点厚度、板的多边形与洞口等几何字段 | packages/core/src/schema/nodes/wall.ts、slab.ts | 拼 create 操作、查字段名 |
AnyNode 判别联合 | 把 46 个节点类型合成一个可校验的联合 | packages/core/src/schema/types.ts | 新增类型、或校验报「未知 type」 |
| 场景 store | 扁平字典、根节点 id、脏节点集合、增删改与加载期迁移 | packages/core/src/store/use-scene.ts | 老场景打不开、节点莫名消失 |
| MCP 场景桥 | 父子解析的三条兜底、祖先链、级联删除判断 | packages/mcp/src/bridge/scene-bridge.ts | Agent 查不到子节点、删不动节点 |
apply_patch 工具 | 批量增删改的原子提交 | packages/mcp/src/tools/apply-patch.ts | Agent 一次改多处 |
| 本地场景库 | 场景与事件落盘、数据库路径解析 | packages/mcp/src/storage/sqlite-scene-store.ts | 关心数据存在哪台机器上 |
顺带一提,README 的架构章节只列了四个主要运行时包,而 packages/ 目录下实际有 9 个子目录(MCP 服务器、IFC 转换器、共享 UI、两份共享配置都不在那张表里);apps/ 下有 2 个应用。全仓受版本控制的文件是 2508 个,wiki/architecture/ 下有 20 份架构文档。这些数字你 ls 一下就能自己复现。
四、扁平字典换来了什么
从 Agent 这一侧看,收益很集中。
get_scene 工具的自述是「返回完整场景图:扁平节点字典、根节点 id、以及集合」。模型拿到的就是一坨平铺的 JSON,不需要在嵌套结构里递归。
写入走 apply_patch。它的三种操作是 create、update、delete,工具描述写明:整批先全部校验、通过后才逐条应用,整批构成一个 undo 步骤。update 传的是 data,语义是浅合并——架构文档里给的例子就是 updateNode(wall.id, { height: 2.8 }),只改高度,其它字段原样保留。
这三点叠起来才是关键:定位靠 id,改动是部分字段,提交是原子的。模型不用重发整棵树,输出 token 少了一大截,两个 Agent 同时动同一栋楼时的冲突面也小得多——它们只有落在同一个节点的同一个字段上才真正打架。这跟你在别处见过的「让模型吐一整个结构化对象、然后祈祷它别漏字段」是两种路数:站内 让 AI 参与数据库结构设计 讲的是从零设计一套表结构,结构化输出不稳定怎么办 讲的是模型吐 JSON 本身不靠谱怎么兜,抽象出好用的模型接口层 讲的是自己造一层抽象;本篇是第四种处境——schema 是别人定死的,你只能顺着它的字段和校验规则去改,能不能改对,取决于你有多懂它的数据模型。
改完还有两件事可做:validate_scene 对场景里每个节点跑一遍 Zod 校验,返回 { nodeId, path, message } 三元组,出错时能直接定位到某个节点的某个字段;describe_node 返回单个节点的 parentId、ancestryIds、childrenIds、properties 和一句人读的描述——墙会被描述成「从 (x1,z1) 到 (x2,z2),厚 0.10 米,高 2.80 米」这种句子。
五、边界与代价
引用完整性没人替你保证。 扁平字典不是关系数据库,没有外键约束。children 里留一个已删除节点的 id,系统不会当场报错,得靠加载期的迁移代码去过滤。上面那些孤儿清理逻辑,本质上都是在还这笔债。
删除必须自己表态。 MCP 桥的 deleteNode 在发现节点有后代、而调用方没传 cascade: true 时会直接抛错,错误文案是「node has N descendant(s); pass cascade: true to delete recursively」。这是刻意的:扁平字典里删一个楼层,视觉上只是少一个键,实际会带走底下所有墙板门窗。
它不管几何。 节点只是数据。真正生成形状的是运行在渲染循环里的 systems:store 把改动过的节点标记为「脏节点」,WallSystem 之类的系统每帧挑出脏节点重算几何,墙的转角斜接、门窗洞的布尔运算都在那一层(README 的技术栈里列了 three-bvh-csg 做布尔运算)。改对了字段不等于看到对的形状,中间隔着一整套几何生成。
它不管空间可行性。 两个家具重叠、门开不开得了,字典层面完全合法。这类判断在别的地方:项目有一个空间网格管理器负责放置校验和碰撞检测,MCP 侧则有 check_collisions、verify_scene 这类工具。
schema 演进有明确代价。 架构文档把规则写死了:加字段必须给 .default() 或 .optional(),否则每一个已存在的旧场景都会校验失败;改名、删除、改类型光给默认值不够,会静默丢数据,必须去加载期迁移里补一条把旧形状读出来重写成新形状。文档还注明,节点定义上的 schemaVersion 目前只用于记录形状变了,按类型分的迁移映射是留给将来的,今天所有加载期迁移都集中在一处。
本地跑起来意味着什么,要看清楚。 MCP 服务器跑在你自己机器上,场景落在一个本地 SQLite 文件里,路径优先取环境变量 PASCAL_DB_PATH,其次是 PASCAL_DATA_DIR 下的 pascal.db,两个都没配才按平台猜——Windows 落在 %APPDATA%/Pascal/data/ 下,其余系统看 $XDG_DATA_HOME,兜底是 $HOME/.pascal/data/pascal.db。HTTP 传输默认绑 127.0.0.1,代码里有一条硬约束:绑到非回环地址就必须提供 PASCAL_MCP_HTTP_TOKEN,否则直接拒绝启动。抓取外部图片的那条链路单独做了防护,代码注释里记着一次内部排查的结论——几个图片相关的工具原先直接 fetch(url),等于在任何主机上都开了一个指向云元数据地址的外带口子,现在统一走带允许列表的封装,列表由 PASCAL_ALLOWED_ASSET_ORIGINS 配。权限边界也要认清:接了这个服务器的 Agent,有能力创建、修改、级联删除场景里的任何节点。
六、上手与避坑清单
别手写 id。 会踩是因为 id 看着就是普通字符串,随手编一个也能塞进字典;但前缀是类型约定的一部分,编错了后续按前缀分辨类型的地方就全乱。做法是永远用 schema 的 .parse(),架构文档的原话是「总是用 .parse()——它会生成正确的 id 前缀并填好默认值」。
新增字段一律带默认值。 会踩是因为本地测试用的都是新建场景,字段缺失根本暴露不出来;一上线,几个月前存的场景全部解析失败。改完 schema 拿一个旧场景或固定样例加载一次,确认还能解析、还能渲染。
删除带子节点的东西,显式传 cascade: true。 会踩是因为不传时报的是抛错而不是静默删除,Agent 容易把这个错误当成「工具坏了」然后换别的路子绕,绕出来的往往是残缺场景。正确做法是先 describe_node 看 childrenIds,确认要连带删的东西,再传参数。
别用自己写的遍历去推父子。 会踩是因为你测试时用的场景恰好都是经过 store 写入的,parentId 齐全;换成一份历史存档、或者任何绕过 store 写入路径拼出来的场景,parentId 就可能整片是 null。用 MCP 提供的查询工具,让那三条兜底逻辑生效。
一次改动就发一次 apply_patch。 会踩是因为把十个改动拆成十次调用看起来更好调试,但那样就有十个 undo 步骤、十个中间态,任何一步失败都留下半成品。批量提交是原子的,回滚也是一次。
改完立刻 validate_scene。 会踩是因为节点写进字典时不一定当场报错,问题要等到渲染或下次加载才显形。校验返回里的 path 字段能直接告诉你是哪个字段不合法。
暴露端口前先想清楚。 会踩是因为「远程连一下更方便」,一改绑定地址就把一个能删你全部模型的接口放到了网络上。默认回环是有意的;真要改,token 那一条不是可选项。
知道数据在哪。 会踩是因为默认路径藏在用户目录下,做备份、换机器、清环境时容易漏。先把 PASCAL_DB_PATH 显式配到你自己管得住的位置。
顺带说一句,仓库根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件——这个项目对「代码要被模型读」这件事是有准备的,接手前值得先扫一眼这三份里的约定,参考 写好 CLAUDE.md 的方法。若你打算自己给这类项目补 MCP 接口,MCP Server 开发入门 可以当作起点。
收个尾
判断一个开源建模工具值不值得接 Agent,看它的数据模型比看它的工具列表更有效。扁平字典加字段化父子关系,换来的是「按 id 定位、按字段增量修改、按批次原子提交」,这三件事凑齐了,模型才谈得上可靠地改它。至于扁平化欠下的引用完整性和几何一致性,这个项目的还法是加载期迁移加上多条兜底路径,都写在明处,你能读到、也能预判。
接下来该读哪个文件,按你的目的来:想搞清楚字段怎么加,读 wiki/architecture/node-schemas.md;想搞清楚旧场景怎么被修补,读 packages/core/src/store/use-scene.ts 的加载期迁移部分;想搞清楚 Agent 那一侧到底能拿到什么,读 packages/mcp/src/bridge/scene-bridge.ts 和 packages/mcp/src/tools/ 目录。三处读完,你对这个场景图能不能被 Agent 安全改动,就有自己的判断了。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 仓库导读:3D 建筑编辑器怎样切开渲染层与编辑层 和 开源浏览器 3D 建筑编辑器 Pascal Editor:场景状态的撤销层与落盘层怎么拆。