Files
editor/packages/viewer/src/systems/roof/roof-system.tsx
T
ce6f999310 arch: enforce layer boundaries — ceiling dispatch, store relocation, shared helper (#382)
* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* editor: per-door-type floor-plan symbols

Render a distinct, static plan symbol for each door type in the
registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`),
independent of the door's live open/close animation:

- single / hinged: fixed 90° swing with a dashed quarter-circle arc
- double / french: two mirrored half-width leaves + dashed arcs
- folding / bifold: static zigzag accordion (~80% span) on the wall face
- sliding: bypass — two overlapping panels on parallel tracks + arrow
- pocket: thin white leaf, ~60% closed, sliding into the solid wall
- barn: surface-mounted panel parked over the wall, dashed closed-ghost
  + slide arrow

The swing arc is dashed in screen-pixel units (the renderer uses
non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection
/ slideDirection as appropriate.

Also includes pre-existing working-tree changes unrelated to the door
symbols: group move/rotate transform and box-select tweaks, and a
regenerated ifc-converter next-env.d.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix recessed ceiling fixtures and draw safety

* feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D)

Snap the wall draft / endpoint-move point onto existing wall geometry —
corners, midpoints, wall–wall intersections, and along-wall edges — and
show a beacon at the snap point whose glyph encodes what it caught
(square = corner, triangle = midpoint, ✕ = intersection, circle = edge).

- Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested).
- Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon
  and a 2D SVG glyph beacon, both indigo to match the alignment guides.
- Gated by a new persisted "Magnetic snap" toggle in the Display menu
  (useEditor); honored by draw + commit + endpoint-move in both views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* editor: garage and open-doorway floor-plan symbols

Extend the per-door-type plan symbols in the registry floor-plan
builder (packages/nodes/src/door/floorplan.ts):

- open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel
  (mirrors the 3D system, which renders only the cutout for openings)
- garage sectional: closed leaf + side tracks into the garage + dashed
  parked ghost at the inner end
- garage roll-up: closed leaf + coil barrel (capsule) with a coil hint
- garage tilt-up: closed leaf + dashed parked panel + dashed curved
  up-and-over swing path
- gate the swing arc to actual swing doors (hinged/double/french) so
  other types fall back to the plain footprint

Garage mechanisms sit on the interior (door-local -z) side to match the
3D garage builders, independent of swingDirection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* arch: enforce layer boundaries — registry dispatch, store relocation, shared helper

Three architectural fixes to bring the branch into full compliance:

1. **ceiling-system kind check → CeilingCutCapability**
   Replace `child.type === 'item'` branch in `ceiling-system` with registry
   dispatch. Add `CeilingCutCapability` type to `packages/core` registry types,
   implement `buildCeilingHole` on `itemDefinition`, and rewrite
   `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through
   `nodeRegistry` — viewer never again inspects a node's kind directly.

2. **useAlignmentGuides + useWallSnapIndicator → packages/editor**
   These stores are editor-only UI (snap beacons, alignment guides). Move them
   from `packages/core/src/store/` to `packages/editor/src/store/`, re-export
   from `packages/editor`, and update all 34 consumer files across
   `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`.

3. **findLevelAncestorId extracted to core**
   `item-light-system` had a private `resolveNodeLevelId` that duplicated
   level-ancestor traversal logic. Extract it as `findLevelAncestorId` in
   `packages/core` (spatial-grid-sync), export it, and replace the local copy.

All four packages typecheck cleanly (zero errors).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: fix lint, untrack .claude/launch.json

Run bun check --write to clear 8 Biome errors (formatting + import order
+ one unused import). Untrack .claude/launch.json and add it plus
.claude/settings.local.json to .gitignore so local IDE/agent configs
stop landing in commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 13:14:39 -04:00

1478 lines
47 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'
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']
function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
;(geometry as any).computeBoundsTree = computeBoundsTree
;(geometry as any).computeBoundsTree({ maxLeafSize: 10 })
}
function prepareBrushForCSG(brush: Brush) {
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()
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)
if (def?.capabilities?.roofAccessory) {
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
if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
updateRoofSegmentGeometry(effectiveSegment, mesh)
segmentsProcessed++
} else if (isVisible) {
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),
)
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) {
const newGeo = generateRoofSegmentGeometry(node)
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
}
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 = getRoofSegmentBrushes(child)
if (!brushes) continue
// Per-child cuts in SEGMENT-LOCAL space: subtract every accessory
// that contributes a cut (declares
// `capabilities.roofAccessory.buildCut`) from shin / deck / wall
// before we accumulate. Mirrors roof-system v1 — the cut is built
// in segment-local, then carved out before the segment transform
// stacks on. Registry-driven so the viewer never names a kind.
let workingShin = brushes.shinSlab
let workingDeck = brushes.deckSlab
let workingWall = brushes.wallBrush
for (const childElemId of child.children ?? []) {
const childElem = nodes[childElemId as AnyNodeId]
if (!childElem) continue
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, child)
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()
computeGeometryBoundsTree(welded)
const cut = new Brush(welded, dummyMats[0])
cut.updateMatrixWorld()
try {
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
_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)
const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION)
const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION)
const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION)
const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION)
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()
ensureUv2Attribute(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
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)
const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION)
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): THREE.BufferGeometry {
const brushes = getRoofSegmentBrushes(node)
if (!brushes) {
// Fallback: simple box
return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth)
}
const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes
let resultGeo = new THREE.BufferGeometry()
try {
const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION)
const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION)
const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION)
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()
ensureUv2Attribute(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()
ensureUv2Attribute(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
}
}
if (absX >= absZ) {
uvs.push(_uvFaceNormal.x >= 0 ? point.z : -point.z, -point.y)
return
}
uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, -point.y)
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
// ─── 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 }
}