Phase 2 spike: spawn migration (flagged) + new shelf node

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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-14 13:07:38 -04:00
co-authored by Claude Opus 4.7
parent e8cf85313b
commit b6d77206b4
27 changed files with 881 additions and 6 deletions
@@ -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())
})
})
@@ -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)
})
})
+63
View File
@@ -0,0 +1,63 @@
import type { NodeDefinition } from '@pascal-app/core'
import { shelfParametrics } from './parametrics'
import { ShelfNode } from './schema'
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
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.',
},
}
+65
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
export { shelfDefinition } from './definition'
export { buildShelfGeometry } from './geometry'
export { ShelfNode } from './schema'
+28
View File
@@ -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<ShelfNode> = {
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' },
],
},
],
}
+62
View File
@@ -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<Group>(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 (
<group
position={liveTransform?.position ?? node.position}
ref={ref}
rotation={liveTransform?.rotation ? [0, liveTransform.rotation, 0] : node.rotation}
visible={node.visible}
/>
)
}
export default ShelfRenderer
+7
View File
@@ -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'
+77
View File
@@ -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<Group>(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 (
<group ref={cursorRef}>
<mesh position={[0, 0.9, 0]}>
<boxGeometry args={[1.2, 0.04, 0.3]} />
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
</mesh>
</group>
)
}
export default ShelfTool