Proves the unified (nodeId, slotId) slot contract on a procedural generator, beyond items and walls. A shelf now exposes three paintable slots — shelves / frame / back — painted through the same PaintCapability dispatch and the same node.slots: Record<slotId, MaterialRef> shape items use. Foundation (shared, reusable by future procedural kinds): - core: SlotDeclaration type + capabilities.slots(node) registry declaration; GeometryContext gains `materials` so a pure builder can resolve scene:<id> slot refs without importing useScene. - viewer GeometrySystem: threads the scene material library into every builder ctx, and re-dirties (bypassing the geometryKey skip) any geometry node that references a scene material when that material changes — so editing a custom colour propagates to every shelf using it, matching items. Shelf: - schema: slots: Record<string, MaterialRef> (mirrors ItemNode). - geometry: per-slot material resolution (slot override -> legacy whole-shelf -> declared default colour); every mesh stamped with userData.slotId; DEFAULT_SHELF_MATERIAL retired (declared default gives identical off-white). - paint.ts: PaintCapability (resolveRole from userData.slotId, scene-material commit for one-off colours, preview restricted to __fromGeometry meshes so hosted items aren't ghosted, getEffectiveMaterial incl. legacy fallback). - definition: paint + slots capabilities; slots folded into geometryKey. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
3.3 KiB
TypeScript
86 lines
3.3 KiB
TypeScript
'use client'
|
|
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect, useMemo } from 'react'
|
|
import type { Material } 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: shelf geometry may receive materials from
|
|
* shared viewer caches, so mutating `mat.transparent = true` here would
|
|
* leak into committed shelves using the same material.
|
|
*
|
|
* 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 shading = useViewer((s) => s.shading)
|
|
const built = useMemo(() => buildShelfGeometry(node, undefined, shading), [node, shading])
|
|
|
|
useEffect(() => {
|
|
const cloned: Material[] = []
|
|
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 material from the geometry builder.
|
|
// Access through a structural cast keeps the assignment well-typed
|
|
// without depending on the Mesh union.
|
|
const mesh = obj as {
|
|
material?: Material | Material[]
|
|
}
|
|
if (!mesh.material) return
|
|
|
|
const cloneAndSwap = (mat: Material): Material => {
|
|
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 the builder, which other shelves in the
|
|
// scene may 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
|