Phase 5 batch kind: item migrates to registry (always-on)

Item is the first kind to use the `def.renderer` escape hatch (custom
React component with `useGLTF` + drei + interactive widgets) — not
expressible as a pure `def.geometry`. Catalog-backed + multi-host
(free / wall / wall-side / ceiling).

Files added (packages/nodes/src/item/):
 - schema.ts: re-exports ItemNode from core.
 - parametrics.ts: empty groups[]. Item parametrics come from the
   asset's catalog-defined interactive controls (toggles / sliders /
   temperature) — too dynamic for the auto-inspector at Stage A.
   Legacy ItemPanel renders the catalog-driven controls; Phase 5
   Stage E will likely use parametrics.customPanel.
 - definition.ts: capabilities (no `movable` — item move is bespoke
   MoveItemContent that handles attachTo transitions floor↔wall↔
   ceiling mid-drag; capability-driven dispatch keeps legacy mover),
   parametrics, renderer (wrap-export of ItemRenderer), system
   bundling ItemSystem + ItemLightSystem, toolHints matching the
   user's screenshot (Place item / R rotate ccw / T rotate cw /
   Shift free place / Esc cancel). defaults() casts an object
   literal with a stub asset since asset is required by the schema;
   createNode re-parses through ItemNode at runtime.
 - renderer.tsx: wrap-export of legacy ItemRenderer (~280 lines with
   useGLTF + interactive widgets — too much to duplicate at Stage A).
 - system.tsx: bundles ItemSystem + ItemLightSystem.
 - index.ts: barrel.

Files changed:
 - packages/viewer/src/index.ts: new public exports for ItemRenderer,
   ItemSystem, ItemLightSystem.
 - packages/nodes/src/index.ts: appends itemDefinition.
 - packages/editor/src/components/ui/panels/item-panel.tsx: panel
   slider-drag fix recipe applied (nodeRef pattern, drop subscribed
   updateNode dep, drop node from useCallback deps). Item panel has
   scale + position + rotation sliders all subject to the cascade.

Item is the registry's stress test for `def.renderer` escape hatch.
GLB loading via useGLTF + drei works as-is; nothing in the registry
forces a pure-geometry shape on kinds that don't fit.

