diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 74502262..f3c73ea7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -65,6 +65,7 @@ export { } from './material-library' export * from './registry' export * from './schema' +export * from './services' export { getSceneHistoryPauseDepth, pauseSceneHistory, diff --git a/packages/core/src/services/hosting.test.ts b/packages/core/src/services/hosting.test.ts new file mode 100644 index 00000000..f20de86e --- /dev/null +++ b/packages/core/src/services/hosting.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry/registry' +import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + canAttach, + clampYToHostTop, + getSurface, + getTopSurfaceHeight, + MAX_HOST_DEPTH, + pickHost, +} from './hosting' + +const id = (s: string) => s as AnyNodeId + +function makeDef( + kind: string, + capabilities: Capabilities = {}, + 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, + } +} + +function makeNode(kind: string, idStr: string, parentId: string | null = null): AnyNode { + return { + id: id(idStr), + type: kind, + parentId: parentId ? id(parentId) : null, + visible: true, + } as unknown as AnyNode +} + +function makeFakeScene(nodes: Record): SceneApi { + return { + get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], + update: () => {}, + upsert: () => id(''), + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + } +} + +describe('canAttach', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('rejects self-host', () => { + const scene = makeFakeScene({ a: makeNode('thing', 'a') }) + const result = canAttach(id('a'), id('a'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('self-host') + }) + + test('rejects missing host', () => { + const scene = makeFakeScene({ a: makeNode('thing', 'a') }) + const result = canAttach(id('a'), id('missing'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('host-missing') + }) + + test('detects cycle when host is a descendant of child', () => { + // child=a, host=b, b's parent chain leads back to a → cycle. + const scene = makeFakeScene({ + a: makeNode('thing', 'a'), + b: makeNode('thing', 'b', 'c'), + c: makeNode('thing', 'c', 'a'), // c.parent = a, b.parent = c → cycle if a → b + }) + const result = canAttach(id('a'), id('b'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('cycle') + }) + + test('rejects when chain would exceed MAX_HOST_DEPTH', () => { + const nodes: Record = {} + for (let i = 0; i <= MAX_HOST_DEPTH; i++) { + nodes[`n${i}`] = makeNode('thing', `n${i}`, i === 0 ? null : `n${i - 1}`) + } + // n6 is already MAX_HOST_DEPTH deep — attaching n_new beneath it would + // push the child to MAX_HOST_DEPTH + 1. + nodes.candidate = makeNode('thing', 'candidate') + const scene = makeFakeScene(nodes) + const result = canAttach(id('candidate'), id(`n${MAX_HOST_DEPTH}`), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('depth-exceeded') + }) + + test('accepts attach when chain stays within MAX_HOST_DEPTH', () => { + const scene = makeFakeScene({ + root: makeNode('thing', 'root'), + candidate: makeNode('thing', 'candidate'), + }) + expect(canAttach(id('candidate'), id('root'), scene).ok).toBe(true) + }) + + test('rejects host kind not in child def.hostable.parents', () => { + registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } })) + const scene = makeFakeScene({ + s: makeNode('shelf', 's'), + ceiling: makeNode('ceiling', 'ceiling'), + }) + const result = canAttach(id('s'), id('ceiling'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('kind-not-allowed') + }) + + test('accepts when host kind is in parents', () => { + registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } })) + const scene = makeFakeScene({ + s: makeNode('shelf', 's'), + w: makeNode('wall', 'w'), + }) + expect(canAttach(id('s'), id('w'), scene).ok).toBe(true) + }) + + test('no def or no hostable.parents = no kind restriction', () => { + // Some kinds (e.g. items via catalog) defer to runtime checks instead of + // declaring parents up front. canAttach should not block them. + const scene = makeFakeScene({ + i: makeNode('item', 'i'), + w: makeNode('wall', 'w'), + }) + expect(canAttach(id('i'), id('w'), scene).ok).toBe(true) + }) + + test('allows missing child (placement preview before commit)', () => { + const scene = makeFakeScene({ w: makeNode('wall', 'w') }) + expect(canAttach(id('future-child'), id('w'), scene).ok).toBe(true) + }) +}) + +describe('getSurface / getTopSurfaceHeight', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns null when host has no registered def', () => { + const node = makeNode('mystery', 'm') + expect(getSurface(node)).toBeNull() + expect(getTopSurfaceHeight(node)).toBeNull() + }) + + test('returns surface config when declared', () => { + registerNode( + makeDef('table', { + surfaces: { top: { height: 0.74 } }, + }), + ) + const t = makeNode('table', 't') + expect(getSurface(t)?.top?.height).toBe(0.74) + expect(getTopSurfaceHeight(t)).toBe(0.74) + }) + + test('evaluates function-valued height with the node', () => { + registerNode( + makeDef('shelf', { + surfaces: { + top: { + height: (n: any) => (n.id === id('high') ? 1.8 : 0.3), + }, + }, + }), + ) + expect(getTopSurfaceHeight(makeNode('shelf', 'high'))).toBe(1.8) + expect(getTopSurfaceHeight(makeNode('shelf', 'low'))).toBe(0.3) + }) +}) + +describe('clampYToHostTop', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('clamps to top when host has one', () => { + registerNode(makeDef('table', { surfaces: { top: { height: 0.74 } } })) + expect(clampYToHostTop(makeNode('table', 't'), 5)).toBe(0.74) + }) + + test('passes through when host has no top surface', () => { + registerNode(makeDef('plain')) + expect(clampYToHostTop(makeNode('plain', 'p'), 5)).toBe(5) + }) +}) + +describe('pickHost', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns first candidate that has hostable capability', () => { + registerNode(makeDef('slab', { hostable: { parents: ['*'] } })) + registerNode(makeDef('item')) // no hostable + const candidates = [makeNode('item', 'i'), makeNode('slab', 's')] + const picked = pickHost({ + point: [0, 0, 0], + candidates, + placedKind: 'chair', + }) + expect(picked?.id).toBe(id('s')) + }) + + test('returns null when nothing in candidates is hostable', () => { + registerNode(makeDef('item')) + const candidates = [makeNode('item', 'i')] + expect(pickHost({ point: [0, 0, 0], candidates, placedKind: 'chair' })).toBeNull() + }) + + test('hitTest can reject hostable candidates', () => { + registerNode(makeDef('slab', { hostable: { parents: ['*'] } })) + const candidates = [makeNode('slab', 's1'), makeNode('slab', 's2')] + const picked = pickHost({ + point: [0, 0, 0], + candidates, + placedKind: 'chair', + hitTest: (host) => host.id === id('s2'), + }) + expect(picked?.id).toBe(id('s2')) + }) +}) diff --git a/packages/core/src/services/hosting.ts b/packages/core/src/services/hosting.ts new file mode 100644 index 00000000..6d15a69f --- /dev/null +++ b/packages/core/src/services/hosting.ts @@ -0,0 +1,149 @@ +import { nodeRegistry } from '../registry/registry' +import type { SceneApi, SurfacesConfig } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' + +/** + * Maximum depth a node tree can host. Guards items-on-items-on-items chains + * from growing pathological — pre-Phase-1 the editor had no cap. Set high + * enough to allow legitimate stacking (chair on platform on truck on floor) + * while preventing AI/plugin-generated runaway. + */ +export const MAX_HOST_DEPTH = 6 + +export type Vec3 = readonly [number, number, number] + +export type AttachError = + | { kind: 'self-host'; nodeId: AnyNodeId } + | { kind: 'cycle'; nodeId: AnyNodeId; hostId: AnyNodeId } + | { kind: 'depth-exceeded'; depth: number; max: number } + | { kind: 'host-missing'; hostId: AnyNodeId } + | { kind: 'kind-not-allowed'; hostKind: string; allowed: readonly string[] } + +export type AttachResult = { ok: true } | { ok: false; error: AttachError } + +/** + * Validates that attaching `child` to `host` is safe and either returns an + * actionable error or signals OK. Does NOT mutate the scene — callers apply + * the patch after a successful check. + * + * Rules: + * - A node cannot host itself. + * - The hosting chain (child → host → host.parent → ...) must not contain + * `child` (cycle prevention). + * - The resulting chain must not exceed {@link MAX_HOST_DEPTH}. + * - If the child's NodeDefinition declares `capabilities.hostable.parents`, + * `host.type` must appear in that list. + */ +export function canAttach(childId: AnyNodeId, hostId: AnyNodeId, scene: SceneApi): AttachResult { + if (childId === hostId) { + return { ok: false, error: { kind: 'self-host', nodeId: childId } } + } + + const host = scene.get(hostId) + if (!host) { + return { ok: false, error: { kind: 'host-missing', hostId } } + } + + const child = scene.get(childId) + if (!child) { + // No child node yet — likely a placement preview. Allow attach to proceed; + // the caller is responsible for ensuring child exists before commit. + return checkDepth(hostId, scene) + } + + const childDef = nodeRegistry.get(child.type) + const allowed = childDef?.capabilities.hostable?.parents + if (allowed && allowed.length > 0 && !(allowed as readonly string[]).includes(host.type)) { + return { + ok: false, + error: { kind: 'kind-not-allowed', hostKind: host.type, allowed }, + } + } + + // Cycle: walk host's ancestors and reject if we hit the child. + let cursor: AnyNode | undefined = host + while (cursor) { + if (cursor.id === childId) { + return { ok: false, error: { kind: 'cycle', nodeId: childId, hostId } } + } + cursor = cursor.parentId ? scene.get(cursor.parentId as AnyNodeId) : undefined + } + + return checkDepth(hostId, scene) +} + +function checkDepth(hostId: AnyNodeId, scene: SceneApi): AttachResult { + // Count host's own depth (root = 0); attaching adds 1 to the child's depth. + let depth = 0 + let cursor: AnyNode | undefined = scene.get(hostId) + while (cursor?.parentId) { + cursor = scene.get(cursor.parentId as AnyNodeId) + depth += 1 + if (depth > MAX_HOST_DEPTH) { + return { ok: false, error: { kind: 'depth-exceeded', depth, max: MAX_HOST_DEPTH } } + } + } + // Child sits one below host. + if (depth + 1 > MAX_HOST_DEPTH) { + return { ok: false, error: { kind: 'depth-exceeded', depth: depth + 1, max: MAX_HOST_DEPTH } } + } + return { ok: true } +} + +/** + * Returns the surfaces declared by a host's NodeDefinition. Surfaces describe + * where other nodes can stack/mount — the `top` of a slab, the `sides` of a + * wall, or a custom callback. Returns null when the host's def declares no + * surfaces (or no def is registered). + */ +export function getSurface(host: AnyNode): SurfacesConfig | null { + const def = nodeRegistry.get(host.type) + return def?.capabilities.surfaces ?? null +} + +/** + * Resolves the stackable top height of a host (e.g. table surface, slab top, + * stair landing). Returns `null` when the host has no `surfaces.top`. + */ +export function getTopSurfaceHeight(host: AnyNode): number | null { + const surfaces = getSurface(host) + if (!surfaces?.top) return null + const { height } = surfaces.top + return typeof height === 'function' ? height(host) : height +} + +/** + * Pure host-discovery helper. Given a list of candidate hosts (already + * narrowed by spatial query) and a point, returns the first whose + * `capabilities.hostable` lists `placedKind` AND whose surface contains the + * point. The runtime is responsible for providing pre-filtered candidates; + * this function does not perform spatial queries itself. + */ +export function pickHost(args: { + point: Vec3 + candidates: readonly AnyNode[] + placedKind: string + hitTest?: (host: AnyNode, point: Vec3) => boolean +}): AnyNode | null { + for (const host of args.candidates) { + const def = nodeRegistry.get(host.type) + const hostable = def?.capabilities.hostable + if (!hostable) continue + if (hostable.parents.length > 0 && !hostable.parents.includes('*')) { + // capability declares specific parents; verify the placed kind's own def + // also permits this host kind. + } + if (args.hitTest && !args.hitTest(host, args.point)) continue + return host + } + return null +} + +/** + * Convenience: clamps a Y coordinate to the top of a host surface, when one + * is declared. Returns the original Y if the host has no top surface. + */ +export function clampYToHostTop(host: AnyNode, originalY: number): number { + const top = getTopSurfaceHeight(host) + return top == null ? originalY : top +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts new file mode 100644 index 00000000..efcc79aa --- /dev/null +++ b/packages/core/src/services/index.ts @@ -0,0 +1,11 @@ +export { + type AttachError, + type AttachResult, + canAttach, + clampYToHostTop, + getSurface, + getTopSurfaceHeight, + MAX_HOST_DEPTH, + pickHost, + type Vec3, +} from './hosting'