* 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>
2585 lines
84 KiB
TypeScript
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 }
|
|
}
|