import { type AnyNode, type AnyNodeId, calculateLevelMiters, DEFAULT_WALL_HEIGHT, type DoorNode, getAdjacentWallIds, getEffectiveNode, getWallCurveFrameAt, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, type Point2D, pointToKey, resolveLevelId, sceneRegistry, spatialGridManager, useLiveNodeOverrides, useLiveTransforms, useScene, type WallMiterData, type WallNode, type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' import { buildOpeningCutoutGeometry } from './opening-cutout-geometry' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const WALL_FACE_NORMAL_Y_EPSILON = 0.6 const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { ;(geometry as any).computeBoundsTree = computeBoundsTree ;(geometry as any).computeBoundsTree({ maxLeafSize: 10 }) } function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry } type WallBoundaryEdgeTag = 'front' | 'back' | 'base' type TaggedWallBoundaryEdge = { start: THREE.Vector2 end: THREE.Vector2 tag: WallBoundaryEdgeTag } function insetCurvedWallBoundaryPointsFor3D( wall: WallNode, boundaryPoints: ReturnType, miterData: WallMiterData, ) { if (!(boundaryPoints && isCurvedWall(wall))) { return boundaryPoints } const insetDistance = Math.min( CURVED_WALL_3D_ENDPOINT_INSET, Math.max((wall.thickness ?? 0.1) * 0.01, 0.0005), ) if (insetDistance <= 0) { return boundaryPoints } const next = { ...boundaryPoints } const startJunction = miterData.junctions.get(pointToKey({ x: wall.start[0], y: wall.start[1] })) const endJunction = miterData.junctions.get(pointToKey({ x: wall.end[0], y: wall.end[1] })) if (startJunction && startJunction.connectedWalls.length > 1) { const frame = getWallCurveFrameAt(wall, 0) next.startLeft = { x: next.startLeft.x + frame.tangent.x * insetDistance, y: next.startLeft.y + frame.tangent.y * insetDistance, } next.startRight = { x: next.startRight.x + frame.tangent.x * insetDistance, y: next.startRight.y + frame.tangent.y * insetDistance, } } if (endJunction && endJunction.connectedWalls.length > 1) { const frame = getWallCurveFrameAt(wall, 1) next.endLeft = { x: next.endLeft.x - frame.tangent.x * insetDistance, y: next.endLeft.y - frame.tangent.y * insetDistance, } next.endRight = { x: next.endRight.x - frame.tangent.x * insetDistance, y: next.endRight.y - frame.tangent.y * insetDistance, } } return next } function addTaggedWallBoundaryEdge( edges: TaggedWallBoundaryEdge[], points: { x: number; z: number }[], startIndex: number, endIndex: number, tag: WallBoundaryEdgeTag, ) { const start = points[startIndex] const end = points[endIndex] if (!(start && end)) return if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return edges.push({ start: new THREE.Vector2(start.x, start.z), end: new THREE.Vector2(end.x, end.z), tag, }) } function buildTaggedWallBoundaryEdges( wall: WallNode, localPoints: { x: number; z: number }[], miterData: WallMiterData, ): TaggedWallBoundaryEdge[] { if (localPoints.length < 2) return [] const edges: TaggedWallBoundaryEdge[] = [] if (isCurvedWall(wall)) { const sidePointCount = Math.floor(localPoints.length / 2) if (sidePointCount < 2) return edges for (let index = 0; index < sidePointCount - 1; index += 1) { addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back') } addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base') for (let index = sidePointCount; index < localPoints.length - 1; index += 1) { addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front') } addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base') return edges } const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] }) const startJunction = miterData.junctionData.get(startKey)?.get(wall.id) const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1 const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2 addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back') for (let index = 1; index < endLeftIndex; index += 1) { addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base') } addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front') for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) { addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base') } addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base') return edges } function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number { const edgeDx = edge.end.x - edge.start.x const edgeDz = edge.end.y - edge.start.y const pointDx = point.x - edge.start.x const pointDz = point.y - edge.start.y const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz if (edgeLengthSq < 1e-12) { return point.distanceTo(edge.start) } const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1) const closestX = edge.start.x + edgeDx * t const closestZ = edge.start.y + edgeDz * t return Math.hypot(point.x - closestX, point.y - closestZ) } function getWallFaceMaterialIndex( wall: Pick, face: 'front' | 'back', ): 0 | 1 | 2 { const semantic = face === 'front' ? wall.frontSide : wall.backSide const fallback = face === 'front' ? 1 : 2 if (semantic === 'interior') return 1 if (semantic === 'exterior') return 2 return fallback } function assignWallMaterialGroups( geometry: THREE.BufferGeometry, wall: WallNode, boundaryEdges: TaggedWallBoundaryEdge[], ) { const position = geometry.getAttribute('position') if (!position) return const index = geometry.getIndex() const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3) if (triangleCount === 0) { geometry.clearGroups() return } const triangleMaterials = new Array(triangleCount).fill(0) const a = new THREE.Vector3() const b = new THREE.Vector3() const c = new THREE.Vector3() const ab = new THREE.Vector3() const ac = new THREE.Vector3() const normal = new THREE.Vector3() const centroid = new THREE.Vector3() const projectedCentroid = new THREE.Vector2() const maxBoundaryDistance = Math.max( getWallThickness(wall) * 0.02, WALL_FACE_EDGE_DISTANCE_EPSILON, ) for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { const baseIndex = triangleIndex * 3 const ia = index ? index.getX(baseIndex) : baseIndex const ib = index ? index.getX(baseIndex + 1) : baseIndex + 1 const ic = index ? index.getX(baseIndex + 2) : baseIndex + 2 a.fromBufferAttribute(position, ia) b.fromBufferAttribute(position, ib) c.fromBufferAttribute(position, ic) ab.subVectors(b, a) ac.subVectors(c, a) normal.crossVectors(ab, ac) if (normal.lengthSq() < 1e-12) { triangleMaterials[triangleIndex] = 0 continue } normal.normalize() if (Math.abs(normal.y) >= WALL_FACE_NORMAL_Y_EPSILON) { triangleMaterials[triangleIndex] = 0 continue } centroid .copy(a) .add(b) .add(c) .multiplyScalar(1 / 3) projectedCentroid.set(centroid.x, centroid.z) let nearestTag: WallBoundaryEdgeTag | null = null let nearestDistance = Number.POSITIVE_INFINITY for (const edge of boundaryEdges) { const distance = distanceToWallBoundaryEdge(projectedCentroid, edge) if (distance < nearestDistance) { nearestDistance = distance nearestTag = edge.tag } } if (!nearestTag || nearestDistance > maxBoundaryDistance) { triangleMaterials[triangleIndex] = 0 continue } if (nearestTag === 'base') { triangleMaterials[triangleIndex] = 0 continue } triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag) } geometry.clearGroups() let currentMaterial = triangleMaterials[0] ?? 0 let groupStart = 0 for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) { const materialIndex = triangleMaterials[triangleIndex] ?? 0 if (materialIndex === currentMaterial) continue geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) groupStart = triangleIndex currentMaterial = materialIndex } geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) } // ============================================================================ // WALL SYSTEM // ============================================================================ let useFrameNb = 0 // ─── Drag-throttle state (singleton — one WallSystem mounted globally) ── // // Endpoint drags fire `markDirty(wallId)` on every pointermove tick. Without // throttling, each tick rebuilds the dragged wall (~1 CSG + miter pass) AND // every adjacent wall sharing a corner (3–4× in a t-junction or room). // Visible as drag lag, especially on walls with door/window cutouts. // // Strategy: rebuild the dragged wall every tick (so the drag follows the // cursor with full fidelity), but defer adjacent rebuilds to a trailing- // edge flush DRAG_FLUSH_MS after the dirty stream stops. Visually, neighbor // corners stay at their pre-drag miter until release, then snap into place // within ~80ms. Standard CAD-app behavior. Speeds up t-junction drags ~3×, // 4-corner-room drags ~4×. const DRAG_FLUSH_MS = 80 const MAX_WALL_REBUILDS_PER_FRAME = 8 const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 let lastWallDirtyAtMs = 0 const pendingAdjacentByLevel = new Map>() function getPendingAdjacentCount() { let count = 0 for (const ids of pendingAdjacentByLevel.values()) { count += ids.size } return count } export const WallSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) // Subscribe so override-only changes (no scene write) still re-run // this component, which lets the gate below pick up the latest // `dirtyNodes` set from the same render pass that received the // override-publishing `markDirty` call. Without this, very fast // drags could land an override and a markDirty in the same React // tick and the next `useFrame` would still see the stale closure. useLiveNodeOverrides((s) => s.overrides) useFrame(() => { const hasDirty = dirtyNodes.size > 0 const hasPending = pendingAdjacentByLevel.size > 0 if (!hasDirty && !hasPending) return const nodes = useScene.getState().nodes const now = performance.now() // Collect dirty walls and their levels const dirtyWallsByLevel = new Map>() let dirtyWallCount = 0 useFrameNb += 1 if (hasDirty) { dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'wall') return const levelId = node.parentId if (!levelId) return if (!dirtyWallsByLevel.has(levelId)) { dirtyWallsByLevel.set(levelId, new Set()) } dirtyWallsByLevel.get(levelId)?.add(id) dirtyWallCount += 1 }) } const hasDirtyWalls = dirtyWallsByLevel.size > 0 if (hasDirtyWalls) { lastWallDirtyAtMs = now } const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD let rebuiltWallsThisFrame = 0 const rebuildFrameStartedAt = now // Process each level that has dirty walls for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { break } const levelWalls = getLevelWalls(levelId) const miterData = calculateLevelMiters(levelWalls) const rebuiltWallIds = new Set() // Update dirty walls — always, no throttling. The dragged wall must // follow the cursor with full fidelity (cutouts and all). Large imports // enter the progressive path so initial load can't lock the tab. for (const wallId of dirtyWallIds) { if (useProgressiveWallRebuilds) { if (rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { break } if ( rebuiltWallsThisFrame > 0 && performance.now() - rebuildFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS ) { break } } const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) { updateWallGeometry(wallId, miterData) clearDirty(wallId as AnyNodeId) rebuiltWallIds.add(wallId) rebuiltWallsThisFrame += 1 } // If mesh not found, keep it dirty for next frame } if (rebuiltWallIds.size === 0) { continue } // Adjacent walls sharing junctions — *defer* during active drag // (dirty arrived this frame), flush on the trailing edge. const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds) let pending = pendingAdjacentByLevel.get(levelId) if (!pending) { pending = new Set() pendingAdjacentByLevel.set(levelId, pending) } for (const wallId of adjacentWallIds) { if (!dirtyWallIds.has(wallId)) { pending.add(wallId) } } } // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the // drag has ended — rebuild the queued neighbors so corners snap into // their correct miter joins. const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS if (quiet && pendingAdjacentByLevel.size > 0) { const pendingCount = getPendingAdjacentCount() const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD let rebuiltAdjacentThisFrame = 0 const adjacentFrameStartedAt = performance.now() for (const [levelId, pendingIds] of pendingAdjacentByLevel) { if (pendingIds.size === 0) continue const levelWalls = getLevelWalls(levelId) const miterData = calculateLevelMiters(levelWalls) for (const wallId of Array.from(pendingIds)) { if (useProgressiveAdjacentRebuilds) { if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { break } if ( rebuiltAdjacentThisFrame > 0 && performance.now() - adjacentFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS ) { break } } const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) updateWallGeometry(wallId, miterData) pendingIds.delete(wallId) rebuiltAdjacentThisFrame += 1 } if (pendingIds.size === 0) { pendingAdjacentByLevel.delete(levelId) } if ( useProgressiveAdjacentRebuilds && rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME ) { break } } } }, 4) return null } /** * Merge any live override for a wall into the scene record. Lets the * 2D move handler publish `{ start, end, curveOffset }` to * `useLiveNodeOverrides` and have the geometry / miter pipeline use * those values without zustand churn during the drag. When no * override is set, the wall is returned unchanged. */ function getEffectiveWall(wall: WallNode): WallNode { const override = useLiveNodeOverrides.getState().get(wall.id) if (!override || Object.keys(override).length === 0) return wall return { ...wall, ...override } as WallNode } /** * Gets all walls that belong to a level, with any live overrides * merged in so miters compute against the cursor-driven positions * (not the pre-drag scene state). */ function getLevelWalls(levelId: string): WallNode[] { const { nodes } = useScene.getState() const level = nodes[levelId as AnyNodeId] if (!level || level.type !== 'level') return [] const walls: WallNode[] = [] for (const childId of level.children) { const child = nodes[childId] if (child?.type === 'wall') { walls.push(getEffectiveWall(child as WallNode)) } } return walls } /** * Updates the geometry for a single wall. Reads the effective node * (override-merged) so a 2D drag visibly moves the 3D mesh without * having touched `useScene` mid-drag. */ function updateWallGeometry(wallId: string, miterData: WallMiterData) { const nodes = useScene.getState().nodes const sceneNode = nodes[wallId as WallNode['id']] if (!sceneNode || sceneNode.type !== 'wall') return const node = getEffectiveWall(sceneNode as WallNode) const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (!mesh) return const levelId = resolveLevelId(node, nodes) const slabElevation = spatialGridManager.getSlabElevationForWall( levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, ) const childrenIds = node.children || [] // Merge live overrides into door / window children so cutouts track an // in-flight resize drag (door width arrow, window height arrow, etc.) // without waiting on the scene store. Non-cutout children pass through // unchanged. const childrenNodes = childrenIds .map((childId) => nodes[childId]) .filter((n): n is AnyNode => n !== undefined) .map((child) => { if (child.type !== 'door' && child.type !== 'window') return child // `getEffectiveNode` folds in resize overrides (width/height arrows). // Position moves publish to `useLiveTransforms` instead, so fold that // in too — otherwise shaped openings (arch/rounded/`opening`), whose // cutout brush is rebuilt from `node.position`, lag the live move // (rectangular cutouts already track via the live mesh matrixWorld). const effective = getEffectiveNode(child) const live = useLiveTransforms.getState().get(child.id) if (!live?.position) return effective return { ...effective, position: live.position } }) const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) // World transform the render mesh will apply (position + Y-rotation below). // Reproduce it here so the UVs can be projected in WORLD space — see // `applyWorldPlanarWallUVs`. const wallWorldMatrix = new THREE.Matrix4().compose( new THREE.Vector3(node.start[0], slabElevation, node.start[1]), new THREE.Quaternion().setFromAxisAngle(WALL_UV_Y_AXIS, -wallAngle), WALL_UV_UNIT_SCALE, ) const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix) mesh.geometry.dispose() mesh.geometry = newGeo // Update collision mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh if (collisionMesh) { const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo } mesh.position.set(node.start[0], slabElevation, node.start[1]) const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) mesh.rotation.y = -angle } const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0) const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1) /** * Re-project a wall's UVs in WORLD space (1 UV unit = 1 m) so the finish tiles * continuously across adjacent walls and lines up with the roof gable above — * instead of THREE's `ExtrudeGeometry` UVs, which restart at each wall's own * start/end. Matches `roof-system`'s `pushRoofUv` projection exactly: vertical * faces use `U = ±worldX/Z` (the axis across the face normal) and `V = 1 - * worldY`; the thin top/bottom caps use `(worldX, worldZ)`. De-indexes first so * every triangle projects by its own face normal (no shared-vertex seams at * edges). Applied only to the render mesh; collision/floorplan geometry is * untouched. */ function applyWorldPlanarWallUVs( geometry: THREE.BufferGeometry, worldMatrix: THREE.Matrix4, ): THREE.BufferGeometry { const target = geometry.index ? geometry.toNonIndexed() : geometry if (target !== geometry) geometry.dispose() const position = target.getAttribute('position') if (!position || position.count === 0) return target const a = new THREE.Vector3() const b = new THREE.Vector3() const c = new THREE.Vector3() const normal = new THREE.Vector3() const edgeAB = new THREE.Vector3() const edgeAC = new THREE.Vector3() const uvs = new Float32Array(position.count * 2) for (let i = 0; i < position.count; i += 3) { a.fromBufferAttribute(position, i).applyMatrix4(worldMatrix) b.fromBufferAttribute(position, i + 1).applyMatrix4(worldMatrix) c.fromBufferAttribute(position, i + 2).applyMatrix4(worldMatrix) edgeAB.subVectors(b, a) edgeAC.subVectors(c, a) normal.crossVectors(edgeAB, edgeAC).normalize() const absX = Math.abs(normal.x) const absY = Math.abs(normal.y) const absZ = Math.abs(normal.z) for (let k = 0; k < 3; k += 1) { const p = k === 0 ? a : k === 1 ? b : c let u: number let v: number if (absY >= absX && absY >= absZ) { u = p.x v = p.z } else { v = 1 - p.y u = absX >= absZ ? (normal.x >= 0 ? p.z : -p.z) : normal.z >= 0 ? p.x : -p.x } uvs[(i + k) * 2] = u uvs[(i + k) * 2 + 1] = v } } target.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) target.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2)) return target } /** * Generates extruded wall geometry with mitering and cutouts * * Key insight from demo: polygon is built in WORLD coordinates first, * then we transform to wall-local for the 3D mesh. */ export function generateExtrudedWall( wallNode: WallNode, childrenNodes: AnyNode[], miterData: WallMiterData, slabElevation = 0, ): THREE.BufferGeometry { const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } // Positive slab: shift the whole wall up (full height preserved) // Negative slab: extend wall downward so top stays fixed at wallNode.height const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation const thickness = getWallThickness(wallNode) // Wall direction and normal (exactly like demo) const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y } const L = Math.sqrt(v.x * v.x + v.y * v.y) if (L < 1e-9) { return new THREE.BufferGeometry() } const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData) const polyPoints = isCurvedWall(wallNode) ? getWallSurfacePolygon( wallNode, 24, insetCurvedWallBoundaryPointsFor3D(wallNode, boundaryPoints, miterData) ?? undefined, ) : getWallPlanFootprint(wallNode, miterData) if (polyPoints.length < 3) { return new THREE.BufferGeometry() } // Transform world coordinates to wall-local coordinates // Wall-local: x along wall, z perpendicular (thickness direction) const wallAngle = Math.atan2(v.y, v.x) const cosA = Math.cos(-wallAngle) const sinA = Math.sin(-wallAngle) const worldToLocal = (worldPt: Point2D): { x: number; z: number } => { const dx = worldPt.x - wallStart.x const dy = worldPt.y - wallStart.y return { x: dx * cosA - dy * sinA, z: dx * sinA + dy * cosA, } } // Convert polygon to local coordinates const localPoints = polyPoints.map(worldToLocal) const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData) // Build THREE.js shape // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z // The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z const footprint = new THREE.Shape() footprint.moveTo(localPoints[0]!.x, -localPoints[0]!.z) for (let i = 1; i < localPoints.length; i++) { footprint.lineTo(localPoints[i]!.x, -localPoints[i]!.z) } footprint.closePath() // Extrude along Z by height const geometry = new THREE.ExtrudeGeometry(footprint, { depth: height, bevelEnabled: false, }) // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges) ensureRenderableGeometryAttributes(geometry) // Apply CSG subtraction for cutouts (doors/windows) const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) if (cutoutBrushes.length === 0) { return geometry } // Create wall brush from geometry // Pre-compute BVH with new API to avoid deprecation warning ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) const wallBrush = new Brush(geometry) wallBrush.updateMatrixWorld() // Subtract each cutout from the wall let resultBrush = wallBrush for (const cutoutBrush of cutoutBrushes) { prepareBrushForCSG(cutoutBrush) const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION) prepareBrushForCSG(newResult) if (resultBrush !== wallBrush) { csgGeometry(resultBrush).dispose() } resultBrush = newResult } // Clean up csgGeometry(wallBrush).dispose() for (const brush of cutoutBrushes) { csgGeometry(brush).dispose() } const resultGeometry = csgGeometry(resultBrush) resultGeometry.computeVertexNormals() assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges) ensureRenderableGeometryAttributes(resultGeometry) return resultGeometry } /** * Collects cutout brushes from child items for CSG subtraction * The cutout mesh is a plane, so we extrude it into a box that goes through the wall */ function collectCutoutBrushes( wallNode: WallNode, childrenNodes: AnyNode[], wallThickness: number, ): Brush[] { const brushes: Brush[] = [] const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh if (!wallMesh) return brushes // Get wall's world matrix inverse to transform cutouts to wall-local space wallMesh.updateMatrixWorld() const wallMatrixInverse = wallMesh.matrixWorld.clone().invert() for (const child of childrenNodes) { if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue if ( (child.type === 'door' && child.openingKind === 'opening') || (child.type === 'door' && child.openingKind === 'door' && (child.openingShape === 'arch' || child.openingShape === 'rounded')) || (child.type === 'window' && child.openingKind === 'opening') || (child.type === 'window' && child.openingKind === 'window' && (child.openingShape === 'arch' || child.openingShape === 'rounded')) ) { brushes.push(createShapedOpeningCutoutBrush(child, wallThickness)) continue } const childMesh = sceneRegistry.nodes.get(child.id) if (!childMesh) continue const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh if (!cutoutMesh) continue // Get the cutout's bounding box in world space cutoutMesh.updateMatrixWorld() const positions = cutoutMesh.geometry?.attributes?.position if (!positions) continue // Calculate bounds in wall-local space const v3 = new THREE.Vector3() let minX = Number.POSITIVE_INFINITY, maxX = Number.NEGATIVE_INFINITY let minY = Number.POSITIVE_INFINITY, maxY = Number.NEGATIVE_INFINITY for (let i = 0; i < positions.count; i++) { v3.fromBufferAttribute(positions, i) v3.applyMatrix4(cutoutMesh.matrixWorld) v3.applyMatrix4(wallMatrixInverse) minX = Math.min(minX, v3.x) maxX = Math.max(maxX, v3.x) minY = Math.min(minY, v3.y) maxY = Math.max(maxY, v3.y) } if (!Number.isFinite(minX)) continue // Create a box geometry that extends through the wall thickness const width = maxX - minX const height = maxY - minY const depth = wallThickness * 2 // Extend beyond wall to ensure clean cut const boxGeo = new THREE.BoxGeometry(width, height, depth) // Position box at the center of the cutout boxGeo.translate( minX + width / 2, minY + height / 2, 0, // Center on Z axis (wall thickness direction) ) // Pre-compute BVH with new API to avoid deprecation warning computeGeometryBoundsTree(boxGeo) const brush = new Brush(boxGeo) brushes.push(brush) } return brushes } function createShapedOpeningCutoutBrush( opening: DoorNode | WindowNode, wallThickness: number, ): Brush { const halfWidth = opening.width / 2 const geometry = buildOpeningCutoutGeometry( opening, { left: opening.position[0] - halfWidth, right: opening.position[0] + halfWidth, bottom: opening.position[1] - opening.height / 2, top: opening.position[1] + opening.height / 2, }, wallThickness * 2, wallThickness, ) computeGeometryBoundsTree(geometry) return new Brush(geometry) }