diff --git a/packages/core/package.json b/packages/core/package.json index 0b2322b3..42644f39 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d50139f..74502262 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts new file mode 100644 index 00000000..183aae14 --- /dev/null +++ b/packages/core/src/registry/index.ts @@ -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' diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts new file mode 100644 index 00000000..0e1abc0f --- /dev/null +++ b/packages/core/src/registry/registry.test.ts @@ -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 { + 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/) + }) +}) diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts new file mode 100644 index 00000000..29432918 --- /dev/null +++ b/packages/core/src/registry/registry.ts @@ -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() + + 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[] { + 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 { + 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) + } +} diff --git a/packages/core/src/registry/scene-api.test.ts b/packages/core/src/registry/scene-api.test.ts new file mode 100644 index 00000000..fee5f175 --- /dev/null +++ b/packages/core/src/registry/scene-api.test.ts @@ -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 = {}) { + const state = { + nodes: { ...initial } as Record, + rootNodeIds: [] as AnyNodeId[], + dirtyNodes: new Set(), + createNode(node: AnyNode) { + state.nodes[node.id] = node + }, + updateNode(id: AnyNodeId, data: Partial) { + 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 = {}): 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) => + store._state.nodes as unknown as Record + +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) + 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) + 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) + api.update(id('b'), { visible: false } as Partial) + 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) + 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) + api.update(id('a'), { visible: true } as Partial) // second update — must not overwrite snapshot + api.update(id('a'), { visible: false } as Partial) + api.restore(id('a')) + expect(nodes(store)['a']).toMatchObject({ visible: true }) // the *first* pre-pause value + api.resumeHistory() + }) +}) diff --git a/packages/core/src/registry/scene-api.ts b/packages/core/src/registry/scene-api.ts new file mode 100644 index 00000000..65aa50fd --- /dev/null +++ b/packages/core/src/registry/scene-api.ts @@ -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 + rootNodeIds: AnyNodeId[] + dirtyNodes: Set + createNode: (node: AnyNode, parentId?: AnyNodeId) => void + updateNode: (id: AnyNodeId, data: Partial) => 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 | 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(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 + }, + } +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts new file mode 100644 index 00000000..8c8273f6 --- /dev/null +++ b/packages/core/src/registry/types.ts @@ -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> + +export type NodeDefinition> = { + kind: string + schemaVersion: number + schema: S + category: NodeCategory + + defaults: () => Omit, 'id' | 'type'> + migrate?: Record unknown> + + capabilities: Capabilities + relations?: Relations + parametrics?: ParametricDescriptor> + + renderer: RendererSource> + system?: SystemContribution + tool?: LazyComponent + affordances?: Affordance>[] + + mcp?: McpOverrides +} + +export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility' + +export type LazyComponent = () => Promise<{ default: ComponentType }> + +export type RendererSource = + | { + 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> + 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 } + 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 = { + groups: ParamGroup[] + invariants?: ReadonlyArray<(n: N) => Issue[]> + derive?: (n: N) => Partial + customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> +} + +export type ParamGroup = { + label: string + fields: ParamField[] +} + +export type ParamField = + | { + 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 = { + 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 = { + 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 + 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: (id: AnyNodeId) => N | undefined + update: (id: AnyNodeId, patch: Partial) => 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[] + readonly size: number +}