Files
editor/packages/viewer/src/systems/wall/wall-system.tsx
T
Wassim SAMADandClaude Opus 4.8 65a7fcb74b feat(paint-slots): world-space planar UVs for walls (seamless tiling)
Walls used THREE's ExtrudeGeometry UVs, which restart at each wall's local
start/end — so finishes seamed at wall joins, showed a mid-face axis-switch
stripe, and didn't line up with the roof gable. Re-project the render mesh's
UVs in WORLD space (1 unit = 1 m), matching roof-system's pushRoofUv exactly:
vertical faces U = ±worldX/Z, V = 1 - worldY. De-indexes so each triangle uses
its own face normal (no edge seams). Applied only to the render mesh; collision
and floorplan geometry keep the original UVs. Now walls tile continuously
across segments and meet the gable seamlessly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:11:57 -04:00

895 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof getWallMiterBoundaryPoints>,
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<WallNode, 'frontSide' | 'backSide'>,
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<number>(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 (34× 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<string, Set<string>>()
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<string, Set<string>>()
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<string>()
// 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)
}