diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index e20d54bd..7e531da4 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -1,6 +1,6 @@ '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 { 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 { MeshBasicNodeMaterial } from 'three/webgpu' import { useKeyboard } from '@/hooks/use-keyboard' +import useEditor from '@/store/use-editor' import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' @@ -23,6 +24,7 @@ import { SelectionManager } from './selection-manager' useScene.getState().loadScene() console.log('Loaded scene in editor') initSpatialGridSync() +initSpaceDetectionSync(useScene, useEditor) export default function Editor() { useKeyboard() diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index e64c271a..ee4ece60 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -1,6 +1,6 @@ '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 { create } from 'zustand' import type { AssetInput } from '@pascal-app/core' @@ -50,7 +50,7 @@ type EditorState = { setMode: (mode: Mode) => void tool: Tool | null setTool: (tool: Tool | null) => void - structureLayer: StructureLayer + structureLayer: StructureLayer setStructureLayer: (layer: StructureLayer) => void catalogCategory: CatalogCategory | null setCatalogCategory: (category: CatalogCategory | null) => void @@ -60,6 +60,9 @@ type EditorState = { setMovingNode: (node: ItemNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void + // Space detection for cutaway mode + spaces: Record + setSpaces: (spaces: Record) => void } const useEditor = create()((set, get) => ({ @@ -174,6 +177,8 @@ const useEditor = create()((set, get) => ({ setMovingNode: (node) => set({ movingNode: node }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + spaces: {}, + setSpaces: (spaces) => set({ spaces }), })) export default useEditor diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 73ceda2b..dbad2144 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,3 +40,5 @@ export { WallSystem } from './systems/wall/wall-system' export { isObject } from './utils/types' // Asset storage export { saveAsset, loadAssetUrl } from './lib/asset-storage' +// Space detection +export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts new file mode 100644 index 00000000..cae5426e --- /dev/null +++ b/packages/core/src/lib/space-detection.ts @@ -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>() + 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>() + + // 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() + + // 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 = {} + + 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 + 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() + + // 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() + 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, 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) +} diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 6ce0ddef..143419a5 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -16,6 +16,9 @@ export const WallNode = BaseNode.extend({ // e.g., start/end points for path start: 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( dedent` 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 - end: end point of the wall in level coordinate system - 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 diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 8042b1da..66132dc5 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -38,6 +38,7 @@ export const WallSystem = () => { const node = nodes[id] if (!node || node.type !== 'wall') return + console.log('wall front/back', node.frontSide, node.backSide) const levelId = node.parentId if (!levelId) return