From 8d65be17fa97843929045fcd14443a64a481758e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 15:23:08 -0400 Subject: [PATCH] Phase 5 batch kind: item migrates to registry (always-on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/ui/panels/item-panel.tsx | 21 ++-- packages/nodes/src/index.ts | 15 ++- packages/nodes/src/item/definition.ts | 110 ++++++++++++++++++ packages/nodes/src/item/index.ts | 2 + packages/nodes/src/item/parametrics.ts | 18 +++ packages/nodes/src/item/renderer.tsx | 21 ++++ packages/nodes/src/item/schema.ts | 1 + packages/nodes/src/item/system.tsx | 25 ++++ packages/viewer/src/index.ts | 6 + 9 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 packages/nodes/src/item/definition.ts create mode 100644 packages/nodes/src/item/index.ts create mode 100644 packages/nodes/src/item/parametrics.ts create mode 100644 packages/nodes/src/item/renderer.tsx create mode 100644 packages/nodes/src/item/schema.ts create mode 100644 packages/nodes/src/item/system.tsx diff --git a/packages/editor/src/components/ui/panels/item-panel.tsx b/packages/editor/src/components/ui/panels/item-panel.tsx index b4ee0154..0df023dc 100644 --- a/packages/editor/src/components/ui/panels/item-panel.tsx +++ b/packages/editor/src/components/ui/panels/item-panel.tsx @@ -3,7 +3,7 @@ import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' 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 { cn } from '../../../lib/utils' import useEditor from '../../../store/use-editor' @@ -16,7 +16,6 @@ import { PanelWrapper } from './panel-wrapper' export function ItemPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) @@ -26,18 +25,26 @@ export function ItemPanel() { 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( (updates: Partial) => { - if (!(selectedId && node)) return - updateNode(selectedId as AnyNode['id'], updates) + if (!selectedId) return + 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(() => { - 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(() => { diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 1f9bea25..7fdd2f96 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -2,6 +2,7 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { ceilingDefinition } from './ceiling' import { doorDefinition } from './door' import { fenceDefinition } from './fence' +import { itemDefinition } from './item' import { shelfDefinition } from './shelf' import { slabDefinition } from './slab' import { spawnDefinition } from './spawn' @@ -19,12 +20,12 @@ import { windowDefinition } from './window' * `loadPlugin` call path. This is intentional: the API is stress-tested * by built-ins before any third-party plugin lands. * - * All four current kinds are registered unconditionally. Parity is - * verified by comparing against deployed production rather than an - * in-app env-var flag toggle. Legacy paths still exist in `viewer/` and - * `editor/` for kinds undergoing migration; they short-circuit via the - * Phase 0 `` wrapper + `NodeRenderer`'s registry-first - * dispatch. Phase 6 deletes the legacy paths. + * All kinds are registered unconditionally. Parity is verified by + * comparing against deployed production rather than an in-app env-var + * flag toggle. Legacy paths still exist in `viewer/` and `editor/` for + * kinds undergoing migration; they short-circuit via the Phase 0 + * `` wrapper + `NodeRenderer`'s registry-first dispatch. + * Phase 6 deletes the legacy paths. */ export const builtinPlugin: Plugin = { id: 'pascal:core', @@ -38,12 +39,14 @@ export const builtinPlugin: Plugin = { ceilingDefinition as unknown as AnyNodeDefinition, doorDefinition as unknown as AnyNodeDefinition, windowDefinition as unknown as AnyNodeDefinition, + itemDefinition as unknown as AnyNodeDefinition, ], } export { ceilingDefinition } from './ceiling' export { doorDefinition } from './door' export { fenceDefinition } from './fence' +export { itemDefinition } from './item' export { shelfDefinition } from './shelf' export { slabDefinition } from './slab' export { spawnDefinition } from './spawn' diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts new file mode 100644 index 00000000..0f9217f7 --- /dev/null +++ b/packages/nodes/src/item/definition.ts @@ -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 = { + 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, + + 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.', + }, +} diff --git a/packages/nodes/src/item/index.ts b/packages/nodes/src/item/index.ts new file mode 100644 index 00000000..2d02e614 --- /dev/null +++ b/packages/nodes/src/item/index.ts @@ -0,0 +1,2 @@ +export { itemDefinition } from './definition' +export { ItemNode } from './schema' diff --git a/packages/nodes/src/item/parametrics.ts b/packages/nodes/src/item/parametrics.ts new file mode 100644 index 00000000..2daabdad --- /dev/null +++ b/packages/nodes/src/item/parametrics.ts @@ -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 `` 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 = { + groups: [], +} diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx new file mode 100644 index 00000000..b4aa7b51 --- /dev/null +++ b/packages/nodes/src/item/renderer.tsx @@ -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 diff --git a/packages/nodes/src/item/schema.ts b/packages/nodes/src/item/schema.ts new file mode 100644 index 00000000..823f2451 --- /dev/null +++ b/packages/nodes/src/item/schema.ts @@ -0,0 +1 @@ +export { ItemNode } from '@pascal-app/core' diff --git a/packages/nodes/src/item/system.tsx b/packages/nodes/src/item/system.tsx new file mode 100644 index 00000000..5b7f06d2 --- /dev/null +++ b/packages/nodes/src/item/system.tsx @@ -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 `` legacy mounts; with + * item registered, those short-circuit and this bundle takes over. + */ +const ItemSystems = () => { + return ( + <> + + + + ) +} + +export default ItemSystems diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 72f24ba8..53bcba52 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -12,6 +12,7 @@ export { CeilingRenderer } from './components/renderers/ceiling/ceiling-renderer // Each kind has a geometry system + animation system to mount via // RegisteredSystems when the kind is registry-driven. export { DoorRenderer } from './components/renderers/door/door-renderer' +export { ItemRenderer } from './components/renderers/item/item-renderer' export { NodeRenderer } from './components/renderers/node-renderer' export { WindowRenderer } from './components/renderers/window/window-renderer' export { default as Viewer } from './components/viewer' @@ -50,6 +51,11 @@ export { DoorSystem } from './systems/door/door-system' // alongside the legacy fence mount point. export { FenceSystem } from './systems/fence/fence-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 { getRoofMaterialArray } from './systems/roof/roof-materials' // Slab system follows the wall + fence re-export pattern — composed into