Harden stair reload and spiral post-processing

This commit is contained in:
sudhir
2026-04-17 13:58:54 +05:30
parent 608b3fc744
commit 7b6d295cd8
3 changed files with 368 additions and 91 deletions
+103
View File
@@ -8,9 +8,98 @@ import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections'
import { LevelNode } from '../schema/nodes/level' import { LevelNode } from '../schema/nodes/level'
import { SiteNode } from '../schema/nodes/site' import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
function getFiniteNumber(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function getBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback
}
function getEnumValue<T extends readonly string[]>(
value: unknown,
allowed: T,
fallback: T[number],
): T[number] {
return typeof value === 'string' && allowed.includes(value) ? value : fallback
}
function getNullableString(value: unknown) {
return typeof value === 'string' ? value : null
}
function getStringArray(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string')
: []
}
function getVector3(value: unknown, fallback: [number, number, number]): [number, number, number] {
if (!Array.isArray(value) || value.length < 3) {
return fallback
}
return [
getFiniteNumber(value[0], fallback[0]),
getFiniteNumber(value[1], fallback[1]),
getFiniteNumber(value[2], fallback[2]),
]
}
function normalizeStairNode(node: Record<string, unknown>) {
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
rotation: getFiniteNumber(node.rotation, 0),
stairType: getEnumValue(node.stairType, ['straight', 'curved', 'spiral'] as const, 'straight'),
fromLevelId: getNullableString(node.fromLevelId),
toLevelId: getNullableString(node.toLevelId),
slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'),
openingOffset: getFiniteNumber(node.openingOffset, 0),
width: getFiniteNumber(node.width, 1),
totalRise: getFiniteNumber(node.totalRise, 2.5),
stepCount: getFiniteNumber(node.stepCount, 10),
thickness: getFiniteNumber(node.thickness, 0.25),
fillToFloor: getBoolean(node.fillToFloor, true),
innerRadius: getFiniteNumber(node.innerRadius, 0.9),
sweepAngle: getFiniteNumber(node.sweepAngle, Math.PI / 2),
topLandingMode: getEnumValue(node.topLandingMode, ['none', 'integrated'] as const, 'none'),
topLandingDepth: getFiniteNumber(node.topLandingDepth, 0.9),
showCenterColumn: getBoolean(node.showCenterColumn, true),
showStepSupports: getBoolean(node.showStepSupports, true),
railingMode: getEnumValue(node.railingMode, ['none', 'left', 'right', 'both'] as const, 'none'),
railingHeight: getFiniteNumber(node.railingHeight, 0.92),
children: getStringArray(node.children),
}
const parsed = StairNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null
}
function normalizeStairSegmentNode(node: Record<string, unknown>) {
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
rotation: getFiniteNumber(node.rotation, 0),
segmentType: getEnumValue(node.segmentType, ['stair', 'landing'] as const, 'stair'),
width: getFiniteNumber(node.width, 1),
length: getFiniteNumber(node.length, 3),
height: getFiniteNumber(node.height, 2.5),
stepCount: getFiniteNumber(node.stepCount, 10),
attachmentSide: getEnumValue(node.attachmentSide, ['front', 'left', 'right'] as const, 'front'),
fillToFloor: getBoolean(node.fillToFloor, true),
thickness: getFiniteNumber(node.thickness, 0.25),
}
const parsed = StairSegmentNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null
}
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)) {
@@ -50,6 +139,20 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
children: [segmentId], children: [segmentId],
} }
} }
if (node.type === 'stair') {
const normalized = normalizeStairNode(node)
if (normalized) {
patchedNodes[id] = normalized
}
}
if (node.type === 'stair-segment') {
const normalized = normalizeStairSegmentNode(node)
if (normalized) {
patchedNodes[id] = normalized
}
}
} }
return patchedNodes as Record<string, AnyNode> return patchedNodes as Record<string, AnyNode>
} }
@@ -1,8 +1,18 @@
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 { 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 {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
type SegmentTransform = { type SegmentTransform = {
@@ -37,6 +47,7 @@ type LandingChainNextStair = {
export const StairRenderer = ({ node }: { node: StairNode }) => { export const StairRenderer = ({ node }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
const isSegmentBasedStair = node.stairType === 'straight'
useRegistry(node.id, 'stair', ref) useRegistry(node.id, 'stair', ref)
@@ -52,7 +63,13 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
const mat = node.material const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
return ( return (
<group <group
@@ -63,18 +80,20 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> >
{isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow> <mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
</mesh> </mesh>
{node.stairType === 'curved' || node.stairType === 'spiral' ? (
<CurvedStairBody material={material} stair={node} />
) : null} ) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null}
<StairRailings material={material} stair={node} /> <StairRailings material={material} stair={node} />
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
</group> </group>
) : null}
</group> </group>
) )
} }
@@ -86,11 +105,17 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
() => () =>
(stair.children ?? []) (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((node): node is StairSegmentNode => node?.type === 'stair-segment' && node.visible !== false), .filter(
(node): node is StairSegmentNode =>
node?.type === 'stair-segment' && node.visible !== false,
),
[nodes, stair.children], [nodes, stair.children],
) )
const railPaths = useMemo(() => buildStairRailPaths(segments, stair.railingMode ?? 'none'), [segments, stair.railingMode]) const railPaths = useMemo(
() => buildStairRailPaths(segments, stair.railingMode ?? 'none'),
[segments, stair.railingMode],
)
const railHeight = stair.railingHeight ?? 0.92 const railHeight = stair.railingHeight ?? 0.92
const midRailHeight = Math.max(railHeight * 0.45, 0.35) const midRailHeight = Math.max(railHeight * 0.45, 0.35)
@@ -103,10 +128,14 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
if (stair.stairType === 'curved' || stair.stairType === 'spiral') { if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const sweepAngle = stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2) const sweepAngle =
stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
const stepSweep = sweepAngle / stepCount const stepSweep = sweepAngle / stepCount
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
const innerRadius = Math.max(stair.stairType === 'spiral' ? 0.05 : 0.2, stair.innerRadius ?? 0.9) const innerRadius = Math.max(
stair.stairType === 'spiral' ? 0.05 : 0.2,
stair.innerRadius ?? 0.9,
)
const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4) const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4)
const leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 0.04 const leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 0.04
const rightRadius = sweepAngle >= 0 ? outerRadius - 0.04 : innerRadius + 0.04 const rightRadius = sweepAngle >= 0 ? outerRadius - 0.04 : innerRadius + 0.04
@@ -183,7 +212,11 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
{railPaths.map((segmentPath, index) => ( {railPaths.map((segmentPath, index) => (
<group <group
key={`${segmentPath.layout.segment.id}-railing`} key={`${segmentPath.layout.segment.id}-railing`}
position={[segmentPath.layout.center[0], segmentPath.layout.elevation, segmentPath.layout.center[1]]} position={[
segmentPath.layout.center[0],
segmentPath.layout.elevation,
segmentPath.layout.center[1],
]}
rotation-y={segmentPath.layout.rotation} rotation-y={segmentPath.layout.rotation}
> >
{segmentPath.sidePaths.map((sidePath, sideIndex) => ( {segmentPath.sidePaths.map((sidePath, sideIndex) => (
@@ -204,7 +237,9 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
if (!nextPoint) return null if (!nextPoint) return null
return ( return (
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}> <group
key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}
>
<RailSegment <RailSegment
end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]} end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]}
material={material} material={material}
@@ -240,12 +275,13 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const lastPoint = entry.points[entry.points.length - 1] const lastPoint = entry.points[entry.points.length - 1]
return { return {
entry, entry,
distance: lastPoint ? distance3(toWorldRailPoint(previousPath.layout, lastPoint), currentWorldPoint) : Number.POSITIVE_INFINITY, distance: lastPoint
? distance3(toWorldRailPoint(previousPath.layout, lastPoint), currentWorldPoint)
: Number.POSITIVE_INFINITY,
} }
}) })
.sort((left, right) => left.distance - right.distance)[0]?.entry .sort((left, right) => left.distance - right.distance)[0]?.entry
const previousPoint = const previousPoint = previousSidePath?.points.length
previousSidePath && previousSidePath.points.length
? previousSidePath.points[previousSidePath.points.length - 1] ? previousSidePath.points[previousSidePath.points.length - 1]
: null : null
@@ -256,18 +292,36 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const previousWorldPoint = toWorldRailPoint(previousPath.layout, previousPoint) const previousWorldPoint = toWorldRailPoint(previousPath.layout, previousPoint)
return ( return (
<group key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}> <group
key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}
>
<RailSegment <RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + railHeight, currentWorldPoint[2]]} end={[
currentWorldPoint[0],
currentWorldPoint[1] + railHeight,
currentWorldPoint[2],
]}
material={material} material={material}
radius={railRadius} radius={railRadius}
start={[previousWorldPoint[0], previousWorldPoint[1] + railHeight, previousWorldPoint[2]]} start={[
previousWorldPoint[0],
previousWorldPoint[1] + railHeight,
previousWorldPoint[2],
]}
/> />
<RailSegment <RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + midRailHeight, currentWorldPoint[2]]} end={[
currentWorldPoint[0],
currentWorldPoint[1] + midRailHeight,
currentWorldPoint[2],
]}
material={material} material={material}
radius={railRadius * 0.8} radius={railRadius * 0.8}
start={[previousWorldPoint[0], previousWorldPoint[1] + midRailHeight, previousWorldPoint[2]]} start={[
previousWorldPoint[0],
previousWorldPoint[1] + midRailHeight,
previousWorldPoint[2],
]}
/> />
</group> </group>
) )
@@ -296,10 +350,17 @@ function RailSegment({
const direction = useMemo(() => endVector.clone().sub(startVector), [endVector, startVector]) const direction = useMemo(() => endVector.clone().sub(startVector), [endVector, startVector])
const length = Math.max(direction.length(), 0.01) const length = Math.max(direction.length(), 0.01)
const quaternion = useMemo( const quaternion = useMemo(
() => new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize()), () =>
new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction.clone().normalize(),
),
[direction], [direction],
) )
const midpoint = useMemo(() => startVector.clone().add(endVector).multiplyScalar(0.5), [endVector, startVector]) const midpoint = useMemo(
() => startVector.clone().add(endVector).multiplyScalar(0.5),
[endVector, startVector],
)
return ( return (
<mesh <mesh
@@ -327,11 +388,16 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
const fillToFloor = stair.fillToFloor ?? true const fillToFloor = stair.fillToFloor ?? true
const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03)) const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03))
const spiralColumnHeight = totalRise + thickness const spiralColumnHeight = totalRise + thickness
const spiralLandingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max((stair.width ?? 1) * 0.9, 0.8)) const spiralLandingDepth = Math.max(
0.3,
stair.topLandingDepth ?? Math.max((stair.width ?? 1) * 0.9, 0.8),
)
const spiralLandingSweep = const spiralLandingSweep =
isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' isSpiral && (stair.topLandingMode ?? 'none') === 'integrated'
? Math.min(Math.PI * 0.75, spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1)) * ? Math.min(
Math.sign(sweepAngle || 1) Math.PI * 0.75,
spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1),
) * Math.sign(sweepAngle || 1)
: 0 : 0
const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness
const spiralLandingThickness = const spiralLandingThickness =
@@ -342,28 +408,52 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
return ( return (
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}> <group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
{isSpiral && (stair.showCenterColumn ?? true) ? ( {isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh castShadow receiveShadow material={material} position={[0, spiralColumnHeight / 2, 0]}> <mesh
<cylinderGeometry args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]} /> castShadow
receiveShadow
material={material}
position={[0, spiralColumnHeight / 2, 0]}
>
<cylinderGeometry
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
/>
</mesh> </mesh>
) : null} ) : null}
{Array.from({ length: stepCount }).map((_, index) => { {Array.from({ length: stepCount }).map((_, index) => {
const currentHeight = stepHeight * (index + 1) const currentHeight = stepHeight * (index + 1)
const actualStepHeight = isSpiral ? thickness : fillToFloor ? Math.max(currentHeight, thickness) : thickness const actualStepHeight = isSpiral
? thickness
: fillToFloor
? Math.max(currentHeight, thickness)
: thickness
const startAngle = -sweepAngle / 2 + stepSweep * index const startAngle = -sweepAngle / 2 + stepSweep * index
const endAngle = startAngle + stepSweep const endAngle = startAngle + stepSweep
const stepY = isSpiral ? stepHeight * index : fillToFloor ? 0 : Math.max(currentHeight - thickness, 0) const stepY = isSpiral
? stepHeight * index
: fillToFloor
? 0
: Math.max(currentHeight - thickness, 0)
const midAngle = startAngle + stepSweep / 2 const midAngle = startAngle + stepSweep / 2
return ( return (
<group key={`${stair.id}-${isSpiral ? 'spiral' : 'curved'}-step-${index}`} position-y={stepY}> <group
key={`${stair.id}-${isSpiral ? 'spiral' : 'curved'}-step-${index}`}
position-y={stepY}
>
{isSpiral && (stair.showStepSupports ?? true) ? ( {isSpiral && (stair.showStepSupports ?? true) ? (
<mesh <mesh
castShadow castShadow
material={material} material={material}
position={[ position={[
Math.cos(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02), Math.cos(midAngle) *
(spiralColumnRadius +
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
0.02),
Math.max(thickness * 0.55, 0.025) / 2, Math.max(thickness * 0.55, 0.025) / 2,
Math.sin(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02), Math.sin(midAngle) *
(spiralColumnRadius +
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
0.02),
]} ]}
receiveShadow receiveShadow
rotation-y={-midAngle} rotation-y={-midAngle}
@@ -426,11 +516,20 @@ function CurvedStepMesh({
material: THREE.Material material: THREE.Material
}) { }) {
const geometry = useMemo( const geometry = useMemo(
() => buildCurvedStepGeometry(innerRadius, outerRadius, startAngle, endAngle, Math.max(stepHeight, thickness)), () =>
buildCurvedStepGeometry(
innerRadius,
outerRadius,
startAngle,
endAngle,
Math.max(stepHeight, thickness),
),
[endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness], [endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness],
) )
return <mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow /> return (
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
)
} }
function buildCurvedStepGeometry( function buildCurvedStepGeometry(
@@ -445,7 +544,15 @@ function buildCurvedStepGeometry(
const y1 = clampedHeight const y1 = clampedHeight
const sweepAngle = endAngle - startAngle const sweepAngle = endAngle - startAngle
const sweepDirection = Math.sign(sweepAngle) || 1 const sweepDirection = Math.sign(sweepAngle) || 1
const segmentCount = Math.max(4, Math.min(24, Math.ceil(Math.abs(sweepAngle) / (Math.PI / 18) + Math.max(0, (outerRadius - innerRadius) * 3)))) const segmentCount = Math.max(
4,
Math.min(
24,
Math.ceil(
Math.abs(sweepAngle) / (Math.PI / 18) + Math.max(0, (outerRadius - innerRadius) * 3),
),
),
)
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
@@ -453,7 +560,12 @@ function buildCurvedStepGeometry(
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 pushTriangle = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, normal: THREE.Vector3) => { const pushTriangle = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
normal: THREE.Vector3,
) => {
const edgeAB = b.clone().sub(a) const edgeAB = b.clone().sub(a)
const edgeAC = c.clone().sub(a) const edgeAC = c.clone().sub(a)
const faceNormal = edgeAB.cross(edgeAC) const faceNormal = edgeAB.cross(edgeAC)
@@ -464,7 +576,13 @@ function buildCurvedStepGeometry(
} }
} }
const pushQuad = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, d: THREE.Vector3, normal: THREE.Vector3) => { const pushQuad = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
d: THREE.Vector3,
normal: THREE.Vector3,
) => {
pushTriangle(a, b, c, normal) pushTriangle(a, b, c, normal)
pushTriangle(a, c, d, normal) pushTriangle(a, c, d, normal)
} }
@@ -548,7 +666,10 @@ function buildStairRailPaths(
return layouts.map((layout, index) => { return layouts.map((layout, index) => {
const previousLayout = index > 0 ? layouts[index - 1] : undefined const previousLayout = index > 0 ? layouts[index - 1] : undefined
const nextLayout = layouts[index + 1] const nextLayout = layouts[index + 1]
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index) const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(
layouts,
index,
)
const hideLandingRailing = const hideLandingRailing =
layout.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing' &&
previousLayout?.segment.segmentType === 'stair' && previousLayout?.segment.segmentType === 'stair' &&
@@ -586,11 +707,18 @@ function buildStairRailPaths(
layout, layout,
sidePaths: sidePaths:
isStraightLineDoubleLandingLayout && index === 1 isStraightLineDoubleLandingLayout && index === 1
? (['left', 'right'] as const).map((side) => buildSegmentRailPath(layouts, index, side, landingInset)) ? (['left', 'right'] as const).map((side) =>
: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)), buildSegmentRailPath(layouts, index, side, landingInset),
)
: sideCandidates.map((side) =>
buildSegmentRailPath(layouts, index, side, landingInset),
),
connectFromPrevious: connectFromPrevious:
index > 0 && index > 0 &&
!(previousLayout?.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing'), !(
previousLayout?.segment.segmentType === 'landing' &&
layout.segment.segmentType === 'landing'
),
} }
}) })
} }
@@ -607,7 +735,10 @@ function buildStairRailPaths(
return layouts.map((layout, index) => { return layouts.map((layout, index) => {
const previousLayout = index > 0 ? layouts[index - 1] : undefined const previousLayout = index > 0 ? layouts[index - 1] : undefined
const nextLayout = layouts[index + 1] const nextLayout = layouts[index + 1]
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index) const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(
layouts,
index,
)
const isMiddleLandingBetweenFlights = const isMiddleLandingBetweenFlights =
layout.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing' &&
previousLayout?.segment.segmentType === 'stair' && previousLayout?.segment.segmentType === 'stair' &&
@@ -626,11 +757,12 @@ function buildStairRailPaths(
suppressMiddleLandingOnPreferredTurnSide suppressMiddleLandingOnPreferredTurnSide
const landingContinuesOnPreferredSide = const landingContinuesOnPreferredSide =
layout.segment.segmentType === 'landing' layout.segment.segmentType === 'landing'
? nextAttachmentSide == null || nextAttachmentSide === 'front' || nextAttachmentSide === railingMode ? nextAttachmentSide == null ||
nextAttachmentSide === 'front' ||
nextAttachmentSide === railingMode
: true : true
const sideCandidates = const sideCandidates = suppressLandingRailing
suppressLandingRailing
? ([] as StairRailPathSide[]) ? ([] as StairRailPathSide[])
: layout.segment.segmentType !== 'landing' : layout.segment.segmentType !== 'landing'
? [railingMode] ? [railingMode]
@@ -667,7 +799,9 @@ function buildStairRailPaths(
return { return {
layout, layout,
sidePaths: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)), sidePaths: sideCandidates.map((side) =>
buildSegmentRailPath(layouts, index, side, landingInset),
),
connectFromPrevious: connectFromPrevious:
index > 0 && index > 0 &&
!suppressLandingRailing && !suppressLandingRailing &&
@@ -677,7 +811,10 @@ function buildStairRailPaths(
}) })
} }
function resolveLandingChainNextStair(layouts: StairRailLayout[], index: number): LandingChainNextStair { function resolveLandingChainNextStair(
layouts: StairRailLayout[],
index: number,
): LandingChainNextStair {
const layout = layouts[index] const layout = layouts[index]
if (!layout || layout.segment.segmentType !== 'landing') { if (!layout || layout.segment.segmentType !== 'landing') {
return { isTerminalLandingBeforeStair: false } return { isTerminalLandingBeforeStair: false }
@@ -728,9 +865,13 @@ function buildSegmentRailPath(
const stepHeight = segment.segmentType === 'landing' ? 0 : segment.height / steps const stepHeight = segment.segmentType === 'landing' ? 0 : segment.height / steps
const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045 const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
const flightStartX = const flightStartX =
previousLayout?.segment.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2 previousLayout?.segment.segmentType === 'landing'
? -segment.length / 2 + landingInset
: -segment.length / 2
const flightEndX = const flightEndX =
nextLayout?.segment.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2 nextLayout?.segment.segmentType === 'landing'
? segment.length / 2 - landingInset
: segment.length / 2
const landingFrontX = const landingFrontX =
previousLayout?.segment.segmentType === 'stair' && previousLayout?.segment.segmentType === 'stair' &&
segment.attachmentSide && segment.attachmentSide &&
@@ -767,7 +908,13 @@ function buildSegmentRailPath(
return { return {
side, side,
points: [ points: [
...(previousLayout?.segment.segmentType === 'landing' ? [] : ([[flightStartX, stepHeight > 0 ? stepHeight : 0, flightSideOffset]] as [number, number, number][])), ...(previousLayout?.segment.segmentType === 'landing'
? []
: ([[flightStartX, stepHeight > 0 ? stepHeight : 0, flightSideOffset]] as [
number,
number,
number,
][])),
...Array.from({ length: steps }).map( ...Array.from({ length: steps }).map(
(_, index) => (_, index) =>
[ [
@@ -783,7 +930,10 @@ function buildSegmentRailPath(
} }
} }
function toWorldRailPoint(layout: StairRailLayout, point: [number, number, number]): [number, number, number] { function toWorldRailPoint(
layout: StairRailLayout,
point: [number, number, number],
): [number, number, number] {
const [localX, localY, localZ] = point const [localX, localY, localZ] = point
const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation) const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation)
return [layout.center[0] + offsetX, layout.elevation + localY, layout.center[1] + offsetZ] return [layout.center[0] + offsetX, layout.elevation + localY, layout.center[1] + offsetZ]
@@ -798,7 +948,10 @@ function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransfor
const segment = segments[i]! const segment = segments[i]!
if (i === 0) { if (i === 0) {
transforms.push({ position: [currentPos.x, currentPos.y, currentPos.z], rotation: currentRot }) transforms.push({
position: [currentPos.x, currentPos.y, currentPos.z],
rotation: currentRot,
})
continue continue
} }
@@ -1,6 +1,6 @@
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Color, Layers, UnsignedByteType } from 'three' import { Color, Layers, type Object3D, UnsignedByteType } from 'three'
import { ssgi } from 'three/addons/tsl/display/SSGINode.js' import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import { import {
@@ -47,6 +47,21 @@ const RETRY_DELAY_MS = 500
const DARK_BG = '#1f2433' const DARK_BG = '#1f2433'
const LIGHT_BG = '#ffffff' const LIGHT_BG = '#ffffff'
function sanitizeOutlineObjects(objects: Object3D[]) {
let nextIndex = 0
for (const object of objects) {
if (!(object && typeof object.id === 'number' && object.parent)) {
continue
}
objects[nextIndex] = object
nextIndex++
}
objects.length = nextIndex
}
const PostProcessingPasses = () => { const PostProcessingPasses = () => {
const { gl: renderer, scene, camera } = useThree() const { gl: renderer, scene, camera } = useThree()
const renderPipelineRef = useRef<RenderPipeline | null>(null) const renderPipelineRef = useRef<RenderPipeline | null>(null)
@@ -138,6 +153,8 @@ const PostProcessingPasses = () => {
// Clear outliner arrays synchronously to prevent stale Object3D refs // Clear outliner arrays synchronously to prevent stale Object3D refs
// from the previous project leaking into the new pipeline's outline passes. // from the previous project leaking into the new pipeline's outline passes.
const outliner = useViewer.getState().outliner const outliner = useViewer.getState().outliner
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
outliner.selectedObjects.length = 0 outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0 outliner.hoveredObjects.length = 0
@@ -289,6 +306,10 @@ const PostProcessingPasses = () => {
bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)
bgUniform.current.value.copy(bgCurrent.current) bgUniform.current.value.copy(bgCurrent.current)
const outliner = useViewer.getState().outliner
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
if (hasPipelineErrorRef.current || !renderPipelineRef.current) { if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
try { try {
if ((renderer as any).setClearAlpha) { if ((renderer as any).setClearAlpha) {