slab polygons elevation
This commit is contained in:
@@ -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<string, SpatialGrid>() // levelId -> grid
|
||||
private wallGrids = new Map<string, WallSpatialGrid>() // 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)
|
||||
|
||||
@@ -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, AnyNode>): 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<string, AnyNode>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user