ai-chatbot/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
2026-02-24 10:27:51 +09:00

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()