671 lines
23 KiB
TypeScript
671 lines
23 KiB
TypeScript
import { getScaledDimensions } from '../../schema'
|
|
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
|
import { SpatialGrid } from './spatial-grid'
|
|
import { WallSpatialGrid } from './wall-spatial-grid'
|
|
|
|
// ============================================================================
|
|
// GEOMETRY HELPERS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Point-in-polygon test using ray casting algorithm.
|
|
*/
|
|
export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {
|
|
let inside = false
|
|
const n = polygon.length
|
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
const xi = polygon[i]![0], zi = polygon[i]![1]
|
|
const xj = polygon[j]![0], zj = polygon[j]![1]
|
|
|
|
if ((zi > pz) !== (zj > pz) && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
|
inside = !inside
|
|
}
|
|
}
|
|
return inside
|
|
}
|
|
|
|
/**
|
|
* Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation.
|
|
*/
|
|
function getItemFootprint(
|
|
position: [number, number, number],
|
|
dimensions: [number, number, number],
|
|
rotation: [number, number, number],
|
|
inset = 0,
|
|
): Array<[number, number]> {
|
|
const [x, , z] = position
|
|
const [w, , d] = dimensions
|
|
const yRot = rotation[1]
|
|
const halfW = Math.max(0, w / 2 - inset)
|
|
const halfD = Math.max(0, d / 2 - inset)
|
|
const cos = Math.cos(yRot)
|
|
const sin = Math.sin(yRot)
|
|
|
|
return [
|
|
[x + (-halfW * cos + halfD * sin), z + (-halfW * sin - halfD * cos)],
|
|
[x + (halfW * cos + halfD * sin), z + (halfW * sin - halfD * cos)],
|
|
[x + (halfW * cos - halfD * sin), z + (halfW * sin + halfD * cos)],
|
|
[x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)],
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Test if two line segments (a1->a2) and (b1->b2) intersect.
|
|
*/
|
|
function segmentsIntersect(
|
|
ax1: number, az1: number, ax2: number, az2: number,
|
|
bx1: number, bz1: number, bx2: number, bz2: number,
|
|
): boolean {
|
|
const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) =>
|
|
(ax - ox) * (bz - oz) - (az - oz) * (bx - ox)
|
|
|
|
const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)
|
|
const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)
|
|
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
|
|
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
|
|
|
|
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
|
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
|
return true
|
|
}
|
|
|
|
// Collinear touching cases
|
|
const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
|
Math.min(px, qx) <= rx && rx <= Math.max(px, qx) &&
|
|
Math.min(pz, qz) <= rz && rz <= Math.max(pz, qz)
|
|
|
|
if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
|
|
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true
|
|
if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true
|
|
if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Test if a line segment intersects any edge of a polygon.
|
|
*/
|
|
function segmentIntersectsPolygon(
|
|
sx1: number, sz1: number, sx2: number, sz2: number,
|
|
polygon: Array<[number, number]>,
|
|
): boolean {
|
|
const n = polygon.length
|
|
for (let i = 0; i < n; i++) {
|
|
const j = (i + 1) % n
|
|
if (segmentsIntersect(
|
|
sx1, sz1, sx2, sz2,
|
|
polygon[i]![0], polygon[i]![1], polygon[j]![0], polygon[j]![1],
|
|
)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Test if an item's footprint overlaps with a polygon.
|
|
* Checks: any item corner inside polygon, or any polygon vertex inside item AABB, or edges intersect.
|
|
*/
|
|
export function itemOverlapsPolygon(
|
|
position: [number, number, number],
|
|
dimensions: [number, number, number],
|
|
rotation: [number, number, number],
|
|
polygon: Array<[number, number]>,
|
|
inset = 0,
|
|
): boolean {
|
|
const corners = getItemFootprint(position, dimensions, rotation, inset)
|
|
|
|
// Check if any item corner is inside the polygon
|
|
for (const [cx, cz] of corners) {
|
|
if (pointInPolygon(cx, cz, polygon)) return true
|
|
}
|
|
|
|
// Check if any polygon vertex is inside the item footprint
|
|
// (handles case where slab is fully inside a large item)
|
|
for (const [px, pz] of polygon) {
|
|
if (pointInPolygon(px, pz, corners)) return true
|
|
}
|
|
|
|
// Check if any item edge intersects any polygon edge
|
|
for (let i = 0; i < 4; i++) {
|
|
const j = (i + 1) % 4
|
|
if (segmentIntersectsPolygon(
|
|
corners[i]![0], corners[i]![1], corners[j]![0], corners[j]![1],
|
|
polygon,
|
|
)) return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Check if wall segment (a) is substantially on polygon edge segment (b).
|
|
* Returns true only if BOTH endpoints of the wall are on or very close to the edge.
|
|
* This prevents walls that just touch one point from being detected.
|
|
*/
|
|
function segmentsCollinearAndOverlap(
|
|
ax1: number, az1: number, ax2: number, az2: number,
|
|
bx1: number, bz1: number, bx2: number, bz2: number,
|
|
): boolean {
|
|
const EPSILON = 1e-6
|
|
|
|
// Cross product to check collinearity
|
|
const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1)
|
|
const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1)
|
|
|
|
if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) {
|
|
return false // Not collinear
|
|
}
|
|
|
|
// Check if a point is on segment b
|
|
const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
|
Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON &&
|
|
Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON
|
|
|
|
// BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
|
|
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)
|
|
const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2)
|
|
|
|
return a1OnB && a2OnB
|
|
}
|
|
|
|
/**
|
|
* Test if a wall segment overlaps with a polygon.
|
|
* A wall is considered to overlap if:
|
|
* - Its midpoint is inside the polygon (wall crosses through)
|
|
* - At least one endpoint is inside (wall partially or fully in slab)
|
|
* - It's collinear with and overlaps a polygon edge (wall on slab boundary)
|
|
*
|
|
* Note: A wall with just one endpoint touching the edge but the rest outside
|
|
* is NOT considered overlapping (adjacent only).
|
|
*/
|
|
export function wallOverlapsPolygon(
|
|
start: [number, number],
|
|
end: [number, number],
|
|
polygon: Array<[number, number]>,
|
|
): boolean {
|
|
const dx = end[0] - start[0]
|
|
const dz = end[1] - start[1]
|
|
const len = Math.sqrt(dx * dx + dz * dz)
|
|
|
|
// Nudge endpoint test points a tiny step inward along the wall direction before
|
|
// testing containment. pointInPolygon (ray casting) produces false positives for
|
|
// points exactly on polygon vertices or edges — specifically the minimum-z corner
|
|
// of an axis-aligned polygon returns "inside" because the ray hits the opposite
|
|
// vertical edge exactly at its base. Nudging by 1e-6 m avoids this: a wall that
|
|
// merely starts at a slab corner and extends outward will have its nudged point
|
|
// clearly outside, while a wall that genuinely starts inside stays inside.
|
|
if (len > 1e-10) {
|
|
const step = Math.min(1e-6, len * 0.01)
|
|
const nx = (dx / len) * step
|
|
const nz = (dz / len) * step
|
|
if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true
|
|
if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true
|
|
|
|
// Also nudge perpendicular to the wall (into the slab interior) for walls that
|
|
// lie exactly on the slab boundary. The along-wall nudge keeps points on the
|
|
// boundary where pointInPolygon is unreliable; a perpendicular inward nudge
|
|
// moves the point clearly inside (or outside) the polygon.
|
|
// Sample the wall at 1/4, 1/2, 3/4 positions with a perpendicular nudge.
|
|
const PERP_STEP = 1e-4
|
|
const pnx = (-nz / step) * PERP_STEP // perpendicular left
|
|
const pnz = (nx / step) * PERP_STEP
|
|
for (const t of [0.25, 0.5, 0.75]) {
|
|
const bx = start[0] + dx * t
|
|
const bz = start[1] + dz * t
|
|
if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true
|
|
if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true
|
|
}
|
|
}
|
|
|
|
// Check if midpoint is inside (catches walls crossing through)
|
|
const midX = (start[0] + end[0]) / 2
|
|
const midZ = (start[1] + end[1]) / 2
|
|
if (pointInPolygon(midX, midZ, polygon)) return true
|
|
|
|
// Check if the wall is collinear with and overlaps any polygon edge
|
|
const n = polygon.length
|
|
for (let i = 0; i < n; i++) {
|
|
const j = (i + 1) % n
|
|
const [p1x, p1z] = polygon[i]!
|
|
const [p2x, p2z] = polygon[j]!
|
|
|
|
if (segmentsCollinearAndOverlap(start[0], start[1], end[0], end[1], p1x, p1z, p2x, p2z)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export class SpatialGridManager {
|
|
private floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
|
|
private wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
|
|
private walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)
|
|
private slabsByLevel = new Map<string, Map<string, SlabNode>>() // levelId -> (slabId -> slab)
|
|
private ceilingGrids = new Map<string, SpatialGrid>() // ceilingId -> grid
|
|
private ceilings = new Map<string, CeilingNode>() // ceilingId -> ceiling data
|
|
private itemCeilingMap = new Map<string, string>() // itemId -> ceilingId (reverse lookup)
|
|
|
|
constructor(private cellSize = 0.5) {}
|
|
|
|
private getFloorGrid(levelId: string): SpatialGrid {
|
|
if (!this.floorGrids.has(levelId)) {
|
|
this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize }))
|
|
}
|
|
return this.floorGrids.get(levelId)!
|
|
}
|
|
|
|
private getWallGrid(levelId: string): WallSpatialGrid {
|
|
if (!this.wallGrids.has(levelId)) {
|
|
this.wallGrids.set(levelId, new WallSpatialGrid())
|
|
}
|
|
return this.wallGrids.get(levelId)!
|
|
}
|
|
|
|
private getWallLength(wallId: string): number {
|
|
const wall = this.walls.get(wallId)
|
|
if (!wall) return 0
|
|
const dx = wall.end[0] - wall.start[0]
|
|
const dy = wall.end[1] - wall.start[1]
|
|
return Math.sqrt(dx * dx + dy * dy)
|
|
}
|
|
|
|
private getWallHeight(wallId: string): number {
|
|
const wall = this.walls.get(wallId)
|
|
return wall?.height ?? 2.5 // Default wall height
|
|
}
|
|
|
|
private getCeilingGrid(ceilingId: string): SpatialGrid {
|
|
if (!this.ceilingGrids.has(ceilingId)) {
|
|
this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize }))
|
|
}
|
|
return this.ceilingGrids.get(ceilingId)!
|
|
}
|
|
|
|
private getSlabMap(levelId: string): Map<string, SlabNode> {
|
|
if (!this.slabsByLevel.has(levelId)) {
|
|
this.slabsByLevel.set(levelId, new Map())
|
|
}
|
|
return this.slabsByLevel.get(levelId)!
|
|
}
|
|
|
|
// Called when nodes change
|
|
handleNodeCreated(node: AnyNode, levelId: string) {
|
|
if (node.type === 'slab') {
|
|
this.getSlabMap(levelId).set(node.id, node as SlabNode)
|
|
} else if (node.type === 'ceiling') {
|
|
this.ceilings.set(node.id, node as CeilingNode)
|
|
} else if (node.type === 'wall') {
|
|
const wall = node as WallNode
|
|
this.walls.set(wall.id, wall)
|
|
} else if (node.type === 'item') {
|
|
const item = node as ItemNode
|
|
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
|
|
// Wall-attached item - use parentId as the wall ID
|
|
const wallId = item.parentId
|
|
if (wallId && this.walls.has(wallId)) {
|
|
const wallLength = this.getWallLength(wallId)
|
|
if (wallLength > 0) {
|
|
const [width, height] = getScaledDimensions(item)
|
|
const halfW = width / wallLength / 2
|
|
// Calculate t from local X position (position[0] is distance along wall)
|
|
const t = item.position[0] / wallLength
|
|
// position[1] is the bottom of the item
|
|
this.getWallGrid(levelId).insert({
|
|
itemId: item.id,
|
|
wallId: wallId,
|
|
tStart: t - halfW,
|
|
tEnd: t + halfW,
|
|
yStart: item.position[1],
|
|
yEnd: item.position[1] + height,
|
|
attachType: item.asset.attachTo as 'wall' | 'wall-side',
|
|
side: item.side,
|
|
})
|
|
}
|
|
}
|
|
} else if (item.asset.attachTo === 'ceiling') {
|
|
// Ceiling item - use parentId as the ceiling ID
|
|
const ceilingId = item.parentId
|
|
if (ceilingId && this.ceilings.has(ceilingId)) {
|
|
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
|
this.itemCeilingMap.set(item.id, ceilingId)
|
|
}
|
|
} else if (!item.asset.attachTo) {
|
|
// Floor item
|
|
this.getFloorGrid(levelId).insert(
|
|
item.id,
|
|
item.position,
|
|
getScaledDimensions(item),
|
|
item.rotation,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
handleNodeUpdated(node: AnyNode, levelId: string) {
|
|
if (node.type === 'slab') {
|
|
this.getSlabMap(levelId).set(node.id, node as SlabNode)
|
|
} else if (node.type === 'ceiling') {
|
|
this.ceilings.set(node.id, node as CeilingNode)
|
|
} else if (node.type === 'wall') {
|
|
const wall = node as WallNode
|
|
this.walls.set(wall.id, wall)
|
|
} else if (node.type === 'item') {
|
|
const item = node as ItemNode
|
|
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
|
|
// Remove old placement and re-insert
|
|
this.getWallGrid(levelId).removeByItemId(item.id)
|
|
const wallId = item.parentId
|
|
if (wallId && this.walls.has(wallId)) {
|
|
const wallLength = this.getWallLength(wallId)
|
|
if (wallLength > 0) {
|
|
const [width, height] = getScaledDimensions(item)
|
|
const halfW = width / wallLength / 2
|
|
// Calculate t from local X position (position[0] is distance along wall)
|
|
const t = item.position[0] / wallLength
|
|
// position[1] is the bottom of the item
|
|
this.getWallGrid(levelId).insert({
|
|
itemId: item.id,
|
|
wallId: wallId,
|
|
tStart: t - halfW,
|
|
tEnd: t + halfW,
|
|
yStart: item.position[1],
|
|
yEnd: item.position[1] + height,
|
|
attachType: item.asset.attachTo as 'wall' | 'wall-side',
|
|
side: item.side,
|
|
})
|
|
}
|
|
}
|
|
} else if (item.asset.attachTo === 'ceiling') {
|
|
// Remove from old ceiling grid
|
|
const oldCeilingId = this.itemCeilingMap.get(item.id)
|
|
if (oldCeilingId) {
|
|
this.getCeilingGrid(oldCeilingId).remove(item.id)
|
|
this.itemCeilingMap.delete(item.id)
|
|
}
|
|
// Insert into new ceiling grid
|
|
const ceilingId = item.parentId
|
|
if (ceilingId && this.ceilings.has(ceilingId)) {
|
|
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
|
this.itemCeilingMap.set(item.id, ceilingId)
|
|
}
|
|
} else if (!item.asset.attachTo) {
|
|
this.getFloorGrid(levelId).update(
|
|
item.id,
|
|
item.position,
|
|
getScaledDimensions(item),
|
|
item.rotation,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
|
|
if (nodeType === 'slab') {
|
|
this.getSlabMap(levelId).delete(nodeId)
|
|
} else if (nodeType === 'ceiling') {
|
|
this.ceilings.delete(nodeId)
|
|
this.ceilingGrids.delete(nodeId)
|
|
} else if (nodeType === 'wall') {
|
|
this.walls.delete(nodeId)
|
|
// Remove all items attached to this wall from the spatial grid
|
|
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId)
|
|
return removedItemIds // Caller can use this to delete the items from scene
|
|
} else if (nodeType === 'item') {
|
|
this.getFloorGrid(levelId).remove(nodeId)
|
|
this.getWallGrid(levelId).removeByItemId(nodeId)
|
|
// Also clean up ceiling grid
|
|
const oldCeilingId = this.itemCeilingMap.get(nodeId)
|
|
if (oldCeilingId) {
|
|
this.getCeilingGrid(oldCeilingId).remove(nodeId)
|
|
this.itemCeilingMap.delete(nodeId)
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
// Query methods
|
|
canPlaceOnFloor(
|
|
levelId: string,
|
|
position: [number, number, number],
|
|
dimensions: [number, number, number],
|
|
rotation: [number, number, number],
|
|
ignoreIds?: string[],
|
|
) {
|
|
const grid = this.getFloorGrid(levelId)
|
|
return grid.canPlace(position, dimensions, rotation, ignoreIds)
|
|
}
|
|
|
|
/**
|
|
* Check if an item can be placed on a wall
|
|
* @param levelId - the level containing the wall
|
|
* @param wallId - the wall to check
|
|
* @param localX - X position in wall-local space (distance from wall start)
|
|
* @param localY - Y position (height from floor)
|
|
* @param dimensions - item dimensions [width, height, depth]
|
|
* @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side)
|
|
* @param side - which side for 'wall-side' items
|
|
* @param ignoreIds - item IDs to ignore in collision check
|
|
*/
|
|
canPlaceOnWall(
|
|
levelId: string,
|
|
wallId: string,
|
|
localX: number,
|
|
localY: number,
|
|
dimensions: [number, number, number],
|
|
attachType: 'wall' | 'wall-side' = 'wall',
|
|
side?: 'front' | 'back',
|
|
ignoreIds?: string[],
|
|
) {
|
|
const wallLength = this.getWallLength(wallId)
|
|
if (wallLength === 0) {
|
|
return { valid: false, conflictIds: [] }
|
|
}
|
|
const wallHeight = this.getWallHeight(wallId)
|
|
// Convert local X position to parametric t (0-1)
|
|
const tCenter = localX / wallLength
|
|
const [itemWidth, itemHeight] = dimensions
|
|
return this.getWallGrid(levelId).canPlaceOnWall(
|
|
wallId,
|
|
wallLength,
|
|
wallHeight,
|
|
tCenter,
|
|
itemWidth,
|
|
localY,
|
|
itemHeight,
|
|
attachType,
|
|
side,
|
|
ignoreIds,
|
|
)
|
|
}
|
|
|
|
getWallForItem(levelId: string, itemId: string): string | undefined {
|
|
return this.getWallGrid(levelId).getWallForItem(itemId)
|
|
}
|
|
|
|
/**
|
|
* Get the total slab elevation at a given (x, z) position on a level.
|
|
* Returns the highest slab elevation if the point is inside any slab polygon (but not in any holes), otherwise 0.
|
|
*/
|
|
getSlabElevationAt(levelId: string, x: number, z: number): number {
|
|
const slabMap = this.slabsByLevel.get(levelId)
|
|
if (!slabMap) return 0
|
|
|
|
let maxElevation = 0
|
|
for (const slab of slabMap.values()) {
|
|
if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) {
|
|
// Check if point is in any hole
|
|
let inHole = false
|
|
const holes = slab.holes || []
|
|
for (const hole of holes) {
|
|
if (hole.length >= 3 && pointInPolygon(x, z, hole)) {
|
|
inHole = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!inHole) {
|
|
const elevation = slab.elevation ?? 0.05
|
|
if (elevation > maxElevation) {
|
|
maxElevation = elevation
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return maxElevation
|
|
}
|
|
|
|
/**
|
|
* Get the slab elevation for an item using its full footprint (bounding box).
|
|
* Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes).
|
|
* Returns the highest overlapping slab elevation, or 0 if none.
|
|
*/
|
|
getSlabElevationForItem(
|
|
levelId: string,
|
|
position: [number, number, number],
|
|
dimensions: [number, number, number],
|
|
rotation: [number, number, number],
|
|
): number {
|
|
const slabMap = this.slabsByLevel.get(levelId)
|
|
if (!slabMap) return 0
|
|
|
|
let maxElevation = -Infinity
|
|
for (const slab of slabMap.values()) {
|
|
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
|
// Check if item is entirely within a hole (if so, ignore this slab)
|
|
// We consider it entirely in a hole if the item center is in the hole
|
|
let inHole = false
|
|
const [cx, , cz] = position
|
|
const holes = slab.holes || []
|
|
for (const hole of holes) {
|
|
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {
|
|
inHole = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!inHole) {
|
|
const elevation = slab.elevation ?? 0.05
|
|
if (elevation > maxElevation) {
|
|
maxElevation = elevation
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return maxElevation === -Infinity ? 0 : maxElevation
|
|
}
|
|
|
|
/**
|
|
* Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes).
|
|
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
|
* Returns the highest slab elevation found, or 0 if none.
|
|
*/
|
|
getSlabElevationForWall(
|
|
levelId: string,
|
|
start: [number, number],
|
|
end: [number, number],
|
|
): number {
|
|
const slabMap = this.slabsByLevel.get(levelId)
|
|
if (!slabMap) return 0
|
|
|
|
let maxElevation = -Infinity
|
|
for (const slab of slabMap.values()) {
|
|
if (slab.polygon.length < 3) continue
|
|
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
|
|
|
|
const holes = slab.holes || []
|
|
if (holes.length === 0) {
|
|
// No holes: wall is on this slab
|
|
const elevation = slab.elevation ?? 0.05
|
|
if (elevation > maxElevation) maxElevation = elevation
|
|
continue
|
|
}
|
|
|
|
// Sample multiple points along the wall to check whether any portion lies on
|
|
// solid slab (not inside any hole). Checking only the midpoint fails when the
|
|
// midpoint falls in a staircase hole but the wall's endpoints are on solid slab.
|
|
const dx = end[0] - start[0]
|
|
const dz = end[1] - start[1]
|
|
let hasValidPoint = false
|
|
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
|
|
const px = start[0] + dx * t
|
|
const pz = start[1] + dz * t
|
|
let inHole = false
|
|
for (const hole of holes) {
|
|
if (hole.length >= 3 && pointInPolygon(px, pz, hole)) {
|
|
inHole = true
|
|
break
|
|
}
|
|
}
|
|
if (!inHole) {
|
|
hasValidPoint = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (hasValidPoint) {
|
|
const elevation = slab.elevation ?? 0.05
|
|
if (elevation > maxElevation) maxElevation = elevation
|
|
}
|
|
}
|
|
return maxElevation === -Infinity ? 0 : maxElevation
|
|
}
|
|
|
|
/**
|
|
* Check if an item can be placed on a ceiling.
|
|
* Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items.
|
|
*/
|
|
canPlaceOnCeiling(
|
|
ceilingId: string,
|
|
position: [number, number, number],
|
|
dimensions: [number, number, number],
|
|
rotation: [number, number, number],
|
|
ignoreIds?: string[],
|
|
): { valid: boolean; conflictIds: string[] } {
|
|
const ceiling = this.ceilings.get(ceilingId)
|
|
if (!ceiling || ceiling.polygon.length < 3) {
|
|
return { valid: false, conflictIds: [] }
|
|
}
|
|
|
|
// Check that the item footprint is entirely within the ceiling polygon
|
|
const corners = getItemFootprint(position, dimensions, rotation)
|
|
for (const [cx, cz] of corners) {
|
|
if (!pointInPolygon(cx, cz, ceiling.polygon)) {
|
|
return { valid: false, conflictIds: [] }
|
|
}
|
|
}
|
|
|
|
// Check if item center is in any hole (if so, it cannot be placed)
|
|
const [centerX, , centerZ] = position
|
|
const holes = ceiling.holes || []
|
|
for (const hole of holes) {
|
|
if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) {
|
|
return { valid: false, conflictIds: [] }
|
|
}
|
|
}
|
|
|
|
// Check for overlaps with other ceiling items
|
|
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)
|
|
}
|
|
|
|
clearLevel(levelId: string) {
|
|
this.floorGrids.delete(levelId)
|
|
this.wallGrids.delete(levelId)
|
|
this.slabsByLevel.delete(levelId)
|
|
}
|
|
|
|
clear() {
|
|
this.floorGrids.clear()
|
|
this.wallGrids.clear()
|
|
this.walls.clear()
|
|
this.slabsByLevel.clear()
|
|
this.ceilingGrids.clear()
|
|
this.ceilings.clear()
|
|
this.itemCeilingMap.clear()
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
export const spatialGridManager = new SpatialGridManager()
|