'use client' import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, CollectionsPopover, PanelSection, PanelWrapper, SliderControl, triggerSFX, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react' import { useCallback, useRef, useState } from 'react' /** * Stage E inspector for item. 1:1 port of the legacy * `editor/components/ui/panels/item-panel.tsx`, relocated into the * kind's folder so `parametrics.customPanel` mounts it through the * registry inspector. The catalog popover (``) is * the only kind-specific UI that can't be expressed via the generic * auto-inspector today — kept inline. * * Slider-drag fix recipe applied: scale / position / rotation slider * `onChange` callbacks read from a `useRef(node)` instead of the * closure-captured node, which would re-render every panel-driven * update mid-drag and exceed React's update-depth budget on big scenes * (see the wiki / plan recipe). */ export default function ItemPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined, ) const [uniformScale, setUniformScale] = useState(true) const nodeRef = useRef(node) nodeRef.current = node const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return const n = nodeRef.current if (!n) return useScene.getState().updateNode(selectedId as AnyNode['id'], updates) // When an item is mounted on a wall, dirty the wall so the next // frame regenerates its cutout geometry around the moved item. if (n.asset.attachTo === 'wall' && n.parentId) { requestAnimationFrame(() => { useScene.getState().markDirty(n.parentId as AnyNode['id']) }) } }, [selectedId], ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) const handleMove = useCallback(() => { if (node) { triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) } }, [node, setMovingNode, setSelection]) const handleDuplicate = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') const proto = ItemNode.parse({ position: [...node.position] as [number, number, number], rotation: [...node.rotation] as [number, number, number], name: node.name, asset: node.asset, parentId: node.parentId, side: node.side, metadata: { isNew: true }, }) setMovingNode(proto) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!selectedId) return triggerSFX('sfx:item-delete') deleteNode(selectedId as AnyNode['id']) setSelection({ selectedIds: [] }) }, [selectedId, deleteNode, setSelection]) if (!(node && node.type === 'item' && selectedId)) return null return ( Xpos } max={node.position[0] + 2} min={node.position[0] - 2} onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] }) } precision={2} step={0.01} unit="m" value={Math.round(node.position[0] * 100) / 100} /> Ypos } max={node.position[1] + 2} min={node.position[1] - 2} onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] }) } precision={2} step={0.01} unit="m" value={Math.round(node.position[1] * 100) / 100} /> Zpos } max={node.position[2] + 2} min={node.position[2] - 2} onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] }) } precision={2} step={0.01} unit="m" value={Math.round(node.position[2] * 100) / 100} /> Yrot } max={Math.round((node.rotation[1] * 180) / Math.PI) + 45} min={Math.round((node.rotation[1] * 180) / Math.PI) - 45} onChange={(degrees) => { const radians = (degrees * Math.PI) / 180 handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) }} precision={0} step={1} unit="°" value={Math.round((node.rotation[1] * 180) / Math.PI)} />
{ triggerSFX('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI const radians = ((currentDegrees - 45) * Math.PI) / 180 handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) }} /> { triggerSFX('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI const radians = ((currentDegrees + 45) * Math.PI) / 180 handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) }} />
Uniform Scale
{uniformScale ? ( XYZscale } max={10} min={0.01} onChange={(value) => { const v = Math.max(0.01, value) handleUpdate({ scale: [v, v, v] }) }} precision={2} step={0.1} value={Math.round(node.scale[0] * 100) / 100} /> ) : ( <> Xscale } max={10} min={0.01} onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] }) } precision={2} step={0.1} value={Math.round(node.scale[0] * 100) / 100} /> Yscale } max={10} min={0.01} onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] }) } precision={2} step={0.1} value={Math.round(node.scale[1] * 100) / 100} /> Zscale } max={10} min={0.01} onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] }) } precision={2} step={0.1} value={Math.round(node.scale[2] * 100) / 100} /> )}
Dimensions {(() => { const [w, h, d] = getScaledDimensions(node) return ( {Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100} ) })()}
} label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> } label="Delete" onClick={handleDelete} />
) }