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 {