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:
co-authored by
Claude Opus 4.7
parent
e8cf85313b
commit
b6d77206b4
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { spawnParametrics } from './parametrics'
|
||||
import { SpawnNode } from './schema'
|
||||
|
||||
export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
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.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { spawnDefinition } from './definition'
|
||||
export { SpawnNode } from './schema'
|
||||
@@ -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<SpawnNode> = {
|
||||
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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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<Group>(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 (
|
||||
<group
|
||||
position={liveTransform?.position ?? node.position}
|
||||
ref={ref}
|
||||
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
|
||||
visible={!walkthroughMode}
|
||||
>
|
||||
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
|
||||
<ringGeometry args={[0.34, 0.48, 48]} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0.1, -0.52]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
|
||||
<shapeGeometry args={[arrowShape]} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0.41, 0]} {...handlers}>
|
||||
<boxGeometry args={[0.3, 0.54, 0.16]} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0.83, 0]} {...handlers}>
|
||||
<boxGeometry args={[0.18, 0.18, 0.18]} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpawnRenderer
|
||||
@@ -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'
|
||||
@@ -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<Group>(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 (
|
||||
<group ref={cursorRef}>
|
||||
<mesh position={[0, 1.1, 0]}>
|
||||
<sphereGeometry args={[0.18, 16, 12]} />
|
||||
<meshStandardMaterial color="#60a5fa" transparent opacity={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpawnTool
|
||||
Reference in New Issue
Block a user