From d9965f487ce05be08d9f236307890e0897fc79b2 Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 6 Apr 2026 12:02:57 -0400 Subject: [PATCH] fix: sync stair elevations with floor slabs (#216) Mark stair nodes as dirty when slabs change so stairs properly stack on top of floor slabs. StairSystem now computes slab-driven elevation by sampling segment centers against the spatial grid. Changes: - spatial-grid-sync: mark stair nodes dirty on slab changes (same level) - stair-system: syncStairGroupElevation + getStairSlabElevation compute Y offset from slab data; rotateXZ helper for world-space transforms - stair-renderer: use position-x/position-z so the system controls Y --- .../hooks/spatial-grid/spatial-grid-sync.ts | 5 +- .../core/src/systems/stair/stair-system.tsx | 68 +++++++++++++++++++ .../renderers/stair/stair-renderer.tsx | 3 +- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 250a0f31..d64dbd7c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -124,7 +124,7 @@ function arraysEqual(a: number[], b: number[]): boolean { } /** - * Mark all floor items and walls that overlap a slab polygon as dirty. + * Mark all floor items, walls, and stairs that may be affected by a slab change as dirty. */ function markNodesOverlappingSlab( slab: SlabNode, @@ -157,6 +157,9 @@ function markNodesOverlappingSlab( if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) { markDirty(node.id) } + } else if (node.type === 'stair') { + if (resolveLevelId(node, nodes) !== slabLevelId) continue + markDirty(node.id) } } } diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx index bfa5091b..d85a8c14 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -2,6 +2,8 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' import useScene from '../../store/use-scene' @@ -75,6 +77,10 @@ export const StairSystem = () => { for (const stairId of parentsNeedingSegmentSync) { const stairNode = nodes[stairId] if (!stairNode || stairNode.type !== 'stair') continue + const group = sceneRegistry.nodes.get(stairId) as THREE.Group | undefined + if (group) { + syncStairGroupElevation(stairNode as StairNode, group, nodes) + } syncSegmentMeshTransforms(stairNode as StairNode, nodes) } @@ -224,6 +230,62 @@ function syncSegmentMeshTransforms(stairNode: StairNode, nodes: Record, +) { + const levelId = resolveLevelId(stairNode, nodes) + const slabElevation = getStairSlabElevation(levelId, stairNode, nodes) + group.position.y = stairNode.position[1] + slabElevation +} + +function getStairSlabElevation( + levelId: string, + stairNode: StairNode, + nodes: Record, +): number { + const segments = (stairNode.children ?? []) + .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) + .filter((n): n is StairSegmentNode => n?.type === 'stair-segment') + + if (segments.length === 0) return 0 + + const transforms = computeSegmentTransforms(segments) + let maxElevation = Number.NEGATIVE_INFINITY + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]! + const transform = transforms[i]! + + const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation) + const centerInGroupX = transform.position[0] + centerOffsetX + const centerInGroupZ = transform.position[2] + centerOffsetZ + const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ( + centerInGroupX, + centerInGroupZ, + stairNode.rotation, + ) + + const slabElevation = spatialGridManager.getSlabElevationForItem( + levelId, + [ + stairNode.position[0] + centerOffsetWorldX, + stairNode.position[1] + transform.position[1], + stairNode.position[2] + centerOffsetWorldZ, + ], + [segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length], + [0, stairNode.rotation + transform.rotation, 0], + ) + + if (slabElevation > maxElevation) { + maxElevation = slabElevation + } + } + + return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation +} + // ============================================================================ // MERGED STAIR GEOMETRY // ============================================================================ @@ -348,6 +410,12 @@ function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransfor return transforms } +function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + /** * Computes the absolute Y height of a segment by traversing the stair's segment chain. */ diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index e3a659b9..efeb24ad 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -24,7 +24,8 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { return (