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
+72 -37
View File
@@ -1,50 +1,85 @@
'use client'
import { useMemo } from 'react'
import { Color } from 'three'
import { useEffect, useMemo } from 'react'
import type { MeshStandardMaterial } from 'three'
import { buildShelfGeometry } from './geometry'
import type { ShelfNode } from './schema'
/**
* Translucent preview of a shelf. Used by:
* - The placement tool's cursor (ShelfTool) — at the cursor position
* - The move tool (MoveRegistryNodeTool) — at the drag target position
* Translucent preview of a shelf — used by the placement tool's cursor
* and the registry mover. Defers to `buildShelfGeometry` so the preview
* shape stays in lockstep with whatever the actual shelf will render,
* then walks the result, **clones** each mesh's material, and mutates
* the clone for a translucent ghost.
*
* Renders the same primitives as the actual ShelfRenderer, but with
* `transparent: true, opacity: 0.5` so the user can see what they're
* placing/moving without it being a hard solid.
* Cloning is non-negotiable: `getShelfMaterial` caches the default
* `MeshStandardMaterial` instance in a module-scoped map keyed on
* `material` / `materialPreset`, so every unpainted shelf in the scene
* shares the same material. Mutating `mat.transparent = true` here
* would leak into every committed shelf and render them all see-through.
*
* Building the full geometry tree per-frame would be wasteful, so we
* memoize the group + dispose the per-mesh material clones on unmount.
* Geometry is intentionally NOT disposed — `buildShelfGeometry` creates
* fresh BufferGeometry per call, but if a future revision returns
* cached geometry, disposing here would corrupt later renders. Keep the
* cleanup focused on what the preview itself created (the clones).
*
* **Raycast is disabled** on every preview mesh: the cursor follows the
* shelf, so without this the preview itself would intercept the cursor
* ray, `grid:move` would stop firing as soon as the preview entered the
* cursor cone, and the placement tool would lose track of the cursor's
* grid position. Disabling raycast lets the ray pass through the ghost
* to the grid plane below.
*/
const ShelfPreview = ({ node }: { node: ShelfNode }) => {
const color = useMemo(() => new Color(node.color), [node.color])
const topY = node.height + node.thickness / 2
const built = useMemo(() => buildShelfGeometry(node), [node])
const inset = Math.min(0.12, node.width / 6)
const bracketHeight = Math.max(0.01, node.height)
const bracketWidth =
node.bracketStyle === 'industrial'
? Math.max(0.04, node.depth * 0.2)
: Math.max(0.02, node.depth * 0.12)
const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7
useEffect(() => {
const cloned: MeshStandardMaterial[] = []
built.traverse((obj) => {
// Skip pointer events: see component-level note above.
;(obj as unknown as { raycast: () => void }).raycast = () => {}
return (
<group>
<mesh position={[0, topY, 0]}>
<boxGeometry args={[node.width, node.thickness, node.depth]} />
<meshStandardMaterial color={color} transparent opacity={0.5} />
</mesh>
{node.bracketStyle !== 'hidden' && (
<>
<mesh position={[-(node.width / 2 - inset), bracketHeight / 2, 0]}>
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
<meshStandardMaterial color={color} transparent opacity={0.5} />
</mesh>
<mesh position={[node.width / 2 - inset, bracketHeight / 2, 0]}>
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
<meshStandardMaterial color={color} transparent opacity={0.5} />
</mesh>
</>
)}
</group>
)
// `Mesh.material` is typed as `Material | Material[]` upstream;
// every shelf board carries a `MeshStandardMaterial` from
// `getShelfMaterial`. Access through a structural cast keeps the
// assignment well-typed without depending on the Mesh union.
const mesh = obj as {
material?: MeshStandardMaterial | MeshStandardMaterial[]
}
if (!mesh.material) return
const cloneAndSwap = (mat: MeshStandardMaterial): MeshStandardMaterial => {
const c = mat.clone()
c.transparent = true
c.opacity = 0.5
c.depthWrite = false
cloned.push(c)
return c
}
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map(cloneAndSwap)
} else {
mesh.material = cloneAndSwap(mesh.material)
}
})
return () => {
// Dispose only the clones we made — never the shared cached
// material returned by `getShelfMaterial`, which other shelves in
// the scene still reference. Geometry is left alone for the same
// reason; the builder may move to a cached strategy in future.
for (const c of cloned) c.dispose()
built.traverse((obj) => {
const mesh = obj as { geometry?: { dispose: () => void } }
mesh.geometry?.dispose()
})
}
}, [built])
return <primitive object={built} />
}
export default ShelfPreview