diff --git a/apps/editor/components/tools/item/placement-strategies.ts b/apps/editor/components/tools/item/placement-strategies.ts index 3fd7f501..dc48a204 100644 --- a/apps/editor/components/tools/item/placement-strategies.ts +++ b/apps/editor/components/tools/item/placement-strategies.ts @@ -91,12 +91,14 @@ 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, + validators: SpatialValidators, ): TransitionResult | null { const attachTo = ctx.asset.attachTo if (attachTo !== 'wall' && attachTo !== 'wall-side') return null @@ -114,16 +116,30 @@ export const wallStrategy = { 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.asset.dimensions ?? DEFAULT_DIMENSIONS, + attachTo, + side, + [], + ) + + const adjustedY = validation.adjustedY ?? y + return { stateUpdate: { surface: 'wall', wallId: event.node.id }, nodeUpdate: { - position: [x, y, z], + position: [x, adjustedY, z], parentId: event.node.id, side, rotation: [0, itemRotation, 0], }, cursorRotationY: cursorRotation, - gridPosition: [x, y, z], + gridPosition: [x, adjustedY, z], cursorPosition: [ snapToHalf(event.position[0]), snapToHalf(event.position[1]), @@ -136,22 +152,37 @@ export const wallStrategy = { /** * 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): PlacementResult | null { + move(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): PlacementResult | null { if (ctx.state.surface !== 'wall') return null - if (!ctx.draftItem) 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, + ctx.draftItem.asset.dimensions, + ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', + side, + [ctx.draftItem.id], + ) + + const adjustedY = validation.adjustedY ?? snappedY + return { - gridPosition: [ - snapToHalf(event.localPosition[0]), - snapToHalf(event.localPosition[1]), - snapToHalf(event.localPosition[2]), - ], + gridPosition: [snappedX, adjustedY, snappedZ], cursorPosition: [ snapToHalf(event.position[0]), snapToHalf(event.position[1]), @@ -159,6 +190,7 @@ export const wallStrategy = { ], cursorRotationY: cursorRotation, nodeUpdate: { + position: [snappedX, adjustedY, snappedZ], side, rotation: [0, itemRotation, 0], }, diff --git a/apps/editor/components/tools/item/placement-types.ts b/apps/editor/components/tools/item/placement-types.ts index a9610a3f..a5d84559 100644 --- a/apps/editor/components/tools/item/placement-types.ts +++ b/apps/editor/components/tools/item/placement-types.ts @@ -93,7 +93,7 @@ export interface SpatialValidators { attachType: 'wall' | 'wall-side', side?: 'front' | 'back', ignoreIds?: string[], - ) => { valid: boolean } + ) => { valid: boolean; adjustedY?: number; wasAdjusted?: boolean } canPlaceOnCeiling: ( ceilingId: CeilingNode['id'], position: [number, number, number], diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 056e465b..cde1b12e 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -213,7 +213,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onWallEnter = (event: WallEvent) => { const nodes = useScene.getState().nodes - const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) + const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators) if (!result) return event.stopPropagation() @@ -235,7 +235,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (ctx.state.surface !== 'wall') { const nodes = useScene.getState().nodes - const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes) + const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, validators) if (!enterResult) return event.stopPropagation() @@ -251,7 +251,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!draftNode.current) { const nodes = useScene.getState().nodes - const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) + const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators) if (!setup) return event.stopPropagation() @@ -259,7 +259,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - const result = wallStrategy.move(ctx, event) + const result = wallStrategy.move(ctx, event, validators) if (!result) return event.stopPropagation() @@ -318,7 +318,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (configRef.current.onCommitted()) { const nodes = useScene.getState().nodes - const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) + const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators) if (enterResult) { applyTransition(enterResult) } else { diff --git a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts index d36a3bb2..6bcb6e9d 100644 --- a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts +++ b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts @@ -4,6 +4,9 @@ type AttachType = 'wall' | 'wall-side' // Small tolerance for floating point comparison to allow adjacent items const EPSILON = 0.001 +// Margin from ceiling/floor when auto-snapping items +const AUTO_SNAP_MARGIN = 0.05 + interface WallItemPlacement { itemId: string wallId: string @@ -15,12 +18,42 @@ interface WallItemPlacement { side?: WallSide // Which side for 'wall-side' items (undefined means both for 'wall') } +/** + * Auto-adjust Y position to fit item within wall bounds + * Returns the adjusted Y position (bottom of item) + */ +function autoAdjustYPosition( + yBottom: number, + itemHeight: number, + wallHeight: number, +): { adjustedY: number; wasAdjusted: boolean } { + const yTop = yBottom + itemHeight + + // If fits perfectly, no adjustment needed + if (yBottom >= 0 && yTop <= wallHeight) { + return { adjustedY: yBottom, wasAdjusted: false } + } + + // If too high (top exceeds wall height), snap down from ceiling + if (yTop > wallHeight) { + const adjustedY = wallHeight - itemHeight - AUTO_SNAP_MARGIN + return { adjustedY: Math.max(0, adjustedY), wasAdjusted: true } + } + + // If too low (bottom below floor), snap up from floor + if (yBottom < 0) { + return { adjustedY: AUTO_SNAP_MARGIN, wasAdjusted: true } + } + + return { adjustedY: yBottom, wasAdjusted: false } +} + export class WallSpatialGrid { private wallItems = new Map() // wallId -> placements private itemToWall = new Map() // itemId -> wallId (reverse lookup) /** - * Check if an item can be placed on a wall + * Check if an item can be placed on a wall with auto-adjustment for vertical position * @param wallId - The wall to place on * @param wallLength - Length of the wall * @param wallHeight - Height of the wall @@ -31,6 +64,7 @@ export class WallSpatialGrid { * @param attachType - 'wall' (blocks both sides) or 'wall-side' (blocks one side) * @param side - Which side for 'wall-side' items * @param ignoreIds - Item IDs to ignore in conflict check + * @returns Validation result with auto-adjusted Y position if needed */ canPlaceOnWall( wallId: string, @@ -43,19 +77,21 @@ export class WallSpatialGrid { attachType: AttachType = 'wall', side?: WallSide, ignoreIds: string[] = [], - ): { valid: boolean; conflictIds: string[] } { + ): { valid: boolean; conflictIds: string[]; adjustedY: number; wasAdjusted: boolean } { const halfW = itemWidth / wallLength / 2 const tStart = tCenter - halfW const tEnd = tCenter + halfW - // yBottom is the bottom of the item, so yEnd = yBottom + itemHeight - const yStart = yBottom - const yEnd = yBottom + itemHeight - // Check wall boundaries - if (tStart < 0 || tEnd > 1 || yStart < 0 || yEnd > wallHeight) { - return { valid: false, conflictIds: [] } + // Check horizontal boundaries (still reject if item exceeds wall width) + if (tStart < 0 || tEnd > 1) { + return { valid: false, conflictIds: [], adjustedY: yBottom, wasAdjusted: false } } + // Auto-adjust vertical position to fit within wall bounds + const { adjustedY, wasAdjusted } = autoAdjustYPosition(yBottom, itemHeight, wallHeight) + const yStart = adjustedY + const yEnd = adjustedY + itemHeight + const existing = this.wallItems.get(wallId) ?? [] const ignoreSet = new Set(ignoreIds) const conflicts: string[] = [] @@ -76,7 +112,7 @@ export class WallSpatialGrid { } } - return { valid: conflicts.length === 0, conflictIds: conflicts } + return { valid: conflicts.length === 0, conflictIds: conflicts, adjustedY, wasAdjusted } } /**