3D 建筑编辑器 Pascal Editor 的节点模型为何是扁平字典

2026-08-05

本文基于 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>)里,不是嵌套树;父子关系由 parentIdchildren 数组来定义。也就是说,一栋三层小楼的几百个节点,在内存里是几百个平铺的键值对,谁是谁的爹全靠字段说了算。

二、字段长什么样

所有节点共享同一个基类。这是 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 的数组,不是子节点对象的数组——这正是「扁平」二字的落点。墙本身只记两个二维端点 startend,厚度高度是可选字段。

所有节点类型最后汇进 packages/core/src/schema/types.ts 里的 AnyNode,它是一个以 type 为判别键的 z.discriminatedUnion。数一下那个数组的成员,从 SiteNodePipeTrapNode 一共 46 个。里头除了墙板门窗,还有屋面构件、风管、水管、太阳能板这些偏工程的类型。packages/nodes/src/ 下也是 46 个子目录,但两个 46 不是同一件事:那边有一个 shared/ 放公共代码,而 CabinetModuleNode 没有独立目录、和 cabinet/ 合在一起。想核对类型有没有落地实现,只能逐个目录看,不能拿数字对数字。

挂在 WallNode 定义末尾的那段 .describe() 文本也值得留意:它逐字段写了「thickness 是米、curveOffset 是把墙弯成弧的中点矢高」这类说明。这段文字会跟着 schema 一起走,人和模型读的是同一份。

三、父子关系有两处真相

parentId 记一遍,父节点的 children 数组再记一遍——同一件事写在两个地方,就会有对不上的时候。这不是猜测,仓库里为此写了一大堆兜底代码。

packages/mcp/src/bridge/scene-bridge.tsgetChildren 方法,注释开门见山说「用了三条兜底路径,因为这个代码库的父子追踪并不统一」:先扫全字典找 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.tsslab.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.tsAgent 查不到子节点、删不动节点
apply_patch 工具批量增删改的原子提交packages/mcp/src/tools/apply-patch.tsAgent 一次改多处
本地场景库场景与事件落盘、数据库路径解析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 返回单个节点的 parentIdancestryIdschildrenIdsproperties 和一句人读的描述——墙会被描述成「从 (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_collisionsverify_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_nodechildrenIds,确认要连带删的东西,再传参数。

别用自己写的遍历去推父子。 会踩是因为你测试时用的场景恰好都是经过 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.tspackages/mcp/src/tools/ 目录。三处读完,你对这个场景图能不能被 Agent 安全改动,就有自己的判断了。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 仓库导读:3D 建筑编辑器怎样切开渲染层与编辑层开源浏览器 3D 建筑编辑器 Pascal Editor:场景状态的撤销层与落盘层怎么拆

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