From 16c117142fca7595e266d0e3373b593238178916 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 17 Jun 2026 15:33:42 -0400 Subject: [PATCH] feat(paint-slots): migrate stair onto the unified slot model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/nodes/src/stair/definition.ts | 4 + packages/nodes/src/stair/paint.ts | 102 +++++++++++++++++++ packages/nodes/src/stair/renderer.tsx | 135 ++++++++++++++++++++----- packages/nodes/src/stair/slots.ts | 20 ++++ 4 files changed, 233 insertions(+), 28 deletions(-) create mode 100644 packages/nodes/src/stair/paint.ts create mode 100644 packages/nodes/src/stair/slots.ts diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index 4cb01637..25400b25 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -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 = { 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 00000000..e3bc0037 --- /dev/null +++ b/packages/nodes/src/stair/paint.ts @@ -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, +}) diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index ec928e18..090a00c6 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,13 +154,14 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { ) : null} {isSegmentBasedStair ? null : ( - + )} {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[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 +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 ( - + ) } @@ -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} /> ) } diff --git a/packages/nodes/src/stair/slots.ts b/packages/nodes/src/stair/slots.ts new file mode 100644 index 00000000..876d26a1 --- /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: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 +}