feat(paint-slots): migrate stair onto the unified slot model

Stair exposes three paintable slots — treads / body / railing (railing only
when railingMode != none) — on node.slots. The 2-material-index body mesh
maps materialIndex 0->treads, 1->body via userData.slotIds; railing meshes
tag userData.slotId='railing'. Per-slot resolution layers over the viewer's
base body/railing materials only in textures-on mode: node.slots ref ->
legacy per-part field (preserved) -> declared default (treads wood-woodplank48,
body wood-woodfine2, railing metal-steel). A custom preview swaps the targeted
body material-array index and whole-mesh railing materials. Monochrome
unchanged. Stops using DEFAULT_STAIR_MATERIAL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-17 15:33:42 -04:00
co-authored by Claude Opus 4.8
parent a8f32dd47a
commit 16c117142f
4 changed files with 233 additions and 28 deletions
+4
View File
@@ -407,7 +407,9 @@ import {
} from './floorplan-affordances' } from './floorplan-affordances'
import { stairFloorplanMoveTarget } from './floorplan-move' import { stairFloorplanMoveTarget } from './floorplan-move'
import { stairParametrics } from './parametrics' import { stairParametrics } from './parametrics'
import { stairPaint } from './paint'
import { StairNode } from './schema' import { StairNode } from './schema'
import { stairSlots } from './slots'
/** /**
* Stair — Stage A. Composite node like roof: owns overall framing, * Stair — Stage A. Composite node like roof: owns overall framing,
@@ -444,6 +446,8 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
footprints: (node, ctx) => footprints: (node, ctx) =>
ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [], 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 // Bespoke move shared with roof / roof-segment / stair-segment via
+102
View File
@@ -0,0 +1,102 @@
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,
})
+107 -28
View File
@@ -9,13 +9,11 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
DEFAULT_STAIR_MATERIAL,
getStairBodyMaterials, getStairBodyMaterials,
getStairRailingMaterial, getStairRailingMaterial,
NodeRenderer, NodeRenderer,
resolveMaterialRef,
resolveSlotDefaultMaterial,
type StairBodyMaterials, type StairBodyMaterials,
useNodeEvents, useNodeEvents,
useViewer, useViewer,
@@ -23,6 +21,12 @@ import {
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { createPlaceholderGeometry } from '../shared/placeholder-geometry' 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 = { type SegmentTransform = {
position: [number, number, number] position: [number, number, number]
@@ -78,36 +82,57 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
const shading = useViewer((s) => s.shading) const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures) const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset) const colorPreset = useViewer((s) => s.colorPreset)
const sceneMaterials = useScene((s) => s.materials)
const material = useMemo(() => { const baseBodyMaterials = 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(
() => getStairBodyMaterials(node, shading, textures, colorPreset), () => getStairBodyMaterials(node, shading, textures, colorPreset),
[node, shading, textures, colorPreset], [node, shading, textures, colorPreset],
) )
const railingMaterial = useMemo( const bodyMaterials = useMemo<StairBodyMaterials>(
() => [
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), () => getStairRailingMaterial(node, shading, textures, colorPreset),
[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), []) const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
useEffect(() => { useEffect(() => {
@@ -129,13 +154,14 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
<mesh <mesh
castShadow castShadow
geometry={straightPlaceholderGeometry} geometry={straightPlaceholderGeometry}
material={straightBodyMaterials} material={bodyMaterials}
name="merged-stair" name="merged-stair"
receiveShadow receiveShadow
userData={STAIR_BODY_SLOT_USER_DATA}
/> />
) : null} ) : null}
{isSegmentBasedStair ? null : ( {isSegmentBasedStair ? null : (
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> <CurvedStairBody bodyMaterials={bodyMaterials} stair={node} />
)} )}
<StairRailings material={railingMaterial} stair={node} /> <StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
@@ -235,6 +261,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
position={[point[0], point[1] + railHeight / 2, point[2]]} position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]} scale={[balusterRadius, railHeight, balusterRadius]}
userData={STAIR_RAILING_SLOT_USER_DATA}
/> />
))} ))}
{sidePoints.slice(0, -1).map((point, pointIndex) => { {sidePoints.slice(0, -1).map((point, pointIndex) => {
@@ -293,6 +320,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
position={[point[2], point[1] + railHeight / 2, point[0]]} position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]} scale={[balusterRadius, railHeight, balusterRadius]}
userData={STAIR_RAILING_SLOT_USER_DATA}
/> />
))} ))}
{sidePath.points.slice(0, -1).map((point, pointIndex) => { {sidePath.points.slice(0, -1).map((point, pointIndex) => {
@@ -398,6 +426,47 @@ const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const RAIL_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_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1 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<typeof resolveMaterialRef>[1]
type ViewerShading = Parameters<typeof resolveMaterialRef>[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({ function RailSegment({
start, start,
@@ -437,6 +506,7 @@ function RailSegment({
quaternion={quaternion} quaternion={quaternion}
receiveShadow receiveShadow
scale={[Math.max(radius, 0.01), length, Math.max(radius, 0.01)]} scale={[Math.max(radius, 0.01), length, Math.max(radius, 0.01)]}
userData={STAIR_RAILING_SLOT_USER_DATA}
/> />
) )
} }
@@ -591,7 +661,14 @@ function CurvedStepMesh({
) )
return ( return (
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow /> <mesh
castShadow
geometry={geometry}
material={material}
position-y={positionY}
receiveShadow
userData={STAIR_BODY_SLOT_USER_DATA}
/>
) )
} }
@@ -630,6 +707,7 @@ function SpiralColumnMesh({
name="stair-side" name="stair-side"
position={[0, height / 2, 0]} position={[0, height / 2, 0]}
receiveShadow receiveShadow
userData={STAIR_BODY_SINGLE_SLOT_USER_DATA}
/> />
) )
} }
@@ -674,6 +752,7 @@ function SpiralStepSupportMesh({
position={[Math.cos(midAngle) * radial, sizeY / 2, Math.sin(midAngle) * radial]} position={[Math.cos(midAngle) * radial, sizeY / 2, Math.sin(midAngle) * radial]}
receiveShadow receiveShadow
rotation-y={-midAngle} rotation-y={-midAngle}
userData={STAIR_BODY_SINGLE_SLOT_USER_DATA}
/> />
) )
} }
+20
View File
@@ -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:wood-woodfine2'
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
}