feat: add project plugin management (#501)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user