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