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 { LevelNode } from '../schema/nodes/level'
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 * 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> {
const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) {
@@ -50,6 +139,20 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
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>
}
@@ -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 * 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'
import { NodeRenderer } from '../node-renderer'
type SegmentTransform = {
@@ -37,6 +47,7 @@ type LandingChainNextStair = {
export const StairRenderer = ({ node }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!)
const isSegmentBasedStair = node.stairType === 'straight'
useRegistry(node.id, 'stair', ref)
@@ -52,7 +63,13 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
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 (
<group
@@ -63,18 +80,20 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
visible={node.visible}
{...handlers}
>
<mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} />
</mesh>
{node.stairType === 'curved' || node.stairType === 'spiral' ? (
<CurvedStairBody material={material} stair={node} />
{isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} />
</mesh>
) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null}
<StairRailings material={material} stair={node} />
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
) : null}
</group>
)
}
@@ -86,11 +105,17 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
() =>
(stair.children ?? [])
.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],
)
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 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') {
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 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 leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 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) => (
<group
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}
>
{segmentPath.sidePaths.map((sidePath, sideIndex) => (
@@ -204,7 +237,9 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
if (!nextPoint) return null
return (
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}>
<group
key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}
>
<RailSegment
end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]}
material={material}
@@ -240,14 +275,15 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const lastPoint = entry.points[entry.points.length - 1]
return {
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
const previousPoint =
previousSidePath && previousSidePath.points.length
? previousSidePath.points[previousSidePath.points.length - 1]
: null
const previousPoint = previousSidePath?.points.length
? previousSidePath.points[previousSidePath.points.length - 1]
: null
if (!(previousPoint && currentPoint)) {
return null
@@ -256,18 +292,36 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const previousWorldPoint = toWorldRailPoint(previousPath.layout, previousPoint)
return (
<group key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}>
<group
key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}
>
<RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + railHeight, currentWorldPoint[2]]}
end={[
currentWorldPoint[0],
currentWorldPoint[1] + railHeight,
currentWorldPoint[2],
]}
material={material}
radius={railRadius}
start={[previousWorldPoint[0], previousWorldPoint[1] + railHeight, previousWorldPoint[2]]}
start={[
previousWorldPoint[0],
previousWorldPoint[1] + railHeight,
previousWorldPoint[2],
]}
/>
<RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + midRailHeight, currentWorldPoint[2]]}
end={[
currentWorldPoint[0],
currentWorldPoint[1] + midRailHeight,
currentWorldPoint[2],
]}
material={material}
radius={railRadius * 0.8}
start={[previousWorldPoint[0], previousWorldPoint[1] + midRailHeight, previousWorldPoint[2]]}
start={[
previousWorldPoint[0],
previousWorldPoint[1] + midRailHeight,
previousWorldPoint[2],
]}
/>
</group>
)
@@ -296,10 +350,17 @@ function RailSegment({
const direction = useMemo(() => endVector.clone().sub(startVector), [endVector, startVector])
const length = Math.max(direction.length(), 0.01)
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],
)
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 (
<mesh
@@ -327,11 +388,16 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
const fillToFloor = stair.fillToFloor ?? true
const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03))
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 =
isSpiral && (stair.topLandingMode ?? 'none') === 'integrated'
? Math.min(Math.PI * 0.75, spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1)) *
Math.sign(sweepAngle || 1)
? Math.min(
Math.PI * 0.75,
spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1),
) * Math.sign(sweepAngle || 1)
: 0
const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness
const spiralLandingThickness =
@@ -342,28 +408,52 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
return (
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
{isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh castShadow receiveShadow material={material} position={[0, spiralColumnHeight / 2, 0]}>
<cylinderGeometry args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]} />
<mesh
castShadow
receiveShadow
material={material}
position={[0, spiralColumnHeight / 2, 0]}
>
<cylinderGeometry
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
/>
</mesh>
) : null}
{Array.from({ length: stepCount }).map((_, index) => {
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 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
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) ? (
<mesh
castShadow
material={material}
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.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
rotation-y={-midAngle}
@@ -426,11 +516,20 @@ function CurvedStepMesh({
material: THREE.Material
}) {
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],
)
return <mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
return (
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
)
}
function buildCurvedStepGeometry(
@@ -445,7 +544,15 @@ function buildCurvedStepGeometry(
const y1 = clampedHeight
const sweepAngle = endAngle - startAngle
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 normals: number[] = []
@@ -453,7 +560,12 @@ function buildCurvedStepGeometry(
const pointOnArc = (radius: number, angle: number, y: number) =>
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 edgeAC = c.clone().sub(a)
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, c, d, normal)
}
@@ -548,7 +666,10 @@ function buildStairRailPaths(
return layouts.map((layout, index) => {
const previousLayout = index > 0 ? layouts[index - 1] : undefined
const nextLayout = layouts[index + 1]
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index)
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(
layouts,
index,
)
const hideLandingRailing =
layout.segment.segmentType === 'landing' &&
previousLayout?.segment.segmentType === 'stair' &&
@@ -565,32 +686,39 @@ function buildStairRailPaths(
? (['front', 'left'] as const)
: (['left', 'right'] as const)
: hideLandingRailing
? visualTurnSide === 'left'
? (['front', 'right'] as const)
: visualTurnSide === 'right'
? (['front', 'left'] as const)
: (['left', 'right'] as const)
: layout.segment.segmentType === 'landing'
? nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'left'
? visualTurnSide === 'left'
? (['front', 'right'] as const)
: nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'right'
: visualTurnSide === 'right'
? (['front', 'left'] as const)
: visualTurnSide === 'left'
? (['right'] as const)
: visualTurnSide === 'right'
? (['left'] as const)
: (['left', 'right'] as const)
: (['left', 'right'] as const)
: (['left', 'right'] as const)
: layout.segment.segmentType === 'landing'
? nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'left'
? (['front', 'right'] as const)
: nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'right'
? (['front', 'left'] as const)
: visualTurnSide === 'left'
? (['right'] as const)
: visualTurnSide === 'right'
? (['left'] as const)
: (['left', 'right'] as const)
: (['left', 'right'] as const)
return {
layout,
sidePaths:
isStraightLineDoubleLandingLayout && index === 1
? (['left', 'right'] as const).map((side) => buildSegmentRailPath(layouts, index, side, landingInset))
: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)),
? (['left', 'right'] as const).map((side) =>
buildSegmentRailPath(layouts, index, side, landingInset),
)
: sideCandidates.map((side) =>
buildSegmentRailPath(layouts, index, side, landingInset),
),
connectFromPrevious:
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) => {
const previousLayout = index > 0 ? layouts[index - 1] : undefined
const nextLayout = layouts[index + 1]
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index)
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(
layouts,
index,
)
const isMiddleLandingBetweenFlights =
layout.segment.segmentType === 'landing' &&
previousLayout?.segment.segmentType === 'stair' &&
@@ -626,28 +757,29 @@ function buildStairRailPaths(
suppressMiddleLandingOnPreferredTurnSide
const landingContinuesOnPreferredSide =
layout.segment.segmentType === 'landing'
? nextAttachmentSide == null || nextAttachmentSide === 'front' || nextAttachmentSide === railingMode
? nextAttachmentSide == null ||
nextAttachmentSide === 'front' ||
nextAttachmentSide === railingMode
: true
const sideCandidates =
suppressLandingRailing
? ([] as StairRailPathSide[])
: layout.segment.segmentType !== 'landing'
? [railingMode]
: isTerminalLandingBeforeStair
? railingMode === 'left'
? terminalNextAttachmentSide === 'right'
? (['front', 'left'] as const)
const sideCandidates = suppressLandingRailing
? ([] as StairRailPathSide[])
: layout.segment.segmentType !== 'landing'
? [railingMode]
: isTerminalLandingBeforeStair
? railingMode === 'left'
? terminalNextAttachmentSide === 'right'
? (['front', 'left'] as const)
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
? (['left'] as const)
: ([] as StairRailPathSide[])
: railingMode === 'right'
? terminalNextAttachmentSide === 'left'
? (['front', 'right'] as const)
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
? (['left'] as const)
? (['right'] as const)
: ([] as StairRailPathSide[])
: railingMode === 'right'
? terminalNextAttachmentSide === 'left'
? (['front', 'right'] as const)
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
? (['right'] as const)
: ([] as StairRailPathSide[])
: [railingMode]
: [railingMode]
: isStraightLineDoubleLandingLayout
? [railingMode]
: isMiddleLandingBetweenFlights && railingMode === 'left'
@@ -667,7 +799,9 @@ function buildStairRailPaths(
return {
layout,
sidePaths: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)),
sidePaths: sideCandidates.map((side) =>
buildSegmentRailPath(layouts, index, side, landingInset),
),
connectFromPrevious:
index > 0 &&
!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]
if (!layout || layout.segment.segmentType !== 'landing') {
return { isTerminalLandingBeforeStair: false }
@@ -728,9 +865,13 @@ function buildSegmentRailPath(
const stepHeight = segment.segmentType === 'landing' ? 0 : segment.height / steps
const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
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 =
nextLayout?.segment.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2
nextLayout?.segment.segmentType === 'landing'
? segment.length / 2 - landingInset
: segment.length / 2
const landingFrontX =
previousLayout?.segment.segmentType === 'stair' &&
segment.attachmentSide &&
@@ -767,7 +908,13 @@ function buildSegmentRailPath(
return {
side,
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(
(_, 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 [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation)
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]!
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
}
@@ -1,6 +1,6 @@
import { useFrame, useThree } from '@react-three/fiber'
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 { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import {
@@ -47,6 +47,21 @@ const RETRY_DELAY_MS = 500
const DARK_BG = '#1f2433'
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 { gl: renderer, scene, camera } = useThree()
const renderPipelineRef = useRef<RenderPipeline | null>(null)
@@ -138,6 +153,8 @@ const PostProcessingPasses = () => {
// Clear outliner arrays synchronously to prevent stale Object3D refs
// from the previous project leaking into the new pipeline's outline passes.
const outliner = useViewer.getState().outliner
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0
@@ -289,6 +306,10 @@ const PostProcessingPasses = () => {
bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)
bgUniform.current.value.copy(bgCurrent.current)
const outliner = useViewer.getState().outliner
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
try {
if ((renderer as any).setClearAlpha) {