slab polygons elevation

This commit is contained in:
wass08
2026-01-27 19:20:01 +09:00
parent 11aba2af36
commit 408fd8b87c
3 changed files with 214 additions and 6 deletions
@@ -2,11 +2,14 @@ import type { AnyNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid'
// ============================================================================
// GEOMETRY HELPERS
// ============================================================================
/** /**
* Point-in-polygon test using ray casting algorithm. * 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 let inside = false
const n = polygon.length const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) { 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 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 { export class SpatialGridManager {
private floorGrids = new Map<string, SpatialGrid>() // levelId -> grid private floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
private wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid private wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
@@ -243,6 +378,32 @@ export class SpatialGridManager {
return maxElevation 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) { clearLevel(levelId: string) {
this.floorGrids.delete(levelId) this.floorGrids.delete(levelId)
this.wallGrids.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 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 { export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
// If the node itself is a level // If the node itself is a level
@@ -34,6 +34,7 @@ export function initSpatialGridSync() {
} }
// 2. Then subscribe to future changes // 2. Then subscribe to future changes
const markDirty = (id: AnyNodeId) => store.getState().markDirty(id)
// Subscribe to all changes // Subscribe to all changes
store.subscribe((state, prevState) => { store.subscribe((state, prevState) => {
@@ -42,6 +43,11 @@ export function initSpatialGridSync() {
if (!prevState.nodes[id as AnyNode['id']]) { if (!prevState.nodes[id as AnyNode['id']]) {
const levelId = resolveLevelId(node, state.nodes) const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeCreated(node, levelId) 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']]) { if (!state.nodes[id as AnyNode['id']]) {
const levelId = resolveLevelId(node, prevState.nodes) const levelId = resolveLevelId(node, prevState.nodes)
spatialGridManager.handleNodeDeleted(id, node.type, levelId) 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) { if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) {
const levelId = resolveLevelId(node, state.nodes) const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId) 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 { function arraysEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]) 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 mesh.position.z = wallThickness / 2
} }
} else if (!item.asset.attachTo) { } 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 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 mesh.position.y = slabElevation
} }