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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user