import type { AnyNode, CeilingEvent, CeilingNode, GridEvent, WallEvent, WallNode, } from '@pascal-app/core' 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.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, ctx.draftItem.asset.dimensions, [0, 0, 0], [ctx.draftItem.id], ).valid if (!valid) return null return { nodeUpdate: { position: pos, 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. */ enter( ctx: PlacementContext, event: WallEvent, resolveLevelId: LevelResolver, nodes: Record, ): 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]) return { stateUpdate: { surface: 'wall', wallId: event.node.id }, nodeUpdate: { position: [x, y, z], parentId: event.node.id, side, rotation: [0, itemRotation, 0], }, cursorRotationY: cursorRotation, gridPosition: [x, y, 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. */ move(ctx: PlacementContext, event: WallEvent): PlacementResult | null { if (ctx.state.surface !== 'wall') return null if (!ctx.draftItem) 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) return { gridPosition: [ snapToHalf(event.localPosition[0]), snapToHalf(event.localPosition[1]), snapToHalf(event.localPosition[2]), ], cursorPosition: [ snapToHalf(event.position[0]), snapToHalf(event.position[1]), snapToHalf(event.position[2]), ], cursorRotationY: cursorRotation, nodeUpdate: { 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, ctx.draftItem.asset.dimensions, 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, ): 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.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 = 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 { 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, ctx.draftItem.asset.dimensions, 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, } }, } // ============================================================================ // 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 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], ctx.draftItem.asset.dimensions, 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, ctx.draftItem.asset.dimensions, attachTo, ctx.draftItem.side, [ctx.draftItem.id], ).valid } // Floor (no attachTo) return validators.canPlaceOnFloor( ctx.levelId, [ctx.gridPosition.x, 0, ctx.gridPosition.z], ctx.draftItem.asset.dimensions, [0, 0, 0], [ctx.draftItem.id], ).valid }