Pascal 编辑器插件开发指南:NodeDefinition 契约、宿主集成与地形落地实践
【免费下载链接】editorOpen-source 3D architectural editor with a local CLI, MCP tools, and practical workflows for humans and AI agents.项目地址: https://gitcode.com/GitHub_Trending/editor93/editor
本文面向为开源 3D 建筑编辑器 Pascal(editor)编写第三方节点插件(node pack)的开发者与 AI Agent。文章以仓库中 插件开发契约文档 为骨架,结合
@pascal-app/core的注册表源码(registry.ts、types.ts)与内置插件 @pascal-app/nodes 的实际定义,完整讲解 Plugin 清单结构、NodeDefinition 可贡献的全部能力、地形落地(floorPlaced / levelBaseAt)的正确姿势、宿主包引用约定、生命周期、插件发现(setPluginDiscovery)、编辑器宿主面板(EditorHostPanel)与项目级安装机制,以及版本化与本地测试方法。读完本文,你可以从一个可工作的示例插件出发,编写出与内置pascal:core同等地位、能正确处理坡地地形、遵守宿主外观与性能约定的第三方节点插件。
插件是什么:一个对象、一个清单
在 Pascal 的插件体系中,插件只是一个导出一个符号的 JS 对象——manifest(清单)。它不包含任何"特殊"格式,内置插件与第三方插件走的是同一条loadPlugin路径。
import type { Plugin } from '@pascal-app/core' export const myPlugin: Plugin = { id: 'acme:furniture-pack', apiVersion: 1, nodes: [ couchDefinition, armchairDefinition, // ... ], }清单字段如下:
| 字段 | 必填 | 说明 |
|---|---|---|
id | 是 | 全局唯一。使用vendor:pack-name命名空间避免冲突。宿主将其视为不透明字符串。 |
apiVersion | 是 | 当前为1。宿主在不匹配时抛错——提升版本会故意破坏旧插件。 |
nodes | 否 | AnyNodeDefinition数组。 |
从源码看,loadPlugin的 API 版本门槛是硬编码在注册表实现中的(registry.ts 的HOST_API_VERSION = 1),不匹配时抛出形如plugin "..." requires apiVersion ...; host supports 1的错误。plugin.nodes中的每个定义会经registerNode注册,并记录"该 kind 由哪个插件贡献"(pluginIdsByKind),这是后面项目级安装(isNodeKindEnabled)的基础。
值得注意的是,Plugin类型在 types.ts 中还支持inspectorExtensions字段——它允许插件为指定 node kind 的浮动检查器(inspector)卡片贡献一个独立分区(如"Engineering"分区)。扩展只在对应插件被项目安装时才显示,且与 kind 自身的控件是二选一关系(详见后文宿主面板章节)。
内置插件的"同构"印证:仓库中内置插件pascal:core定义于 packages/nodes/src/index.ts,它把 shelf、wall、door、roof、duct-segment 等几十个内置 kind 打包进同一个Plugin形状。源码注释明确写道:"External plugins follow the exact same shape — samePlugintype, sameloadPlugincall path." 也就是说,内置插件能做的,第三方插件全都能做。
文档还提到独立的pascalorg/plugin-trees仓库作为可工作的参考示例,建议将其克隆作为起点。
NodeDefinition 能贡献什么
插件 v1 中nodes数组是唯一有意义的贡献点。每一项都是NodeDefinition<S extends ZodObject>,注册表会为其盖上kind、schemaVersion、schema三个必填字段,并支持下列能力的任意组合:
defaults—— 新实例的初始字段值。capabilities——selectable/duplicable/deletable/surfaces/relations等框架消费的标志。parametrics—— 自动推导的检查器 UI 形态(fields+ 可选的customPanel逃生舱)。renderer—— 自定义 3D React 组件(GLB、drei、TSL 材质;可"退出"def.geometry)。system—— 逐帧工作(动画、dirty 级联、运行时状态)。geometry—— 纯函数(node, ctx) => Object3D,供通用<GeometrySystem>使用。floorplan—— 纯函数(node, ctx) => FloorplanGeometry,用于 2D 平面图图层。floorplanAffordances/floorplanMoveTarget—— 2D 拖拽处理器。tool/affordanceTools—— 3D 放置 + 移动工具(懒加载组件)。presentation—— 面板/侧边栏元数据(label、icon、paletteSection等)。mcp—— 面向 AI 消费者的 MCP 工具描述。relations/computeLevelData—— 兄弟节点查找 + 层级批量预计算。
从"三选一"组合模型看贡献边界
这 13 类能力中,geometry/renderer/system三者构成核心的三复选框组合模型(详见 node-definitions.md):三个字段相互独立、没有判别标签,"存在即参与"。一个 kind 可以选择:
- 仅
geometry(如 shelf):纯函数构建网格,框架的<ParametricNodeRenderer>挂空 group,<GeometrySystem>在节点变脏时重建子对象。 - 仅
renderer(如 GLB 物品):需要 JSX-only 特性——<Html>、useGLTF、drei 辅助、实例化、TSL 着色器材质、R3F portal。 renderer+system(如 zone):树中包含 React-only 原语(<Html>),同时需要逐帧命令式工作(按名字修改 uniform / 透明度)。geometry+system(如 door / window):参数化几何 + 动画职责分离——geometry纯函数建网格,system驱动operationState动画状态。
文档推荐组合的完整清单与迁移步骤见 node-definitions.md,这里不再展开。需要强调的是:纯函数 builder 不得导入useScene,场景读取必须经由GeometryContext(resolve/children/siblings/parent),这是保证 builder 可单元测试、可被通用系统重建的关键约束。
一个真实的NodeDefinition示例:shelf
packages/nodes/src/shelf/definition.ts 是内置 kind 中最"教科书"的注册方式,几乎覆盖了文档列举的全部贡献面:
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = { kind: 'shelf', snapProfile: 'item', schemaVersion: 2, schema: ShelfNode, category: 'furnish', surfaceRole: 'joinery', defaults: () => ({ object: 'node', parentId: null, visible: true, metadata: {}, children: [], position: [0, 0, 0], rotation: [0, 0, 0], width: 1, depth: 0.5, thickness: 0.05, height: 1.8, style: 'cubby', rows: 3, columns: 2, withBack: true, withSides: true, withBottom: true, bracketStyle: 'minimal', }), capabilities: { movable: { axes: ['x', 'z'], gridSnap: true }, rotatable: { axes: ['y'], snapAngles: [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI] }, surfaces: { top: { height: (n) => shelfRowSurfaceYs(n as ShelfNode).at(-1) ?? 0 }, custom: (n) => shelfRowSurfaceYs(n as ShelfNode).map((y) => ({ position: [0, y, 0] as const, normal: [0, 1, 0] as const, })), }, selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, paint: shelfPaint, slots: (n) => shelfSlots(n as ShelfNode), floorPlaced: { footprint: (node) => ({ dimensions: [...], rotation: ... }), collides: true, }, }, relations: { hosts: ['item'], cascadeDelete: 'descendants' }, parametrics: shelfParametrics, handles: shelfHandles, geometry: buildShelfGeometry, geometryKey: (n) => JSON.stringify([/* 几何相关字段 */]), floorplan: buildShelfFloorplan, floorplanMoveTarget: shelfFloorplanMoveTarget, floorplanAffordances: { 'shelf-resize': shelfResizeAffordance, 'shelf-rotate': shelfRotateAffordance }, preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place shelf' }, { key: 'Esc', label: 'Cancel' }, ], presentation: { label: 'Shelf', description: 'A configurable shelving unit. Items host on each row.', icon: { kind: 'url', src: '/icons/shelf.webp' }, paletteSection: 'furnish', paletteOrder: 30, }, mcp: { description: 'A parametric shelving unit. ...' }, }这个真实定义展示了文档提到的几乎所有字段的取值形态:surfaceRole: 'joinery'决定无纹理表面解析出的主题角色色;floorPlaced携带 footprint 让FloorElevationSystem每帧抬升;relations.hosts: ['item']声明可托管物品;toolHints声明放置工具激活时的快捷键提示;presentation.icon使用url类型的 IconRef(指向 shelf.webp)。写插件时可直接照此结构填充。
站在地面上:地形落地的三种姿势
场地(site)携带雕刻的高度场,所以"地面"不是y = 0平面。一个把基准硬编码为0的插件 kind 在平坦地块上看起来正确,在雕刻的山坡上则会把自己埋进坡里。文档明确:没有能力需要声明、也没有东西需要注册——根据你的 kind 如何获得 Y 坐标,选择下面三种方式之一,地形会自动跟随:
节点站在某个表面上→ 声明
capabilities.floorPlaced,并带上footprint(复合形体用footprints)。FloorElevationSystem每帧抬升已注册的 mesh,在每个 footprint 上于重叠的楼板与地面之间"择优"。这是完整契约:一棵树、一条长凳、一个花盆只需要这一步。FloorPlacedConfig在 types.ts 中定义,支持三个字段:footprint/footprints—— 解析占地形状;applies?: (node) => boolean—— 按节点决定是否适用;collides?: boolean—— 是否参与落地碰撞:实心家具类 kind(item / shelf / column)设置为true,使其 footprint 阻挡其他放置且自身放置/移动拒绝重叠;spawn、MEP、stair 等标记类与端口对接类 kind 留空,既不阻挡也不被阻挡(默认关闭)。
def.geometrybuilder 自行烘焙垂直原点→ 用ctx.levelBaseAt(x, z)替代硬编码的0。它返回该层级局部坐标点处的地面高度(该 storey 下方没有地形时返回0)。调用它还会把你的 kind 纳入地形失效机制——地面移动时 builder 会重新运行,无需你手工接线 dirty 规则。但注意:def.floorplan中不存在levelBaseAt——平面图没有高程——所以被 2D/3D 共享的 builder 必须写成ctx.levelBaseAt?.(x, z) ?? 0。你提供集体
renderer(一个组件绘制多个节点——实例化 mesh、合并缓冲)→ 你拥有每个实例的 Y。FloorElevationSystem写入的是节点已注册的对象,对集体 kind 而言那是不可见的选择代理(selection proxy),而非实例本身,所以逐实例直接写node.position[1]会同时忽略楼板与地形。正确做法是:在写每个实例矩阵时通过getFloorStackedPosition({ node, nodes, position })解析,且放置工具提交基准位置([x, 0, z])——抬升只是表现层,绝不持久化。
最后一条铁律:在几何体锚定的同一个 XZ 坐标上采样地面。一个手柄、一条吸附参考线、一个 mesh 若在坡面上采样不同位置,会肉眼可见地不一致。背景机制见 vertical-model.md。
导入宿主包:peer 依赖,不是普通依赖
插件从已发布的@pascal-app/*包导入——与内置节点使用的表面一致,peer-dependency 风格:
// Schemas, types, registry types import { type AnyNode, type NodeDefinition, type Plugin, z, // re-exported from zod for schema authoring } from '@pascal-app/core' // Viewer-side primitives (lazy: only inside renderers / systems) import { useNodeEvents, NodeRenderer } from '@pascal-app/viewer' // Editor-side primitives (lazy: only inside `tool` / `affordanceTools`) import { useDragAction, EDITOR_LAYER } from '@pascal-app/editor'这些包是peer dependencies 而非普通 dependencies——宿主应用拥有版本。如果插件钉死自己的一份@pascal-app/core副本,会创建两个注册表并静默失败(npm 的 peer-dep 解析会在安装时捕获这一点)。z由@pascal-app/core从 zod 再导出,供 schema 编写直接使用。
遵循宿主的外观与性能偏好
自定义renderer拥有自己的材质,因此必须像内置节点一样遵循宿主的外观轴。以只读方式订阅useViewer的shading、textures、colorPreset、sceneTheme;不要添加插件专属的画质开关,也不要把这些值复制进场景数据。
- Colored + Rendered:保留导入模型自带的材质。
- Colored + Solid:使用
createDefaultMaterial(..., 'solid')或另一种缓存的MeshLambertNodeMaterial变体。保留作者提供的 albedo 贴图、颜色、透明度与材质槽位,但省略会破坏更廉价 Solid 路径的 PBR-only 贴图。 - Monochrome:使用
createSurfaceRoleMaterial(def.surfaceRole, colorPreset, side, sceneTheme)。导入的道具通常声明surfaceRole: 'furnishing'。
性能纪律:模型加载时捕获一次作者材质、按源材质缓存变体、仅在偏好变化时交换、销毁前恢复。绝不逐帧克隆材质、绝不修改 loader 缓存的作者材质、绝不销毁来自宿主缓存的材质。完整材质生命周期模式见 materials-and-themes.md。
shadows、edges、Solid/Rendered 后期处理开销都是宿主全局的。普通插件几何体保持在SCENE_LAYER,因此光照 rig 与深度/法线管线自动包含它。仅编辑器放置预览应放在OVERLAY_LAYER/EDITOR_LAYER——这能让 ghost 预览避开阴影、SSGI 与墨线(ink-edge)通道。插件只需要为透明或 overlay mesh 管理castShadow/receiveShadow,无需复制宿主设置。
生命周期与注册表变更通知
loadPlugin在 v1 中是**只增(add-only)**的。热移除一个 kind 需要拆除场景中每个已挂载实例——超出范围。插件只在启动时加载一次。
registerNode在重复kind时抛错,因此两个插件都提供kind: 'couch'是启动期错误,而非静默覆盖。实现细节(registry.ts):_register首先校验kind为非空字符串、schemaVersion为正整数;重复 kind 时,生产环境抛[registry] duplicate node kind: ...,而开发模式(HMR)降级为警告并原地替换,避免保存def.ts时崩溃或留下陈旧描述符。
插件 kind 是异步注册的(应用引导通过动态导入发现它们),因此任何在挂载时快照注册表的消费者(如选择管理器的getSelectableKinds()订阅列表)都会在插件稍后加载时过期。为此_register/_reset会递增单调版本号并通知监听器;useRegistryVersion()(use-registry-version.ts)通过useSyncExternalStore把它变成 React 重渲染,让 effect 能重新推导 kind 列表。
在 apps/editor/lib/bootstrap.ts 可以看到宿主应用的完整引导序列:loadBuiltinsSync()以同步方式在模块导入时注册所有内置 kind(保证 SSR / 水合首帧就看到完整注册表),随后loadExternalPlugins()异步调用discoverPlugins()并逐个await loadPlugin(plugin);开发模式下控制台打印[pascal:registry] loaded pascal:core v1 (... kinds ...),这就是文档"验证锚点"的来源。
发现机制:setPluginDiscovery
宿主在加载内置插件后调用discoverPlugins()。默认实现返回[]。需要携带外部插件的应用在bootstrap 模块求值之前替换它:
// In app boot, BEFORE `import './pascal-bootstrap'` import { setPluginDiscovery } from '@pascal-app/core' import { myPlugin } from '@acme/furniture-pack' setPluginDiscovery(async () => { // Static import: bundled into the app. return [myPlugin] // Or fetch a manifest, dynamic-import each entry, etc. // const manifest = await fetch('/plugins.json').then(r => r.json()) // return Promise.all(manifest.map(m => import(m.url).then(mod => mod.default))) })setPluginDiscovery是全局的。调用两次会静默覆盖——与 bootstrap 导入的顺序至关重要。
从 registry.ts 的源码看,契约被刻意保持最小——只是"返回要加载的插件列表"。加载器可以是静态import.meta.glob、针对注册表端点的fetch、worker IPC 等;每个返回的插件仍会走loadPlugin,因此同样的 API 版本门槛与重复 kind 保护都适用。文档未提及的extendPluginDiscovery也在同一处实现:它把新发现源追加到现有链上(Promise.all合并),用于宿主捆绑的一方可选插件(如 bootstrap.ts 中treesPlugin、bonesPlugin、mintPlugin、streetscapePlugin的注册方式),避免覆盖宿主提供的发现源。
宿主面板与项目安装
核心Plugin清单保持渲染器无关。一个同时提供编辑器 UI 的插件单独导出EditorHostPanel:
import type { EditorHostPanel } from '@pascal-app/editor' export const myHostPanel: EditorHostPanel = { id: 'acme:furniture-pack:catalog', pluginId: 'acme:furniture-pack', label: 'Furniture pack', description: 'A curated furniture catalog.', creator: { name: 'Acme', url: 'https://acme.example', }, pluginUrl: 'https://github.com/acme/pascal-furniture-pack', icon: { kind: 'iconify', name: 'lucide:armchair' }, component: () => import('./catalog-panel'), }EditorHostPanel类型定义在 packages/editor/src/lib/plugin-panels.ts,与文档示例一致,且额外支持kinds(按 kind 关联面板)、workspaces、defaultInstalled等字段。宿主通过registerEditorHostPanel注册它。注册的插件出现在Plugins 侧边栏,而场景图的installedPlugins: string[]控制该项目的图标栏显示哪些插件面板。defaultInstalled: true让第一方插件自动进入旧项目与新创建的项目(Nature 当前就使用这一机制;Bones 则显式defaultInstalled: false,作为可选的专业视图,见 bootstrap.ts)。
安装/卸载是项目级别的可见性操作。插件代码与节点定义在浏览器会话内保持加载(因为loadPlugin只增),但未安装插件的面板、放置 UI、渲染器、系统与平面图输出都会被禁用。场景图中已序列化的插件节点仍然保留,插件重新安装后重新可见;卸载从不删除项目数据。底层依据是isNodeKindEnabled(kind, installedPlugins)(registry.ts):宿主直接注册的 kind 与内置插件始终启用;省略安装列表(遗留场景)为向后兼容保持插件可见;否则按installedPlugins.includes(pluginId)门控。编辑器 2D 图层(floorplan-registry-layer.tsx)与面板挂载处(panel-wrapper.tsx)都调用它做过滤。
creator与pluginUrl是可选的管理员元数据。在 Plugins 侧边栏选择插件会打开其详情页,宿主在此展示元数据与项目的安装/卸载控件。宿主面板在错误边界(error boundary)内懒加载挂载。使用宿主的 CSS 变量、保持 CSS 作用域在插件内,不要写全局样式。
版本化
apiVersion: 1覆盖上述全部表面。宿主的策略:删除或改变既有字段的形态时提升 major;新增可选字段不提升。计划是尽可能长期保持向后兼容的增量——bump 是逃生舱,不是默认。
插件自身的数据版本化是每个NodeDefinition上的schemaVersion。宿主不做迁移;插件的migrate(node, fromVersion)(未来实现)负责处理自己的遗留持久化节点。
哪些尚不属于插件贡献
契约刻意保持窄边界以便可交付,每个"not yet"都是计划而非"永不":
- 材质—— 没有
plugin.materials槽位。在def.renderer/def.system内使用@pascal-app/viewer的createMaterial。 - 平面图原语——
FloorplanGeometry联合类型归宿主所有。要绘制联合类型无法表达的内容,退回def.renderer,通过另一个 2D 挂载点渲染(或提交 issue)。 - 核心清单中的面板 / 侧边栏 UI—— 宿主相关。为使用
@pascal-app/editor的宿主单独导出EditorHostPanel。 - Stores—— 插件创建自己的 Zustand store;它们不扩展
useScene、useEditor或useViewer。渲染器可以只读订阅导出的宿主表现状态(如useViewer外观轴),但不得把宿主 store 当作插件自有状态。 - 路由 / 页面—— 插件是可视化 + 交互代码,不是完整应用表面。承载设置页属于应用。
测试你的插件
@pascal-app/nodes是内置参考实现,独立的pascalorg/plugin-trees是独立示例。本地测试步骤:
- 把插件构建为普通 npm 包,
@pascal-app/*作为 peerDependencies。 - 在消费你内置包的宿主应用(
apps/editor是最容易的目标)中,接线setPluginDiscovery返回你的插件。 - 开发模式的
[pascal:registry]控制台日志会显示加载的插件 id + 节点数量——这就是验证锚点(对应 bootstrap.ts 的+ N discovered plugin(s)输出)。
宿主的对等测试(packages/nodes/src/index.test.ts)断言每个AnyNode判别符都有已注册的 kind。插件贡献的 kind不参与该测试(它们不在AnyNode中);如果你在别处维护手写类型联合,请自行添加等价测试。
小结
插件在 Pascal 中的定位非常明确:一个Plugin清单 + 若干NodeDefinition,通过setPluginDiscovery交给宿主,在启动时一次性加载并冻结注册表。地形落地的三条路径(floorPlaced、ctx.levelBaseAt、集体 renderer 的getFloorStackedPosition)覆盖了从简单家具到实例化批处理的全部场景;peer 依赖与只读订阅useViewer保证了多版本宿主兼容与外观一致;项目级installedPlugins让安装/卸载成为纯可见性操作。把 plugin-authoring.md 与 node-definitions.md 结合阅读,再对照内置 shelfDefinition 这个完整样例,即可动手编写自己的节点插件。
延伸阅读
- node-definitions.md —— 三复选框组合模型、
GeometryContext、迁移步骤与陷阱 - vertical-model.md —— 地形继承的通用接缝
- materials-and-themes.md —— 外部插件渲染器的材质生命周期
- registry.ts ——
loadPlugin/discoverPlugins/isNodeKindEnabled的实现 - types.ts ——
Plugin/NodeDefinition/Capabilities完整类型定义 - packages/nodes/src/index.ts —— 内置
pascal:core插件清单 - apps/editor/lib/bootstrap.ts —— 宿主应用的实际引导与插件发现接线
- packages/editor/src/lib/plugin-panels.ts ——
EditorHostPanel类型与宿主面板注册表
【免费下载链接】editorOpen-source 3D architectural editor with a local CLI, MCP tools, and practical workflows for humans and AI agents.项目地址: https://gitcode.com/GitHub_Trending/editor93/editor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考