Add click-targeted stair materials and UV mapping
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{!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
|
<MaterialPicker
|
||||||
|
hideSideControl
|
||||||
nodeType="stair"
|
nodeType="stair"
|
||||||
onChange={handleMaterialChange}
|
onChange={handleTargetedMaterialChange}
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||||
selectedMaterialPreset={node.materialPreset}
|
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||||
value={node.material}
|
value={materialPickerValue.material}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user