wall sides feed with floo fill algorithm
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { initSpatialGridSync, sceneRegistry, useScene } from '@pascal-app/core'
|
import { initSpatialGridSync, initSpaceDetectionSync, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
|
import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
|
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
@@ -10,6 +10,7 @@ import { MathUtils, type Mesh } from 'three'
|
|||||||
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
|
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
@@ -23,6 +24,7 @@ import { SelectionManager } from './selection-manager'
|
|||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
console.log('Loaded scene in editor')
|
console.log('Loaded scene in editor')
|
||||||
initSpatialGridSync()
|
initSpatialGridSync()
|
||||||
|
initSpaceDetectionSync(useScene, useEditor)
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
useKeyboard()
|
useKeyboard()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type BuildingNode, type ItemNode, type LevelNode, useScene } from '@pascal-app/core'
|
import { type BuildingNode, type ItemNode, type LevelNode, type Space, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { AssetInput } from '@pascal-app/core'
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
@@ -50,7 +50,7 @@ type EditorState = {
|
|||||||
setMode: (mode: Mode) => void
|
setMode: (mode: Mode) => void
|
||||||
tool: Tool | null
|
tool: Tool | null
|
||||||
setTool: (tool: Tool | null) => void
|
setTool: (tool: Tool | null) => void
|
||||||
structureLayer: StructureLayer
|
structureLayer: StructureLayer
|
||||||
setStructureLayer: (layer: StructureLayer) => void
|
setStructureLayer: (layer: StructureLayer) => void
|
||||||
catalogCategory: CatalogCategory | null
|
catalogCategory: CatalogCategory | null
|
||||||
setCatalogCategory: (category: CatalogCategory | null) => void
|
setCatalogCategory: (category: CatalogCategory | null) => void
|
||||||
@@ -60,6 +60,9 @@ type EditorState = {
|
|||||||
setMovingNode: (node: ItemNode | null) => void
|
setMovingNode: (node: ItemNode | null) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
|
// Space detection for cutaway mode
|
||||||
|
spaces: Record<string, Space>
|
||||||
|
setSpaces: (spaces: Record<string, Space>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditor = create<EditorState>()((set, get) => ({
|
const useEditor = create<EditorState>()((set, get) => ({
|
||||||
@@ -174,6 +177,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setMovingNode: (node) => set({ movingNode: node }),
|
setMovingNode: (node) => set({ movingNode: node }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
|
spaces: {},
|
||||||
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export default useEditor
|
export default useEditor
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ export { WallSystem } from './systems/wall/wall-system'
|
|||||||
export { isObject } from './utils/types'
|
export { isObject } from './utils/types'
|
||||||
// Asset storage
|
// Asset storage
|
||||||
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
|
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
|
||||||
|
// Space detection
|
||||||
|
export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection'
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import type { WallNode } from '../schema'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Space = {
|
||||||
|
id: string
|
||||||
|
levelId: string
|
||||||
|
polygon: Array<[number, number]>
|
||||||
|
wallIds: string[]
|
||||||
|
isExterior: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYNC INITIALIZATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes space detection sync with scene and editor stores
|
||||||
|
* Call this once during app initialization
|
||||||
|
*/
|
||||||
|
export function initSpaceDetectionSync(
|
||||||
|
sceneStore: any, // useScene store
|
||||||
|
editorStore: any, // useEditor store
|
||||||
|
): () => void {
|
||||||
|
const prevWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
let isProcessing = false // Prevent re-entrant calls
|
||||||
|
|
||||||
|
// Subscribe to scene changes (standard Zustand subscribe, not selector-based)
|
||||||
|
const unsubscribe = sceneStore.subscribe((state: any) => {
|
||||||
|
// Skip if already processing to avoid infinite loops
|
||||||
|
if (isProcessing) return
|
||||||
|
|
||||||
|
const nodes = state.nodes
|
||||||
|
const currentWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
// Group walls by level
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if ((node as any).type === 'wall' && (node as any).parentId) {
|
||||||
|
const levelId = (node as any).parentId
|
||||||
|
if (!currentWallsByLevel.has(levelId)) {
|
||||||
|
currentWallsByLevel.set(levelId, new Set())
|
||||||
|
}
|
||||||
|
currentWallsByLevel.get(levelId)!.add((node as any).id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each level for changes
|
||||||
|
const levelsToUpdate = new Set<string>()
|
||||||
|
|
||||||
|
// Check for new walls (created)
|
||||||
|
for (const [levelId, wallIds] of currentWallsByLevel.entries()) {
|
||||||
|
const prevWallIds = prevWallsByLevel.get(levelId)
|
||||||
|
|
||||||
|
if (!prevWallIds) {
|
||||||
|
// New level with walls - run detection if there are multiple walls
|
||||||
|
if (wallIds.size > 1) {
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find newly added walls
|
||||||
|
for (const wallId of wallIds) {
|
||||||
|
if (!prevWallIds.has(wallId)) {
|
||||||
|
// Wall was added - check if it touches other walls
|
||||||
|
const wall = nodes[wallId as keyof typeof nodes] as WallNode
|
||||||
|
const otherWalls = Array.from(wallIds)
|
||||||
|
.filter((id) => id !== wallId)
|
||||||
|
.map((id) => nodes[id as keyof typeof nodes] as WallNode)
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (wallTouchesOthers(wall, otherWalls)) {
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for deleted walls
|
||||||
|
for (const [levelId, prevWallIds] of prevWallsByLevel.entries()) {
|
||||||
|
const currentWallIds = currentWallsByLevel.get(levelId)
|
||||||
|
|
||||||
|
if (!currentWallIds) {
|
||||||
|
// All walls deleted from level - clear spaces
|
||||||
|
if (prevWallIds.size > 0) {
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any walls were deleted
|
||||||
|
for (const wallId of prevWallIds) {
|
||||||
|
if (!currentWallIds.has(wallId)) {
|
||||||
|
// Wall was deleted - run detection
|
||||||
|
levelsToUpdate.add(levelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run detection for affected levels
|
||||||
|
if (levelsToUpdate.size > 0) {
|
||||||
|
isProcessing = true
|
||||||
|
try {
|
||||||
|
runSpaceDetection(Array.from(levelsToUpdate), sceneStore, editorStore, nodes)
|
||||||
|
} finally {
|
||||||
|
isProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous walls reference
|
||||||
|
prevWallsByLevel.clear()
|
||||||
|
for (const [levelId, wallIds] of currentWallsByLevel.entries()) {
|
||||||
|
prevWallsByLevel.set(levelId, wallIds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs space detection for the given levels
|
||||||
|
* Updates wall nodes and editor spaces
|
||||||
|
*/
|
||||||
|
function runSpaceDetection(
|
||||||
|
levelIds: string[],
|
||||||
|
sceneStore: any,
|
||||||
|
editorStore: any,
|
||||||
|
nodes: any,
|
||||||
|
): void {
|
||||||
|
const { updateNode } = sceneStore.getState()
|
||||||
|
const { setSpaces } = editorStore.getState()
|
||||||
|
|
||||||
|
const allSpaces: Record<string, any> = {}
|
||||||
|
|
||||||
|
for (const levelId of levelIds) {
|
||||||
|
// Get walls for this level
|
||||||
|
const walls = Object.values(nodes).filter(
|
||||||
|
(node: any) => node.type === 'wall' && node.parentId === levelId,
|
||||||
|
) as WallNode[]
|
||||||
|
|
||||||
|
if (walls.length === 0) {
|
||||||
|
// No walls - clear any spaces for this level
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run detection
|
||||||
|
const { wallUpdates, spaces } = detectSpacesForLevel(levelId, walls)
|
||||||
|
|
||||||
|
// Update wall nodes (only if values changed to avoid infinite loop)
|
||||||
|
for (const update of wallUpdates) {
|
||||||
|
const wall = nodes[update.wallId as keyof typeof nodes] as WallNode
|
||||||
|
if (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) {
|
||||||
|
updateNode(update.wallId as any, {
|
||||||
|
frontSide: update.frontSide,
|
||||||
|
backSide: update.backSide,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store spaces
|
||||||
|
for (const space of spaces) {
|
||||||
|
allSpaces[space.id] = space
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update editor spaces
|
||||||
|
setSpaces(allSpaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Grid = {
|
||||||
|
cells: Map<string, 'empty' | 'wall' | 'exterior' | 'interior'>
|
||||||
|
resolution: number
|
||||||
|
minX: number
|
||||||
|
minZ: number
|
||||||
|
maxX: number
|
||||||
|
maxZ: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type WallSideUpdate = {
|
||||||
|
wallId: string
|
||||||
|
frontSide: 'interior' | 'exterior' | 'unknown'
|
||||||
|
backSide: 'interior' | 'exterior' | 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN DETECTION FUNCTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects spaces for a level by flood-filling a grid from the edges
|
||||||
|
* Returns wall side updates and detected spaces
|
||||||
|
*/
|
||||||
|
export function detectSpacesForLevel(
|
||||||
|
levelId: string,
|
||||||
|
walls: WallNode[],
|
||||||
|
gridResolution: number = 0.5, // Match spatial grid cell size
|
||||||
|
): {
|
||||||
|
wallUpdates: WallSideUpdate[]
|
||||||
|
spaces: Space[]
|
||||||
|
} {
|
||||||
|
if (walls.length === 0) {
|
||||||
|
return { wallUpdates: [], spaces: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build grid from walls
|
||||||
|
const grid = buildGrid(walls, gridResolution)
|
||||||
|
|
||||||
|
// Flood fill from edges to mark exterior
|
||||||
|
floodFillFromEdges(grid)
|
||||||
|
|
||||||
|
// Find interior spaces
|
||||||
|
const interiorSpaces = findInteriorSpaces(grid, levelId)
|
||||||
|
|
||||||
|
// Assign wall sides
|
||||||
|
const wallUpdates = assignWallSides(walls, grid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
wallUpdates,
|
||||||
|
spaces: interiorSpaces,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GRID BUILDING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a discrete grid and marks cells occupied by walls
|
||||||
|
*/
|
||||||
|
function buildGrid(walls: WallNode[], resolution: number): Grid {
|
||||||
|
// Find bounds
|
||||||
|
let minX = Number.POSITIVE_INFINITY
|
||||||
|
let minZ = Number.POSITIVE_INFINITY
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY
|
||||||
|
let maxZ = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
for (const wall of walls) {
|
||||||
|
minX = Math.min(minX, wall.start[0], wall.end[0])
|
||||||
|
minZ = Math.min(minZ, wall.start[1], wall.end[1])
|
||||||
|
maxX = Math.max(maxX, wall.start[0], wall.end[0])
|
||||||
|
maxZ = Math.max(maxZ, wall.start[1], wall.end[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add padding around bounds
|
||||||
|
const padding = 2 // meters
|
||||||
|
minX -= padding
|
||||||
|
minZ -= padding
|
||||||
|
maxX += padding
|
||||||
|
maxZ += padding
|
||||||
|
|
||||||
|
const width = Math.ceil((maxX - minX) / resolution)
|
||||||
|
const height = Math.ceil((maxZ - minZ) / resolution)
|
||||||
|
|
||||||
|
const grid: Grid = {
|
||||||
|
cells: new Map(),
|
||||||
|
resolution,
|
||||||
|
minX,
|
||||||
|
minZ,
|
||||||
|
maxX,
|
||||||
|
maxZ,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark wall cells
|
||||||
|
for (const wall of walls) {
|
||||||
|
markWallCells(grid, wall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks all grid cells occupied by a wall using line rasterization
|
||||||
|
* Uses denser sampling to ensure continuous barriers
|
||||||
|
*/
|
||||||
|
function markWallCells(grid: Grid, wall: WallNode): void {
|
||||||
|
const thickness = wall.thickness ?? 0.2
|
||||||
|
const halfThickness = thickness / 2
|
||||||
|
|
||||||
|
const [x1, z1] = wall.start
|
||||||
|
const [x2, z2] = wall.end
|
||||||
|
|
||||||
|
// Wall direction vector
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dz = z2 - z1
|
||||||
|
const len = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
if (len < 0.001) return
|
||||||
|
|
||||||
|
// Normalized direction and perpendicular
|
||||||
|
const dirX = dx / len
|
||||||
|
const dirZ = dz / len
|
||||||
|
const perpX = -dirZ
|
||||||
|
const perpZ = dirX
|
||||||
|
|
||||||
|
// Denser sampling along wall length (at least 2x resolution)
|
||||||
|
const steps = Math.max(Math.ceil(len / (grid.resolution * 0.5)), 2)
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps
|
||||||
|
const x = x1 + dx * t
|
||||||
|
const z = z1 + dz * t
|
||||||
|
|
||||||
|
// Denser sampling across wall thickness
|
||||||
|
const thicknessSteps = Math.max(Math.ceil(thickness / (grid.resolution * 0.5)), 2)
|
||||||
|
for (let j = 0; j <= thicknessSteps; j++) {
|
||||||
|
const offset = (j / thicknessSteps - 0.5) * thickness
|
||||||
|
const wx = x + perpX * offset
|
||||||
|
const wz = z + perpZ * offset
|
||||||
|
|
||||||
|
const key = getCellKey(grid, wx, wz)
|
||||||
|
if (key) {
|
||||||
|
grid.cells.set(key, 'wall')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FLOOD FILL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flood fills from all edge cells to mark exterior space
|
||||||
|
*/
|
||||||
|
function floodFillFromEdges(grid: Grid): void {
|
||||||
|
const queue: string[] = []
|
||||||
|
|
||||||
|
// Add all edge cells to queue
|
||||||
|
for (let x = 0; x < grid.width; x++) {
|
||||||
|
for (let z = 0; z < grid.height; z++) {
|
||||||
|
// Only process edge cells
|
||||||
|
if (x === 0 || x === grid.width - 1 || z === 0 || z === grid.height - 1) {
|
||||||
|
const key = getCellKeyFromIndex(x, z, grid.width)
|
||||||
|
const cell = grid.cells.get(key)
|
||||||
|
if (cell !== 'wall') {
|
||||||
|
grid.cells.set(key, 'exterior')
|
||||||
|
queue.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flood fill
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const key = queue.shift()!
|
||||||
|
const [x, z] = parseCellKey(key)
|
||||||
|
|
||||||
|
// Check 4 neighbors
|
||||||
|
const neighbors = [
|
||||||
|
[x + 1, z],
|
||||||
|
[x - 1, z],
|
||||||
|
[x, z + 1],
|
||||||
|
[x, z - 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [nx, nz] of neighbors) {
|
||||||
|
if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height) continue
|
||||||
|
|
||||||
|
const nKey = getCellKeyFromIndex(nx, nz, grid.width)
|
||||||
|
const cell = grid.cells.get(nKey)
|
||||||
|
|
||||||
|
if (cell !== 'wall' && cell !== 'exterior') {
|
||||||
|
grid.cells.set(nKey, 'exterior')
|
||||||
|
queue.push(nKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERIOR SPACE DETECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all interior spaces (connected regions not marked as exterior or wall)
|
||||||
|
*/
|
||||||
|
function findInteriorSpaces(grid: Grid, levelId: string): Space[] {
|
||||||
|
const spaces: Space[] = []
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
// Scan grid for interior cells
|
||||||
|
for (let x = 0; x < grid.width; x++) {
|
||||||
|
for (let z = 0; z < grid.height; z++) {
|
||||||
|
const key = getCellKeyFromIndex(x, z, grid.width)
|
||||||
|
if (visited.has(key)) continue
|
||||||
|
|
||||||
|
const cell = grid.cells.get(key)
|
||||||
|
if (cell === 'wall' || cell === 'exterior') {
|
||||||
|
visited.add(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found interior cell - flood fill to find full space
|
||||||
|
const spaceCells = new Set<string>()
|
||||||
|
const queue = [key]
|
||||||
|
visited.add(key)
|
||||||
|
spaceCells.add(key)
|
||||||
|
// Mark the seed cell as interior in the grid
|
||||||
|
grid.cells.set(key, 'interior')
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const curKey = queue.shift()!
|
||||||
|
const [cx, cz] = parseCellKey(curKey)
|
||||||
|
|
||||||
|
const neighbors = [
|
||||||
|
[cx + 1, cz],
|
||||||
|
[cx - 1, cz],
|
||||||
|
[cx, cz + 1],
|
||||||
|
[cx, cz - 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [nx, nz] of neighbors) {
|
||||||
|
if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height) continue
|
||||||
|
|
||||||
|
const nKey = getCellKeyFromIndex(nx, nz, grid.width)
|
||||||
|
if (visited.has(nKey)) continue
|
||||||
|
|
||||||
|
const nCell = grid.cells.get(nKey)
|
||||||
|
if (nCell === 'wall' || nCell === 'exterior') {
|
||||||
|
visited.add(nKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(nKey)
|
||||||
|
spaceCells.add(nKey)
|
||||||
|
// Mark as interior in grid
|
||||||
|
grid.cells.set(nKey, 'interior')
|
||||||
|
queue.push(nKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create space from cells
|
||||||
|
const polygon = extractPolygonFromCells(spaceCells, grid)
|
||||||
|
spaces.push({
|
||||||
|
id: `space-${spaces.length}`,
|
||||||
|
levelId,
|
||||||
|
polygon,
|
||||||
|
wallIds: [],
|
||||||
|
isExterior: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaces
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a simplified polygon from a set of grid cells
|
||||||
|
* Returns bounding box for now (can be improved to trace actual boundary)
|
||||||
|
*/
|
||||||
|
function extractPolygonFromCells(cells: Set<string>, grid: Grid): Array<[number, number]> {
|
||||||
|
let minX = Number.POSITIVE_INFINITY
|
||||||
|
let minZ = Number.POSITIVE_INFINITY
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY
|
||||||
|
let maxZ = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
for (const key of cells) {
|
||||||
|
const [x, z] = parseCellKey(key)
|
||||||
|
const worldX = grid.minX + x * grid.resolution
|
||||||
|
const worldZ = grid.minZ + z * grid.resolution
|
||||||
|
|
||||||
|
minX = Math.min(minX, worldX)
|
||||||
|
minZ = Math.min(minZ, worldZ)
|
||||||
|
maxX = Math.max(maxX, worldX)
|
||||||
|
maxZ = Math.max(maxZ, worldZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return bounding box as polygon
|
||||||
|
return [
|
||||||
|
[minX, minZ],
|
||||||
|
[maxX, minZ],
|
||||||
|
[maxX, maxZ],
|
||||||
|
[minX, maxZ],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WALL SIDE ASSIGNMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns front/back side classification to each wall based on grid
|
||||||
|
*/
|
||||||
|
function assignWallSides(walls: WallNode[], grid: Grid): WallSideUpdate[] {
|
||||||
|
const updates: WallSideUpdate[] = []
|
||||||
|
|
||||||
|
for (const wall of walls) {
|
||||||
|
const thickness = wall.thickness ?? 0.2
|
||||||
|
const [x1, z1] = wall.start
|
||||||
|
const [x2, z2] = wall.end
|
||||||
|
|
||||||
|
// Wall direction and perpendicular
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dz = z2 - z1
|
||||||
|
const len = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
if (len < 0.001) continue
|
||||||
|
|
||||||
|
const perpX = -dz / len
|
||||||
|
const perpZ = dx / len
|
||||||
|
|
||||||
|
// Sample point on front side (perpendicular direction)
|
||||||
|
const midX = (x1 + x2) / 2
|
||||||
|
const midZ = (z1 + z2) / 2
|
||||||
|
// Sample beyond wall thickness + one full grid cell to ensure we're in the next cell
|
||||||
|
const offset = thickness / 2 + grid.resolution
|
||||||
|
|
||||||
|
const frontX = midX + perpX * offset
|
||||||
|
const frontZ = midZ + perpZ * offset
|
||||||
|
const backX = midX - perpX * offset
|
||||||
|
const backZ = midZ - perpZ * offset
|
||||||
|
|
||||||
|
// Check what space each side faces
|
||||||
|
const frontKey = getCellKey(grid, frontX, frontZ)
|
||||||
|
const backKey = getCellKey(grid, backX, backZ)
|
||||||
|
|
||||||
|
const frontCell = frontKey ? grid.cells.get(frontKey) : undefined
|
||||||
|
const backCell = backKey ? grid.cells.get(backKey) : undefined
|
||||||
|
|
||||||
|
const frontSide = classifySide(frontCell)
|
||||||
|
const backSide = classifySide(backCell)
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
wallId: wall.id,
|
||||||
|
frontSide,
|
||||||
|
backSide,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a cell as interior, exterior, or unknown
|
||||||
|
*/
|
||||||
|
function classifySide(cell: string | undefined): 'interior' | 'exterior' | 'unknown' {
|
||||||
|
if (cell === 'exterior') return 'exterior'
|
||||||
|
if (cell === 'interior') return 'interior'
|
||||||
|
// Wall cells or out-of-bounds (undefined) are unknown
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GRID UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets grid cell key from world coordinates
|
||||||
|
*/
|
||||||
|
function getCellKey(grid: Grid, x: number, z: number): string | null {
|
||||||
|
const cellX = Math.floor((x - grid.minX) / grid.resolution)
|
||||||
|
const cellZ = Math.floor((z - grid.minZ) / grid.resolution)
|
||||||
|
|
||||||
|
if (cellX < 0 || cellX >= grid.width || cellZ < 0 || cellZ >= grid.height) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${cellX},${cellZ}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets cell key from grid indices
|
||||||
|
*/
|
||||||
|
function getCellKeyFromIndex(x: number, z: number, width: number): string {
|
||||||
|
return `${x},${z}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses cell key back to indices
|
||||||
|
*/
|
||||||
|
function parseCellKey(key: string): [number, number] {
|
||||||
|
const parts = key.split(',')
|
||||||
|
return [Number.parseInt(parts[0]!, 10), Number.parseInt(parts[1]!, 10)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WALL CONNECTIVITY DETECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a wall touches any other walls
|
||||||
|
* Used to determine if space detection should run
|
||||||
|
*/
|
||||||
|
export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean {
|
||||||
|
const threshold = 0.1 // 10cm connection threshold
|
||||||
|
|
||||||
|
for (const other of otherWalls) {
|
||||||
|
if (other.id === wall.id) continue
|
||||||
|
|
||||||
|
// Check if any endpoint of wall is close to any endpoint or segment of other
|
||||||
|
if (
|
||||||
|
distanceToSegment(wall.start, other.start, other.end) < threshold ||
|
||||||
|
distanceToSegment(wall.end, other.start, other.end) < threshold ||
|
||||||
|
distanceToSegment(other.start, wall.start, wall.end) < threshold ||
|
||||||
|
distanceToSegment(other.end, wall.start, wall.end) < threshold
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance from point to line segment
|
||||||
|
*/
|
||||||
|
function distanceToSegment(
|
||||||
|
point: [number, number],
|
||||||
|
segStart: [number, number],
|
||||||
|
segEnd: [number, number],
|
||||||
|
): number {
|
||||||
|
const [px, pz] = point
|
||||||
|
const [x1, z1] = segStart
|
||||||
|
const [x2, z2] = segEnd
|
||||||
|
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dz = z2 - z1
|
||||||
|
const lenSq = dx * dx + dz * dz
|
||||||
|
|
||||||
|
if (lenSq < 0.0001) {
|
||||||
|
// Segment is a point
|
||||||
|
const dpx = px - x1
|
||||||
|
const dpz = pz - z1
|
||||||
|
return Math.sqrt(dpx * dpx + dpz * dpz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project point onto line
|
||||||
|
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lenSq))
|
||||||
|
const projX = x1 + t * dx
|
||||||
|
const projZ = z1 + t * dz
|
||||||
|
|
||||||
|
const distX = px - projX
|
||||||
|
const distZ = pz - projZ
|
||||||
|
|
||||||
|
return Math.sqrt(distX * distX + distZ * distZ)
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ export const WallNode = BaseNode.extend({
|
|||||||
// e.g., start/end points for path
|
// e.g., start/end points for path
|
||||||
start: z.tuple([z.number(), z.number()]),
|
start: z.tuple([z.number(), z.number()]),
|
||||||
end: z.tuple([z.number(), z.number()]),
|
end: z.tuple([z.number(), z.number()]),
|
||||||
|
// Space detection for cutaway mode
|
||||||
|
frontSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'),
|
||||||
|
backSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Wall node - used to represent a wall in the building
|
Wall node - used to represent a wall in the building
|
||||||
@@ -24,6 +27,8 @@ export const WallNode = BaseNode.extend({
|
|||||||
- start: start point of the wall in level coordinate system
|
- start: start point of the wall in level coordinate system
|
||||||
- end: end point of the wall in level coordinate system
|
- end: end point of the wall in level coordinate system
|
||||||
- size: size of the wall in grid units
|
- size: size of the wall in grid units
|
||||||
|
- frontSide: whether the front side faces interior, exterior, or unknown
|
||||||
|
- backSide: whether the back side faces interior, exterior, or unknown
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
export type WallNode = z.infer<typeof WallNode>
|
export type WallNode = z.infer<typeof WallNode>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const WallSystem = () => {
|
|||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
|
|
||||||
|
console.log('wall front/back', node.frontSide, node.backSide)
|
||||||
const levelId = node.parentId
|
const levelId = node.parentId
|
||||||
if (!levelId) return
|
if (!levelId) return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user