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.
This commit is contained in:
Aymeric Rabot
2026-06-05 16:24:48 -04:00
committed by GitHub
parent d1b40aa98d
commit 0b338cf647
51 changed files with 3942 additions and 1418 deletions
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeDefinition,
getFloorPlacedElevation,
nodeRegistry,
registerNode,
type SlabNode,
StairNode,
StairSegmentNode,
spatialGridManager,
} from '@pascal-app/core'
import { stairDefinition } from './definition'
import { getStairSegmentFloorPlacedFootprints } from './floor-stack'
const LEVEL_ID = 'level_test'
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab_${elevation}`) {
const slab = {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
} as SlabNode
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
describe('stair floor-stack footprints', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
})
test('derives a rotated segment footprint from stair position and rotation', () => {
const segment = StairSegmentNode.parse({
id: 'sseg_single',
width: 2,
length: 4,
height: 1,
thickness: 0.25,
})
const stair = StairNode.parse({
id: 'stair_single',
parentId: LEVEL_ID,
position: [10, 0, 20],
rotation: Math.PI / 2,
children: [segment.id],
})
const [footprint] = getStairSegmentFloorPlacedFootprints(stair, [segment])
expect(footprint?.position?.[0]).toBeCloseTo(12)
expect(footprint?.position?.[1]).toBeCloseTo(0)
expect(footprint?.position?.[2]).toBeCloseTo(20)
expect(footprint?.dimensions).toEqual([2, 1, 4])
expect(footprint?.rotation[1]).toBeCloseTo(Math.PI / 2)
})
test('emits one footprint per chained stair segment', () => {
const first = StairSegmentNode.parse({
id: 'sseg_first',
width: 2,
length: 4,
height: 1,
thickness: 0.25,
})
const second = StairSegmentNode.parse({
id: 'sseg_second',
attachmentSide: 'left',
width: 1.5,
length: 3,
height: 0.8,
thickness: 0.2,
})
const stair = StairNode.parse({
id: 'stair_multi',
parentId: LEVEL_ID,
position: [0, 0, 0],
rotation: 0,
children: [first.id, second.id],
})
const footprints = getStairSegmentFloorPlacedFootprints(stair, [first, second])
expect(footprints).toHaveLength(2)
expect(footprints[0]?.position).toEqual([0, 0, 2])
expect(footprints[0]?.rotation[1]).toBeCloseTo(0)
expect(footprints[1]?.position?.[0]).toBeCloseTo(2.5)
expect(footprints[1]?.position?.[1]).toBeCloseTo(1)
expect(footprints[1]?.position?.[2]).toBeCloseTo(2)
expect(footprints[1]?.rotation[1]).toBeCloseTo(Math.PI / 2)
})
test('uses the max slab elevation across stair segment footprints', () => {
registerNode(stairDefinition as unknown as AnyNodeDefinition)
addSlab(
[
[-0.6, 1.4],
[0.6, 1.4],
[0.6, 2.6],
[-0.6, 2.6],
],
0.25,
'slab_low',
)
addSlab(
[
[2.2, 1.7],
[2.8, 1.7],
[2.8, 2.3],
[2.2, 2.3],
],
0.75,
'slab_high',
)
const level = makeLevel()
const first = StairSegmentNode.parse({
id: 'sseg_resolver_first',
width: 2,
length: 4,
height: 1,
})
const second = StairSegmentNode.parse({
id: 'sseg_resolver_second',
attachmentSide: 'left',
width: 1.5,
length: 3,
height: 0.8,
})
const stair = StairNode.parse({
id: 'stair_resolver',
parentId: LEVEL_ID,
position: [0, 0, 0],
rotation: 0,
children: [first.id, second.id],
})
const nodes = {
[level.id]: level,
[stair.id]: stair,
[first.id]: first,
[second.id]: second,
}
expect(
getFloorPlacedElevation({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: LEVEL_ID,
}),
).toBeCloseTo(0.75)
})
})