- 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.
807 lines
27 KiB
TypeScript
807 lines
27 KiB
TypeScript
import type {
|
||
AnyNode,
|
||
AnyNodeId,
|
||
CeilingEvent,
|
||
CeilingNode,
|
||
GridEvent,
|
||
ItemEvent,
|
||
ItemNode,
|
||
ShelfEvent,
|
||
ShelfNode,
|
||
WallEvent,
|
||
WallNode,
|
||
} from '@pascal-app/core'
|
||
import {
|
||
getScaledDimensions,
|
||
isLowProfileItemSurface,
|
||
nodeRegistry,
|
||
sceneRegistry,
|
||
useScene,
|
||
} from '@pascal-app/core'
|
||
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
||
import {
|
||
calculateCursorRotation,
|
||
calculateItemRotation,
|
||
getGridAlignedDimensions,
|
||
getSideFromNormal,
|
||
isValidWallSideFace,
|
||
snapToGrid,
|
||
snapToHalf,
|
||
stripTransient,
|
||
} from './placement-math'
|
||
import type {
|
||
CommitResult,
|
||
LevelResolver,
|
||
PlacementContext,
|
||
PlacementResult,
|
||
SpatialValidators,
|
||
TransitionResult,
|
||
} from './placement-types'
|
||
|
||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||
const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
|
||
|
||
function getWorldNormalY(event: ItemEvent): number | null {
|
||
if (!event.normal) return null
|
||
|
||
const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
|
||
normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
|
||
return normal.y
|
||
}
|
||
|
||
function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
|
||
const normalY = getWorldNormalY(event)
|
||
return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
|
||
}
|
||
|
||
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
|
||
if (isLowProfileItemSurface(surfaceItem)) return null
|
||
if (!isUpwardItemSurfaceHit(event)) return null
|
||
|
||
if (surfaceItem.asset.surface) {
|
||
return surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
||
}
|
||
|
||
if (!Number.isFinite(localPos.y)) return null
|
||
return localPos.y
|
||
}
|
||
|
||
function isDescendantOfItem(
|
||
candidate: ItemNode,
|
||
ancestor: ItemNode,
|
||
nodes: Record<string, AnyNode>,
|
||
): boolean {
|
||
let parentId = candidate.parentId
|
||
while (parentId) {
|
||
if (parentId === ancestor.id) return true
|
||
const parent = nodes[parentId as AnyNodeId]
|
||
parentId = parent?.parentId ?? null
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ============================================================================
|
||
// FLOOR STRATEGY
|
||
// ============================================================================
|
||
|
||
export const floorStrategy = {
|
||
/**
|
||
* Handle grid:move — update position when on floor surface.
|
||
* Returns null if currently on wall/ceiling.
|
||
*/
|
||
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
||
if (ctx.state.surface !== 'floor') return null
|
||
|
||
const rawDims = ctx.draftItem
|
||
? getScaledDimensions(ctx.draftItem)
|
||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
|
||
const [dimX, , dimZ] = dims
|
||
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||
// event.localPosition is building-local; the coordinator cursor group is inside the
|
||
// building-local ToolManager group, so local coords are correct for both data and visuals.
|
||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||
const y = ctx.gridPosition.y
|
||
|
||
return {
|
||
gridPosition: [x, y, z],
|
||
cursorPosition: [x, y, z],
|
||
cursorRotationY: rotY,
|
||
nodeUpdate: { position: [x, y, z] },
|
||
stopPropagation: false,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle grid:click — commit placement on floor.
|
||
* Returns null if on wall/ceiling or validation fails.
|
||
*/
|
||
click(
|
||
ctx: PlacementContext,
|
||
_event: GridEvent,
|
||
validators: SpatialValidators,
|
||
): CommitResult | null {
|
||
if (ctx.state.surface !== 'floor') return null
|
||
if (!(ctx.levelId && ctx.draftItem)) return null
|
||
|
||
const pos: [number, number, number] = [
|
||
ctx.gridPosition.x,
|
||
ctx.gridPosition.y,
|
||
ctx.gridPosition.z,
|
||
]
|
||
const valid = validators.canPlaceOnFloor(
|
||
ctx.levelId,
|
||
pos,
|
||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||
ctx.draftItem.rotation,
|
||
[ctx.draftItem.id],
|
||
).valid
|
||
|
||
if (!valid) return null
|
||
|
||
return {
|
||
nodeUpdate: {
|
||
position: pos,
|
||
parentId: ctx.levelId,
|
||
metadata: stripTransient(ctx.draftItem.metadata),
|
||
},
|
||
stopPropagation: false,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
}
|
||
|
||
// ============================================================================
|
||
// WALL STRATEGY
|
||
// ============================================================================
|
||
|
||
export const wallStrategy = {
|
||
/**
|
||
* Handle wall:enter — transition from floor to wall surface.
|
||
* Returns null if item doesn't attach to walls, face is invalid, or wrong level.
|
||
* Auto-adjusts Y position to fit within wall bounds.
|
||
*/
|
||
enter(
|
||
ctx: PlacementContext,
|
||
event: WallEvent,
|
||
resolveLevelId: LevelResolver,
|
||
nodes: Record<string, AnyNode>,
|
||
validators: SpatialValidators,
|
||
): TransitionResult | null {
|
||
const attachTo = ctx.asset.attachTo
|
||
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
||
if (!isValidWallSideFace(event.normal)) return null
|
||
|
||
// Level guard
|
||
const wallLevelId = resolveLevelId(event.node, nodes)
|
||
if (ctx.levelId !== wallLevelId) return null
|
||
|
||
const side = getSideFromNormal(event.normal)
|
||
const itemRotation = calculateItemRotation(event.normal)
|
||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||
|
||
const x = snapToHalf(event.localPosition[0])
|
||
const y = snapToHalf(event.localPosition[1])
|
||
const z = snapToHalf(event.localPosition[2])
|
||
|
||
// Get auto-adjusted Y position from validator
|
||
const rawDims = ctx.draftItem
|
||
? getScaledDimensions(ctx.draftItem)
|
||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||
const validation = validators.canPlaceOnWall(
|
||
ctx.levelId,
|
||
event.node.id,
|
||
x,
|
||
y,
|
||
getGridAlignedDimensions(rawDims, attachTo),
|
||
attachTo,
|
||
side,
|
||
[],
|
||
)
|
||
|
||
const adjustedY = validation.adjustedY ?? y
|
||
|
||
return {
|
||
stateUpdate: { surface: 'wall', wallId: event.node.id },
|
||
nodeUpdate: {
|
||
position: [x, adjustedY, z],
|
||
parentId: event.node.id,
|
||
side,
|
||
rotation: [0, itemRotation, 0],
|
||
},
|
||
cursorRotationY: cursorRotation,
|
||
gridPosition: [x, adjustedY, z],
|
||
cursorPosition: [
|
||
snapToHalf(event.position[0]),
|
||
snapToHalf(event.position[1]),
|
||
snapToHalf(event.position[2]),
|
||
],
|
||
stopPropagation: true,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle wall:move — update position while on wall.
|
||
* Returns null if not on a wall or face is invalid.
|
||
* Auto-adjusts Y position to fit within wall bounds.
|
||
*/
|
||
move(
|
||
ctx: PlacementContext,
|
||
event: WallEvent,
|
||
validators: SpatialValidators,
|
||
): PlacementResult | null {
|
||
if (ctx.state.surface !== 'wall') return null
|
||
if (!(ctx.draftItem && ctx.levelId)) return null
|
||
if (!isValidWallSideFace(event.normal)) return null
|
||
|
||
const side = getSideFromNormal(event.normal)
|
||
const itemRotation = calculateItemRotation(event.normal)
|
||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||
|
||
const snappedX = snapToHalf(event.localPosition[0])
|
||
const snappedY = snapToHalf(event.localPosition[1])
|
||
const snappedZ = snapToHalf(event.localPosition[2])
|
||
|
||
// Get auto-adjusted Y position from validator
|
||
const validation = validators.canPlaceOnWall(
|
||
ctx.levelId,
|
||
event.node.id,
|
||
snappedX,
|
||
snappedY,
|
||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||
side,
|
||
[ctx.draftItem.id],
|
||
)
|
||
|
||
const adjustedY = validation.adjustedY ?? snappedY
|
||
|
||
return {
|
||
gridPosition: [snappedX, adjustedY, snappedZ],
|
||
cursorPosition: [
|
||
snapToHalf(event.position[0]),
|
||
snapToHalf(event.position[1]),
|
||
snapToHalf(event.position[2]),
|
||
],
|
||
cursorRotationY: cursorRotation,
|
||
nodeUpdate: {
|
||
position: [snappedX, adjustedY, snappedZ],
|
||
side,
|
||
rotation: [0, itemRotation, 0],
|
||
},
|
||
stopPropagation: true,
|
||
dirtyNodeId: event.node.id,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle wall:click — commit placement on wall.
|
||
* Returns null if not on wall, face invalid, or validation fails.
|
||
*/
|
||
click(
|
||
ctx: PlacementContext,
|
||
event: WallEvent,
|
||
validators: SpatialValidators,
|
||
): CommitResult | null {
|
||
if (ctx.state.surface !== 'wall') return null
|
||
if (!isValidWallSideFace(event.normal)) return null
|
||
if (!(ctx.levelId && ctx.draftItem)) return null
|
||
|
||
const valid = validators.canPlaceOnWall(
|
||
ctx.levelId,
|
||
ctx.state.wallId as WallNode['id'],
|
||
ctx.gridPosition.x,
|
||
ctx.gridPosition.y,
|
||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||
ctx.draftItem.side,
|
||
[ctx.draftItem.id],
|
||
).valid
|
||
|
||
if (!valid) return null
|
||
|
||
return {
|
||
nodeUpdate: {
|
||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
parentId: event.node.id,
|
||
side: ctx.draftItem.side,
|
||
rotation: ctx.draftItem.rotation,
|
||
metadata: stripTransient(ctx.draftItem.metadata),
|
||
},
|
||
stopPropagation: true,
|
||
dirtyNodeId: event.node.id,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle wall:leave — transition back to floor surface.
|
||
*/
|
||
leave(ctx: PlacementContext): TransitionResult | null {
|
||
if (ctx.state.surface !== 'wall') return null
|
||
|
||
return {
|
||
stateUpdate: { surface: 'floor', wallId: null },
|
||
nodeUpdate: {
|
||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
parentId: ctx.levelId,
|
||
},
|
||
cursorRotationY: 0,
|
||
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
stopPropagation: true,
|
||
}
|
||
},
|
||
}
|
||
|
||
// ============================================================================
|
||
// CEILING STRATEGY
|
||
// ============================================================================
|
||
|
||
export const ceilingStrategy = {
|
||
/**
|
||
* Handle ceiling:enter — transition from floor to ceiling surface.
|
||
* Returns null if item doesn't attach to ceilings or wrong level.
|
||
*/
|
||
enter(
|
||
ctx: PlacementContext,
|
||
event: CeilingEvent,
|
||
resolveLevelId: LevelResolver,
|
||
nodes: Record<string, AnyNode>,
|
||
): TransitionResult | null {
|
||
if (ctx.asset.attachTo !== 'ceiling') return null
|
||
|
||
// Level guard
|
||
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
||
if (ctx.levelId !== ceilingLevelId) return null
|
||
|
||
const rawDims = ctx.draftItem
|
||
? getScaledDimensions(ctx.draftItem)
|
||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
|
||
const [dimX, , dimZ] = dims
|
||
const itemHeight = rawDims[1]
|
||
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||
|
||
// Ceiling items are stored in ceiling-local coordinates, so snapping must
|
||
// use the ceiling hit's local position rather than world position.
|
||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
|
||
|
||
return {
|
||
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
||
nodeUpdate: {
|
||
position: [x, -itemHeight, z],
|
||
parentId: event.node.id,
|
||
},
|
||
cursorRotationY: 0,
|
||
gridPosition: [x, -itemHeight, z],
|
||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||
stopPropagation: true,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle ceiling:move — update position while on ceiling.
|
||
*/
|
||
move(ctx: PlacementContext, event: CeilingEvent): PlacementResult | null {
|
||
if (ctx.state.surface !== 'ceiling') return null
|
||
if (!ctx.draftItem) return null
|
||
|
||
const rawDims = getScaledDimensions(ctx.draftItem)
|
||
const dims = getGridAlignedDimensions(rawDims, ctx.draftItem.asset.attachTo)
|
||
const [dimX, , dimZ] = dims
|
||
const itemHeight = rawDims[1]
|
||
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||
|
||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
|
||
|
||
return {
|
||
gridPosition: [x, -itemHeight, z],
|
||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||
cursorRotationY: 0,
|
||
nodeUpdate: null,
|
||
stopPropagation: true,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle ceiling:click — commit placement on ceiling.
|
||
*/
|
||
click(
|
||
ctx: PlacementContext,
|
||
event: CeilingEvent,
|
||
validators: SpatialValidators,
|
||
): CommitResult | null {
|
||
if (ctx.state.surface !== 'ceiling') return null
|
||
if (!ctx.draftItem) return null
|
||
|
||
const pos: [number, number, number] = [
|
||
ctx.gridPosition.x,
|
||
ctx.gridPosition.y,
|
||
ctx.gridPosition.z,
|
||
]
|
||
|
||
const valid = validators.canPlaceOnCeiling(
|
||
ctx.state.ceilingId as CeilingNode['id'],
|
||
pos,
|
||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||
ctx.draftItem.rotation,
|
||
[ctx.draftItem.id],
|
||
).valid
|
||
|
||
if (!valid) return null
|
||
|
||
return {
|
||
nodeUpdate: {
|
||
position: pos,
|
||
parentId: event.node.id,
|
||
metadata: stripTransient(ctx.draftItem.metadata),
|
||
},
|
||
stopPropagation: true,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle ceiling:leave — transition back to floor surface.
|
||
*/
|
||
leave(ctx: PlacementContext): TransitionResult | null {
|
||
if (ctx.state.surface !== 'ceiling') return null
|
||
|
||
return {
|
||
stateUpdate: { surface: 'floor', ceilingId: null },
|
||
nodeUpdate: {
|
||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
parentId: ctx.levelId,
|
||
},
|
||
cursorRotationY: 0,
|
||
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
stopPropagation: true,
|
||
}
|
||
},
|
||
}
|
||
|
||
// ============================================================================
|
||
// ITEM SURFACE STRATEGY
|
||
// ============================================================================
|
||
|
||
export const itemSurfaceStrategy = {
|
||
/**
|
||
* Handle item:enter — transition from floor to an item surface.
|
||
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
|
||
*/
|
||
enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
|
||
// Only floor items can be placed on surfaces
|
||
if (ctx.asset.attachTo) return null
|
||
|
||
const surfaceItem = event.node as ItemNode
|
||
// Don't surface-place on the draft itself
|
||
if (surfaceItem.id === ctx.draftItem?.id) return null
|
||
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
|
||
return null
|
||
}
|
||
const nodes = useScene.getState().nodes
|
||
if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
|
||
|
||
// Size check: our footprint must fit on surface item's footprint
|
||
const ourDims = ctx.draftItem
|
||
? getScaledDimensions(ctx.draftItem)
|
||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||
const surfDims = getScaledDimensions(surfaceItem)
|
||
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
||
|
||
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
|
||
if (!surfaceMesh) return null
|
||
|
||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||
if (surfaceHeight === null) return null
|
||
|
||
const x = snapToGrid(localPos.x, ourDims[0])
|
||
const z = snapToGrid(localPos.z, ourDims[2])
|
||
const y = surfaceHeight
|
||
|
||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||
|
||
// Counter-rotate so the draft's world Y rotation stays continuous when
|
||
// the user drags onto a rotated surface item. The cursor wireframe
|
||
// already shows the user's intended world rotation; we just need to
|
||
// store the right local value relative to the new parent.
|
||
const surfaceQuat = new Quaternion()
|
||
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
||
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
||
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
||
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
||
|
||
return {
|
||
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
||
nodeUpdate: {
|
||
position: [x, y, z],
|
||
parentId: surfaceItem.id,
|
||
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
||
},
|
||
cursorRotationY: ctx.currentCursorRotationY,
|
||
gridPosition: [x, y, z],
|
||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||
stopPropagation: true,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle item:move — update position while on an item surface.
|
||
*/
|
||
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
||
if (ctx.state.surface !== 'item-surface') return null
|
||
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
||
if (event.node.id !== ctx.state.surfaceItemId) return null
|
||
|
||
const nodes = useScene.getState().nodes
|
||
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
||
if (!surfaceItem) return null
|
||
|
||
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
||
if (!surfaceMesh) return null
|
||
|
||
const ourDims = getScaledDimensions(ctx.draftItem)
|
||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||
if (surfaceHeight === null) return null
|
||
|
||
const x = snapToGrid(localPos.x, ourDims[0])
|
||
const z = snapToGrid(localPos.z, ourDims[2])
|
||
const y = surfaceHeight
|
||
|
||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||
|
||
return {
|
||
gridPosition: [x, y, z],
|
||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||
cursorRotationY: ctx.currentCursorRotationY,
|
||
nodeUpdate: { position: [x, y, z] },
|
||
stopPropagation: true,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle item:click — commit placement on item surface.
|
||
*/
|
||
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
||
if (ctx.state.surface !== 'item-surface') return null
|
||
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
||
if (_event.node.id !== ctx.state.surfaceItemId) return null
|
||
|
||
return {
|
||
nodeUpdate: {
|
||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
parentId: ctx.state.surfaceItemId,
|
||
metadata: stripTransient(ctx.draftItem.metadata),
|
||
},
|
||
stopPropagation: true,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
}
|
||
|
||
// ============================================================================
|
||
// SHELF SURFACE STRATEGY
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Resolve the row Y closest to the cursor's local Y. Reads candidate row
|
||
* positions from the kind's `capabilities.surfaces.custom` — the shelf
|
||
* declaration emits one `SurfacePoint` per board's top surface. The
|
||
* strategy stays kind-agnostic at this level: any future "multi-board"
|
||
* kind that declares `surfaces.custom` with upward normals gets the
|
||
* same hit behaviour for free.
|
||
*/
|
||
function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
|
||
const def = nodeRegistry.get('shelf')
|
||
const custom = def?.capabilities?.surfaces?.custom
|
||
if (!custom) return null
|
||
const candidates = custom(shelfNode as AnyNode)
|
||
if (candidates.length === 0) return null
|
||
let best = candidates[0]
|
||
let bestDist = Math.abs(best!.position[1] - localY)
|
||
for (let i = 1; i < candidates.length; i++) {
|
||
const c = candidates[i]
|
||
if (!c) continue
|
||
const dist = Math.abs(c.position[1] - localY)
|
||
if (dist < bestDist) {
|
||
best = c
|
||
bestDist = dist
|
||
}
|
||
}
|
||
return best?.position[1] ?? null
|
||
}
|
||
|
||
export const shelfSurfaceStrategy = {
|
||
/**
|
||
* Handle shelf:enter — transition the draft onto the closest shelf
|
||
* row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
|
||
* surface heights from the shelf kind's `surfaces.custom` (one Y per
|
||
* board) instead of `asset.surface.height`. Picks the row whose
|
||
* surface Y is nearest the cursor's local Y so the user can target a
|
||
* specific row by hovering near it.
|
||
*/
|
||
enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
|
||
if (ctx.asset.attachTo) return null
|
||
const shelfNode = event.node as ShelfNode
|
||
|
||
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
|
||
return null
|
||
}
|
||
if (!isUpwardShelfSurfaceHit(event)) return null
|
||
|
||
// Size check: draft footprint must fit on the shelf board (width × depth).
|
||
const ourDims = ctx.draftItem
|
||
? getScaledDimensions(ctx.draftItem)
|
||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||
if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
|
||
|
||
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
|
||
if (!shelfMesh) return null
|
||
|
||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||
const localPos = shelfMesh.worldToLocal(worldPos)
|
||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||
if (rowY === null) return null
|
||
|
||
const x = snapToGrid(localPos.x, ourDims[0])
|
||
const z = snapToGrid(localPos.z, ourDims[2])
|
||
|
||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||
|
||
const surfaceQuat = new Quaternion()
|
||
shelfMesh.getWorldQuaternion(surfaceQuat)
|
||
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
||
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
||
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
||
|
||
return {
|
||
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
|
||
nodeUpdate: {
|
||
position: [x, rowY, z],
|
||
parentId: shelfNode.id,
|
||
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
||
},
|
||
cursorRotationY: ctx.currentCursorRotationY,
|
||
gridPosition: [x, rowY, z],
|
||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||
stopPropagation: true,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle shelf:move — re-derive the closest row each tick so the user
|
||
* can slide between rows without leaving the shelf.
|
||
*/
|
||
move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
|
||
if (ctx.state.surface !== 'shelf-surface') return null
|
||
if (!(ctx.state.shelfId && ctx.draftItem)) return null
|
||
if (event.node.id !== ctx.state.shelfId) return null
|
||
|
||
const shelfNode = event.node as ShelfNode
|
||
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
|
||
if (!shelfMesh) return null
|
||
|
||
const ourDims = getScaledDimensions(ctx.draftItem)
|
||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||
const localPos = shelfMesh.worldToLocal(worldPos)
|
||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||
if (rowY === null) return null
|
||
|
||
const x = snapToGrid(localPos.x, ourDims[0])
|
||
const z = snapToGrid(localPos.z, ourDims[2])
|
||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||
|
||
return {
|
||
gridPosition: [x, rowY, z],
|
||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||
cursorRotationY: ctx.currentCursorRotationY,
|
||
nodeUpdate: { position: [x, rowY, z] },
|
||
stopPropagation: true,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Handle shelf:click — commit placement on the active row.
|
||
*/
|
||
click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
|
||
if (ctx.state.surface !== 'shelf-surface') return null
|
||
if (!(ctx.draftItem && ctx.state.shelfId)) return null
|
||
if (event.node.id !== ctx.state.shelfId) return null
|
||
|
||
return {
|
||
nodeUpdate: {
|
||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
parentId: ctx.state.shelfId,
|
||
metadata: stripTransient(ctx.draftItem.metadata),
|
||
},
|
||
stopPropagation: true,
|
||
dirtyNodeId: null,
|
||
}
|
||
},
|
||
}
|
||
|
||
/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
|
||
* for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
|
||
* via a tiny `ItemEvent`-shaped adapter — the function only reads
|
||
* `event.normal` + `event.object`. */
|
||
function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
|
||
return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
|
||
}
|
||
|
||
// ============================================================================
|
||
// VALIDATION
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Unified validation: check if the current draft item can be placed at its current position.
|
||
* Switches on the active surface type and calls the appropriate spatial validator.
|
||
*/
|
||
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
||
if (!(ctx.levelId && ctx.draftItem)) return false
|
||
|
||
// Item surface: valid if we entered (size check was in enter)
|
||
if (ctx.state.surface === 'item-surface') {
|
||
return ctx.state.surfaceItemId !== null
|
||
}
|
||
|
||
// Shelf surface: same — size check already happened on enter
|
||
if (ctx.state.surface === 'shelf-surface') {
|
||
return ctx.state.shelfId !== null
|
||
}
|
||
|
||
const attachTo = ctx.draftItem.asset.attachTo
|
||
|
||
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
|
||
|
||
if (attachTo === 'ceiling') {
|
||
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
|
||
return validators.canPlaceOnCeiling(
|
||
ctx.state.ceilingId as CeilingNode['id'],
|
||
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
alignedDims,
|
||
ctx.draftItem.rotation,
|
||
[ctx.draftItem.id],
|
||
).valid
|
||
}
|
||
|
||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||
if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
|
||
return validators.canPlaceOnWall(
|
||
ctx.levelId,
|
||
ctx.state.wallId as WallNode['id'],
|
||
ctx.gridPosition.x,
|
||
ctx.gridPosition.y,
|
||
alignedDims,
|
||
attachTo,
|
||
ctx.draftItem.side,
|
||
[ctx.draftItem.id],
|
||
).valid
|
||
}
|
||
|
||
// Floor (no attachTo)
|
||
return validators.canPlaceOnFloor(
|
||
ctx.levelId,
|
||
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||
alignedDims,
|
||
ctx.draftItem.rotation,
|
||
[ctx.draftItem.id],
|
||
).valid
|
||
}
|