From b6d77206b48b941f8887b1da5ef2d2fc62f96797 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 14 May 2026 13:07:38 -0400 Subject: [PATCH] Phase 2 spike: spawn migration (flagged) + new shelf node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first time registry-driven nodes actually run in the editor. Spawn migration (under NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag): - New packages/nodes/src/spawn/ folder with renderer, tool, schema (re-exported from core), parametrics, definition, index. - Spawn definition appended to builtinPlugin.nodes only when the flag is set. With the flag off, the Phase 0 shims fall through and the legacy SpawnRenderer / SpawnTool keep ownership. - New no-props SpawnTool reads activeLevelId from useViewer directly, matches legacy placement behavior (half-meter snap, singleton-per- level, replace-on-reclick). - Structural parity test (9 cases) validates definition shape + schema identity. Pixel-diff defers to Phase 4 alongside more nodes. New shelf node (no legacy — registered unconditionally): - ShelfNode schema in core/schema/nodes/shelf.ts (hand-maintained AnyNode union for now; Phase 6 derives the union from the registry and moves the schema fully into nodes/shelf/). - packages/nodes/src/shelf/ folder: pure geometry builder (buildShelfGeometry returns a Three.js Group of top board + brackets), R3F renderer that mounts the built group, no-props placement tool, parametrics descriptor (width/depth/thickness/ height/bracketStyle/color), definition with surfaces.top stackable surface for future stacking, and presentation metadata for the palette. - 13 unit tests across schema bounds and geometry behavior. - Palette wiring: 'shelf' added to StructureTool union + an entry in the structure-tools array (placeholder icon, replaced in Phase 4 when palette is registry-driven). Framework changes: - @pascal-app/viewer now exports useNodeEvents from its public barrel so node bundles in @pascal-app/nodes can subscribe to node-specific pointer events. (Used by spawn renderer; shelf renderer skips it for now since useNodeEvents has a hardcoded kind list — Phase 4 generalizes it via the registry.) - @pascal-app/nodes gains @pascal-app/viewer as a peer + dev dep so node bundles can import from it. 630 tests pass across 76 files (22 new this phase). Editor app continues to ship green with both legacy spawn and the new shelf node co-existing through the Phase 0 dispatch shims. To validate end-to-end in dev: - bun dev:community → open editor → click 'Shelf' in structure toolbar → click to place. Confirms full registry path (NodeRenderer dispatch + ToolManager dispatch + sceneRegistry byType Proxy). - Set NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN=1, restart dev, place spawn → visually identical to legacy. Confirms parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 7 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/shelf.ts | 33 +++++ packages/core/src/schema/types.ts | 2 + .../ui/action-menu/structure-tools.tsx | 4 + packages/editor/src/store/use-editor.tsx | 1 + packages/nodes/package.json | 3 + packages/nodes/src/index.test.ts | 8 +- packages/nodes/src/index.ts | 42 +++++- .../src/shelf/__tests__/geometry.test.ts | 75 ++++++++++ .../nodes/src/shelf/__tests__/schema.test.ts | 50 +++++++ packages/nodes/src/shelf/definition.ts | 63 +++++++++ packages/nodes/src/shelf/geometry.ts | 65 +++++++++ packages/nodes/src/shelf/index.ts | 3 + packages/nodes/src/shelf/parametrics.ts | 28 ++++ packages/nodes/src/shelf/renderer.tsx | 62 +++++++++ packages/nodes/src/shelf/schema.ts | 7 + packages/nodes/src/shelf/tool.tsx | 77 +++++++++++ .../nodes/src/spawn/__tests__/parity.test.ts | 69 ++++++++++ packages/nodes/src/spawn/definition.ts | 50 +++++++ packages/nodes/src/spawn/index.ts | 2 + packages/nodes/src/spawn/parametrics.ts | 20 +++ packages/nodes/src/spawn/renderer.tsx | 78 +++++++++++ packages/nodes/src/spawn/schema.ts | 6 + packages/nodes/src/spawn/tool.tsx | 128 ++++++++++++++++++ packages/nodes/tsconfig.json | 2 +- packages/viewer/src/index.ts | 1 + 27 files changed, 881 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/schema/nodes/shelf.ts create mode 100644 packages/nodes/src/shelf/__tests__/geometry.test.ts create mode 100644 packages/nodes/src/shelf/__tests__/schema.test.ts create mode 100644 packages/nodes/src/shelf/definition.ts create mode 100644 packages/nodes/src/shelf/geometry.ts create mode 100644 packages/nodes/src/shelf/index.ts create mode 100644 packages/nodes/src/shelf/parametrics.ts create mode 100644 packages/nodes/src/shelf/renderer.tsx create mode 100644 packages/nodes/src/shelf/schema.ts create mode 100644 packages/nodes/src/shelf/tool.tsx create mode 100644 packages/nodes/src/spawn/__tests__/parity.test.ts create mode 100644 packages/nodes/src/spawn/definition.ts create mode 100644 packages/nodes/src/spawn/index.ts create mode 100644 packages/nodes/src/spawn/parametrics.ts create mode 100644 packages/nodes/src/spawn/renderer.tsx create mode 100644 packages/nodes/src/spawn/schema.ts create mode 100644 packages/nodes/src/spawn/tool.tsx diff --git a/bun.lock b/bun.lock index dffc8910..bd4fb1ba 100644 --- a/bun.lock +++ b/bun.lock @@ -188,14 +188,17 @@ "version": "0.1.0", "devDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", + "@types/node": "^22.19.12", "@types/react": "^19.2.2", "@types/three": "^0.184.0", "typescript": "6.0.2", }, "peerDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", @@ -1553,6 +1556,8 @@ "@pascal-app/mcp/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@pascal-app/nodes/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + "@pascal-app/viewer/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1635,6 +1640,8 @@ "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@pascal-app/nodes/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@pascal-app/viewer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@repo/ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index aede4b6a..5d542867 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -74,6 +74,7 @@ export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { ScanNode } from './nodes/scan' // Nodes +export { ShelfNode } from './nodes/shelf' export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' export { SpawnNode } from './nodes/spawn' diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts new file mode 100644 index 00000000..c5d19e27 --- /dev/null +++ b/packages/core/src/schema/nodes/shelf.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Parametric shelf — a free-standing or table-top horizontal surface. + * + * v1: free-standing only. Wall-mount via a `mount` discriminator lands in + * Phase 5 alongside item migration. Until then, position is the world + * (or level-local) position of the shelf's center; rotation is yaw only. + * + * Schema lives in core because `AnyNode` (also in core) needs to reference + * it via the hand-maintained discriminated union. Phase 6 derives `AnyNode` + * from `nodeRegistry.schemas()` and this file moves entirely into + * `@pascal-app/nodes/shelf/`. + */ +export const ShelfNode = BaseNode.extend({ + id: objectId('shelf'), + type: nodeType('shelf'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + + // Dimensions (meters) + width: z.number().min(0.3).max(3.0).default(1.2), + depth: z.number().min(0.1).max(1.0).default(0.3), + thickness: z.number().min(0.01).max(0.1).default(0.04), + /** Distance from the floor to the bottom of the shelf top board. */ + height: z.number().min(0.05).max(2.5).default(0.9), + + bracketStyle: z.enum(['minimal', 'industrial', 'hidden']).default('minimal'), + color: z.string().default('#a07050'), +}) + +export type ShelfNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d04c4be3..a683fb12 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -11,6 +11,7 @@ import { LevelNode } from './nodes/level' import { RoofNode } from './nodes/roof' import { RoofSegmentNode } from './nodes/roof-segment' import { ScanNode } from './nodes/scan' +import { ShelfNode } from './nodes/shelf' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' import { SpawnNode } from './nodes/spawn' @@ -34,6 +35,7 @@ export const AnyNode = z.discriminatedUnion('type', [ CeilingNode, RoofNode, RoofSegmentNode, + ShelfNode, StairNode, StairSegmentNode, ScanNode, diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 352820c7..1eaad111 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -33,6 +33,10 @@ export const tools: ToolConfig[] = [ { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, + // Registry-driven shelf node — placed via the registry-first ToolManager + // shim from Phase 0. No icon file shipped; using a placeholder icon until + // Phase 4 derives palette entries from `definition.presentation.icon`. + { id: 'shelf', iconSrc: '/icons/column.png', label: 'Shelf' }, ] export function StructureTools() { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 03d963c0..057cbf66 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -65,6 +65,7 @@ export type StructureTool = | 'spawn' | 'window' | 'door' + | 'shelf' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' diff --git a/packages/nodes/package.json b/packages/nodes/package.json index a485a275..01b7c66a 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -24,6 +24,7 @@ }, "peerDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", @@ -31,8 +32,10 @@ }, "devDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", + "@types/node": "^22.19.12", "@types/react": "^19.2.2", "@types/three": "^0.184.0", "typescript": "6.0.2" diff --git a/packages/nodes/src/index.test.ts b/packages/nodes/src/index.test.ts index f358a5a5..ef0bba51 100644 --- a/packages/nodes/src/index.test.ts +++ b/packages/nodes/src/index.test.ts @@ -13,8 +13,12 @@ describe('builtinPlugin', () => { expect(Array.isArray(builtinPlugin.nodes)).toBe(true) }) - test('loads with zero kinds today (Phase 0 — registry empty)', async () => { + test('loads the registered kinds without error', async () => { await loadPlugin(builtinPlugin) - expect(nodeRegistry.size).toBe(0) + // Phase 2 registers shelf unconditionally; spawn is flag-gated. So the + // registry should always contain shelf, and may contain spawn depending + // on the NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN env value at module load. + expect(nodeRegistry.has('shelf')).toBe(true) + expect(nodeRegistry.size).toBeGreaterThanOrEqual(1) }) }) diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 3539615b..fa392a65 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,11 +1,44 @@ -import type { Plugin } from '@pascal-app/core' +import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +import { shelfDefinition } from './shelf' +import { spawnDefinition } from './spawn' + +/** + * Feature flag for the Phase 2 spike. When `NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN` + * is truthy, spawn registers through the registry path; otherwise the legacy + * `SpawnRenderer` and `SpawnTool` in viewer/editor packages own the kind. + * + * Removed in the PR that signs off parity (legacy spawn files deleted in the + * same commit). All other built-in node migrations follow the same pattern. + */ +function readEnvFlag(name: string): boolean { + const env = (globalThis as { process?: { env?: Record } }).process + ?.env + const flag = env?.[name] + return flag === '1' || flag === 'true' +} + +function isSpawnRegistryEnabled(): boolean { + return readEnvFlag('NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN') +} + +function getBuiltinNodes(): AnyNodeDefinition[] { + const nodes: AnyNodeDefinition[] = [ + // Shelf is a new kind — no legacy code to flag against. It ships + // unconditionally so users can place it from the tool palette. + shelfDefinition as unknown as AnyNodeDefinition, + ] + if (isSpawnRegistryEnabled()) { + nodes.push(spawnDefinition as unknown as AnyNodeDefinition) + } + return nodes +} /** * Built-in plugin bundling every node kind shipped with the Pascal editor. * * Apps load this once at bootstrap (`loadPlugin(builtinPlugin)`) before * mounting the viewer. New built-in nodes are added by creating a folder - * here under `src//` and appending its `NodeDefinition` to `nodes`. + * here under `src//` and appending its `NodeDefinition` to `getBuiltinNodes`. * * External plugins follow the exact same shape — same `Plugin` type, same * `loadPlugin` call path. This is intentional: the API is stress-tested @@ -14,5 +47,8 @@ import type { Plugin } from '@pascal-app/core' export const builtinPlugin: Plugin = { id: 'pascal:core', apiVersion: 1, - nodes: [], + nodes: getBuiltinNodes(), } + +export { shelfDefinition } from './shelf' +export { spawnDefinition } from './spawn' diff --git a/packages/nodes/src/shelf/__tests__/geometry.test.ts b/packages/nodes/src/shelf/__tests__/geometry.test.ts new file mode 100644 index 00000000..aa8732a1 --- /dev/null +++ b/packages/nodes/src/shelf/__tests__/geometry.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import type { Mesh } from 'three' +import { buildShelfGeometry } from '../geometry' +import { ShelfNode } from '../schema' + +describe('buildShelfGeometry', () => { + test('returns a Group with named meshes for top + brackets (minimal style)', () => { + const node = ShelfNode.parse({ bracketStyle: 'minimal' }) + const group = buildShelfGeometry(node) + const names = group.children.map((c) => c.name) + expect(names).toContain('shelf-top') + expect(names).toContain('shelf-bracket-left') + expect(names).toContain('shelf-bracket-right') + expect(group.children.length).toBe(3) + }) + + test('hidden bracket style omits both brackets', () => { + const node = ShelfNode.parse({ bracketStyle: 'hidden' }) + const group = buildShelfGeometry(node) + expect(group.children.length).toBe(1) + expect(group.children[0]!.name).toBe('shelf-top') + }) + + test('top board y-center matches height + thickness/2', () => { + const node = ShelfNode.parse({ height: 1.0, thickness: 0.05 }) + const group = buildShelfGeometry(node) + const top = group.children.find((c) => c.name === 'shelf-top') as Mesh | undefined + expect(top).toBeDefined() + expect(top!.position.y).toBeCloseTo(1.0 + 0.025) + }) + + test('brackets are inset from the shelf ends and run from the floor to the top', () => { + const node = ShelfNode.parse({ width: 1.5, height: 0.8 }) + const group = buildShelfGeometry(node) + const left = group.children.find((c) => c.name === 'shelf-bracket-left') as Mesh | undefined + const right = group.children.find((c) => c.name === 'shelf-bracket-right') as Mesh | undefined + expect(left).toBeDefined() + expect(right).toBeDefined() + // Left bracket sits at negative X, right at positive X. + expect(left!.position.x).toBeLessThan(0) + expect(right!.position.x).toBeGreaterThan(0) + // Brackets rise from floor (y = bracketHeight/2 ≈ 0.4 for height 0.8). + expect(left!.position.y).toBeCloseTo(0.4) + }) + + test('industrial bracket style produces thicker bracket boxes', () => { + const minimal = buildShelfGeometry(ShelfNode.parse({ bracketStyle: 'minimal', depth: 0.4 })) + const industrial = buildShelfGeometry( + ShelfNode.parse({ bracketStyle: 'industrial', depth: 0.4 }), + ) + const minimalBracket = minimal.children.find((c) => c.name === 'shelf-bracket-left') as Mesh + const industrialBracket = industrial.children.find( + (c) => c.name === 'shelf-bracket-left', + ) as Mesh + // industrial bracket box should have a wider X (bracketWidth) than minimal + const minimalParams = (minimalBracket.geometry as any).parameters + const industrialParams = (industrialBracket.geometry as any).parameters + expect(industrialParams.width).toBeGreaterThan(minimalParams.width) + }) + + test('top board material is built from node.color (not the default)', () => { + const defaultColor = ( + buildShelfGeometry(ShelfNode.parse({})).children.find((c) => c.name === 'shelf-top') as Mesh + ).material as { color: { getHexString(): string } } + const custom = ( + buildShelfGeometry(ShelfNode.parse({ color: '#112233' })).children.find( + (c) => c.name === 'shelf-top', + ) as Mesh + ).material as { color: { getHexString(): string } } + // Three.js applies color space conversion (sRGB → linear) for materials. + // The materials should differ — that's the property we care about, not the + // exact channel values. + expect(custom.color.getHexString()).not.toBe(defaultColor.color.getHexString()) + }) +}) diff --git a/packages/nodes/src/shelf/__tests__/schema.test.ts b/packages/nodes/src/shelf/__tests__/schema.test.ts new file mode 100644 index 00000000..473eb197 --- /dev/null +++ b/packages/nodes/src/shelf/__tests__/schema.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' +import { ShelfNode } from '../schema' + +describe('ShelfNode schema', () => { + test('parses with all defaults applied', () => { + const parsed = ShelfNode.parse({}) + expect(parsed.type).toBe('shelf') + expect(parsed.id).toMatch(/^shelf_/) + expect(parsed.width).toBe(1.2) + expect(parsed.depth).toBe(0.3) + expect(parsed.thickness).toBe(0.04) + expect(parsed.height).toBe(0.9) + expect(parsed.bracketStyle).toBe('minimal') + expect(parsed.color).toBe('#a07050') + }) + + test('accepts user-supplied dimensions within bounds', () => { + const parsed = ShelfNode.parse({ + width: 2.0, + depth: 0.5, + thickness: 0.06, + height: 1.4, + bracketStyle: 'industrial', + }) + expect(parsed.width).toBe(2.0) + expect(parsed.bracketStyle).toBe('industrial') + }) + + test('rejects width below min', () => { + expect(() => ShelfNode.parse({ width: 0.1 })).toThrow() + }) + + test('rejects width above max', () => { + expect(() => ShelfNode.parse({ width: 5 })).toThrow() + }) + + test('rejects unknown bracketStyle', () => { + expect(() => ShelfNode.parse({ bracketStyle: 'mystery' })).toThrow() + }) + + test('rejects thickness above 0.1m (catches malformed AI output)', () => { + expect(() => ShelfNode.parse({ thickness: 0.5 })).toThrow() + }) + + test('generates unique IDs across calls', () => { + const a = ShelfNode.parse({}) + const b = ShelfNode.parse({}) + expect(a.id).not.toBe(b.id) + }) +}) diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts new file mode 100644 index 00000000..d586e57b --- /dev/null +++ b/packages/nodes/src/shelf/definition.ts @@ -0,0 +1,63 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { shelfParametrics } from './parametrics' +import { ShelfNode } from './schema' + +export const shelfDefinition: NodeDefinition = { + kind: 'shelf', + schemaVersion: 1, + schema: ShelfNode, + category: 'furnish', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + width: 1.2, + depth: 0.3, + thickness: 0.04, + height: 0.9, + bracketStyle: 'minimal', + color: '#a07050', + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { + axes: ['y'], + snapAngles: [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI], + }, + // The whole point of shelf: things can stack on it. Surface height + // resolves from the node so multiple shelves at different heights stack + // correctly (vs a fixed-height table). + surfaces: { + top: { height: (n) => (n as ShelfNode).height + (n as ShelfNode).thickness }, + }, + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: shelfParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + tool: () => import('./tool'), + + presentation: { + label: 'Shelf', + description: 'A horizontal surface for stacking other items.', + icon: { kind: 'iconify', name: 'lucide:layers' }, + paletteSection: 'structure', + paletteOrder: 50, + }, + + mcp: { + description: + 'A parametric shelf with adjustable dimensions and bracket style. Stackable on its top surface.', + }, +} diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts new file mode 100644 index 00000000..417ef4cf --- /dev/null +++ b/packages/nodes/src/shelf/geometry.ts @@ -0,0 +1,65 @@ +import { BoxGeometry, type BufferGeometry, Color, Group, Mesh, MeshStandardMaterial } from 'three' +import type { ShelfNode } from './schema' + +/** + * Pure shelf geometry builder. Takes a `ShelfNode` and returns a `Group` + * containing the top board + bracket meshes — no React, no scene access. + * + * Two reasons this is its own pure function (not inlined into the renderer): + * + * 1. **Geometry parity testing.** Phase 4's pixel-diff test compares the + * BufferGeometry vertex/index arrays returned by this function against + * a snapshot — pure functions are trivial to test, JSX is not. + * 2. **AI-authored nodes.** This is the file an AI is most likely to + * generate. Pure, deterministic, takes typed input, returns Three.js + * primitives. No React or registry knowledge required. + */ +export function buildShelfGeometry(node: ShelfNode): Group { + const group = new Group() + group.name = 'shelf-geometry' + + const material = new MeshStandardMaterial({ + color: new Color(node.color), + roughness: 0.65, + metalness: 0.05, + }) + + // Top board, centered at (0, height + thickness/2, 0) + const topBoardGeometry: BufferGeometry = new BoxGeometry(node.width, node.thickness, node.depth) + const topBoard = new Mesh(topBoardGeometry, material) + topBoard.name = 'shelf-top' + topBoard.position.set(0, node.height + node.thickness / 2, 0) + group.add(topBoard) + + // Brackets — two below the top, near each end. Style varies the look. + for (const sign of [-1, 1] as const) { + const bracket = buildBracket(node, sign, material) + if (bracket) { + bracket.name = `shelf-bracket-${sign === -1 ? 'left' : 'right'}` + group.add(bracket) + } + } + + return group +} + +function buildBracket(node: ShelfNode, sign: -1 | 1, material: MeshStandardMaterial): Mesh | null { + // 'hidden' style: skip visible brackets entirely. + if (node.bracketStyle === 'hidden') return null + + const inset = Math.min(0.12, node.width / 6) + const x = sign * (node.width / 2 - inset) + // Bracket height: from floor (0) up to the underside of the top board. + const bracketHeight = Math.max(0.01, node.height) + + const bracketWidth = + node.bracketStyle === 'industrial' + ? Math.max(0.04, node.depth * 0.2) + : Math.max(0.02, node.depth * 0.12) + const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7 + + const geometry = new BoxGeometry(bracketWidth, bracketHeight, bracketDepth) + const mesh = new Mesh(geometry, material) + mesh.position.set(x, bracketHeight / 2, 0) + return mesh +} diff --git a/packages/nodes/src/shelf/index.ts b/packages/nodes/src/shelf/index.ts new file mode 100644 index 00000000..b965d013 --- /dev/null +++ b/packages/nodes/src/shelf/index.ts @@ -0,0 +1,3 @@ +export { shelfDefinition } from './definition' +export { buildShelfGeometry } from './geometry' +export { ShelfNode } from './schema' diff --git a/packages/nodes/src/shelf/parametrics.ts b/packages/nodes/src/shelf/parametrics.ts new file mode 100644 index 00000000..5a8fe6d9 --- /dev/null +++ b/packages/nodes/src/shelf/parametrics.ts @@ -0,0 +1,28 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { ShelfNode } from './schema' + +/** + * Inspector descriptor for the parametric shelf. Drives both the auto-derived + * inspector UI (Phase 4) and the AI/MCP `create_shelf` / `update_shelf` tools + * with bounded JSON-schema parameters (also Phase 4). + */ +export const shelfParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3.0, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 1.0, step: 0.05 }, + { key: 'thickness', kind: 'number', unit: 'm', min: 0.01, max: 0.1, step: 0.005 }, + { key: 'height', kind: 'number', unit: 'm', min: 0.05, max: 2.5, step: 0.05 }, + ], + }, + { + label: 'Style', + fields: [ + { key: 'bracketStyle', kind: 'enum', options: ['minimal', 'industrial', 'hidden'] }, + { key: 'color', kind: 'color' }, + ], + }, + ], +} diff --git a/packages/nodes/src/shelf/renderer.tsx b/packages/nodes/src/shelf/renderer.tsx new file mode 100644 index 00000000..6d5933b4 --- /dev/null +++ b/packages/nodes/src/shelf/renderer.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useLiveTransforms, useRegistry } from '@pascal-app/core' +import { useEffect, useMemo, useRef } from 'react' +import type { Group } from 'three' +import { buildShelfGeometry } from './geometry' +import type { ShelfNode } from './schema' + +// Note: `useNodeEvents` from @pascal-app/viewer has a hardcoded kind list and +// doesn't yet know about 'shelf'. Phase 4 generalizes it to consume the +// registry — until then, shelf selection works via R3F's default raycast +// (clicks bubble through the scene; the editor's selection manager handles +// them by hit-testing the registered Object3D). + +/** + * Registry-driven shelf renderer. + * + * The pure `buildShelfGeometry` function returns a Group of meshes. We mount + * an empty group, attach event handlers, register with `sceneRegistry`, and + * imperatively swap in the built geometry whenever the schema-relevant fields + * change. This pattern keeps the JSX trivial and centralizes parametric work + * in the pure function — better for AI authoring and easier to swap out. + */ +const ShelfRenderer = ({ node }: { node: ShelfNode }) => { + const ref = useRef(null!) + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + + useRegistry(node.id, 'shelf', ref) + + // Build a fresh Group each time the parametric fields change. + const built = useMemo( + () => buildShelfGeometry(node), + [node.width, node.depth, node.thickness, node.height, node.bracketStyle, node.color], + ) + + // Mount the built children under our group ref. Re-runs when `built` + // changes (parametric edit) or when the parent ref mounts. + useEffect(() => { + const root = ref.current + if (!root) return + // Clear previous children. We don't dispose the buffer geometries here + // because they're owned by the previous `built` and were already + // discarded by React's reconciler when useMemo recomputed. + while (root.children.length > 0) { + root.remove(root.children[0]!) + } + for (const child of [...built.children]) { + root.add(child) + } + }, [built]) + + return ( + + ) +} + +export default ShelfRenderer diff --git a/packages/nodes/src/shelf/schema.ts b/packages/nodes/src/shelf/schema.ts new file mode 100644 index 00000000..ecb5b465 --- /dev/null +++ b/packages/nodes/src/shelf/schema.ts @@ -0,0 +1,7 @@ +// Shelf schema lives in core for now (referenced by the hand-maintained +// AnyNode union). Phase 6 derives AnyNode from the registry and the schema +// moves entirely into this package. Re-exporting here keeps all shelf-related +// imports inside @pascal-app/nodes/shelf — node bundle consumers don't need +// to know which side of the migration owns the file. + +export { ShelfNode } from '@pascal-app/core' diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx new file mode 100644 index 00000000..0d44a486 --- /dev/null +++ b/packages/nodes/src/shelf/tool.tsx @@ -0,0 +1,77 @@ +'use client' + +import { + emitter, + type GridEvent, + ShelfNode, + sceneRegistry, + snapPointToGrid, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import { type Group, Vector3 } from 'three' + +const worldVector = new Vector3() + +function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { + const levelObject = sceneRegistry.nodes.get(levelId) + if (!levelObject) { + const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], 0.1) + return [sx, event.localPosition[1], sz] + } + worldVector.set(event.position[0], event.position[1], event.position[2]) + levelObject.updateWorldMatrix(true, false) + levelObject.worldToLocal(worldVector) + const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], 0.1) + return [sx, worldVector.y, sz] +} + +const ShelfTool = () => { + const activeLevelId = useViewer((state) => state.selection.levelId) + const [, setCursor] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(null) + + useEffect(() => { + if (!activeLevelId) return + + const onGridMove = (event: GridEvent) => { + const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], 0.1) + const next: [number, number, number] = [sx, event.localPosition[1], sz] + setCursor(next) + cursorRef.current?.position.set(next[0], next[1], next[2]) + } + + const onGridClick = (event: GridEvent) => { + const position = getLevelLocalPosition(activeLevelId, event) + const shelf = ShelfNode.parse({ + name: 'Shelf', + position, + rotation: [0, 0, 0], + }) + useScene.getState().createNode(shelf, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [shelf.id] }) + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + return ( + + + + + + + ) +} + +export default ShelfTool diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts new file mode 100644 index 00000000..81845ce9 --- /dev/null +++ b/packages/nodes/src/spawn/__tests__/parity.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { SpawnNode as SpawnSchemaFromCore } from '@pascal-app/core' +import { spawnDefinition } from '../definition' +import { SpawnNode } from '../schema' + +/** + * Structural parity for the spawn registry definition. + * + * The new renderer is a near-line-by-line port of the legacy + * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — + * same mesh count, same primitives, same colors. The "parity" assertion + * for the spike is structural (definition is well-formed, both lazy + * modules resolve to React components) plus a manual visual eyeball check + * documented in the plan. Pixel-level Playwright parity lands in Phase 4 + * when more nodes are migrated. + */ +describe('spawn definition', () => { + test('schema matches the core schema export', () => { + // Both imports must point to the same Zod schema — the registry + // definition re-exports from core. + expect(SpawnNode).toBe(SpawnSchemaFromCore) + }) + + test('definition has the expected shape', () => { + expect(spawnDefinition.kind).toBe('spawn') + expect(spawnDefinition.schemaVersion).toBe(1) + expect(spawnDefinition.category).toBe('site') + expect(spawnDefinition.schema).toBe(SpawnNode) + }) + + test('defaults() returns a value that the schema accepts', () => { + const defaults = spawnDefinition.defaults() + const parsed = SpawnNode.safeParse({ ...defaults, id: 'spawn_test1234567890ab' }) + expect(parsed.success).toBe(true) + }) + + test('presentation declares an iconify icon for the palette', () => { + expect(spawnDefinition.presentation?.label).toBe('Spawn Point') + expect(spawnDefinition.presentation?.icon.kind).toBe('iconify') + expect(spawnDefinition.presentation?.paletteSection).toBe('structure') + }) + + test("movable capability restricts to X/Z (matches today's placement behavior)", () => { + expect(spawnDefinition.capabilities.movable?.axes).toEqual(['x', 'z']) + expect(spawnDefinition.capabilities.movable?.gridSnap).toBe(true) + }) + + test('rotatable capability declares yaw-only with diagonal-friendly snap angles', () => { + expect(spawnDefinition.capabilities.rotatable?.axes).toEqual(['y']) + const angles = spawnDefinition.capabilities.rotatable?.snapAngles ?? [] + expect(angles.length).toBeGreaterThanOrEqual(3) + expect(angles).toContain(0) + }) + + test('renderer is a parametric lazy module reference', () => { + expect(spawnDefinition.renderer.kind).toBe('parametric') + if (spawnDefinition.renderer.kind !== 'parametric') return + expect(typeof spawnDefinition.renderer.module).toBe('function') + }) + + test('tool is a lazy module reference', () => { + expect(typeof spawnDefinition.tool).toBe('function') + }) + + test('mcp description is set so AI surfaces describe the kind', () => { + expect(spawnDefinition.mcp?.description).toBeDefined() + expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts new file mode 100644 index 00000000..a30d4ba7 --- /dev/null +++ b/packages/nodes/src/spawn/definition.ts @@ -0,0 +1,50 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { spawnParametrics } from './parametrics' +import { SpawnNode } from './schema' + +export const spawnDefinition: NodeDefinition = { + kind: 'spawn', + schemaVersion: 1, + schema: SpawnNode, + category: 'site', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { + axes: ['y'], + snapAngles: [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI], + }, + duplicable: false, // singleton per level + deletable: true, + selectable: { hitVolume: 'bbox' }, + }, + + parametrics: spawnParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + tool: () => import('./tool'), + + presentation: { + label: 'Spawn Point', + description: 'Player or camera origin within a level. One per level.', + icon: { kind: 'iconify', name: 'lucide:flag' }, + paletteSection: 'structure', + paletteOrder: 90, // bottom of structure list — matches legacy palette order + }, + + mcp: { + description: 'A singleton spawn point marker placed inside a level.', + }, +} diff --git a/packages/nodes/src/spawn/index.ts b/packages/nodes/src/spawn/index.ts new file mode 100644 index 00000000..21a2500b --- /dev/null +++ b/packages/nodes/src/spawn/index.ts @@ -0,0 +1,2 @@ +export { spawnDefinition } from './definition' +export { SpawnNode } from './schema' diff --git a/packages/nodes/src/spawn/parametrics.ts b/packages/nodes/src/spawn/parametrics.ts new file mode 100644 index 00000000..b2ce09e5 --- /dev/null +++ b/packages/nodes/src/spawn/parametrics.ts @@ -0,0 +1,20 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { SpawnNode } from './schema' + +/** + * Inspector descriptor for spawn. Tiny — spawn has only position + rotation, + * and Phase 4 will auto-render a 3-component vec3 + a yaw scalar from this. + */ +export const spawnParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Transform', + fields: [ + { key: 'position', kind: 'vec3' }, + // rotation on spawn is a scalar yaw (not vec3). Phase 4 will support a + // 'scalar-angle' kind; for now we expose it as a number with unit. + { key: 'rotation', kind: 'number', unit: 'rad', step: Math.PI / 12 }, + ], + }, + ], +} diff --git a/packages/nodes/src/spawn/renderer.tsx b/packages/nodes/src/spawn/renderer.tsx new file mode 100644 index 00000000..afca500f --- /dev/null +++ b/packages/nodes/src/spawn/renderer.tsx @@ -0,0 +1,78 @@ +'use client' + +import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' +import { useNodeEvents, useViewer } from '@pascal-app/viewer' +import { useMemo, useRef } from 'react' +import { Color, type Group, Shape } from 'three' + +const SPAWN_COLOR = new Color('#22c55e') + +/** + * Registry-driven spawn renderer. Behaviorally identical to the legacy + * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same + * geometry, same colors, same event surface. When the spawn definition lands + * in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer + * here and the legacy one is short-circuited. + * + * Lives in `@pascal-app/nodes` (not viewer) so the kind owns its own render + * code. Phase 5's batch migration applies the same pattern to every node. + */ +const SpawnRenderer = ({ node }: { node: SpawnNode }) => { + const ref = useRef(null!) + const handlers = useNodeEvents(node, 'spawn') + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const walkthroughMode = useViewer((state) => state.walkthroughMode) + + useRegistry(node.id, 'spawn', ref) + + const materialProps = useMemo( + () => ({ + color: SPAWN_COLOR, + emissive: SPAWN_COLOR, + emissiveIntensity: 0.08, + metalness: 0.03, + roughness: 0.42, + }), + [], + ) + + const arrowShape = useMemo(() => { + const shape = new Shape() + shape.moveTo(0, 0.24) + shape.lineTo(-0.18, -0.14) + shape.lineTo(0.18, -0.14) + shape.closePath() + return shape + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default SpawnRenderer diff --git a/packages/nodes/src/spawn/schema.ts b/packages/nodes/src/spawn/schema.ts new file mode 100644 index 00000000..c6fc5fea --- /dev/null +++ b/packages/nodes/src/spawn/schema.ts @@ -0,0 +1,6 @@ +// Spawn's Zod schema lives in core today (and remains in the hand-maintained +// AnyNode union until Phase 6 derives it from the registry). The registry +// definition references it via this re-export so all consumers in +// `@pascal-app/nodes` can import from one place. + +export { SpawnNode } from '@pascal-app/core' diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx new file mode 100644 index 00000000..8fcd5fa9 --- /dev/null +++ b/packages/nodes/src/spawn/tool.tsx @@ -0,0 +1,128 @@ +'use client' + +import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import { type Group, Vector3 } from 'three' + +/** + * Registry-driven spawn placement tool. No props — reads `activeLevelId` from + * `useViewer` directly and broadcasts placement events through the store. + * + * Behavior parity with the legacy tool in + * `@pascal-app/editor/components/tools/spawn/spawn-tool.tsx`: + * - Grid-snap to half-meter increments on X/Z + * - Project click position into the active level's local frame + * - Singleton: if a spawn already exists for this level, reuse it and clean + * up any duplicates + * - On commit: select the placed spawn and exit build mode + * + * Mounted by `ToolManager`'s registry-first dispatch (Phase 0 shim) when + * `nodeRegistry.has('spawn')` and the active tool is 'spawn'. + */ + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 +const worldVector = new Vector3() + +function getExistingSpawnIds() { + const nodes = useScene.getState().nodes + return Object.values(nodes) + .filter((node) => node.type === 'spawn') + .map((node) => node.id) + .sort() +} + +function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { + const levelObject = sceneRegistry.nodes.get(levelId) + if (!levelObject) { + return [ + roundToHalf(event.localPosition[0]), + event.localPosition[1], + roundToHalf(event.localPosition[2]), + ] + } + worldVector.set(event.position[0], event.position[1], event.position[2]) + levelObject.updateWorldMatrix(true, false) + levelObject.worldToLocal(worldVector) + return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] +} + +const SpawnTool = () => { + const activeLevelId = useViewer((state) => state.selection.levelId) + const [, setCursor] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(null) + + useEffect(() => { + if (!activeLevelId) return + + const onGridMove = (event: GridEvent) => { + const next: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + event.localPosition[1], + roundToHalf(event.localPosition[2]), + ] + setCursor(next) + cursorRef.current?.position.set(next[0], next[1], next[2]) + } + + const onGridClick = (event: GridEvent) => { + const next = getLevelLocalPosition(activeLevelId, event) + const [existingSpawnId, ...duplicates] = getExistingSpawnIds() + let placedId: SpawnNode['id'] + + if (existingSpawnId) { + useScene.getState().updateNode(existingSpawnId, { + parentId: activeLevelId, + position: next, + rotation: 0, + }) + if (duplicates.length > 0) { + useScene.getState().deleteNodes(duplicates) + } + placedId = existingSpawnId + } else { + const spawn = SpawnNode.parse({ + name: 'Spawn Point', + position: next, + rotation: 0, + }) + useScene.getState().createNode(spawn, activeLevelId) + placedId = spawn.id + } + + useViewer.getState().setSelection({ selectedIds: [placedId] }) + // Note: legacy tool also emits sfx:structure-build and resets the editor + // tool/mode. We rely on the legacy ToolManager to do the latter via the + // build-tool exit path; this commit doesn't replicate the SFX since the + // registry doesn't yet bridge to the editor's sfx-emitter. Phase 4's + // command surface adds a clean path. + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + // Visible marker for the cursor — using a simple group + box. The legacy + // tool used a CursorSphere component from @pascal-app/editor; here we keep + // the dependency arrow flowing nodes→editor (which is allowed by the layer + // rules) but use a minimal inline mesh to avoid the dependency entirely for + // the spike. Phase 4 ports CursorSphere to the editor framework so node + // tools can reuse it. + return ( + + + + + + + ) +} + +export default SpawnTool diff --git a/packages/nodes/tsconfig.json b/packages/nodes/tsconfig.json index 523aa369..52df6093 100644 --- a/packages/nodes/tsconfig.json +++ b/packages/nodes/tsconfig.json @@ -11,5 +11,5 @@ }, "include": ["src"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], - "references": [{ "path": "../core" }] + "references": [{ "path": "../core" }, { "path": "../viewer" }] } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 6f3e7020..8a7ee8a6 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -5,6 +5,7 @@ export { SSGI_PARAMS, } from './components/viewer/post-processing' export { WalkthroughControls } from './components/viewer/walkthrough-controls' +export { useNodeEvents } from './hooks/use-node-events' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export {