From 6190ba4bdc16b6ef7a34c108757fa3536be8a5c3 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 15 Apr 2026 14:18:09 +0530 Subject: [PATCH] feat: implement automatic wall-splitting and slab generation from closed wall loops --- packages/core/src/lib/space-detection.ts | 1224 +++++++++-------- packages/core/src/schema/nodes/slab.ts | 2 + .../components/tools/wall/wall-drafting.ts | 150 +- 3 files changed, 805 insertions(+), 571 deletions(-) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index def54092..04653219 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -1,8 +1,6 @@ -import type { WallNode } from '../schema' +import { SlabNode, type SlabNode as SlabNodeType, type WallNode } from '../schema' -// ============================================================================ -// TYPES -// ============================================================================ +type Point2D = { x: number; y: number } export type Space = { id: string @@ -12,589 +10,691 @@ export type Space = { 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 - sceneStore.temporal.getState().pause() - try { - runSpaceDetection(Array.from(levelsToUpdate), sceneStore, editorStore, nodes) - } finally { - sceneStore.temporal.getState().resume() - 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 -// ============================================================================ +type DetectedRoom = { + poly: Point2D[] + sig: string + centroid: Point2D + area: number + bbox: ReturnType +} -/** - * 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 = 0.5, // Match spatial grid cell size -): { - wallUpdates: WallSideUpdate[] - spaces: Space[] -} { - if (walls.length === 0) { - return { wallUpdates: [], spaces: [] } +const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 + +function pointFromTuple(point: [number, number]): Point2D { + return { x: point[0], y: point[1] } +} + +function pointToTuple(point: Point2D): [number, number] { + return [point.x, point.y] +} + +function pointKey(point: Point2D) { + return `${point.x.toFixed(3)},${point.y.toFixed(3)}` +} + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let i = 0; i < points.length; i++) { + const a = points[i] + const b = points[(i + 1) % points.length] + if (!(a && b)) continue + area += a.x * b.y - b.x * a.y + } + return area / 2 +} + +function minRotationSignature(keys: string[]) { + if (keys.length === 0) return '' + let best = '' + for (let i = 0; i < keys.length; i++) { + const rotated = [...keys.slice(i), ...keys.slice(0, i)] + const value = rotated.join('|') + if (!best || value < best) best = value + } + return best +} + +function polygonSignature(points: Point2D[]) { + const keys = points.map(pointKey) + const forward = minRotationSignature(keys) + const reversed = minRotationSignature([...keys].reverse()) + return forward < reversed ? forward : reversed +} + +function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { + return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance +} + +function dedupeSequentialPoints(points: Point2D[], tolerance = 1e-4) { + const deduped: Point2D[] = [] + + for (const point of points) { + const previous = deduped[deduped.length - 1] + if (previous && samePointWithinTolerance(previous, point, tolerance)) { + continue + } + deduped.push(point) } - // Build grid from walls - const grid = buildGrid(walls, gridResolution) + const firstPoint = deduped[0] + const lastPoint = deduped[deduped.length - 1] + if ( + deduped.length > 2 && + firstPoint && + lastPoint && + samePointWithinTolerance(firstPoint, lastPoint, tolerance) + ) { + deduped.pop() + } - // Flood fill from edges to mark exterior - floodFillFromEdges(grid) + return deduped +} - // Find interior spaces - const interiorSpaces = findInteriorSpaces(grid, levelId) +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false - // Assign wall sides - const wallUpdates = assignWallSides(walls, grid) + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i]?.x ?? 0 + const yi = polygon[i]?.y ?? 0 + const xj = polygon[j]?.x ?? 0 + const yj = polygon[j]?.y ?? 0 + + const intersect = + yi > point.y !== yj > point.y && + point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi + if (intersect) inside = !inside + } + + return inside +} + +function pointInAnyPolygon(point: Point2D, polygons: Point2D[][]) { + return polygons.some((polygon) => pointInPolygon(point, polygon)) +} + +function polygonCentroid(points: Point2D[]) { + const sum = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { + x: 0, + y: 0, + }) return { - wallUpdates, - spaces: interiorSpaces, + x: sum.x / Math.max(points.length, 1), + y: sum.y / Math.max(points.length, 1), } } -// ============================================================================ -// GRID BUILDING -// ============================================================================ +function bboxOf(points: Point2D[]) { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity -/** - * 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 point of points) { + minX = Math.min(minX, point.x) + minY = Math.min(minY, point.y) + maxX = Math.max(maxX, point.x) + maxY = Math.max(maxY, point.y) + } + + return { minX, minY, maxX, maxY } +} + +function bboxOverlapArea(a: ReturnType, b: ReturnType) { + const ix = Math.max(0, Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX)) + const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY)) + return ix * iy +} + +function getWallDirection(wall: Pick) { + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + + if (length < 1e-9) { + return { + point: pointFromTuple(wall.start), + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + const tangent = { x: dx / length, y: dy / length } + return { + point: { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + }, + tangent, + normal: { x: -tangent.y, y: tangent.x }, + } +} + +function getDirectedWallBoundaryPoints(wall: WallNode, forward: boolean) { + const start = pointFromTuple(wall.start) + const end = pointFromTuple(wall.end) + return forward ? [start, end] : [end, start] +} + +function extractRoomPolygons(walls: WallNode[]): Point2D[][] { + if (walls.length < 3) return [] + + type HalfEdge = { + id: string + reverseId: string + fromKey: string + toKey: string + angle: number + points: Point2D[] + } + type Node = { point: Point2D; outgoing: string[] } + + const graph = new Map() + const halfEdges = new Map() + + const upsertNode = (point: Point2D) => { + const key = pointKey(point) + if (!graph.has(key)) { + graph.set(key, { point: { ...point }, outgoing: [] }) + } + return key + } 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]) - } + const start = pointFromTuple(wall.start) + const end = pointFromTuple(wall.end) + const startKey = upsertNode(start) + const endKey = upsertNode(end) + if (startKey === endKey) continue - // Add padding around bounds - const padding = 2 // meters - minX -= padding - minZ -= padding - maxX += padding - maxZ += padding + const forwardDirection = getWallDirection(wall) + const reverseDirection = getWallDirection({ start: wall.end, end: wall.start }) - const width = Math.ceil((maxX - minX) / resolution) - const height = Math.ceil((maxZ - minZ) / resolution) + const forwardId = `${wall.id}:f` + const reverseId = `${wall.id}:r` - 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: [number, number][] = [ - [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: [number, number][] = [ - [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, + halfEdges.set(forwardId, { + id: forwardId, + reverseId, + fromKey: startKey, + toKey: endKey, + angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x), + points: getDirectedWallBoundaryPoints(wall, true), }) + halfEdges.set(reverseId, { + id: reverseId, + reverseId: forwardId, + fromKey: endKey, + toKey: startKey, + angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x), + points: getDirectedWallBoundaryPoints(wall, false), + }) + + graph.get(startKey)?.outgoing.push(forwardId) + graph.get(endKey)?.outgoing.push(reverseId) } - 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 + const sortedOutgoing = new Map() + for (const [key, node] of graph.entries()) { + const outgoing = [...node.outgoing] + outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0)) + sortedOutgoing.set(key, outgoing) } - return `${cellX},${cellZ}` + const nextEdge = (edgeId: string) => { + const edge = halfEdges.get(edgeId) + if (!edge) return null + + const outgoing = sortedOutgoing.get(edge.toKey) + if (!outgoing || outgoing.length === 0) return null + + const idx = outgoing.indexOf(edge.reverseId) + if (idx === -1) return null + + const nextIdx = (idx - 1 + outgoing.length) % outgoing.length + return outgoing[nextIdx] ?? null + } + + const visitedDirected = new Set() + const faces: Point2D[][] = [] + const maxSteps = Math.min(500, walls.length * 8 + 20) + + for (const edgeId of halfEdges.keys()) { + if (visitedDirected.has(edgeId)) continue + + const cycleEdgeIds: string[] = [] + let currentEdgeId = edgeId + let valid = true + + for (let step = 0; step < maxSteps; step += 1) { + const currentEdge = halfEdges.get(currentEdgeId) + if (!currentEdge) { + valid = false + break + } + + visitedDirected.add(currentEdgeId) + cycleEdgeIds.push(currentEdgeId) + + const next = nextEdge(currentEdgeId) + if (!next) { + valid = false + break + } + + currentEdgeId = next + if (currentEdgeId === edgeId) break + } + + if (!valid || cycleEdgeIds.length < 3) continue + + const polygon = dedupeSequentialPoints( + cycleEdgeIds.flatMap((id, index) => { + const points = halfEdges.get(id)?.points ?? [] + return index === cycleEdgeIds.length - 1 ? points : points.slice(0, -1) + }), + ) + + if (polygon.length < 3) continue + + const signedArea = polygonArea(polygon) + if (signedArea <= 0) continue + if (signedArea < 0.5 || signedArea > 10000) continue + + const signature = polygonSignature(polygon) + if (faces.some((face) => polygonSignature(face) === signature)) continue + + faces.push(polygon) + } + + faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a))) + return faces } -/** - * Gets cell key from grid indices - */ -function getCellKeyFromIndex(x: number, z: number, width: number): string { - return `${x},${z}` +export function resolveWallSurfaceSides( + wall: Pick, + roomPolygons: Point2D[][], +): Pick { + if (roomPolygons.length === 0) { + return { + frontSide: 'unknown' as const, + backSide: 'unknown' as const, + } + } + + const frame = getWallDirection(wall) + const normalLength = Math.hypot(frame.normal.x, frame.normal.y) + if (normalLength < 1e-9) { + return { + frontSide: wall.frontSide, + backSide: wall.backSide, + } + } + + const normalX = frame.normal.x / normalLength + const normalY = frame.normal.y / normalLength + const sampleDistance = Math.max((wall.thickness ?? 0.2) / 2 + 0.08, 0.16) + + const frontPoint = { + x: frame.point.x + normalX * sampleDistance, + y: frame.point.y + normalY * sampleDistance, + } + const backPoint = { + x: frame.point.x - normalX * sampleDistance, + y: frame.point.y - normalY * sampleDistance, + } + + const frontInside = pointInAnyPolygon(frontPoint, roomPolygons) + const backInside = pointInAnyPolygon(backPoint, roomPolygons) + + if (frontInside === backInside) { + return { + frontSide: wall.frontSide, + backSide: wall.backSide, + } + } + + return { + frontSide: frontInside ? 'interior' : 'exterior', + backSide: backInside ? 'interior' : 'exterior', + } } -/** - * 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)] +function nextAutoRoomName( + slabs: Array<{ + name?: string + }>, +) { + let maxIndex = 0 + + for (const slab of slabs) { + const match = /^Room\s+(\d+)$/.exec((slab.name ?? '').trim()) + if (!match) continue + const index = Number(match[1]) + if (Number.isFinite(index)) { + maxIndex = Math.max(maxIndex, index) + } + } + + return `Room ${maxIndex + 1}` } -// ============================================================================ -// WALL CONNECTIVITY DETECTION -// ============================================================================ +function wallGeometrySignature(wall: WallNode) { + return [ + wall.id, + wall.start[0].toFixed(4), + wall.start[1].toFixed(4), + wall.end[0].toFixed(4), + wall.end[1].toFixed(4), + (wall.thickness ?? 0.2).toFixed(4), + ].join('|') +} + +function levelWallSnapshot(walls: WallNode[]) { + return walls.map(wallGeometrySignature).sort().join('||') +} + +function buildSpace(levelId: string, polygon: Point2D[]): Space { + const signature = polygonSignature(polygon) + return { + id: `space-${levelId}-${signature.slice(0, 12)}`, + levelId, + polygon: polygon.map(pointToTuple), + wallIds: [], + isExterior: false, + } +} + +function syncAutoSlabsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + sceneStore: any, +) { + const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) + const manualSignatures = new Set( + manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), + ) + + const detected: DetectedRoom[] = roomPolygons + .map((poly) => ({ + poly, + sig: polygonSignature(poly), + centroid: polygonCentroid(poly), + area: Math.abs(polygonArea(poly)), + bbox: bboxOf(poly), + })) + .filter(({ sig }) => !manualSignatures.has(sig)) + + const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls) + const existingAutoMeta = existingAuto.map((slab) => { + const poly = slab.polygon.map(pointFromTuple) + return { + slab, + sig: polygonSignature(poly), + centroid: polygonCentroid(poly), + area: Math.abs(polygonArea(poly)), + bbox: bboxOf(poly), + } + }) + + const matchedSlabIds = new Set() + const matchedDetectedIdx = new Set() + const updatesById = new Map() + + const autoBySignature = new Map() + for (const entry of existingAutoMeta) { + autoBySignature.set(entry.sig, entry) + } + + detected.forEach((room, index) => { + const existing = autoBySignature.get(room.sig) + if (!existing) return + + matchedDetectedIdx.add(index) + matchedSlabIds.add(existing.slab.id) + updatesById.set(existing.slab.id, room.poly.map(pointToTuple)) + }) + + const remainingDetected = detected + .map((room, index) => ({ room, index })) + .filter(({ index }) => !matchedDetectedIdx.has(index)) + .sort((a, b) => b.room.area - a.room.area) + + const remainingAuto = existingAutoMeta.filter((entry) => !matchedSlabIds.has(entry.slab.id)) + + for (const { room, index } of remainingDetected) { + let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null + + for (const entry of remainingAuto) { + if (matchedSlabIds.has(entry.slab.id)) continue + + const dx = room.centroid.x - entry.centroid.x + const dy = room.centroid.y - entry.centroid.y + const dist = Math.hypot(dx, dy) + const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999 + const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio))) + const overlap = bboxOverlapArea(room.bbox, entry.bbox) + + if (overlap <= 0.0001 && dist > 1.5) continue + + const score = dist + areaPenalty * 0.35 + if (!bestMatch || score < bestMatch.score) { + bestMatch = { entry, score } + } + } + + if (!bestMatch) continue + + matchedDetectedIdx.add(index) + matchedSlabIds.add(bestMatch.entry.slab.id) + updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple)) + } + + const slabsToDelete = existingAuto + .filter((slab) => !updatesById.has(slab.id)) + .map((slab) => slab.id) + + const slabsToUpdate = existingAuto + .filter((slab) => updatesById.has(slab.id)) + .flatMap((slab) => { + const polygon = updatesById.get(slab.id) + if (!polygon) return [] + + const samePolygon = + slab.polygon.length === polygon.length && + slab.polygon.every((point, index) => { + const nextPoint = polygon[index] + return point[0] === nextPoint?.[0] && point[1] === nextPoint?.[1] + }) + + return samePolygon ? [] : [{ id: slab.id, data: { polygon } }] + }) + + const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs] + const slabsToCreate: SlabNodeType[] = [] + for (let index = 0; index < detected.length; index += 1) { + if (matchedDetectedIdx.has(index)) continue + + const room = detected[index] + if (!room) continue + + const name = nextAutoRoomName(plannedSlabsForNaming) + plannedSlabsForNaming.push({ name }) + + slabsToCreate.push( + SlabNode.parse({ + name, + polygon: room.poly.map(pointToTuple), + holes: [], + elevation: DEFAULT_AUTO_SLAB_ELEVATION, + autoFromWalls: true, + }), + ) + } + + if (slabsToDelete.length > 0) { + sceneStore.getState().deleteNodes(slabsToDelete) + } + + if (slabsToUpdate.length > 0) { + sceneStore.getState().updateNodes(slabsToUpdate) + } + + if (slabsToCreate.length > 0) { + sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId }))) + } +} + +function detectSpacesFromWalls(levelId: string, walls: WallNode[]) { + const roomPolygons = extractRoomPolygons(walls) + const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({ + wallId: wall.id, + ...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick< + WallSideUpdate, + 'frontSide' | 'backSide' + >), + })) + + return { + roomPolygons, + spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)), + wallUpdates, + } +} + +export function detectSpacesForLevel(levelId: string, walls: WallNode[]) { + return detectSpacesFromWalls(levelId, walls) +} + +function runSpaceDetection( + levelIds: string[], + sceneStore: any, + editorStore: any, + nodes: any, +): void { + const { updateNodes } = sceneStore.getState() + const existingSpaces = editorStore.getState().spaces as Record + const nextSpaces: Record = {} + + for (const [spaceId, space] of Object.entries(existingSpaces)) { + if (!levelIds.includes(space.levelId)) { + nextSpaces[spaceId] = space + } + } + + for (const levelId of levelIds) { + const walls = Object.values(nodes).filter( + (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, + ) + + const slabs = Object.values(nodes).filter( + (node: any) => node?.type === 'slab' && node.parentId === levelId, + ) + + const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls) + + const changedWallUpdates = wallUpdates.filter((update) => { + const wall = nodes[update.wallId] + return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) + }) + + if (changedWallUpdates.length > 0) { + updateNodes( + changedWallUpdates.map((update) => ({ + id: update.wallId, + data: { + frontSide: update.frontSide, + backSide: update.backSide, + }, + })), + ) + } + + syncAutoSlabsForLevel( + levelId, + roomPolygons, + slabs.map((slab: any) => SlabNode.parse(slab)), + sceneStore, + ) + + for (const space of spaces) { + nextSpaces[space.id] = space + } + } + + editorStore.getState().setSpaces(nextSpaces) +} + +export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void { + const previousSnapshots = new Map() + let isProcessing = false + + const unsubscribe = sceneStore.subscribe((state: any) => { + if (isProcessing) return + + const nodes = state.nodes + const wallsByLevel = new Map() + + for (const node of Object.values(nodes)) { + if (node && (node as any).type === 'wall' && (node as any).parentId) { + const levelId = (node as any).parentId as string + const levelWalls = wallsByLevel.get(levelId) ?? [] + levelWalls.push(node as WallNode) + wallsByLevel.set(levelId, levelWalls) + } + } + + const currentSnapshots = new Map() + for (const [levelId, walls] of wallsByLevel.entries()) { + currentSnapshots.set(levelId, levelWallSnapshot(walls)) + } + + const levelsToUpdate = new Set() + for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { + if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) { + levelsToUpdate.add(levelId) + } + } + + if (levelsToUpdate.size === 0) { + previousSnapshots.clear() + for (const [levelId, snapshot] of currentSnapshots.entries()) { + previousSnapshots.set(levelId, snapshot) + } + return + } + + isProcessing = true + sceneStore.temporal.getState().pause() + try { + runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes) + } finally { + sceneStore.temporal.getState().resume() + previousSnapshots.clear() + for (const [levelId, snapshot] of currentSnapshots.entries()) { + previousSnapshots.set(levelId, snapshot) + } + isProcessing = false + } + }) + + return unsubscribe +} -/** - * 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 + const threshold = 0.1 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 || @@ -608,36 +708,26 @@ export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boole 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 [px, py] = point + const [x1, y1] = segStart + const [x2, y2] = segEnd const dx = x2 - x1 - const dz = z2 - z1 - const lenSq = dx * dx + dz * dz + const dy = y2 - y1 + const lenSq = dx * dx + dy * dy if (lenSq < 0.0001) { - // Segment is a point - const dpx = px - x1 - const dpz = pz - z1 - return Math.sqrt(dpx * dpx + dpz * dpz) + return Math.hypot(px - x1, py - y1) } - // Project point onto line - const t = Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lenSq)) + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq)) const projX = x1 + t * dx - const projZ = z1 + t * dz + const projY = y1 + t * dy - const distX = px - projX - const distZ = pz - projZ - - return Math.sqrt(distX * distX + distZ * distZ) + return Math.hypot(px - projX, py - projY) } diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 765921ce..0466bd13 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -10,11 +10,13 @@ export const SlabNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), elevation: z.number().default(0.05), // Elevation in meters + autoFromWalls: z.boolean().default(false), }).describe( dedent` Slab node - used to represent a slab/floor in the building - polygon: array of [x, z] points defining the slab boundary - elevation: elevation in meters + - autoFromWalls: whether the slab is automatically generated from a closed wall loop `, ) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index a69256af..90ac05e7 100755 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -1,4 +1,4 @@ -import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core' +import { type AnyNodeId, useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -8,6 +8,11 @@ export const WALL_GRID_STEP = 0.5 export const WALL_JOIN_SNAP_RADIUS = 0.35 export const WALL_MIN_LENGTH = 0.01 +type WallSplitIntersection = { + wallId: WallNode['id'] + point: WallPlanPoint +} + function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number { const dx = a[0] - b[0] const dz = a[1] - b[1] @@ -53,6 +58,103 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi return [x1 + dx * t, z1 + dz * t] } +function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] { + const { id: _id, parentId: _parentId, children, ...rest } = wall + + const first = WallSchema.parse({ + ...rest, + start: wall.start, + end: splitPoint, + children: children ?? [], + }) + const second = WallSchema.parse({ + ...rest, + start: splitPoint, + end: wall.end, + children: children ?? [], + }) + + return [first, second] +} + +function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean { + return distanceSquared(a, b) <= tolerance * tolerance +} + +function findWallIntersection( + point: WallPlanPoint, + walls: WallNode[], + ignoreWallIds?: string[], +): WallSplitIntersection | null { + const ignore = new Set(ignoreWallIds ?? []) + let best: WallSplitIntersection | null = null + let bestDistanceSquared = Number.POSITIVE_INFINITY + + for (const wall of walls) { + if (ignore.has(wall.id)) continue + + const projected = projectPointOntoWall(point, wall) + if (!projected) continue + + const candidateDistanceSquared = distanceSquared(point, projected) + if ( + candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS || + candidateDistanceSquared >= bestDistanceSquared + ) { + continue + } + + best = { wallId: wall.id, point: projected } + bestDistanceSquared = candidateDistanceSquared + } + + return best +} + +function wallHasAttachments(wall: WallNode, nodes: ReturnType['nodes']) { + if ((wall.children?.length ?? 0) > 0) { + return true + } + + return Object.values(nodes).some((node) => { + if (!node) return false + if ('parentId' in node && node.parentId === wall.id) return true + if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true + return false + }) +} + +function splitWallIfNeeded( + intersection: WallSplitIntersection | null, + walls: WallNode[], + nodes: ReturnType['nodes'], + createNodes: ReturnType['createNodes'], + deleteNode: ReturnType['deleteNode'], +): { walls: WallNode[]; point: WallPlanPoint } | null { + if (!intersection) return null + + const wallToSplit = walls.find((wall) => wall.id === intersection.wallId) + if (!wallToSplit) { + return { walls, point: intersection.point } + } + + if (wallHasAttachments(wallToSplit, nodes)) { + return { walls, point: intersection.point } + } + + const [first, second] = splitWallAtPoint(wallToSplit, intersection.point) + createNodes([ + { node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined }, + { node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined }, + ]) + deleteNode(wallToSplit.id as AnyNodeId) + + return { + walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second], + point: intersection.point, + } +} + export function findWallSnapTarget( point: WallPlanPoint, walls: WallNode[], @@ -120,17 +222,57 @@ export function createWallOnCurrentLevel( end: WallPlanPoint, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId - const { createNode, nodes } = useScene.getState() + const { createNode, createNodes, deleteNode, nodes } = useScene.getState() if (!(currentLevelId && isWallLongEnough(start, end))) { return null } + let workingWalls = Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId, + ) + + let resolvedStart = start + let resolvedEnd = end + + const endIntersection = findWallIntersection(resolvedEnd, workingWalls) + const splitEnd = splitWallIfNeeded(endIntersection, workingWalls, nodes, createNodes, deleteNode) + if (splitEnd) { + workingWalls = splitEnd.walls + resolvedEnd = splitEnd.point + } + + const startIntersection = findWallIntersection(resolvedStart, workingWalls) + const splitStart = splitWallIfNeeded( + startIntersection, + workingWalls, + nodes, + createNodes, + deleteNode, + ) + if (splitStart) { + workingWalls = splitStart.walls + resolvedStart = splitStart.point + } + + if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) { + return null + } + + const duplicateWall = workingWalls.some( + (wall) => + (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) || + (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)), + ) + if (duplicateWall) { + return null + } + const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length const wall = WallSchema.parse({ name: `Wall ${wallCount + 1}`, - start, - end, + start: resolvedStart, + end: resolvedEnd, }) createNode(wall, currentLevelId)