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'
|
} 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
|
||||||
|
|||||||
@@ -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,
|
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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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