news 2026/9/13 7:33:58

Pascal 编辑器插件开发指南:NodeDefinition 契约、宿主集成与地形落地实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Pascal 编辑器插件开发指南:NodeDefinition 契约、宿主集成与地形落地实践

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。宿主在不匹配时抛错——提升版本会故意破坏旧插件。
nodesAnyNodeDefinition数组。

从源码看,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>,注册表会为其盖上kindschemaVersionschema三个必填字段,并支持下列能力的任意组合:

  • 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—— 面板/侧边栏元数据(labeliconpaletteSection等)。
  • 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,场景读取必须经由GeometryContextresolve/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 坐标,选择下面三种方式之一,地形会自动跟随:

  1. 节点站在某个表面上→ 声明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 留空,既不阻挡也不被阻挡(默认关闭)。
  2. def.geometrybuilder 自行烘焙垂直原点→ 用ctx.levelBaseAt(x, z)替代硬编码的0。它返回该层级局部坐标点处的地面高度(该 storey 下方没有地形时返回0)。调用它还会把你的 kind 纳入地形失效机制——地面移动时 builder 会重新运行,无需你手工接线 dirty 规则。但注意:def.floorplan中不存在levelBaseAt——平面图没有高程——所以被 2D/3D 共享的 builder 必须写成ctx.levelBaseAt?.(x, z) ?? 0

  3. 你提供集体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拥有自己的材质,因此必须像内置节点一样遵循宿主的外观轴。以只读方式订阅useViewershadingtexturescolorPresetsceneTheme不要添加插件专属的画质开关,也不要把这些值复制进场景数据。

  • 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。

shadowsedges、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 中treesPluginbonesPluginmintPluginstreetscapePlugin的注册方式),避免覆盖宿主提供的发现源。

宿主面板与项目安装

核心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 关联面板)、workspacesdefaultInstalled等字段。宿主通过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)都调用它做过滤。

creatorpluginUrl是可选的管理员元数据。在 Plugins 侧边栏选择插件会打开其详情页,宿主在此展示元数据与项目的安装/卸载控件。宿主面板在错误边界(error boundary)内懒加载挂载。使用宿主的 CSS 变量、保持 CSS 作用域在插件内,不要写全局样式

版本化

apiVersion: 1覆盖上述全部表面。宿主的策略:删除或改变既有字段的形态时提升 major;新增可选字段不提升。计划是尽可能长期保持向后兼容的增量——bump 是逃生舱,不是默认

插件自身的数据版本化是每个NodeDefinition上的schemaVersion。宿主不做迁移;插件的migrate(node, fromVersion)(未来实现)负责处理自己的遗留持久化节点。

哪些尚不属于插件贡献

契约刻意保持窄边界以便可交付,每个"not yet"都是计划而非"永不":

  • 材质—— 没有plugin.materials槽位。在def.renderer/def.system内使用@pascal-app/viewercreateMaterial
  • 平面图原语——FloorplanGeometry联合类型归宿主所有。要绘制联合类型无法表达的内容,退回def.renderer,通过另一个 2D 挂载点渲染(或提交 issue)。
  • 核心清单中的面板 / 侧边栏 UI—— 宿主相关。为使用@pascal-app/editor的宿主单独导出EditorHostPanel
  • Stores—— 插件创建自己的 Zustand store;它们不扩展useSceneuseEditoruseViewer。渲染器可以只读订阅导出的宿主表现状态(如useViewer外观轴),但不得把宿主 store 当作插件自有状态。
  • 路由 / 页面—— 插件是可视化 + 交互代码,不是完整应用表面。承载设置页属于应用。

测试你的插件

@pascal-app/nodes是内置参考实现,独立的pascalorg/plugin-trees是独立示例。本地测试步骤:

  1. 把插件构建为普通 npm 包,@pascal-app/*作为 peerDependencies。
  2. 在消费你内置包的宿主应用(apps/editor是最容易的目标)中,接线setPluginDiscovery返回你的插件。
  3. 开发模式的[pascal:registry]控制台日志会显示加载的插件 id + 节点数量——这就是验证锚点(对应 bootstrap.ts 的+ N discovered plugin(s)输出)。

宿主的对等测试(packages/nodes/src/index.test.ts)断言每个AnyNode判别符都有已注册的 kind。插件贡献的 kind不参与该测试(它们不在AnyNode中);如果你在别处维护手写类型联合,请自行添加等价测试。

小结

插件在 Pascal 中的定位非常明确:一个Plugin清单 + 若干NodeDefinition,通过setPluginDiscovery交给宿主,在启动时一次性加载并冻结注册表。地形落地的三条路径(floorPlacedctx.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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 7:33:54

LKY Office Tools:一键完成 Office 自动安装

LKY Office Tools&#xff1a;一键完成 Office 自动安装 【免费下载链接】LKY_OfficeTools 一键自动化 下载、安装、激活 Office 的利器。 项目地址: https://gitcode.com/GitHub_Trending/lk/LKY_OfficeTools 刚重装完 Windows&#xff0c;还缺一套 Office&#xff1f;…

作者头像 李华
网站建设 2026/9/13 7:33:00

gs-quant 量化回测快速上手:从鉴权到策略跑通的完整教程

gs-quant 量化回测快速上手&#xff1a;从鉴权到策略跑通的完整教程 【免费下载链接】gs-quant Python toolkit for quantitative finance 项目地址: https://gitcode.com/GitHub_Trending/gs/gs-quant gs-quant 是高盛量化团队打造的 Python 量化金融工具包&#xff0c…

作者头像 李华
网站建设 2026/9/13 7:31:11

verl 中 PPO 训练器的完整实战指南:从核心配置到源码级原理

verl 中 PPO 训练器的完整实战指南&#xff1a;从核心配置到源码级原理 【免费下载链接】verl verl/HybridFlow: A Flexible and Efficient RL Post-Training Framework 项目地址: https://gitcode.com/GitHub_Trending/ve/verl 导读 PPO&#xff08;Proximal Policy …

作者头像 李华
网站建设 2026/9/13 7:29:17

AI驱动的人机交互革命:从编程到自然语言操作

1. 从"会编程"到"会操作"&#xff1a;AI能力边界的重大迁移三年前&#xff0c;当我在科技公司第一次接触AI编程助手时&#xff0c;团队里最兴奋的是那些能熟练编写Python的工程师。他们用几行代码就能调用GPT-3的API&#xff0c;把自然语言转换成可执行的S…

作者头像 李华