Add node registry primitives (PR 0.1 of node-registry plan)
Introduces the @pascal-app/core/registry surface that future node-bundle packages (and external plugins) will use to register node kinds with the host. No runtime behavior changes — registry is empty until subsequent PRs populate it. - types.ts: NodeDefinition, Capabilities, Relations, DragAction, Plugin, ParametricDescriptor, Affordance, SceneApi, NodeRegistry. Capability configs accept an override escape hatch; additive-only after v1. - registry.ts: nodeRegistry singleton, registerNode, async loadPlugin. Validates kind, schemaVersion, apiVersion; rejects duplicate kinds. - scene-api.ts: createSceneApi factory wrapping the scene store with copy-on-write snapshot semantics for pauseHistory/restore/resumeHistory. - index.ts: barrel re-exporting the public surface. - core/index.ts + package.json: export * from registry and add the ./registry subpath so consumers can import either way. Tests (27 cases, all bun:test): registry registration / validation / plugin loading; SceneApi read/write/dirty/history; lazy snapshot capture with update/upsert/delete reversal via restore and restoreAll. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c50a4df8e1
commit
fb91713374
@@ -44,10 +44,10 @@ export {
|
||||
} from './lib/door-operation'
|
||||
export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
type AutoSlabSyncPlan,
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
planAutoSlabsForLevel,
|
||||
type AutoSlabSyncPlan,
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
type MaterialCategory,
|
||||
toLibraryMaterialRef,
|
||||
} from './material-library'
|
||||
export * from './registry'
|
||||
export * from './schema'
|
||||
export {
|
||||
getSceneHistoryPauseDepth,
|
||||
@@ -89,6 +90,7 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||
export {
|
||||
type ElevatorDoorSide,
|
||||
getElevatorCabCenterZ,
|
||||
getElevatorCabDepth,
|
||||
getElevatorCabWidth,
|
||||
@@ -101,7 +103,6 @@ export {
|
||||
getResolvedElevatorDoorPanelStyle,
|
||||
getResolvedElevatorDoorStyle,
|
||||
getResolvedElevatorShaftStyle,
|
||||
type ElevatorDoorSide,
|
||||
} from './systems/elevator/elevator-geometry'
|
||||
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
|
||||
export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system'
|
||||
@@ -157,8 +158,8 @@ export {
|
||||
constrainWallMoveDeltaToAxis,
|
||||
getPerpendicularWallMoveAxis,
|
||||
planWallMoveJunctions,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallPlanPoint,
|
||||
} from './systems/wall/wall-move'
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export { loadPlugin, nodeRegistry, registerNode } from './registry'
|
||||
export { createSceneApi, type SceneStoreLike } from './scene-api'
|
||||
export type {
|
||||
Affordance,
|
||||
AnyNodeDefinition,
|
||||
AssetRef,
|
||||
Capabilities,
|
||||
CapabilityCtx,
|
||||
CuttableConfig,
|
||||
DragAction,
|
||||
EditorCtx,
|
||||
HostableConfig,
|
||||
Issue,
|
||||
LazyComponent,
|
||||
McpOverrides,
|
||||
Modifiers,
|
||||
MovableConfig,
|
||||
NodeCategory,
|
||||
NodeDefinition,
|
||||
NodeRegistry,
|
||||
ParametricDescriptor,
|
||||
ParamField,
|
||||
ParamGroup,
|
||||
Plugin,
|
||||
Relations,
|
||||
RendererSource,
|
||||
RotatableConfig,
|
||||
ScalableConfig,
|
||||
SceneApi,
|
||||
SelectableConfig,
|
||||
SnappableConfig,
|
||||
SnapPointKind,
|
||||
SnapServicesLike,
|
||||
SurfacePoint,
|
||||
SurfaceQuery,
|
||||
SurfacesConfig,
|
||||
SystemContribution,
|
||||
Vec2,
|
||||
} from './types'
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { loadPlugin, nodeRegistry, registerNode } from './registry'
|
||||
import type { AnyNodeDefinition, Plugin } from './types'
|
||||
|
||||
function makeDefinition(
|
||||
kind: string,
|
||||
overrides: Partial<AnyNodeDefinition> = {},
|
||||
): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal(kind) }) as any,
|
||||
category: 'utility',
|
||||
defaults: () => ({}) as any,
|
||||
capabilities: {},
|
||||
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('nodeRegistry', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('starts empty', () => {
|
||||
expect(nodeRegistry.size).toBe(0)
|
||||
expect(nodeRegistry.has('anything')).toBe(false)
|
||||
expect(nodeRegistry.get('anything')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('registerNode adds a definition', () => {
|
||||
const def = makeDefinition('column')
|
||||
registerNode(def)
|
||||
expect(nodeRegistry.size).toBe(1)
|
||||
expect(nodeRegistry.has('column')).toBe(true)
|
||||
expect(nodeRegistry.get('column')).toBe(def)
|
||||
})
|
||||
|
||||
test('registerNode throws on duplicate kind', () => {
|
||||
registerNode(makeDefinition('column'))
|
||||
expect(() => registerNode(makeDefinition('column'))).toThrow(/duplicate node kind/)
|
||||
})
|
||||
|
||||
test('registerNode rejects empty kind', () => {
|
||||
expect(() => registerNode(makeDefinition(''))).toThrow(/non-empty string/)
|
||||
})
|
||||
|
||||
test('registerNode rejects invalid schemaVersion', () => {
|
||||
expect(() => registerNode(makeDefinition('bad', { schemaVersion: 0 }))).toThrow(/schemaVersion/)
|
||||
expect(() => registerNode(makeDefinition('bad', { schemaVersion: -1 }))).toThrow(
|
||||
/schemaVersion/,
|
||||
)
|
||||
})
|
||||
|
||||
test('entries() iterates registered definitions', () => {
|
||||
registerNode(makeDefinition('a'))
|
||||
registerNode(makeDefinition('b'))
|
||||
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
|
||||
expect(kinds).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
test('schemas() returns all registered schemas', () => {
|
||||
const a = makeDefinition('a')
|
||||
const b = makeDefinition('b')
|
||||
registerNode(a)
|
||||
registerNode(b)
|
||||
expect(nodeRegistry.schemas()).toEqual([a.schema, b.schema])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadPlugin', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('registers all nodes from a plugin', async () => {
|
||||
const plugin: Plugin = {
|
||||
id: 'test:plugin',
|
||||
apiVersion: 1,
|
||||
nodes: [makeDefinition('a'), makeDefinition('b')],
|
||||
}
|
||||
await loadPlugin(plugin)
|
||||
expect(nodeRegistry.size).toBe(2)
|
||||
expect(nodeRegistry.has('a')).toBe(true)
|
||||
expect(nodeRegistry.has('b')).toBe(true)
|
||||
})
|
||||
|
||||
test('handles plugin with no nodes', async () => {
|
||||
await loadPlugin({ id: 'empty', apiVersion: 1 })
|
||||
expect(nodeRegistry.size).toBe(0)
|
||||
})
|
||||
|
||||
test('handles plugin with empty nodes array', async () => {
|
||||
await loadPlugin({ id: 'empty', apiVersion: 1, nodes: [] })
|
||||
expect(nodeRegistry.size).toBe(0)
|
||||
})
|
||||
|
||||
test('throws on apiVersion mismatch', async () => {
|
||||
const plugin = {
|
||||
id: 'old-plugin',
|
||||
apiVersion: 99 as unknown as 1,
|
||||
nodes: [],
|
||||
}
|
||||
await expect(loadPlugin(plugin)).rejects.toThrow(/apiVersion/)
|
||||
})
|
||||
|
||||
test('propagates duplicate-kind error from a single plugin', async () => {
|
||||
const plugin: Plugin = {
|
||||
id: 'broken',
|
||||
apiVersion: 1,
|
||||
nodes: [makeDefinition('dup'), makeDefinition('dup')],
|
||||
}
|
||||
await expect(loadPlugin(plugin)).rejects.toThrow(/duplicate node kind/)
|
||||
})
|
||||
|
||||
test('propagates duplicate-kind error across plugins', async () => {
|
||||
await loadPlugin({ id: 'a', apiVersion: 1, nodes: [makeDefinition('shared')] })
|
||||
await expect(
|
||||
loadPlugin({ id: 'b', apiVersion: 1, nodes: [makeDefinition('shared')] }),
|
||||
).rejects.toThrow(/duplicate node kind/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ZodObject } from 'zod'
|
||||
import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
|
||||
|
||||
const HOST_API_VERSION = 1 as const
|
||||
|
||||
class NodeRegistryImpl implements NodeRegistry {
|
||||
private readonly defs = new Map<string, AnyNodeDefinition>()
|
||||
|
||||
has(kind: string): boolean {
|
||||
return this.defs.has(kind)
|
||||
}
|
||||
|
||||
get(kind: string): AnyNodeDefinition | undefined {
|
||||
return this.defs.get(kind)
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[string, AnyNodeDefinition]> {
|
||||
return this.defs.entries()
|
||||
}
|
||||
|
||||
schemas(): ZodObject<any>[] {
|
||||
return Array.from(this.defs.values(), (d) => d.schema)
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.defs.size
|
||||
}
|
||||
|
||||
// Internal — exposed via registerNode below.
|
||||
_register(def: AnyNodeDefinition): void {
|
||||
if (this.defs.has(def.kind)) {
|
||||
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
|
||||
}
|
||||
if (typeof def.kind !== 'string' || def.kind.length === 0) {
|
||||
throw new Error('[registry] NodeDefinition.kind must be a non-empty string')
|
||||
}
|
||||
if (typeof def.schemaVersion !== 'number' || def.schemaVersion < 1) {
|
||||
throw new Error(
|
||||
`[registry] NodeDefinition.schemaVersion must be a positive integer (kind: "${def.kind}")`,
|
||||
)
|
||||
}
|
||||
this.defs.set(def.kind, def)
|
||||
}
|
||||
|
||||
// Test-only — clears the registry. Not exported from the package barrel.
|
||||
_reset(): void {
|
||||
this.defs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const nodeRegistry: NodeRegistry & {
|
||||
_register: (def: AnyNodeDefinition) => void
|
||||
_reset: () => void
|
||||
} = new NodeRegistryImpl()
|
||||
|
||||
export function registerNode(def: AnyNodeDefinition): void {
|
||||
nodeRegistry._register(def)
|
||||
}
|
||||
|
||||
export async function loadPlugin(plugin: Plugin): Promise<void> {
|
||||
if (plugin.apiVersion !== HOST_API_VERSION) {
|
||||
throw new Error(
|
||||
`[registry] plugin "${plugin.id}" requires apiVersion ${plugin.apiVersion}; host supports ${HOST_API_VERSION}`,
|
||||
)
|
||||
}
|
||||
for (const def of plugin.nodes ?? []) {
|
||||
registerNode(def)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { resetSceneHistoryPauseDepth } from '../store/history-control'
|
||||
import { createSceneApi, type SceneStoreLike } from './scene-api'
|
||||
|
||||
function makeFakeStore(initial: Record<string, AnyNode> = {}) {
|
||||
const state = {
|
||||
nodes: { ...initial } as Record<AnyNodeId, AnyNode>,
|
||||
rootNodeIds: [] as AnyNodeId[],
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
createNode(node: AnyNode) {
|
||||
state.nodes[node.id] = node
|
||||
},
|
||||
updateNode(id: AnyNodeId, data: Partial<AnyNode>) {
|
||||
const existing = state.nodes[id]
|
||||
if (existing) state.nodes[id] = { ...existing, ...data } as AnyNode
|
||||
},
|
||||
deleteNode(id: AnyNodeId) {
|
||||
delete state.nodes[id]
|
||||
},
|
||||
markDirty(id: AnyNodeId) {
|
||||
state.dirtyNodes.add(id)
|
||||
},
|
||||
}
|
||||
|
||||
let paused = 0
|
||||
const temporal = {
|
||||
getState: () => ({
|
||||
pause: () => {
|
||||
paused += 1
|
||||
},
|
||||
resume: () => {
|
||||
paused -= 1
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const store: SceneStoreLike & { _state: typeof state; _pausedCount: () => number } = {
|
||||
getState: () => state,
|
||||
temporal,
|
||||
_state: state,
|
||||
_pausedCount: () => paused,
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function makeNode(id: string, extra: Record<string, unknown> = {}): AnyNode {
|
||||
return { id, type: 'site', parentId: null, visible: true, ...extra } as unknown as AnyNode
|
||||
}
|
||||
|
||||
// Tests use short string IDs ("a", "b") for readability. The store's
|
||||
// AnyNodeId is a branded template-literal type — cast at the boundary.
|
||||
const id = (s: string) => s as AnyNodeId
|
||||
const nodes = (store: ReturnType<typeof makeFakeStore>) =>
|
||||
store._state.nodes as unknown as Record<string, AnyNode>
|
||||
|
||||
describe('SceneApi', () => {
|
||||
beforeEach(() => {
|
||||
resetSceneHistoryPauseDepth()
|
||||
})
|
||||
|
||||
test('get reads node from store', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a') })
|
||||
const api = createSceneApi(store)
|
||||
expect(api.get(id('a'))).toEqual(makeNode('a'))
|
||||
expect(api.get(id('missing'))).toBeUndefined()
|
||||
})
|
||||
|
||||
test('update applies patch via store.updateNode', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
|
||||
const api = createSceneApi(store)
|
||||
api.update(id('a'), { visible: false } as Partial<AnyNode>)
|
||||
expect(nodes(store)['a']).toMatchObject({ visible: false })
|
||||
})
|
||||
|
||||
test('upsert calls createNode and returns the id', () => {
|
||||
const store = makeFakeStore()
|
||||
const api = createSceneApi(store)
|
||||
const returnedId = api.upsert(makeNode('a'))
|
||||
expect(returnedId).toBe(id('a'))
|
||||
expect(nodes(store)['a']).toBeDefined()
|
||||
})
|
||||
|
||||
test('delete removes node from store', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a') })
|
||||
const api = createSceneApi(store)
|
||||
api.delete(id('a'))
|
||||
expect(nodes(store)['a']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('markDirty forwards to store', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a') })
|
||||
const api = createSceneApi(store)
|
||||
api.markDirty(id('a'))
|
||||
expect(store._state.dirtyNodes.has(id('a'))).toBe(true)
|
||||
})
|
||||
|
||||
test('pauseHistory and resumeHistory bracket store.temporal pause/resume', () => {
|
||||
const store = makeFakeStore()
|
||||
const api = createSceneApi(store)
|
||||
expect(store._pausedCount()).toBe(0)
|
||||
api.pauseHistory()
|
||||
expect(store._pausedCount()).toBe(1)
|
||||
api.resumeHistory()
|
||||
expect(store._pausedCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('nested pause/resume use a depth counter (single pause call to temporal)', () => {
|
||||
const store = makeFakeStore()
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.pauseHistory()
|
||||
expect(store._pausedCount()).toBe(1) // only one actual pause
|
||||
api.resumeHistory()
|
||||
expect(store._pausedCount()).toBe(1) // still paused — inner resume
|
||||
api.resumeHistory()
|
||||
expect(store._pausedCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SceneApi snapshot / restore', () => {
|
||||
beforeEach(() => {
|
||||
resetSceneHistoryPauseDepth()
|
||||
})
|
||||
|
||||
test('restore returns a touched node to its pre-pause state', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.update(id('a'), { visible: false } as Partial<AnyNode>)
|
||||
expect(nodes(store)['a']).toMatchObject({ visible: false })
|
||||
api.restore(id('a'))
|
||||
expect(nodes(store)['a']).toMatchObject({ visible: true })
|
||||
api.resumeHistory()
|
||||
})
|
||||
|
||||
test('restore on a node never touched is a no-op', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.restore(id('a'))
|
||||
expect(nodes(store)['a']).toMatchObject({ visible: true })
|
||||
api.resumeHistory()
|
||||
})
|
||||
|
||||
test('restoreAll reverts every touched node', () => {
|
||||
const store = makeFakeStore({
|
||||
a: makeNode('a', { visible: true }),
|
||||
b: makeNode('b', { visible: true }),
|
||||
})
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.update(id('a'), { visible: false } as Partial<AnyNode>)
|
||||
api.update(id('b'), { visible: false } as Partial<AnyNode>)
|
||||
api.restoreAll()
|
||||
expect(nodes(store)['a']).toMatchObject({ visible: true })
|
||||
expect(nodes(store)['b']).toMatchObject({ visible: true })
|
||||
api.resumeHistory()
|
||||
})
|
||||
|
||||
test('restore re-creates a node that was deleted mid-pause', () => {
|
||||
const original = makeNode('a', { visible: true })
|
||||
const store = makeFakeStore({ a: original })
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.delete(id('a'))
|
||||
expect(nodes(store)['a']).toBeUndefined()
|
||||
api.restore(id('a'))
|
||||
expect(nodes(store)['a']).toEqual(original)
|
||||
api.resumeHistory()
|
||||
})
|
||||
|
||||
test('restore deletes a node that was upserted mid-pause', () => {
|
||||
const store = makeFakeStore()
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.upsert(makeNode('a'))
|
||||
expect(nodes(store)['a']).toBeDefined()
|
||||
api.restore(id('a'))
|
||||
expect(nodes(store)['a']).toBeUndefined()
|
||||
api.resumeHistory()
|
||||
})
|
||||
|
||||
test('snapshot is dropped on resumeHistory; restore after resume is a no-op', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.update(id('a'), { visible: false } as Partial<AnyNode>)
|
||||
api.resumeHistory()
|
||||
api.restore(id('a')) // snapshot gone — no effect
|
||||
expect(nodes(store)['a']).toMatchObject({ visible: false })
|
||||
})
|
||||
|
||||
test('only the first mutation in a pause window captures the original', () => {
|
||||
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
|
||||
const api = createSceneApi(store)
|
||||
api.pauseHistory()
|
||||
api.update(id('a'), { visible: false } as Partial<AnyNode>)
|
||||
api.update(id('a'), { visible: true } as Partial<AnyNode>) // second update — must not overwrite snapshot
|
||||
api.update(id('a'), { visible: false } as Partial<AnyNode>)
|
||||
api.restore(id('a'))
|
||||
expect(nodes(store)['a']).toMatchObject({ visible: true }) // the *first* pre-pause value
|
||||
api.resumeHistory()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control'
|
||||
import type { SceneApi } from './types'
|
||||
|
||||
/**
|
||||
* Minimal store shape this module depends on.
|
||||
*
|
||||
* Decoupled from `useScene` directly so the production singleton and tests can
|
||||
* share one factory. The full store implements a superset.
|
||||
*/
|
||||
export type SceneStoreLike = {
|
||||
getState: () => {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rootNodeIds: AnyNodeId[]
|
||||
dirtyNodes: Set<AnyNodeId>
|
||||
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
|
||||
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
|
||||
deleteNode: (id: AnyNodeId) => void
|
||||
markDirty: (id: AnyNodeId) => void
|
||||
}
|
||||
temporal: {
|
||||
getState: () => { pause: () => void; resume: () => void }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link SceneApi} backed by a store.
|
||||
*
|
||||
* Snapshot semantics:
|
||||
* - `pauseHistory()` starts a copy-on-write window. The first time `update`,
|
||||
* `upsert`, or `delete` touches a node id, the pre-change value is captured.
|
||||
* - `restore(id)` and `restoreAll()` apply the captured value back. Either is
|
||||
* safe to call only while a pause window is active.
|
||||
* - `resumeHistory()` drops the snapshot.
|
||||
*
|
||||
* Snapshots are lazy and bounded by the number of nodes touched during the
|
||||
* pause window — never an upfront clone of the entire scene.
|
||||
*/
|
||||
export function createSceneApi(store: SceneStoreLike): SceneApi {
|
||||
let snapshot: Map<AnyNodeId, AnyNode | null> | null = null
|
||||
|
||||
function captureIfNeeded(id: AnyNodeId): void {
|
||||
if (!snapshot || snapshot.has(id)) return
|
||||
const existing = store.getState().nodes[id]
|
||||
snapshot.set(id, existing ?? null)
|
||||
}
|
||||
|
||||
return {
|
||||
get<N extends AnyNode = AnyNode>(id: AnyNodeId): N | undefined {
|
||||
return store.getState().nodes[id] as N | undefined
|
||||
},
|
||||
|
||||
update(id, patch) {
|
||||
captureIfNeeded(id)
|
||||
store.getState().updateNode(id, patch)
|
||||
},
|
||||
|
||||
upsert(node, parentId) {
|
||||
captureIfNeeded(node.id)
|
||||
store.getState().createNode(node, parentId)
|
||||
return node.id
|
||||
},
|
||||
|
||||
delete(id) {
|
||||
captureIfNeeded(id)
|
||||
store.getState().deleteNode(id)
|
||||
},
|
||||
|
||||
restore(id) {
|
||||
if (!snapshot) return
|
||||
const original = snapshot.get(id)
|
||||
if (original === undefined) return
|
||||
const current = store.getState().nodes[id]
|
||||
if (original === null) {
|
||||
if (current) store.getState().deleteNode(id)
|
||||
} else if (!current) {
|
||||
store.getState().createNode(original)
|
||||
} else {
|
||||
store.getState().updateNode(id, original)
|
||||
}
|
||||
},
|
||||
|
||||
restoreAll() {
|
||||
if (!snapshot) return
|
||||
for (const id of snapshot.keys()) {
|
||||
this.restore(id)
|
||||
}
|
||||
},
|
||||
|
||||
markDirty(id) {
|
||||
store.getState().markDirty(id)
|
||||
},
|
||||
|
||||
pauseHistory() {
|
||||
pauseSceneHistory(store)
|
||||
if (!snapshot) snapshot = new Map()
|
||||
},
|
||||
|
||||
resumeHistory() {
|
||||
resumeSceneHistory(store)
|
||||
snapshot = null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { ZodObject, z } from 'zod'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
|
||||
// ─── Plugin manifest ─────────────────────────────────────────────────
|
||||
|
||||
export type Plugin = {
|
||||
id: string
|
||||
apiVersion: 1
|
||||
nodes?: AnyNodeDefinition[]
|
||||
}
|
||||
|
||||
// ─── NodeDefinition ──────────────────────────────────────────────────
|
||||
|
||||
export type AnyNodeDefinition = NodeDefinition<ZodObject<any>>
|
||||
|
||||
export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
kind: string
|
||||
schemaVersion: number
|
||||
schema: S
|
||||
category: NodeCategory
|
||||
|
||||
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
||||
migrate?: Record<number, (old: unknown) => unknown>
|
||||
|
||||
capabilities: Capabilities
|
||||
relations?: Relations
|
||||
parametrics?: ParametricDescriptor<z.infer<S>>
|
||||
|
||||
renderer: RendererSource<z.infer<S>>
|
||||
system?: SystemContribution
|
||||
tool?: LazyComponent
|
||||
affordances?: Affordance<z.infer<S>>[]
|
||||
|
||||
mcp?: McpOverrides
|
||||
}
|
||||
|
||||
export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
|
||||
|
||||
export type LazyComponent = () => Promise<{ default: ComponentType }>
|
||||
|
||||
export type RendererSource<N> =
|
||||
| {
|
||||
kind: 'parametric'
|
||||
module: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||
}
|
||||
| { kind: 'glb'; getAsset: (n: N) => AssetRef }
|
||||
| { kind: 'instanced-glb'; getAsset: (n: N) => AssetRef }
|
||||
|
||||
export type AssetRef = {
|
||||
id: string
|
||||
src: string
|
||||
}
|
||||
|
||||
export type SystemContribution = {
|
||||
module: () => Promise<{ default: ComponentType }>
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export type McpOverrides = {
|
||||
description?: string
|
||||
semantic?: boolean
|
||||
}
|
||||
|
||||
// ─── Capabilities ────────────────────────────────────────────────────
|
||||
|
||||
export type Capabilities = {
|
||||
movable?: MovableConfig
|
||||
rotatable?: RotatableConfig
|
||||
scalable?: ScalableConfig
|
||||
hostable?: HostableConfig
|
||||
cuttable?: CuttableConfig
|
||||
snappable?: SnappableConfig
|
||||
surfaces?: SurfacesConfig
|
||||
duplicable?: boolean
|
||||
deletable?: boolean
|
||||
groupable?: boolean
|
||||
selectable?: SelectableConfig
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
export type CapabilityCtx = { node: AnyNode }
|
||||
|
||||
export type MovableConfig = {
|
||||
axes: ReadonlyArray<'x' | 'y' | 'z'>
|
||||
gridSnap?: boolean
|
||||
override?: (ctx: CapabilityCtx) => MovableConfig | null
|
||||
}
|
||||
|
||||
export type RotatableConfig = {
|
||||
axes: ReadonlyArray<'x' | 'y' | 'z'>
|
||||
snapAngles?: readonly number[]
|
||||
override?: (ctx: CapabilityCtx) => RotatableConfig | null
|
||||
}
|
||||
|
||||
export type ScalableConfig = {
|
||||
axes: ReadonlyArray<'x' | 'y' | 'z'>
|
||||
min?: number
|
||||
max?: number
|
||||
override?: (ctx: CapabilityCtx) => ScalableConfig | null
|
||||
}
|
||||
|
||||
export type HostableConfig = {
|
||||
parents: readonly string[]
|
||||
align?: 'top' | 'bottom' | 'center' | 'face'
|
||||
fromAsset?: 'attachTo'
|
||||
modes?: Record<string, Partial<HostableConfig>>
|
||||
override?: (ctx: CapabilityCtx) => HostableConfig | null
|
||||
}
|
||||
|
||||
export type CuttableConfig = {
|
||||
hostKinds: readonly string[]
|
||||
override?: (ctx: CapabilityCtx) => CuttableConfig | null
|
||||
}
|
||||
|
||||
export type SnappableConfig = {
|
||||
points?: readonly SnapPointKind[]
|
||||
override?: (ctx: CapabilityCtx) => SnappableConfig | null
|
||||
}
|
||||
|
||||
export type SnapPointKind = 'start' | 'end' | 'midpoint' | 'center' | 'corners'
|
||||
|
||||
export type SurfacesConfig = {
|
||||
top?: { height: number | ((n: AnyNode) => number) }
|
||||
sides?: { faces: 'all' | ReadonlyArray<readonly [number, number, number]> }
|
||||
custom?: SurfaceQuery
|
||||
}
|
||||
|
||||
export type SurfaceQuery = (n: AnyNode) => SurfacePoint[]
|
||||
export type SurfacePoint = {
|
||||
position: readonly [number, number, number]
|
||||
normal: readonly [number, number, number]
|
||||
}
|
||||
|
||||
export type SelectableConfig = {
|
||||
hitVolume?: 'bbox' | 'mesh' | 'none'
|
||||
override?: (ctx: CapabilityCtx) => SelectableConfig | null
|
||||
}
|
||||
|
||||
// ─── Relations ───────────────────────────────────────────────────────
|
||||
|
||||
export type Relations = {
|
||||
linkedBy?: 'endpoint-match' | 'polygon-share' | { custom: (n: AnyNode) => AnyNodeId[] }
|
||||
hosts?: readonly string[]
|
||||
affectsSpatial?: readonly string[]
|
||||
cascadeDelete?: 'descendants' | 'children' | 'none'
|
||||
}
|
||||
|
||||
// ─── ParametricDescriptor ────────────────────────────────────────────
|
||||
|
||||
export type ParametricDescriptor<N> = {
|
||||
groups: ParamGroup<N>[]
|
||||
invariants?: ReadonlyArray<(n: N) => Issue[]>
|
||||
derive?: (n: N) => Partial<N>
|
||||
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||
}
|
||||
|
||||
export type ParamGroup<N> = {
|
||||
label: string
|
||||
fields: ParamField<N>[]
|
||||
}
|
||||
|
||||
export type ParamField<N> =
|
||||
| {
|
||||
key: keyof N
|
||||
kind: 'number'
|
||||
unit?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
visibleIf?: (n: N) => boolean
|
||||
customEditor?: ComponentType
|
||||
}
|
||||
| { key: keyof N; kind: 'enum'; options: readonly string[]; visibleIf?: (n: N) => boolean }
|
||||
| { key: keyof N; kind: 'vec3'; visibleIf?: (n: N) => boolean }
|
||||
| { key: keyof N; kind: 'color'; visibleIf?: (n: N) => boolean }
|
||||
| { key: keyof N; kind: 'material'; visibleIf?: (n: N) => boolean }
|
||||
| { key: keyof N; kind: 'ref'; refKind: string; visibleIf?: (n: N) => boolean }
|
||||
|
||||
export type Issue = { field?: string; msg: string; severity?: 'error' | 'warning' }
|
||||
|
||||
// ─── Affordance ──────────────────────────────────────────────────────
|
||||
|
||||
export type Affordance<N> = {
|
||||
id: string
|
||||
mount: 'on-selection' | 'on-hover' | 'always'
|
||||
enabled?: (n: N, ctx: EditorCtx) => boolean
|
||||
component: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||
}
|
||||
|
||||
export type EditorCtx = {
|
||||
modifiers: Modifiers
|
||||
}
|
||||
|
||||
// ─── DragAction primitive ────────────────────────────────────────────
|
||||
|
||||
export type Vec2 = readonly [number, number]
|
||||
export type Modifiers = { shift: boolean; alt: boolean; ctrl: boolean; meta: boolean }
|
||||
|
||||
export type DragAction<Ctx, Draft> = {
|
||||
begin: (input: { node?: AnyNode; point: Vec2; handleId?: string; modifiers?: Modifiers }) => Ctx
|
||||
preview: (ctx: Ctx, point: Vec2, modifiers: Modifiers) => Draft
|
||||
snap?: (draft: Draft, ctx: Ctx, services: SnapServicesLike) => Draft
|
||||
apply: (draft: Draft, ctx: Ctx, scene: SceneApi) => Iterable<AnyNodeId>
|
||||
commit?: (draft: Draft, ctx: Ctx, scene: SceneApi) => boolean
|
||||
cancel: (ctx: Ctx, scene: SceneApi) => void
|
||||
}
|
||||
|
||||
// Phase 1 fleshes out SnapServices; PR 0.1 only needs the placeholder type.
|
||||
export type SnapServicesLike = unknown
|
||||
|
||||
// ─── SceneApi ────────────────────────────────────────────────────────
|
||||
|
||||
export type SceneApi = {
|
||||
get: <N extends AnyNode = AnyNode>(id: AnyNodeId) => N | undefined
|
||||
update: (id: AnyNodeId, patch: Partial<AnyNode>) => void
|
||||
upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId
|
||||
delete: (id: AnyNodeId) => void
|
||||
restore: (id: AnyNodeId) => void
|
||||
restoreAll: () => void
|
||||
markDirty: (id: AnyNodeId) => void
|
||||
pauseHistory: () => void
|
||||
resumeHistory: () => void
|
||||
}
|
||||
|
||||
// ─── Registry surface ────────────────────────────────────────────────
|
||||
|
||||
export interface NodeRegistry {
|
||||
has: (kind: string) => boolean
|
||||
get: (kind: string) => AnyNodeDefinition | undefined
|
||||
entries: () => IterableIterator<[string, AnyNodeDefinition]>
|
||||
schemas: () => ZodObject<any>[]
|
||||
readonly size: number
|
||||
}
|
||||
Reference in New Issue
Block a user