Files
editor/packages/viewer/src/systems/roof/roof-system.tsx
T
c45808840c nodes: add modular cabinets and wall trim controls (#461)
* 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

* Add modular cabinet node

* Add modular cabinet run and tall cabinet workflows

* Add cabinet paint slots and material groups

* Improve modular cabinet presets and wall snapping

* Improve cabinet wall snapping

* Fix cabinet wall snapping and countertop joins

* Improve cabinet appliance compartments

* Improve fridge visuals and smooth cabinet animations

Fridge exterior is now brushed silver with brass accents and the
interior is all white. Door/drawer animation no longer rebuilds the
full cabinet geometry per frame — animated parts carry pose metadata
that a new per-frame cabinet system applies directly to transforms.

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

* Improve gas hob flames and cooktop knob controls

Port the curved-flame look from reference gas-burner photos: each burner
gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips)
whose spines breathe and flicker per frame at ~30fps, plus a flat ignition
glow and a faded heat halo. Fix the knob pointer notch to rotate with the
knob instead of drifting sideways. Also includes cooktop compartment
presets/panels and cabinet selection/move affordance work.

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

* Fix cabinet UVs and registry actions

* Fix registry move snap typecheck

* Fix cabinet knob animation concurrency

* Fix cabinet material disposal, undo flood, and 2D move parity

- Flag shared cabinet appliance materials (and the viewer's cached material
  factories) as cached so geometry rebuilds no longer dispose materials
  still referenced by other nodes, forcing scene-wide shader recompiles.
- Route the door/drawer open animation through useLiveNodeOverrides with a
  single final commit, so one play is one undo step instead of ~20.
- Add a cabinet-module floorplanMoveTarget: 2D drags now convert through
  planToLocal + magneticSnap like the 3D path, instead of writing plan
  coords into the run-local position (teleporting modules on rotated runs).
- Re-key sibling runs (cabinetAdjacencyRevision) when a run's
  neighbor-affecting inputs change, so countertop overhang joins re-trim
  when a neighbor moves/resizes/deletes.
- Narrow the cabinet panel's scene subscriptions (useShallow module
  selector) so it stops re-rendering on every scene mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restructure cabinet schema and centralize run math/mutations

Prepares the cabinet node for the queued specialty units (corner L-shape,
sink base, appliance gap, open shelving):

- Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode
  so new unit types extend an enum instead of overloading the stack, and
  split CabinetCompartment into a z.discriminatedUnion so invalid field
  combos (drawer with rack style, fridge with burner state) are
  unrepresentable. Shared box fields now come from one `cabinetBoxFields`
  object so run/module schemas can't drift.
- Extract the straight-line run assumption (sort-by-x, edges, adjacency,
  spans, side-insert, reflow, frame transforms) into run-layout.ts —
  previously spread across definition/geometry/quick-actions/stack/
  move-frame, so corner support would have meant five parallel edits.
