The gable wall band tiled its V in segment-local space, so its texture phase didn't line up with the wall below (whose ExtrudeGeometry UVs map V = 1 - height-from-base). Make vertical roof faces tile V in world space (V = 1 - worldY) via the segment's resolved world Y (parent roof + segment position), so the band matches a ground-floor wall's vertical tiling at the eave seam. U is unchanged (stays in the face's local run). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1566 lines
50 KiB
TypeScript
1566 lines
50 KiB
TypeScript
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<T>(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<AnyNodeId>()
|
|
const warnedMergedRoofNaNIds = new Set<AnyNodeId>()
|
|
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<string, AnyNode>,
|
|
) {
|
|
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<string, AnyNode>,
|
|
) {
|
|
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<string, unknown>)
|
|
: 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<string, AnyNode>,
|
|
) {
|
|
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<THREE.Material, number>([
|
|
[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<THREE.Material, number>,
|
|
): 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<string, AnyNode>,
|
|
): 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<THREE.Material, number>([
|
|
[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<number>(triangleCount).fill(0)
|
|
const a = new THREE.Vector3()
|
|
const b = new THREE.Vector3()
|
|
const c = new THREE.Vector3()
|
|
const ab = new THREE.Vector3()
|
|
const ac = new THREE.Vector3()
|
|
const 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 }
|
|
}
|