Pascal Editor 开源 3D 建筑编辑器:新增构件只写一份节点定义
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
这套契约里最值得抄走的一条判断是:它没有为「这个构件属于哪一类」设任何标签位,而是让「你填了哪个字段」直接等于「你参与哪条链路」。 墙、门、货架三种东西差异极大,但它们的定义文件长得一模一样——都是一个纯数据对象,区别只在于哪些可选字段被填上了。仓库的架构文档把这叫做「三选框模型」,并且写死了一句话:The three fields are independent. There is no discriminator tag — presence is participation(这三个字段互相独立,没有判别标签,填了就是参与了)。
先做个消歧:这里说的 Pascal Editor 是一个开源的三维建筑编辑器项目(仓库 https://github.com/pascalorg/editor ,MIT 许可,Copyright 2026 Pascal Group Inc.),跟 Pascal 这门编程语言没有关系,也跟压强单位帕斯卡没有关系。仓库 README 给自己的一句话定位是「A 3D building editor built with React Three Fiber and WebGPU」——用 React Three Fiber(把三维库 Three.js 包装成 React 组件的渲染层)和 WebGPU 做的三维建筑编辑器,也就是说它跑在浏览器里。仓库里除了给人用的画布,还有一整个 MCP 服务器包,让 AI Agent 能直接调工具往场景里砌墙、开洞、摆家具。
同样是「用一份声明把能力挂到宿主上」,站内几篇文章各管一段:Superpowers 技能文件的契约 讲的是写给模型看的自然语言约定该怎么组织,ECC 的组件化 manifest 讲的是一个 Agent 框架怎么把能力打包分发,Pi 的扩展微点 讲的是运行时该留哪些挂载点。本篇的分工不同:它看的是一个非 Agent 出身的图形应用,怎么把三维渲染、鼠标交互、属性面板、二维图纸、AI 工具这五条彻底不同的消费链,绑到同一个对象上,以及绑到什么程度就绑不动了。
一、它解决的是「加一个东西要改多少处」
在一个建模工具里加一种新构件——比如一根柱子——天然要碰很多地方:三维场景里得画出来;左边的构件面板里得有个图标能拖;选中之后右边要弹出属性输入框;平面图(从正上方看的二维图纸,建筑行业的主要交付物)里得画出它的截面;键盘按 R 要能转向;删掉它的时候关联的东西要跟着清理。这些代码分布在渲染层、编辑器层、二维层,任何一个漏改都是一个只在特定操作下才暴露的 bug。
Pascal Editor 的做法是让这些消费方全部改成「从注册表里查」。每种构件写一个 NodeDefinition 对象,注册进 nodeRegistry;渲染系统、工具管理器、属性检查器、平面图层各自去读自己关心的那几个字段。仓库里这样描述这个模型的落点:packages/core/src/registry/ 放契约类型和注册表,packages/nodes/src/<kind>/ 一个构件一个文件夹,消费这些字段的框架组件分在两处——packages/viewer/src/components/renderers/ 放决定「React 挂什么」的渲染组件,packages/viewer/src/systems/geometry/ 放每帧重建网格的那套系统。
顺带一提规模感,这几个数字你自己 ls 一下就能复现:packages/ 下 9 个包,packages/nodes/src/ 有 46 个子目录,其中 shared/ 是公共工具不算构件,所以是 45 种节点类型;wiki/architecture/ 下 20 份 markdown,1 份 README 索引加 19 篇分主题文档。也就是说这套契约不是三五个类型的玩具规模,它已经被四十多种差异极大的构件压过一遍。
二、一份定义里都有什么
下表是我在仓库里实际读到的几块,按「谁来消费」分组:
| 组成部分 | 它负责什么 | 我读到它的仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
schema / schemaVersion / defaults | 这种构件有哪些字段、初始值是什么、数据版本号 | packages/nodes/src/wall/definition.ts、packages/nodes/src/door/definition.ts | 一上来就碰;改字段时必须同步想迁移 |
capabilities | 能不能选中、能不能复制删除、能不能被刷材质、能不能挂在别的构件上 | packages/core/src/registry/types.ts 的 Capabilities | 你想让某个通用操作对新构件生效时 |
relations | 谁能挂谁、影响谁、删除时怎么级联 | packages/nodes/src/wall/definition.ts | 构件之间有从属或联动关系时 |
geometry / renderer / system | 三维场景里怎么生成网格、要不要 React 组件、要不要每帧干活 | 契约见 wiki/architecture/node-definitions.md,消费方在 packages/viewer/src/components/renderers/ 与 packages/viewer/src/systems/geometry/ | 第一次让它在画面里出现时 |
parametrics | 右侧属性面板长什么样,含 customPanel 逃生口 | packages/nodes/src/wall/parametrics.ts + packages/nodes/src/wall/panel.tsx | 想让用户输数值改它时 |
handles | 选中后浮在空中的那些拖拽箭头 | packages/nodes/src/door/definition.ts 里的 doorWidthHandle / doorHeightHandle | 要做「拖着改尺寸」时 |
tool / affordanceTools / toolHints | 放置这个构件的操作流程,以及屏幕上那条快捷键提示 | packages/nodes/src/wall/definition.ts、packages/nodes/src/door/tool.tsx | 要让它能被画出来时 |
floorplan 系列 | 二维平面图里的多边形、平面上的拖拽响应 | packages/nodes/src/wall/floorplan.ts、packages/nodes/src/door/floorplan.ts | 要出图纸时 |
presentation | 构件面板里的名字、图标、分区、排序 | packages/nodes/src/door/definition.ts 末尾 | 想让人能找到它时 |
mcp | 给 AI 消费方的一句描述 | packages/core/src/registry/types.ts 的 McpOverrides | 见第五节,这块的实际覆盖面比你以为的小 |
值得单独拎出来的是 relations。墙的定义里是这么写的:
relations: {
hosts: ['door', 'window', 'item'],
affectsSpatial: ['slab', 'ceiling', 'zone'],
linkedBy: 'endpoint-match',
cascadeDelete: 'descendants',
},
四行说清了四件事:墙上能挂门、窗和摆件;墙一动,楼板(slab,一层楼的水平承重板)、吊顶和区域都要跟着重算;两面墙靠端点重合来判定「连在一起」;删墙的时候子节点跟着删。这些语义原本散在若干个 if (node.type === 'wall') 分支里,现在是可读的数据。
三、渲染这条线:三个可选字段的组合
这是整套契约里设计得最干净的一段,值得逐字看。三个字段:geometry 是纯函数,签名是 (node, ctx, shading, textures, colorPreset, sceneTheme) => Object3D,拿节点数据算出一堆三维网格;renderer 是可选的自定义 React 组件;system 是可选的每帧组件。架构文档给的组合示例是这样的:
// door — pure geometry + animation system
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// ...
geometry: buildDoorGeometry,
system: { module: () => import('./animation') }, // advances operationState
}
运行时怎么串的:<NodeRenderer> 先看有没有 def.renderer,有就挂它,没有就挂一个叫 <ParametricNodeRenderer> 的空 <group>——这个空壳负责往 sceneRegistry 登记自己、挂上鼠标事件、读拖拽中的临时变换、并且在挂载时调一次 markDirty(node.id)。然后 <GeometrySystem> 每帧扫 dirtyNodes,对每个脏节点调它那种构件的 def.geometry,把旧的子对象销毁、新的挂上去、clearDirty(id)。
这个拆法的收益在于几何构建函数是纯的。文档把这条写成硬规则:builder 里不许 import useScene,要读别的节点只能通过第二个参数 ctx,架构文档给它列的口子就是 resolve / children / siblings / parent 这几个只读入口(类型定义上还多一个取地面标高的 levelBaseAt,见第六节第 6 条)。为什么非要这样?因为墙要算「斜接」——两面墙成 L 形相交时,交角处的多余部分要互相切掉,看上去才像一面连续的墙,这就必须读到相邻的墙,而这正是 ctx.siblings 的用途。门和窗则用 ctx.parent 读宿主墙的厚度,让门框的进深跟墙对齐。用一个只读快照参数换来的是「这个函数可以直接单元测试」,代价文档也写了:每个脏节点多几次 Map.get。
第二条硬规则更值得抄:builder 只产出局部坐标的子对象,节点自身的位置和旋转由渲染组件通过 JSX 属性绑定,几何系统只负责换孩子、绝不碰 group.position。文档里记了违反后的现象:几何系统若在重建后顺手把 group.position 归零,React 那边没有理由重新下发属性,节点就会在每次重建时视觉上跳回原点。这类「两个写入方争抢同一个状态」的坑,在任何声明式框架配命令式渲染的项目里都会重演。
四、编辑器那一侧:工具、手柄、面板
同一份定义往编辑器方向也长出了好几只手。
放置工具。墙的定义里 tool: () => import('./tool'),是懒加载的模块引用;toolHints 则是纯数据,门的是四条:Left click / Place door on wall、R / Flip side、Alt / Force place、Esc / Cancel。这些提示由 HelperManager 通过 RegisteredToolHelper 渲染,构件作者只写数据,不碰 UI 组件。
拖拽手柄。门的定义里 handles 是四个纯描述符:一个移动十字、左右两个宽度箭头、一个高度箭头。以宽度箭头为例,它声明 kind: 'linear-resize'、axis: 'x'、anchor 指定哪一侧固定不动、min 给了常量下限、max 是个函数——先问屋顶面能给多宽,问不到就退回宿主墙的长度。类型文档对这套的定位写得很明确:Pure descriptors — no React, no Three.js,编辑器的通用组件读这个列表,把拖拽管线接好。真正塞不进描述符的(墙角的引线虚线、栅栏弯曲)才允许写成独立 React 组件挂在旁边。
属性面板。parametrics 里是分组的字段列表,字段类型有 number、boolean、enum、vec3、color、material、ref 这几种,检查器照着自动渲染。墙的 parametrics.ts 里写了三个数值字段:厚度、高度、还有一个 curveOffset(弯墙的矢高,也就是弧线中点偏离两端点连线的距离)。但墙没有止步于自动生成,它在同一个描述符里挂了逃生口:
// Stage E — kind-owned panel. Wall's panel has a derived Length
// slider (computes from start/end + dirX/dirZ) and a hosted-child-
// aware Curve slider that's only shown when no door / window / wall-
// attached item lives on the wall. The auto-inspector can't express
// "derived" or "conditionally visible" yet — kept as a custom panel.
customPanel: () => import('./panel'),
这段注释我建议做插件体系的人反复读:它没有强行把「派生值」和「条件显示」硬塞进声明式字段类型里,而是承认自动检查器暂时表达不了,留一个懒加载的自定义面板出口,并且在注释里写清为什么走了逃生口。逃生口不写理由,三个月后就没人敢收回去了。
除了面板,ParametricDescriptor 上还挂着 invariants(校验)、derive(改了一个字段要顺带改哪些同节点字段)、reconcile(要顺带改哪些别的节点)、onDelete / onDeleteCascade(删除时的反向收尾)。类型注释里有一句边界说明:derive 只在检查器编辑时触发,直接走存储或 MCP 的写入会绕过它,真正的不变量要放 invariants。这是很诚实的一句话——它等于承认了「面板路径」和「Agent 路径」不是同一条路。
五、边界与代价:这份契约明确不管的事
抄设计之前先看它放弃了什么,这部分我尽量按仓库里能查证的说。
契约没有覆盖 MCP 工具面。 这是我读下来最反直觉的一点。定义里确实有 mcp 字段,但它的类型只有两个可选属性:
export type McpOverrides = {
description?: string
semantic?: boolean
}
墙填的是一句 'A wall segment defined by start + end points, with optional curve sagitta.'。而实际的 MCP 工具是手写的:packages/mcp/src/tools/ 下是一份逐个铺开的实现清单,每个工具一份实现加一份测试,cut_opening 这个工具直接 import { DoorNode, WindowNode } from '@pascal-app/core/schema',自己声明入参、自己校验、自己调场景操作层。也就是说,schema 被复用了,但「有哪些 AI 工具」是另一套独立维护的清单。我在 packages 和 apps 下搜 .mcp 的运行时消费点,只搜到一处测试断言(packages/nodes/src/spawn/__tests__/parity.test.ts 检查 spawnDefinition.mcp?.description 非空)。结论就写到这:加一种新构件,AI 那边不会自动多出一个工具。这跟 MCP Server 开发入门 里讲的手写工具面是同一个量级的工作量。
插件能贡献的东西是刻意收窄的。 插件文档专门列了「暂时不算插件贡献」的清单:材质没有 plugin.materials 槽位;二维平面图的图元联合类型是宿主所有,画不出来的东西只能退回三维渲染器;核心清单里不含面板与侧边栏 UI(要写 UI 得另外导出 EditorHostPanel);插件不许扩展宿主的状态存储;也不能注册路由和页面。文档给的理由是让契约先能发出去,每一条「暂时不」是计划而不是「永远不」。
加载是只增不减的。 loadPlugin 在 v1 是 add-only,理由写得很直白:热卸载一种构件就要销毁场景里每一个已挂载实例,超出范围。项目级的「卸载插件」只是可见性操作——节点仍然序列化在场景图里,重新装回来又出现,卸载不删数据。
几何构建的纯函数约束是有代价的。 墙的定义文件顶部注释里承认,墙的几何依赖整层楼批量算出来的斜接数据,塞不进 (node, ctx) => Group 这个形状,所以墙至今是 renderer 加 system 的组合,几何提取被推到后面的阶段。一个被自己最复杂的构件顶住的抽象,作者选择在注释里写明「这里没做完」,而不是把接口撑变形。
跑起来之后的现实代价,这几条必须自己心里有数。 这个项目的 MCP 服务器是在你自己机器上跑的进程,场景数据落在本地 SQLite:默认路径是 $HOME/.pascal/data/pascal.db,可以用 PASCAL_DB_PATH 指到具体文件,或用 PASCAL_DATA_DIR 指到目录。传输层有 stdio 和 HTTP 两种,HTTP 那条路上代码写着:非回环地址的主机必须提供 PASCAL_MCP_HTTP_TOKEN,否则直接报错——换句话说,你要是把它绑到 0.0.0.0 上还不给令牌,它不让你启动,这个默认值是对的,但也提醒你别自己绕过去。图像相关的工具会按你给的 URL 出网,仓库里为此专门写了一个 safe-fetch,注释里记着它拦截的是回环、链路本地(含云厂商元数据地址)、各段私网地址和非 http(s) 协议,并且每次重定向都重新过一遍同样的名单;额外放行的来源走 PASCAL_ALLOWED_ASSET_ORIGINS。这些防护本身说明了风险点在哪:一个连着 MCP 的 Agent,有权改你本地场景图的任意节点(apply_patch 就是给它做批量图操作的),也有权触发对外请求。授权边界怎么划,可以对着 MCP 的安全边界 那篇一起看。
六、上手与避坑清单
想照着加一种构件,或者想把这套思路搬到自己的插件体系里,下面几条是我从文档的 Pitfalls 段和定义文件注释里挑出来的,每条都写清了为什么会踩。
-
新增字段一律给默认值,改名和删字段必须写迁移。 会踩是因为老场景是几个月前存下的 JSON,加载时要重新解析成当前 schema;只给
.default()不写迁移,旧值会被静默丢掉。避法:改名、删除、改类型这三种操作,去packages/core/src/store/use-scene.ts的migrateNodes里加一条,在解析之前把老结构改写成新结构。 -
构件如果要挂别的构件,schema 里必须自己声明
children字段。 会踩是因为创建子节点时会同时写子节点的parentId和父节点的children数组,父节点这边没有这个字段,那次写入就是空操作,结果是子节点在场景状态里存在、但界面上什么都不显示。避法:声明了relations.hosts就配一个数组字段并给空数组默认值,老场景再补一条迁移,保证每个已保存节点上这个字段都是数组。 -
不参与任何脏队列的构件要显式关掉脏跟踪。 会踩是因为场地、楼栋、楼层这类组织性节点既没有几何构建函数、也没有别的消费方,标记的「脏」永远没人清理,攒一整个会话,让每个消费方每帧的「空集就跳过」优化全部失效。避法:这类构件写
dirtyTracking: false;哪天它长出了几何构建函数,把这个标记删掉。 -
预览的半透明效果必须先克隆材质。 会踩是因为几何构建函数普遍在模块作用域缓存材质,同一种构件的所有实例共用一个材质对象;预览时图省事直接把它改成半透明,场景里所有已放置的同类构件跟着一起变透明。避法:克隆一份、改克隆体、把网格的材质指向克隆体,卸载时只销毁克隆体。仓库给的参考实现是
packages/nodes/src/shelf/preview.tsx。 -
自定义系统往登记过的组里加子对象,要跟着打标记。 会踩是因为一个组里可能同时有两种孩子:几何构建函数产出的网格,和 React 挂进来的宿主子节点(比如拖到货架上的摆件)。重建时若不加区分地全清,摆件就会消失且回不来。避法:几何系统给自己产出的每个子对象打上
userData.__fromGeometry,销毁时只认这个标记;手写系统也照办。 -
别在场景里把「地面」当成 y = 0。 会踩是因为场地带地形高度场(就是一张记录每个平面位置对应地面标高的数据,让地块能起伏),在平地上写死 0 看着没问题,一到坡地就把构件埋进土里。避法看你的构件怎么拿到高度:站在表面上的声明
capabilities.floorPlaced并给出占地轮廓,让高程系统每帧抬它;几何构建函数里自己定竖向原点的,用ctx.levelBaseAt(x, z)取地面高度,顺带还会被登记进地形失效链路;注意二维平面图路径下这个函数不存在,两边共用的构建函数要写成可选调用加兜底。 -
构件类型名全局唯一,撞名在生产环境是启动即报错。 会踩是因为第三方插件包很可能和你想的名字撞车。避法:注册表在生产模式下遇到重复类型名直接抛异常(开发模式为了热更新只警告并替换),插件 id 用
vendor:pack-name这种带前缀的写法先把命名空间占住。 -
插件对宿主包必须是 peer 依赖。 会踩是因为插件如果自带一份
@pascal-app/core,运行时就会出现两个注册表,你的构件注册在一个上、宿主读的是另一个,表现是「代码明明跑了但什么都没出现」。避法:宿主包全部列为 peerDependencies,让包管理器在安装期就把这个问题拦下。
收尾:怎么判断你的契约设计到位了
回到开头那句判断。这份契约值得读,不是因为它字段多,而是因为它在三个地方做了明确取舍:用「填了就参与」代替类型标签,所以加字段不用改分发逻辑;把纯数据和懒加载模块分开,纯描述符(手柄、提示、字段列表)走同步数据,需要 React 和三维库的部分一律 () => import(...),所以核心包不用依赖渲染库;逃生口带理由,自定义面板、自定义渲染器都保留了,但注释里写清了为什么自动路径不够用。
你可以拿三个问题体检自己的插件契约:加一种新东西,是不是只需要新建一个文件夹加一行注册?表达不了的情况有没有留出口,出口有没有写明何时可以收回?以及最容易被忽略的一条——你的契约边界之外还剩哪些手写清单,有没有在文档里说清楚(这个项目的答案是 MCP 工具面)。这类「声明覆盖到哪、手写从哪开始」的判断,跟 Agent 工具设计 里要回答的问题是同一个。
想自己顺一遍,我建议的阅读顺序是:先 wiki/architecture/node-definitions.md 建立整体模型,再 packages/nodes/src/shelf/definition.ts 看最简形态(那个文件夹里没有 renderer.tsx 也没有 system.tsx),然后 packages/nodes/src/door/definition.ts 看手柄和宿主关系,最后 packages/nodes/src/wall/definition.ts 看一个把契约撑到边界的例子——顺手对比一下这三个文件夹里的文件数量,墙明显最厚、门居中、货架最薄,复杂度的分布一目了然,而它们对外暴露的定义对象却是同一个形状。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的插件机制:内置节点自己也是一个插件 和 Pascal Editor 选择机制:开源 3D 建筑编辑器的两层拆分。