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>
895 lines
30 KiB
TypeScript
895 lines
30 KiB
TypeScript
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 (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<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)
|
||
}
|