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:
co-authored by
Claude Opus 4.7
parent
9eced06f32
commit
8d65be17fa
@@ -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(() => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { itemDefinition } from './definition'
|
||||||
|
export { ItemNode } from './schema'
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ItemNode } from '@pascal-app/core'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user