开源 3D 建筑编辑器 Pascal Editor 的插件机制:内置节点自己也是一个插件
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
这个项目的插件机制里最硬的一条,是它没给自己留后门:编辑器自带的那批节点定义,是通过和第三方插件完全相同的一条路径注册进去的。 packages/nodes/src/index.ts 导出的东西不叫「内核」也不叫「内置模块」,它就叫 builtinPlugin,类型是 Plugin,id 是 pascal:core,和你自己写的插件走同一个 loadPlugin。这句话决定了后面所有事情的可信度——一套扩展点如果连自家功能都不用,它多半是没跑通的。
一、先分清是哪个 Pascal,再说它解决什么问题
Pascal Editor 是一个开源项目的名字,指的是这个跑在浏览器里的三维建筑编辑器,和 Pascal 编程语言没有关系,也和压强单位帕斯卡没有关系。仓库在 https://github.com/pascalorg/editor ,许可证是 MIT(Copyright 2026 Pascal Group Inc.)。仓库 README 这样定位自己:用 React Three Fiber 与 WebGPU 构建的 3D 建筑编辑器。
先约定两个建筑侧的词,后面会反复出现。楼板(代码里叫 slab)是一层楼的水平承重板,也就是你站在屋子里脚下踩的那块板;平面图(floor plan)是从正上方俯视这一层、把墙门窗按剖切位置画成线条的二维图纸,编辑器里它是和三维视口并存的另一套渲染层。这两个概念之所以重要,是因为这个项目的插件不是只往三维场景里塞一个模型,它同时要在二维图纸里出现。
规模上给你一个手感,这些数字你自己在仓库里也数得出来:全仓 2508 个受版本控制的文件,apps/ 下 2 个应用,packages/ 下 9 个包。体量最大的是 packages/nodes(733 个文件),其次是 packages/editor(445)、packages/core(233)、packages/mcp(152)、packages/viewer(107)。架构文档集中在 wiki/architecture/,20 份 md,其中 1 份是 README 索引,另外 19 篇分主题。根目录同时躺着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件——这个项目从一开始就假定有模型在读它的代码。
这类编辑器最典型的腐烂路径你大概能猜到:每加一种物体,就在选中逻辑里加一个 if (node.type === 'xxx'),在平面图渲染里加一个分支,在浮动菜单里加一个三元表达式,两年后没人敢动。这套插件机制真正在对付的是这个,不是「让别人来写插件」这句好听话。
本篇只拆一件事:一个三维编辑器怎么把自己的扩展面切出来、切到哪里为止。协议层面怎么给 MCP 做扩展,看 MCP 扩展框架;Agent 运行时那一侧的扩展加载器长什么样,看 Pi 的扩展加载器;三家 Agent 平台扩展模型的横向差异,看 Agent 扩展的跨平台三体对比。
二、注册表只接受一种东西:节点定义
packages/core/src/registry/registry.ts 的主体是一个类 NodeRegistryImpl,内部就一个 Map<string, AnyNodeDefinition>,对外暴露 has / get / entries / schemas / size。整个插件机制的入口窄到只剩一个函数:registerNode(def)。
它做两件校验:kind 必须是非空字符串,schemaVersion 必须是不小于 1 的数字,两条都直接抛错。第三件事更有意思——重复的 kind 怎么办,按环境分叉:
if (this.defs.has(def.kind)) {
if (isDevMode()) {
console.warn(`[registry] re-registering node kind "${def.kind}" (HMR)`)
} else {
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
}
}
生产环境抛错,因为两个插件同时声明 kind: 'couch' 必须是启动期就炸掉的事故,不能静默覆盖;开发环境改成警告加替换,否则你保存一次 def.ts,热更新重新执行注册就会崩,或者干脆跳过、把旧的描述符钉死在内存里。isDevMode() 先试 import.meta.env.DEV,取不到再看 process.env.NODE_ENV,两个信号都没有就按生产处理——保守的那一侧是抛错。
插件清单本身简单到可以整段抄下来,它在 packages/core/src/registry/types.ts:
export type Plugin = {
id: string
apiVersion: 1
nodes?: AnyNodeDefinition[]
}
三个字段,一个也不多。loadPlugin 先比对 apiVersion,不等于宿主的 1 就抛错,然后逐条 registerNode,同时把 kind → plugin.id 记进一张 pluginIdsByKind 表。这张表后面撑起了 getNodePluginId 和 isNodeKindEnabled 两个能力。
真正体现「这套扩展点被内部用过」的,是同一个文件里那一排派生查询函数:getSelectableKinds、isRegistrySelectable、kindsWithFloorplanScope、bakePolicyOf、kindsWithBakePolicy、isRegistryMovable、hasRegistry3DMoveTool、isPresettableKind、resolveFacingIndicator、getHostRefFields、isDrawnViaToolKind。翻它们的注释,写的全是同一件事:编辑器里原本有硬编码的 kind 名单和三元链条(注释点名了选择管理器、浮动操作菜单),现在改成回来问注册表。判断一个扩展点是真是假,看的就是这个——宿主自己有没有停止在别处维护平行名单。
三、内置的 pascal:core 就是一个插件
wiki/architecture/plugin-authoring.md 把插件的形状讲得很直白:一个 JS 对象,导出一个清单符号。文档给的样例长这样:
import type { Plugin } from '@pascal-app/core'
export const myPlugin: Plugin = {
id: 'acme:furniture-pack',
apiVersion: 1,
nodes: [
couchDefinition,
armchairDefinition,
// ...
],
}
文档同一节紧接着写:同样的形状撑起了 @pascal-app/nodes 里的内置 pascal:core 插件,不存在「内部」插件格式。这不是场面话,去 packages/nodes/src/index.ts 就能对上:
export const builtinPlugin: Plugin = {
id: 'pascal:core',
apiVersion: 1,
nodes: [
shelfDefinition as unknown as AnyNodeDefinition,
spawnDefinition as unknown as AnyNodeDefinition,
wallDefinition as unknown as AnyNodeDefinition,
// ...
],
}
这个数组里有 46 条节点定义(在文件里数 as unknown as AnyNodeDefinition 出现的次数就能确认)。而 packages/nodes/src/ 下有 46 个子目录,其中 shared/ 不是节点类型,所以节点类型目录是 45 个。45 个目录出 46 条定义,差的那一条在 cabinet/——它同时导出 cabinetDefinition 和 cabinetModuleDefinition,一个是柜体组合,一个是组合里的单元模块。
引导顺序在 apps/editor/lib/bootstrap.ts,这段值得单独看,因为它踩过一个具体的坑。内置节点是同步注册的(loadBuiltinsSync),不走 loadPlugin 的异步路径,原注释写清楚了原因:之前用异步方式启动,注册只发生在一个微任务里,导致首次服务端渲染与水合看到的是空注册表,表现是 <html> 元素上的水合报错,加上每个 NodeRenderer 都解析成 null,要等后续渲染才恢复。外部插件的发现(discoverPlugins,将来可能走网络)留在异步的 loadExternalPlugins 里。
同一个文件底部是两行真实的第三方接入:
extendPluginDiscovery(async () => [treesPlugin])
registerEditorHostPanel(treesHostPanel)
extendPluginDiscovery(async () => [mintPlugin])
registerEditorHostPanel(mintHostPanel)
treesPlugin 来自 @pascal-app/plugin-trees,mintPlugin 来自 @mint/pascal-plugin,两个包在 apps/editor/package.json 里都是指向 GitHub 的依赖,代码不在这个仓库内。注意这里用的是 extendPluginDiscovery 而不是 setPluginDiscovery:前者把新的发现源和已有的用 Promise.all 合起来,后者是整个替换,而且替换是全局的、调两次会静默覆盖(开发模式下才有一行 warn 提醒你「先前通过 extendPluginDiscovery 注册的插件被丢掉了」)。
把这套机制的零件摊开:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
Plugin 清单(id / apiVersion / nodes) | 插件对外的全部形状,三个字段 | packages/core/src/registry/types.ts | 写插件的第一行代码时 |
registerNode / loadPlugin | 校验并把节点定义放进注册表 | packages/core/src/registry/registry.ts | kind 撞名或 apiVersion 不匹配报错时 |
setPluginDiscovery / extendPluginDiscovery / discoverPlugins | 决定启动时去哪里找插件 | packages/core/src/registry/registry.ts | 宿主要加载外部节点包时 |
builtinPlugin(id 为 pascal:core) | 承载 46 条内置节点定义的那个插件 | packages/nodes/src/index.ts | 想照抄一个内置节点当模板时 |
| 引导顺序(同步注册内置 + 异步发现外部) | 避免首屏渲染撞上空注册表 | apps/editor/lib/bootstrap.ts | 插件注册了但界面上什么都没出现时 |
EditorHostPanel / registerEditorHostPanel | 插件自带的编辑器侧面板与详情页元信息 | wiki/architecture/plugin-authoring.md | 插件需要自己的 UI 时 |
installedPlugins / isNodeKindEnabled | 项目级的安装与卸载可见性开关 | packages/core/src/store/use-scene.ts | 卸载插件后老节点还留在场景里时 |
四、一个节点定义能往里塞什么,以及 AI 那一头开到哪
NodeDefinition 是这套机制里唯一有分量的贡献单位,文档原话是:插件的 nodes 数组是 v1 里唯一有意义的贡献点。所以这个类型很厚,types.ts 有两千多行,绝大部分是它和它的附属类型。
骨架是三个必填字段加一组可选契约。必填的是 kind、schemaVersion、schema(一个 Zod 对象)、category(枚举只有 site / structure / furnish / analysis / utility)和 defaults()。可选的那一大堆里,wiki/architecture/node-definitions.md 把渲染相关的三个字段叫「三选框模型」:geometry 是纯构建函数、renderer 是自定义 React 组件、system 是每帧执行的组件。三者互相独立,没有判别标签,存在即参与——一个节点可以只有 geometry(如 shelf),也可以 renderer 加 system 组合(如 zone)。
geometry 的签名是 (node, ctx) => Object3D,ctx 是 GeometryContext,给的是只读的场景访问:resolve 按 ID 查任意节点、children、siblings、parent、materials,以及两个值得单说的字段。
一个是 levelData:当节点声明了 computeLevelData,调度器会在同一层的逐节点构建之前先跑一次批量预计算,结果塞进 ctx.levelData。墙体就靠这个——每面墙的网格都要读整层的斜接(miter,两面墙在转角处互相切削出一个漂亮接缝)关系图,逐面墙重算是 O(N²)。
另一个是 levelBaseAt(x, z),它背后是这个项目对插件作者最实在的一段提醒。场地带一张雕刻过的高度场(heightfield,用二维网格上的高度值来描述起伏地面的数据结构),所以「地面」不是 y = 0 这个平面。文档的原话是:一个把 0 硬写成自己基准高度的插件类型,在平地上看着完全正确,在做过地形的场地上会把自己埋进山坡里。给的三条出路是:靠地面站着的(树、长椅、花箱)声明 capabilities.floorPlaced 加一个 footprint,FloorElevationSystem 每帧替你抬升;自己烘焙(bake,指在几何构建阶段就把坐标算成定值写进顶点,运行时不再调整)纵向原点的构建器改读 ctx.levelBaseAt(x, z),顺带把这个类型登记进地形失效、地面动了会自动重跑;出集体渲染器(一个组件画很多实例)的自己负责每个实例的 Y,用 getFloorStackedPosition 解析,提交的必须是基准位置,抬升只是表现层、绝不入库。
二维那一侧是 floorplan,返回 FloorplanGeometry 这个纯数据联合类型,由通用渲染器转成 SVG,插件不碰 SVG 元素。这个联合里既有 path / polygon / circle / text / image 这类图元,也有 endpoint-handle / midpoint-handle / edge-handle / move-arrow / rotate-arrow 这类交互手柄,还有 dimension 和 dimension-string 这类建筑制图的尺寸标注(从被测量的两端引出延长线,中间画一条标注线,数字坐在断口里,两端用建筑制图的短斜杠收口)。手柄上的 affordance 是个字符串键,指向节点自己在 floorplanAffordances 里注册的拖拽会话——这就是「第三方能在二维图纸里做交互」的完整链路。
capabilities 是另一大块:movable / rotatable / scalable / hostable / cuttable / snappable / surfaces / floorPlaced / paint / slots / sceneAction / drawTool / presettable / hostRefFields 等等。这些不是标签,是配置:floorPlaced.collides 决定这个类型的占地是否阻挡别人放置,hostRefFields 声明哪些字段是「由放置位置派生」的宿主引用(门窗的 wallId / wallT),宿主应用存预设时会把它们剥掉,下次放置再从光标下的墙重新推导。
再往上是 parametrics,检查器面板的形状由它自动推导,字段类型只有 number / boolean / enum / vec3 / color / material / ref / custom 这几种,customPanel 是逃生口。
最后是留给模型的那一格:mcp。类型窄得只有两个字段,description 和 semantic。也就是说每个节点类型自己写一句给 AI 看的说明。shelf 那条实际内容是:「A parametric shelving unit. Four styles (wall-shelf / bookshelf / open-rack / cubby) with configurable rows, columns, sides, and back. Items host on each row.」——注意它写的是参数空间和挂载规则,不是营销词。你要自己写插件的 MCP 描述,这个密度是可以直接参照的基准。工具描述本身怎么写才让模型不乱调,另见 MCP Server 开发入门。
这里必须把代价讲清楚。packages/mcp 是一个会在你机器上真跑起来的进程,packages/mcp/README.md 写得很坦白:默认走 stdio(bunx pascal-mcp),也可以 pascal-mcp --http --port 8787 暴露在本地回环上;一旦要绑非回环地址,命令形态是 pascal-mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example,并且必须提供 PASCAL_MCP_HTTP_TOKEN 作为 bearer token。数据落在 ~/.pascal/data/pascal.db,是本地 SQLite,用 WAL 模式,可以用 PASCAL_DATA_DIR 换目录或 PASCAL_DB_PATH 指定确切文件。当编辑器和 MCP 服务器共享同一个数据目录时,MCP 的改动会写进 SQLite 并记进本地的 scene_events 流,编辑器页面通过 /api/scenes/:id/events 的服务端推送订阅它——也就是说,Agent 在另一个终端里的操作,会实时反映到你打开的浏览器标签页上。
Agent 的权限范围直接看 packages/mcp/src/tools/ 下的文件就行:create-wall.ts、place-item.ts、cut-opening.ts、delete-node.ts、apply-patch.ts、undo.ts、redo.ts、export-glb.ts、export-json.ts 等等。能建、能删、能打补丁、能导出文件。把它当成一个有写权限的本地进程来对待,而不是一个只读查询端点。端口、令牌、作用域这类边界该怎么划,见 MCP 的安全边界。
五、边界与代价:这套设计明确不管的事
窄,是这套契约的主要设计取向,文档自己也把这句话摆在明面上:边界故意留窄,好让契约能发得出去。具体放弃了这些:
没有材质贡献槽。 文档写明不存在 plugin.materials,要自定义材质只能在你的 renderer 或 system 里用 @pascal-app/viewer 的 createMaterial。
二维图元是宿主所有。 FloorplanGeometry 那个联合类型你加不了成员。表达不了的图形,只能退回 renderer 并从另一个二维挂载点渲染。
核心清单里没有 UI。 面板和侧边栏不在 Plugin 里,得另外导出一个 EditorHostPanel,由宿主用 registerEditorHostPanel 注册,而且它只对使用 @pascal-app/editor 的宿主有意义。面板是懒加载并包在错误边界里的,文档要求用宿主的 CSS 变量、样式限定在插件内、不要写全局样式。
插件不扩展宿主的状态存储。 不能往 useScene / useEditor / useViewer 里加东西,插件要状态就自己建 Zustand store。宿主 store 不在 v1 的插件面里。
没有路由和页面。 文档的定性是:插件是可视化与交互的代码,不是完整的应用面。
加载是只增的。 loadPlugin 在 v1 里 add-only,理由写得很实在:热移除一个类型意味着要拆掉场景里每一个已挂载的实例,超出范围。插件只在启动时加载一次。所谓「卸载」是项目级的可见性操作:面板、放置 UI、渲染器、系统、平面图输出都停掉,但插件代码和节点定义在整个浏览器会话里仍然留在内存里,已有的插件节点仍然序列化在场景图里、重新安装就回来——卸载从不删除项目数据。这既是安全网,也是代价:你没有办法真的把一个插件从这个会话里赶出去。
版本是硬闸。 apiVersion 不匹配直接抛错,文档说「升主版本会打断插件,这是故意的」。规则是宿主移除或改变既有字段的形状才升,新增可选字段不升。插件自己的数据版本靠每个 NodeDefinition 上的 schemaVersion,宿主不负责迁移,而文档里那个 migrate(node, fromVersion) 后面明确标着 future——现在还没有。
宿主的一致性测试不覆盖你。 packages/nodes/src/index.test.ts 会断言每个 AnyNode 判别值都有对应的注册类型,但插件贡献的类型不在 AnyNode 里、不参与这个测试。你要是在别处手写了联合类型,得在自己这边加一个等价的测试。
什么场景下这套东西不适用,也就清楚了:你想换一套材质系统、想加一种新的二维图元、想改宿主的 store、想挂一个设置页——v1 一个都不给。它能干净支持的是一件事:往这个编辑器里加新的物体类型,并让它在三维、二维、检查器、调色、吸附、烘焙、AI 描述这些通道上一次性接全。
六、上手与避坑清单
把 @pascal-app/* 写成 dependencies。 会踩是因为装依赖是肌肉记忆。后果很难查:两份 @pascal-app/core 就是两个注册表,你的类型注册在没人查询的那一个上,界面上什么都不出现而且不报错。做法是全部写成 peerDependencies,文档提到 npm 的 peer 解析会在安装期就抓到冲突。
setPluginDiscovery 调了两次。 会踩是因为它是全局单值,第二次静默覆盖第一次,只有开发模式下才有一行 warn。做法是宿主自己最多调一次,示例插件、第一方插件一律用 extendPluginDiscovery 合并。
发现函数设置得比引导模块晚。 会踩是因为 import 的求值顺序不直观。文档明确要求在 import './pascal-bootstrap' 之前调用 setPluginDiscovery,做法就是把它放在那行 import 上面,并在评审时当成一条顺序约束看。
kind 不加前缀。 会踩是因为本地开发时撞名只 warn 加替换,你根本看不出来;上了生产就变成启动期抛错,整页起不来。做法是 id 用 vendor:pack-name,kind 同样带上自己的前缀,别去抢 couch 这种通用词。
在几何构建器里硬写 y = 0。 会踩是因为默认场地是平的,测试时一切正常。做法按前面那三条分支选:站地面的用 capabilities.floorPlaced 加 footprint;自己算纵向原点的读 ctx.levelBaseAt(x, z);二维三维共用一个构建器的必须写成 ctx.levelBaseAt?.(x, z) ?? 0,因为平面图那一侧根本没有这个函数。另外采样点要和几何的锚点用同一个 XZ 坐标,否则在坡面上,手柄、吸附辅助线和网格会各说各话。
集体渲染器里直接写 node.position[1]。 会踩是因为看起来天经地义。实际上 FloorElevationSystem 写的是节点注册的那个对象,对集体类型来说那是一个不可见的选择代理,不是你画出来的实例,所以逐实例读位置会同时忽略楼板与地形。做法是每个实例矩阵都过一遍 getFloorStackedPosition,放置工具提交基准位置。
给依赖邻居的几何加 geometryKey。 会踩是因为这个字段是个诱人的性能优化——键没变就跳过重建。types.ts 的注释直接警告:几何依赖 ctx(墙、栅栏的斜接)的类型绝对不能设,因为它的输入不只来自节点自身。做法是只给「几何只由自己字段决定」的类型加。
没有验证锚点就开始改。 开发模式下控制台会打印一行 [pascal:registry] 日志,带上已加载的插件 id、apiVersion 和类型数量,文档把它点名为验证锚点;同一模式下注册表还会挂到 globalThis.__pascalNodeRegistry 上,可以直接在控制台里翻。先让这两处显示出你的插件,再去调渲染。
默认 MCP 服务器是安全的。 会踩是因为它跑起来太顺手了。它读写 ~/.pascal/data/pascal.db,共享数据目录时能通过事件流实时改动你打开的编辑器页面,工具目录里有删除和打补丁。做法是先按 stdio 或回环 HTTP 用;确实要绑 0.0.0.0,先把 PASCAL_MCP_HTTP_TOKEN 和 --cors-origin 准备好,并且清楚这台机器上谁能碰到那个端口。
真要动手,读文件的顺序建议是:wiki/architecture/plugin-authoring.md 拿到契约全貌,packages/core/src/registry/registry.ts 看清校验与加载的每一条分支,packages/core/src/registry/types.ts 当字段字典查,然后挑一个内置节点当模板——想要最短的就看 packages/nodes/src/shelf/,它是纯 geometry 加 parametrics 的那一档,presentation 和 mcp 两格也写得很规整。最后回到 apps/editor/lib/bootstrap.ts,把自己的插件按 extendPluginDiscovery 的方式挂上去,看控制台那行日志里有没有你。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的空间查询机制拆解 和 Pascal Editor 开源 3D 建筑编辑器:新增构件只写一份节点定义。