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:
Wassim SAMAD
2026-05-14 11:18:29 -04:00
co-authored by Claude Opus 4.7
parent c50a4df8e1
commit fb91713374
8 changed files with 784 additions and 3 deletions
+5
View File
@@ -16,6 +16,11 @@
"import": "./dist/utils/clone-scene-graph.js",
"default": "./dist/utils/clone-scene-graph.js"
},
"./registry": {
"types": "./dist/registry/index.d.ts",
"import": "./dist/registry/index.js",
"default": "./dist/registry/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
+4 -3
View File
@@ -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'
+39
View File
@@ -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'
+124
View File
@@ -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/)
})
})
+69
View File
@@ -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()
})
})
+104
View File
@@ -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
},
}
}
+234
View File
@@ -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
}