type WallSide = 'front' | 'back' 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 tStart: number // 0-1 parametric position along wall tEnd: number yStart: number // height range yEnd: number attachType?: AttachType // 'wall' blocks both sides, 'wall-side' blocks one side (undefined = 'wall' for legacy) 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 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 * @param tCenter - Parametric center position (0-1) along wall * @param itemWidth - Width of the item * @param yBottom - Bottom Y position of the item * @param itemHeight - Height of the item * @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, wallLength: number, wallHeight: number, tCenter: number, itemWidth: number, yBottom: number, itemHeight: number, attachType: AttachType = 'wall', side?: WallSide, ignoreIds: string[] = [], ): { valid: boolean; conflictIds: string[]; adjustedY: number; wasAdjusted: boolean } { const halfW = itemWidth / wallLength / 2 const tStart = tCenter - halfW const tEnd = tCenter + halfW // 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[] = [] for (const placement of existing) { if (ignoreSet.has(placement.itemId)) continue // Use EPSILON tolerance to allow items to be exactly adjacent const tOverlap = tStart < placement.tEnd - EPSILON && tEnd > placement.tStart + EPSILON const yOverlap = yStart < placement.yEnd - EPSILON && yEnd > placement.yStart + EPSILON if (tOverlap && yOverlap) { // Check side conflicts based on attach types const hasConflict = this.checkSideConflict(attachType, side, placement) if (hasConflict) { conflicts.push(placement.itemId) } } } return { valid: conflicts.length === 0, conflictIds: conflicts, adjustedY, wasAdjusted } } /** * Check if two items conflict based on their attach types and sides * - 'wall' items block both sides, so they conflict with everything * - 'wall-side' items only conflict if they're on the same side or if the other is a 'wall' item */ private checkSideConflict( newAttachType: AttachType, newSide: WallSide | undefined, existing: WallItemPlacement, ): boolean { // Treat undefined/legacy attachType as 'wall' (blocks both sides) const existingAttachType = existing.attachType ?? 'wall' // If new item is 'wall' type, it conflicts with everything (needs both sides) if (newAttachType === 'wall') { return true } // If existing item is 'wall' type, it blocks both sides if (existingAttachType === 'wall') { return true } // Both are 'wall-side' - only conflict if they're on the same side // If either side is undefined, be conservative and assume conflict if (!newSide || !existing.side) { return true } return newSide === existing.side } insert(placement: WallItemPlacement) { const { wallId, itemId } = placement if (!this.wallItems.has(wallId)) { this.wallItems.set(wallId, []) } this.wallItems.get(wallId)!.push(placement) this.itemToWall.set(itemId, wallId) } remove(wallId: string, itemId: string) { const items = this.wallItems.get(wallId) if (items) { const idx = items.findIndex((p) => p.itemId === itemId) if (idx !== -1) items.splice(idx, 1) } this.itemToWall.delete(itemId) } removeByItemId(itemId: string) { const wallId = this.itemToWall.get(itemId) if (wallId) { this.remove(wallId, itemId) } } // Useful for when a wall is deleted - remove all items on it removeWall(wallId: string): string[] { const items = this.wallItems.get(wallId) ?? [] const removedIds = items.map((p) => p.itemId) for (const itemId of removedIds) { this.itemToWall.delete(itemId) } this.wallItems.delete(wallId) return removedIds // Return removed item IDs in case you need to delete them from scene } // Get which wall an item is on getWallForItem(itemId: string): string | undefined { return this.itemToWall.get(itemId) } clear() { this.wallItems.clear() this.itemToWall.clear() } }