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:
co-authored by
Claude Opus 4.8
parent
a8f32dd47a
commit
16c117142f
@@ -407,7 +407,9 @@ import {
|
||||
} from './floorplan-affordances'
|
||||
import { stairFloorplanMoveTarget } from './floorplan-move'
|
||||
import { stairParametrics } from './parametrics'
|
||||
import { stairPaint } from './paint'
|
||||
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<typeof StairNode> = {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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<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),
|
||||
[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,13 +154,14 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={straightPlaceholderGeometry}
|
||||
material={straightBodyMaterials}
|
||||
material={bodyMaterials}
|
||||
name="merged-stair"
|
||||
receiveShadow
|
||||
userData={STAIR_BODY_SLOT_USER_DATA}
|
||||
/>
|
||||
) : null}
|
||||
{isSegmentBasedStair ? null : (
|
||||
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
|
||||
<CurvedStairBody bodyMaterials={bodyMaterials} stair={node} />
|
||||
)}
|
||||
<StairRailings material={railingMaterial} stair={node} />
|
||||
{isSegmentBasedStair ? (
|
||||
@@ -235,6 +261,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 +320,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 +426,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<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({
|
||||
start,
|
||||
@@ -437,6 +506,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 +661,14 @@ function CurvedStepMesh({
|
||||
)
|
||||
|
||||
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"
|
||||
position={[0, height / 2, 0]}
|
||||
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]}
|
||||
receiveShadow
|
||||
rotation-y={-midAngle}
|
||||
userData={STAIR_BODY_SINGLE_SLOT_USER_DATA}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user