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 a458bb8b..54a0b81e 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,11 +1,30 @@ -import type { AnyNode, ItemNode, WallNode } from '../../schema' +import type { AnyNode, ItemNode, SlabNode, WallNode } from '../../schema' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' +/** + * 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 { + 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 +} + export class SpatialGridManager { private floorGrids = new Map() // levelId -> grid private wallGrids = new Map() // levelId -> wall grid private walls = new Map() // wallId -> wall data (for length calculations) + private slabsByLevel = new Map>() // levelId -> (slabId -> slab) constructor(private cellSize = 0.5) {} @@ -36,9 +55,18 @@ export class SpatialGridManager { return wall?.height ?? 2.5 // Default wall height } + private getSlabMap(levelId: string): Map { + 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 === 'wall') { + if (node.type === 'slab') { + this.getSlabMap(levelId).set(node.id, node as SlabNode) + } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) } else if (node.type === 'item') { @@ -79,7 +107,9 @@ export class SpatialGridManager { } handleNodeUpdated(node: AnyNode, levelId: string) { - if (node.type === 'wall') { + if (node.type === 'slab') { + this.getSlabMap(levelId).set(node.id, node as SlabNode) + } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) } else if (node.type === 'item') { @@ -120,7 +150,9 @@ export class SpatialGridManager { } handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { - if (nodeType === 'wall') { + if (nodeType === 'slab') { + this.getSlabMap(levelId).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) @@ -191,15 +223,37 @@ export class SpatialGridManager { 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, 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)) { + const elevation = slab.elevation ?? 0.05 + if (elevation > maxElevation) { + maxElevation = elevation + } + } + } + return maxElevation + } + 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() } } 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 b40759ed..c78462a0 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -53,10 +53,12 @@ export function initSpatialGridSync() { } } - // Detect updated nodes (items with position/rotation/parentId/side changes) + // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes) for (const [id, node] of Object.entries(state.nodes)) { const prev = prevState.nodes[id as AnyNode['id']] - if (prev && node.type === 'item' && prev.type === 'item') { + if (!prev) continue + + if (node.type === 'item' && prev.type === 'item') { if ( !arraysEqual(node.position, prev.position) || !arraysEqual(node.rotation, prev.rotation) || @@ -66,6 +68,11 @@ export function initSpatialGridSync() { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) } + } else if (node.type === 'slab' && prev.type === 'slab') { + if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) + } } } }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7b2ef67a..ac9a5504 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export * from './schema' export { default as useScene } from './store/use-scene' // Systems export { CeilingSystem } from './systems/ceiling/ceiling-system' +export { ItemSystem } from './systems/item/item-system' export { SlabSystem } from './systems/slab/slab-system' export { WallSystem } from './systems/wall/wall-system' diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/core/src/systems/item/item-system.tsx new file mode 100644 index 00000000..a5d94bf4 --- /dev/null +++ b/packages/core/src/systems/item/item-system.tsx @@ -0,0 +1,46 @@ +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import type { AnyNodeId, ItemNode, WallNode } from '../../schema' +import useScene from '../../store/use-scene' + +// ============================================================================ +// ITEM SYSTEM +// ============================================================================ + +export const ItemSystem = () => { + const { nodes, dirtyNodes, clearDirty } = useScene() + + useFrame(() => { + if (dirtyNodes.size === 0) return + + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (!node || node.type !== 'item') return + + const item = node as ItemNode + const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D + if (!mesh) return + + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { + // Wall-attached item: offset Z by half the parent wall's thickness + const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined + if (parentWall && parentWall.type === 'wall') { + const wallThickness = (parentWall as WallNode).thickness ?? 0.1 + mesh.position.z = wallThickness / 2 + } + } else if (!item.asset.attachTo) { + // Floor item: elevate by slab height + const levelId = resolveLevelId(item, nodes) + const slabElevation = spatialGridManager.getSlabElevationAt(levelId, item.position[0], item.position[2]) + mesh.position.y = slabElevation + } + + clearDirty(id as AnyNodeId) + }) + }) + + return null +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index c69ebccb..0b1751f8 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { CeilingSystem, SlabSystem, WallSystem } from '@pascal-app/core' +import { CeilingSystem, ItemSystem, SlabSystem, WallSystem } from '@pascal-app/core' import { Bvh, Environment } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import * as THREE from 'three/webgpu' @@ -41,6 +41,7 @@ const Viewer: React.FC = ({ children }) => { {/* Default Systems */} +