533 lines
17 KiB
TypeScript
533 lines
17 KiB
TypeScript
import type {
|
|
AnyNode,
|
|
AnyNodeId,
|
|
CeilingEvent,
|
|
CeilingNode,
|
|
GridEvent,
|
|
ItemEvent,
|
|
ItemNode,
|
|
WallEvent,
|
|
WallNode,
|
|
} from '@pascal-app/core'
|
|
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
|
|
import { Vector3 } from 'three'
|
|
import type {
|
|
CommitResult,
|
|
LevelResolver,
|
|
PlacementContext,
|
|
PlacementResult,
|
|
SpatialValidators,
|
|
TransitionResult,
|
|
} from './placement-types'
|
|
import {
|
|
calculateCursorRotation,
|
|
calculateItemRotation,
|
|
getSideFromNormal,
|
|
isValidWallSideFace,
|
|
snapToGrid,
|
|
snapToHalf,
|
|
stripTransient,
|
|
} from './placement-math'
|
|
|
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
|
|
// ============================================================================
|
|
// 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 dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
const [dimX, , dimZ] = dims
|
|
const x = snapToGrid(event.position[0], dimX)
|
|
const z = snapToGrid(event.position[2], dimZ)
|
|
|
|
return {
|
|
gridPosition: [x, 0, z],
|
|
cursorPosition: [x, event.position[1], z],
|
|
cursorRotationY: 0,
|
|
nodeUpdate: { position: [x, 0, 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, 0, ctx.gridPosition.z]
|
|
const valid = validators.canPlaceOnFloor(
|
|
ctx.levelId,
|
|
pos,
|
|
getScaledDimensions(ctx.draftItem),
|
|
[0, 0, 0],
|
|
[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 validation = validators.canPlaceOnWall(
|
|
ctx.levelId,
|
|
event.node.id,
|
|
x,
|
|
y,
|
|
ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
|
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,
|
|
getScaledDimensions(ctx.draftItem),
|
|
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,
|
|
getScaledDimensions(ctx.draftItem),
|
|
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 dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
const [dimX, , dimZ] = dims
|
|
const itemHeight = dims[1]
|
|
|
|
const x = snapToGrid(event.position[0], dimX)
|
|
const z = snapToGrid(event.position[2], dimZ)
|
|
|
|
return {
|
|
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
|
nodeUpdate: {
|
|
position: [x, -itemHeight, z],
|
|
parentId: event.node.id,
|
|
},
|
|
cursorRotationY: 0,
|
|
gridPosition: [x, -itemHeight, z],
|
|
cursorPosition: [x, event.position[1] - itemHeight, 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 dims = getScaledDimensions(ctx.draftItem)
|
|
const [dimX, , dimZ] = dims
|
|
const itemHeight = dims[1]
|
|
|
|
const x = snapToGrid(event.position[0], dimX)
|
|
const z = snapToGrid(event.position[2], dimZ)
|
|
|
|
return {
|
|
gridPosition: [x, -itemHeight, z],
|
|
cursorPosition: [x, event.position[1] - itemHeight, 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,
|
|
getScaledDimensions(ctx.draftItem),
|
|
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
|
|
// Surface item must declare a surface
|
|
if (!surfaceItem.asset.surface) 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 x = snapToGrid(localPos.x, ourDims[0])
|
|
const z = snapToGrid(localPos.z, ourDims[2])
|
|
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
|
|
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
|
|
return {
|
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
|
|
cursorRotationY: 0,
|
|
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
|
|
|
|
const nodes = useScene.getState().nodes
|
|
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
|
if (!surfaceItem?.asset.surface) 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 x = snapToGrid(localPos.x, ourDims[0])
|
|
const z = snapToGrid(localPos.z, ourDims[2])
|
|
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
|
|
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
|
|
return {
|
|
gridPosition: [x, y, z],
|
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
cursorRotationY: 0,
|
|
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
|
|
|
|
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,
|
|
}
|
|
},
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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
|
|
}
|
|
|
|
const attachTo = ctx.draftItem.asset.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],
|
|
getScaledDimensions(ctx.draftItem),
|
|
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,
|
|
getScaledDimensions(ctx.draftItem),
|
|
attachTo,
|
|
ctx.draftItem.side,
|
|
[ctx.draftItem.id],
|
|
).valid
|
|
}
|
|
|
|
// Floor (no attachTo)
|
|
return validators.canPlaceOnFloor(
|
|
ctx.levelId,
|
|
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
|
getScaledDimensions(ctx.draftItem),
|
|
[0, 0, 0],
|
|
[ctx.draftItem.id],
|
|
).valid
|
|
}
|