feat: add project plugin management (#501)

This commit is contained in:
Wassim SAMAD
2026-07-16 10:26:59 -04:00
committed by GitHub
parent 4f0aa7f7b6
commit 9ca3eaa7fe
88 changed files with 684 additions and 3100 deletions
+2
View File
@@ -20,10 +20,12 @@ export {
discoverPlugins,
extendPluginDiscovery,
getHostRefFields,
getNodePluginId,
getSelectableKinds,
hasRegistry3DMoveTool,
isDrawnViaTool,
isDrawnViaToolKind,
isNodeKindEnabled,
isPresettable,
isPresettableKind,
isRegistryMovable,
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import {
getHostRefFields,
getNodePluginId,
isDrawnViaTool,
isDrawnViaToolKind,
isNodeKindEnabled,
isPresettable,
isPresettableKind,
loadPlugin,
@@ -188,6 +190,23 @@ describe('loadPlugin', () => {
expect(nodeRegistry.size).toBe(2)
expect(nodeRegistry.has('a')).toBe(true)
expect(nodeRegistry.has('b')).toBe(true)
expect(getNodePluginId('a')).toBe('test:plugin')
expect(getNodePluginId('b')).toBe('test:plugin')
})
test('enables plugin kinds only when the project has the plugin installed', async () => {
await loadPlugin({ id: 'test:plugin', apiVersion: 1, nodes: [makeDefinition('plugin:node')] })
expect(isNodeKindEnabled('plugin:node', [])).toBe(false)
expect(isNodeKindEnabled('plugin:node', ['test:plugin'])).toBe(true)
expect(isNodeKindEnabled('plugin:node')).toBe(true)
expect(isNodeKindEnabled('host:node', [])).toBe(true)
})
test('keeps built-in plugin kinds enabled independently of project installs', async () => {
await loadPlugin({ id: 'pascal:core', apiVersion: 1, nodes: [makeDefinition('wall')] })
expect(isNodeKindEnabled('wall', [])).toBe(true)
})
test('handles plugin with no nodes', async () => {
+22
View File
@@ -2,6 +2,9 @@ import type { ZodObject } from 'zod'
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types'
const HOST_API_VERSION = 1 as const
const BUILTIN_PLUGIN_ID = 'pascal:core'
const pluginIdsByKind = new Map<string, string>()
// True in dev / test builds, false in production. Tries Vite's
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
@@ -74,6 +77,7 @@ class NodeRegistryImpl implements NodeRegistry {
// Test-only — clears the registry. Not exported from the package barrel.
_reset(): void {
this.defs.clear()
pluginIdsByKind.clear()
}
}
@@ -86,6 +90,23 @@ export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def)
}
/** The plugin that registered a node kind, when it came through {@link loadPlugin}. */
export function getNodePluginId(kind: string): string | undefined {
return pluginIdsByKind.get(kind)
}
/**
* Whether a registered kind should participate in a project. Kinds registered
* directly by the host and the built-in plugin are always enabled. An omitted
* install list means a legacy scene whose plugin state predates persistence, so
* loaded plugins remain visible for backward compatibility.
*/
export function isNodeKindEnabled(kind: string, installedPlugins?: readonly string[]): boolean {
const pluginId = getNodePluginId(kind)
if (!pluginId || pluginId === BUILTIN_PLUGIN_ID || installedPlugins === undefined) return true
return installedPlugins.includes(pluginId)
}
/**
* Returns the set of registered kinds whose definition declares the
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
@@ -244,6 +265,7 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
}
for (const def of plugin.nodes ?? []) {
registerNode(def)
pluginIdsByKind.set(def.kind, plugin.id)
}
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { loadPlugin, nodeRegistry } from '../registry'
import type { AnyNodeDefinition } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema'
import useScene from './use-scene'
describe('scene plugin installation state', () => {
beforeEach(() => {
nodeRegistry._reset()
useScene.getState().setReadOnly(false)
useScene.getState().unloadScene()
})
test('loads an explicit installed plugin list with the scene', () => {
useScene.getState().setScene({}, [], {
installedPlugins: ['pascal:trees'],
hasExplicitPluginInstallState: true,
})
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
})
test('install changes are de-duplicated and become explicit', () => {
useScene.getState().setInstalledPlugins(['pascal:trees', 'pascal:trees'], { explicit: true })
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
})
test('clearing geometry preserves project plugin installs', () => {
useScene.getState().setInstalledPlugins(['pascal:trees'], { explicit: true })
useScene.getState().clearScene()
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
})
test('uninstall clears plugin build work and reinstall schedules it again', async () => {
const kind = 'test:plugin-node'
const definition = {
kind,
schemaVersion: 1,
schema: z.object({ id: z.string(), type: z.literal(kind) }),
category: 'utility',
defaults: () => ({}),
capabilities: {},
} as unknown as AnyNodeDefinition
await loadPlugin({ id: 'test:plugin', apiVersion: 1, nodes: [definition] })
const nodeId = 'plugin_node' as AnyNodeId
useScene.getState().setScene(
{
[nodeId]: { id: nodeId, type: kind } as unknown as AnyNode,
},
[nodeId],
{ installedPlugins: ['test:plugin'], hasExplicitPluginInstallState: true },
)
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(true)
useScene.getState().setInstalledPlugins([], { explicit: true })
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(false)
useScene.getState().setInstalledPlugins(['test:plugin'], { explicit: true })
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(true)
})
})
+41 -4
View File
@@ -4,7 +4,7 @@ import type { TemporalState } from 'zundo'
import { temporal } from 'zundo'
import { create, type StoreApi, type UseBoundStore } from 'zustand'
import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
import { nodeRegistry } from '../registry/registry'
import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry'
import { BuildingNode } from '../schema'
import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections'
@@ -952,6 +952,8 @@ export type SceneState = {
// 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection>
materials: Record<SceneMaterialId, SceneMaterial>
installedPlugins: string[]
hasExplicitPluginInstallState: boolean
// 5. Read-only lock — when true all create/update/delete operations are no-ops
readOnly: boolean
@@ -967,8 +969,11 @@ export type SceneState = {
extra?: {
collections?: Record<CollectionId, Collection>
materials?: Record<SceneMaterialId, SceneMaterial>
installedPlugins?: string[]
hasExplicitPluginInstallState?: boolean
},
) => void
setInstalledPlugins: (pluginIds: string[], options?: { explicit?: boolean }) => void
markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void
@@ -1004,7 +1009,9 @@ export type SceneState = {
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
temporal: StoreApi<
TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials'>>
TemporalState<
Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins'>
>
>
}
@@ -1023,6 +1030,8 @@ const useScene: UseSceneStore = create<SceneState>()(
// 4. Collections
collections: {} as Record<CollectionId, Collection>,
materials: {} as Record<SceneMaterialId, SceneMaterial>,
installedPlugins: [],
hasExplicitPluginInstallState: false,
// 5. Read-only lock
readOnly: false,
@@ -1035,12 +1044,17 @@ const useScene: UseSceneStore = create<SceneState>()(
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
materials: {},
installedPlugins: [],
hasExplicitPluginInstallState: false,
})
},
clearScene: () => {
const installedPlugins = get().installedPlugins
const hasExplicitPluginInstallState = get().hasExplicitPluginInstallState
get().unloadScene()
get().loadScene() // Default scene
set({ installedPlugins, hasExplicitPluginInstallState })
},
setScene: (nodes, rootNodeIds, extra) => {
@@ -1086,6 +1100,8 @@ const useScene: UseSceneStore = create<SceneState>()(
dirtyNodes: new Set<AnyNodeId>(),
collections: extra?.collections ?? {},
materials,
installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
})
// Mark all nodes as dirty to trigger re-validation
Object.values(cleanedNodes).forEach((node) => {
@@ -1093,6 +1109,26 @@ const useScene: UseSceneStore = create<SceneState>()(
})
},
setInstalledPlugins: (pluginIds, options) => {
if (get().readOnly) return
const nextInstalledPlugins = Array.from(new Set(pluginIds))
const previousInstalledPlugins = get().installedPlugins
const dirtyNodes = new Set(get().dirtyNodes)
for (const node of Object.values(get().nodes)) {
if (!getNodePluginId(node.type)) continue
if (!isNodeKindEnabled(node.type, nextInstalledPlugins)) {
dirtyNodes.delete(node.id)
} else if (!isNodeKindEnabled(node.type, previousInstalledPlugins)) {
if (nodeRegistry.get(node.type)?.dirtyTracking !== false) dirtyNodes.add(node.id)
}
}
set({
installedPlugins: nextInstalledPlugins,
hasExplicitPluginInstallState: options?.explicit ?? get().hasExplicitPluginInstallState,
dirtyNodes,
})
},
loadScene: () => {
if (get().rootNodeIds.length > 0) {
// Assign all nodes as dirty to force re-validation
@@ -1131,6 +1167,7 @@ const useScene: UseSceneStore = create<SceneState>()(
markDirty: (id) => {
const node = get().nodes[id]
if (node && !isNodeKindEnabled(node.type, get().installedPlugins)) return
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return
get().dirtyNodes.add(id)
},
@@ -1275,8 +1312,8 @@ const useScene: UseSceneStore = create<SceneState>()(
}),
{
partialize: (state) => {
const { nodes, rootNodeIds, collections, materials } = state
return { nodes, rootNodeIds, collections, materials }
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
return { nodes, rootNodeIds, collections, materials, installedPlugins }
},
limit: 50, // Limit to last 50 actions
},
@@ -41,6 +41,7 @@ function makeSceneGraph(): SceneGraph {
nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[],
},
},
installedPlugins: ['pascal:trees'],
}
}
@@ -53,6 +54,7 @@ describe('forkSceneGraph', () => {
expect(nodes.some((node) => node.type === 'guide')).toBe(false)
expect(nodes.some((node) => node.type === 'wall')).toBe(true)
expect(forked.collections).toEqual({})
expect(forked.installedPlugins).toEqual(['pascal:trees'])
})
test('preserves scan and guide nodes when requested', () => {
@@ -66,5 +68,6 @@ describe('forkSceneGraph', () => {
expect(
Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds),
).toHaveLength(2)
expect(forked.installedPlugins).toEqual(['pascal:trees'])
})
})
+5 -2
View File
@@ -6,6 +6,7 @@ export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[]
collections?: Record<CollectionId, Collection>
installedPlugins?: string[]
}
/**
@@ -26,7 +27,7 @@ function extractIdPrefix(id: string): string {
* - Multi-scene in-memory scenarios
*/
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const { nodes, rootNodeIds, collections } = sceneGraph
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
// Build ID mapping: old ID -> new ID
const idMap = new Map<string, string>()
@@ -134,6 +135,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
nodes: clonedNodes,
rootNodeIds: clonedRootNodeIds,
...(clonedCollections && { collections: clonedCollections }),
...(installedPlugins && { installedPlugins: [...installedPlugins] }),
}
}
@@ -255,7 +257,7 @@ export function forkSceneGraph(
return cloneSceneGraph(sceneGraph)
}
const { nodes, rootNodeIds, collections } = sceneGraph
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
const excludedNodeIds = new Set<string>()
@@ -317,5 +319,6 @@ export function forkSceneGraph(
nodes: filteredNodes,
rootNodeIds: filteredRootNodeIds,
...(filteredCollections && { collections: filteredCollections }),
...(installedPlugins && { installedPlugins }),
})
}
@@ -20,6 +20,7 @@ export type BuildStats = {
export type ParsedBuildJson = {
nodes: Record<string, unknown>
rootNodeIds: string[]
installedPlugins?: string[]
}
export type SchemaIssue = {
@@ -100,6 +101,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
const nodesRaw = input.nodes
const rootNodeIdsRaw = input.rootNodeIds
const installedPluginsRaw = input.installedPlugins
if (!isPlainObject(nodesRaw)) {
errors.push({
@@ -135,6 +137,19 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
nodesRaw as Record<string, unknown>,
)
const rootNodeIds = rootNodeIdsRaw as string[]
const installedPlugins =
Array.isArray(installedPluginsRaw) &&
installedPluginsRaw.every((pluginId) => typeof pluginId === 'string')
? Array.from(new Set(installedPluginsRaw))
: undefined
if (installedPluginsRaw !== undefined && installedPlugins === undefined) {
warnings.push({
severity: 'warning',
code: 'invalid_installed_plugins',
message: 'Ignored invalid "installedPlugins" — expected an array of plugin IDs.',
})
}
if (strippedChildRefs > 0 || droppedWallIds.length > 0) {
warnings.push({
@@ -296,7 +311,13 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
const ok = errors.length === 0
return {
ok,
parsed: ok ? { nodes, rootNodeIds } : null,
parsed: ok
? {
nodes,
rootNodeIds,
...(installedPlugins ? { installedPlugins } : {}),
}
: null,
stats,
errors,
warnings,