diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx
index f88effef..0dc17b6b 100644
--- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx
+++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx
@@ -21,6 +21,7 @@ export const ViewerOverlay = () => {
const showGuides = useViewer((s) => s.showGuides)
const cameraMode = useViewer((s) => s.cameraMode)
const levelMode = useViewer((s) => s.levelMode)
+ const wallMode = useViewer((s) => s.wallMode)
const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
@@ -202,6 +203,38 @@ export const ViewerOverlay = () => {
Solo
+
+ {/* Wall Mode */}
+
+
Wall Mode
+
+
+
+
>
)
diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index e20d54bd..5c495509 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -1,6 +1,11 @@
'use client'
-import { initSpatialGridSync, sceneRegistry, useScene } from '@pascal-app/core'
+import {
+ initSpaceDetectionSync,
+ initSpatialGridSync,
+ sceneRegistry,
+ useScene,
+} from '@pascal-app/core'
import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
@@ -10,6 +15,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 +29,7 @@ import { SelectionManager } from './selection-manager'
useScene.getState().loadScene()
console.log('Loaded scene in editor')
initSpatialGridSync()
+initSpaceDetectionSync(useScene, useEditor)
export default function Editor() {
useKeyboard()
@@ -41,7 +48,7 @@ export default function Editor() {
{/* Editor only system to toggle zone visibility */}
{/* */}
-
+
@@ -49,7 +56,6 @@ export default function Editor() {
)
}
-
const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
diff --git a/apps/editor/components/ui/action-menu/view-toggles.tsx b/apps/editor/components/ui/action-menu/view-toggles.tsx
index 48bd6294..4985e405 100644
--- a/apps/editor/components/ui/action-menu/view-toggles.tsx
+++ b/apps/editor/components/ui/action-menu/view-toggles.tsx
@@ -18,11 +18,39 @@ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
+type WallMode = 'up' | 'cutaway' | 'down'
+
+const wallModeConfig: Record<
+ WallMode,
+ { icon: React.FC>; label: string }
+> = {
+ up: {
+ icon: (props) => (
+
+ ),
+ label: 'Full Height',
+ },
+ cutaway: {
+ icon: (props) => (
+
+ ),
+ label: 'Cutaway',
+ },
+ down: {
+ icon: (props) =>
,
+ label: 'Low',
+ },
+}
+
+const wallModeOrder: WallMode[] = ['cutaway', 'up', 'down']
+
export function ViewToggles() {
const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode)
const levelMode = useViewer((state) => state.levelMode)
const setLevelMode = useViewer((state) => state.setLevelMode)
+ const wallMode = useViewer((state) => state.wallMode)
+ const setWallMode = useViewer((state) => state.setWallMode)
const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans)
const showGuides = useViewer((state) => state.showGuides)
@@ -43,6 +71,13 @@ export function ViewToggles() {
if (nextMode) setLevelMode(nextMode)
}
+ const cycleWallMode = () => {
+ const currentIndex = wallModeOrder.indexOf(wallMode)
+ const nextIndex = (currentIndex + 1) % wallModeOrder.length
+ const nextMode = wallModeOrder[nextIndex]
+ if (nextMode) setWallMode(nextMode)
+ }
+
return (
{/* Camera Mode */}
@@ -91,6 +126,31 @@ export function ViewToggles() {
+ {/* Wall Mode */}
+
+
+
+
+
+ Walls: {wallModeConfig[wallMode].label}
+
+
+
{/* Show Scans */}
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..3f631124
--- /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: [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,
+ })
+ }
+
+ 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
diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx
index ad9a2528..f247dd29 100644
--- a/packages/viewer/src/components/renderers/item/item-renderer.tsx
+++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx
@@ -9,17 +9,17 @@ import { useNodeEvents } from '../../../hooks/use-node-events'
// Shared materials to avoid creating new instances for every mesh
const defaultMaterial = new MeshStandardNodeMaterial({
color: 0xffffff,
- roughness: 0.8,
+ roughness: 1,
metalness: 0,
})
const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
- color: 'skyblue',
+ color: 'lightgray',
roughness: 0.8,
metalness: 0,
transparent: true,
- opacity: 0.25,
+ opacity: 0.35,
side: DoubleSide,
depthWrite: false,
})
diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
index 7dfe9767..d456c5ff 100644
--- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
+++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
@@ -15,7 +15,6 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
{/* WallSystem will replace this geometry in the next frame */}
-
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index 37588f9b..df13ebb8 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -7,6 +7,7 @@ import * as THREE from 'three/webgpu'
import { GuideSystem } from '../../systems/guide/guide-system'
import { LevelSystem } from '../../systems/level/level-system'
import { ScanSystem } from '../../systems/scan/scan-system'
+import { WallCutout } from '../../systems/wall/wall-cutout'
import { SceneRenderer } from '../renderers/scene-renderer'
import { Lights } from './lights'
import PostProcessing from './post-processing'
@@ -27,12 +28,12 @@ interface ViewerProps {
const Viewer: React.FC = ({ children, selectionManager = 'default' }) => {
return (