ai-chatbot/apps/editor/components/tools/item/placement-strategies.ts
Aymeric Rabot 6c99851006 Refactor asset types and improve UI primitives
Replaces usage of the Asset type with AssetInput across editor components and hooks for consistency with core types. Adds default dimensions fallback for asset placement logic. Refactors UI primitives (button, sidebar, opacity control) to support forwarding refs and asChild prop, and updates imports for primitives. Updates Radix UI dependencies in package.json and bun.lock.
2026-01-30 22:01:25 -05:00

391 lines
11 KiB
TypeScript

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<string, AnyNode>,
): 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<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.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
}