import { type AnyNode, type AnyNodeId, getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, type RoofNode, type RoofSegmentNode, type RoofType, sceneRegistry, useLiveNodeOverrides, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry } function csgMaterials(brush: Brush): THREE.Material[] { const mat = (brush as any).material return Array.isArray(mat) ? mat : [mat] } const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { ;(geometry as any).computeBoundsTree = computeBoundsTree ;(geometry as any).computeBoundsTree({ maxLeafSize: 10 }) } function prepareBrushForCSG(brush: Brush) { ensureRenderableGeometryAttributes(brush.geometry) computeGeometryBoundsTree(brush.geometry) brush.updateMatrixWorld() } // Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry const _matrix = new THREE.Matrix4() const _position = new THREE.Vector3() const _quaternion = new THREE.Quaternion() const _scale = new THREE.Vector3(1, 1, 1) const _yAxis = new THREE.Vector3(0, 1, 0) const _uvFaceNormal = new THREE.Vector3() const _uvWorldDown = new THREE.Vector3(0, -1, 0) const _uvDownSlope = new THREE.Vector3() const _uvAcrossSlope = new THREE.Vector3() // World transform of the segment whose geometry is currently being built, so // vertical (gable wall) faces project their UVs in WORLD space — identical to // the wall kind's `applyWorldPlanarWallUVs` (U = ±worldX/Z, V = 1 - worldY) so // the gable band tiles continuously into the walls below. Identity = local // (the default outside a segment build). Set via `withSegmentUvMatrix`. const _segmentUvMatrix = new THREE.Matrix4() const _segmentUvNormalMatrix = new THREE.Matrix3() const _uvWorldPoint = new THREE.Vector3() const _uvWorldNormal = new THREE.Vector3() const _prevSegmentUvMatrix = new THREE.Matrix4() const _prevSegmentUvNormalMatrix = new THREE.Matrix3() function withSegmentUvMatrix(matrix: THREE.Matrix4, build: () => T): T { _prevSegmentUvMatrix.copy(_segmentUvMatrix) _prevSegmentUvNormalMatrix.copy(_segmentUvNormalMatrix) _segmentUvMatrix.copy(matrix) _segmentUvNormalMatrix.getNormalMatrix(matrix) try { return build() } finally { _segmentUvMatrix.copy(_prevSegmentUvMatrix) _segmentUvNormalMatrix.copy(_prevSegmentUvNormalMatrix) } } // Scratch matrices for composing a segment's world transform (roof group ∘ // segment) at each build, without per-call allocation. const _segWorldMatrix = new THREE.Matrix4() const _roofGroupMatrix = new THREE.Matrix4() const _segLocalMatrix = new THREE.Matrix4() const _uvTmpPos = new THREE.Vector3() const _uvTmpQuat = new THREE.Quaternion() const _uvUnitScale = new THREE.Vector3(1, 1, 1) /** Compose the world transform `T(roofPos)·Ry(roofRot) · T(segPos)·Ry(segRot)`. */ function composeSegmentWorldMatrix( roofPosition: readonly number[] | undefined, roofRotation: number, segPosition: readonly number[], segRotation: number, ): THREE.Matrix4 { _roofGroupMatrix.compose( _uvTmpPos.set(roofPosition?.[0] ?? 0, roofPosition?.[1] ?? 0, roofPosition?.[2] ?? 0), _uvTmpQuat.setFromAxisAngle(_yAxis, roofRotation), _uvUnitScale, ) _segLocalMatrix.compose( _uvTmpPos.set(segPosition[0] ?? 0, segPosition[1] ?? 0, segPosition[2] ?? 0), _uvTmpQuat.setFromAxisAngle(_yAxis, segRotation), _uvUnitScale, ) return _segWorldMatrix.multiplyMatrices(_roofGroupMatrix, _segLocalMatrix) } const _tmpVec3A = new THREE.Vector3() const _tmpVec3B = new THREE.Vector3() const _surfaceRay = new THREE.Ray() const _surfaceOrigin = new THREE.Vector3() const _surfaceDir = new THREE.Vector3(0, -1, 0) const _surfaceHits: THREE.Intersection[] = [] const _surfaceV0 = new THREE.Vector3() const _surfaceV1 = new THREE.Vector3() const _surfaceV2 = new THREE.Vector3() const _surfaceFaceNormal = new THREE.Vector3() /** * Degenerate placeholder for a roof mesh with nothing to draw (initial * BoxGeometry swap-out, or a roof whose segments were all deleted/painted). * Three zero-vertices (one invisible triangle), not an empty attribute: an * empty position (count 0) leaves WebGPU vertex buffer slot 0 unbound if the * mesh is ever drawn, and computeBoundsTree needs a real position buffer to * index. Deliberately NO groups: count-0 groups crash MeshBVH's packed-tree * build (it partitions roots by group), and a BoxGeometry's 6 groups against * the 4 roof materials crash raycasts and GLTFExporter. Group-less + a * zero-area triangle is safe everywhere — it draws nothing under an array * material and can never be ray-hit. */ function createDegenerateRoofPlaceholder(): THREE.BufferGeometry { const placeholder = new THREE.BufferGeometry() placeholder.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) placeholder.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) placeholder.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) placeholder.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) computeGeometryBoundsTree(placeholder) return placeholder } // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() const warnedMergedRoofNaNIds = new Set() const MAX_ROOFS_PER_FRAME = 1 const MAX_SEGMENTS_PER_FRAME = 3 // ============================================================================ // ROOF SYSTEM // ============================================================================ export const RoofSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) const rootNodeIds = useScene((state) => state.rootNodeIds) // Subscribe so an override-only update (no scene write) still re-runs // the component, letting the useFrame loop pick up the latest dirtyNodes // set from the render pass that received the override-publishing // `markDirty` call. Mirrors WallSystem / DoorSystem. useLiveNodeOverrides((s) => s.overrides) useFrame(() => { // Clear stale pending updates when the scene is unloaded if (rootNodeIds.length === 0) { pendingRoofUpdates.clear() warnedMergedRoofNaNIds.clear() for (const cached of mergedRoofSegmentGeometryCache.values()) { disposeCachedMergedRoofSegmentGeometrySet(cached) } mergedRoofSegmentGeometryCache.clear() return } if (dirtyNodes.size === 0 && pendingRoofUpdates.size === 0) return const nodes = useScene.getState().nodes // --- Pass 1: Process dirty roof-segments (throttled) --- let segmentsProcessed = 0 dirtyNodes.forEach((id) => { const node = nodes[id] if (!node) return // Cutting roof accessories cascade their dirty mark to the host // segment's parent roof so the merged shell re-CSGs with the new // cut. Non-cutting accessories (vents, panels, gutters, etc.) sit on // top of the shell and should not force a full roof merge. const def = nodeRegistry.get(node.type) // Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof // through their own geometry system's parentId cascade instead — // their dirty marks belong to that system, not to this loop. const roofAccessory = def?.capabilities?.roofAccessory if (roofAccessory && !roofAccessory.dirtyHandledByOwnSystem) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined if (roofAccessory.buildCut && seg?.parentId) { pendingRoofUpdates.add(seg.parentId as AnyNodeId) } clearDirty(id as AnyNodeId) return } if (node.type === 'roof-segment') { const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh // Merge any live override (width / depth / wallHeight / pitch / // rotation) so the mesh rebuild reflects the in-flight handle drag // without zustand churn. When no override is set this returns the // scene node unchanged. Same pattern as DoorSystem / WallSystem. const effectiveSegment = getEffectiveNode(node as RoofSegmentNode) if (mesh) { // Only compute expensive individual CSG when the segment is actually rendered // (its parent group is visible = the roof is selected for editing) const isVisible = mesh.parent?.visible !== false // Accessory-reveal mode (RoofEditSystem): the wrapper is shown so // portaled handles render, but the merged shell stays visible and // the segment meshes are stripped to empty placeholders. Rebuilding // per-segment CSG here would draw UNCUT geometry on top of the // merged shell — hiding a freshly cut opening (door / window / // skylight) until the next deselect. Full edit mode hides the // merged mesh, so gate the rebuild on its visibility. const revealOnly = mesh.parent?.name === 'segments-wrapper' && mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { updateRoofSegmentGeometry(effectiveSegment, mesh, nodes) segmentsProcessed++ } else if (isVisible && !revealOnly) { return // Over budget — keep dirty, process next frame } else { // Just sync transform, skip CSG — the merged roof handles visuals. // But replace the initial BoxGeometry once: it has 6 groups (materialIndex 0-5) // while roofMaterials only has 4 entries. Three.js raycasts into invisible groups, // so MeshBVH hits groups[4].materialIndex → undefined.side → crash. if (mesh.geometry.type === 'BoxGeometry') { mesh.geometry.dispose() mesh.geometry = createDegenerateRoofPlaceholder() } mesh.position.set( effectiveSegment.position[0], effectiveSegment.position[1], effectiveSegment.position[2], ) mesh.rotation.y = effectiveSegment.rotation } clearDirty(id as AnyNodeId) } else { clearDirty(id as AnyNodeId) } // Queue the parent roof for a merged geometry update if (effectiveSegment.parentId) { pendingRoofUpdates.add(effectiveSegment.parentId as AnyNodeId) } } else if (node.type === 'roof') { pendingRoofUpdates.add(id as AnyNodeId) clearDirty(id as AnyNodeId) } }) // --- Pass 2: Process pending merged-roof updates (max 1 per frame) --- let roofsProcessed = 0 for (const id of pendingRoofUpdates) { if (roofsProcessed >= MAX_ROOFS_PER_FRAME) break const node = nodes[id] if (node?.type !== 'roof') { pendingRoofUpdates.delete(id) continue } const group = sceneRegistry.nodes.get(id) as THREE.Group if (!group) continue const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined if (!mergedMesh) continue if (mergedMesh.visible !== false) { // Only rebuild when visible — RoofEditSystem re-triggers via markDirty on edit mode exit updateMergedRoofGeometry(node as RoofNode, group, nodes) roofsProcessed++ } pendingRoofUpdates.delete(id) } }, 5) // Priority 5: run after all other systems have settled return null } // ============================================================================ // GEOMETRY GENERATION // ============================================================================ function updateRoofSegmentGeometry( node: RoofSegmentNode, mesh: THREE.Mesh, nodes?: Record, ) { const newGeo = generateRoofSegmentGeometry(node, nodes) mesh.geometry.dispose() mesh.geometry = newGeo computeGeometryBoundsTree(newGeo) mesh.position.set(node.position[0], node.position[1], node.position[2]) mesh.rotation.y = node.rotation } /** * Subtract every hosted accessory cut (`capabilities.roofAccessory. * buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by * the merged-shell path AND the per-segment path (full edit mode / * painted segments) — without the latter, selecting a segment used to * swap the merged shell for uncut per-segment meshes and every door / * window / skylight hole vanished until deselect. Children are read * live-effective so an in-flight handle drag carves the live hole. * Registry-driven so the viewer never names a kind. */ function subtractAccessoryCuts( brushes: RoofSegmentBrushSet, segment: RoofSegmentNode, nodes: Record, ) { let workingShin = brushes.shinSlab let workingDeck = brushes.deckSlab let workingWall = brushes.wallBrush for (const childElemId of segment.children ?? []) { const storedChild = nodes[childElemId as AnyNodeId] if (!storedChild) continue const childElem = getEffectiveNode(storedChild) const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null ? (childElem.metadata as Record) : undefined if (meta?.isTransient) continue const childDef = nodeRegistry.get(childElem.type) const buildCut = childDef?.capabilities?.roofAccessory?.buildCut if (!buildCut) continue const cutGeo = buildCut(childElem, segment) if (!cutGeo) continue // Wrap the kind-emitted geometry in a Brush. Kinds return raw // shapes; the viewer welds (mandatory after rotations leave // duplicated verts), attaches a single material group, and // builds the bounds tree — keeping kind code free of // three-bvh-csg / three-mesh-bvh imports. const welded = mergeVertices(cutGeo, 1e-4) cutGeo.dispose() const idxCount = welded.getIndex()?.count ?? 0 if (idxCount === 0) { welded.dispose() continue } welded.clearGroups() welded.addGroup(0, idxCount, 0) welded.computeVertexNormals() ensureRenderableGeometryAttributes(welded) computeGeometryBoundsTree(welded) const cut = new Brush(welded, dummyMats[0]) cut.updateMatrixWorld() const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all' try { if (cutScope !== 'wall') { const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush workingShin.geometry.dispose() prepareBrushForCSG(nextShin) workingShin = nextShin const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush workingDeck.geometry.dispose() prepareBrushForCSG(nextDeck) workingDeck = nextDeck } const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush workingWall.geometry.dispose() prepareBrushForCSG(nextWall) workingWall = nextWall } catch (e) { console.error(`[${childElem.type}] cut CSG failed:`, e) } finally { cut.geometry.dispose() } } brushes.shinSlab = workingShin brushes.deckSlab = workingDeck brushes.wallBrush = workingWall } function getMergedRoofSegmentBrushes( roofNode: RoofNode, segment: RoofSegmentNode, nodes: Record, ): RoofSegmentBrushSet | null { const segmentId = segment.id as AnyNodeId const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes) const cached = mergedRoofSegmentGeometryCache.get(segmentId) if (cached?.key === cacheKey) { return cloneCachedMergedRoofSegmentBrushes(cached) } const brushes = withSegmentUvMatrix( composeSegmentWorldMatrix( roofNode.position, roofNode.rotation ?? 0, segment.position, segment.rotation ?? 0, ), () => getRoofSegmentBrushes(segment), ) if (!brushes) { disposeCachedMergedRoofSegmentGeometrySet(cached) mergedRoofSegmentGeometryCache.delete(segmentId) return null } subtractAccessoryCuts(brushes, segment, nodes) _matrix.compose( _position.set(segment.position[0], segment.position[1], segment.position[2]), _quaternion.setFromAxisAngle(_yAxis, segment.rotation), _scale, ) const applyTransform = (brush: Brush) => { csgGeometry(brush).applyMatrix4(_matrix) brush.updateMatrixWorld() } applyTransform(brushes.shinSlab) applyTransform(brushes.deckSlab) applyTransform(brushes.wallBrush) applyTransform(brushes.innerBrush) brushes.rakeBoards?.applyMatrix4(_matrix) const nextCached: CachedMergedRoofSegmentGeometrySet = { key: cacheKey, deckSlab: { geometry: csgGeometry(brushes.deckSlab).clone(), materials: csgMaterials(brushes.deckSlab), }, shinSlab: { geometry: csgGeometry(brushes.shinSlab).clone(), materials: csgMaterials(brushes.shinSlab), }, wallBrush: { geometry: csgGeometry(brushes.wallBrush).clone(), materials: csgMaterials(brushes.wallBrush), }, innerBrush: { geometry: csgGeometry(brushes.innerBrush).clone(), materials: csgMaterials(brushes.innerBrush), }, rakeBoards: brushes.rakeBoards?.clone() ?? null, } disposeCachedMergedRoofSegmentGeometrySet(cached) mergedRoofSegmentGeometryCache.set(segmentId, nextCached) const cloned = cloneCachedMergedRoofSegmentBrushes(nextCached) disposeRoofSegmentBrushSet(brushes) return cloned } function updateMergedRoofGeometry( roofNode: RoofNode, group: THREE.Group, nodes: Record, ) { const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined if (!mergedMesh) return // Segments that carry their own material / preset (catch-all or any of // the role-specific fields) are rendered as their own per-segment mesh // in `RoofRenderer` so the painted material is preserved. Exclude them // from the merged shell — otherwise the merged mesh would draw on top // with the roof's default material. // // Merge each child through `getEffectiveNode` so an in-flight handle // drag (live override on width / depth / wallHeight / pitch / rotation) // is reflected in the merged shell during the drag, not only on commit. const children = (roofNode.children ?? []) .map((id) => { const scn = nodes[id] as RoofSegmentNode | undefined return scn ? getEffectiveNode(scn) : undefined }) .filter((n): n is RoofSegmentNode => n !== undefined && !hasSegmentMaterialOverride(n)) if (children.length === 0) { mergedMesh.geometry.dispose() // Not BoxGeometry: its 6 groups against the merged mesh's 4-material array // crash GLTFExporter (materials[4] → undefined) when the roof bakes. mergedMesh.geometry = createDegenerateRoofPlaceholder() return } let totalShinSlab: Brush | null = null let totalDeckSlab: Brush | null = null let totalWall: Brush | null = null let totalInner: Brush | null = null const rakeBoardGeometries: THREE.BufferGeometry[] = [] for (const child of children) { const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes) if (!brushes) continue if (brushes.rakeBoards) { rakeBoardGeometries.push(brushes.rakeBoards) } if (totalShinSlab) { const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush totalShinSlab.geometry.dispose() brushes.shinSlab.geometry.dispose() prepareBrushForCSG(next) totalShinSlab = next } else { totalShinSlab = brushes.shinSlab } if (totalDeckSlab) { const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush totalDeckSlab.geometry.dispose() brushes.deckSlab.geometry.dispose() prepareBrushForCSG(next) totalDeckSlab = next } else { totalDeckSlab = brushes.deckSlab } if (totalWall) { const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush totalWall.geometry.dispose() brushes.wallBrush.geometry.dispose() prepareBrushForCSG(next) totalWall = next } else { totalWall = brushes.wallBrush } if (totalInner) { const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush totalInner.geometry.dispose() brushes.innerBrush.geometry.dispose() prepareBrushForCSG(next) totalInner = next } else { totalInner = brushes.innerBrush } } if (totalShinSlab && totalDeckSlab && totalWall && totalInner) { try { const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) prepareBrushForCSG(finalWallTrimmed) const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION) prepareBrushForCSG(shinDeck) const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) prepareBrushForCSG(combined) const resultGeo = csgGeometry(combined) if (geometryHasInvalidAttributes(resultGeo)) { if (!warnedMergedRoofNaNIds.has(roofNode.id)) { console.warn( '[RoofSystem] Skipping merged roof geometry with invalid attributes', roofNode.id, ) warnedMergedRoofNaNIds.add(roofNode.id) } resultGeo.dispose() finalWallTrimmed.geometry.dispose() shinDeck.geometry.dispose() totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() totalWall.geometry.dispose() totalInner.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() return } const resultMaterials = csgMaterials(combined) const matToIndex = new Map([ [dummyMats[0], 0], [dummyMats[1], 1], [dummyMats[2], 2], [dummyMats[3], 3], ]) for (const g of resultGeo.groups) { g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) } let finalGeo = resultGeo if (rakeBoardGeometries.length > 0) { const merged = mergeGeometriesPreservingGroups([finalGeo, ...rakeBoardGeometries]) if (merged) { finalGeo.dispose() finalGeo = merged } } for (const geometry of rakeBoardGeometries) geometry.dispose() finalGeo.computeVertexNormals() ensureRenderableGeometryAttributes(finalGeo) mergedMesh.geometry.dispose() mergedMesh.geometry = finalGeo finalWallTrimmed.geometry.dispose() shinDeck.geometry.dispose() } catch (e) { console.error('Merged roof CSG failed:', e) } totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() totalWall.geometry.dispose() totalInner.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() } } function geometryHasInvalidAttributes(geometry: THREE.BufferGeometry) { const position = geometry.getAttribute('position') if (!(position && position.count > 0)) return true for (const name of ['position', 'normal', 'uv', 'uv2']) { const attribute = geometry.getAttribute(name) if (!attribute) continue for (let i = 0; i < attribute.array.length; i++) { if (!Number.isFinite(attribute.array[i])) return true } } const index = geometry.getIndex() if (!index || index.count === 0) return true for (let i = 0; i < index.count; i++) { const value = index.getX(i) if (!Number.isInteger(value) || value < 0 || value >= position.count) return true } for (const group of geometry.groups) { if ( !Number.isInteger(group.start) || !Number.isInteger(group.count) || group.start < 0 || group.count <= 0 || group.start + group.count > index.count ) { return true } } return false } /** * Four dummy materials used as identity placeholders during CSG. Shared * across every input brush so three-bvh-csg can preserve reference * equality on the result and `mapRoofGroupMaterialIndex` can map result * groups back to slots 0..3. Exposed so kinds that compose additional * CSG ops on top of `getRoofSegmentBrushes` (e.g. dormer) use the same * identity refs. */ export const roofCsgDummyMats: [ THREE.MeshBasicMaterial, THREE.MeshBasicMaterial, THREE.MeshBasicMaterial, THREE.MeshBasicMaterial, ] = [ new THREE.MeshBasicMaterial(), new THREE.MeshBasicMaterial(), new THREE.MeshBasicMaterial(), new THREE.MeshBasicMaterial(), ] // Internal alias kept so the surrounding file's many call sites don't churn. const dummyMats = roofCsgDummyMats export const ROOF_MATERIAL_SLOT_COUNT = 4 type RoofSegmentBrushSet = { deckSlab: Brush shinSlab: Brush wallBrush: Brush innerBrush: Brush rakeBoards: THREE.BufferGeometry | null } type CachedMergedRoofSegmentGeometrySet = { key: string deckSlab: CachedMergedRoofSegmentBrush shinSlab: CachedMergedRoofSegmentBrush wallBrush: CachedMergedRoofSegmentBrush innerBrush: CachedMergedRoofSegmentBrush rakeBoards: THREE.BufferGeometry | null } type CachedMergedRoofSegmentBrush = { geometry: THREE.BufferGeometry materials: THREE.Material[] } const mergedRoofSegmentGeometryCache = new Map() function disposeCachedMergedRoofSegmentGeometrySet( cached: CachedMergedRoofSegmentGeometrySet | undefined, ) { if (!cached) return cached.deckSlab.geometry.dispose() cached.shinSlab.geometry.dispose() cached.wallBrush.geometry.dispose() cached.innerBrush.geometry.dispose() cached.rakeBoards?.dispose() } function disposeRoofSegmentBrushSet(brushes: RoofSegmentBrushSet) { brushes.deckSlab.geometry.dispose() brushes.shinSlab.geometry.dispose() brushes.wallBrush.geometry.dispose() brushes.innerBrush.geometry.dispose() brushes.rakeBoards?.dispose() } function cloneCachedBrush(cached: CachedMergedRoofSegmentBrush): Brush { const brush = new Brush(cached.geometry.clone(), cached.materials) prepareBrushForCSG(brush) return brush } function cloneCachedMergedRoofSegmentBrushes( cached: CachedMergedRoofSegmentGeometrySet, ): RoofSegmentBrushSet { return { deckSlab: cloneCachedBrush(cached.deckSlab), shinSlab: cloneCachedBrush(cached.shinSlab), wallBrush: cloneCachedBrush(cached.wallBrush), innerBrush: cloneCachedBrush(cached.innerBrush), rakeBoards: cached.rakeBoards?.clone() ?? null, } } function getMergedRoofAccessoryCachePayload( segment: RoofSegmentNode, nodes: Record, ): unknown[] { const payload: unknown[] = [] for (const childElemId of segment.children ?? []) { const storedChild = nodes[childElemId as AnyNodeId] if (!storedChild) continue const childElem = getEffectiveNode(storedChild) const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null ? (childElem.metadata as Record) : undefined if (meta?.isTransient) continue const childDef = nodeRegistry.get(childElem.type) if (!childDef?.capabilities?.roofAccessory?.buildCut) continue payload.push(childElem) } return payload } function getMergedRoofSegmentCacheKey( roofNode: RoofNode, segment: RoofSegmentNode, nodes: Record, ): string { return JSON.stringify({ roofPosition: roofNode.position ?? [0, 0, 0], roofRotation: roofNode.rotation ?? 0, segment, accessories: getMergedRoofAccessoryCachePayload(segment, nodes), }) } export function mapRoofGroupMaterialIndex( groupMaterialIndex: number | undefined, csgMaterials: THREE.Material[], matToIndex: Map, ): number { if (groupMaterialIndex === undefined) return 0 // Primary path — reference-equality lookup. Fast and exact when // three-bvh-csg preserves the original `dummyMats` references on // the result brush. const sourceMaterial = csgMaterials[groupMaterialIndex] const mappedIndex = sourceMaterial ? matToIndex.get(sourceMaterial) : undefined if (mappedIndex !== undefined) return mappedIndex // Robust fallback — every input brush was constructed with the same // 4-slot `dummyMats` array, so after N union/subtraction passes the // result's material array is `[dummyMats[0..3], dummyMats[0..3], ...]` // and the group's materialIndex is `slot + (brushOffset * 4)`. The // slot we care about is therefore `materialIndex % 4`. Without this // fallback, any CSG pass that returns a fresh `Material` object (or // clones the dummyMats refs) makes every group collapse to slot 0 // (Wall) — which is the "shape is there but the wrong colour" // symptom roofs show after deselect / refresh. return ( ((groupMaterialIndex % ROOF_MATERIAL_SLOT_COUNT) + ROOF_MATERIAL_SLOT_COUNT) % ROOF_MATERIAL_SLOT_COUNT ) } function normalizeRoofMaterialIndex(materialIndex: number | undefined): number { if (materialIndex === undefined || !Number.isFinite(materialIndex)) return 0 const normalized = Math.trunc(materialIndex) if (normalized < 0 || normalized >= ROOF_MATERIAL_SLOT_COUNT) return 0 return normalized } function remapDutchRakeBoardMaterials(geometry: THREE.BufferGeometry) { const position = geometry.getAttribute('position') if (!position) return const index = geometry.getIndex() const triangleCount = (index?.count ?? position.count) / 3 if (!Number.isFinite(triangleCount) || triangleCount <= 0) return 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 triangleMaterials = new Array(triangleCount).fill(DUTCH_RAKE_SIDE_MATERIAL_INDEX) for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { const offset = triangleIndex * 3 const ia = index ? index.getX(offset) : offset const ib = index ? index.getX(offset + 1) : offset + 1 const ic = index ? index.getX(offset + 2) : offset + 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).normalize() triangleMaterials[triangleIndex] = Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON ? DUTCH_RAKE_TOP_MATERIAL_INDEX : DUTCH_RAKE_SIDE_MATERIAL_INDEX } geometry.clearGroups() let currentMaterial = triangleMaterials[0] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX let groupStart = 0 for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) { const materialIndex = triangleMaterials[triangleIndex] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX if (materialIndex === currentMaterial) continue geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) groupStart = triangleIndex currentMaterial = materialIndex } geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) } const SHINGLE_SURFACE_EPSILON = 0.02 const RAKE_FACE_NORMAL_EPSILON = 0.3 const RAKE_FACE_ALIGNMENT_EPSILON = 0.35 const TRIM_CUT_EPSILON = 0.002 const DUTCH_RAKE_SIDE_MATERIAL_INDEX = 1 const DUTCH_RAKE_TOP_MATERIAL_INDEX = 3 const DUTCH_RAKE_SLOPE_SEAT_OFFSET = 0.0002 function pushDoubleSidedFace(targetFaces: THREE.Vector3[][], face: THREE.Vector3[]) { targetFaces.push(face) targetFaces.push(face.map((point) => point.clone()).reverse()) } function hasSegmentTrim(node: RoofSegmentNode): boolean { const trim = normalizeRoofSegmentTrim(node) return ( trim.left > 0 || trim.right > 0 || trim.front > 0 || trim.back > 0 || trim.frontLeft > 0 || trim.frontRight > 0 || trim.backLeft > 0 || trim.backRight > 0 || trim.frontLeftX > 0 || trim.frontLeftZ > 0 || trim.frontRightX > 0 || trim.frontRightZ > 0 || trim.backLeftX > 0 || trim.backLeftZ > 0 || trim.backRightX > 0 || trim.backRightZ > 0 ) } // Material slot the freshly exposed trim plane is assigned to. Slot 0 is the // wall/trim band — the same finish the gable walls render with (concrete-drywall // by default, continuous with the walls below). `remapRoofShellFaces` does not // reclassify slot 0, so any new interior face CSG carves out of a BoxGeometry // cutter lands on the wall band regardless of which box face it came from. // Without this the box's default 6 groups (materialIndex 0..5) collapse via // mod-4 and `remapRoofShellFaces` would reshuffle the vertical ones across // slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry` // when they expose fewer material slots. const TRIM_CUT_MATERIAL_SLOT = 0 function assignTrimCutterSlot(geometry: THREE.BufferGeometry): void { geometry.clearGroups() const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count geometry.addGroup(0, count, TRIM_CUT_MATERIAL_SLOT) } function buildTrimCutBrush( minX: number, maxX: number, minZ: number, maxZ: number, minY: number, maxY: number, ): Brush | null { const width = maxX - minX const height = maxY - minY const depth = maxZ - minZ if (!(width > 0 && height > 0 && depth > 0)) return null const geometry = new THREE.BoxGeometry(width, height, depth) geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) // World-metre UVs on the cutter so the newly exposed interior faces produced // by CSG inherit tiled UVs (1 uv unit per metre) instead of the box's default // 0→1-per-face, which would stretch the finish to fit each cut face. applyWorldScaleBoxUVs(geometry, width, height, depth) assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) const cut = new Brush(geometry, dummyMats) cut.updateMatrixWorld() return cut } type TrimPlanPoint = readonly [number, number] function buildDiagonalTrimCutBrush( bounds: { minX: number maxX: number minZ: number maxZ: number minY: number maxY: number }, lineA: TrimPlanPoint, lineB: TrimPlanPoint, outsidePoint: TrimPlanPoint, ): Brush | null { const dx = lineB[0] - lineA[0] const dz = lineB[1] - lineA[1] const lineLength = Math.hypot(dx, dz) const height = bounds.maxY - bounds.minY if (!(lineLength > 0 && height > 0)) return null const ux = dx / lineLength const uz = dz / lineLength let nx = -uz let nz = ux const midX = (lineA[0] + lineB[0]) / 2 const midZ = (lineA[1] + lineB[1]) / 2 const toOutsideX = outsidePoint[0] - midX const toOutsideZ = outsidePoint[1] - midZ let normalFlipped = false if (nx * toOutsideX + nz * toOutsideZ < 0) { nx *= -1 nz *= -1 normalFlipped = true } const boundsDiagonal = Math.hypot(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) const cutterLength = Math.max(lineLength, boundsDiagonal) * 2 const cutterDepth = boundsDiagonal * 2 const centerX = midX + nx * (cutterDepth / 2 - TRIM_CUT_EPSILON) const centerZ = midZ + nz * (cutterDepth / 2 - TRIM_CUT_EPSILON) const yaw = Math.atan2(-uz, ux) + (normalFlipped ? Math.PI : 0) const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth) geometry.rotateY(yaw) geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ) // World-metre UVs so the diagonal cut's interior face inherits tiled UVs. applyWorldScaleBoxUVs(geometry, cutterLength, height, cutterDepth) assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) const cut = new Brush(geometry, dummyMats) cut.updateMatrixWorld() return cut } function subtractCutFromSegmentBrushes(brushes: RoofSegmentBrushSet, cut: Brush) { for (const key of ['shinSlab', 'deckSlab', 'wallBrush', 'innerBrush'] as const) { const next = csgEvaluator.evaluate(brushes[key], cut, SUBTRACTION) as Brush brushes[key].geometry.dispose() prepareBrushForCSG(next) brushes[key] = next } } function buildSegmentTrimCutBrushes(segment: RoofSegmentNode): Brush[] { const trim = normalizeRoofSegmentTrim(segment) if ( trim.left === 0 && trim.right === 0 && trim.front === 0 && trim.back === 0 && trim.frontLeft === 0 && trim.frontRight === 0 && trim.backLeft === 0 && trim.backRight === 0 && trim.frontLeftX === 0 && trim.frontLeftZ === 0 && trim.frontRightX === 0 && trim.frontRightZ === 0 && trim.backLeftX === 0 && trim.backLeftZ === 0 && trim.backRightX === 0 && trim.backRightZ === 0 ) { return [] } const { activeRh } = getSegmentSlopeFrame(segment) const extra = segment.wallThickness + segment.overhang + segment.deckThickness + segment.shingleThickness + 2 const minX = -segment.width / 2 - extra const maxX = segment.width / 2 + extra const minZ = -segment.depth / 2 - extra const maxZ = segment.depth / 2 + extra const minY = -2 const maxY = segment.wallHeight + activeRh + segment.deckThickness + segment.shingleThickness + 2 const cuts: Brush[] = [] if (trim.left > 0) { const planeX = -segment.width / 2 + trim.left const cut = buildTrimCutBrush(minX, planeX + TRIM_CUT_EPSILON, minZ, maxZ, minY, maxY) if (cut) cuts.push(cut) } if (trim.right > 0) { const planeX = segment.width / 2 - trim.right const cut = buildTrimCutBrush(planeX - TRIM_CUT_EPSILON, maxX, minZ, maxZ, minY, maxY) if (cut) cuts.push(cut) } if (trim.front > 0) { const planeZ = segment.depth / 2 - trim.front const cut = buildTrimCutBrush(minX, maxX, planeZ - TRIM_CUT_EPSILON, maxZ, minY, maxY) if (cut) cuts.push(cut) } if (trim.back > 0) { const planeZ = -segment.depth / 2 + trim.back const cut = buildTrimCutBrush(minX, maxX, minZ, planeZ + TRIM_CUT_EPSILON, minY, maxY) if (cut) cuts.push(cut) } const leftX = -segment.width / 2 + trim.left const rightX = segment.width / 2 - trim.right const frontZ = segment.depth / 2 - trim.front const backZ = -segment.depth / 2 + trim.back if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) { const cut = buildDiagonalTrimCutBrush( { minX, maxX, minZ, maxZ, minY, maxY }, [leftX + trim.frontLeftX + TRIM_CUT_EPSILON, frontZ], [leftX, frontZ - trim.frontLeftZ - TRIM_CUT_EPSILON], [minX, maxZ], ) if (cut) cuts.push(cut) } if (trim.frontRightX > 0 && trim.frontRightZ > 0) { const cut = buildDiagonalTrimCutBrush( { minX, maxX, minZ, maxZ, minY, maxY }, [rightX, frontZ - trim.frontRightZ - TRIM_CUT_EPSILON], [rightX - trim.frontRightX - TRIM_CUT_EPSILON, frontZ], [maxX, maxZ], ) if (cut) cuts.push(cut) } if (trim.backLeftX > 0 && trim.backLeftZ > 0) { const cut = buildDiagonalTrimCutBrush( { minX, maxX, minZ, maxZ, minY, maxY }, [leftX, backZ + trim.backLeftZ + TRIM_CUT_EPSILON], [leftX + trim.backLeftX + TRIM_CUT_EPSILON, backZ], [minX, minZ], ) if (cut) cuts.push(cut) } if (trim.backRightX > 0 && trim.backRightZ > 0) { const cut = buildDiagonalTrimCutBrush( { minX, maxX, minZ, maxZ, minY, maxY }, [rightX - trim.backRightX - TRIM_CUT_EPSILON, backZ], [rightX, backZ + trim.backRightZ + TRIM_CUT_EPSILON], [maxX, minZ], ) if (cut) cuts.push(cut) } return cuts } function subtractSegmentTrimCuts(brushes: RoofSegmentBrushSet, segment: RoofSegmentNode) { const cuts = buildSegmentTrimCutBrushes(segment) for (const cut of cuts) { try { subtractCutFromSegmentBrushes(brushes, cut) } catch (e) { console.error('Roof trim CSG failed:', e) } finally { cut.geometry.dispose() } } } /** * Subtract a segment's trim cuts from an arbitrary segment-local geometry, * returning the clipped result. The input geometry is consumed (disposed) on * each successful CSG pass — callers that need to keep the original must pass a * clone. Returns the input untouched when the segment has no trim. Used * internally for rake-board / end-slope attachments and exported so roof * accessories (chimney, vents, skylight, …) can clip their own meshes by the * same trim, in the same segment-local frame. */ export function clipGeometryBySegmentTrim( geometry: THREE.BufferGeometry | null, segment: RoofSegmentNode, ): THREE.BufferGeometry | null { if (!geometry) return null const cuts = buildSegmentTrimCutBrushes(segment) if (cuts.length === 0) return geometry let currentGeometry = geometry for (const cut of cuts) { try { const brush = new Brush(currentGeometry, dummyMats) prepareBrushForCSG(brush) const next = csgEvaluator.evaluate(brush, cut, SUBTRACTION) as Brush const trimmed = csgGeometry(next).clone() currentGeometry.dispose() next.geometry.dispose() ensureRenderableGeometryAttributes(trimmed) currentGeometry = trimmed } catch (e) { console.error('Roof trim CSG failed for attachment geometry:', e) } finally { cut.geometry.dispose() } } return currentGeometry } /** * Generate complete hollow-shell geometry for a roof segment. * Ports the prototype's CSG approach using three-bvh-csg. */ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSet | null { const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang, shingleThickness, } = node const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) const shapeRatios = getRoofShapeRatios({ gambrelLowerWidthRatio: node.gambrelLowerWidthRatio, mansardSteepWidthRatio: node.mansardSteepWidthRatio, dutchHipWidthRatio: node.dutchHipWidthRatio, dutchHipHeightRatio: node.dutchHipHeightRatio, dutchWaistLengthRatio: node.dutchWaistLengthRatio, dutchGabletRake: node.dutchGabletRake, }) const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness // Gablet inset must track dutchHipWidthRatio so the 3D waist matches both // the 2D floorplan and the slope frame (which derives activeRh from the // same ratio). A hardcoded 0.25 desyncs the gablet from the parameter. const baseI = Math.min(width, depth) * node.dutchHipWidthRatio const getVol = ( wExt: number, vOffset: number, baseY: number, matIndex: number, isVoid: boolean, ) => { const wV = Math.max(0.01, width + 2 * wExt) const dV = Math.max(0.01, depth + 2 * wExt) const autoDrop = wExt * tanTheta const whV = Math.max(0.01, wallHeight - autoDrop + vOffset) let rhV = activeRh if (activeRh > 0) { rhV = activeRh + autoDrop if (roofType === 'shed') rhV = activeRh + 2 * autoDrop } const safeBaseY = Math.min(baseY, whV - 0.05) let structuralI = baseI if (isVoid) { structuralI += deckThickness } const faces = getRoofModuleFaces({ type: roofType, w: wV, d: dV, wh: whV, rh: rhV, baseY: safeBaseY, insets: { dutchI: structuralI }, baseW: width, baseD: depth, tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) return createGeometryFromFaces(faces, matIndex) } const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false) const innerGeo = getVol(-wallThickness / 2, 0, -5, 2, false) const horizontalOverhang = overhang * cosTheta const deckExt = wallThickness / 2 + horizontalOverhang const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false) const deckBotGeo = getVol(deckExt, 0, -5, 0, true) const stSin = shingleThickness * sinTheta const stCos = shingleThickness * cosTheta const shinBotW = Math.max(0.01, width + 2 * deckExt) const shinBotD = Math.max(0.01, depth + 2 * deckExt) const deckDrop = deckExt * tanTheta const shinBotWh = wallHeight - deckDrop + verticalRt let shinBotRh = activeRh if (activeRh > 0) { shinBotRh = activeRh + deckDrop if (roofType === 'shed') shinBotRh = activeRh + 2 * deckDrop } let shinTopW = shinBotW let shinTopD = shinBotD let transZ = 0 if (['hip', 'mansard', 'dutch'].includes(roofType)) { shinTopW += 2 * stSin shinTopD += 2 * stSin } else if (['gable', 'gambrel'].includes(roofType)) { shinTopD += 2 * stSin } else if (roofType === 'shed') { shinTopD += stSin transZ = stSin / 2 } const shinTopWh = shinBotWh + stCos let shinTopRh = shinBotRh if (activeRh > 0) { shinTopRh = shinBotRh + stSin * tanTheta } const availableR = (Math.min(shinBotW, shinBotD) / 2) * 0.95 const maxDrop = tanTheta > 0.001 ? availableR / tanTheta : 2.0 const dropTop = Math.min(1.0, maxDrop * 0.4) const dropBot = Math.min(2.0, maxDrop * 0.8) const topBaseY = shinBotWh - dropTop const botBaseY = shinBotWh - dropBot const insetsBot = getRoofShapeInsets({ roofType, width, depth, wh: shinBotWh, baseY: botBaseY, isVoid: true, brushW: shinBotW, brushD: shinBotD, tanTheta, shingleThickness, dutchHipWidthRatio: node.dutchHipWidthRatio, }) const insetsTop = getRoofShapeInsets({ roofType, width, depth, wh: shinTopWh, baseY: topBaseY, isVoid: false, brushW: shinTopW, brushD: shinTopD, tanTheta, shingleThickness, dutchHipWidthRatio: node.dutchHipWidthRatio, }) const botFaces = getRoofModuleFaces({ type: roofType, w: shinBotW, d: shinBotD, wh: shinBotWh, rh: shinBotRh, baseY: botBaseY, insets: insetsBot, baseW: width, baseD: depth, tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) const topFaces = getRoofModuleFaces({ type: roofType, w: shinTopW, d: shinTopD, wh: shinTopWh, rh: shinTopRh, baseY: topBaseY, insets: insetsTop, baseW: width, baseD: depth, tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) let rakeBoards: THREE.BufferGeometry | null = null if (roofType === 'dutch' && insetsTop.dutchI !== undefined) { rakeBoards = buildDutchRakeBoards( shinTopW, shinTopD, shinTopWh, shinTopRh, insetsTop.dutchI, shapeRatios, node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, ) } const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, ) const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, ) if (transZ !== 0) { shinTopGeo.translate(0, 0, transZ) rakeBoards?.translate(0, 0, transZ) } const toBrush = (geo: THREE.BufferGeometry): Brush | null => { if (!geo?.attributes.position || geo.attributes.position.count === 0) return null if (!geo.index) return null // Strip zero-count groups — three-bvh-csg crashes with groupIndices[i] undefined // when a group exists but covers no triangles (can happen after mergeVertices) geo.groups = geo.groups.filter((g) => g.count > 0) if (geo.groups.length === 0) return null ensureRenderableGeometryAttributes(geo) if (geometryHasInvalidAttributes(geo)) return null computeGeometryBoundsTree(geo) const brush = new Brush(geo, dummyMats) brush.updateMatrixWorld() return brush } const eps = 0.002 const wallBrush = toBrush(wallGeo) const innerBrush = toBrush(innerGeo) if (innerBrush) { const wV = Math.max(0.01, width - wallThickness) const dV = Math.max(0.01, depth - wallThickness) innerBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV) innerBrush.updateMatrixWorld() } const deckTopBrush = toBrush(deckTopGeo) const deckBotBrush = toBrush(deckBotGeo) if (deckBotBrush) { const wV = Math.max(0.01, width + 2 * deckExt) const dV = Math.max(0.01, depth + 2 * deckExt) deckBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV) deckBotBrush.updateMatrixWorld() } const shinTopBrush = toBrush(shinTopGeo) const shinBotBrush = toBrush(shinBotGeo) if (shinBotBrush) { const wV = shinBotW const dV = shinBotD shinBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV) shinBotBrush.updateMatrixWorld() } wallGeo.dispose() innerGeo.dispose() deckTopGeo.dispose() deckBotGeo.dispose() shinTopGeo.dispose() shinBotGeo.dispose() if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) { try { const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION) prepareBrushForCSG(deckSlab) const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION) prepareBrushForCSG(shinSlab) deckTopBrush.geometry.dispose() deckBotBrush.geometry.dispose() shinTopBrush.geometry.dispose() shinBotBrush.geometry.dispose() const brushes = { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards, } if (hasSegmentTrim(node)) { subtractSegmentTrimCuts(brushes, node) brushes.rakeBoards = clipGeometryBySegmentTrim(brushes.rakeBoards, node) // The clip is a CSG subtraction: rake faces can come back as // `slot + 4n` because the cutter contributes its own material array. // Preserve top roof-material faces (slot 3) and force only cutter / // side faces back to the rake side material. if (brushes.rakeBoards) { remapDutchRakeBoardMaterials(brushes.rakeBoards) } } return brushes } catch (e) { console.error('CSG prep failed:', e) } } if (deckTopBrush) deckTopBrush.geometry.dispose() if (deckBotBrush) deckBotBrush.geometry.dispose() if (shinTopBrush) shinTopBrush.geometry.dispose() if (shinBotBrush) shinBotBrush.geometry.dispose() if (wallBrush) wallBrush.geometry.dispose() if (innerBrush) innerBrush.geometry.dispose() rakeBoards?.dispose() return null } export function generateRoofSegmentGeometry( node: RoofSegmentNode, nodes?: Record, ): THREE.BufferGeometry { const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined const parentRoofPosition = parentRoof && 'position' in parentRoof ? (parentRoof.position as number[]) : undefined const parentRoofRotation = parentRoof && 'rotation' in parentRoof ? ((parentRoof as { rotation?: number }).rotation ?? 0) : 0 const brushes = withSegmentUvMatrix( composeSegmentWorldMatrix( parentRoofPosition, parentRoofRotation, node.position, node.rotation ?? 0, ), () => getRoofSegmentBrushes(node), ) if (!brushes) { // Fallback: simple box return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth) } if (nodes) { subtractAccessoryCuts(brushes, node, nodes) } const { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards } = brushes let resultGeo = new THREE.BufferGeometry() try { const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) prepareBrushForCSG(hollowWall) const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION) prepareBrushForCSG(shinDeck) const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) prepareBrushForCSG(combined) resultGeo = csgGeometry(combined) if (geometryHasInvalidAttributes(resultGeo)) { resultGeo.dispose() resultGeo = csgGeometry(wallBrush).clone() } const resultMaterials = csgMaterials(combined) const matToIndex = new Map([ [dummyMats[0], 0], [dummyMats[1], 1], [dummyMats[2], 2], [dummyMats[3], 3], ]) for (const group of resultGeo.groups) { group.materialIndex = mapRoofGroupMaterialIndex( group.materialIndex, resultMaterials, matToIndex, ) } remapRoofShellFaces(resultGeo, node) hollowWall.geometry.dispose() shinDeck.geometry.dispose() } catch (e) { console.error('Roof CSG failed:', e) resultGeo = csgGeometry(wallBrush).clone() } deckSlab.geometry.dispose() shinSlab.geometry.dispose() wallBrush.geometry.dispose() innerBrush.geometry.dispose() if (rakeBoards) { const merged = mergeGeometriesPreservingGroups([resultGeo, rakeBoards]) rakeBoards.dispose() if (merged) { resultGeo.dispose() resultGeo = merged } } resultGeo.computeVertexNormals() ensureRenderableGeometryAttributes(resultGeo) return resultGeo } // ============================================================================ // FACE-BASED GEOMETRY HELPERS (ported from prototype) // ============================================================================ type Insets = { iF?: number iB?: number iL?: number iR?: number dutchI?: number } type RawGeometryGroup = { start: number count: number materialIndex: number } function createGeometryFromRawAttributes( positions: number[], normals: number[], uvs: number[], groups: RawGeometryGroup[], ): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geometry.setIndex(Array.from({ length: positions.length / 3 }, (_, i) => i)) for (const group of groups) { geometry.addGroup(group.start, group.count, group.materialIndex) } ensureRenderableGeometryAttributes(geometry) return geometry } function mergeGeometriesPreservingGroups( geometries: THREE.BufferGeometry[], ): THREE.BufferGeometry | null { if (geometries.length === 0) return null const merged = mergeGeometries(geometries, false) if (!merged) return null merged.clearGroups() let indexStart = 0 for (const geometry of geometries) { if (geometry.groups.length > 0) { for (const group of geometry.groups) { merged.addGroup(indexStart + group.start, group.count, group.materialIndex ?? 0) } } else { const count = geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0 if (count > 0) { merged.addGroup(indexStart, count, 0) } } indexStart += geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0 } return merged } function collectGeometryPlanes(geometry: THREE.BufferGeometry): THREE.Plane[] { const source = geometry.index ? geometry.toNonIndexed() : geometry const position = source.getAttribute('position') as THREE.BufferAttribute | undefined const planes: THREE.Plane[] = [] const a = new THREE.Vector3() const b = new THREE.Vector3() const c = new THREE.Vector3() if (!position) return planes for (let i = 0; i + 2 < position.count; i += 3) { a.fromBufferAttribute(position, i) b.fromBufferAttribute(position, i + 1) c.fromBufferAttribute(position, i + 2) const plane = new THREE.Plane().setFromCoplanarPoints(a, b, c) if (Number.isFinite(plane.normal.x) && plane.normal.lengthSq() > 1e-10) { planes.push(plane.normalize()) } } if (source !== geometry) source.dispose() return planes } function splitShellByFacePlanes( geometry: THREE.BufferGeometry, planes: THREE.Plane[], materialIndex: number, ): THREE.BufferGeometry { if (planes.length === 0) return geometry const source = geometry.index ? geometry.toNonIndexed() : geometry.clone() const position = source.getAttribute('position') as THREE.BufferAttribute | undefined const normal = source.getAttribute('normal') as THREE.BufferAttribute | undefined const uv = source.getAttribute('uv') as THREE.BufferAttribute | undefined if (!position || !normal || !uv) { source.dispose() return geometry } const groups = source.groups.length > 0 ? source.groups : [{ start: 0, count: position.count, materialIndex }] const keptPositions: number[] = [] const keptNormals: number[] = [] const keptUvs: number[] = [] const keptGroups: RawGeometryGroup[] = [] let keptVertexCount = 0 const point = new THREE.Vector3() const triNormal = new THREE.Vector3() const pushTriangle = ( targetPositions: number[], targetNormals: number[], targetUvs: number[], targetGroups: RawGeometryGroup[], startVertex: number, groupMaterialIndex: number, vertexOffset: number, ) => { if ( targetGroups.length === 0 || targetGroups[targetGroups.length - 1]!.materialIndex !== groupMaterialIndex ) { targetGroups.push({ start: startVertex, count: 0, materialIndex: groupMaterialIndex }) } const targetGroup = targetGroups[targetGroups.length - 1]! for (let k = 0; k < 3; k += 1) { const vi = vertexOffset + k targetPositions.push(position.getX(vi), position.getY(vi), position.getZ(vi)) targetNormals.push(normal.getX(vi), normal.getY(vi), normal.getZ(vi)) targetUvs.push(uv.getX(vi), uv.getY(vi)) } targetGroup.count += 3 } const isPlaneMatch = (vertexOffset: number) => { if ( materialIndex >= 0 && triNormal.fromBufferAttribute(normal, vertexOffset).y <= SHINGLE_SURFACE_EPSILON ) { return false } triNormal.fromBufferAttribute(normal, vertexOffset).normalize() return planes.some((plane) => { if (Math.abs(triNormal.dot(plane.normal)) < 0.999) return false for (let k = 0; k < 3; k += 1) { point.fromBufferAttribute(position, vertexOffset + k) if (Math.abs(plane.distanceToPoint(point)) > 1e-3) return false } return true }) } for (const group of groups) { const groupStart = Math.max(0, group.start) const groupEnd = Math.min(position.count, group.start + group.count) for (let i = groupStart; i + 2 < groupEnd; i += 3) { if (group.materialIndex === materialIndex && isPlaneMatch(i)) { continue } pushTriangle( keptPositions, keptNormals, keptUvs, keptGroups, keptVertexCount, group.materialIndex ?? 0, i, ) keptVertexCount += 3 } } source.dispose() geometry.dispose() return createGeometryFromRawAttributes(keptPositions, keptNormals, keptUvs, keptGroups) } export function remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSegmentNode) { const position = geometry.getAttribute('position') const index = geometry.getIndex() if (!(position && index) || index.count === 0 || geometry.groups.length === 0) return geometry.computeBoundingBox() const triangleCount = index.count / 3 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 centroid = new THREE.Vector3() const normal = new THREE.Vector3() for (const group of geometry.groups) { const startTriangle = Math.floor(group.start / 3) const endTriangle = Math.min(triangleCount, Math.floor((group.start + group.count) / 3)) for (let triangleIndex = startTriangle; triangleIndex < endTriangle; triangleIndex++) { const indexOffset = triangleIndex * 3 let materialIndex = normalizeRoofMaterialIndex(group.materialIndex) if (materialIndex === 1 || materialIndex === 3) { const ia = index.getX(indexOffset) const ib = index.getX(indexOffset + 1) const ic = index.getX(indexOffset + 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).normalize() centroid .copy(a) .add(b) .add(c) .multiplyScalar(1 / 3) if (node.roofType === 'dutch' && Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON) { materialIndex = 3 } else if (normal.y > SHINGLE_SURFACE_EPSILON) { materialIndex = 3 } else if (isRakeFace(node, geometry, centroid, normal)) { materialIndex = 1 } else { materialIndex = 0 } } triangleMaterials[triangleIndex] = materialIndex } } geometry.clearGroups() let currentMaterial = triangleMaterials[0] ?? 0 let groupStart = 0 for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex++) { 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) } function isRakeFace( node: RoofSegmentNode, geometry: THREE.BufferGeometry, centroid: THREE.Vector3, normal: THREE.Vector3, ) { const rakeAxis = getRakeAxis(node) const bounds = geometry.boundingBox if (!(rakeAxis && bounds)) return false if (Math.abs(normal.y) > RAKE_FACE_NORMAL_EPSILON) return false const axisNormal = rakeAxis === 'x' ? Math.abs(normal.x) : Math.abs(normal.z) if (axisNormal < RAKE_FACE_ALIGNMENT_EPSILON) return false const halfExtent = rakeAxis === 'x' ? Math.max(Math.abs(bounds.min.x), Math.abs(bounds.max.x)) : Math.max(Math.abs(bounds.min.z), Math.abs(bounds.max.z)) const axisCoord = rakeAxis === 'x' ? Math.abs(centroid.x) : Math.abs(centroid.z) const planeTolerance = Math.max( node.overhang + node.wallThickness + node.deckThickness + node.shingleThickness, 0.25, ) if (halfExtent - axisCoord > planeTolerance) return false return true } function getRakeAxis(node: RoofSegmentNode): 'x' | 'z' | null { if (node.roofType === 'gable' || node.roofType === 'gambrel') return 'x' return null } type ShapeWidthRatios = { gambrelLowerWidthRatio: number mansardSteepWidthRatio: number dutchHipWidthRatio: number dutchHipHeightRatio: number dutchWaistLengthRatio: number dutchGabletRake: number } /** * Generates faces for a roof module volume. * Supports: hip, gable, shed, gambrel, dutch, mansard, flat. * * `shapeRatios` controls the kink positions on multi-slope roofs. The * height ratios are already baked into `tanTheta` (via the slope frame) * so they don't need to be threaded again. */ function getModuleFaces( type: RoofType, w: number, d: number, wh: number, rh: number, baseY: number, insets: Insets, baseW: number, baseD: number, tanTheta: number, shapeRatios: ShapeWidthRatios, dutchTopRakeThickness?: number, ): THREE.Vector3[][] { const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets const b1 = v(-w / 2 + iL, baseY, d / 2 - iF) const b2 = v(w / 2 - iR, baseY, d / 2 - iF) const b3 = v(w / 2 - iR, baseY, -d / 2 + iB) const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB) const bottom = [b4, b3, b2, b1] const e1 = v(-w / 2, wh, d / 2) const e2 = v(w / 2, wh, d / 2) const e3 = v(w / 2, wh, -d / 2) const e4 = v(-w / 2, wh, -d / 2) const faces: THREE.Vector3[][] = [] faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom) const h = wh + Math.max(0.001, rh) if (type === 'flat' || rh === 0) { faces.push([e1, e2, e3, e4]) } else if (type === 'gable') { const r1 = v(-w / 2, h, 0) const r2 = v(w / 2, h, 0) faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) } else if (type === 'hip') { if (Math.abs(w - d) < 0.01) { const r = v(0, h, 0) faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]) } else if (w >= d) { const r1 = v(-w / 2 + d / 2, h, 0) const r2 = v(w / 2 - d / 2, h, 0) faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) } else { const r1 = v(0, h, d / 2 - w / 2) const r2 = v(0, h, -d / 2 + w / 2) faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) } } else if (type === 'shed') { const t1 = v(-w / 2, h, -d / 2) const t2 = v(w / 2, h, -d / 2) faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]) } else if (type === 'gambrel') { const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio const dist = d / 2 - mz const mh = wh + dist * (tanTheta || 0) const m1 = v(-w / 2, mh, mz) const m2 = v(w / 2, mh, mz) const m3 = v(w / 2, mh, -mz) const m4 = v(-w / 2, mh, -mz) const r1 = v(-w / 2, h, 0) const r2 = v(w / 2, h, 0) faces.push( [e4, e1, m1, r1, m4], [e2, e3, m3, r2, m2], [e1, e2, m2, m1], [m1, m2, r2, r1], [e3, e4, m4, m3], [m3, m4, r1, r2], ) } else if (type === 'mansard') { const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio const mh = wh + i * (tanTheta || 0) const m1 = v(-w / 2 + i, mh, d / 2 - i) const m2 = v(w / 2 - i, mh, d / 2 - i) const m3 = v(w / 2 - i, mh, -d / 2 + i) const m4 = v(-w / 2 + i, mh, -d / 2 + i) const topW = w - i * 2 const topD = d - i * 2 faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4]) if (Math.abs(topW - topD) < 0.01) { const r = v(0, h, 0) faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r]) } else if (topW >= topD) { const r1 = v(-topW / 2 + topD / 2, h, 0) const r2 = v(topW / 2 - topD / 2, h, 0) faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]) } else { const r1 = v(0, h, topD / 2 - topW / 2) const r2 = v(0, h, -topD / 2 + topW / 2) faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]) } } else if (type === 'dutch') { const dutch = getDutchRoofShapeMetrics({ w, d, wh, rh, dutchI: insets.dutchI, baseW, baseD, shapeRatios, }) if (!dutch) return faces if (dutch.axis === 'width') { const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) const r1 = v(-dutch.innerWaistHalfX, h, 0) const r2 = v(dutch.innerWaistHalfX, h, 0) const endSlopes = getDutchEndSlopeFaces({ w, d, wh, rh, insets, baseW, baseD, shapeRatios, dutchTopRakeThickness, }).map((face) => face.map((point) => v(point.x, point.y, point.z))) faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3]) if (endSlopes.length === 2) { faces.push(...endSlopes) } else { faces.push([e2, e3, o3, o2], [e4, e1, o1, o4]) } faces.push([m1, m2, r2, r1], [m3, m4, r1, r2]) faces.push([m4, m1, r1], [m2, m3, r2]) } else { const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) const r1 = v(0, h, dutch.innerWaistHalfZ) const r2 = v(0, h, -dutch.innerWaistHalfZ) const endSlopes = getDutchEndSlopeFaces({ w, d, wh, rh, insets, baseW, baseD, shapeRatios, dutchTopRakeThickness, }).map((face) => face.map((point) => v(point.x, point.y, point.z))) faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4]) if (endSlopes.length === 2) { faces.push(...endSlopes) } else { faces.push([e1, e2, o2, o1], [e3, e4, o4, o3]) } faces.push([m2, m3, r2, r1], [m4, m1, r1, r2]) faces.push([m1, m2, r1], [m3, m4, r2]) } } return faces } function addDutchRakeBoard( apex: THREE.Vector3, base: THREE.Vector3, outward: THREE.Vector3, reach: number, thickness: number, topFaces: THREE.Vector3[][], sideFaces: THREE.Vector3[][], ) { if (!(reach > 0.001) || !(thickness > 0.0001)) return const apexOuter = apex.clone().addScaledVector(outward, reach) const baseOuter = base.clone().addScaledVector(outward, reach) const topPoly = [apex.clone(), base.clone(), baseOuter, apexOuter] const normal = new THREE.Vector3() .crossVectors( new THREE.Vector3().subVectors(topPoly[1]!, topPoly[0]!), new THREE.Vector3().subVectors(topPoly[2]!, topPoly[0]!), ) .normalize() if (normal.y < 0) { topPoly.reverse() normal.multiplyScalar(-1) } const top = topPoly.map((point) => point.clone().addScaledVector(normal, DUTCH_RAKE_SLOPE_SEAT_OFFSET), ) const bottom = top.map((point) => new THREE.Vector3(point.x, point.y - thickness, point.z)) pushDoubleSidedFace(topFaces, top) pushDoubleSidedFace(sideFaces, bottom.slice().reverse()) for (let i = 0; i < top.length; i += 1) { const next = (i + 1) % top.length pushDoubleSidedFace(sideFaces, [ top[i]!.clone(), top[next]!.clone(), bottom[next]!.clone(), bottom[i]!.clone(), ]) } } function buildDutchRakeBoards( W: number, D: number, wh: number, rh: number, i: number, shapeRatios: ShapeWidthRatios, rake: number, thickness: number, ): THREE.BufferGeometry | null { if (!(rake > 0.001) || !(thickness > 0.0001) || !(i > 0.001) || !(rh > 0.001)) { return null } const dutch = getDutchRoofShapeMetrics({ w: W, d: D, wh, rh, dutchI: i, baseW: W, baseD: D, shapeRatios: { ...shapeRatios, dutchGabletRake: rake, }, }) if (!dutch || !(dutch.rakeReach > 0.001)) return null const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) const topFaces: THREE.Vector3[][] = [] const sideFaces: THREE.Vector3[][] = [] if (dutch.axis === 'width') { addDutchRakeBoard( v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(-1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces, ) addDutchRakeBoard( v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(-1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces, ) addDutchRakeBoard( v(dutch.innerWaistHalfX, dutch.peakHeight, 0), v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces, ) addDutchRakeBoard( v(dutch.innerWaistHalfX, dutch.peakHeight, 0), v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces, ) } else { addDutchRakeBoard( v(0, dutch.peakHeight, dutch.innerWaistHalfZ), v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(0, 0, 1), dutch.rakeReach, thickness, topFaces, sideFaces, ) addDutchRakeBoard( v(0, dutch.peakHeight, dutch.innerWaistHalfZ), v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(0, 0, 1), dutch.rakeReach, thickness, topFaces, sideFaces, ) addDutchRakeBoard( v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(0, 0, -1), dutch.rakeReach, thickness, topFaces, sideFaces, ) addDutchRakeBoard( v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(0, 0, -1), dutch.rakeReach, thickness, topFaces, sideFaces, ) } const geometries: THREE.BufferGeometry[] = [] if (topFaces.length > 0) { geometries.push(createGeometryFromFaces(topFaces, DUTCH_RAKE_TOP_MATERIAL_INDEX)) } if (sideFaces.length > 0) { geometries.push(createGeometryFromFaces(sideFaces, DUTCH_RAKE_SIDE_MATERIAL_INDEX)) } if (geometries.length === 0) return null if (geometries.length === 1) return geometries[0]! const merged = mergeGeometriesPreservingGroups(geometries) for (const geometry of geometries) geometry.dispose() return merged } /** * Converts an array of face polygons into a BufferGeometry. * Each face is triangulated via fan triangulation. */ function createGeometryFromFaces( faces: THREE.Vector3[][], matRule: number | ((normal: THREE.Vector3) => number) | null = null, options?: { treatBidirectionalSlopeFacesAsSlope?: boolean }, ): THREE.BufferGeometry { const positions: number[] = [] const normals: number[] = [] const uvs: number[] = [] const indices: number[] = [] const groups: { start: number; count: number; materialIndex: number }[] = [] let vertexCount = 0 for (const face of faces) { if (face.length < 3) continue const p0 = face[0]! const p1 = face[1]! const p2 = face[2]! const vA = new THREE.Vector3().subVectors(p1, p0) const vB = new THREE.Vector3().subVectors(p2, p0) const normal = new THREE.Vector3().crossVectors(vA, vB).normalize() if (normal.lengthSq() < 1e-12) continue let slopeAlignedDown: THREE.Vector3 | null = null let slopeAlignedAcross: THREE.Vector3 | null = null let slopeAlignedVOrigin = 0 const slopeUvNormal = options?.treatBidirectionalSlopeFacesAsSlope && normal.y < 0 ? normal.clone().multiplyScalar(-1) : normal if (Math.abs(slopeUvNormal.y) > SHINGLE_SURFACE_EPSILON) { _uvDownSlope.copy(_uvWorldDown).projectOnPlane(slopeUvNormal) if (_uvDownSlope.lengthSq() > 1e-8) { _uvDownSlope.normalize() _uvAcrossSlope.crossVectors(_uvDownSlope, slopeUvNormal).normalize() let highestPoint = face[0]! for (const candidate of face) { if (candidate.y > highestPoint.y) { highestPoint = candidate } } slopeAlignedDown = _uvDownSlope.clone() slopeAlignedAcross = _uvAcrossSlope.clone() slopeAlignedVOrigin = highestPoint.dot(slopeAlignedDown) } } let assignedMatIndex = 0 if (typeof matRule === 'function') { assignedMatIndex = matRule(normal) } else if (matRule !== null && matRule !== undefined) { assignedMatIndex = matRule } else { const isVertical = Math.abs(normal.y) < 0.01 assignedMatIndex = isVertical ? 0 : 1 } let faceVertexCount = 0 const startVertexCount = vertexCount for (let i = 1; i < face.length - 1; i++) { const fi = face[i]! const fi1 = face[i + 1]! positions.push(p0.x, p0.y, p0.z) positions.push(fi.x, fi.y, fi.z) positions.push(fi1.x, fi1.y, fi1.z) normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z) if (slopeAlignedDown && slopeAlignedAcross) { uvs.push(p0.dot(slopeAlignedAcross), slopeAlignedVOrigin - p0.dot(slopeAlignedDown)) uvs.push(fi.dot(slopeAlignedAcross), slopeAlignedVOrigin - fi.dot(slopeAlignedDown)) uvs.push(fi1.dot(slopeAlignedAcross), slopeAlignedVOrigin - fi1.dot(slopeAlignedDown)) } else { pushRoofUv(uvs, p0, normal) pushRoofUv(uvs, fi, normal) pushRoofUv(uvs, fi1, normal) } indices.push(vertexCount, vertexCount + 1, vertexCount + 2) faceVertexCount += 3 vertexCount += 3 } groups.push({ start: startVertexCount, count: faceVertexCount, materialIndex: assignedMatIndex, }) } const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geometry.setIndex(indices) for (const g of groups) { geometry.addGroup(g.start, g.count, g.materialIndex) } // Merge identical vertices to optimize geometry for CSG and create clean topology const mergedGeo = mergeVertices(geometry, 1e-4) geometry.dispose() ensureRenderableGeometryAttributes(mergedGeo) return mergedGeo } function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) { // Project in WORLD space (via the current segment's world transform) so // vertical gable faces tile identically to the walls below; for a local // build the matrix is identity and this is the original behaviour. const p = _uvWorldPoint.copy(point).applyMatrix4(_segmentUvMatrix) _uvFaceNormal.copy(normal).applyMatrix3(_segmentUvNormalMatrix).normalize() _uvWorldNormal.copy(_uvFaceNormal) const absX = Math.abs(_uvFaceNormal.x) const absY = Math.abs(_uvFaceNormal.y) const absZ = Math.abs(_uvFaceNormal.z) if (absY >= absX && absY >= absZ) { uvs.push(p.x, p.z) return } if (_uvFaceNormal.y > SHINGLE_SURFACE_EPSILON) { _uvDownSlope.copy(_uvWorldDown).projectOnPlane(_uvFaceNormal) if (_uvDownSlope.lengthSq() > 1e-8) { _uvDownSlope.normalize() _uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize() uvs.push(p.dot(_uvAcrossSlope), p.dot(_uvDownSlope)) return } } // Vertical (gable wall) faces: U = ±worldX/Z (axis across the face normal), // V = 1 - worldY — the same world-space projection the wall kind uses. const wallV = 1 - p.y if (absX >= absZ) { uvs.push(_uvWorldNormal.x >= 0 ? p.z : -p.z, wallV) return } uvs.push(_uvWorldNormal.z >= 0 ? p.x : -p.x, wallV) } // ─── Skylight cutout ───────────────────────────────────────────────── export type SurfaceFrame = { point: THREE.Vector3 normal: THREE.Vector3 } /** * Returns the outer roof surface frame (point + normal) at a given segment-local XZ. * This is used for skylight placement + cut direction so cutouts remain perpendicular * to the true roof surface even on multi-slope roofs (gambrel/mansard/dutch). */ export function getRoofOuterSurfaceFrameAtPoint( segment: RoofSegmentNode, lx: number, lz: number, ): SurfaceFrame { const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang, shingleThickness, } = segment const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) if (roofType === 'flat' || activeRh === 0) { return { point: new THREE.Vector3(lx, wallHeight + deckThickness + shingleThickness, lz), normal: new THREE.Vector3(0, 1, 0), } } const verticalRt = deckThickness / cosTheta const horizontalOverhang = overhang * cosTheta const deckExt = wallThickness / 2 + horizontalOverhang const stSin = shingleThickness * sinTheta const stCos = shingleThickness * cosTheta const shinBotW = Math.max(0.01, width + 2 * deckExt) const shinBotD = Math.max(0.01, depth + 2 * deckExt) const deckDrop = deckExt * tanTheta const shinBotWh = wallHeight - deckDrop + verticalRt let shinBotRh = activeRh if (activeRh > 0) { shinBotRh = activeRh + deckDrop if (roofType === 'shed') shinBotRh = activeRh + 2 * deckDrop } let shinTopW = shinBotW let shinTopD = shinBotD let transZ = 0 if (['hip', 'mansard', 'dutch'].includes(roofType)) { shinTopW += 2 * stSin shinTopD += 2 * stSin } else { shinTopW += 2 * stSin shinTopD += 2 * stSin transZ = stSin } const shinTopWh = shinBotWh + stCos const shinTopRh = shinBotRh + stCos const topBaseY = 0 const baseI = Math.min(width, depth) * 0.25 // Dutch gablet waist tracks dutchHipWidthRatio (see getRoofSegmentBrushes); // the generic baseI above still drives the bottom-rect insets for other types. const dutchBaseI = Math.min(width, depth) * segment.dutchHipWidthRatio const getInsets = ( _wh: number, _baseY: number, isVoid: boolean, _wV: number, _dV: number, ): Insets => { const inset = Math.max(0.01, baseI) let iF = 0 let iB = 0 let iL = 0 let iR = 0 if (roofType === 'hip') { iF = inset iB = inset iL = inset iR = inset } else if (roofType === 'gable' || roofType === 'gambrel') { iL = inset iR = inset } else if (roofType === 'mansard' || roofType === 'dutch') { iF = inset iB = inset iL = inset iR = inset } else if (roofType === 'shed') { iF = inset } let structuralI = dutchBaseI if (isVoid) { structuralI += shingleThickness } return { iF, iB, iL, iR, dutchI: structuralI } } const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD) const shapeRatios: ShapeWidthRatios = { gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio, mansardSteepWidthRatio: segment.mansardSteepWidthRatio, dutchHipWidthRatio: segment.dutchHipWidthRatio, dutchHipHeightRatio: segment.dutchHipHeightRatio, dutchWaistLengthRatio: segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, dutchGabletRake: segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, } const topFaces = getModuleFaces( roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta, shapeRatios, segment.dutchTopRakeThickness, ) const topGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, ) if (transZ !== 0) topGeo.translate(0, 0, transZ) topGeo.computeBoundingBox() const topY = wallHeight + activeRh + deckThickness + shingleThickness + 10 _surfaceOrigin.set(lx, topY, lz) _surfaceRay.set(_surfaceOrigin, _surfaceDir) _surfaceHits.length = 0 const pos = topGeo.getAttribute('position') const index = topGeo.getIndex() if (!pos || !index) { topGeo.dispose() return { point: new THREE.Vector3(lx, wallHeight, lz), normal: new THREE.Vector3(0, 1, 0), } } let bestT = Number.POSITIVE_INFINITY let bestPoint: THREE.Vector3 | null = null let bestNormal: THREE.Vector3 | null = null for (let i = 0; i < index.count; i += 3) { const a = index.getX(i) const b = index.getX(i + 1) const c = index.getX(i + 2) _surfaceV0.fromBufferAttribute(pos as any, a) _surfaceV1.fromBufferAttribute(pos as any, b) _surfaceV2.fromBufferAttribute(pos as any, c) const hit = _surfaceRay.intersectTriangle(_surfaceV0, _surfaceV1, _surfaceV2, false, _tmpVec3A) if (!hit) continue const t = hit.distanceTo(_surfaceOrigin) if (t < bestT) { bestT = t bestPoint = hit.clone() _surfaceFaceNormal .subVectors(_surfaceV1, _surfaceV0) .cross(_tmpVec3B.subVectors(_surfaceV2, _surfaceV0)) .normalize() bestNormal = _surfaceFaceNormal.clone() } } topGeo.dispose() if (!bestPoint || !bestNormal) { return { point: new THREE.Vector3(lx, wallHeight, lz), normal: new THREE.Vector3(0, 1, 0), } } if (bestNormal.y < 0) bestNormal.multiplyScalar(-1) return { point: bestPoint, normal: bestNormal } }