-
-
- {MATERIAL_CATEGORIES.map((category) => (
-
- ))}
-
+
+ {availableCategories.map((category) => (
+
+ ))}
- {catalogItems.map((item) => (
+ {catalogItems.map((item) => {
+ const isSelected = selectedCatalogId === toLibraryMaterialRef(item.id)
+ return (
- ))}
- {selectedCategory === 'other' && onChange ? (
-
+ ) : null}
)}
diff --git a/packages/editor/src/components/ui/controls/material-properties-editor.tsx b/packages/editor/src/components/ui/controls/material-properties-editor.tsx
new file mode 100644
index 000000000..533400097
--- /dev/null
+++ b/packages/editor/src/components/ui/controls/material-properties-editor.tsx
@@ -0,0 +1,108 @@
+'use client'
+
+import type { MaterialProperties, MaterialSchema } from '@pascal-app/core'
+import { Input } from '../primitives/input'
+import { SliderControl } from './slider-control'
+
+const DEFAULT_MATERIAL_PROPERTIES: MaterialProperties = {
+ color: '#ffffff',
+ roughness: 0.5,
+ metalness: 0,
+ opacity: 1,
+ transparent: false,
+ side: 'front',
+}
+
+export function MaterialPropertiesEditor({
+ value,
+ onChange,
+}: {
+ value: MaterialSchema
+ onChange: (next: MaterialSchema) => void
+}) {
+ const currentProps = value.properties ?? DEFAULT_MATERIAL_PROPERTIES
+
+ const updateMaterial = (
+ updates: Partial
,
+ nextTransparent = currentProps.transparent,
+ ) => {
+ onChange({
+ ...value,
+ preset: value.preset ?? 'custom',
+ properties: {
+ ...currentProps,
+ ...updates,
+ transparent: nextTransparent,
+ },
+ })
+ }
+
+ return (
+
+
+
+
updateMaterial({ roughness: value })}
+ precision={2}
+ step={0.01}
+ value={currentProps.roughness}
+ />
+
+ updateMaterial({ metalness: value })}
+ precision={2}
+ step={0.01}
+ value={currentProps.metalness}
+ />
+
+ updateMaterial({ opacity: value }, value < 1 || currentProps.transparent)}
+ precision={2}
+ step={0.01}
+ value={currentProps.opacity}
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/packages/editor/src/components/ui/controls/scene-material-list.tsx b/packages/editor/src/components/ui/controls/scene-material-list.tsx
new file mode 100644
index 000000000..a300ffc33
--- /dev/null
+++ b/packages/editor/src/components/ui/controls/scene-material-list.tsx
@@ -0,0 +1,239 @@
+'use client'
+
+import {
+ generateSceneMaterialId,
+ type MaterialSchema,
+ type SceneMaterial,
+ type SceneMaterialId,
+ toSceneMaterialRef,
+ useScene,
+} from '@pascal-app/core'
+import { Copy, Paintbrush, Pencil, Trash2 } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import useEditor from '../../../store/use-editor'
+import { Button } from '../primitives/button'
+import { Input } from '../primitives/input'
+import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
+import { MaterialPropertiesEditor } from './material-properties-editor'
+
+type SlotRecord = Record
+
+function getSlotRecord(node: unknown): SlotRecord | null {
+ if (!node || typeof node !== 'object' || !('slots' in node)) return null
+ const slots = (node as { slots?: unknown }).slots
+ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) return null
+ return slots as SlotRecord
+}
+
+export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId | null }) {
+ const materials = useScene((state) => state.materials)
+ const nodes = useScene((state) => state.nodes)
+ const addSceneMaterial = useScene((state) => state.addSceneMaterial)
+ const updateSceneMaterial = useScene((state) => state.updateSceneMaterial)
+ const removeSceneMaterial = useScene((state) => state.removeSceneMaterial)
+ const activePaintTarget = useEditor((state) => state.activePaintTarget)
+ const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
+
+ const materialEntries = useMemo(
+ () => Object.entries(materials) as [SceneMaterialId, SceneMaterial][],
+ [materials],
+ )
+
+ const usageCounts = useMemo(() => {
+ const counts = new Map()
+ const refToId = new Map()
+
+ for (const [id] of materialEntries) {
+ counts.set(id, 0)
+ refToId.set(toSceneMaterialRef(id), id)
+ }
+
+ for (const node of Object.values(nodes)) {
+ const slots = getSlotRecord(node)
+ if (!slots) continue
+
+ for (const value of Object.values(slots)) {
+ if (typeof value !== 'string') continue
+ const materialId = refToId.get(value)
+ if (!materialId) continue
+ counts.set(materialId, (counts.get(materialId) ?? 0) + 1)
+ }
+ }
+
+ return counts
+ }, [materialEntries, nodes])
+
+ return (
+
+ {materialEntries.map(([id, sceneMaterial]) => (
+
+ ))}
+
+ )
+}
+
+function SceneMaterialRow({
+ id,
+ sceneMaterial,
+ usageCount,
+ activePaintTarget,
+ autoEdit,
+ addSceneMaterial,
+ updateSceneMaterial,
+ removeSceneMaterial,
+ setActivePaintMaterial,
+}: {
+ id: SceneMaterialId
+ sceneMaterial: SceneMaterial
+ usageCount: number
+ activePaintTarget: ReturnType['activePaintTarget']
+ autoEdit: boolean
+ addSceneMaterial: ReturnType['addSceneMaterial']
+ updateSceneMaterial: ReturnType['updateSceneMaterial']
+ removeSceneMaterial: ReturnType['removeSceneMaterial']
+ setActivePaintMaterial: ReturnType['setActivePaintMaterial']
+}) {
+ // A freshly-created material (via "+ Custom") mounts with its editor open.
+ const [isEditingMaterial, setIsEditingMaterial] = useState(autoEdit)
+ const [draftName, setDraftName] = useState(sceneMaterial.name)
+ const swatchColor = sceneMaterial.material.properties?.color ?? '#ffffff'
+
+ useEffect(() => {
+ setDraftName(sceneMaterial.name)
+ }, [sceneMaterial.name])
+
+ const commitName = () => {
+ const nextName = draftName.trim()
+ if (!nextName) {
+ setDraftName(sceneMaterial.name)
+ return
+ }
+ if (nextName !== sceneMaterial.name) {
+ updateSceneMaterial(id, { name: nextName })
+ }
+ }
+
+ const duplicateMaterial = () => {
+ addSceneMaterial({
+ id: generateSceneMaterialId(),
+ name: `${sceneMaterial.name} copy`,
+ material: structuredClone(sceneMaterial.material) as MaterialSchema,
+ })
+ }
+
+ return (
+
+
+
+ setDraftName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.currentTarget.blur()
+ }
+ if (e.key === 'Escape') {
+ setDraftName(sceneMaterial.name)
+ e.currentTarget.blur()
+ }
+ }}
+ value={draftName}
+ />
+
+
+
+
+ Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'}
+
+
+
+
+
+ setActivePaintMaterial({
+ materialPreset: toSceneMaterialRef(id),
+ sourceTarget: activePaintTarget,
+ })
+ }
+ size="icon-sm"
+ type="button"
+ variant="outline"
+ >
+
+
+
+ Paint with
+
+
+
+ setIsEditingMaterial((value) => !value)}
+ size="icon-sm"
+ type="button"
+ variant={isEditingMaterial ? 'default' : 'outline'}
+ >
+
+
+
+ Edit
+
+
+
+
+
+
+
+ Duplicate
+
+
+
+ removeSceneMaterial(id)}
+ size="icon-sm"
+ type="button"
+ variant="outline"
+ >
+
+
+
+ Delete
+
+
+
+
+ {isEditingMaterial ? (
+
+ updateSceneMaterial(id, { material })}
+ value={sceneMaterial.material}
+ />
+
+ ) : null}
+
+ )
+}
diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx
deleted file mode 100644
index c11a26a01..000000000
--- a/packages/editor/src/components/ui/panels/paint-panel.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-'use client'
-
-import useEditor from '../../../store/use-editor'
-import { PanelSection } from '../controls/panel-section'
-import { Input } from '../primitives/input'
-import { PanelWrapper } from './panel-wrapper'
-
-function buildDefaultCustomMaterial() {
- return {
- preset: 'custom' as const,
- properties: {
- color: '#ffffff',
- roughness: 0.5,
- metalness: 0,
- opacity: 1,
- transparent: false,
- side: 'front' as const,
- },
- }
-}
-
-export function PaintPanel() {
- const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
- const activePaintTarget = useEditor((state) => state.activePaintTarget)
- const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
- const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
-
- const customMaterial =
- activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
- ? activePaintMaterial.material
- : null
-
- if (!customMaterial) return null
-
- const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
-
- const updateCustomMaterial = (
- updates: Partial,
- nextTransparent = currentProps.transparent,
- ) => {
- setActivePaintMaterial({
- material: {
- preset: 'custom',
- properties: {
- ...currentProps,
- ...updates,
- transparent: nextTransparent,
- },
- },
- sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
- })
- }
-
- return (
- setPaintPanelOpen(false)} title="Material" width={320}>
-
-
-
-
-
-
-
-
- {currentProps.roughness.toFixed(2)}
-
-
-
- updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })
- }
- step={0.01}
- type="range"
- value={currentProps.roughness}
- />
-
-
-
-
-
-
- {currentProps.metalness.toFixed(2)}
-
-
-
- updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })
- }
- step={0.01}
- type="range"
- value={currentProps.metalness}
- />
-
-
-
-
-
-
- {currentProps.opacity.toFixed(2)}
-
-
-
{
- const opacity = Number.parseFloat(e.target.value)
- updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
- }}
- step={0.01}
- type="range"
- value={currentProps.opacity}
- />
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx
index 7ff454746..d16a6913b 100644
--- a/packages/editor/src/components/ui/panels/panel-manager.tsx
+++ b/packages/editor/src/components/ui/panels/panel-manager.tsx
@@ -29,7 +29,6 @@ import useEditor from '../../../store/use-editor'
import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display'
-import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel'
@@ -174,9 +173,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
const selectedZoneId = useViewer((s) => s.selection.zoneId)
const setSelection = useViewer((s) => s.setSelection)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
- const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
- const mode = useEditor((s) => s.mode)
- const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
// Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => {
@@ -208,15 +204,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
return
}
- if (
- isPaintPanelOpen &&
- mode === 'material-paint' &&
- activePaintMaterial?.material?.properties &&
- !activePaintMaterial.materialPreset
- ) {
- return
- }
-
if (selectedZoneId && selectedIds.length === 0) {
return (
s.shading)
+ const textures = useViewer((s) => s.textures)
const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
const ActiveIcon = active.icon
return (
@@ -121,6 +130,23 @@ function RenderModeMenu() {
)
})}
+
+ {TEXTURE_OPTIONS.map((option) => {
+ const OptionIcon = option.icon
+ return (
+ useViewer.getState().setTextures(option.id)}
+ >
+
+
+ {option.name}
+ {option.detail}
+
+ {textures === option.id ? : null}
+
+ )
+ })}
)
diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts
index fd4acf3ac..89b82da63 100644
--- a/packages/editor/src/hooks/use-auto-save.ts
+++ b/packages/editor/src/hooks/use-auto-save.ts
@@ -61,6 +61,12 @@ export function useAutoSave({
useEffect(() => {
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
let lastNodeCount = Object.keys(useScene.getState().nodes).length
+ // Collections + scene materials are document-level state that persists with
+ // the graph but lives outside `nodes`. Track them by reference (zustand
+ // hands out a new object on every mutation) so a material edit or a
+ // collection change still triggers a save.
+ let lastCollectionsRef = useScene.getState().collections
+ let lastMaterialsRef = useScene.getState().materials
async function executeSave() {
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
@@ -69,8 +75,8 @@ export function useAutoSave({
return
}
- const { nodes, rootNodeIds } = useScene.getState()
- const sceneGraph = { nodes, rootNodeIds } as SceneGraph
+ const { nodes, rootNodeIds, collections, materials } = useScene.getState()
+ const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
// Guard: refuse to autosave if the scene went from populated to nearly empty.
// This catches accidental full deletions before they're persisted.
@@ -118,19 +124,29 @@ export function useAutoSave({
const unsubscribe = useScene.subscribe((state) => {
if (isLoadingSceneRef.current) {
lastNodesSnapshot = JSON.stringify(state.nodes)
+ lastCollectionsRef = state.collections
+ lastMaterialsRef = state.materials
return
}
if (isVersionPreviewModeRef.current) {
setSaveStatus('paused')
lastNodesSnapshot = JSON.stringify(state.nodes)
+ lastCollectionsRef = state.collections
+ lastMaterialsRef = state.materials
return
}
const currentNodesSnapshot = JSON.stringify(state.nodes)
- if (currentNodesSnapshot === lastNodesSnapshot) return
+ const changed =
+ currentNodesSnapshot !== lastNodesSnapshot ||
+ state.collections !== lastCollectionsRef ||
+ state.materials !== lastMaterialsRef
+ if (!changed) return
lastNodesSnapshot = currentNodesSnapshot
+ lastCollectionsRef = state.collections
+ lastMaterialsRef = state.materials
hasDirtyChangesRef.current = true
onDirtyRef.current?.()
setSaveStatus('pending')
@@ -156,8 +172,8 @@ export function useAutoSave({
function flushOnExit() {
if (!hasDirtyChangesRef.current) return
hasDirtyChangesRef.current = false
- const { nodes, rootNodeIds } = useScene.getState()
- const sceneGraph = { nodes, rootNodeIds } as SceneGraph
+ const { nodes, rootNodeIds, collections, materials } = useScene.getState()
+ const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
if (onSaveRef.current) {
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
} else {
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index 120915ed5..1ef26d771 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -250,7 +250,6 @@ export {
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
- buildWallSurfaceMaterialPatch,
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts
index fbd592718..08313bbae 100644
--- a/packages/editor/src/lib/material-paint.ts
+++ b/packages/editor/src/lib/material-paint.ts
@@ -13,7 +13,6 @@ import {
getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial,
getEffectiveStairSurfaceMaterial,
- getEffectiveWallSurfaceMaterial,
getLibraryMaterialIdFromRef,
type MaterialSchema,
type MaterialTarget,
@@ -26,28 +25,29 @@ import {
type SlabNode,
type StairNode,
type StairSurfaceMaterialRole,
- type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
-export type PaintableMaterialTarget = Extract<
- MaterialTarget,
- | 'wall'
- | 'roof'
- | 'stair'
- | 'fence'
- | 'column'
- | 'slab'
- | 'ceiling'
- | 'shelf'
- | 'chimney'
- | 'dormer'
- | 'box-vent'
- | 'ridge-vent'
- | 'turbine-vent'
- | 'cupola'
- | 'eyebrow-vent'
->
+export type PaintableMaterialTarget =
+ | Extract<
+ MaterialTarget,
+ | 'wall'
+ | 'roof'
+ | 'stair'
+ | 'fence'
+ | 'column'
+ | 'slab'
+ | 'ceiling'
+ | 'shelf'
+ | 'chimney'
+ | 'dormer'
+ | 'box-vent'
+ | 'ridge-vent'
+ | 'turbine-vent'
+ | 'cupola'
+ | 'eyebrow-vent'
+ >
+ | 'item'
export type SingleSurfaceMaterialRole = 'surface'
@@ -76,32 +76,6 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
}
-export function buildWallSurfaceMaterialPatch(
- node: WallNode,
- targetSide: WallSurfaceSide,
- material: MaterialSchema | undefined,
- materialPreset: string | undefined,
-): Partial {
- const nextSurfaceMaterial = { material, materialPreset }
- const nextInterior =
- targetSide === 'interior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'interior')
- const nextExterior =
- targetSide === 'exterior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'exterior')
-
- return {
- interiorMaterial: nextInterior.material,
- interiorMaterialPreset: nextInterior.materialPreset,
- exteriorMaterial: nextExterior.material,
- exteriorMaterialPreset: nextExterior.materialPreset,
- material: undefined,
- materialPreset: undefined,
- }
-}
-
export function buildRoofSurfaceMaterialPatch(
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
@@ -179,6 +153,7 @@ export function buildResetSurfaceMaterialUpdates(
if (
key === 'material' ||
key === 'materialPreset' ||
+ key === 'slots' ||
key.endsWith('Material') ||
key.endsWith('MaterialPreset')
) {
@@ -276,6 +251,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
| ChimneyMaterialRole
| DormerSurfaceMaterialRole
| SingleSurfaceMaterialRole
+ | string
} | null
}): ActivePaintMaterial | null {
const { nodes, selectedId, selectedMaterialTarget } = params
@@ -378,8 +354,6 @@ export function resolveActivePaintMaterialFromSelection(params: {
if (
(selectedNode.type === 'fence' ||
selectedNode.type === 'column' ||
- selectedNode.type === 'slab' ||
- selectedNode.type === 'ceiling' ||
selectedNode.type === 'shelf') &&
selectedMaterialTarget.role === 'surface'
) {
@@ -444,6 +418,10 @@ export function resolvePaintTargetFromSelection(params: {
return 'shelf'
}
+ if (selectedNode.type === 'item') {
+ return 'item'
+ }
+
if (selectedNode.type === 'chimney') {
return 'chimney'
}
diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts
index 0f8f3552c..02f020c62 100644
--- a/packages/editor/src/lib/scene.ts
+++ b/packages/editor/src/lib/scene.ts
@@ -11,6 +11,10 @@ import useEditor, {
export type SceneGraph = {
nodes: Record
rootNodeIds: string[]
+ // Document-level scene state that travels with the graph. Optional so older
+ // payloads (and callers that only build nodes) stay valid.
+ collections?: Record
+ materials?: Record
}
type PersistedSelectionPath = {
@@ -374,8 +378,11 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
if (hasUsableSceneGraph(sceneGraph)) {
- const { nodes, rootNodeIds } = sceneGraph
- useScene.getState().setScene(nodes as any, rootNodeIds as any)
+ const { nodes, rootNodeIds, collections, materials } = sceneGraph
+ useScene.getState().setScene(nodes as any, rootNodeIds as any, {
+ collections: collections as any,
+ materials: materials as any,
+ })
} else {
useScene.getState().clearScene()
}
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index 19543f62f..64c28b319 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -177,6 +177,7 @@ export type MaterialTargetRole =
| ChimneyMaterialRole
| DormerSurfaceMaterialRole
| SingleSurfaceMaterialRole
+ | string
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
@@ -325,8 +326,6 @@ type EditorState = {
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
- isPaintPanelOpen: boolean
- setPaintPanelOpen: (open: boolean) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
guideUi: Record
@@ -891,8 +890,6 @@ const useEditor = create()(
set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
),
- isPaintPanelOpen: false,
- setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
guideUi: {},
diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts
index e716dc6a2..8183f36a6 100644
--- a/packages/nodes/src/ceiling/definition.ts
+++ b/packages/nodes/src/ceiling/definition.ts
@@ -10,8 +10,10 @@ import {
ceilingMoveVertexAffordance,
} from './floorplan-affordances'
import { ceilingFloorplanMoveTarget } from './floorplan-move'
+import { ceilingPaint } from './paint'
import { ceilingParametrics } from './parametrics'
import { CeilingNode } from './schema'
+import { ceilingSlots } from './slots'
const HEIGHT_HANDLE_OFFSET = 0.22
const MIN_CEILING_HEIGHT = 0.5
@@ -102,6 +104,10 @@ export const ceilingDefinition: NodeDefinition = {
},
duplicable: true,
deletable: true,
+ // Unified slot model: one paintable underside surface with a declared
+ // default, painted through the registry `capabilities.paint` dispatch.
+ slots: () => ceilingSlots(),
+ paint: ceilingPaint,
},
relations: {
diff --git a/packages/nodes/src/ceiling/materials.ts b/packages/nodes/src/ceiling/materials.ts
new file mode 100644
index 000000000..a6e4e590e
--- /dev/null
+++ b/packages/nodes/src/ceiling/materials.ts
@@ -0,0 +1,80 @@
+import {
+ getMaterialPresetByRef,
+ parseMaterialRef,
+ resolveMaterial,
+ type SceneMaterial,
+ type SceneMaterialId,
+} from '@pascal-app/core'
+import { float, mix, positionWorld, smoothstep } from 'three/tsl'
+import { BackSide, FrontSide, MeshBasicNodeMaterial } from 'three/webgpu'
+
+/**
+ * Ceiling material builders, shared by the renderer (mesh appearance) and the
+ * paint capability (hover preview). A ceiling is a flat tinted surface: the
+ * underside (`bottom`, seen from inside the room, `BackSide`) is opaque, while
+ * the `top` carries a transparent TSL grid overlay used while placing /
+ * selecting ceiling-hosted items. Both derive from a single colour, so slot
+ * painting resolves a colour and rebuilds these — it never applies a PBR map.
+ */
+
+const gridScale = 5
+const gridX = positionWorld.x.mul(gridScale).fract()
+const gridY = positionWorld.z.mul(gridScale).fract()
+const lineWidth = 0.05
+const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
+const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
+const gridPattern = lineX.max(lineY)
+const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
+
+export type CeilingMaterials = {
+ topMaterial: MeshBasicNodeMaterial
+ bottomMaterial: MeshBasicNodeMaterial
+}
+
+function createCeilingMaterials(color = '#999999'): CeilingMaterials {
+ const topMaterial = new MeshBasicNodeMaterial({
+ color,
+ transparent: true,
+ depthWrite: false,
+ side: FrontSide,
+ })
+ topMaterial.opacityNode = gridOpacity
+
+ const bottomMaterial = new MeshBasicNodeMaterial({
+ color,
+ transparent: true,
+ side: BackSide,
+ })
+
+ return { topMaterial, bottomMaterial }
+}
+
+const ceilingMaterialCache = new Map()
+
+export function getCeilingMaterials(color = '#999999'): CeilingMaterials {
+ const cached = ceilingMaterialCache.get(color)
+ if (cached) return cached
+ const materials = createCeilingMaterials(color)
+ ceilingMaterialCache.set(color, materials)
+ return materials
+}
+
+/**
+ * Resolve a slot `MaterialRef` to a flat colour for the ceiling surface.
+ * `library:` refs use the catalog preset's base colour; `scene:` refs use the
+ * stored material's colour. Returns null for a dangling / unparseable ref so
+ * the caller falls back to its default.
+ */
+export function ceilingColorFromRef(
+ ref: string | undefined,
+ sceneMaterials: Record | undefined,
+): string | null {
+ const parsed = parseMaterialRef(ref)
+ if (!parsed) return null
+ if (parsed.kind === 'library') {
+ return getMaterialPresetByRef(ref)?.mapProperties.color ?? null
+ }
+ const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId]
+ if (!sceneMaterial) return null
+ return resolveMaterial(sceneMaterial.material).color ?? null
+}
diff --git a/packages/nodes/src/ceiling/paint.ts b/packages/nodes/src/ceiling/paint.ts
new file mode 100644
index 000000000..3efce6994
--- /dev/null
+++ b/packages/nodes/src/ceiling/paint.ts
@@ -0,0 +1,42 @@
+import {
+ type AnyNode,
+ type CeilingNode,
+ getMaterialPresetByRef,
+ resolveMaterial,
+} from '@pascal-app/core'
+import type { Mesh } from 'three'
+import { createSlotPaintCapability } from '../shared/slot-paint'
+import { getCeilingMaterials } from './materials'
+
+/**
+ * Ceiling paint on the unified slot model. A ceiling has one paintable surface,
+ * so every hit resolves to `surface`; commit writes `node.slots.surface`. The
+ * preview swaps the registered underside mesh to the ceiling's own flat-tinted
+ * material (built `BackSide`, the way it renders), so the hover preview matches
+ * the committed result — a generic PBR preview would be invisible from below.
+ */
+export const ceilingPaint = createSlotPaintCapability({
+ resolveRole: () => 'surface',
+ applyPreview: ({ material, materialPreset, root }) => {
+ const color = materialPreset
+ ? (getMaterialPresetByRef(materialPreset)?.mapProperties.color ?? null)
+ : material
+ ? (resolveMaterial(material).color ?? null)
+ : null
+ if (!color) return () => {}
+ const mesh = root as Mesh
+ if (!mesh.isMesh) return null
+ const previous = mesh.material
+ mesh.material = getCeilingMaterials(color).bottomMaterial
+ return () => {
+ mesh.material = previous
+ }
+ },
+ legacyEffective: (node: AnyNode) => {
+ const ceiling = node as CeilingNode
+ if (ceiling.materialPreset || ceiling.material) {
+ return { material: ceiling.material, materialPreset: ceiling.materialPreset }
+ }
+ return null
+ },
+})
diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx
index 263c4fd81..9f49086c8 100644
--- a/packages/nodes/src/ceiling/renderer.tsx
+++ b/packages/nodes/src/ceiling/renderer.tsx
@@ -15,53 +15,15 @@ import {
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
-import { float, mix, positionWorld, smoothstep } from 'three/tsl'
-import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
+import { BackSide, type Mesh } from 'three/webgpu'
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
+import { ceilingColorFromRef, getCeilingMaterials } from './materials'
+import { CEILING_SLOT_DEFAULT_COLOR } from './slots'
function createEmptyGeometry() {
return createPlaceholderGeometry()
}
-const gridScale = 5
-const gridX = positionWorld.x.mul(gridScale).fract()
-const gridY = positionWorld.z.mul(gridScale).fract()
-const lineWidth = 0.05
-const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
-const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
-const gridPattern = lineX.max(lineY)
-const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
-
-function createCeilingMaterials(color = '#999999') {
- const topMaterial = new MeshBasicNodeMaterial({
- color,
- transparent: true,
- depthWrite: false,
- side: FrontSide,
- })
- topMaterial.opacityNode = gridOpacity
-
- const bottomMaterial = new MeshBasicNodeMaterial({
- color,
- transparent: true,
- side: BackSide,
- })
-
- return { topMaterial, bottomMaterial }
-}
-
-const ceilingMaterialCache = new Map>()
-
-function getCeilingMaterials(color = '#999999') {
- const cacheKey = color
- const cached = ceilingMaterialCache.get(cacheKey)
- if (cached) return cached
-
- const materials = createCeilingMaterials(color)
- ceilingMaterialCache.set(cacheKey, materials)
- return materials
-}
-
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
@@ -80,6 +42,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
+ // Subscribe to the scene-material library so editing a `scene:` material the
+ // ceiling slot references re-tints it live.
+ const sceneMaterials = useScene((s) => s.materials)
const liveTransform = useLiveTransforms((s) => s.get(node.id))
const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0)
const position: [number, number, number] = [
@@ -97,18 +62,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
)
const materials = useMemo(() => {
- // Untextured ceilings (and everything in textures-off mode) take the themed
- // 'ceiling' role colour; only an explicit preset/material keeps a texture.
- const hasExplicit = Boolean(node.materialPreset || node.material)
- if (!textures || !hasExplicit) {
- // Bottom (seen from inside the room, looking up) stays opaque so the
- // ceiling reads as a solid surface. Top uses the transparent
- // grid-pattern material so the ceiling stays see-through whenever
- // the editor reveals the `ceiling-grid` overlay (placing a
- // ceiling-hosted item, or selecting one of its children — e.g.
- // after committing a placement). Without this the top mesh shipped
- // an opaque surface-role material, so a top-down camera lost view
- // of everything under the ceiling once the overlay turned on.
+ // Textures-off mode takes the themed 'ceiling' role colour — the guaranteed
+ // escape hatch, independent of any slot override. The bottom (seen from
+ // inside the room, looking up) stays opaque so the ceiling reads as a solid
+ // surface; the top keeps the transparent grid material so a top-down camera
+ // can see through the ceiling whenever the `ceiling-grid` overlay is
+ // revealed (placing a ceiling-hosted item, or selecting one of its
+ // children). Without that the top mesh would ship an opaque surface-role
+ // material and a top-down camera would lose everything under the ceiling.
+ if (!textures) {
const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme)
return {
topMaterial: getCeilingMaterials(ceilingColor).topMaterial,
@@ -116,14 +78,26 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
}
}
- const preset = getMaterialPresetByRef(node.materialPreset)
- const props = preset?.mapProperties ?? resolveMaterial(node.material)
- const color = props.color || '#999999'
- return getCeilingMaterials(color)
+ // Unified slot override — shared scene material or catalog `library:` finish
+ // (resolved to its base colour; a ceiling renders flat-tinted, not mapped).
+ const slotColor = ceilingColorFromRef(node.slots?.surface, sceneMaterials)
+ if (slotColor) return getCeilingMaterials(slotColor)
+
+ // Legacy inline material / preset (scenes painted before the slot model).
+ if (node.materialPreset || node.material) {
+ const preset = getMaterialPresetByRef(node.materialPreset)
+ const props = preset?.mapProperties ?? resolveMaterial(node.material)
+ return getCeilingMaterials(props.color || '#999999')
+ }
+
+ // Declared slot default.
+ return getCeilingMaterials(CEILING_SLOT_DEFAULT_COLOR)
}, [
textures,
colorPreset,
sceneTheme,
+ sceneMaterials,
+ node.slots,
node.materialPreset,
node.material,
node.material?.preset,
diff --git a/packages/nodes/src/ceiling/slots.ts b/packages/nodes/src/ceiling/slots.ts
new file mode 100644
index 000000000..7b7c48a99
--- /dev/null
+++ b/packages/nodes/src/ceiling/slots.ts
@@ -0,0 +1,12 @@
+import type { SlotDeclaration } from '@pascal-app/core'
+
+export type CeilingSlotId = 'surface'
+
+// Soft white — the default underside colour for an unpainted ceiling. (A
+// ceiling renders flat-tinted, so this is a colour, not a `library:` finish.)
+export const CEILING_SLOT_DEFAULT_COLOR = '#f2eee6'
+
+/** A ceiling exposes a single paintable underside surface. */
+export function ceilingSlots(): SlotDeclaration[] {
+ return [{ slotId: 'surface', label: 'Surface', default: CEILING_SLOT_DEFAULT_COLOR }]
+}
diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts
index aa90fbb28..f4f901c9c 100644
--- a/packages/nodes/src/column/definition.ts
+++ b/packages/nodes/src/column/definition.ts
@@ -7,8 +7,10 @@ import {
import { buildColumnFloorplan } from './floorplan'
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
import { columnFloorplanMoveTarget } from './floorplan-move'
+import { columnPaint } from './paint'
import { columnParametrics } from './parametrics'
import { ColumnNode } from './schema'
+import { columnSlots } from './slots'
// Limits + offsets shared with the in-world arrows. Mirrors the floors
// the renderer clamps to (`Math.max(0.2, node.height)` etc.) so a drag
@@ -325,6 +327,8 @@ export const columnDefinition: NodeDefinition = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
+ slots: (node) => columnSlots(node as ColumnNodeType),
+ paint: columnPaint,
// Slab elevation lift via the generic ``.
floorPlaced: {
footprint: (node) => {
diff --git a/packages/nodes/src/column/paint.ts b/packages/nodes/src/column/paint.ts
new file mode 100644
index 000000000..212d9f9fe
--- /dev/null
+++ b/packages/nodes/src/column/paint.ts
@@ -0,0 +1,18 @@
+import type { AnyNode } from '@pascal-app/core'
+import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint'
+import type { ColumnNode } from './schema'
+
+export const columnPaint = createSlotPaintCapability({
+ resolveRole: (args) => {
+ const slotId = args.hitObject?.userData?.slotId
+ return typeof slotId === 'string' ? slotId : null
+ },
+ applyPreview: previewSlotByUserData,
+ legacyEffective: (node: AnyNode) => {
+ const column = node as ColumnNode
+ if (column.materialPreset || column.material) {
+ return { material: column.material, materialPreset: column.materialPreset }
+ }
+ return null
+ },
+})
diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx
index 05c9e5ab4..fb070206a 100644
--- a/packages/nodes/src/column/renderer.tsx
+++ b/packages/nodes/src/column/renderer.tsx
@@ -5,6 +5,7 @@ import {
useLiveNodeOverrides,
useLiveTransforms,
useRegistry,
+ useScene,
} from '@pascal-app/core'
import {
baseMaterial,
@@ -17,21 +18,52 @@ import {
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
type RenderShading,
+ resolveMaterialRef,
+ resolveSlotDefaultMaterial,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
-import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
+import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
+import {
+ COLUMN_BASE_DEFAULT,
+ COLUMN_CAPITAL_DEFAULT,
+ COLUMN_FRAME_DEFAULT,
+ COLUMN_SHAFT_DEFAULT,
+ type ColumnSlotId,
+} from './slots'
+
+type ColumnSlotMaterials = Record
+type SceneMaterials = ReturnType['materials']
+
+const DEFAULT_COLUMN_MATERIAL = baseMaterial()
+const DEFAULT_COLUMN_SLOT_MATERIALS = createSingleColumnMaterialMap(DEFAULT_COLUMN_MATERIAL)
-const ColumnMaterialContext = createContext(baseMaterial())
+const ColumnMaterialContext = createContext(DEFAULT_COLUMN_SLOT_MATERIALS)
+const ColumnSlotContext = createContext('shaft')
const ColumnEdgeSoftnessContext = createContext(0.025)
function ColumnMaterial() {
- const material = useContext(ColumnMaterialContext)
+ const slotId = useContext(ColumnSlotContext)
+ const materials = useContext(ColumnMaterialContext)
+ const material = materials[slotId] ?? materials.shaft
return
}
-function createColumnMaterial({
+function ColumnSlot({ children, slotId }: { children: ReactNode; slotId: ColumnSlotId }) {
+ return {children}
+}
+
+function createSingleColumnMaterialMap(material: Material): ColumnSlotMaterials {
+ return {
+ shaft: material,
+ base: material,
+ capital: material,
+ frame: material,
+ }
+}
+
+function createLegacyColumnMaterial({
material,
materialPreset,
shading,
@@ -50,6 +82,99 @@ function createColumnMaterial({
return baseMaterial(shading)
}
+function resolveColumnSlotMaterial({
+ colorPreset,
+ legacyMaterial,
+ node,
+ sceneMaterials,
+ shading,
+ slotId,
+ textures,
+}: {
+ colorPreset: ColorPreset
+ legacyMaterial: Material | null
+ node: ColumnNode
+ sceneMaterials: SceneMaterials
+ shading: RenderShading
+ slotId: ColumnSlotId
+ textures: boolean
+}): Material {
+ if (!textures) return createSurfaceRoleMaterial('wall', colorPreset)
+
+ const slotRef = node.slots?.[slotId]
+ if (slotRef) {
+ const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
+ if (resolved) return resolved
+ }
+
+ if (legacyMaterial) return legacyMaterial
+
+ if (slotId === 'frame') return resolveSlotDefaultMaterial(COLUMN_FRAME_DEFAULT, shading)
+ if (slotId === 'base') return resolveSlotDefaultMaterial(COLUMN_BASE_DEFAULT, shading)
+ if (slotId === 'capital') return resolveSlotDefaultMaterial(COLUMN_CAPITAL_DEFAULT, shading)
+ return resolveSlotDefaultMaterial(COLUMN_SHAFT_DEFAULT, shading)
+}
+
+function createColumnSlotMaterials({
+ colorPreset,
+ material,
+ materialPreset,
+ node,
+ sceneMaterials,
+ shading,
+ textures,
+}: Pick & {
+ colorPreset: ColorPreset
+ node: ColumnNode
+ sceneMaterials: SceneMaterials
+ shading: RenderShading
+ textures: boolean
+}): ColumnSlotMaterials {
+ const legacyMaterial =
+ materialPreset || material
+ ? createLegacyColumnMaterial({ colorPreset, material, materialPreset, shading, textures })
+ : null
+
+ return {
+ shaft: resolveColumnSlotMaterial({
+ colorPreset,
+ legacyMaterial,
+ node,
+ sceneMaterials,
+ shading,
+ slotId: 'shaft',
+ textures,
+ }),
+ base: resolveColumnSlotMaterial({
+ colorPreset,
+ legacyMaterial,
+ node,
+ sceneMaterials,
+ shading,
+ slotId: 'base',
+ textures,
+ }),
+ capital: resolveColumnSlotMaterial({
+ colorPreset,
+ legacyMaterial,
+ node,
+ sceneMaterials,
+ shading,
+ slotId: 'capital',
+ textures,
+ }),
+ frame: resolveColumnSlotMaterial({
+ colorPreset,
+ legacyMaterial,
+ node,
+ sceneMaterials,
+ shading,
+ slotId: 'frame',
+ textures,
+ }),
+ }
+}
+
function getSegments(node: ColumnNode) {
if (node.crossSection === 'octagonal') return 8
if (node.crossSection === 'sixteen-sided') return 16
@@ -126,6 +251,7 @@ function MappedBox({
width: number
}) {
const edgeSoftness = useContext(ColumnEdgeSoftnessContext)
+ const slotId = useContext(ColumnSlotContext)
const minDimension = Math.max(0, Math.min(width, height, depth))
const bevelRadius = softenEdges ? Math.min(Math.max(0, edgeSoftness), minDimension * 0.35) : 0
const geometry = useMemo(() => {
@@ -136,7 +262,14 @@ function MappedBox({
if (!geometry) return null
return (
-
+
@@ -154,6 +287,7 @@ function FlatEndedBeam({
start: VectorTuple
width: number
}) {
+ const slotId = useContext(ColumnSlotContext)
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const dz = end[2] - start[2]
@@ -244,7 +378,7 @@ function FlatEndedBeam({
if (!geometry) return null
return (
-
+
@@ -763,6 +897,7 @@ function MappedCylinder({
rotation?: VectorTuple
segments?: number
}) {
+ const slotId = useContext(ColumnSlotContext)
const geometry = useMemo(() => {
if (height <= 0 || radius <= 0 || radiusBottom < 0 || radiusTop < 0) return null
return createColumnCylinderGeometry({
@@ -778,7 +913,14 @@ function MappedCylinder({
if (!geometry) return null
return (
-
+
@@ -800,6 +942,7 @@ function MappedCone({
rotation?: VectorTuple
segments?: number
}) {
+ const slotId = useContext(ColumnSlotContext)
const geometry = useMemo(() => {
if (height <= 0 || radiusX <= 0 || radiusZ <= 0) return null
return createColumnCylinderGeometry({
@@ -815,7 +958,14 @@ function MappedCone({
if (!geometry) return null
return (
-
+
@@ -833,6 +983,7 @@ function MappedSphere({
segments?: number
verticalSegments?: number
}) {
+ const slotId = useContext(ColumnSlotContext)
const geometry = useMemo(() => {
if (radius <= 0) return null
return createColumnSphereGeometry(radius, segments, verticalSegments)
@@ -841,7 +992,7 @@ function MappedSphere({
if (!geometry) return null
return (
-
+
@@ -867,6 +1018,7 @@ function MappedTorus({
scaleZ?: number
tubeRadius: number
}) {
+ const slotId = useContext(ColumnSlotContext)
const geometry = useMemo(() => {
if (ringRadius <= 0 || tubeRadius <= 0) return null
return createColumnTorusGeometry({
@@ -882,7 +1034,14 @@ function MappedTorus({
if (!geometry) return null
return (
-
+
@@ -2094,55 +2253,67 @@ function ColumnBody({ node }: { node: ColumnNode }) {
return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight }
}, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height])
- return node.supportStyle === 'a-frame' ? (
-
- ) : node.supportStyle === 'y-frame' ? (
-
- ) : node.supportStyle === 'v-frame' ? (
-
- ) : node.supportStyle === 'x-brace' ? (
-
- ) : node.supportStyle === 'k-brace' ? (
-
- ) : node.supportStyle === 'single-strut' ? (
-
- ) : node.supportStyle === 'tripod' ? (
-
- ) : node.supportStyle === 'trestle' ? (
-
- ) : node.supportStyle === 'portal-frame' ? (
-
- ) : node.supportStyle === 'box-frame' ? (
-
- ) : (
+ if (node.supportStyle !== 'vertical') {
+ const support =
+ node.supportStyle === 'a-frame' ? (
+
+ ) : node.supportStyle === 'y-frame' ? (
+
+ ) : node.supportStyle === 'v-frame' ? (
+
+ ) : node.supportStyle === 'x-brace' ? (
+
+ ) : node.supportStyle === 'k-brace' ? (
+
+ ) : node.supportStyle === 'single-strut' ? (
+
+ ) : node.supportStyle === 'tripod' ? (
+
+ ) : node.supportStyle === 'trestle' ? (
+
+ ) : node.supportStyle === 'portal-frame' ? (
+
+ ) : (
+
+ )
+ return {support}
+ }
+
+ return (
<>
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
)
}
@@ -2152,7 +2323,7 @@ function ColumnBody({ node }: { node: ColumnNode }) {
* cursor preview, mirroring `ShelfPreview`. Builds the same geometry tree
* as the real renderer via `` but:
* - clones the material and makes it transparent (cloning is required:
- * `createColumnMaterial` can hand back a shared/cached instance, and
+ * `createLegacyColumnMaterial` can hand back a shared/cached instance, and
* mutating it would turn every committed column see-through);
* - disables raycast on every mesh so the ghost doesn't intercept the
* placement cursor ray (which would stall `grid:move`);
@@ -2164,8 +2335,8 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => {
const colorPreset = useViewer((state) => state.colorPreset)
const groupRef = useRef(null)
- const material = useMemo(() => {
- const ghost = createColumnMaterial({
+ const materials = useMemo(() => {
+ const ghost = createLegacyColumnMaterial({
material: node.material,
materialPreset: node.materialPreset,
shading,
@@ -2175,10 +2346,15 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => {
ghost.transparent = true
ghost.opacity = 0.5
ghost.depthWrite = false
- return ghost
+ return createSingleColumnMaterialMap(ghost)
}, [shading, textures, colorPreset, node.material, node.materialPreset])
- useEffect(() => () => material.dispose(), [material])
+ useEffect(
+ () => () => {
+ for (const material of new Set(Object.values(materials))) material.dispose()
+ },
+ [materials],
+ )
// Strip pointer events off the freshly-built meshes every render — the
// geometry tree rebuilds when the ghost's dimensions change, so a one-shot
@@ -2190,7 +2366,7 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => {
})
return (
-
+
@@ -2216,11 +2392,14 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
- const material = useMemo(
+ const sceneMaterials = useScene((state) => state.materials)
+ const materials = useMemo(
() =>
- createColumnMaterial({
+ createColumnSlotMaterials({
material: node.material,
materialPreset: node.materialPreset,
+ node,
+ sceneMaterials,
shading,
textures,
colorPreset,
@@ -2234,13 +2413,15 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
node.material?.properties,
node.material?.texture,
node.materialPreset,
+ node.slots,
+ sceneMaterials,
],
)
useRegistry(node.id, node.type, ref)
return (
-
+
= {
// placed. Host apps strip these at preset-save time via
// `getHostRefFields(def)`.
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
+ // Panel / glass slots painted through the registry. The door system tags
+ // each mesh with its `userData.slotId`; paint writes `node.slots`.
+ slots: () => doorSlots(),
+ paint: doorPaint,
},
parametrics: doorParametrics,
diff --git a/packages/nodes/src/door/paint.ts b/packages/nodes/src/door/paint.ts
new file mode 100644
index 000000000..e236a5e38
--- /dev/null
+++ b/packages/nodes/src/door/paint.ts
@@ -0,0 +1,16 @@
+import {
+ createSlotPaintCapability,
+ previewSlotByUserData,
+ resolveSlotByReRaycast,
+} from '../shared/slot-paint'
+
+/**
+ * Door paint on the unified slot model. The door's opening proxy (a proud,
+ * invisible cutout) wins the shared scene raycast over the wall in front of the
+ * recessed door body, so `resolveSlotByReRaycast` re-raycasts the door's own
+ * subtree to find the part (panel / frame / glass / hardware) under the cursor.
+ */
+export const doorPaint = createSlotPaintCapability({
+ resolveRole: resolveSlotByReRaycast,
+ applyPreview: previewSlotByUserData,
+})
diff --git a/packages/nodes/src/door/slots.ts b/packages/nodes/src/door/slots.ts
new file mode 100644
index 000000000..2e2218793
--- /dev/null
+++ b/packages/nodes/src/door/slots.ts
@@ -0,0 +1,25 @@
+import type { SlotDeclaration } from '@pascal-app/core'
+
+export type DoorSlotId = 'panel' | 'frame' | 'glass' | 'hardware'
+
+// Picker swatches. Rendering falls back to the live body/glass/hardware defaults
+// (which already track shading + theme), so these are just the indicator colours.
+const PANEL_DEFAULT = 'library:preset-softwhite'
+const FRAME_DEFAULT = 'library:preset-softwhite'
+const GLASS_DEFAULT = 'library:preset-glass'
+// Chrome — a flat (non-PBR) catalog metal finish.
+const HARDWARE_DEFAULT = 'library:metal-chrome'
+
+/**
+ * A door exposes four paintable slots: `panel` (leaf faces), `frame`, `glass`,
+ * and `hardware` (handle / hinges / closer / panic bar). The opening reveal
+ * keeps its own material.
+ */
+export function doorSlots(): SlotDeclaration[] {
+ return [
+ { slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT },
+ { slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT },
+ { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT },
+ { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT },
+ ]
+}
diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts
index c692cf395..7f505bd3b 100644
--- a/packages/nodes/src/elevator/definition.ts
+++ b/packages/nodes/src/elevator/definition.ts
@@ -12,8 +12,10 @@ import {
} from '@pascal-app/core'
import { buildElevatorFloorplan } from './floorplan'
import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances'
+import { elevatorPaint } from './paint'
import { elevatorParametrics } from './parametrics'
import { ElevatorNode } from './schema'
+import { elevatorSlots } from './slots'
const SIDE_HANDLE_OFFSET = 0.22
const HEIGHT_HANDLE_OFFSET = 0.3
@@ -223,6 +225,8 @@ export const elevatorDefinition: NodeDefinition = {
},
duplicable: true,
deletable: true,
+ slots: (node) => elevatorSlots(node as ElevatorNodeType),
+ paint: elevatorPaint,
},
parametrics: elevatorParametrics,
diff --git a/packages/nodes/src/elevator/paint.ts b/packages/nodes/src/elevator/paint.ts
new file mode 100644
index 000000000..ac38e484a
--- /dev/null
+++ b/packages/nodes/src/elevator/paint.ts
@@ -0,0 +1,7 @@
+import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint'
+
+export const elevatorPaint = createSlotPaintCapability({
+ resolveRole: (args) => (args.hitObject?.userData?.slotId as string) ?? null,
+ applyPreview: previewSlotByUserData,
+ legacyEffective: () => null,
+})
diff --git a/packages/nodes/src/elevator/renderer.tsx b/packages/nodes/src/elevator/renderer.tsx
index 31410b4ee..9d0fb3f68 100644
--- a/packages/nodes/src/elevator/renderer.tsx
+++ b/packages/nodes/src/elevator/renderer.tsx
@@ -28,11 +28,13 @@ import {
createDefaultMaterial,
createSurfaceRoleMaterial,
type RenderShading,
+ resolveMaterialRef,
+ resolveSlotDefaultMaterial,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
-import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
+import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
import {
BoxGeometry,
CylinderGeometry,
@@ -43,6 +45,13 @@ import {
TorusGeometry,
} from 'three'
import { useShallow } from 'zustand/react/shallow'
+import {
+ ELEVATOR_CAB_SLOT_DEFAULT,
+ ELEVATOR_DOORS_SLOT_DEFAULT,
+ ELEVATOR_GLASS_SLOT_DEFAULT,
+ ELEVATOR_SHAFT_SLOT_DEFAULT,
+ type ElevatorSlotId,
+} from './slots'
const DEFAULT_STRUCTURE_WHITE = '#f2f0ed'
const SHAFT_WALL_COLOR = DEFAULT_STRUCTURE_WHITE
@@ -360,48 +369,102 @@ function getElevatorMaterials(
return materials
}
-let {
- SHAFT_WALL_MATERIAL,
- SHAFT_SIDE_MATERIAL,
- SHAFT_TRIM_MATERIAL,
- CAB_MATERIAL,
- DOOR_MATERIAL,
- DOOR_GROOVE_MATERIAL,
- GLASS_MATERIAL,
- PANEL_MATERIAL,
- LANDING_PANEL_MATERIAL,
- INDICATOR_SCREEN_MATERIALS,
- INDICATOR_GLYPH_MATERIALS,
- BUTTON_FACE_MATERIALS,
- BUTTON_RING_MATERIALS,
- BUTTON_GLOW_MATERIALS,
- BUTTON_LABEL_MATERIALS,
- QUEUE_STRIP_MATERIALS,
-} = getElevatorMaterials('rendered')
-
-function setElevatorMaterials(
+type ElevatorSceneMaterials = ReturnType['materials']
+
+function resolveElevatorFinishMaterial(
+ node: ElevatorNode,
+ slotId: ElevatorSlotId,
+ slotDefault: string,
+ sceneMaterials: ElevatorSceneMaterials,
shading: RenderShading,
- textures = true,
- colorPreset: ColorPreset = 'clay',
-) {
- ;({
- SHAFT_WALL_MATERIAL,
- SHAFT_SIDE_MATERIAL,
- SHAFT_TRIM_MATERIAL,
- CAB_MATERIAL,
- DOOR_MATERIAL,
- DOOR_GROOVE_MATERIAL,
- GLASS_MATERIAL,
- PANEL_MATERIAL,
- LANDING_PANEL_MATERIAL,
- INDICATOR_SCREEN_MATERIALS,
- INDICATOR_GLYPH_MATERIALS,
- BUTTON_FACE_MATERIALS,
- BUTTON_RING_MATERIALS,
- BUTTON_GLOW_MATERIALS,
- BUTTON_LABEL_MATERIALS,
- QUEUE_STRIP_MATERIALS,
- } = getElevatorMaterials(shading, textures, colorPreset))
+ roughness: number,
+): Material {
+ const ref = node.slots?.[slotId]
+ if (ref) {
+ const resolved = resolveMaterialRef(ref, sceneMaterials, shading)
+ if (resolved) return resolved
+ }
+ return resolveSlotDefaultMaterial(slotDefault, shading, roughness)
+}
+
+function withElevatorGlassTransparency(material: Material): Material {
+ const glass = material.clone()
+ glass.depthWrite = false
+ glass.opacity = 0.2
+ glass.transparent = true
+ glass.needsUpdate = true
+ return glass
+}
+
+function getResolvedElevatorMaterials(
+ node: ElevatorNode,
+ shading: RenderShading,
+ textures: boolean,
+ colorPreset: ColorPreset,
+ sceneMaterials: ElevatorSceneMaterials,
+): ElevatorMaterialSet {
+ const materials = getElevatorMaterials(shading, textures, colorPreset)
+ if (!textures) return materials
+
+ const cab = resolveElevatorFinishMaterial(
+ node,
+ 'cab',
+ ELEVATOR_CAB_SLOT_DEFAULT,
+ sceneMaterials,
+ shading,
+ 0.48,
+ )
+ const doors = resolveElevatorFinishMaterial(
+ node,
+ 'doors',
+ ELEVATOR_DOORS_SLOT_DEFAULT,
+ sceneMaterials,
+ shading,
+ 0.34,
+ )
+ const shaft = resolveElevatorFinishMaterial(
+ node,
+ 'shaft',
+ ELEVATOR_SHAFT_SLOT_DEFAULT,
+ sceneMaterials,
+ shading,
+ 0.56,
+ )
+ const glass = withElevatorGlassTransparency(
+ resolveElevatorFinishMaterial(
+ node,
+ 'glass',
+ ELEVATOR_GLASS_SLOT_DEFAULT,
+ sceneMaterials,
+ shading,
+ 0.08,
+ ),
+ )
+
+ return {
+ ...materials,
+ SHAFT_WALL_MATERIAL: shaft,
+ SHAFT_SIDE_MATERIAL: shaft,
+ SHAFT_TRIM_MATERIAL: shaft,
+ CAB_MATERIAL: cab,
+ DOOR_MATERIAL: doors,
+ DOOR_GROOVE_MATERIAL: doors,
+ GLASS_MATERIAL: glass,
+ }
+}
+
+const DEFAULT_ELEVATOR_MATERIALS = getElevatorMaterials('rendered')
+const ElevatorMaterialsContext = createContext(DEFAULT_ELEVATOR_MATERIALS)
+
+function useElevatorMaterialSet(): ElevatorMaterialSet {
+ return useContext(ElevatorMaterialsContext)
+}
+
+const ELEVATOR_SLOT_USER_DATA: Record = {
+ cab: { slotId: 'cab' },
+ doors: { slotId: 'doors' },
+ shaft: { slotId: 'shaft' },
+ glass: { slotId: 'glass' },
}
type ElevatorButtonAction = 'open-door' | 'request-level'
@@ -449,6 +512,7 @@ function BoxPrimitive({
receiveShadow = false,
rotation,
scale,
+ slotId,
}: {
castShadow?: boolean
material: Material
@@ -456,6 +520,7 @@ function BoxPrimitive({
receiveShadow?: boolean
rotation?: Vector3Tuple
scale: Vector3Tuple
+ slotId?: ElevatorSlotId
}) {
return (
)
}
@@ -597,6 +663,8 @@ function ElevatorFloorIndicator({
scale?: number
showReadout?: boolean
}) {
+ const { INDICATOR_GLYPH_MATERIALS, INDICATOR_SCREEN_MATERIALS, PANEL_MATERIAL } =
+ useElevatorMaterialSet()
const glyphMaterial = active ? INDICATOR_GLYPH_MATERIALS.active : INDICATOR_GLYPH_MATERIALS.idle
const screenMaterial = active
? INDICATOR_SCREEN_MATERIALS.active
@@ -721,6 +789,12 @@ function ElevatorMeshButton({
queued: boolean
radius?: number
}) {
+ const {
+ BUTTON_FACE_MATERIALS,
+ BUTTON_GLOW_MATERIALS,
+ BUTTON_LABEL_MATERIALS,
+ BUTTON_RING_MATERIALS,
+ } = useElevatorMaterialSet()
const state = disabled ? 'disabled' : active ? 'active' : queued ? 'queued' : 'idle'
const depth = active ? 0.028 : 0.04
const faceZ = faceSign * (depth / 2 + 0.004)
@@ -845,6 +919,7 @@ function DoorLeaf({
y: number
z: number
}) {
+ const { DOOR_GROOVE_MATERIAL, DOOR_MATERIAL, GLASS_MATERIAL } = useElevatorMaterialSet()
const ref = useRef(null)
const getLeafX = (openAmount: number) => getElevatorDoorLeafX(side, width, openAmount, doorStyle)
const leafWidth = getElevatorDoorLeafWidth(width, doorStyle)
@@ -880,6 +955,7 @@ function DoorLeaf({
position={[0, height / 2 - railHeight / 2, 0]}
receiveShadow
scale={[leafWidth, railHeight, 0.05]}
+ slotId="doors"
/>
>
) : (
@@ -916,11 +996,13 @@ function DoorLeaf({
position={[0, 0, 0]}
receiveShadow
scale={[leafWidth, height, 0.05]}
+ slotId="doors"
/>
{resolvedPanelStyle === 'segmented-panel'
? Array.from({ length: segmentCount - 1 }).map((_, index) => (
@@ -929,6 +1011,7 @@ function DoorLeaf({
material={DOOR_GROOVE_MATERIAL}
position={[0, -panelInsetHeight / 2 + segmentSpacing * (index + 1), -0.03]}
scale={[panelInsetWidth, 0.018, 0.012]}
+ slotId="doors"
/>
))
: null}
@@ -936,11 +1019,13 @@ function DoorLeaf({
material={DOOR_GROOVE_MATERIAL}
position={[0, panelInsetHeight / 2, -0.029]}
scale={[panelInsetWidth, 0.012, 0.01]}
+ slotId="doors"
/>
>
)}
@@ -1011,6 +1096,7 @@ function LandingDoorFrame({
shaftWidth: number
z: number
}) {
+ const { SHAFT_TRIM_MATERIAL, SHAFT_WALL_MATERIAL } = useElevatorMaterialSet()
const wallDepth = 0.09
const levelHeight = Math.max(levelTopY - levelY, 0.01)
const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
@@ -1026,6 +1112,7 @@ function LandingDoorFrame({
position={[-jambCenterOffset, levelY + levelHeight / 2, z]}
receiveShadow
scale={[jambWidth, levelHeight, wallDepth]}
+ slotId="shaft"
/>
{headerHeight > 0.01 && (
)}
>
)
@@ -1119,6 +1212,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
+ const sceneMaterials = useScene((state) => state.materials)
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const renderNode = useMemo(
@@ -1128,8 +1222,19 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const levelContextNodes = useScene(
useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)),
)
-
- setElevatorMaterials(shading, textures, colorPreset)
+ const materials = useMemo(
+ () => getResolvedElevatorMaterials(renderNode, shading, textures, colorPreset, sceneMaterials),
+ [colorPreset, renderNode, sceneMaterials, shading, textures],
+ )
+ const {
+ CAB_MATERIAL,
+ GLASS_MATERIAL,
+ LANDING_PANEL_MATERIAL,
+ PANEL_MATERIAL,
+ QUEUE_STRIP_MATERIALS,
+ SHAFT_SIDE_MATERIAL,
+ SHAFT_TRIM_MATERIAL,
+ } = materials
useRegistry(node.id, 'elevator', ref)
@@ -1174,6 +1279,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const doorStyle = getResolvedDoorStyle(renderNode.doorStyle)
const shaftStyle = getResolvedShaftStyle(renderNode.shaftStyle)
const shaftShellMaterial = shaftStyle === 'glass' ? GLASS_MATERIAL : SHAFT_SIDE_MATERIAL
+ const shaftShellSlotId: ElevatorSlotId = shaftStyle === 'glass' ? 'glass' : 'shaft'
const shaftTopMaterial = shaftStyle === 'glass' ? SHAFT_TRIM_MATERIAL : SHAFT_SIDE_MATERIAL
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01)
@@ -1269,217 +1375,232 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
)
return (
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
-
+
-
+
-
- {entries.map((entry, index) => {
- const column = index % cabButtonColumns
- const row = Math.floor(index / cabButtonColumns)
- const isDisabledLevel = disabledLevelIds.has(entry.id)
- const x =
- cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX
- const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY
-
- return (
-
- )
- })}
-
+
+
+
+
-
-
- {entrySpans.map(({ entry, levelTopY }) => {
- const isCurrentLevel = activeLevelId === entry.id
- const isDisabledLevel = disabledLevelIds.has(entry.id)
- const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id)
- const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id)
- const isPendingLevel = pendingLevelId === entry.id
- const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel
-
- return (
-
-
+
- {
+ const column = index % cabButtonColumns
+ const row = Math.floor(index / cabButtonColumns)
+ const isDisabledLevel = disabledLevelIds.has(entry.id)
+ const x =
+ cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX
+ const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY
+
+ return (
+
+ )
+ })}
+
-
-
-
+
+
+ {entrySpans.map(({ entry, levelTopY }) => {
+ const isCurrentLevel = activeLevelId === entry.id
+ const isDisabledLevel = disabledLevelIds.has(entry.id)
+ const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id)
+ const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id)
+ const isPendingLevel = pendingLevelId === entry.id
+ const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel
+
+ return (
+
+
- 0.5}
- buttonKind="landing"
- disabled={isDisabledLevel || isServiceOnlyLevel}
+
-
+
+
+ 0.5
+ }
+ buttonKind="landing"
+ disabled={isDisabledLevel || isServiceOnlyLevel}
+ elevatorId={elevatorId}
+ levelId={entry.id as AnyNodeId}
+ position={[0, 0.06, -0.045]}
+ queued={isQueuedLevel}
+ radius={0.045}
+ />
+
+
-
- )
- })}
-
+ )
+ })}
+
+
)
}
diff --git a/packages/nodes/src/elevator/slots.ts b/packages/nodes/src/elevator/slots.ts
new file mode 100644
index 000000000..7f1cc0483
--- /dev/null
+++ b/packages/nodes/src/elevator/slots.ts
@@ -0,0 +1,30 @@
+import {
+ type ElevatorNode,
+ getResolvedElevatorDoorPanelStyle,
+ getResolvedElevatorShaftStyle,
+ type SlotDeclaration,
+} from '@pascal-app/core'
+
+export type ElevatorSlotId = 'cab' | 'doors' | 'shaft' | 'glass'
+
+export const ELEVATOR_CAB_SLOT_DEFAULT = 'library:preset-softwhite'
+export const ELEVATOR_DOORS_SLOT_DEFAULT = 'library:metal-steel'
+export const ELEVATOR_SHAFT_SLOT_DEFAULT = 'library:preset-lightgrey'
+export const ELEVATOR_GLASS_SLOT_DEFAULT = 'library:preset-glass'
+
+export function elevatorSlots(node: ElevatorNode): SlotDeclaration[] {
+ const slots: SlotDeclaration[] = [
+ { slotId: 'cab', label: 'Cab', default: ELEVATOR_CAB_SLOT_DEFAULT },
+ { slotId: 'doors', label: 'Doors', default: ELEVATOR_DOORS_SLOT_DEFAULT },
+ { slotId: 'shaft', label: 'Shaft', default: ELEVATOR_SHAFT_SLOT_DEFAULT },
+ ]
+
+ const hasGlass =
+ getResolvedElevatorShaftStyle(node.shaftStyle) === 'glass' ||
+ getResolvedElevatorDoorPanelStyle(node.doorPanelStyle) === 'glass-frame'
+
+ if (hasGlass)
+ slots.push({ slotId: 'glass', label: 'Glass', default: ELEVATOR_GLASS_SLOT_DEFAULT })
+
+ return slots
+}
diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts
index 2d3aa09f8..f184cf354 100644
--- a/packages/nodes/src/fence/definition.ts
+++ b/packages/nodes/src/fence/definition.ts
@@ -3,8 +3,10 @@ import { buildFenceFloorplan } from './floorplan'
import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
import { fenceFloorplanMoveTarget } from './floorplan-move'
import { buildFenceGeometry } from './geometry'
+import { fencePaint } from './paint'
import { fenceParametrics } from './parametrics'
import { FenceNode } from './schema'
+import { fenceSlots } from './slots'
const SIDE_HANDLE_OFFSET = 0.27
const SIDE_HANDLE_MIN_OFFSET = 0.33
@@ -163,6 +165,8 @@ export const fenceDefinition: NodeDefinition = {
surfaces: { sides: { faces: 'all' } },
duplicable: true,
deletable: true,
+ slots: (node) => fenceSlots(node as FenceNodeType),
+ paint: fencePaint,
// Placed by drawing the span with the two-click tool; a saved preset
// seeds its build parameters via `toolDefaults.fence` (see `tool.tsx`
// and `createFenceOnCurrentLevel`).
diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts
index e87f7ebfe..e48f63550 100644
--- a/packages/nodes/src/fence/geometry.ts
+++ b/packages/nodes/src/fence/geometry.ts
@@ -1,37 +1,133 @@
+import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core'
import {
- DEFAULT_STAIR_MATERIAL,
- generateFenceGeometry,
+ applyMaterialPresetToMaterials,
+ type ColorPreset,
+ createDefaultMaterial,
+ createMaterial,
+ createSurfaceRoleMaterial,
+ generateFenceSlotGeometries,
type RenderShading,
+ resolveMaterialRef,
+ resolveSlotDefaultMaterial,
} from '@pascal-app/viewer'
-import { Group, Mesh } from 'three'
+import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
import type { FenceNode } from './schema'
+import { FENCE_SLOT_DEFAULTS, type FenceSlotId } from './slots'
/**
- * Stage B builder for fence. Reuses the legacy `generateFenceGeometry`
- * (pure function from viewer that returns a merged BufferGeometry of
- * posts + base + top rail + curve spans) and wraps it in a Mesh-in-Group
- * shape the generic `` expects.
+ * Stage B builder for fence. Splits the geometry into four paintable slots —
+ * `posts`, `infill`, `base`, `rail` (matching the build options in the panel) —
+ * each its own Mesh with a `userData.slotId` so the unified slot paint resolves
+ * and previews per part. Empty groups (no infill / floating base) are skipped.
*
- * Material is a single shared reference — fences look the same regardless
- * of instance, so we don't clone per node. If per-fence material
- * customization lands later (color picker on the panel maps to a real
- * material), this becomes a per-node lookup.
+ * Per slot the material resolves: `node.slots[slotId]` (a shared scene material
+ * or `library:` finish) → the legacy inline `node.material` / `materialPreset`
+ * (pre-slot-model scenes, applied to every part) → the declared slot default.
+ * Textures-off collapses every part to the themed joinery role.
*
- * Phase 6 cleanup moves the 280 lines of geometry math out of the
- * legacy `viewer/src/systems/fence/fence-system.tsx` into this folder
- * once the legacy system file is deleted. Until then `generateFenceGeometry`
- * is publicly re-exported from viewer.
+ * Phase 6 cleanup moves the geometry math out of the legacy
+ * `viewer/src/systems/fence/fence-system.tsx` into this folder once the legacy
+ * system file is deleted. Until then `generateFenceSlotGeometries` is publicly
+ * re-exported from viewer.
*/
+type FenceMaterial = Material & {
+ alphaMap?: Texture | null
+ depthWrite: boolean
+ opacity: number
+ transparent: boolean
+}
+
+const FENCE_SLOT_ORDER: FenceSlotId[] = ['posts', 'infill', 'base', 'rail']
+
+const fenceMaterialCache = new Map()
+
+function getFenceSlotMaterial(
+ node: FenceNode,
+ slotId: FenceSlotId,
+ shading: RenderShading,
+ textures: boolean,
+ colorPreset: ColorPreset,
+ sceneTheme: string | undefined,
+ sceneMaterials: GeometryContext['materials'],
+): Material {
+ if (!textures) {
+ return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme)
+ }
+
+ const slotRef = node.slots?.[slotId]
+ if (slotRef) {
+ const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
+ if (resolved) return resolved
+ }
+
+ if (node.materialPreset || node.material) {
+ return getLegacyFenceMaterial(node, shading)
+ }
+
+ return resolveSlotDefaultMaterial(FENCE_SLOT_DEFAULTS[slotId], shading, 0.8)
+}
+
+function getLegacyFenceMaterial(node: FenceNode, shading: RenderShading): Material {
+ const cacheKey = JSON.stringify({
+ shading,
+ material: node.material ?? null,
+ materialPreset: node.materialPreset ?? null,
+ })
+ const cached = fenceMaterialCache.get(cacheKey)
+ if (cached) return cached
+
+ const preset = getMaterialPresetByRef(node.materialPreset)
+ const material = preset
+ ? createDefaultMaterial('#ffffff', 0.5, shading)
+ : node.material
+ ? createMaterial(node.material, shading).clone()
+ : createDefaultMaterial('#ffffff', 0.9, shading)
+
+ if (preset) {
+ applyMaterialPresetToMaterials(material, preset)
+ }
+
+ const fenceMaterial = material as FenceMaterial
+ fenceMaterial.transparent = false
+ fenceMaterial.opacity = 1
+ fenceMaterial.alphaMap = null
+ fenceMaterial.side = FrontSide
+ fenceMaterial.depthWrite = true
+ fenceMaterial.needsUpdate = true
+
+ fenceMaterialCache.set(cacheKey, material)
+ return material
+}
+
export function buildFenceGeometry(
node: FenceNode,
- _ctx?: unknown,
+ ctx?: GeometryContext,
shading: RenderShading = 'rendered',
+ textures = true,
+ colorPreset: ColorPreset = 'clay',
+ sceneTheme?: string,
): Group {
const group = new Group()
- const geometry = generateFenceGeometry(node)
- const mesh = new Mesh(geometry, DEFAULT_STAIR_MATERIAL(shading))
- mesh.castShadow = true
- mesh.receiveShadow = true
- group.add(mesh)
+ const geometries = generateFenceSlotGeometries(node)
+
+ for (const slotId of FENCE_SLOT_ORDER) {
+ const geometry = geometries[slotId]
+ if (geometry.getAttribute('position') === undefined) continue
+ const material = getFenceSlotMaterial(
+ node,
+ slotId,
+ shading,
+ textures,
+ colorPreset,
+ sceneTheme,
+ ctx?.materials,
+ )
+ const mesh = new Mesh(geometry, material)
+ mesh.castShadow = true
+ mesh.receiveShadow = true
+ mesh.userData.slotId = slotId
+ group.add(mesh)
+ }
+
return group
}
diff --git a/packages/nodes/src/fence/paint.ts b/packages/nodes/src/fence/paint.ts
new file mode 100644
index 000000000..004563823
--- /dev/null
+++ b/packages/nodes/src/fence/paint.ts
@@ -0,0 +1,21 @@
+import type { AnyNode, FenceNode, PaintResolveArgs } from '@pascal-app/core'
+import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
+
+const FENCE_SLOT_IDS = new Set(['posts', 'infill', 'base', 'rail'])
+
+function resolveFenceRole(args: PaintResolveArgs): string | null {
+ const slotId = (args.hitObject?.userData as { slotId?: unknown } | undefined)?.slotId
+ return typeof slotId === 'string' && FENCE_SLOT_IDS.has(slotId) ? slotId : null
+}
+
+export const fencePaint = createSlotPaintCapability({
+ resolveRole: resolveFenceRole,
+ applyPreview: previewGeometrySlot,
+ legacyEffective: (node: AnyNode) => {
+ const fence = node as FenceNode
+ if (fence.materialPreset || fence.material) {
+ return { material: fence.material, materialPreset: fence.materialPreset }
+ }
+ return null
+ },
+})
diff --git a/packages/nodes/src/fence/slots.ts b/packages/nodes/src/fence/slots.ts
new file mode 100644
index 000000000..bda95e929
--- /dev/null
+++ b/packages/nodes/src/fence/slots.ts
@@ -0,0 +1,32 @@
+import type { SlotDeclaration } from '@pascal-app/core'
+import type { FenceNode } from './schema'
+
+// Slots map 1:1 to the fence panel's build options: the end posts, the infill
+// slats (the showInfill toggle), the base kickboard, and the top rail.
+export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail'
+
+export const FENCE_POSTS_SLOT_DEFAULT = 'library:preset-charcoal'
+export const FENCE_INFILL_SLOT_DEFAULT = 'library:preset-charcoal'
+export const FENCE_BASE_SLOT_DEFAULT = 'library:preset-greige'
+export const FENCE_RAIL_SLOT_DEFAULT = 'library:preset-greige'
+
+export const FENCE_SLOT_DEFAULTS: Record = {
+ posts: FENCE_POSTS_SLOT_DEFAULT,
+ infill: FENCE_INFILL_SLOT_DEFAULT,
+ base: FENCE_BASE_SLOT_DEFAULT,
+ rail: FENCE_RAIL_SLOT_DEFAULT,
+}
+
+export function fenceSlots(node: FenceNode): SlotDeclaration[] {
+ const slots: SlotDeclaration[] = [
+ { slotId: 'posts', label: 'Posts', default: FENCE_POSTS_SLOT_DEFAULT },
+ ]
+ if (node.showInfill !== false) {
+ slots.push({ slotId: 'infill', label: 'Infill', default: FENCE_INFILL_SLOT_DEFAULT })
+ }
+ if (node.baseStyle !== 'floating') {
+ slots.push({ slotId: 'base', label: 'Base', default: FENCE_BASE_SLOT_DEFAULT })
+ }
+ slots.push({ slotId: 'rail', label: 'Rail', default: FENCE_RAIL_SLOT_DEFAULT })
+ return slots
+}
diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts
index 115727d17..c501c986b 100644
--- a/packages/nodes/src/item/definition.ts
+++ b/packages/nodes/src/item/definition.ts
@@ -7,6 +7,7 @@ import {
} from '@pascal-app/core'
import { buildItemFloorplan } from './floorplan'
import { itemFloorplanMoveTarget } from './floorplan-move'
+import { itemPaint } from './paint'
import { itemParametrics } from './parametrics'
import { ItemNode } from './schema'
@@ -199,6 +200,7 @@ export const itemDefinition: NodeDefinition = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
+ paint: itemPaint,
// Items participate in compositions — e.g. "table-with-plants",
// "shelf-with-books-on-top" — so they're presettable in their own
// right (and as descendants of presettable parents). The GLB-kind
diff --git a/packages/nodes/src/item/paint.ts b/packages/nodes/src/item/paint.ts
new file mode 100644
index 000000000..b8b191e71
--- /dev/null
+++ b/packages/nodes/src/item/paint.ts
@@ -0,0 +1,256 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ generateSceneMaterialId,
+ type ItemNode,
+ type MaterialSchema,
+ type PaintCapability,
+ parseMaterialRef,
+ type SceneMaterial,
+ type SceneMaterialId,
+ toSceneMaterialRef,
+ useScene,
+} from '@pascal-app/core'
+import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer'
+import type { Material, Mesh } from 'three'
+
+type SlotTag = string | null | (string | null)[]
+
+type SlotUserData = {
+ slotId?: SlotTag
+}
+
+function deepEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true
+ if (typeof a !== typeof b) return false
+ if (a === null || b === null) return false
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
+ for (let index = 0; index < a.length; index += 1) {
+ if (!deepEqual(a[index], b[index])) return false
+ }
+ return true
+ }
+ if (typeof a === 'object') {
+ const aRecord = a as Record
+ const bRecord = b as Record
+ const aKeys = Object.keys(aRecord)
+ const bKeys = Object.keys(bRecord)
+ if (aKeys.length !== bKeys.length) return false
+ for (const key of aKeys) {
+ if (!Object.hasOwn(bRecord, key)) return false
+ if (!deepEqual(aRecord[key], bRecord[key])) return false
+ }
+ return true
+ }
+ return false
+}
+
+function getSlotTag(mesh: Mesh): SlotTag | undefined {
+ return (mesh.userData as SlotUserData).slotId
+}
+
+function slotTagContainsRole(tag: SlotTag | undefined, role: string): boolean {
+ if (Array.isArray(tag)) return tag.includes(role)
+ return tag === role
+}
+
+function resolveItemSlotId(args: {
+ materialIndex: number | null
+ hitObject?: { userData?: SlotUserData }
+}): string | null {
+ const tag = args.hitObject?.userData?.slotId
+ const slotId = Array.isArray(tag)
+ ? (tag[args.materialIndex ?? 0] ?? null)
+ : typeof tag === 'string'
+ ? tag
+ : null
+ return slotId
+}
+
+function buildItemSlotsPatch(
+ node: ItemNode,
+ role: string,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): Partial {
+ const slots = { ...(node.slots ?? {}) }
+ if (material === undefined && materialPreset === undefined) {
+ delete slots[role]
+ return { slots }
+ }
+ if (materialPreset) {
+ slots[role] = materialPreset
+ return { slots }
+ }
+ return { slots }
+}
+
+function findMatchingSceneMaterial(
+ materials: Record,
+ material: MaterialSchema,
+): SceneMaterial | null {
+ for (const sceneMaterial of Object.values(materials)) {
+ if (deepEqual(sceneMaterial.material, material)) return sceneMaterial
+ }
+ return null
+}
+
+function commitNewSceneMaterialAndSlots(
+ nodeId: AnyNodeId,
+ nextSlots: ItemNode['slots'],
+ sceneMaterial: SceneMaterial,
+): void {
+ // Creating the scene material and setting the slot ref are one logical
+ // edit, so apply both in a single `set` — zundo records one history entry,
+ // and one undo removes both the ref and its (now orphaned) material.
+ useScene.setState((state) => {
+ if (state.readOnly) return state
+ const currentNode = state.nodes[nodeId]
+ if (!currentNode || currentNode.type !== 'item') return state
+ return {
+ materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial },
+ nodes: {
+ ...state.nodes,
+ [nodeId]: { ...currentNode, slots: nextSlots } as AnyNode,
+ },
+ }
+ })
+ useScene.getState().markDirty(nodeId)
+}
+
+function commitItemPaint(
+ node: ItemNode,
+ role: string,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): void {
+ const nodeId = node.id as AnyNodeId
+ const state = useScene.getState()
+ const currentNode = (state.nodes[nodeId] as ItemNode | undefined) ?? node
+ let ref: string | undefined
+ let newSceneMaterial: SceneMaterial | null = null
+
+ if (material === undefined && materialPreset === undefined) {
+ ref = undefined
+ } else if (materialPreset) {
+ ref = materialPreset
+ } else if (material) {
+ const existing = findMatchingSceneMaterial(state.materials, material)
+ if (existing) {
+ ref = toSceneMaterialRef(existing.id)
+ } else {
+ const id = generateSceneMaterialId()
+ newSceneMaterial = {
+ id,
+ name: `Material ${Object.keys(state.materials).length + 1}`,
+ material,
+ }
+ ref = toSceneMaterialRef(id)
+ }
+ } else {
+ return
+ }
+
+ const nextSlots = { ...(currentNode.slots ?? {}) }
+ if (ref) nextSlots[role] = ref
+ else delete nextSlots[role]
+
+ if (newSceneMaterial) {
+ commitNewSceneMaterialAndSlots(nodeId, nextSlots, newSceneMaterial)
+ return
+ }
+
+ state.updateNode(nodeId, { slots: nextSlots } as Partial)
+}
+
+function buildPreviewMaterial(
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): Material | null {
+ const shading = useViewer.getState().shading
+ if (materialPreset) {
+ const parsed = parseMaterialRef(materialPreset)
+ if (parsed?.kind === 'scene') {
+ const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
+ return sceneMaterial ? createMaterial(sceneMaterial.material, shading) : null
+ }
+ return createMaterialFromPresetRef(materialPreset, shading)
+ }
+ if (material) return createMaterial(material, shading)
+ return null
+}
+
+function applyItemPreview(
+ role: string,
+ root: import('three').Object3D,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): (() => void) | null {
+ const previewMaterial = buildPreviewMaterial(material, materialPreset)
+ if (!previewMaterial) return () => {}
+
+ const restores: Array<() => void> = []
+ root.traverse((object) => {
+ const mesh = object as Mesh
+ if (!mesh.isMesh) return
+ const tag = getSlotTag(mesh)
+ if (!slotTagContainsRole(tag, role)) return
+
+ if (Array.isArray(tag)) {
+ const current = mesh.material as Material | Material[]
+ if (Array.isArray(current)) {
+ const previousArray = [...current]
+ const nextArray = [...current]
+ let changed = false
+ for (let index = 0; index < tag.length; index += 1) {
+ if (tag[index] !== role || !nextArray[index]) continue
+ nextArray[index] = previewMaterial
+ changed = true
+ }
+ if (!changed) return
+ mesh.material = nextArray
+ restores.push(() => {
+ mesh.material = previousArray
+ })
+ return
+ }
+ if (tag[0] !== role) return
+ }
+
+ const previous = mesh.material
+ mesh.material = previewMaterial
+ restores.push(() => {
+ mesh.material = previous
+ })
+ })
+
+ if (restores.length === 0) return null
+ return () => {
+ for (let index = restores.length - 1; index >= 0; index -= 1) {
+ restores[index]?.()
+ }
+ }
+}
+
+export const itemPaint: PaintCapability = {
+ resolveRole: ({ materialIndex, hitObject }) =>
+ resolveItemSlotId({ materialIndex, hitObject: hitObject as { userData?: SlotUserData } }),
+ buildPatch: ({ node, role, material, materialPreset }) =>
+ buildItemSlotsPatch(node as ItemNode, role, material, materialPreset) as Partial,
+ commit: ({ node, role, material, materialPreset }) =>
+ commitItemPaint(node as ItemNode, role, material, materialPreset),
+ applyPreview: ({ role, root, material, materialPreset }) =>
+ applyItemPreview(role, root, material, materialPreset),
+ getEffectiveMaterial: ({ node, role }) => {
+ const ref = (node as ItemNode).slots?.[role]
+ const parsed = parseMaterialRef(ref)
+ if (!parsed) return null
+ if (parsed.kind === 'library') {
+ return { material: undefined, materialPreset: ref }
+ }
+ const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
+ if (!sceneMaterial) return null
+ return { material: sceneMaterial.material, materialPreset: undefined }
+ },
+}
diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx
index 2c4adf6c9..dc18848ca 100644
--- a/packages/nodes/src/item/renderer.tsx
+++ b/packages/nodes/src/item/renderer.tsx
@@ -3,17 +3,21 @@
import {
type AnimationEffect,
type AnyNodeId,
+ deriveSlotId,
getScaledDimensions,
type Interactive,
type ItemNode,
+ isSlotMaterialName,
+ LIBRARY_MATERIAL_REF_PREFIX,
type LightEffect,
+ SCENE_MATERIAL_REF_PREFIX,
+ toLibraryMaterialRef,
useInteractive,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
- baseMaterial,
type ColorPreset,
createDefaultMaterial,
createSurfaceRoleMaterial,
@@ -22,6 +26,7 @@ import {
NodeRenderer,
type RenderShading,
resolveCdnUrl,
+ resolveMaterialRef,
useItemLightPool,
useNodeEvents,
useViewer,
@@ -30,7 +35,7 @@ import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { useFrame } from '@react-three/fiber'
-import { Suspense, useEffect, useMemo, useRef } from 'react'
+import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
@@ -44,17 +49,132 @@ type MutableMaterial = Material & {
wireframe?: boolean
}
-const getMaterialForOriginal = (
- original: Material,
- shading: RenderShading,
- textures: boolean,
- colorPreset: ColorPreset,
-): Material => {
- if (original.name.toLowerCase() === 'glass') {
- return glassMaterial
+type CapturedSingleItemMaterialData = {
+ captured: true
+ authoredMaterials: Material
+ curatedRefs: string | undefined
+ slotIds: string | null
+}
+
+type CapturedMultiItemMaterialData = {
+ captured: true
+ authoredMaterials: Material[]
+ curatedRefs: (string | undefined)[]
+ slotIds: (string | null)[]
+}
+
+type CapturedItemMaterialData = CapturedSingleItemMaterialData | CapturedMultiItemMaterialData
+
+type ItemMeshUserData = Mesh['userData'] & {
+ pascalItemMaterialCapture?: CapturedItemMaterialData
+ slotId?: string | null | (string | null)[]
+}
+
+type SceneMaterials = ReturnType['materials']
+
+const getAuthoredSlotId = (material: Material): string | null =>
+ isSlotMaterialName(material.name) ? deriveSlotId(material.name) : null
+
+function curatedRefFromMaterial(material: Material): string | undefined {
+ const raw = (material.userData as { pascal_material?: unknown }).pascal_material
+ if (typeof raw !== 'string' || raw.length === 0) return undefined
+ if (raw.startsWith(LIBRARY_MATERIAL_REF_PREFIX) || raw.startsWith(SCENE_MATERIAL_REF_PREFIX)) {
+ return raw
+ }
+ return toLibraryMaterialRef(raw)
+}
+
+const captureItemMeshMaterials = (mesh: Mesh): CapturedItemMaterialData => {
+ const userData = mesh.userData as ItemMeshUserData
+ const captured = userData.pascalItemMaterialCapture
+ if (captured?.captured) {
+ userData.slotId = captured.slotIds
+ return captured
+ }
+
+ if (Array.isArray(mesh.material)) {
+ const authoredMaterials = mesh.material.slice()
+ const slotIds = authoredMaterials.map(getAuthoredSlotId)
+ const curatedRefs = authoredMaterials.map(curatedRefFromMaterial)
+ const next: CapturedItemMaterialData = {
+ captured: true,
+ authoredMaterials,
+ curatedRefs,
+ slotIds,
+ }
+ userData.pascalItemMaterialCapture = next
+ userData.slotId = slotIds
+ return next
+ }
+
+ const slotId = getAuthoredSlotId(mesh.material)
+ const curatedRef = curatedRefFromMaterial(mesh.material)
+ const next: CapturedItemMaterialData = {
+ captured: true,
+ authoredMaterials: mesh.material,
+ curatedRefs: curatedRef,
+ slotIds: slotId,
+ }
+ userData.pascalItemMaterialCapture = next
+ userData.slotId = slotId
+ return next
+}
+
+const isCapturedMaterialArray = (
+ captured: CapturedItemMaterialData,
+): captured is CapturedMultiItemMaterialData => Array.isArray(captured.authoredMaterials)
+
+const isGlassMaterial = (material: Material): boolean =>
+ material === glassMaterial || material.name.toLowerCase() === 'glass'
+
+const clampGeometryGroups = (mesh: Mesh, matCount: number): void => {
+ if (mesh.geometry.groups.length === 0) return
+
+ const needsClamp = mesh.geometry.groups.some(
+ (group) => group.materialIndex !== undefined && group.materialIndex >= matCount,
+ )
+ if (!needsClamp) return
+
+ mesh.geometry = mesh.geometry.clone()
+ for (const group of mesh.geometry.groups) {
+ if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
+ group.materialIndex = 0
+ }
}
+}
+
+const resolveItemMaterial = (
+ authoredMaterial: Material,
+ slotId: string | null,
+ curatedRef: string | undefined,
+ {
+ colorPreset,
+ nodeSlots,
+ sceneMaterials,
+ shading,
+ textures,
+ }: {
+ colorPreset: ColorPreset
+ nodeSlots: ItemNode['slots']
+ sceneMaterials: SceneMaterials
+ shading: RenderShading
+ textures: boolean
+ },
+): Material => {
+ // Monochrome (textures off): collapse to the themed furnishing clay colour.
if (!textures) return createSurfaceRoleMaterial('furnishing', colorPreset)
- return baseMaterial(shading)
+ if (authoredMaterial.name.toLowerCase() === 'glass') return glassMaterial
+ if (slotId != null) {
+ const override = resolveMaterialRef(nodeSlots?.[slotId], sceneMaterials, shading)
+ if (override) return override
+ const curated = resolveMaterialRef(curatedRef, sceneMaterials, shading)
+ if (curated) return curated
+ return authoredMaterial
+ }
+ // Colored (textures on): show the item's real authored material — its
+ // textures, vertex colours, and default colours — for every item, not just
+ // slot-authored ones (no more strip-to-clay default).
+ return authoredMaterial
}
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
@@ -182,6 +302,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
+ const sceneMaterials = useScene((s) => s.materials)
// Freeze the interactive definition at mount — asset schemas don't change at runtime
const interactiveRef = useRef(node.asset.interactive)
@@ -203,44 +324,62 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
return () => useInteractive.getState().removeItem(node.id)
}, [node.id])
- useMemo(() => {
- scene.traverse((child) => {
- if ((child as Mesh).isMesh) {
- const mesh = child as Mesh
- if (mesh.name === 'cutout') {
- child.visible = false
- return
- }
-
- let hasGlass = false
-
- // Handle both single material and material array cases
- if (Array.isArray(mesh.material)) {
- mesh.material = mesh.material.map((mat) =>
- getMaterialForOriginal(mat, shading, textures, colorPreset),
- )
- hasGlass = mesh.material.some((mat) => mat.name === 'glass')
-
- // Fix geometry groups that reference materialIndex beyond the material
- // array length — this causes three-mesh-bvh to crash with
- // "Cannot read properties of undefined (reading 'side')"
- const matCount = mesh.material.length
- if (mesh.geometry.groups.length > 0) {
- for (const group of mesh.geometry.groups) {
- if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
- group.materialIndex = 0
- }
- }
- }
- } else {
- mesh.material = getMaterialForOriginal(mesh.material, shading, textures, colorPreset)
- hasGlass = mesh.material.name === 'glass'
- }
- mesh.castShadow = !hasGlass
- mesh.receiveShadow = !hasGlass
+ useLayoutEffect(() => {
+ const root = ref.current
+ if (!root) return
+
+ const meshEntries: { mesh: Mesh; captured: CapturedItemMaterialData }[] = []
+
+ root.traverse((child) => {
+ if (!(child as Mesh).isMesh) return
+
+ const mesh = child as Mesh
+ if (mesh.name === 'cutout') {
+ child.visible = false
}
+
+ const captured = captureItemMeshMaterials(mesh)
+ if (mesh.name !== 'cutout') meshEntries.push({ mesh, captured })
})
- }, [scene, shading, textures, colorPreset])
+
+ const materialOptions = {
+ colorPreset,
+ nodeSlots: node.slots,
+ sceneMaterials,
+ shading,
+ textures,
+ }
+
+ for (const { mesh, captured } of meshEntries) {
+ let hasGlass = false
+
+ if (isCapturedMaterialArray(captured)) {
+ const nextMaterials = captured.authoredMaterials.map((authoredMaterial, index) =>
+ resolveItemMaterial(
+ authoredMaterial,
+ captured.slotIds[index] ?? null,
+ captured.curatedRefs[index],
+ materialOptions,
+ ),
+ )
+ mesh.material = nextMaterials
+ hasGlass = nextMaterials.some(isGlassMaterial)
+ clampGeometryGroups(mesh, nextMaterials.length)
+ } else {
+ const nextMaterial = resolveItemMaterial(
+ captured.authoredMaterials,
+ captured.slotIds,
+ captured.curatedRefs,
+ materialOptions,
+ )
+ mesh.material = nextMaterial
+ hasGlass = isGlassMaterial(nextMaterial)
+ }
+
+ mesh.castShadow = !hasGlass
+ mesh.receiveShadow = !hasGlass
+ }
+ }, [ref, scene, shading, textures, colorPreset, node.slots, sceneMaterials])
const interactive = interactiveRef.current
const animEffect =
diff --git a/packages/nodes/src/roof/roof-materials.ts b/packages/nodes/src/roof/roof-materials.ts
index b05cca1cc..940ae3bf1 100644
--- a/packages/nodes/src/roof/roof-materials.ts
+++ b/packages/nodes/src/roof/roof-materials.ts
@@ -3,6 +3,7 @@ import {
createDefaultMaterial,
createSurfaceRoleMaterial,
type RenderShading,
+ resolveSlotDefaultMaterial,
} from '@pascal-app/viewer'
import * as THREE from 'three'
@@ -21,10 +22,12 @@ export function getRoofMaterials(
const materials = textures
? [
- createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 0: Wall/Trim
- createDefaultMaterial('#e5e5e5', 1, shading, THREE.FrontSide), // 1: Deck
- createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 2: Interior
- createDefaultMaterial('#e5e5e5', 0.9, shading, THREE.FrontSide), // 3: Shingle
+ // Mirrors getRoofMaterialArray's catalog defaults (wall/trim drywall,
+ // soft-white deck + soffit, terracotta shingle) for the no-parent path.
+ resolveSlotDefaultMaterial('library:concrete-drywall', shading), // 0: Wall/Trim
+ resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 1: Deck
+ resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 2: Interior
+ resolveSlotDefaultMaterial('library:roof-terracottatiles', shading), // 3: Shingle
]
: [
createSurfaceRoleMaterial('roof', colorPreset),
diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts
new file mode 100644
index 000000000..3e46173af
--- /dev/null
+++ b/packages/nodes/src/shared/slot-paint.ts
@@ -0,0 +1,271 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ generateSceneMaterialId,
+ type MaterialSchema,
+ type PaintCapability,
+ type PaintPreviewArgs,
+ type PaintResolveArgs,
+ parseMaterialRef,
+ type SceneMaterial,
+ type SceneMaterialId,
+ sceneRegistry,
+ toSceneMaterialRef,
+ useScene,
+} from '@pascal-app/core'
+import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer'
+import { type Material, type Mesh, type Object3D, Raycaster } from 'three'
+
+/**
+ * Shared paint capability for procedural kinds on the unified slot model
+ * (`node.slots: Record` + the shared scene-material
+ * palette) — the same data shape items derive from their GLB and the shelf
+ * declares via `capabilities.slots`. Distinct from `surface-paint.ts`, which
+ * writes the legacy inline `node.material` copy the plan is retiring.
+ *
+ * The commit / resolve / effective-material logic is identical across kinds;
+ * only the slot-resolution from a pointer hit and the mesh preview differ, so
+ * those are injected per kind.
+ */
+
+type SlotsNode = AnyNode & { slots?: Record }
+
+function deepEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true
+ if (typeof a !== typeof b) return false
+ if (a === null || b === null) return false
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
+ for (let index = 0; index < a.length; index += 1) {
+ if (!deepEqual(a[index], b[index])) return false
+ }
+ return true
+ }
+ if (typeof a === 'object') {
+ const aRecord = a as Record
+ const bRecord = b as Record
+ const aKeys = Object.keys(aRecord)
+ const bKeys = Object.keys(bRecord)
+ if (aKeys.length !== bKeys.length) return false
+ for (const key of aKeys) {
+ if (!Object.hasOwn(bRecord, key)) return false
+ if (!deepEqual(aRecord[key], bRecord[key])) return false
+ }
+ return true
+ }
+ return false
+}
+
+function findMatchingSceneMaterial(
+ materials: Record,
+ material: MaterialSchema,
+): SceneMaterial | null {
+ for (const sceneMaterial of Object.values(materials)) {
+ if (deepEqual(sceneMaterial.material, material)) return sceneMaterial
+ }
+ return null
+}
+
+function commitSlotPaint(
+ node: SlotsNode,
+ role: string,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): void {
+ const nodeId = node.id as AnyNodeId
+ const state = useScene.getState()
+ const currentNode = (state.nodes[nodeId] as SlotsNode | undefined) ?? node
+
+ let ref: string | undefined
+ let newSceneMaterial: SceneMaterial | null = null
+
+ if (material === undefined && materialPreset === undefined) {
+ ref = undefined
+ } else if (materialPreset) {
+ ref = materialPreset
+ } else if (material) {
+ const existing = findMatchingSceneMaterial(state.materials, material)
+ if (existing) {
+ ref = toSceneMaterialRef(existing.id)
+ } else {
+ const id = generateSceneMaterialId()
+ newSceneMaterial = {
+ id,
+ name: `Material ${Object.keys(state.materials).length + 1}`,
+ material,
+ }
+ ref = toSceneMaterialRef(id)
+ }
+ } else {
+ return
+ }
+
+ const nextSlots = { ...(currentNode.slots ?? {}) }
+ if (ref) nextSlots[role] = ref
+ else delete nextSlots[role]
+
+ if (newSceneMaterial) {
+ // Creating the scene material and setting the slot ref are one logical
+ // edit, so apply both in a single `set` — zundo records one history entry,
+ // and one undo removes both the ref and its (now orphaned) material.
+ const sceneMaterial = newSceneMaterial
+ useScene.setState((s) => {
+ if (s.readOnly) return s
+ const node2 = s.nodes[nodeId] as SlotsNode | undefined
+ if (!node2) return s
+ return {
+ materials: { ...s.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial },
+ nodes: {
+ ...s.nodes,
+ [nodeId]: { ...node2, slots: nextSlots } as AnyNode,
+ },
+ }
+ })
+ useScene.getState().markDirty(nodeId)
+ return
+ }
+
+ state.updateNode(nodeId, { slots: nextSlots } as Partial)
+}
+
+/** Preview material for a slot paint — mirrors the commit's resolution. */
+export function buildSlotPreviewMaterial(
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): Material | null {
+ const shading = useViewer.getState().shading
+ if (materialPreset) {
+ const parsed = parseMaterialRef(materialPreset)
+ if (parsed?.kind === 'scene') {
+ const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
+ return sceneMaterial ? createMaterial(sceneMaterial.material, shading) : null
+ }
+ return createMaterialFromPresetRef(materialPreset, shading)
+ }
+ if (material) return createMaterial(material, shading)
+ return null
+}
+
+/**
+ * Preview for kinds whose meshes are produced by `def.geometry` and tagged
+ * with `userData.slotId` (+ `__fromGeometry`). Swaps every builder mesh whose
+ * slot matches `role`, leaving hosted-child meshes (which can carry a colliding
+ * `userData.slotId` from their own GLB) untouched.
+ */
+export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null {
+ const { role, root, material, materialPreset } = args
+ const preview = buildSlotPreviewMaterial(material, materialPreset)
+ if (!preview) return () => {}
+
+ const restores: Array<() => void> = []
+ ;(root as Object3D).traverse((object) => {
+ const mesh = object as Mesh
+ if (!mesh.isMesh) return
+ const userData = mesh.userData as { slotId?: string | null; __fromGeometry?: boolean }
+ if (userData.__fromGeometry !== true) return
+ if (userData.slotId !== role) return
+ const previous = mesh.material
+ mesh.material = preview
+ restores.push(() => {
+ mesh.material = previous
+ })
+ })
+
+ if (restores.length === 0) return null
+ return () => {
+ for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.()
+ }
+}
+
+/**
+ * Preview for kinds whose meshes are built by a viewer system (window, door)
+ * and tagged with `userData.slotId` — no `__fromGeometry` marker and no hosted
+ * children to guard against, so it swaps every mesh whose slot matches `role`.
+ */
+export function previewSlotByUserData(args: PaintPreviewArgs): (() => void) | null {
+ const { role, root, material, materialPreset } = args
+ const preview = buildSlotPreviewMaterial(material, materialPreset)
+ if (!preview) return () => {}
+
+ const restores: Array<() => void> = []
+ ;(root as Object3D).traverse((object) => {
+ const mesh = object as Mesh
+ if (!mesh.isMesh) return
+ if ((mesh.userData as { slotId?: string | null }).slotId !== role) return
+ const previous = mesh.material
+ mesh.material = preview
+ restores.push(() => {
+ mesh.material = previous
+ })
+ })
+
+ if (restores.length === 0) return null
+ return () => {
+ for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.()
+ }
+}
+
+// Reused across calls — set from the pointer ray each time.
+const subtreeRaycaster = new Raycaster()
+
+/**
+ * Resolve the slot for a kind whose paint hit lands on a proud opening proxy
+ * (door/window: a 1m-deep invisible cutout that wins the scene raycast over the
+ * wall in front of the recessed body) rather than the part itself. Re-raycasts
+ * the kind's OWN registered subtree (ignoring everything else) and returns the
+ * first tagged sub-mesh under the cursor; falls back to the direct hit's slot
+ * (e.g. a proud part the scene raycast hit directly).
+ */
+export function resolveSlotByReRaycast(args: PaintResolveArgs): string | null {
+ const direct = (args.hitObject?.userData as { slotId?: string } | undefined)?.slotId
+ if (typeof direct === 'string') return direct
+ const root = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
+ if (!root || !args.ray) return null
+ subtreeRaycaster.ray.copy(args.ray)
+ for (const hit of subtreeRaycaster.intersectObject(root, true)) {
+ const slot = (hit.object.userData as { slotId?: string }).slotId
+ if (typeof slot === 'string') return slot
+ }
+ return null
+}
+
+export type SlotPaintConfig = {
+ /** Resolve the slot id for a pointer hit (`null` = not paintable here). */
+ resolveRole: (args: PaintResolveArgs) => string | null
+ /** Apply a preview to the registered mesh subtree for `role`. */
+ applyPreview: (args: PaintPreviewArgs) => (() => void) | null
+ /**
+ * Optional legacy fallback for the picker's current-value indicator — read
+ * when no `node.slots[role]` ref exists yet (e.g. a scene painted before the
+ * kind moved onto the slot model still carries inline `material`/`preset`).
+ */
+ legacyEffective?: (
+ node: AnyNode,
+ role: string,
+ ) => { material: MaterialSchema | undefined; materialPreset: string | undefined } | null
+}
+
+export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability {
+ return {
+ resolveRole: config.resolveRole,
+ buildPatch: ({ node, role, materialPreset }) => {
+ const slots = { ...((node as SlotsNode).slots ?? {}) }
+ if (materialPreset) slots[role] = materialPreset
+ else delete slots[role]
+ return { slots } as Partial
+ },
+ commit: ({ node, role, material, materialPreset }) =>
+ commitSlotPaint(node as SlotsNode, role, material, materialPreset),
+ applyPreview: config.applyPreview,
+ getEffectiveMaterial: ({ node, role }) => {
+ const ref = (node as SlotsNode).slots?.[role]
+ const parsed = parseMaterialRef(ref)
+ if (parsed) {
+ if (parsed.kind === 'library') return { material: undefined, materialPreset: ref }
+ const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
+ if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined }
+ }
+ return config.legacyEffective?.(node, role) ?? null
+ },
+ }
+}
diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts
index d42bd14f7..8819f0f97 100644
--- a/packages/nodes/src/shelf/definition.ts
+++ b/packages/nodes/src/shelf/definition.ts
@@ -4,8 +4,10 @@ import { buildShelfFloorplan } from './floorplan'
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
import { shelfFloorplanMoveTarget } from './floorplan-move'
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
+import { shelfPaint } from './paint'
import { shelfParametrics } from './parametrics'
import { ShelfNode } from './schema'
+import { shelfSlots } from './slots'
const SIDE_HANDLE_OFFSET = 0.18
const HEIGHT_HANDLE_OFFSET = 0.22
@@ -155,8 +157,8 @@ export const shelfDefinition: NodeDefinition = {
withBottom: true,
bracketStyle: 'minimal',
// material / materialPreset left undefined — geometry falls back to
- // `DEFAULT_SHELF_MATERIAL` (off-white), and paint mode writes the
- // chosen catalog material into these fields.
+ // the per-slot off-white default, and slot paint mode writes chosen
+ // catalog materials into `slots`.
}),
capabilities: {
@@ -183,6 +185,8 @@ export const shelfDefinition: NodeDefinition = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
+ paint: shelfPaint,
+ slots: (n) => shelfSlots(n as ShelfNode),
// Slab elevation lift via the generic `` — a
// shelf sitting over a raised slab visually rests on top of it.
floorPlaced: {
@@ -233,6 +237,7 @@ export const shelfDefinition: NodeDefinition = {
s.bracketStyle,
s.material,
s.materialPreset,
+ JSON.stringify(s.slots ?? null),
])
},
floorplan: buildShelfFloorplan,
diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts
index c72eabfd5..d616d9425 100644
--- a/packages/nodes/src/shelf/floorplan-move.ts
+++ b/packages/nodes/src/shelf/floorplan-move.ts
@@ -109,7 +109,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
},
canCommit() {
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
- if (!live || live.type !== 'shelf') return false
+ if (live?.type !== 'shelf') return false
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
},
}
diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts
index f71a3d470..d5d266c93 100644
--- a/packages/nodes/src/shelf/geometry.ts
+++ b/packages/nodes/src/shelf/geometry.ts
@@ -1,14 +1,15 @@
-import { getMaterialPresetByRef } from '@pascal-app/core'
+import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core'
import {
applyMaterialPresetToMaterials,
createDefaultMaterial,
createMaterial,
- DEFAULT_SHELF_MATERIAL,
type RenderShading,
+ resolveMaterialRef,
} from '@pascal-app/viewer'
-import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three'
+import { BoxGeometry, Group, type Material, Mesh } from 'three'
import { sanitizeShelfDimensions } from './dimensions'
import type { ShelfNode } from './schema'
+import { SHELF_SLOT_DEFAULT_COLOR, type ShelfSlotId } from './slots'
/**
* Pure shelf geometry builder. Takes a `ShelfNode` and returns a `Group`
@@ -23,75 +24,104 @@ import type { ShelfNode } from './schema'
* index arrays directly, and lets AI-generated nodes follow the same
* shape with no editor-specific knowledge.
*
- * Materials: the kind exposes a single paintable surface via
- * `node.material` / `node.materialPreset` — same shape walls / slabs /
- * stairs use. When neither is set, every mesh shares the
- * `DEFAULT_SHELF_MATERIAL` (off-white). When the user paints, the
- * library preset's properties land on a cloned material here. The cache
- * key includes the preset / material signature so paint changes
- * invalidate without stomping unrelated shelves.
+ * Materials: the kind exposes per-slot paintable surfaces through
+ * `node.slots`, while `node.material` / `node.materialPreset` remain as
+ * legacy whole-shelf fallbacks. Every generated mesh is tagged with the
+ * slot it belongs to so paint mode can target shelves, frame, or back.
*
* Style dispatch lives at the top of the function; each style helper
* mutates the same `group`.
*/
-type ShelfMaterial = Material & {
- depthWrite: boolean
-}
+type ShelfSlotMaterials = Record
-const shelfMaterialCache = new Map()
-
-function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material {
- const cacheKey = JSON.stringify({
- shading,
- material: node.material ?? null,
- materialPreset: node.materialPreset ?? null,
- })
- const cached = shelfMaterialCache.get(cacheKey)
- if (cached) return cached
-
- const preset = getMaterialPresetByRef(node.materialPreset)
- const material = preset
- ? createDefaultMaterial('#ffffff', 0.5, shading)
- : node.material
- ? createMaterial(node.material, shading).clone()
- : DEFAULT_SHELF_MATERIAL(shading).clone()
-
- if (preset) {
- applyMaterialPresetToMaterials(material, preset)
+function getShelfSlotMaterial(
+ node: ShelfNode,
+ slotId: ShelfSlotId,
+ materials: GeometryContext['materials'],
+ shading: RenderShading,
+): Material {
+ const ref = node.slots?.[slotId]
+ if (ref) {
+ const resolved = resolveMaterialRef(ref, materials, shading)
+ if (resolved) return resolved
+ }
+ // Legacy whole-shelf paint applies to every slot when set (no per-slot override).
+ if (node.materialPreset) {
+ const preset = getMaterialPresetByRef(node.materialPreset)
+ if (preset) {
+ const base = createDefaultMaterial('#ffffff', 0.5, shading)
+ applyMaterialPresetToMaterials(base, preset)
+ return base
+ }
}
+ if (node.material) return createMaterial(node.material, shading)
+ return createDefaultMaterial(SHELF_SLOT_DEFAULT_COLOR, 0.9, shading)
+}
+
+function stampShelfSlot(mesh: Mesh, slotId: ShelfSlotId): Mesh {
+ mesh.userData.slotId = slotId
+ return mesh
+}
- const shelfMaterial = material as ShelfMaterial
- shelfMaterial.side = FrontSide
- shelfMaterial.depthWrite = true
- shelfMaterial.needsUpdate = true
+// A board's front/back faces land on the frame's outer faces (posts / back panel)
+// — coplanar surfaces the depth buffer can't separate, which flickers as z-fighting.
+// Recess 1mm so the board sits just inside: the meshes still overlap (no gap), but
+// no faces are coplanar. Depth is always recessed (boards reach into the back panel
+// / posts). Width is recessed only at call sites where boards span OVER posts
+// (open-rack / no-sides bookshelf); boards that ABUT side panels keep full width so
+// they meet the sides flush — abutting faces are back-to-back and never fight.
+const BOARD_INSET = 0.001
+
+// Frame members that pass under the top board (dividers / back / corner posts) reach
+// y=unitHeight, coplanar with the top board's top face → z-fighting. Drop their top 1mm so
+// the board cleanly caps them; their bottom stays on the floor.
+const FRAME_TOP_INSET = 0.001
+
+function cappedFrameY(unitHeight: number): { height: number; centerY: number } {
+ const height = Math.max(unitHeight - FRAME_TOP_INSET, 0.001)
+ return { height, centerY: height / 2 }
+}
- shelfMaterialCache.set(cacheKey, material)
- return material
+function boardGeometry(
+ width: number,
+ thickness: number,
+ depth: number,
+ insetWidth = false,
+): BoxGeometry {
+ return new BoxGeometry(
+ insetWidth ? Math.max(width - 2 * BOARD_INSET, 0.001) : width,
+ thickness,
+ Math.max(depth - 2 * BOARD_INSET, 0.001),
+ )
}
export function buildShelfGeometry(
rawNode: ShelfNode,
- _ctx?: unknown,
+ ctx?: GeometryContext,
shading: RenderShading = 'rendered',
): Group {
const node = sanitizeShelfDimensions(rawNode)
const group = new Group()
group.name = 'shelf-geometry'
- const material = getShelfMaterial(node, shading)
+ const materials: ShelfSlotMaterials = {
+ shelves: getShelfSlotMaterial(node, 'shelves', ctx?.materials, shading),
+ frame: getShelfSlotMaterial(node, 'frame', ctx?.materials, shading),
+ back: getShelfSlotMaterial(node, 'back', ctx?.materials, shading),
+ }
switch (node.style) {
case 'wall-shelf':
- buildWallShelf(group, node, material)
+ buildWallShelf(group, node, materials)
break
case 'bookshelf':
- buildBookshelf(group, node, material)
+ buildBookshelf(group, node, materials)
break
case 'open-rack':
- buildOpenRack(group, node, material)
+ buildOpenRack(group, node, materials)
break
case 'cubby':
- buildCubby(group, node, material)
+ buildCubby(group, node, materials)
break
}
@@ -112,9 +142,12 @@ export function buildShelfGeometry(
* evenly-spaced boards from `height/rows` up to `height`. Brackets
* span from floor to the topmost board.
*/
-function buildWallShelf(group: Group, node: ShelfNode, material: Material) {
+function buildWallShelf(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) {
for (const y of boardCenterYs(node)) {
- const board = new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), material)
+ const board = stampShelfSlot(
+ new Mesh(boardGeometry(node.width, node.thickness, node.depth), materials.shelves),
+ 'shelves',
+ )
board.name = `shelf-board-${boardRowIndex(node, y)}`
board.position.set(0, y, 0)
group.add(board)
@@ -131,7 +164,10 @@ function buildWallShelf(group: Group, node: ShelfNode, material: Material) {
const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7
for (const sign of [-1, 1] as const) {
- const bracket = new Mesh(new BoxGeometry(bracketWidth, bracketHeight, bracketDepth), material)
+ const bracket = stampShelfSlot(
+ new Mesh(new BoxGeometry(bracketWidth, bracketHeight, bracketDepth), materials.frame),
+ 'frame',
+ )
bracket.name = `shelf-bracket-${sign === -1 ? 'left' : 'right'}`
bracket.position.set(sign * (node.width / 2 - inset), bracketHeight / 2, 0)
group.add(bracket)
@@ -144,20 +180,30 @@ function buildWallShelf(group: Group, node: ShelfNode, material: Material) {
* `withSides === false`, side panels become slim corner posts (a rack
* silhouette).
*/
-function buildBookshelf(group: Group, node: ShelfNode, material: Material) {
+function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) {
const unitHeight = node.height + node.thickness
const innerWidth = node.withSides ? node.width - 2 * node.thickness : node.width
- // Top + bottom + intermediate boards
+ // Top + bottom + intermediate boards. No sides => boards span over corner
+ // posts, so inset their width too; with sides they abut the panels (flush).
for (const y of boardCenterYs(node)) {
- const board = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material)
+ const board = stampShelfSlot(
+ new Mesh(
+ boardGeometry(innerWidth, node.thickness, node.depth, !node.withSides),
+ materials.shelves,
+ ),
+ 'shelves',
+ )
board.name = `shelf-board-${boardRowIndex(node, y)}`
board.position.set(0, y, 0)
group.add(board)
}
if (node.withBottom) {
- const bottom = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material)
+ const bottom = stampShelfSlot(
+ new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves),
+ 'shelves',
+ )
bottom.name = 'shelf-board-bottom'
bottom.position.set(0, node.thickness / 2, 0)
group.add(bottom)
@@ -166,30 +212,48 @@ function buildBookshelf(group: Group, node: ShelfNode, material: Material) {
// Side panels (or corner posts) — span the full unit height.
if (node.withSides) {
for (const sign of [-1, 1] as const) {
- const side = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material)
+ const side = stampShelfSlot(
+ new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame),
+ 'frame',
+ )
side.name = `shelf-side-${sign === -1 ? 'left' : 'right'}`
side.position.set(sign * (node.width / 2 - node.thickness / 2), unitHeight / 2, 0)
group.add(side)
}
} else {
- addCornerPosts(group, node, material, unitHeight, 'rack')
+ addCornerPosts(group, node, materials.frame, unitHeight, 'rack')
}
if (node.withBack) {
- const back = new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), material)
+ const fy = cappedFrameY(unitHeight)
+ const back = stampShelfSlot(
+ new Mesh(new BoxGeometry(innerWidth, fy.height, node.thickness), materials.back),
+ 'back',
+ )
back.name = 'shelf-back'
- back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2))
+ back.position.set(0, fy.centerY, -(node.depth / 2 - node.thickness / 2))
group.add(back)
}
// Vertical dividers between columns
if (node.columns > 1) {
+ const fy = cappedFrameY(unitHeight)
const colStep = innerWidth / node.columns
for (let c = 1; c < node.columns; c++) {
const x = -innerWidth / 2 + c * colStep
- const divider = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material)
+ const divider = stampShelfSlot(
+ // A full-height divider crosses the shelves, so its depth must sit
+ // INSIDE the boards' (already recessed) depth: embedded at each crossing
+ // (the board occludes it — no coplanar fight) and tucked inside the back
+ // panel, rather than proud at the front / coplanar with the back.
+ new Mesh(
+ new BoxGeometry(node.thickness, fy.height, node.depth - 4 * BOARD_INSET),
+ materials.frame,
+ ),
+ 'frame',
+ )
divider.name = `shelf-divider-col-${c}`
- divider.position.set(x, unitHeight / 2, 0)
+ divider.position.set(x, fy.centerY, 0)
group.add(divider)
}
}
@@ -200,26 +264,32 @@ function buildBookshelf(group: Group, node: ShelfNode, material: Material) {
* X-brace on the back face for stability. `withSides` / `bracketStyle`
* are ignored (the rack defines its own posts).
*/
-function buildOpenRack(group: Group, node: ShelfNode, material: Material) {
+function buildOpenRack(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) {
const unitHeight = node.height + node.thickness
const innerWidth = node.width
const boardThickness = Math.max(0.02, node.thickness * 0.8)
for (const y of boardCenterYs(node)) {
- const board = new Mesh(new BoxGeometry(innerWidth, boardThickness, node.depth), material)
+ const board = stampShelfSlot(
+ new Mesh(boardGeometry(innerWidth, boardThickness, node.depth, true), materials.shelves),
+ 'shelves',
+ )
board.name = `shelf-board-${boardRowIndex(node, y)}`
board.position.set(0, y, 0)
group.add(board)
}
- addCornerPosts(group, node, material, unitHeight, 'rack')
+ addCornerPosts(group, node, materials.frame, unitHeight, 'rack')
if (node.withBack) {
const braceThickness = Math.max(0.015, node.thickness * 0.6)
for (const y of [boardThickness, unitHeight - boardThickness] as const) {
- const brace = new Mesh(
- new BoxGeometry(node.width - braceThickness * 2, braceThickness, braceThickness),
- material,
+ const brace = stampShelfSlot(
+ new Mesh(
+ new BoxGeometry(node.width - braceThickness * 2, braceThickness, braceThickness),
+ materials.frame,
+ ),
+ 'frame',
)
brace.name = `shelf-brace-h-${y < unitHeight / 2 ? 'bottom' : 'top'}`
brace.position.set(0, y, -(node.depth / 2 - braceThickness / 2))
@@ -233,49 +303,69 @@ function buildOpenRack(group: Group, node: ShelfNode, material: Material) {
* boards + vertical dividers. `withBack` / `withSides` are forced on
* because the cubby shape requires them.
*/
-function buildCubby(group: Group, node: ShelfNode, material: Material) {
+function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) {
const unitHeight = node.height + node.thickness
const innerWidth = node.width - 2 * node.thickness
for (const y of boardCenterYs(node)) {
- const board = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material)
+ const board = stampShelfSlot(
+ new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves),
+ 'shelves',
+ )
board.name = `shelf-board-${boardRowIndex(node, y)}`
board.position.set(0, y, 0)
group.add(board)
}
if (node.withBottom) {
- const bottom = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material)
+ const bottom = stampShelfSlot(
+ new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves),
+ 'shelves',
+ )
bottom.name = 'shelf-board-bottom'
bottom.position.set(0, node.thickness / 2, 0)
group.add(bottom)
}
for (const sign of [-1, 1] as const) {
- const side = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material)
+ const side = stampShelfSlot(
+ new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame),
+ 'frame',
+ )
side.name = `shelf-side-${sign === -1 ? 'left' : 'right'}`
side.position.set(sign * (node.width / 2 - node.thickness / 2), unitHeight / 2, 0)
group.add(side)
}
- const back = new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), material)
+ const fy = cappedFrameY(unitHeight)
+ const back = stampShelfSlot(
+ new Mesh(new BoxGeometry(innerWidth, fy.height, node.thickness), materials.back),
+ 'back',
+ )
back.name = 'shelf-back'
- back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2))
+ back.position.set(0, fy.centerY, -(node.depth / 2 - node.thickness / 2))
group.add(back)
if (node.columns > 1) {
const colStep = innerWidth / node.columns
const rowStep = node.height / node.rows
for (let r = 0; r < node.rows; r++) {
- const cellBottomY = node.thickness + r * rowStep
+ // Without a bottom board the lowest cell opens onto the floor, so its
+ // divider must reach y=0 rather than rest on a (missing) board top.
+ const cellBottomY = r === 0 && !node.withBottom ? 0 : node.thickness + r * rowStep
const cellTopY = node.thickness + (r + 1) * rowStep
const dividerHeight = cellTopY - cellBottomY - node.thickness
if (dividerHeight <= 0) continue
for (let c = 1; c < node.columns; c++) {
const x = -innerWidth / 2 + c * colStep
- const divider = new Mesh(
- new BoxGeometry(node.thickness, dividerHeight, node.depth),
- material,
+ const divider = stampShelfSlot(
+ // Same depth recess as the boards: the divider sits flush with the
+ // shelf fronts (not proud) and its back tucks inside the back panel,
+ // so it neither overflows the boards at the front nor z-fights the
+ // back panel down the centre. Height is flush (the board faces it
+ // meets top/bottom are back-to-back, so they don't fight).
+ new Mesh(boardGeometry(node.thickness, dividerHeight, node.depth), materials.frame),
+ 'frame',
)
divider.name = `shelf-divider-${r}-${c}`
divider.position.set(x, cellBottomY + dividerHeight / 2, 0)
@@ -319,16 +409,20 @@ function addCornerPosts(
unitHeight: number,
postStyle: 'rack' | 'leg',
) {
+ const fy = cappedFrameY(unitHeight)
const postThickness =
postStyle === 'rack' ? Math.max(0.025, node.thickness * 1.5) : Math.max(0.02, node.thickness)
const inset = postThickness / 2
for (const xSign of [-1, 1] as const) {
for (const zSign of [-1, 1] as const) {
- const post = new Mesh(new BoxGeometry(postThickness, unitHeight, postThickness), material)
+ const post = stampShelfSlot(
+ new Mesh(new BoxGeometry(postThickness, fy.height, postThickness), material),
+ 'frame',
+ )
post.name = `shelf-post-${xSign === -1 ? 'l' : 'r'}${zSign === -1 ? 'b' : 'f'}`
post.position.set(
xSign * (node.width / 2 - inset),
- unitHeight / 2,
+ fy.centerY,
zSign * (node.depth / 2 - inset),
)
group.add(post)
diff --git a/packages/nodes/src/shelf/paint.ts b/packages/nodes/src/shelf/paint.ts
new file mode 100644
index 000000000..e6c8a5642
--- /dev/null
+++ b/packages/nodes/src/shelf/paint.ts
@@ -0,0 +1,218 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ generateSceneMaterialId,
+ type MaterialSchema,
+ type PaintCapability,
+ parseMaterialRef,
+ type SceneMaterial,
+ type SceneMaterialId,
+ type ShelfNode,
+ toSceneMaterialRef,
+ useScene,
+} from '@pascal-app/core'
+import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer'
+import type { Material, Mesh } from 'three'
+
+type ShelfSlotUserData = {
+ slotId?: string | null
+}
+
+function deepEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true
+ if (typeof a !== typeof b) return false
+ if (a === null || b === null) return false
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
+ for (let index = 0; index < a.length; index += 1) {
+ if (!deepEqual(a[index], b[index])) return false
+ }
+ return true
+ }
+ if (typeof a === 'object') {
+ const aRecord = a as Record
+ const bRecord = b as Record
+ const aKeys = Object.keys(aRecord)
+ const bKeys = Object.keys(bRecord)
+ if (aKeys.length !== bKeys.length) return false
+ for (const key of aKeys) {
+ if (!Object.hasOwn(bRecord, key)) return false
+ if (!deepEqual(aRecord[key], bRecord[key])) return false
+ }
+ return true
+ }
+ return false
+}
+
+function resolveShelfSlotId(args: { hitObject?: { userData?: ShelfSlotUserData } }): string | null {
+ const slotId = args.hitObject?.userData?.slotId
+ return typeof slotId === 'string' ? slotId : null
+}
+
+function buildShelfSlotsPatch(
+ node: ShelfNode,
+ role: string,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): Partial {
+ const slots = { ...(node.slots ?? {}) }
+ if (material === undefined && materialPreset === undefined) {
+ delete slots[role]
+ return { slots }
+ }
+ if (materialPreset) {
+ slots[role] = materialPreset
+ return { slots }
+ }
+ return { slots }
+}
+
+function findMatchingSceneMaterial(
+ materials: Record,
+ material: MaterialSchema,
+): SceneMaterial | null {
+ for (const sceneMaterial of Object.values(materials)) {
+ if (deepEqual(sceneMaterial.material, material)) return sceneMaterial
+ }
+ return null
+}
+
+function commitNewSceneMaterialAndSlots(
+ nodeId: AnyNodeId,
+ nextSlots: ShelfNode['slots'],
+ sceneMaterial: SceneMaterial,
+): void {
+ // Creating the scene material and setting the slot ref are one logical
+ // edit, so apply both in a single `set` — zundo records one history entry,
+ // and one undo removes both the ref and its (now orphaned) material.
+ useScene.setState((state) => {
+ if (state.readOnly) return state
+ const currentNode = state.nodes[nodeId]
+ if (currentNode?.type !== 'shelf') return state
+ return {
+ materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial },
+ nodes: {
+ ...state.nodes,
+ [nodeId]: { ...currentNode, slots: nextSlots } as AnyNode,
+ },
+ }
+ })
+ useScene.getState().markDirty(nodeId)
+}
+
+function commitShelfPaint(
+ node: ShelfNode,
+ role: string,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): void {
+ const nodeId = node.id as AnyNodeId
+ const state = useScene.getState()
+ const currentNode = (state.nodes[nodeId] as ShelfNode | undefined) ?? node
+ let ref: string | undefined
+ let newSceneMaterial: SceneMaterial | null = null
+
+ if (material === undefined && materialPreset === undefined) {
+ ref = undefined
+ } else if (materialPreset) {
+ ref = materialPreset
+ } else if (material) {
+ const existing = findMatchingSceneMaterial(state.materials, material)
+ if (existing) {
+ ref = toSceneMaterialRef(existing.id)
+ } else {
+ const id = generateSceneMaterialId()
+ newSceneMaterial = {
+ id,
+ name: `Material ${Object.keys(state.materials).length + 1}`,
+ material,
+ }
+ ref = toSceneMaterialRef(id)
+ }
+ } else {
+ return
+ }
+
+ const nextSlots = { ...(currentNode.slots ?? {}) }
+ if (ref) nextSlots[role] = ref
+ else delete nextSlots[role]
+
+ if (newSceneMaterial) {
+ commitNewSceneMaterialAndSlots(nodeId, nextSlots, newSceneMaterial)
+ return
+ }
+
+ state.updateNode(nodeId, { slots: nextSlots } as Partial)
+}
+
+function buildPreviewMaterial(
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): Material | null {
+ const shading = useViewer.getState().shading
+ if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading)
+ if (material) return createMaterial(material, shading)
+ return null
+}
+
+function applyShelfPreview(
+ role: string,
+ root: import('three').Object3D,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): (() => void) | null {
+ const previewMaterial = buildPreviewMaterial(material, materialPreset)
+ if (!previewMaterial) return () => {}
+
+ const restores: Array<() => void> = []
+ root.traverse((object) => {
+ const mesh = object as Mesh
+ if (!mesh.isMesh) return
+ const userData = mesh.userData as ShelfSlotUserData & { __fromGeometry?: boolean }
+ // Only the shelf's own builder meshes — never hosted item children, whose
+ // GLB meshes can carry a colliding `userData.slotId` (slot_frame, etc.).
+ if (userData.__fromGeometry !== true) return
+ if (userData.slotId !== role) return
+
+ const previous = mesh.material
+ mesh.material = previewMaterial
+ restores.push(() => {
+ mesh.material = previous
+ })
+ })
+
+ if (restores.length === 0) return null
+ return () => {
+ for (let index = restores.length - 1; index >= 0; index -= 1) {
+ restores[index]?.()
+ }
+ }
+}
+
+export const shelfPaint: PaintCapability = {
+ resolveRole: ({ hitObject }) =>
+ resolveShelfSlotId({ hitObject: hitObject as { userData?: ShelfSlotUserData } }),
+ buildPatch: ({ node, role, material, materialPreset }) =>
+ buildShelfSlotsPatch(node as ShelfNode, role, material, materialPreset) as Partial,
+ commit: ({ node, role, material, materialPreset }) =>
+ commitShelfPaint(node as ShelfNode, role, material, materialPreset),
+ applyPreview: ({ role, root, material, materialPreset }) =>
+ applyShelfPreview(role, root, material, materialPreset),
+ getEffectiveMaterial: ({ node, role }) => {
+ const shelf = node as ShelfNode
+ const parsed = parseMaterialRef(shelf.slots?.[role])
+ if (parsed) {
+ if (parsed.kind === 'library') {
+ return { material: undefined, materialPreset: shelf.slots?.[role] }
+ }
+ const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
+ if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined }
+ }
+ // No (or dangling) slot ref — surface the legacy whole-shelf paint the
+ // geometry builder still falls back to, so the picker matches what renders.
+ if (shelf.materialPreset || shelf.material) {
+ return { material: shelf.material, materialPreset: shelf.materialPreset }
+ }
+ return null
+ },
+}
diff --git a/packages/nodes/src/shelf/preview.tsx b/packages/nodes/src/shelf/preview.tsx
index e069a065b..f9fa83209 100644
--- a/packages/nodes/src/shelf/preview.tsx
+++ b/packages/nodes/src/shelf/preview.tsx
@@ -13,11 +13,9 @@ import type { ShelfNode } from './schema'
* 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
- * material 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.
+ * 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.
@@ -44,9 +42,9 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => {
;(obj as unknown as { raycast: () => void }).raycast = () => {}
// `Mesh.material` is typed as `Material | Material[]` upstream;
- // every shelf board carries a material from
- // `getShelfMaterial`. Access through a structural cast keeps the
- // assignment well-typed without depending on the Mesh union.
+ // 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[]
}
@@ -70,8 +68,8 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => {
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
+ // 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) => {
diff --git a/packages/nodes/src/shelf/slots.ts b/packages/nodes/src/shelf/slots.ts
new file mode 100644
index 000000000..411eed965
--- /dev/null
+++ b/packages/nodes/src/shelf/slots.ts
@@ -0,0 +1,35 @@
+import type { SlotDeclaration } from '@pascal-app/core'
+import type { ShelfNode } from './schema'
+
+export type ShelfSlotId = 'shelves' | 'frame' | 'back'
+
+// Visual parity with the retired DEFAULT_SHELF_MATERIAL (off-white).
+export const SHELF_SLOT_DEFAULT_COLOR = '#ffffff'
+
+/** Map a builder mesh name to its slot id (null = not a paintable shelf part). */
+export function shelfSlotIdForMeshName(name: string): ShelfSlotId | null {
+ if (name.startsWith('shelf-board')) return 'shelves'
+ if (name === 'shelf-back') return 'back'
+ if (
+ name.startsWith('shelf-side') ||
+ name.startsWith('shelf-post') ||
+ name.startsWith('shelf-divider') ||
+ name.startsWith('shelf-bracket') ||
+ name.startsWith('shelf-brace')
+ ) {
+ return 'frame'
+ }
+ return null
+}
+
+/** Which slots a given shelf actually exposes (depends on style/flags). */
+export function shelfSlots(node: ShelfNode): SlotDeclaration[] {
+ const slots: SlotDeclaration[] = [
+ { slotId: 'shelves', label: 'Shelves', default: SHELF_SLOT_DEFAULT_COLOR },
+ ]
+ const hasFrame = !(node.style === 'wall-shelf' && node.bracketStyle === 'hidden')
+ if (hasFrame) slots.push({ slotId: 'frame', label: 'Frame', default: SHELF_SLOT_DEFAULT_COLOR })
+ const hasBack = node.style === 'cubby' || (node.style === 'bookshelf' && node.withBack)
+ if (hasBack) slots.push({ slotId: 'back', label: 'Back', default: SHELF_SLOT_DEFAULT_COLOR })
+ return slots
+}
diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts
index d70a736b2..d8f698bc9 100644
--- a/packages/nodes/src/slab/definition.ts
+++ b/packages/nodes/src/slab/definition.ts
@@ -12,8 +12,10 @@ import {
} from './floorplan-affordances'
import { slabFloorplanMoveTarget } from './floorplan-move'
import { buildSlabGeometry } from './geometry'
+import { slabPaint } from './paint'
import { slabParametrics } from './parametrics'
import { SlabNode } from './schema'
+import { slabSlots } from './slots'
const HEIGHT_HANDLE_OFFSET = 0.22
const MIN_SLAB_ELEVATION = 0.02
@@ -155,6 +157,10 @@ export const slabDefinition: NodeDefinition = {
},
duplicable: true,
deletable: true,
+ // Unified slot model: one paintable floor surface with a declared default,
+ // painted through the registry `capabilities.paint` dispatch like the shelf.
+ slots: () => slabSlots(),
+ paint: slabPaint,
},
relations: {
diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts
index dd1a68080..6b3c241db 100644
--- a/packages/nodes/src/slab/geometry.ts
+++ b/packages/nodes/src/slab/geometry.ts
@@ -1,25 +1,37 @@
-import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
+import { type GeometryContext, getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
import {
applyMaterialPresetToMaterials,
type ColorPreset,
createDefaultMaterial,
createMaterial,
createSurfaceRoleMaterial,
- DEFAULT_SLAB_MATERIAL,
generateSlabGeometry,
type RenderShading,
+ resolveMaterialRef,
+ resolveSlotDefaultMaterial,
} from '@pascal-app/viewer'
-import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
+import {
+ BufferGeometry,
+ Float32BufferAttribute,
+ FrontSide,
+ Group,
+ type Material,
+ Mesh,
+ type Texture,
+ Vector3,
+} from 'three'
+import { SLAB_SIDE_SLOT_DEFAULT, SLAB_TOP_SLOT_DEFAULT, type SlabSlotId } from './slots'
/**
* Stage B builder for slab. Reuses `generateSlabGeometry` (pure
* triangulation + hole CSG from viewer) and the same material cache
* pattern the legacy slab renderer used.
*
- * Materials are cached by `{material, materialPreset}` signature so
- * slabs sharing settings share the GPU resource. Cached entry mutation
- * (preset apply) is preserved — async texture loads still update the
- * rendered material after re-mount.
+ * Materials follow the unified slot model: the single `surface` slot resolves
+ * `node.slots.surface` (a shared scene material or `library:` finish) → the
+ * legacy inline `node.material` / `materialPreset` (pre-slot-model scenes) →
+ * the declared slot default colour. Textures-off collapses to the themed
+ * `floor` role — the guaranteed monochrome escape hatch.
*/
type SlabMaterial = Material & {
alphaMap?: Texture | null
@@ -30,24 +42,98 @@ type SlabMaterial = Material & {
const slabMaterialCache = new Map()
-function getSlabMaterial(
+function getSlabSlotMaterial(
node: SlabNode,
+ slotId: SlabSlotId,
shading: RenderShading,
textures: boolean,
colorPreset: ColorPreset,
- sceneTheme?: string,
+ sceneTheme: string | undefined,
+ sceneMaterials: GeometryContext['materials'],
): Material {
- // Untextured slabs (and everything in textures-off mode) take the themed
- // 'floor' role colour. createSurfaceRoleMaterial returns a shared cached
- // material, so it is returned as-is without the mutation below.
- // FrontSide — DoubleSide on the role material's NodeMaterial poisons the
- // MRT scene pass (see `materials.ts` line 77 / glazing fix 9400f1c5).
- // Slab side faces still render correctly because `generateSlabGeometry`
- // produces outward-facing normals on the top, bottom, and perimeter.
- if (!textures || (!node.materialPreset && !node.material)) {
+ // Textures-off mode takes the themed 'floor' role colour for every face — the
+ // guaranteed escape hatch, independent of any slot override. FrontSide —
+ // DoubleSide on the role material's NodeMaterial poisons the MRT scene pass
+ // (see `materials.ts` line 77 / glazing fix 9400f1c5). Slab side faces still
+ // render correctly because `generateSlabGeometry` emits outward-facing normals.
+ if (!textures) {
return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme)
}
+ // Unified slot override — shared scene material or catalog `library:` finish.
+ const slotRef = node.slots?.[slotId]
+ if (slotRef) {
+ const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
+ if (resolved) return resolved
+ }
+
+ // Legacy inline material / preset (pre-slot-model scenes) applied to the whole
+ // slab — map it onto the top face only; sides take their own default.
+ if (slotId === 'surface' && (node.materialPreset || node.material)) {
+ return getLegacySlabMaterial(node, shading)
+ }
+
+ // Declared slot default — a catalog `library:` finish or a flat colour.
+ const slotDefault = slotId === 'side' ? SLAB_SIDE_SLOT_DEFAULT : SLAB_TOP_SLOT_DEFAULT
+ return resolveSlotDefaultMaterial(slotDefault, shading, 0.8)
+}
+
+// Split the merged slab buffer into top-facing (floor) and everything-else
+// (vertical walls + underside) sub-geometries by per-triangle face normal, so
+// the two paintable slots get distinct materials + raycast tags. De-indexes
+// into per-face triangles (slabs are flat-shaded, so no shared-vertex seams).
+function splitSlabFacesByFacing(geometry: BufferGeometry): {
+ top: BufferGeometry
+ side: BufferGeometry
+} {
+ const position = geometry.getAttribute('position')
+ const uv = geometry.getAttribute('uv')
+ const index = geometry.getIndex()
+ const triangleCount = index ? index.count / 3 : position.count / 3
+
+ const top = { pos: [] as number[], uv: [] as number[] }
+ const side = { pos: [] as number[], uv: [] as number[] }
+ const a = new Vector3()
+ const b = new Vector3()
+ const c = new Vector3()
+ const ab = new Vector3()
+ const ac = new Vector3()
+ const normal = new Vector3()
+
+ for (let t = 0; t < triangleCount; t += 1) {
+ const i0 = index ? index.getX(t * 3) : t * 3
+ const i1 = index ? index.getX(t * 3 + 1) : t * 3 + 1
+ const i2 = index ? index.getX(t * 3 + 2) : t * 3 + 2
+ a.fromBufferAttribute(position, i0)
+ b.fromBufferAttribute(position, i1)
+ c.fromBufferAttribute(position, i2)
+ ab.subVectors(b, a)
+ ac.subVectors(c, a)
+ normal.crossVectors(ab, ac)
+ const lengthSq = normal.lengthSq()
+ const isTop = lengthSq > 1e-12 && normal.y / Math.sqrt(lengthSq) > 0.5
+ const target = isTop ? top : side
+ for (const i of [i0, i1, i2]) {
+ target.pos.push(position.getX(i), position.getY(i), position.getZ(i))
+ if (uv) target.uv.push(uv.getX(i), uv.getY(i))
+ }
+ }
+
+ const build = (data: { pos: number[]; uv: number[] }) => {
+ const geo = new BufferGeometry()
+ geo.setAttribute('position', new Float32BufferAttribute(data.pos, 3))
+ if (data.uv.length > 0) geo.setAttribute('uv', new Float32BufferAttribute(data.uv, 2))
+ geo.computeVertexNormals()
+ return geo
+ }
+
+ return { top: build(top), side: build(side) }
+}
+
+function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material {
+ // Cached by `{material, materialPreset}` signature so slabs sharing settings
+ // share the GPU resource; cached entry mutation (preset apply) is preserved
+ // so async texture loads still update the rendered material after re-mount.
const cacheKey = JSON.stringify({
shading,
material: node.material ?? null,
@@ -61,7 +147,7 @@ function getSlabMaterial(
? createDefaultMaterial('#ffffff', 0.5, shading)
: node.material
? createMaterial(node.material, shading).clone()
- : DEFAULT_SLAB_MATERIAL(shading).clone()
+ : createDefaultMaterial('#e5e5e5', 0.8, shading)
if (preset) {
applyMaterialPresetToMaterials(material, preset)
@@ -84,20 +170,39 @@ function getSlabMaterial(
export function buildSlabGeometry(
node: SlabNode,
- _ctx?: unknown,
+ ctx?: GeometryContext,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Group {
const group = new Group()
- const geometry = generateSlabGeometry(node)
- const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme)
- const mesh = new Mesh(geometry, material)
- mesh.castShadow = true
- mesh.receiveShadow = true
+ const merged = generateSlabGeometry(node)
+ const { top, side } = splitSlabFacesByFacing(merged)
+ merged.dispose()
+
const elevation = node.elevation ?? 0.05
- if (elevation < 0) mesh.position.y = elevation
- group.add(mesh)
+ // One mesh per slot, each tagged with its slot id so the unified slot paint
+ // resolves the hit (`resolveRole` reads `userData.slotId`) and previews it.
+ for (const [slotId, geometry] of [
+ ['surface', top],
+ ['side', side],
+ ] as const) {
+ const material = getSlabSlotMaterial(
+ node,
+ slotId,
+ shading,
+ textures,
+ colorPreset,
+ sceneTheme,
+ ctx?.materials,
+ )
+ const mesh = new Mesh(geometry, material)
+ mesh.castShadow = true
+ mesh.receiveShadow = true
+ mesh.userData.slotId = slotId
+ if (elevation < 0) mesh.position.y = elevation
+ group.add(mesh)
+ }
return group
}
diff --git a/packages/nodes/src/slab/paint.ts b/packages/nodes/src/slab/paint.ts
new file mode 100644
index 000000000..628c75cc4
--- /dev/null
+++ b/packages/nodes/src/slab/paint.ts
@@ -0,0 +1,26 @@
+import type { AnyNode, SlabNode } from '@pascal-app/core'
+import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
+
+/**
+ * Slab paint on the unified slot model. A slab exposes two faces — `surface`
+ * (top) and `side` (walls + underside) — each its own mesh tagged with
+ * `userData.slotId`, so the clicked face resolves to its slot; commit writes
+ * `node.slots[slotId]` (a shared scene-material or `library:` ref) like the shelf.
+ */
+export const slabPaint = createSlotPaintCapability({
+ resolveRole: ({ hitObject }) => {
+ const slotId = (hitObject?.userData as { slotId?: string } | undefined)?.slotId
+ return slotId === 'side' ? 'side' : 'surface'
+ },
+ applyPreview: previewGeometrySlot,
+ // Legacy inline material applied to the whole slab → maps onto the top only;
+ // the side picker shows its own default.
+ legacyEffective: (node: AnyNode, role: string) => {
+ if (role !== 'surface') return null
+ const slab = node as SlabNode
+ if (slab.materialPreset || slab.material) {
+ return { material: slab.material, materialPreset: slab.materialPreset }
+ }
+ return null
+ },
+})
diff --git a/packages/nodes/src/slab/slots.ts b/packages/nodes/src/slab/slots.ts
new file mode 100644
index 000000000..bdb2c8082
--- /dev/null
+++ b/packages/nodes/src/slab/slots.ts
@@ -0,0 +1,25 @@
+import type { SlotDeclaration } from '@pascal-app/core'
+
+export type SlabSlotId = 'surface' | 'side'
+
+// Declared default appearances for an unpainted slab in colored mode — a
+// catalog `library:` finish or a `#rrggbb` colour. Textures-off collapses
+// both to the themed floor role (the escape hatch).
+//
+// `surface` (top face) keeps the wood floor default and the slot id used before
+// the top/side split, so existing painted slabs keep their floor finish. `side`
+// (walls + underside) defaults to a light grey so a slab's edges read as a
+// distinct trim rather than wood end-grain.
+export const SLAB_TOP_SLOT_DEFAULT = 'library:wood-woodplank48'
+export const SLAB_SIDE_SLOT_DEFAULT = '#cccccc'
+
+/**
+ * A slab exposes two paintable faces: the top floor surface and its sides
+ * (vertical walls + underside).
+ */
+export function slabSlots(): SlotDeclaration[] {
+ return [
+ { slotId: 'surface', label: 'Top', default: SLAB_TOP_SLOT_DEFAULT },
+ { slotId: 'side', label: 'Sides', default: SLAB_SIDE_SLOT_DEFAULT },
+ ]
+}
diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts
index a6bf6218b..200b86821 100644
--- a/packages/nodes/src/stair/definition.ts
+++ b/packages/nodes/src/stair/definition.ts
@@ -406,8 +406,10 @@ import {
stairRotateAffordance,
} from './floorplan-affordances'
import { stairFloorplanMoveTarget } from './floorplan-move'
+import { stairPaint } from './paint'
import { stairParametrics } from './parametrics'
import { StairNode } from './schema'
+import { stairSlots } from './slots'
/**
* Stair — Stage A. Composite node like roof: owns overall framing,
@@ -444,6 +446,8 @@ export const stairDefinition: NodeDefinition = {
footprints: (node, ctx) =>
ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [],
},
+ slots: (node) => stairSlots(node as StairNodeType),
+ paint: stairPaint,
},
// Bespoke move shared with roof / roof-segment / stair-segment via
diff --git a/packages/nodes/src/stair/paint.ts b/packages/nodes/src/stair/paint.ts
new file mode 100644
index 000000000..09b3de8bc
--- /dev/null
+++ b/packages/nodes/src/stair/paint.ts
@@ -0,0 +1,100 @@
+import type { AnyNode, PaintPreviewArgs, PaintResolveArgs, StairNode } from '@pascal-app/core'
+import type { Mesh, Object3D } from 'three'
+import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint'
+import type { StairSlotId } from './slots'
+
+function isStairSlotId(value: unknown): value is StairSlotId {
+ return value === 'treads' || value === 'body' || value === 'railing'
+}
+
+function resolveStairPaintRole(args: PaintResolveArgs): StairSlotId | null {
+ const userData = args.hitObject?.userData as { slotId?: unknown; slotIds?: unknown } | undefined
+
+ if (isStairSlotId(userData?.slotId)) {
+ return userData.slotId
+ }
+
+ if (Array.isArray(userData?.slotIds)) {
+ const slotId = userData.slotIds[args.materialIndex ?? 0]
+ return isStairSlotId(slotId) ? slotId : null
+ }
+
+ return null
+}
+
+function previewStairSlot(args: PaintPreviewArgs): (() => void) | null {
+ const { role, root, material, materialPreset } = args
+ if (!isStairSlotId(role)) return null
+
+ const preview = buildSlotPreviewMaterial(material, materialPreset)
+ if (!preview) return () => {}
+
+ const restores: Array<() => void> = []
+ ;(root as Object3D).traverse((object) => {
+ const mesh = object as Mesh
+ if (!mesh.isMesh) return
+
+ const userData = mesh.userData as { slotId?: unknown; slotIds?: unknown }
+ if (userData.slotId === role) {
+ const previous = mesh.material
+ mesh.material = preview
+ restores.push(() => {
+ mesh.material = previous
+ })
+ return
+ }
+
+ if (!Array.isArray(userData.slotIds)) return
+ const materialIndex = userData.slotIds.findIndex((slotId) => slotId === role)
+ if (materialIndex < 0) return
+ if (!Array.isArray(mesh.material)) return
+
+ const previous = mesh.material
+ const next = previous.slice()
+ next[materialIndex] = preview
+ mesh.material = next
+ restores.push(() => {
+ mesh.material = previous
+ })
+ })
+
+ if (restores.length === 0) return null
+ return () => {
+ for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.()
+ }
+}
+
+function legacyEffective(node: AnyNode, role: string) {
+ if (!isStairSlotId(role)) return null
+
+ const stair = node as StairNode
+ const perSlot =
+ role === 'treads'
+ ? { material: stair.treadMaterial, materialPreset: stair.treadMaterialPreset }
+ : role === 'body'
+ ? { material: stair.sideMaterial, materialPreset: stair.sideMaterialPreset }
+ : { material: stair.railingMaterial, materialPreset: stair.railingMaterialPreset }
+
+ if (perSlot.material !== undefined || typeof perSlot.materialPreset === 'string') {
+ return {
+ material: perSlot.material,
+ materialPreset:
+ typeof perSlot.materialPreset === 'string' ? perSlot.materialPreset : undefined,
+ }
+ }
+
+ if (stair.material !== undefined || typeof stair.materialPreset === 'string') {
+ return {
+ material: stair.material,
+ materialPreset: typeof stair.materialPreset === 'string' ? stair.materialPreset : undefined,
+ }
+ }
+
+ return null
+}
+
+export const stairPaint = createSlotPaintCapability({
+ resolveRole: resolveStairPaintRole,
+ applyPreview: previewStairSlot,
+ legacyEffective,
+})
diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx
index ec928e186..c038c9194 100644
--- a/packages/nodes/src/stair/renderer.tsx
+++ b/packages/nodes/src/stair/renderer.tsx
@@ -9,13 +9,11 @@ import {
useScene,
} from '@pascal-app/core'
import {
- createMaterial,
- createMaterialFromPresetRef,
- createSurfaceRoleMaterial,
- DEFAULT_STAIR_MATERIAL,
getStairBodyMaterials,
getStairRailingMaterial,
NodeRenderer,
+ resolveMaterialRef,
+ resolveSlotDefaultMaterial,
type StairBodyMaterials,
useNodeEvents,
useViewer,
@@ -23,6 +21,12 @@ import {
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
+import {
+ STAIR_BODY_SLOT_DEFAULT,
+ STAIR_RAILING_SLOT_DEFAULT,
+ STAIR_TREADS_SLOT_DEFAULT,
+ type StairSlotId,
+} from './slots'
type SegmentTransform = {
position: [number, number, number]
@@ -78,36 +82,57 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
+ const sceneMaterials = useScene((s) => s.materials)
- const material = useMemo(() => {
- if (!textures) return createSurfaceRoleMaterial('joinery', colorPreset)
- const presetMaterial = createMaterialFromPresetRef(node.materialPreset, shading)
- if (presetMaterial) return presetMaterial
- const mat = node.material
- if (!mat) return DEFAULT_STAIR_MATERIAL(shading)
- return createMaterial(mat, shading)
- }, [
- shading,
- node.materialPreset,
- node.material,
- node.material?.preset,
- node.material?.properties,
- node.material?.texture,
- textures,
- colorPreset,
- ])
-
- const straightBodyMaterials = useMemo(
+ const baseBodyMaterials = useMemo(
() => getStairBodyMaterials(node, shading, textures, colorPreset),
[node, shading, textures, colorPreset],
)
- const railingMaterial = useMemo(
+ const bodyMaterials = useMemo(
+ () => [
+ resolveStairSlotMaterial(
+ node,
+ 'treads',
+ STAIR_TREADS_SLOT_DEFAULT,
+ baseBodyMaterials[STAIR_TREAD_MATERIAL_INDEX],
+ sceneMaterials,
+ shading,
+ textures,
+ ),
+ resolveStairSlotMaterial(
+ node,
+ 'body',
+ STAIR_BODY_SLOT_DEFAULT,
+ baseBodyMaterials[STAIR_SIDE_MATERIAL_INDEX],
+ sceneMaterials,
+ shading,
+ textures,
+ ),
+ ],
+ [baseBodyMaterials, node, sceneMaterials, shading, textures],
+ )
+
+ const baseRailingMaterial = useMemo(
() => getStairRailingMaterial(node, shading, textures, colorPreset),
[node, shading, textures, colorPreset],
)
- // 2 groups map 1:1 to the stair body's 2-material array (body + tread).
+ const railingMaterial = useMemo(
+ () =>
+ resolveStairSlotMaterial(
+ node,
+ 'railing',
+ STAIR_RAILING_SLOT_DEFAULT,
+ baseRailingMaterial,
+ sceneMaterials,
+ shading,
+ textures,
+ ),
+ [baseRailingMaterial, node, sceneMaterials, shading, textures],
+ )
+
+ // 2 groups map 1:1 to the stair body's 2-material array (treads + body).
const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
useEffect(() => {
@@ -129,14 +154,13 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
) : null}
- {isSegmentBasedStair ? null : (
-
- )}
+ {isSegmentBasedStair ? null : }
{isSegmentBasedStair ? (
@@ -235,6 +259,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
+ userData={STAIR_RAILING_SLOT_USER_DATA}
/>
))}
{sidePoints.slice(0, -1).map((point, pointIndex) => {
@@ -293,6 +318,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
+ userData={STAIR_RAILING_SLOT_USER_DATA}
/>
))}
{sidePath.points.slice(0, -1).map((point, pointIndex) => {
@@ -398,6 +424,47 @@ const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
+const STAIR_BODY_SLOT_IDS: StairSlotId[] = ['treads', 'body']
+const STAIR_BODY_SLOT_USER_DATA = { slotIds: STAIR_BODY_SLOT_IDS }
+const STAIR_BODY_SINGLE_SLOT_USER_DATA = { slotId: 'body' satisfies StairSlotId }
+const STAIR_RAILING_SLOT_USER_DATA = { slotId: 'railing' satisfies StairSlotId }
+
+type SceneMaterials = Parameters[1]
+type ViewerShading = Parameters[2]
+
+function hasMaterialSpec(material: unknown, materialPreset: unknown): boolean {
+ return material !== undefined || typeof materialPreset === 'string'
+}
+
+function hasLegacyStairSlotMaterial(node: StairNode, slotId: StairSlotId): boolean {
+ const hasWhole = hasMaterialSpec(node.material, node.materialPreset)
+ const hasTread = hasMaterialSpec(node.treadMaterial, node.treadMaterialPreset)
+ const hasSide = hasMaterialSpec(node.sideMaterial, node.sideMaterialPreset)
+ const hasRailing = hasMaterialSpec(node.railingMaterial, node.railingMaterialPreset)
+
+ if (slotId === 'treads') return hasTread || hasSide || hasWhole
+ if (slotId === 'body') return hasSide || hasTread || hasWhole
+ return hasRailing || hasTread || hasSide || hasWhole
+}
+
+function resolveStairSlotMaterial(
+ node: StairNode,
+ slotId: StairSlotId,
+ defaultRef: string,
+ baseMaterial: THREE.Material,
+ sceneMaterials: SceneMaterials,
+ shading: ViewerShading,
+ textures: boolean,
+): THREE.Material {
+ if (!textures) return baseMaterial
+
+ const slotMaterial = resolveMaterialRef(node.slots?.[slotId], sceneMaterials, shading)
+ if (slotMaterial) return slotMaterial
+
+ if (hasLegacyStairSlotMaterial(node, slotId)) return baseMaterial
+
+ return resolveSlotDefaultMaterial(defaultRef, shading)
+}
function RailSegment({
start,
@@ -437,6 +504,7 @@ function RailSegment({
quaternion={quaternion}
receiveShadow
scale={[Math.max(radius, 0.01), length, Math.max(radius, 0.01)]}
+ userData={STAIR_RAILING_SLOT_USER_DATA}
/>
)
}
@@ -591,7 +659,14 @@ function CurvedStepMesh({
)
return (
-
+
)
}
@@ -630,6 +705,7 @@ function SpiralColumnMesh({
name="stair-side"
position={[0, height / 2, 0]}
receiveShadow
+ userData={STAIR_BODY_SINGLE_SLOT_USER_DATA}
/>
)
}
@@ -674,6 +750,7 @@ function SpiralStepSupportMesh({
position={[Math.cos(midAngle) * radial, sizeY / 2, Math.sin(midAngle) * radial]}
receiveShadow
rotation-y={-midAngle}
+ userData={STAIR_BODY_SINGLE_SLOT_USER_DATA}
/>
)
}
diff --git a/packages/nodes/src/stair/slots.ts b/packages/nodes/src/stair/slots.ts
new file mode 100644
index 000000000..fdbb5eca5
--- /dev/null
+++ b/packages/nodes/src/stair/slots.ts
@@ -0,0 +1,20 @@
+import type { SlotDeclaration, StairNode } from '@pascal-app/core'
+
+export type StairSlotId = 'treads' | 'body' | 'railing'
+
+export const STAIR_TREADS_SLOT_DEFAULT = 'library:wood-woodplank48'
+export const STAIR_BODY_SLOT_DEFAULT = 'library:preset-lightgrey'
+export const STAIR_RAILING_SLOT_DEFAULT = 'library:metal-steel'
+
+export function stairSlots(node: StairNode): SlotDeclaration[] {
+ const slots: SlotDeclaration[] = [
+ { slotId: 'treads', label: 'Treads', default: STAIR_TREADS_SLOT_DEFAULT },
+ { slotId: 'body', label: 'Body', default: STAIR_BODY_SLOT_DEFAULT },
+ ]
+
+ if (node.railingMode && node.railingMode !== 'none') {
+ slots.push({ slotId: 'railing', label: 'Railing', default: STAIR_RAILING_SLOT_DEFAULT })
+ }
+
+ return slots
+}
diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts
index 2f29b9ad7..473568502 100644
--- a/packages/nodes/src/wall/definition.ts
+++ b/packages/nodes/src/wall/definition.ts
@@ -6,6 +6,7 @@ import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
import { wallPaint } from './paint'
import { wallParametrics } from './parametrics'
import { WallNode } from './schema'
+import { wallSlots } from './slots'
/**
* Wall — the Phase 3 stress test of the registry-driven node model.
@@ -56,6 +57,11 @@ export const wallDefinition: NodeDefinition = {
// preview through this entry rather than carrying a kind-name
// arm.
paint: wallPaint,
+ // Declared paintable slots (interior / exterior) with their default
+ // appearance — the same `{ slotId, label, default }` contract every other
+ // paintable kind exposes. Paint still writes the legacy inline fields via
+ // `wallPaint`; migrating those into `node.slots` is a later step.
+ slots: () => wallSlots(),
},
relations: {
diff --git a/packages/nodes/src/wall/move-shared.ts b/packages/nodes/src/wall/move-shared.ts
index 7cb591d53..aebea59fe 100644
--- a/packages/nodes/src/wall/move-shared.ts
+++ b/packages/nodes/src/wall/move-shared.ts
@@ -2,7 +2,9 @@ import {
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
getMaterialPresetByRef,
+ parseMaterialRef,
resolveMaterial,
+ type SceneMaterialId,
useScene,
type WallMoveBridgePlan,
type WallNode,
@@ -109,7 +111,25 @@ function wallSegmentExists(
)
}
+// Resolve a wall slot ref (`library:`/`scene:`) to a swatch colour, or
+// undefined when the ref is absent / dangling / colourless.
+function resolveWallSlotRefColor(ref: string | undefined): string | undefined {
+ const parsed = parseMaterialRef(ref)
+ if (!parsed) return undefined
+ if (parsed.kind === 'library') {
+ return getMaterialPresetByRef(ref)?.mapProperties.color ?? undefined
+ }
+ const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
+ return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : undefined
+}
+
export function getWallGhostColor(wall: WallNode) {
+ const slotColor =
+ resolveWallSlotRefColor(wall.slots?.interior) ?? resolveWallSlotRefColor(wall.slots?.exterior)
+ if (slotColor) {
+ return slotColor
+ }
+
const presetColor =
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts
index a8485944b..d4f5cfa43 100644
--- a/packages/nodes/src/wall/paint.ts
+++ b/packages/nodes/src/wall/paint.ts
@@ -1,14 +1,15 @@
import {
+ type AnyNode,
type AnyNodeId,
getEffectiveWallSurfaceMaterial,
- type MaterialSchema,
type PaintCapability,
+ type PaintPreviewArgs,
sceneRegistry,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
-import { getVisibleWallMaterials } from '@pascal-app/viewer'
import type { Material, Mesh } from 'three'
+import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint'
/**
* Resolve which side of a wall the user clicked. Walls expose two
@@ -56,81 +57,59 @@ export function resolveWallRole(args: {
return hitFace === 'front' ? 'interior' : 'exterior'
}
-export function buildWallSurfaceMaterialPatch(
- node: WallNode,
- targetSide: WallSurfaceSide,
- material: MaterialSchema | undefined,
- materialPreset: string | undefined,
-): Partial {
- const nextSurfaceMaterial = { material, materialPreset }
- const nextInterior =
- targetSide === 'interior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'interior')
- const nextExterior =
- targetSide === 'exterior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'exterior')
-
- return {
- interiorMaterial: nextInterior.material,
- interiorMaterialPreset: nextInterior.materialPreset,
- exteriorMaterial: nextExterior.material,
- exteriorMaterialPreset: nextExterior.materialPreset,
- material: undefined,
- materialPreset: undefined,
- }
+// The wall's 3-material array maps side → group index (see
+// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior.
+const WALL_SIDE_MATERIAL_INDEX: Record = {
+ interior: 1,
+ exterior: 2,
}
/**
- * Apply a preview to the wall's registered mesh by synthesising the
- * post-paint node, asking the viewer's `getVisibleWallMaterials` for
- * the corresponding material array, and swapping the mesh's
- * material assignment until the editor calls the returned cleanup.
+ * Preview a wall paint by swapping just the painted face's entry in the wall
+ * mesh's material array. The array is the shared cached `WallMaterials.visible`,
+ * so we clone it before swapping and restore the original reference on cleanup
+ * (never mutate the cache).
*/
-function applyWallPreview(
- node: WallNode,
- role: WallSurfaceSide,
- material: MaterialSchema | undefined,
- materialPreset: string | undefined,
-): (() => void) | null {
- const mesh = sceneRegistry.nodes.get(node.id as AnyNodeId)
+function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
+ const { role, material, materialPreset } = args
+ const side = role as WallSurfaceSide
+ const index = WALL_SIDE_MATERIAL_INDEX[side]
+ if (!index) return null
+
+ const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
if (!(mesh && (mesh as Mesh).isMesh)) return null
const wallMesh = mesh as Mesh
- const previewNode: WallNode = {
- ...node,
- ...buildWallSurfaceMaterialPatch(node, role, material, materialPreset),
- }
- const nextMaterial = getVisibleWallMaterials(previewNode)
- if (!nextMaterial) return null
+ const current = wallMesh.material
+ if (!Array.isArray(current)) return null
+
+ const preview = buildSlotPreviewMaterial(material, materialPreset)
+ if (!preview) return () => {}
+
+ const previous = current as Material[]
+ const next = previous.slice()
+ next[index] = preview
+ wallMesh.material = next
- const previousMaterial = wallMesh.material as Material | Material[]
- wallMesh.material = nextMaterial
return () => {
- wallMesh.material = previousMaterial
+ wallMesh.material = previous
}
}
/**
- * Capability binding for the wall kind. The editor's
- * selection-manager invokes these in place of the legacy
- * `if (node.type === 'wall') { ... }` arms.
+ * Capability binding for the wall kind on the unified slot model. Painting
+ * writes `node.slots[interior|exterior]` (a `library:` ref or a minted
+ * `scene:` material) exactly like every other kind; `legacyEffective` reads
+ * the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the
+ * picker still shows the current value on a pre-migration scene.
*/
-export const wallPaint: PaintCapability = {
+export const wallPaint: PaintCapability = createSlotPaintCapability({
resolveRole: ({ node, materialIndex, normal, localPosition }) =>
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
- buildPatch: ({ node, role, material, materialPreset }) =>
- buildWallSurfaceMaterialPatch(
- node as WallNode,
- role as WallSurfaceSide,
- material,
- materialPreset,
- ),
- applyPreview: ({ node, role, material, materialPreset }) =>
- applyWallPreview(node as WallNode, role as WallSurfaceSide, material, materialPreset),
- getEffectiveMaterial: ({ node, role }) => {
+ applyPreview: applyWallPreview,
+ legacyEffective: (node: AnyNode, role: string) => {
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide)
+ if (spec.material === undefined && spec.materialPreset === undefined) return null
return { material: spec.material, materialPreset: spec.materialPreset }
},
-}
+})
diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx
index d2d9f152b..856f0609b 100644
--- a/packages/nodes/src/wall/renderer.tsx
+++ b/packages/nodes/src/wall/renderer.tsx
@@ -49,7 +49,19 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
- const material = getVisibleWallMaterials(node, shading, textures, colorPreset, sceneTheme)
+ // Subscribe to the scene-material palette so editing a `scene:` material a
+ // wall slot references re-renders the wall live (the wall-system geometry
+ // dirty loop never fires for a material-only edit). `getMaterialsForWall`'s
+ // content hash keeps unaffected walls on their cached materials.
+ const sceneMaterials = useScene((s) => s.materials)
+ const material = getVisibleWallMaterials(
+ node,
+ shading,
+ textures,
+ colorPreset,
+ sceneTheme,
+ sceneMaterials,
+ )
return (
= {
// `wallId` / `roofSegmentId` are re-derived from the surface under
// the cursor at preset placement time — see door for the pattern.
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
+ // Frame / glass slots painted through the registry. The window system tags
+ // each mesh with its `userData.slotId`; paint writes `node.slots`.
+ slots: () => windowSlots(),
+ paint: windowPaint,
},
parametrics: windowParametrics,
diff --git a/packages/nodes/src/window/paint.ts b/packages/nodes/src/window/paint.ts
new file mode 100644
index 000000000..ca5120d28
--- /dev/null
+++ b/packages/nodes/src/window/paint.ts
@@ -0,0 +1,16 @@
+import {
+ createSlotPaintCapability,
+ previewSlotByUserData,
+ resolveSlotByReRaycast,
+} from '../shared/slot-paint'
+
+/**
+ * Window paint on the unified slot model. The window's opening proxy (a proud,
+ * invisible cutout) wins the shared scene raycast over the wall in front of the
+ * recessed window, so `resolveSlotByReRaycast` re-raycasts the window's own
+ * subtree to find the part (frame / glass) under the cursor.
+ */
+export const windowPaint = createSlotPaintCapability({
+ resolveRole: resolveSlotByReRaycast,
+ applyPreview: previewSlotByUserData,
+})
diff --git a/packages/nodes/src/window/slots.ts b/packages/nodes/src/window/slots.ts
new file mode 100644
index 000000000..4067d4d49
--- /dev/null
+++ b/packages/nodes/src/window/slots.ts
@@ -0,0 +1,16 @@
+import type { SlotDeclaration } from '@pascal-app/core'
+
+export type WindowSlotId = 'frame' | 'glass'
+
+// Picker swatches. Rendering falls back to the live frame/glass defaults (which
+// already track shading + theme), so these are just the indicator colours.
+const FRAME_DEFAULT = 'library:preset-softwhite'
+const GLASS_DEFAULT = 'library:preset-glass'
+
+/** A window exposes two paintable slots: the joinery frame and the glass. */
+export function windowSlots(): SlotDeclaration[] {
+ return [
+ { slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT },
+ { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT },
+ ]
+}
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index f4450ab96..6e257b525 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -13,6 +13,7 @@ import * as THREE from 'three/webgpu'
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { applyIsolation, clearIsolation } from '../../lib/isolation'
+import { ensureKtx2Support } from '../../lib/ktx2-loader'
import type { ColorPreset, RenderShading } from '../../lib/materials'
import { getSceneTheme } from '../../lib/scene-themes'
import useViewer, { type RenderContext } from '../../store/use-viewer'
@@ -144,6 +145,11 @@ function GPUDeviceWatcher() {
const gl = useThree((s) => s.gl)
useEffect(() => {
+ // Detect KTX2 transcode support as soon as the renderer exists, so catalog
+ // `.ktx2` finish textures load even in scenes with no GLB items (whose
+ // loader would otherwise be the only thing to call this).
+ ensureKtx2Support(gl)
+
const backend = (gl as any).backend
const device = backend?.device as WebGPUDeviceLike | undefined
diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx
index 767832ed0..68b4041ba 100644
--- a/packages/viewer/src/components/viewer/post-processing.tsx
+++ b/packages/viewer/src/components/viewer/post-processing.tsx
@@ -284,7 +284,6 @@ const PostProcessingPasses = ({
denoise: denoiseEnabled,
outline: outlineEnabled,
perfDisable,
- hoverHighlightMode,
projectId,
shading,
transparentBackground,
@@ -511,9 +510,12 @@ const PostProcessingPasses = ({
renderPipelineRef.current = null
}
}, [
+ // NOTE: hoverHighlightMode intentionally excluded — the hover style is
+ // pushed to uniforms in a separate effect, so a hover must NOT rebuild the
+ // whole pipeline. The uniform refs below are stable (useMemo), so they
+ // never trigger a rebuild either.
camera,
hoverHiddenColor,
- hoverHighlightMode,
hoverPulseMix,
hoverStrength,
hoverVisibleColor,
diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx
new file mode 100644
index 000000000..1834c8f3e
--- /dev/null
+++ b/packages/viewer/src/components/viewer/scene-environment.tsx
@@ -0,0 +1,22 @@
+'use client'
+
+import { Environment } from '@react-three/drei'
+import { Suspense } from 'react'
+
+/**
+ * Scene IBL — drei's prefiltered environment map, exported as an opt-in
+ * *child* rather than baked into the Viewer component, so embed /
+ * thumbnail surfaces that don't want the HDRI fetch simply don't mount it.
+ * This is what gives PBR metals their reflections and lifts the lighting on
+ * vertical surfaces (walls), which flat directional + hemisphere lights can't
+ * do alone. Intensity is dialled below the preset default so it complements
+ * the scene lights rather than washing them out. Only visible in `rendered`
+ * shading.
+ */
+export function SceneEnvironment() {
+ return (
+
+
+
+ )
+}
diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx
index 040beaf7e..000b8ff6c 100644
--- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx
+++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx
@@ -1,38 +1,16 @@
import { useGLTF } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
-import { KTX2Loader } from 'three/examples/jsm/Addons.js'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
-
-const ktx2LoaderInstance = new KTX2Loader()
-ktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/')
-const ktx2ConfiguredRenderers = new WeakSet