diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 83803afa..543ac30a 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -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( + 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) { + 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) { + 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): Record { const patchedNodes = { ...nodes } for (const [id, node] of Object.entries(patchedNodes)) { @@ -50,6 +139,20 @@ function migrateNodes(nodes: Record): Record { 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 } diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index eb3117e3..0eb5795f 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -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(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 ( { visible={node.visible} {...handlers} > - - - - {node.stairType === 'curved' || node.stairType === 'spiral' ? ( - + {isSegmentBasedStair ? ( + + + ) : null} + {!isSegmentBasedStair ? : null} - - {(node.children ?? []).map((childId) => ( - - ))} - + {isSegmentBasedStair ? ( + + {(node.children ?? []).map((childId) => ( + + ))} + + ) : null} ) } @@ -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) => ( {segmentPath.sidePaths.map((sidePath, sideIndex) => ( @@ -204,7 +237,9 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. if (!nextPoint) return null return ( - + 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 ( - + ) @@ -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 ( {isSpiral && (stair.showCenterColumn ?? true) ? ( - - + + ) : 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 ( - + {isSpiral && (stair.showStepSupports ?? true) ? ( 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 + return ( + + ) } 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 } diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index ef3fa646..91ab442f 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -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(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) {