import { type AnyNode, type AnyNodeId, getEffectiveNode, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, 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 { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' 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 Y of the segment whose geometry is currently being built. Vertical // (gable wall) faces tile their V in WORLD space (`V = 1 - worldY`) so the band // lines up with the wall below — which THREE's ExtrudeGeometry UVs map as // `1 - height` from the wall base, i.e. `1 - worldY` for a ground-floor wall. // Set around each segment build via `withSegmentUvWorldY`, otherwise 0. let _segmentUvWorldY = 0 function withSegmentUvWorldY(worldY: number, build: () => T): T { const previous = _segmentUvWorldY _segmentUvWorldY = worldY try { return build() } finally { _segmentUvWorldY = previous } } 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() // 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() 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 // Roof accessories (chimney, skylight, solar-panel, dormer, // ridge-vent, box-vent — anything declaring // `capabilities.roofAccessory` on its NodeDefinition) cascade // their dirty mark to the host segment's parent roof so the // merged shell re-CSGs with the new cut. Without this, moving / // resizing an accessory leaves the merged roof showing the // previous cut shape (stale CSG) once the user exits segment // edit mode. Registry-driven so the viewer stays kind-agnostic. 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. if ( def?.capabilities?.roofAccessory && !def.capabilities.roofAccessory.dirtyHandledByOwnSystem ) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined if (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() const placeholder = new THREE.BufferGeometry() // Three zero-vertices (one degenerate, 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. 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) mesh.geometry = placeholder } 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 || 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: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush }, 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 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() // Keep a valid position attribute so Drei's BVH can index safely. mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0) return } let totalShinSlab: Brush | null = null let totalDeckSlab: Brush | null = null let totalWall: Brush | null = null let totalInner: Brush | null = null for (const child of children) { const brushes = withSegmentUvWorldY(roofNode.position[1] + child.position[1], () => getRoofSegmentBrushes(child), ) if (!brushes) continue subtractAccessoryCuts(brushes, child, nodes) _matrix.compose( _position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale, ) const applyTransform = (brush: Brush) => { csgGeometry(brush).applyMatrix4(_matrix) brush.updateMatrixWorld() } applyTransform(brushes.shinSlab) applyTransform(brushes.deckSlab) applyTransform(brushes.wallBrush) applyTransform(brushes.innerBrush) 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 finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION) prepareBrushForCSG(finalShinTrimmed) const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION) prepareBrushForCSG(finalDeckTrimmed) const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) prepareBrushForCSG(finalWallTrimmed) const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION) prepareBrushForCSG(shinDeck) const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) prepareBrushForCSG(combined) const resultGeo = csgGeometry(combined) if (geometryHasNaNPositions(resultGeo)) { if (!warnedMergedRoofNaNIds.has(roofNode.id)) { console.warn('[RoofSystem] Skipping merged roof geometry with NaN positions', roofNode.id) warnedMergedRoofNaNIds.add(roofNode.id) } resultGeo.dispose() finalShinTrimmed.geometry.dispose() finalDeckTrimmed.geometry.dispose() finalWallTrimmed.geometry.dispose() shinDeck.geometry.dispose() totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() totalWall.geometry.dispose() totalInner.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) } resultGeo.computeVertexNormals() ensureRenderableGeometryAttributes(resultGeo) mergedMesh.geometry.dispose() mergedMesh.geometry = resultGeo finalShinTrimmed.geometry.dispose() finalDeckTrimmed.geometry.dispose() 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() } } function geometryHasNaNPositions(geometry: THREE.BufferGeometry) { const position = geometry.getAttribute('position') if (!position) return false for (let i = 0; i < position.array.length; i++) { if (Number.isNaN(position.array[i])) 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 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 } const SHINGLE_SURFACE_EPSILON = 0.02 const RAKE_FACE_NORMAL_EPSILON = 0.3 const RAKE_FACE_ALIGNMENT_EPSILON = 0.35 /** * Generate complete hollow-shell geometry for a roof segment. * Ports the prototype's CSG approach using three-bvh-csg. */ export function getRoofSegmentBrushes( node: RoofSegmentNode, ): { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush } | null { const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang, shingleThickness, } = node const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) const shapeRatios: ShapeWidthRatios = { gambrelLowerWidthRatio: node.gambrelLowerWidthRatio, mansardSteepWidthRatio: node.mansardSteepWidthRatio, dutchHipWidthRatio: node.dutchHipWidthRatio, } const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness const baseI = Math.min(width, depth) * 0.25 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 = getModuleFaces( roofType, wV, dV, whV, rhV, safeBaseY, { dutchI: structuralI }, width, depth, tanTheta, shapeRatios, ) 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 getInsets = (wh: number, bY: number, isVoid: boolean, brushW: number, brushD: number) => { let inset = (wh - bY) * tanTheta const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005 if (inset > maxSafeInset) { inset = maxSafeInset } let iF = 0, iB = 0, iL = 0, iR = 0 if (['hip', 'mansard', 'dutch'].includes(roofType)) { iF = inset iB = inset iL = inset iR = inset } else if (['gable', 'gambrel'].includes(roofType)) { iF = inset iB = inset } else if (roofType === 'shed') { iF = inset } let structuralI = baseI if (isVoid) { structuralI += shingleThickness } return { iF, iB, iL, iR, dutchI: structuralI } } const insetsBot = getInsets(shinBotWh, botBaseY, true, shinBotW, shinBotD) const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD) const botFaces = getModuleFaces( roofType, shinBotW, shinBotD, shinBotWh, shinBotRh, botBaseY, insetsBot, width, depth, tanTheta, shapeRatios, ) const topFaces = getModuleFaces( roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta, shapeRatios, ) const shinBotGeo = createGeometryFromFaces(botFaces, 1) const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, ) if (transZ !== 0) { shinTopGeo.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) 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() return { deckSlab, shinSlab, wallBrush, innerBrush } } 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() return null } export function generateRoofSegmentGeometry( node: RoofSegmentNode, nodes?: Record, ): THREE.BufferGeometry { const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined const parentRoofWorldY = parentRoof && 'position' in parentRoof ? ((parentRoof.position as number[])[1] ?? 0) : 0 const brushes = withSegmentUvWorldY(parentRoofWorldY + node.position[1], () => 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 } = 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) 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() 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 } 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 (normal.y > SHINGLE_SURFACE_EPSILON) { materialIndex = 3 } else if (isRakeFace(node, geometry, centroid, normal)) { materialIndex = 0 } else { materialIndex = 1 } } 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' if (node.roofType === 'dutch') return node.width >= node.depth ? 'x' : 'z' return null } type ShapeWidthRatios = { gambrelLowerWidthRatio: number mansardSteepWidthRatio: number dutchHipWidthRatio: 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, ): 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 t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2) const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2) const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2) const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2) if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) { 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 { faces.push( [t1, t2, t3, t4], [e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m1, m2, t2, t1], [m2, m3, t3, t2], [m3, m4, t4, t3], [m4, m1, t1, t4], ) } } else if (type === 'dutch') { const i = insets.dutchI !== undefined ? insets.dutchI : Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio const mh = wh + i * (tanTheta || 0) if (w >= d) { 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 r1 = v(-w / 2 + i, h, 0) const r2 = v(w / 2 - i, h, 0) faces.push( [e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2], ) } else { 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 r1 = v(0, h, d / 2 - i) const r2 = v(0, h, -d / 2 + i) faces.push( [e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2], ) } } return faces } /** * 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, ): 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() let slopeAlignedDown: THREE.Vector3 | null = null let slopeAlignedAcross: THREE.Vector3 | null = null let slopeAlignedVOrigin = 0 if (normal.y > SHINGLE_SURFACE_EPSILON) { _uvDownSlope.copy(_uvWorldDown).projectOnPlane(normal) if (_uvDownSlope.lengthSq() > 1e-8) { _uvDownSlope.normalize() _uvAcrossSlope.crossVectors(_uvDownSlope, normal).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) { _uvFaceNormal.copy(normal).normalize() 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(point.x, point.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(point.dot(_uvAcrossSlope), point.dot(_uvDownSlope)) return } } // Vertical (gable wall) faces: V tiles in world space so the band aligns with // the wall below (see `_segmentUvWorldY`). U stays in the face's local run. const wallV = 1 - (point.y + _segmentUvWorldY) if (absX >= absZ) { uvs.push(_uvFaceNormal.x >= 0 ? point.z : -point.z, wallV) return } uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.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 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 = baseI 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, } const topFaces = getModuleFaces( roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta, shapeRatios, ) 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 } }