- Consolidate the run mutations (add module, wall cabinet/hood above,
  base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi.
  Panel and quick-actions had drifted copies: the panel's add-module
  skipped gap checks, revision-bump scope differed per surface.
- Remove dead code: node-level doorStyle (with a migrateNodes entry —
  geometry only ever read per-compartment doorType), the unreachable
  slot-handle mesh block, the no-op handlePosition 'edge' enum value,
  wallLocalX, and duplicate totalCabinetHeight definitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add cabinet snap tests and split the cabinet panel

- New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold,
  Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset /
  collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss
  fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71.
- De-brittle geometry test lookups: coordinate-encoded mesh names are now
  matched by pattern, so dimension-default changes don't break them.
- Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants
  into compartment-card.tsx, CabinetRunPanel + reflow wiring into
  run-panel.tsx, and the compartment type-transition tables into a pure
  resolveCompartmentTransition in stack-transitions.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split cabinet geometry.ts into per-appliance modules

Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator
dispatching into geometry/ — shared helpers + cached appliance materials
(shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans +
countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/
oven-microwave/pantry builders. No mesh names, userData stamps, materials,
or math changed; the './geometry' import path is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add undermount sink compartment with CSG-cut countertops

New 'sink' compartment type (single / double / 60-40 bowl layouts)
following the cooktop pattern: zero stack height, countertop-plane
geometry, panel card with a bowl-layout selector, and a Sink Base
preset. Bowl openings are subtracted from both module- and run-owned
countertop slabs via three-bvh-csg; the basin shells, drain plumbing,
and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin
lever) render beneath the cut. Sink modules skip the carcass top panel
and the deck under the sink row so the basin hangs through.

Also scope the viewer mock in roof-surface-placement-guides.test.ts to
only stub useViewer — the bare module stub leaked into later suites and
broke real CSG imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update ifc-converter next-env reference to build-mode route types

Regenerated by `next typegen` during check-types; points at
.next/types instead of the dev-mode .next/dev/types path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Draw cabinet floor plans with kitchen drafting symbols

Replace the per-module box soup with NKBA/architectural-convention
symbols: the run draws one heavier countertop outline per span
(extended by the overhang), base modules draw their front edge plus
compartment symbols — rounded bowl rects + faucet dot for sinks,
burner/zone rings for cooktops (reusing the 3D layout tables so 2D and
3D always agree) — and appliance modules carry standard upright labels
(DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules
draw as dashed open outlines per the above-cut-plane convention.

* Play cabinet open/close animation on the E interaction key

Add an `e` slot to registry KeyboardActions and dispatch it in the
keyboard hook ahead of the legacy door/window arms, so kinds opt into
the E interaction on their NodeDefinition. Cabinets register it: E on a
module eases its doors/drawers open or closed, E on a run swings every
child module together, and hood-only modules fall through. The panel's
rAF animator moves into cabinet/interaction.ts (live-override frames,
single undo commit) so the Play button and E share one animation, and
the shortcuts dialog now documents E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add kitchen island and bar counter support to cabinet runs

Islands: run-level countertopBackOverhang (seating side) and
withFinishedBack (decorative back panel), plus an I-key island placement
mode in the cabinet tool that skips wall snap. Bar counters: optional
barLedge {edge, height, depth} drawing a knee wall and raised slab along
the back or either run end, superseding the seating overhang on its
edge. withWaterfall drops slab-material panels to the floor on exposed
run ends. 2D floorplan outlines, selection bounds, geometry keys, and
run panel controls updated in parity; schemaVersion 3 -> 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Merge quick-action arrow and plus into one directional add glyph

The add-left/add-right buttons showed two unrelated icons; a single
plus-on-tail arrow reads as "add on this side".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extend plugin discovery instead of replacing the host source

Also track the ifc-converter next-env routes reference update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add L-corner runs to modular cabinets with run-scoped grouping

An "L Left/Right" quick action spawns a perpendicular base leg, wall leg,
and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the
run stays the single group: selecting a base module selects only it, and
the clicked module never becomes a container. Corner runs re-anchor when
the source module resizes or moves (new MovableParentFrame.onCommit hook,
mirrored in the 2D floorplan commit), run bounds fold in child leg runs,
and deleting one corner member removes only that node while unlink patches
keep the survivors' link metadata consistent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Delete emptied cabinet run groups in the same delete gesture

Removing a run's last child (module or derived corner leg) left an
empty group node in the scene graph and persisted data. onDeleteCascade
now receives the gesture's pending delete ids so multi-select deletes
count siblings as gone, and both cabinet kinds cascade the orphaned
parent run away in one undo step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove filler front gap treatment

* Apply architecture review fixes across cabinet, plugin-trees, and framework

Move cabinet semantics out of packages/editor behind registry capabilities
(def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons,
catalog tool field), route cabinet selection through a core selection-proxy
helper, and make PanelWorkspace an opaque host tag. Fix the cabinet
placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable
slots, plugin-trees ghost layers/stale level transforms/untyped find-sync
event, and cross-file store-mock pollution plus orphaned dist tests that
broke combined test runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Propagate front styling to L-corner runs even when re-layout bails

Group style changes rode entirely on syncDerivedCornerRun, which bails
silently when a later-drawn wall blocks the corner layout or a leg run
gained modules outside the derived-run spec — leaving the L legs styled
stale. Apply the style patch directly to every linked corner run before
attempting the geometric re-layout.

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

* Improve editor viewer integration

* Refactor plugin panels and floorplan invalidation

* UV-unwrap and material-assign roof-segment trim planes

* Fix cabinet placement snap feedback

* Add cabinet wall snap bypass

* Fix cabinet continuous placement flow

* Implement wall band material slots

* Add corner addition preview functionality and related tests

* Fix cabinet and wall band behavior

* Fix architecture review blockers

* Fix post-merge quality check

* fix cursor review feedback

* Fix cabinet placement type and quick action history

* Improve cabinet placement feedback

* Make wall bands configurable

* Add wall trim profiles and default materials

* Fix architecture review issues

* Fix cabinet rotation and wall band visibility

* Fix sink appliance paint persistence

* Bake cabinet animations and preserve wall band material

* Fix cabinet group duplication and module snapping

* Fix cabinet duplication architecture

* Fix cabinet floorplan test and make placement-type hint a live chip

- floorplan.test.ts: pass the required liveTransforms map to
  cabinetFloorplanSiblingOverrides (the runtime callers always supply it)
- ToolHint gains an optional generic `chip` contract (subscribe/value/
  cycle/labels) so a kind can render a tool hint as a live mode chip —
  like the snapping/continuation chips — without the editor knowing the
  kind's store
- cabinet: the I hint now shows the current value (Type: Cabinet /
  Type: Island) and is clickable to toggle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-07-12 18:33:48 -04:00

2585 lines
84 KiB
TypeScript

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