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>
86 lines
3.4 KiB
TypeScript
86 lines
3.4 KiB
TypeScript
'use client'
|
|
|
|
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
|
|
* 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.
|
|
*
|
|
* 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 built = useMemo(() => buildShelfGeometry(node), [node])
|
|
|
|
useEffect(() => {
|
|
const cloned: MeshStandardMaterial[] = []
|
|
built.traverse((obj) => {
|
|
// Skip pointer events: see component-level note above.
|
|
;(obj as unknown as { raycast: () => void }).raycast = () => {}
|
|
|
|
// `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
|