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 { 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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user