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 { SlabNode } from './nodes/slab'
export {
getEffectiveStairSurfaceMaterial,
StairNode,
StairRailingMode,
StairSlabOpeningMode,
StairTopLandingMode,
StairType,
} from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
+84 -2
View File
@@ -1,7 +1,7 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { StairSegmentNode } from './stair-segment'
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 StairTopLandingMode = z.infer<typeof StairTopLandingMode>
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({
id: objectId('stair'),
type: nodeType('stair'),
material: MaterialSchema.optional(),
material: MaterialSchemaSchema.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]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
@@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({
)
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
}
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> {
const patchedNodes = { ...nodes }
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') {
const normalized = normalizeStairNode(node)
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) {
patchedNodes[id] = normalized
}
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2
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
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, {
const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
steps: 1,
depth: width,
bevelEnabled: false,
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2)
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
}
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
const absoluteHeight = computeAbsoluteHeight(node)
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
applyStraightStairMaterialGroups(newGeometry)
mesh.geometry.dispose()
mesh.geometry = newGeometry
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
}
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
applyStraightStairMaterialGroups(merged)
replaceMeshGeometry(mergedMesh, merged)
// 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
// ============================================================================
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
function createEmptyGeometry(): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
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
}
@@ -7,6 +7,9 @@ import {
type NodeEvent,
resolveLevelId,
sceneRegistry,
type StairEvent,
type StairNode,
type StairSegmentEvent,
useScene,
type WallEvent,
type WallSurfaceSide,
@@ -16,7 +19,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three'
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'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -96,6 +99,35 @@ function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
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 = {
delete: {
color: new Color('#dc2626'),
@@ -484,6 +516,28 @@ export const SelectionManager = () => {
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
setTimeout(() => {
clickHandledRef.current = false
@@ -517,6 +571,7 @@ export const SelectionManager = () => {
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedWallMaterialTarget(null)
useEditor.getState().setSelectedStairMaterialTarget(null)
// When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') {
@@ -752,6 +807,8 @@ export const SelectionManager = () => {
const SelectionStateSync = () => {
const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget)
const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget)
const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget)
const setSelectedStairMaterialTarget = useEditor((s) => s.setSelectedStairMaterialTarget)
const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
)
@@ -803,6 +860,25 @@ const SelectionStateSync = () => {
}
}, [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
}
@@ -3,10 +3,12 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode,
type MaterialSchema,
type StairNode,
type StairRailingMode,
type StairSurfaceMaterialRole,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
@@ -31,6 +33,32 @@ import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
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 }[] = [
{ label: 'None', value: 'none' },
{ label: 'Left', value: 'left' },
@@ -62,6 +90,7 @@ export function StairPanel() {
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -92,18 +121,27 @@ export function StairPanel() {
[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) => {
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) => {
handleUpdate({ materialPreset, material: undefined })
if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
},
[handleUpdate],
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
@@ -569,13 +607,21 @@ export function StairPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
{!materialTargetRole ? (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
) : null}
{materialTargetRole ? (
<MaterialPicker
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
) : null}
</PanelSection>
</PanelWrapper>
)
+11
View File
@@ -86,6 +86,13 @@ export type SelectedWallMaterialTarget = {
side: WallSurfaceSide
}
export type StairMaterialTargetRole = 'railing' | 'tread' | 'side'
export type SelectedStairMaterialTarget = {
stairId: StairNode['id']
role: StairMaterialTargetRole
}
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -135,6 +142,8 @@ type EditorState = {
setCurvingWall: (wall: WallNode | null) => void
selectedWallMaterialTarget: SelectedWallMaterialTarget | null
setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void
selectedStairMaterialTarget: SelectedStairMaterialTarget | null
setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -512,6 +521,8 @@ const useEditor = create<EditorState>()(
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedWallMaterialTarget: null,
setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }),
selectedStairMaterialTarget: null,
setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},
@@ -1,8 +1,8 @@
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
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 }) => {
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
const material = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
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)
return getStraightStairSegmentBodyMaterials(node, parentNode)
}, [
node.materialPreset,
node.material,
@@ -38,19 +31,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
parentNode?.material?.preset,
parentNode?.material?.properties,
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 (
<mesh
geometry={placeholderGeometry}
material={material}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
{/* StairSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
/>
)
}
@@ -5,14 +5,15 @@ import {
useRegistry,
useScene,
} from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
getStairRailingMaterial,
getStairBodyMaterials,
type StairBodyMaterials,
} from '../../../systems/stair/stair-materials'
import { NodeRenderer } from '../node-renderer'
type SegmentTransform = {
@@ -71,6 +72,48 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
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 (
<group
position-x={node.position[0]}
@@ -81,12 +124,16 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
{...handlers}
>
{isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} />
</mesh>
<mesh
castShadow
geometry={straightPlaceholderGeometry}
material={straightBodyMaterials}
name="merged-stair"
receiveShadow
/>
) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null}
<StairRailings material={material} stair={node} />
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
<StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
@@ -170,6 +217,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY}
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
material={material}
name="stair-railing-baluster"
position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
@@ -227,6 +275,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY}
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
material={material}
name="stair-railing-baluster"
position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow
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 RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
function RailSegment({
start,
@@ -367,6 +418,7 @@ function RailSegment({
castShadow
geometry={RAIL_GEOMETRY}
material={material}
name="stair-railing-rail"
position={[midpoint.x, midpoint.y, midpoint.z]}
quaternion={quaternion}
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 totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
const stepHeight = totalRise / stepCount
@@ -411,7 +470,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<mesh
castShadow
receiveShadow
material={material}
material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]}
>
<cylinderGeometry
@@ -443,7 +503,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
{isSpiral && (stair.showStepSupports ?? true) ? (
<mesh
castShadow
material={material}
material={sideMaterial}
name="stair-side"
position={[
Math.cos(midAngle) *
(spiralColumnRadius +
@@ -470,7 +531,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh
endAngle={endAngle}
innerRadius={innerRadius}
material={material}
material={bodyMaterials}
outerRadius={outerRadius}
positionY={0}
startAngle={startAngle}
@@ -484,7 +545,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh
endAngle={sweepAngle / 2 + spiralLandingSweep}
innerRadius={innerRadius}
material={material}
material={bodyMaterials}
outerRadius={outerRadius}
positionY={spiralLastStepTop}
startAngle={sweepAngle / 2}
@@ -513,7 +574,7 @@ function CurvedStepMesh({
stepHeight: number
thickness: number
positionY: number
material: THREE.Material
material: THREE.Material | THREE.Material[]
}) {
const geometry = useMemo(
() =>
@@ -556,15 +617,39 @@ function buildCurvedStepGeometry(
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const triangleMaterialIndices: number[] = []
const pointOnArc = (radius: number, angle: number, y: number) =>
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 = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
normal: THREE.Vector3,
materialIndex: number,
) => {
const edgeAB = b.clone().sub(a)
const edgeAC = c.clone().sub(a)
@@ -573,7 +658,9 @@ function buildCurvedStepGeometry(
for (const point of ordered) {
positions.push(point.x, point.y, point.z)
normals.push(normal.x, normal.y, normal.z)
pushUv(point, normal, materialIndex)
}
triangleMaterialIndices.push(materialIndex)
}
const pushQuad = (
@@ -582,9 +669,10 @@ function buildCurvedStepGeometry(
c: THREE.Vector3,
d: THREE.Vector3,
normal: THREE.Vector3,
materialIndex: number,
) => {
pushTriangle(a, b, c, normal)
pushTriangle(a, c, d, normal)
pushTriangle(a, b, c, normal, materialIndex)
pushTriangle(a, c, d, normal, materialIndex)
}
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 innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize()
pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal)
pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal)
pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal)
pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal)
pushQuad(
innerStartTop,
outerStartTop,
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)
@@ -634,12 +750,49 @@ function buildCurvedStepGeometry(
sweepDirection * Math.cos(endAngle),
).normalize()
pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal)
pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal)
pushQuad(
startInnerBottom,
startOuterBottom,
startOuterTop,
startInnerTop,
startNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
endInnerBottom,
endInnerTop,
endOuterTop,
endOuterBottom,
endNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 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()
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]
}