Phase 5 progress: shelf  spawn  wall  fence  slab  ceiling 
door  window  item . Nine kinds on the registry. Stair / roof /
zone / containers remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 15:23:08 -04:00
co-authored by Claude Opus 4.7
parent 9eced06f32
commit 8d65be17fa
9 changed files with 206 additions and 13 deletions
@@ -3,7 +3,7 @@
import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core' import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react' import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -16,7 +16,6 @@ import { PanelWrapper } from './panel-wrapper'
export function ItemPanel() { export function ItemPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
@@ -26,18 +25,26 @@ export function ItemPanel() {
const [uniformScale, setUniformScale] = useState(true) const [uniformScale, setUniformScale] = useState(true)
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Item
// panel has scale + position + rotation sliders — same Maximum update
// depth cascade risk as fence / wall / etc. without the nodeRef.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<ItemNode>) => { (updates: Partial<ItemNode>) => {
if (!(selectedId && node)) return if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates) const n = nodeRef.current
if (!n) return
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
if (node.asset.attachTo === 'wall' && node.parentId) { if (n.asset.attachTo === 'wall' && n.parentId) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id']) useScene.getState().dirtyNodes.add(n.parentId as AnyNode['id'])
}) })
} }
}, },
[selectedId, node, updateNode], [selectedId],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
+9 -6
View File
@@ -2,6 +2,7 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { ceilingDefinition } from './ceiling' import { ceilingDefinition } from './ceiling'
import { doorDefinition } from './door' import { doorDefinition } from './door'
import { fenceDefinition } from './fence' import { fenceDefinition } from './fence'
import { itemDefinition } from './item'
import { shelfDefinition } from './shelf' import { shelfDefinition } from './shelf'
import { slabDefinition } from './slab' import { slabDefinition } from './slab'
import { spawnDefinition } from './spawn' import { spawnDefinition } from './spawn'
@@ -19,12 +20,12 @@ import { windowDefinition } from './window'
* `loadPlugin` call path. This is intentional: the API is stress-tested * `loadPlugin` call path. This is intentional: the API is stress-tested
* by built-ins before any third-party plugin lands. * by built-ins before any third-party plugin lands.
* *
* All four current kinds are registered unconditionally. Parity is * All kinds are registered unconditionally. Parity is verified by
* verified by comparing against deployed production rather than an * comparing against deployed production rather than an in-app env-var
* in-app env-var flag toggle. Legacy paths still exist in `viewer/` and * flag toggle. Legacy paths still exist in `viewer/` and `editor/` for
* `editor/` for kinds undergoing migration; they short-circuit via the * kinds undergoing migration; they short-circuit via the Phase 0
* Phase 0 `<LegacySystem>` wrapper + `NodeRenderer`'s registry-first * `<LegacySystem>` wrapper + `NodeRenderer`'s registry-first dispatch.
* dispatch. Phase 6 deletes the legacy paths. * Phase 6 deletes the legacy paths.
*/ */
export const builtinPlugin: Plugin = { export const builtinPlugin: Plugin = {
id: 'pascal:core', id: 'pascal:core',
@@ -38,12 +39,14 @@ export const builtinPlugin: Plugin = {
ceilingDefinition as unknown as AnyNodeDefinition, ceilingDefinition as unknown as AnyNodeDefinition,
doorDefinition as unknown as AnyNodeDefinition, doorDefinition as unknown as AnyNodeDefinition,
windowDefinition as unknown as AnyNodeDefinition, windowDefinition as unknown as AnyNodeDefinition,
itemDefinition as unknown as AnyNodeDefinition,
], ],
} }
export { ceilingDefinition } from './ceiling' export { ceilingDefinition } from './ceiling'
export { doorDefinition } from './door' export { doorDefinition } from './door'
export { fenceDefinition } from './fence' export { fenceDefinition } from './fence'
export { itemDefinition } from './item'
export { shelfDefinition } from './shelf' export { shelfDefinition } from './shelf'
export { slabDefinition } from './slab' export { slabDefinition } from './slab'
export { spawnDefinition } from './spawn' export { spawnDefinition } from './spawn'
+110
View File
@@ -0,0 +1,110 @@
import type { ItemNode as ItemNodeType, NodeDefinition } from '@pascal-app/core'
import { itemParametrics } from './parametrics'
import { ItemNode } from './schema'
/**
* Item — Phase 5 batch kind. Catalog-backed, GLB-rendered, multi-host.
*
* Demonstrates the **custom `def.renderer` escape hatch** (see
* plans/editor-node-registry.md): items use `useGLTF` from drei to
* load CDN assets, plus a non-trivial interactive-widget layer inside
* the rendered scene. Not expressible as a pure `def.geometry`. The
* registry mounts the custom React renderer as-is.
*
* Capabilities:
* - **No `movable`**: item's move is bespoke `MoveItemContent` —
* handles attachTo transitions mid-drag (floor ↔ wall ↔ ceiling),
* asset.attachTo lookups, scale-preserving Y math for surface
* placement. The smooth generic mover can't express that. Legacy
* mover keeps running via capability-driven dispatch.
* - `selectable`, `duplicable`, `deletable` standard.
* - Items have a catalog-defined `surface.height` (some items act as
* tables — they expose a surface other items stack on). For Stage A
* we don't surface this via `capabilities.surfaces.top` yet —
* legacy ItemSystem computes the stack y via spatial-grid lookups.
* Phase 5+ may surface it.
*
* `toolHints`: matches the legacy ItemHelper UI (mouse / R / T / Shift /
* Esc) — same panel the user sees during placement. Once item registers,
* `HelperManager` consults `def.toolHints` and renders the
* `RegisteredToolHelper` for placement (the legacy ItemHelper still
* renders for movingNode state — that's a generic "you're moving
* something" panel, not item-specific; Phase 5+ may deprecate it).
*
* Renderer + system: wrap-export of legacy ItemRenderer + bundle of
* ItemSystem + ItemLightSystem.
*
* Tool field absent: catalog UI + item-tool placement flow stays on
* editor state. Phase 5+ may port to `DragAction` once the registry's
* catalog-aware affordances exist.
*/
export const itemDefinition: NodeDefinition<typeof ItemNode> = {
kind: 'item',
schemaVersion: 1,
schema: ItemNode,
category: 'furnish',
// Defaults shape is cast: the schema requires a fully-typed `asset`
// field, but in practice items are always created from the catalog
// (the asset is supplied at placement time). `createNode` re-parses
// through the schema, so any missing zod defaults fill at runtime.
defaults: () =>
({
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'placeholder',
category: 'misc',
name: 'Item',
thumbnail: '',
src: 'asset:placeholder',
dimensions: [1, 1, 1],
source: 'library',
},
}) as unknown as Omit<ItemNodeType, 'id' | 'type'>,
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: itemParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
// Same priority as the legacy ItemSystem.
priority: 2,
},
toolHints: [
{ key: 'Left click', label: 'Place item' },
{ key: 'R', label: 'Rotate counterclockwise' },
{ key: 'T', label: 'Rotate clockwise' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Item',
description: 'A catalog-backed item (furniture, fixtures, decorations).',
icon: { kind: 'iconify', name: 'lucide:armchair' },
paletteSection: 'furnish',
paletteOrder: 10,
},
mcp: {
description:
'A catalog-backed item with asset reference, transforms, and optional attachTo for wall/ceiling mounting.',
},
}
+2
View File
@@ -0,0 +1,2 @@
export { itemDefinition } from './definition'
export { ItemNode } from './schema'
+18
View File
@@ -0,0 +1,18 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { ItemNode } from './schema'
/**
* Minimal inspector descriptor for item. Items have catalog-driven
* properties (asset.id, asset.dimensions, asset.interactive controls,
* etc.) that don't fit the auto-inspector at Stage A — those are edited
* via the legacy `<ItemPanel>` which renders the catalog-defined
* controls dynamically. Auto-inspector covers only the per-instance
* transform (uniform scale).
*
* Phase 5 Stage E (drop legacy panel) probably uses
* `parametrics.customPanel` to render the catalog-driven controls in
* a registry-aware way.
*/
export const itemParametrics: ParametricDescriptor<ItemNode> = {
groups: [],
}
+21
View File
@@ -0,0 +1,21 @@
'use client'
import { ItemRenderer } from '@pascal-app/viewer'
/**
* Wrap-export of the legacy `ItemRenderer`.
*
* Item's renderer is ~280 lines using `useGLTF` from `@react-three/drei`
* to load GLB assets from the CDN. It also handles asset-loaded
* `interactive` widgets (clickable hot-spots, sliders inside the
* scene), surface mounting, attachment offsets — too much code to
* duplicate at Stage A. Phase 5 Stage F (cleanup) moves it into this
* folder if useful, or leaves it in viewer with the public re-export.
*
* Item is also the first kind to demonstrate the "custom def.renderer"
* escape hatch documented in plans/editor-node-registry.md — kinds with
* GLB loaders, drei helpers, `useGLTF`, etc., set `def.renderer` to a
* full React component rather than trying to express geometry as a
* pure builder.
*/
export default ItemRenderer
+1
View File
@@ -0,0 +1 @@
export { ItemNode } from '@pascal-app/core'
+25
View File
@@ -0,0 +1,25 @@
'use client'
import { ItemLightSystem, ItemSystem } from '@pascal-app/viewer'
/**
* Registry-driven item system bundle.
*
* - **`ItemSystem`** — applies attachTo-driven transforms each frame
* (wall-side z-offset, slab elevation, ceiling mounting).
* - **`ItemLightSystem`** — manages light sources attached to items
* (lamps, ceiling lights, etc.).
*
* Both are wrapped in `<LegacySystem kind="item">` legacy mounts; with
* item registered, those short-circuit and this bundle takes over.
*/
const ItemSystems = () => {
return (
<>
<ItemSystem />
<ItemLightSystem />
</>
)
}
export default ItemSystems
+6
View File
@@ -12,6 +12,7 @@ export { CeilingRenderer } from './components/renderers/ceiling/ceiling-renderer
// Each kind has a geometry system + animation system to mount via // Each kind has a geometry system + animation system to mount via
// RegisteredSystems when the kind is registry-driven. // RegisteredSystems when the kind is registry-driven.
export { DoorRenderer } from './components/renderers/door/door-renderer' export { DoorRenderer } from './components/renderers/door/door-renderer'
export { ItemRenderer } from './components/renderers/item/item-renderer'
export { NodeRenderer } from './components/renderers/node-renderer' export { NodeRenderer } from './components/renderers/node-renderer'
export { WindowRenderer } from './components/renderers/window/window-renderer' export { WindowRenderer } from './components/renderers/window/window-renderer'
export { default as Viewer } from './components/viewer' export { default as Viewer } from './components/viewer'
@@ -50,6 +51,11 @@ export { DoorSystem } from './systems/door/door-system'
// alongside the legacy fence mount point. // alongside the legacy fence mount point.
export { FenceSystem } from './systems/fence/fence-system' export { FenceSystem } from './systems/fence/fence-system'
export { InteractiveSystem } from './systems/interactive/interactive-system' export { InteractiveSystem } from './systems/interactive/interactive-system'
// Item systems for the registry-driven item definition. ItemSystem
// applies attachTo-driven transforms each frame; ItemLightSystem
// manages item-mounted light sources.
export { ItemSystem } from './systems/item/item-system'
export { ItemLightSystem } from './systems/item-light/item-light-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils' export { snapLevelsToTruePositions } from './systems/level/level-utils'
export { getRoofMaterialArray } from './systems/roof/roof-materials' export { getRoofMaterialArray } from './systems/roof/roof-materials'
// Slab system follows the wall + fence re-export pattern — composed into // Slab system follows the wall + fence re-export pattern — composed into