ai-chatbot/packages/nodes/src/stair/floor-stack.ts
Aymeric Rabot 0b338cf647
feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)
- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged.
- Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass.
- Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors.
- NaN-safe node mutations + non-finite shadow-light bounds guard.
- Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
2026-06-05 16:24:48 -04:00

115 lines
3.1 KiB
TypeScript

import type {
AnyNode,
AnyNodeId,
FloorPlacedFootprint,
StairNode,
StairSegmentNode,
} from '@pascal-app/core'
type SegmentTransform = {
position: [number, number, number]
rotation: number
}
export function getStairFloorPlacedFootprints(
stair: StairNode,
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
): FloorPlacedFootprint[] {
const segments = (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId])
.filter((node): node is StairSegmentNode => node?.type === 'stair-segment')
return getStairSegmentFloorPlacedFootprints(stair, segments)
}
export function getStairSegmentFloorPlacedFootprints(
stair: StairNode,
segments: readonly StairSegmentNode[],
): FloorPlacedFootprint[] {
const transforms = computeStairSegmentFloorStackTransforms(segments)
return segments.map((segment, index) => {
const transform = transforms[index]!
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,
stair.rotation,
)
return {
position: [
stair.position[0] + centerOffsetWorldX,
stair.position[1] + transform.position[1],
stair.position[2] + centerOffsetWorldZ,
],
dimensions: [
segment.width,
Math.max(segment.height, segment.thickness, 0.01),
segment.length,
],
rotation: [0, stair.rotation + transform.rotation, 0],
}
})
}
export function computeStairSegmentFloorStackTransforms(
segments: readonly StairSegmentNode[],
): SegmentTransform[] {
const transforms: SegmentTransform[] = []
let currentX = 0
let currentY = 0
let currentZ = 0
let currentRot = 0
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index]!
if (index > 0) {
const previous = segments[index - 1]!
let attachX = 0
let attachZ = 0
let rotationDelta = 0
switch (segment.attachmentSide) {
case 'front':
attachX = 0
attachZ = previous.length
rotationDelta = 0
break
case 'left':
attachX = previous.width / 2
attachZ = previous.length / 2
rotationDelta = Math.PI / 2
break
case 'right':
attachX = -previous.width / 2
attachZ = previous.length / 2
rotationDelta = -Math.PI / 2
break
}
const [rotatedX, rotatedZ] = rotateXZ(attachX, attachZ, currentRot)
currentX += rotatedX
currentY += previous.height
currentZ += rotatedZ
currentRot += rotationDelta
}
transforms.push({
position: [currentX, currentY, currentZ],
rotation: currentRot,
})
}
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]
}