198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
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<string, WallItemPlacement[]>() // wallId -> placements
|
|
private itemToWall = new Map<string, string>() // 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()
|
|
}
|
|
}
|