Add click-targeted stair materials and UV mapping

This commit is contained in:
sudhir
2026-04-20 12:01:23 +05:30
parent df9c4e499f
commit 4f9b822e54
10 changed files with 698 additions and 58 deletions
+2
View File
@@ -50,12 +50,14 @@ export { ScanNode } from './nodes/scan'
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export { export {
getEffectiveStairSurfaceMaterial,
StairNode, StairNode,
StairRailingMode, StairRailingMode,
StairSlabOpeningMode, StairSlabOpeningMode,
StairTopLandingMode, StairTopLandingMode,
StairType, StairType,
} from './nodes/stair' } from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
+84 -2
View File
@@ -1,7 +1,7 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { StairSegmentNode } from './stair-segment' import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
@@ -13,12 +13,23 @@ export type StairRailingMode = z.infer<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType> export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode> export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode> export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
export type StairSurfaceMaterialSpec = {
material?: MaterialSchema
materialPreset?: string
}
export const StairNode = BaseNode.extend({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('stair'), type: nodeType('stair'),
material: MaterialSchema.optional(), material: MaterialSchemaSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
railingMaterial: MaterialSchemaSchema.optional(),
railingMaterialPreset: z.string().optional(),
treadMaterial: MaterialSchemaSchema.optional(),
treadMaterialPreset: z.string().optional(),
sideMaterial: MaterialSchemaSchema.optional(),
sideMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
@@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({
) )
export type StairNode = z.infer<typeof StairNode> export type StairNode = z.infer<typeof StairNode>
function getLegacyStairSurfaceMaterial(node: StairNode): StairSurfaceMaterialSpec {
return {
material: node.material,
materialPreset: node.materialPreset,
}
}
export function getEffectiveStairSurfaceMaterial(
node: StairNode,
role: StairSurfaceMaterialRole,
): StairSurfaceMaterialSpec {
if (role === 'railing') {
if (node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string') {
return {
material: node.railingMaterial,
materialPreset:
typeof node.railingMaterialPreset === 'string' ? node.railingMaterialPreset : undefined,
}
}
}
if (role === 'tread') {
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
return {
material: node.treadMaterial,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
}
}
if (role === 'side') {
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
return {
material: node.sideMaterial,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
}
}
const treadFallback = {
material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
const sideFallback = {
material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) {
return sideFallback
}
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) {
return treadFallback
}
if (role === 'railing') {
if (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined) {
return treadFallback
}
if (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined) {
return sideFallback
}
}
return getLegacyStairSurfaceMaterial(node)
}
+82 -1
View File
@@ -143,6 +143,87 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
return node return node
} }
function migrateStairSurfaceMaterials(node: Record<string, any>) {
const hasRailing =
node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string'
const hasTread = node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
const hasSide = node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
const legacyFinish = {
material: node.material,
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
const resolveBodyFallback = () => {
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
return {
material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
}
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
return {
material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
}
return legacyFinish
}
if (!hasRailing && !hasTread && !hasSide) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
return {
...node,
railingMaterial: legacyFinish.material,
railingMaterialPreset: legacyFinish.materialPreset,
treadMaterial: legacyFinish.material,
treadMaterialPreset: legacyFinish.materialPreset,
sideMaterial: legacyFinish.material,
sideMaterialPreset: legacyFinish.materialPreset,
}
}
const next = { ...node }
if (!hasTread) {
const fallback =
node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
? {
material: node.sideMaterial,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
: resolveBodyFallback()
next.treadMaterial = fallback.material
next.treadMaterialPreset = fallback.materialPreset
}
if (!hasSide) {
const fallback =
node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
? {
material: node.treadMaterial,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
: resolveBodyFallback()
next.sideMaterial = fallback.material
next.sideMaterialPreset = fallback.materialPreset
}
if (!hasRailing) {
const fallback = resolveBodyFallback()
next.railingMaterial = fallback.material
next.railingMaterialPreset = fallback.materialPreset
}
return next
}
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> { function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
const patchedNodes = { ...nodes } const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) { for (const [id, node] of Object.entries(patchedNodes)) {
@@ -184,7 +265,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
} }
if (node.type === 'stair') { if (node.type === 'stair') {
const normalized = normalizeStairNode(node) const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) { if (normalized) {
patchedNodes[id] = normalized patchedNodes[id] = normalized
} }
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
const pendingStairUpdates = new Set<AnyNodeId>() const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2 const MAX_STAIRS_PER_FRAME = 2
const MAX_SEGMENTS_PER_FRAME = 4 const MAX_SEGMENTS_PER_FRAME = 4
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
const _uvPosition = new THREE.Vector3()
const _uvNormal = new THREE.Vector3()
// ============================================================================ // ============================================================================
// STAIR SYSTEM // STAIR SYSTEM
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
shape.lineTo(0, 0) shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, { const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
steps: 1, steps: 1,
depth: width, depth: width,
bevelEnabled: false, bevelEnabled: false,
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
const matrix = new THREE.Matrix4() const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2) matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(width / 2, 0, 0) matrix.setPosition(width / 2, 0, 0)
geometry.applyMatrix4(matrix) extrudedGeometry.applyMatrix4(matrix)
extrudedGeometry.computeVertexNormals()
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry
if (geometry !== extrudedGeometry) {
extrudedGeometry.dispose()
}
applyStairSegmentUvs(geometry)
ensureUv2Attribute(geometry)
return geometry return geometry
} }
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
const absoluteHeight = computeAbsoluteHeight(node) const absoluteHeight = computeAbsoluteHeight(node)
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight) const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
applyStraightStairMaterialGroups(newGeometry)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeometry mesh.geometry = newGeometry
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
} }
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
applyStraightStairMaterialGroups(merged)
replaceMeshGeometry(mergedMesh, merged) replaceMeshGeometry(mergedMesh, merged)
// Dispose individual geometries // Dispose individual geometries
@@ -371,6 +386,108 @@ function updateMergedStairGeometry(
} }
} }
function applyStraightStairMaterialGroups(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
if (!position || position.count < 3) {
geometry.clearGroups()
return
}
const index = geometry.getIndex()
const triangleCount = index ? index.count / 3 : position.count / 3
if (!Number.isFinite(triangleCount) || triangleCount <= 0) {
geometry.clearGroups()
return
}
const triangleMaterials: number[] = new Array(triangleCount)
const v0 = new THREE.Vector3()
const v1 = new THREE.Vector3()
const v2 = new THREE.Vector3()
const edge1 = new THREE.Vector3()
const edge2 = new THREE.Vector3()
const normal = new THREE.Vector3()
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex++) {
const vertexOffset = triangleIndex * 3
const a = index ? index.getX(vertexOffset) : vertexOffset
const b = index ? index.getX(vertexOffset + 1) : vertexOffset + 1
const c = index ? index.getX(vertexOffset + 2) : vertexOffset + 2
v0.fromBufferAttribute(position, a)
v1.fromBufferAttribute(position, b)
v2.fromBufferAttribute(position, c)
edge1.subVectors(v1, v0)
edge2.subVectors(v2, v0)
normal.crossVectors(edge1, edge2)
triangleMaterials[triangleIndex] =
normal.lengthSq() > 0 && normal.normalize().y > 0.75
? STAIR_TREAD_MATERIAL_INDEX
: STAIR_SIDE_MATERIAL_INDEX
}
geometry.clearGroups()
let currentMaterial = triangleMaterials[0]
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleMaterials.length; triangleIndex++) {
const materialIndex = triangleMaterials[triangleIndex]
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
geometry.addGroup(
groupStart * 3,
(triangleMaterials.length - groupStart) * 3,
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
)
}
function applyStairSegmentUvs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
if (!position || !normal || position.count === 0) {
geometry.deleteAttribute('uv')
return
}
const uv: number[] = []
for (let index = 0; index < position.count; index++) {
_uvPosition.fromBufferAttribute(position, index)
_uvNormal.fromBufferAttribute(normal, index).normalize()
const absX = Math.abs(_uvNormal.x)
const absY = Math.abs(_uvNormal.y)
const absZ = Math.abs(_uvNormal.z)
if (absY >= absX && absY >= absZ) {
uv.push(_uvPosition.x, _uvPosition.z)
} else if (absX >= absZ) {
uv.push(_uvPosition.z, _uvPosition.y)
} else {
uv.push(_uvPosition.x, _uvPosition.y)
}
}
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
// ============================================================================ // ============================================================================
// SEGMENT CHAINING // SEGMENT CHAINING
// ============================================================================ // ============================================================================
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
function createEmptyGeometry(): THREE.BufferGeometry { function createEmptyGeometry(): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
return geometry return geometry
} }
@@ -7,6 +7,9 @@ import {
type NodeEvent, type NodeEvent,
resolveLevelId, resolveLevelId,
sceneRegistry, sceneRegistry,
type StairEvent,
type StairNode,
type StairSegmentEvent,
useScene, useScene,
type WallEvent, type WallEvent,
type WallSurfaceSide, type WallSurfaceSide,
@@ -16,7 +19,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three' import { Color, type Material, type Mesh, type Object3D } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor' import useEditor, { type Phase, type StairMaterialTargetRole, type StructureLayer } from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool' import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => { const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -96,6 +99,35 @@ function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
return hitFace === 'front' ? 'interior' : 'exterior' return hitFace === 'front' ? 'interior' : 'exterior'
} }
function resolveStairMaterialTarget(
event: StairEvent | StairSegmentEvent,
): StairMaterialTargetRole | null {
const hitObjectName = event.nativeEvent.object?.name ?? ''
if (hitObjectName.startsWith('stair-railing')) {
return 'railing'
}
if (event.materialIndex === 0) {
return 'tread'
}
if (event.materialIndex === 1) {
return 'side'
}
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.75) {
return 'tread'
}
if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
return 'side'
}
return null
}
const HIGHLIGHT_PROFILES = { const HIGHLIGHT_PROFILES = {
delete: { delete: {
color: new Color('#dc2626'), color: new Color('#dc2626'),
@@ -484,6 +516,28 @@ export const SelectionManager = () => {
useEditor.getState().setSelectedWallMaterialTarget(null) useEditor.getState().setSelectedWallMaterialTarget(null)
} }
if (
(node.type === 'stair' || node.type === 'stair-segment') &&
nodeToSelect.type === 'stair'
) {
const nextStairMaterialTarget = resolveStairMaterialTarget(
event as StairEvent | StairSegmentEvent,
)
if (nextStairMaterialTarget) {
useEditor.getState().setSelectedStairMaterialTarget({
stairId: nodeToSelect.id,
role: nextStairMaterialTarget,
})
} else {
const currentStairMaterialTarget = useEditor.getState().selectedStairMaterialTarget
if (currentStairMaterialTarget?.stairId !== nodeToSelect.id) {
useEditor.getState().setSelectedStairMaterialTarget(null)
}
}
} else if (useEditor.getState().selectedStairMaterialTarget) {
useEditor.getState().setSelectedStairMaterialTarget(null)
}
// Reset the handled flag after a short delay to allow grid:click to be ignored // Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => { setTimeout(() => {
clickHandledRef.current = false clickHandledRef.current = false
@@ -517,6 +571,7 @@ export const SelectionManager = () => {
const activeStrategy = SELECTION_STRATEGIES[phase] const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect() if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedWallMaterialTarget(null) useEditor.getState().setSelectedWallMaterialTarget(null)
useEditor.getState().setSelectedStairMaterialTarget(null)
// When deselecting from zone mode, return to structure select // When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') { if (phase === 'structure' && structureLayer === 'zones') {
@@ -752,6 +807,8 @@ export const SelectionManager = () => {
const SelectionStateSync = () => { const SelectionStateSync = () => {
const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget) const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget)
const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget) const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget)
const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget)
const setSelectedStairMaterialTarget = useEditor((s) => s.setSelectedStairMaterialTarget)
const singleSelectedId = useViewer((s) => const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null, s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
) )
@@ -803,6 +860,25 @@ const SelectionStateSync = () => {
} }
}, [selectedWallMaterialTarget, setSelectedWallMaterialTarget, singleSelectedId]) }, [selectedWallMaterialTarget, setSelectedWallMaterialTarget, singleSelectedId])
useEffect(() => {
if (!selectedStairMaterialTarget) return
if (!singleSelectedId) {
setSelectedStairMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (!(selectedNode?.type === 'stair')) {
setSelectedStairMaterialTarget(null)
return
}
if (selectedStairMaterialTarget.stairId !== selectedNode.id) {
setSelectedStairMaterialTarget(null)
}
}, [selectedStairMaterialTarget, setSelectedStairMaterialTarget, singleSelectedId])
return null return null
} }
@@ -3,10 +3,12 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode, type LevelNode,
type MaterialSchema, type MaterialSchema,
type StairNode, type StairNode,
type StairRailingMode, type StairRailingMode,
type StairSurfaceMaterialRole,
type StairSlabOpeningMode, type StairSlabOpeningMode,
type StairTopLandingMode, type StairTopLandingMode,
type StairType, type StairType,
@@ -31,6 +33,32 @@ import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control' import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildStairSurfaceMaterialPatch(
node: StairNode,
targetRole: StairSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<StairNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextRailing =
targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing')
const nextTread =
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
const nextSide =
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
return {
railingMaterial: nextRailing.material,
railingMaterialPreset: nextRailing.materialPreset,
treadMaterial: nextTread.material,
treadMaterialPreset: nextTread.materialPreset,
sideMaterial: nextSide.material,
sideMaterialPreset: nextSide.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
{ label: 'None', value: 'none' }, { label: 'None', value: 'none' },
{ label: 'Left', value: 'left' }, { label: 'Left', value: 'left' },
@@ -62,6 +90,7 @@ export function StairPanel() {
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes) const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -92,18 +121,27 @@ export function StairPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback( const materialTargetRole =
selectedStairMaterialTarget && selectedStairMaterialTarget.stairId === node?.id
? selectedStairMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined }) if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
}, },
[handleUpdate], [handleUpdate, materialTargetRole, node],
) )
const handleMaterialPresetChange = useCallback( const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => { (materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined }) if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
}, },
[handleUpdate], [handleUpdate, materialTargetRole, node],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -569,13 +607,21 @@ export function StairPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker {!materialTargetRole ? (
nodeType="stair" <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
onChange={handleMaterialChange} Click the stair surface you want to edit. Materials apply to one target at a time.
onSelectMaterialPreset={handleMaterialPresetChange} </div>
selectedMaterialPreset={node.materialPreset} ) : null}
value={node.material} {materialTargetRole ? (
/> <MaterialPicker
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
) : null}
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
+11
View File
@@ -86,6 +86,13 @@ export type SelectedWallMaterialTarget = {
side: WallSurfaceSide side: WallSurfaceSide
} }
export type StairMaterialTargetRole = 'railing' | 'tread' | 'side'
export type SelectedStairMaterialTarget = {
stairId: StairNode['id']
role: StairMaterialTargetRole
}
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
@@ -135,6 +142,8 @@ type EditorState = {
setCurvingWall: (wall: WallNode | null) => void setCurvingWall: (wall: WallNode | null) => void
selectedWallMaterialTarget: SelectedWallMaterialTarget | null selectedWallMaterialTarget: SelectedWallMaterialTarget | null
setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void
selectedStairMaterialTarget: SelectedStairMaterialTarget | null
setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -512,6 +521,8 @@ const useEditor = create<EditorState>()(
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedWallMaterialTarget: null, selectedWallMaterialTarget: null,
setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }), setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }),
selectedStairMaterialTarget: null,
setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
@@ -1,8 +1,8 @@
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!) const ref = useRef<THREE.Mesh>(null!)
@@ -19,14 +19,7 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
const material = useMemo(() => { const material = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset return getStraightStairSegmentBodyMaterials(node, parentNode)
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [ }, [
node.materialPreset, node.materialPreset,
node.material, node.material,
@@ -38,19 +31,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
parentNode?.material?.preset, parentNode?.material?.preset,
parentNode?.material?.properties, parentNode?.material?.properties,
parentNode?.material?.texture, parentNode?.material?.texture,
parentNode?.railingMaterialPreset,
parentNode?.railingMaterial,
parentNode?.sideMaterialPreset,
parentNode?.sideMaterial,
parentNode?.treadMaterialPreset,
parentNode?.treadMaterial,
]) ])
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
return geometry
}, [])
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return ( return (
<mesh <mesh
geometry={placeholderGeometry}
material={material} material={material}
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation-y={node.rotation} rotation-y={node.rotation}
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> />
{/* StairSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
) )
} }
@@ -5,14 +5,15 @@ import {
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { import {
createMaterial, getStairRailingMaterial,
createMaterialFromPresetRef, getStairBodyMaterials,
DEFAULT_STAIR_MATERIAL, type StairBodyMaterials,
} from '../../../lib/materials' } from '../../../systems/stair/stair-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
type SegmentTransform = { type SegmentTransform = {
@@ -71,6 +72,48 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
node.material?.texture, node.material?.texture,
]) ])
const straightBodyMaterials = useMemo(
() => getStairBodyMaterials(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const railingMaterial = useMemo(
() => getStairRailingMaterial(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const straightPlaceholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
return geometry
}, [])
useEffect(() => {
return () => {
straightPlaceholderGeometry.dispose()
}
}, [straightPlaceholderGeometry])
return ( return (
<group <group
position-x={node.position[0]} position-x={node.position[0]}
@@ -81,12 +124,16 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
{...handlers} {...handlers}
> >
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow> <mesh
<boxGeometry args={[0, 0, 0]} /> castShadow
</mesh> geometry={straightPlaceholderGeometry}
material={straightBodyMaterials}
name="merged-stair"
receiveShadow
/>
) : null} ) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null} {!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
<StairRailings material={material} stair={node} /> <StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
@@ -170,6 +217,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY} geometry={BALUSTER_GEOMETRY}
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`} key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
material={material} material={material}
name="stair-railing-baluster"
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]}
@@ -227,6 +275,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY} geometry={BALUSTER_GEOMETRY}
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`} key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
material={material} material={material}
name="stair-railing-baluster"
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]}
@@ -333,6 +382,8 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) 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_SIDE_MATERIAL_INDEX = 1
function RailSegment({ function RailSegment({
start, start,
@@ -367,6 +418,7 @@ function RailSegment({
castShadow castShadow
geometry={RAIL_GEOMETRY} geometry={RAIL_GEOMETRY}
material={material} material={material}
name="stair-railing-rail"
position={[midpoint.x, midpoint.y, midpoint.z]} position={[midpoint.x, midpoint.y, midpoint.z]}
quaternion={quaternion} quaternion={quaternion}
receiveShadow receiveShadow
@@ -375,7 +427,14 @@ function RailSegment({
) )
} }
function CurvedStairBody({ stair, material }: { stair: StairNode; material: THREE.Material }) { function CurvedStairBody({
stair,
bodyMaterials,
}: {
stair: StairNode
bodyMaterials: StairBodyMaterials
}) {
const sideMaterial = bodyMaterials[1]
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
const stepHeight = totalRise / stepCount const stepHeight = totalRise / stepCount
@@ -411,7 +470,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<mesh <mesh
castShadow castShadow
receiveShadow receiveShadow
material={material} material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]} position={[0, spiralColumnHeight / 2, 0]}
> >
<cylinderGeometry <cylinderGeometry
@@ -443,7 +503,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
{isSpiral && (stair.showStepSupports ?? true) ? ( {isSpiral && (stair.showStepSupports ?? true) ? (
<mesh <mesh
castShadow castShadow
material={material} material={sideMaterial}
name="stair-side"
position={[ position={[
Math.cos(midAngle) * Math.cos(midAngle) *
(spiralColumnRadius + (spiralColumnRadius +
@@ -470,7 +531,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh <CurvedStepMesh
endAngle={endAngle} endAngle={endAngle}
innerRadius={innerRadius} innerRadius={innerRadius}
material={material} material={bodyMaterials}
outerRadius={outerRadius} outerRadius={outerRadius}
positionY={0} positionY={0}
startAngle={startAngle} startAngle={startAngle}
@@ -484,7 +545,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh <CurvedStepMesh
endAngle={sweepAngle / 2 + spiralLandingSweep} endAngle={sweepAngle / 2 + spiralLandingSweep}
innerRadius={innerRadius} innerRadius={innerRadius}
material={material} material={bodyMaterials}
outerRadius={outerRadius} outerRadius={outerRadius}
positionY={spiralLastStepTop} positionY={spiralLastStepTop}
startAngle={sweepAngle / 2} startAngle={sweepAngle / 2}
@@ -513,7 +574,7 @@ function CurvedStepMesh({
stepHeight: number stepHeight: number
thickness: number thickness: number
positionY: number positionY: number
material: THREE.Material material: THREE.Material | THREE.Material[]
}) { }) {
const geometry = useMemo( const geometry = useMemo(
() => () =>
@@ -556,15 +617,39 @@ function buildCurvedStepGeometry(
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = []
const triangleMaterialIndices: number[] = []
const pointOnArc = (radius: number, angle: number, y: number) => const pointOnArc = (radius: number, angle: number, y: number) =>
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius) new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
const pushUv = (point: THREE.Vector3, normal: THREE.Vector3, materialIndex: number) => {
if (materialIndex === STAIR_TREAD_MATERIAL_INDEX) {
const angle = Math.atan2(point.z, point.x)
const arcOffset = (angle - startAngle) * Math.max((innerRadius + outerRadius) * 0.5, 0.01)
uvs.push(arcOffset, Math.sqrt(point.x * point.x + point.z * point.z) - innerRadius)
return
}
const absX = Math.abs(normal.x)
const absY = Math.abs(normal.y)
const absZ = Math.abs(normal.z)
if (absY >= absX && absY >= absZ) {
uvs.push(point.x, point.z)
} else if (absX >= absZ) {
uvs.push(point.z, point.y)
} else {
uvs.push(point.x, point.y)
}
}
const pushTriangle = ( const pushTriangle = (
a: THREE.Vector3, a: THREE.Vector3,
b: THREE.Vector3, b: THREE.Vector3,
c: THREE.Vector3, c: THREE.Vector3,
normal: THREE.Vector3, normal: THREE.Vector3,
materialIndex: number,
) => { ) => {
const edgeAB = b.clone().sub(a) const edgeAB = b.clone().sub(a)
const edgeAC = c.clone().sub(a) const edgeAC = c.clone().sub(a)
@@ -573,7 +658,9 @@ function buildCurvedStepGeometry(
for (const point of ordered) { for (const point of ordered) {
positions.push(point.x, point.y, point.z) positions.push(point.x, point.y, point.z)
normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z)
pushUv(point, normal, materialIndex)
} }
triangleMaterialIndices.push(materialIndex)
} }
const pushQuad = ( const pushQuad = (
@@ -582,9 +669,10 @@ function buildCurvedStepGeometry(
c: THREE.Vector3, c: THREE.Vector3,
d: THREE.Vector3, d: THREE.Vector3,
normal: THREE.Vector3, normal: THREE.Vector3,
materialIndex: number,
) => { ) => {
pushTriangle(a, b, c, normal) pushTriangle(a, b, c, normal, materialIndex)
pushTriangle(a, c, d, normal) pushTriangle(a, c, d, normal, materialIndex)
} }
const upNormal = new THREE.Vector3(0, 1, 0) const upNormal = new THREE.Vector3(0, 1, 0)
@@ -609,10 +697,38 @@ function buildCurvedStepGeometry(
const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize() const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize()
const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize() const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize()
pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal) pushQuad(
pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal) innerStartTop,
pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal) outerStartTop,
pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal) outerEndTop,
innerEndTop,
upNormal,
STAIR_TREAD_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerEndBottom,
outerEndBottom,
outerStartBottom,
downNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerStartTop,
innerEndTop,
innerEndBottom,
innerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
outerStartBottom,
outerEndBottom,
outerEndTop,
outerStartTop,
outerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
} }
const startInnerBottom = pointOnArc(innerRadius, startAngle, y0) const startInnerBottom = pointOnArc(innerRadius, startAngle, y0)
@@ -634,12 +750,49 @@ function buildCurvedStepGeometry(
sweepDirection * Math.cos(endAngle), sweepDirection * Math.cos(endAngle),
).normalize() ).normalize()
pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal) pushQuad(
pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal) startInnerBottom,
startOuterBottom,
startOuterTop,
startInnerTop,
startNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
endInnerBottom,
endInnerTop,
endOuterTop,
endOuterBottom,
endNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.clearGroups()
let currentMaterial = triangleMaterialIndices[0]
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleMaterialIndices.length; triangleIndex++) {
const materialIndex = triangleMaterialIndices[triangleIndex]
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
if (triangleMaterialIndices.length > 0) {
geometry.addGroup(
groupStart * 3,
(triangleMaterialIndices.length - groupStart) * 3,
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
)
}
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
geometry.computeVertexNormals() geometry.computeVertexNormals()
return geometry return geometry
} }
@@ -0,0 +1,59 @@
import {
getEffectiveStairSurfaceMaterial,
type StairNode,
type StairSegmentNode,
} from '@pascal-app/core'
import type * as THREE from 'three'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../lib/materials'
export type StairBodyMaterials = [THREE.Material, THREE.Material]
function createResolvedMaterial(
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
materialPreset: string | undefined,
): THREE.Material {
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset) ?? DEFAULT_STAIR_MATERIAL
}
if (material) {
return createMaterial(material)
}
return DEFAULT_STAIR_MATERIAL
}
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
return [
createResolvedMaterial(tread.material, tread.materialPreset),
createResolvedMaterial(side.material, side.materialPreset),
]
}
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
return createResolvedMaterial(railing.material, railing.materialPreset)
}
export function getStraightStairSegmentBodyMaterials(
segment: StairSegmentNode,
parentNode?: StairNode,
): StairBodyMaterials {
if (segment.material !== undefined || typeof segment.materialPreset === 'string') {
const override = createResolvedMaterial(segment.material, segment.materialPreset)
return [override, override]
}
if (parentNode) {
return getStairBodyMaterials(parentNode)
}
return [DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL]
}