shelf: v2 — cubby default, withBottom, item hosting, paintable surface

Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle
and a `children: ItemNode[]` field for item hosting. Schema-level defaults
preserve the v1 wall-shelf visual so existing scenes load unchanged; the
placement tool spreads `shelfDefinition.defaults()` for fresh shelves
(cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on).

Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share
the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per
row, plus the bottom-board top when `withBottom` is on for cubby /
bookshelf.

Material is a single paintable surface (same shape walls / slabs / stairs
use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so
unpainted shelves read as the canonical off-white.

Preview clones each cached material before mutating `transparent / opacity`
on the ghost — without the clone the mutation leaked into the cached
`getShelfMaterial` instance every committed shelf was using, rendering
them all see-through after the first placement preview rendered.

Store hardening: `migrateNodes` patches missing `children: []` on v1
shelves, and `updateNodesAction` reparenting tolerates a missing children
array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint
mode picks up the kind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-19 15:11:13 -04:00
co-authored by Claude Opus 4.7
parent 56e022a093
commit 924567293a
16 changed files with 1115 additions and 217 deletions
@@ -1,12 +1,13 @@
'use client'
import { type ShelfNode, useScene } from '@pascal-app/core'
import { type AnyNodeId, type ShelfNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Layers } from 'lucide-react'
import { memo, useCallback, useState } from 'react'
import Image from 'next/image'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface ShelfTreeNodeProps {
@@ -16,10 +17,11 @@ interface ShelfTreeNodeProps {
}
/**
* Sidebar tree entry for shelf. Mirrors spawn-tree-node's shape so the
* existing tree-node-wrapper / selection / hover / rename plumbing all work
* unchanged. Phase 4 derives this row generically from
* `definition.presentation` — until then, one file per kind.
* Sidebar tree entry for shelf. Mirrors `item-tree-node`'s shape so the
* shelf's hosted items list as collapsible children — same pattern items
* use for their nested items. The shelf has its own `children: ItemNode[`id`]`
* field on the schema; items reparent into it via `def.surfaces` + the
* placement coordinator's shelf strategy.
*/
export const ShelfTreeNode = memo(function ShelfTreeNode({
nodeId,
@@ -27,12 +29,37 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
isLast,
}: ShelfTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(true)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const children = useScene(
useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []),
)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
// Expand when a descendant is selected — same imperative subscription
// the item tree-node uses, so we don't re-render when unrelated
// selection-state ticks.
useEffect(() => {
return useViewer.subscribe((state) => {
const { selectedIds } = state.selection
if (selectedIds.length === 0) return
const nodes = useScene.getState().nodes
for (const id of selectedIds) {
let current = nodes[id as AnyNodeId]
while (current?.parentId) {
if (current.parentId === nodeId) {
setExpanded(true)
return
}
current = nodes[current.parentId as AnyNodeId]
}
}
})
}, [nodeId])
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -49,13 +76,24 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
[nodeId, setSelection],
)
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const hasChildren = children.length > 0
return (
<TreeNodeWrapper
actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth}
expanded={false}
hasChildren={false}
icon={<Layers size={14} />}
expanded={expanded}
hasChildren={hasChildren}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/shelf.png" width={14} />
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
@@ -65,16 +103,26 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
defaultName="Shelf"
isEditing={isEditing}
nodeId={nodeId}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
onStartEditing={handleStartEditing}
onStopEditing={handleStopEditing}
/>
}
nodeId={nodeId}
onClick={handleClick}
onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}}
/>
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onToggle={handleToggle}
>
{hasChildren &&
children.map((childId, index) => (
<TreeNode
depth={depth + 1}
isLast={index === children.length - 1}
key={childId}
nodeId={childId}
/>
))}
</TreeNodeWrapper>
)
})