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,365 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, SlabNode } from '../../schema'
import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
const LEVEL_ID = 'level_test'
function makeDefinition(
kind: AnyNode['type'],
capabilities: AnyNodeDefinition['capabilities'] = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
capabilities,
}
}
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
return {
id: 'item_test',
type: 'item',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'asset_test',
category: 'test',
name: 'Test',
thumbnail: '',
src: 'asset:test',
dimensions: [1, 1, 1],
source: 'library',
},
...overrides,
} 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)
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
describe('floor-placed elevation resolver', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
})
test('returns 0 without a floorPlaced capability', () => {
registerNode(makeDefinition('item'))
addSlab(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
],
0.4,
)
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBe(0)
})
test('returns 0 when applies returns false', () => {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
applies: () => false,
},
}),
)
addSlab(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
],
0.4,
)
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBe(0)
})
test('clamps non-finite slab elevation to 0', () => {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
const original = spatialGridManager.getSlabElevationForItem
spatialGridManager.getSlabElevationForItem = (() => Number.NaN) as typeof original
try {
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBe(0)
} finally {
spatialGridManager.getSlabElevationForItem = original
}
})
test('returns 0 for a non-level direct parent', () => {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
addSlab(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
],
0.4,
)
const level = makeLevel()
const shelf = {
id: 'shelf_test',
type: 'shelf',
parentId: LEVEL_ID,
} as unknown as AnyNode
const node = makeFloorNode({ parentId: shelf.id })
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, shelf, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBe(0)
})
test('returns 0 when the declared parent is missing', () => {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
addSlab(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
],
0.4,
)
const node = makeFloorNode({ parentId: 'missing_level' })
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(node),
position: [0, 0, 0],
rotation: [0, 0, 0],
levelId: LEVEL_ID,
}),
).toBe(0)
})
test('uses the pending rotated footprint', () => {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: (node) => ({
dimensions: [4, 1, 1],
rotation: (node as { rotation: [number, number, number] }).rotation,
}),
},
}),
)
addSlab(
[
[-0.2, 1.2],
[0.2, 1.2],
[0.2, 1.8],
[-0.2, 1.8],
],
0.45,
)
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, Math.PI / 2, 0],
}),
).toBeCloseTo(0.45)
})
test('returns slab overlap elevation and stacks Y onto canonical position', () => {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: (node) => ({
dimensions: [1, 1, 1],
rotation: (node as { rotation: [number, number, number] }).rotation,
}),
},
}),
)
addSlab(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
],
0.35,
)
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0.1, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.35)
const stacked = getFloorStackedPosition({
node,
nodes: nodesFor(level, node),
position: [0, 0.1, 0],
rotation: [0, 0, 0],
})
expect(stacked[0]).toBe(0)
expect(stacked[1]).toBeCloseTo(0.45)
expect(stacked[2]).toBe(0)
})
test('takes the max elevation across composite footprints', () => {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprints: () => [
{ position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
{ position: [3, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
],
},
}),
)
addSlab(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
],
0.2,
'slab_low',
)
addSlab(
[
[2.5, -0.5],
[3.5, -0.5],
[3.5, 0.5],
[2.5, 0.5],
],
0.8,
'slab_high',
)
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.8)
})
})
@@ -0,0 +1,93 @@
import { nodeRegistry } from '../../registry'
import type {
FloorPlacedConfig,
FloorPlacedFootprint,
FloorPlacedFootprintContext,
FloorPlacedFootprintsResolver,
} from '../../registry/types'
import type { AnyNode, AnyNodeId } from '../../schema'
import { spatialGridManager } from './spatial-grid-manager'
export type FloorPlacedElevationArgs = {
node: AnyNode
nodes: Record<string, AnyNode>
position: [number, number, number]
rotation?: unknown
levelId?: string | null
}
function finiteSlabElevation(elevation: number): number {
return Number.isFinite(elevation) ? elevation : 0
}
function withPositionAndRotation({
node,
position,
rotation,
}: Pick<FloorPlacedElevationArgs, 'node' | 'position' | 'rotation'>): AnyNode {
return {
...(node as Record<string, unknown>),
position,
...(rotation !== undefined ? { rotation } : {}),
} as AnyNode
}
export function getFloorPlacedFootprints(
floorPlaced: FloorPlacedConfig,
node: AnyNode,
ctx?: FloorPlacedFootprintContext,
): FloorPlacedFootprint[] {
const rawFootprints = floorPlaced.footprints?.(node, ctx)
if (rawFootprints) return [...rawFootprints]
const footprint = floorPlaced.footprint?.(node, ctx)
return footprint ? [footprint] : []
}
export function getFloorPlacedElevation({
node,
nodes,
position,
rotation,
levelId,
}: FloorPlacedElevationArgs): number {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced) return 0
const effectiveNode = withPositionAndRotation({ node, position, rotation })
if (floorPlaced.applies && !floorPlaced.applies(effectiveNode)) return 0
const parentId = (effectiveNode as { parentId?: AnyNodeId | null }).parentId ?? null
const parent = parentId ? nodes[parentId] : null
if (parentId && !parent) return 0
if (parent && parent.type !== 'level') return 0
if (!parent && !levelId) return 0
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
if (!resolvedLevelId) return 0
let maxElevation = Number.NEGATIVE_INFINITY
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
const footprintPosition = footprint.position ?? position
const elevation = finiteSlabElevation(
spatialGridManager.getSlabElevationForItem(
resolvedLevelId,
footprintPosition,
footprint.dimensions,
footprint.rotation,
),
)
if (elevation > maxElevation) {
maxElevation = elevation
}
}
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
}
export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
const [x, y, z] = args.position
return [x, y + getFloorPlacedElevation(args), z]
}
export type { FloorPlacedFootprint, FloorPlacedFootprintContext, FloorPlacedFootprintsResolver }
@@ -1,6 +1,7 @@
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import {
itemOverlapsPolygon,
spatialGridManager,
@@ -152,7 +153,7 @@ function arraysEqual(a: number[], b: number[]): boolean {
}
/**
* Mark all floor items, walls, and stairs that may be affected by a slab change as dirty.
* Mark all floor items and walls that may be affected by a slab change as dirty.
*/
function markNodesOverlappingSlab(
slab: SlabNode,
@@ -181,12 +182,6 @@ function markNodesOverlappingSlab(
}
continue
}
if (node.type === 'stair') {
if (resolveLevelId(node, nodes) !== slabLevelId) continue
markDirty(node.id)
continue
}
// Generic floor-placed sweep: any registry kind that opts in via
// `capabilities.floorPlaced` (item / shelf / column / spawn / …)
// re-elevates through `<FloorElevationSystem>` when a slab below
@@ -196,12 +191,25 @@ function markNodesOverlappingSlab(
const floorPlaced = def?.capabilities?.floorPlaced
if (!floorPlaced) continue
if (floorPlaced.applies && !floorPlaced.applies(node)) continue
const parentId = node.parentId as AnyNodeId | null
const parent = parentId ? nodes[parentId] : null
if (parent && parent.type !== 'level') continue
if (resolveLevelId(node, nodes) !== slabLevelId) continue
const position = (node as { position?: [number, number, number] }).position
if (!position) continue
const { dimensions, rotation } = floorPlaced.footprint(node)
if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
markDirty(node.id)
for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
if (
itemOverlapsPolygon(
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
slab.polygon,
0.01,
)
) {
markDirty(node.id)
break
}
}
}
}