diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 54a0b81e..f1a365d0 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -2,11 +2,14 @@ import type { AnyNode, 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. - * Returns true if point (px, pz) is inside the polygon defined by vertices. */ -function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean { +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++) { @@ -20,6 +23,138 @@ function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]> 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 +} + +/** + * Test if a wall segment overlaps with a polygon. + */ +export function wallOverlapsPolygon( + start: [number, number], + end: [number, number], + polygon: Array<[number, number]>, +): boolean { + // Either endpoint inside the polygon + if (pointInPolygon(start[0], start[1], polygon)) return true + if (pointInPolygon(end[0], end[1], polygon)) return true + + // Wall segment intersects any polygon edge + if (segmentIntersectsPolygon(start[0], start[1], end[0], end[1], polygon)) return true + + return false +} + export class SpatialGridManager { private floorGrids = new Map() // levelId -> grid private wallGrids = new Map() // levelId -> wall grid @@ -243,6 +378,32 @@ export class SpatialGridManager { 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. + * 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 = 0 + for (const slab of slabMap.values()) { + if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) { + const elevation = slab.elevation ?? 0.05 + if (elevation > maxElevation) { + maxElevation = elevation + } + } + } + return maxElevation + } + clearLevel(levelId: string) { this.floorGrids.delete(levelId) this.wallGrids.delete(levelId) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index c78462a0..d978403a 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,6 +1,6 @@ -import type { AnyNode } from '../../schema' +import type { AnyNode, AnyNodeId, ItemNode, SlabNode, WallNode } from '../../schema' import useScene from '../../store/use-scene' -import { spatialGridManager } from './spatial-grid-manager' +import { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager' export function resolveLevelId(node: AnyNode, nodes: Record): string { // If the node itself is a level @@ -34,6 +34,7 @@ export function initSpatialGridSync() { } // 2. Then subscribe to future changes + const markDirty = (id: AnyNodeId) => store.getState().markDirty(id) // Subscribe to all changes store.subscribe((state, prevState) => { @@ -42,6 +43,11 @@ export function initSpatialGridSync() { if (!prevState.nodes[id as AnyNode['id']]) { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeCreated(node, levelId) + + // When a slab is added, mark overlapping items/walls dirty + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + } } } @@ -50,6 +56,11 @@ export function initSpatialGridSync() { if (!state.nodes[id as AnyNode['id']]) { const levelId = resolveLevelId(node, prevState.nodes) spatialGridManager.handleNodeDeleted(id, node.type, levelId) + + // When a slab is removed, mark items/walls that were on it dirty (using current state) + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + } } } @@ -72,6 +83,10 @@ export function initSpatialGridSync() { if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) + + // Mark nodes overlapping old polygon and new polygon as dirty + markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) } } } @@ -81,3 +96,33 @@ export function initSpatialGridSync() { function arraysEqual(a: number[], b: number[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]) } + +/** + * Mark all floor items and walls that overlap a slab polygon as dirty. + */ +function markNodesOverlappingSlab( + slab: SlabNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + if (slab.polygon.length < 3) return + const slabLevelId = resolveLevelId(slab, nodes) + + for (const node of Object.values(nodes)) { + if (node.type === 'item') { + const item = node as ItemNode + // Only floor items are affected by slabs + if (item.asset.attachTo) continue + if (resolveLevelId(node, nodes) !== slabLevelId) continue + if (itemOverlapsPolygon(item.position, item.asset.dimensions, item.rotation, slab.polygon, 0.01)) { + markDirty(node.id) + } + } else if (node.type === 'wall') { + const wall = node as WallNode + if (resolveLevelId(node, nodes) !== slabLevelId) continue + if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) { + markDirty(node.id) + } + } + } +} diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/core/src/systems/item/item-system.tsx index a5d94bf4..4722da9b 100644 --- a/packages/core/src/systems/item/item-system.tsx +++ b/packages/core/src/systems/item/item-system.tsx @@ -32,9 +32,11 @@ export const ItemSystem = () => { mesh.position.z = wallThickness / 2 } } else if (!item.asset.attachTo) { - // Floor item: elevate by slab height + // Floor item: elevate by slab height (using full footprint overlap) const levelId = resolveLevelId(item, nodes) - const slabElevation = spatialGridManager.getSlabElevationAt(levelId, item.position[0], item.position[2]) + const slabElevation = spatialGridManager.getSlabElevationForItem( + levelId, item.position, item.asset.dimensions, item.rotation, + ) mesh.position.y = slabElevation }