src/components/building-viewer/stage.tsx'use client'import { useEffect, useMemo, useRef } from 'react'import { Canvas, useFrame } from '@react-three/fiber'import {OrbitControls,Environment,Lightformer,Html,Line,} from '@react-three/drei'import * as THREE from 'three'/*** Annotated 3D model of a pre-engineered metal building: gable roof, red wall* panels, an open framed opening, and leader-line callouts for the key* components (ridge cap, eave/base closures, trims, soffit, structure). Drag to* orbit; idles with a slow auto-rotate when not hovered.*/// --- Building dimensions (world units) ---const HX = 17 // half length (X, ridge runs along X)const HZ = 10 // half span (Z, gable ends face ±X)const EY = 8 // eave heightconst RR = 1.8 // ridge rise above the eaveconst RIDGE_Y = EY + RRconst WALL_T = 0.3 // wall thicknessconst RAKE = 0.7 // roof overhang past the wallsconst RIB_STEP = 1.5 // spacing between vertical panel ribsconst RIB_W = 0.16 // rib widthconst RIB_OUT = 0.22 // how far a rib stands proud of the wall/** Height of the gable wall top at a given span position (roofline). */function gableTopY(z: number): number {return EY + RR * (1 - Math.abs(z) / HZ)}// Big framed opening in the +X gable end (spans part of the span/height).const DOOR_Z0 = -3.5const DOOR_Z1 = 3.5const DOOR_H = 7// Small walk door in the +Z side wall, placed toward the +X gable (the main// entrance with the big opening) so the two are on the same end.const WALK_X0 = 9.5const WALK_X1 = 12const WALK_H = 4.2// --- Finishes ---const WALL_COLOR = '#9c2b2b' // barn red panelsconst ROOF_COLOR = '#c7cace' // light grey roof panelsconst TRIM_COLOR = '#5f656d' // grey trim / closuresconst SOFFIT_COLOR = '#e9e7e0' // white soffit / undersideconst FLOOR_COLOR = '#d7d3ca' // concreteconst STEEL_COLOR = '#3b3e44' // structural steelconst LEADER_COLOR = '#1c1d1f'/** Selectable model views the viewer can switch between. */export type BuildingView = 'anatomy' | 'frame' | 'frame60' | 'frame100'// Per-view camera framing (the frame models have a longer footprint).const CAMERAS: Record<BuildingView,{ position: [number, number, number]; fov: number }> = {anatomy: { position: [40, 15, 36], fov: 38 },frame: { position: [54, 30, 46], fov: 34 },frame60: { position: [60, 38, 54], fov: 34 },frame100: { position: [50, 22, 50], fov: 34 },}export interface BuildingStageProps {autoRotate?: booleanview?: BuildingView}export function BuildingStage({autoRotate = false,view = 'anatomy',}: BuildingStageProps) {const cam = CAMERAS[view]// Scroll-zoom clamps, derived from the view's default framing distance.const dist = Math.hypot(...cam.position)const zoom = { min: dist * 0.55, max: dist * 1.5 }return (<Canvas// Remount on view change so the camera re-inits at the new framing.key={view}camera={{ position: cam.position, fov: cam.fov, near: 0.1, far: 600 }}dpr={[1, 2]}gl={{ antialias: true, alpha: true }}>{view === 'anatomy' ? (<SceneautoRotate={autoRotate}zoom={zoom}/>) : (<SteelFramecfg={view === 'frame60'? FRAME_80x140: view === 'frame100'? FRAME_100x100: FRAME_200x400}autoRotate={autoRotate}zoom={zoom}/>)}</Canvas>)}/** Shared studio lighting + reflection environment used by every view. */function Lights() {return (<><ambientLight intensity={0.35} /><hemisphereLightintensity={0.4}groundColor="#1c1d1f"/><directionalLightposition={[16, 24, 22]}intensity={1.6}/><directionalLightposition={[-24, 8, 10]}intensity={0.6}/>{/* Lightformer-built studio environment for metallic reflections, with nonetwork HDRI required, so it works offline and in headless builds. */}<Environment resolution={256}><Lightformerposition={[0, 6, -6]}scale={[12, 12, 1]}intensity={2}color="#ffffff"/><Lightformerposition={[-6, 2, 4]}scale={[6, 8, 1]}intensity={1}color="#dfe6ee"/><Lightformerposition={[6, 3, 4]}scale={[6, 8, 1]}intensity={1}color="#ffffff"/></Environment></>)}function Scene({autoRotate,zoom,}: {autoRotate: booleanzoom: { min: number; max: number }}) {// Gable end wall (pentagon) with an optional door cut-out, extruded along the// building length. Built in the span/height plane, then rotated onto X.const gable = useMemo(() => buildGable(false), [])const gableDoor = useMemo(() => buildGable(true), [])// Side wall (rectangle) with an optional walk-door cut-out.const sideWall = useMemo(() => buildSideWall(false), [])const sideWallDoor = useMemo(() => buildSideWall(true), [])useEffect(() => () =>[gable, gableDoor, sideWall, sideWallDoor].forEach((g) => g.dispose()),[gable, gableDoor, sideWall, sideWallDoor])const wallMat = useMemo(() =>new THREE.MeshStandardMaterial({color: WALL_COLOR,metalness: 0.4,roughness: 0.55,envMapIntensity: 0.3,side: THREE.DoubleSide,}),[])const roofMat = useMemo(() =>new THREE.MeshStandardMaterial({color: ROOF_COLOR,metalness: 0.45,roughness: 0.5,envMapIntensity: 0.35,}),[])const trimMat = useMemo(() =>new THREE.MeshStandardMaterial({color: TRIM_COLOR,metalness: 0.5,roughness: 0.5,}),[])// Trim that overlaps the sloped roof (ridge cap, eave closures): polygon// offset pulls it toward the camera so it always wins the depth test and// never z-fights the roof panels it sits over.const roofTrimMat = useMemo(() =>new THREE.MeshStandardMaterial({color: TRIM_COLOR,metalness: 0.5,roughness: 0.5,polygonOffset: true,polygonOffsetFactor: -2,polygonOffsetUnits: -2,}),[])const soffitMat = useMemo(() =>new THREE.MeshStandardMaterial({color: SOFFIT_COLOR,metalness: 0.1,roughness: 0.8,side: THREE.DoubleSide,}),[])useEffect(() => () =>[wallMat, roofMat, trimMat, roofTrimMat, soffitMat].forEach((m) =>m.dispose()),[wallMat, roofMat, trimMat, roofTrimMat, soffitMat])// Roof slope geometry shared by both panels. Each panel runs from the ridge// (z=0) down to the eave, with the rake overhang added at the eave end only so// the two panels meet flush at the centered ridge.const slopeLen = Math.hypot(HZ, RR)const slopeAngle = Math.atan2(RR, HZ)const cosA = HZ / slopeLenconst ohZ = (RAKE * HZ) / slopeLen // eave overhang, horizontal componentconst ohY = (RAKE * RR) / slopeLen // eave overhang, vertical drop// Run the panels all the way to (and slightly past) the ridge so they cross// under the ridge cap, which covers the intersection.const RIDGE_GAP = -0.3 // negative = overhang past the ridgeconst eaveZ = HZ + ohZconst eaveY = EY - ohYconst ridgeEndY = RIDGE_Y - (RIDGE_GAP * RR) / HZ// Pull the panel's eave end in a hair so it tucks behind the eave closures// instead of overhanging them.const EAVE_INSET = 0.25const roofEaveZ = eaveZ - EAVE_INSETconst roofEaveY = eaveY + (EAVE_INSET * RR) / HZconst roofLen = (roofEaveZ - RIDGE_GAP) / cosAconst roofCenterZ = (RIDGE_GAP + roofEaveZ) / 2const roofCenterY = (ridgeEndY + roofEaveY) / 2return (<><Lights /><group position={[0, -EY / 2 + 4, 0]}>{/* Concrete floor, sitting on top of the base plinth to avoid z-fighting */}<mesh position={[0, 0.65, 0]}><boxGeometry args={[2 * HX - 0.4, 0.1, 2 * HZ - 0.4]} /><meshStandardMaterialcolor={FLOOR_COLOR}roughness={0.95}/></mesh>{/* Gable end walls (±X). +X has the big framed opening. */}<meshgeometry={gableDoor}material={wallMat}position={[HX, 0, 0]}rotation={[0, -Math.PI / 2, 0]}/><meshgeometry={gable}material={wallMat}position={[-HX, 0, 0]}rotation={[0, Math.PI / 2, 0]}/>{/* Side walls (±Z). +Z has the walk door. */}<meshgeometry={sideWallDoor}material={wallMat}position={[0, 0, HZ - WALL_T]}/><meshgeometry={sideWall}material={wallMat}position={[0, 0, -HZ]}/>{/* Vertical panel ribs on the red walls */}<WallRibs material={wallMat} />{/* Roof panels with standing-seam ribs */}<RoofPanelmaterial={roofMat}position={[0, roofCenterY, roofCenterZ]}rotation={[slopeAngle, 0, 0]}length={roofLen}/><RoofPanelmaterial={roofMat}position={[0, roofCenterY, -roofCenterZ]}rotation={[-slopeAngle, 0, 0]}length={roofLen}/>{/* Ridge cap */}<meshmaterial={roofTrimMat}position={[0, RIDGE_Y + 0.12, 0]}><boxGeometry args={[2 * HX + 2 * RAKE, 0.3, 0.7]} /></mesh>{/* Eave trim / closures along both side walls, running the full rooflength and sit flush under the eave edge (incl. rake overhang) */}<meshmaterial={roofTrimMat}position={[0, roofEaveY, roofEaveZ + 0.13]}rotation={[slopeAngle, 0, 0]}><boxGeometry args={[2 * HX + 2 * RAKE, 0.18, 0.3]} /></mesh><meshmaterial={roofTrimMat}position={[0, roofEaveY, -(roofEaveZ + 0.13)]}rotation={[-slopeAngle, 0, 0]}><boxGeometry args={[2 * HX + 2 * RAKE, 0.18, 0.3]} /></mesh>{/* Base trim, a solid filled plinth across the whole footprint so itdoubles as a real foundation base (not just a perimeter band) */}<mesh position={[0, 0.3, 0]}><boxGeometry args={[2 * HX + 0.52, 0.6, 2 * HZ + 0.52]} /><meshStandardMaterialcolor={TRIM_COLOR}metalness={0.5}roughness={0.5}/></mesh>{/* Vertical corner trim, one at each wall corner, base top to eave */}<CornerTrims material={trimMat} />{/* Full wrap trim, framing around the big opening (+X gable) */}<DoorFramematerial={trimMat}x={HX + 0.08}/>{/* White roll-up shutter at the head of the opening */}<Shutter material={soffitMat} />{/* Soffit under the +Z eave overhang */}<meshmaterial={soffitMat}position={[0, EY - 0.1, HZ + RAKE / 2]}rotation={[Math.PI / 2, 0, 0]}><planeGeometry args={[2 * HX + 2 * RAKE, RAKE]} /></mesh>{/* Interior structural columns down both side walls (visible throughthe opening) */}<InteriorColumns />{/* Walk door (+Z wall) */}<meshposition={[(WALK_X0 + WALK_X1) / 2, (0.6 + WALK_H) / 2, HZ + 0.04]}><boxGeometry args={[WALK_X1 - WALK_X0, WALK_H - 0.6, 0.1]} /><meshStandardMaterialcolor={SOFFIT_COLOR}roughness={0.7}/></mesh>{/* Callouts */}<Calloutanchor={[3, RIDGE_Y + 0.2, 0]}label={[-2, RIDGE_Y + 4, 0]}text="Ridge Cap"/><Calloutanchor={[-6, EY + RR * 0.55, 4]}label={[-12, RIDGE_Y + 2.5, 5]}text="Siphon Groove on Panels"/><Calloutanchor={[1, EY, HZ + 0.2]}label={[-3, EY + 2.6, HZ + 1]}text="Closures at Eave"/><Calloutanchor={[HX + RAKE, roofCenterY, -roofCenterZ]}label={[HX + 6, roofCenterY + 2, -roofCenterZ - 2]}text="Soffit System (Optional)"/><Calloutanchor={[HX - 4, 4.5, -HZ + WALL_T + 0.3]}label={[HX, 6.5, 4]}text="Structural Integrity"/><Calloutanchor={[HX + 0.1, 2.2, DOOR_Z0]}label={[HX + 6.5, 0.5, DOOR_Z0 - 1]}text="Full Wrap Trim"/><Calloutanchor={[0, 0.4, HZ + 0.2]}label={[-1, 2.4, HZ + 1.5]}text="Closures at Base"/><Calloutanchor={[HX + 0.25, 0.3, 6]}label={[HX + 2.5, -1.2, 6]}text="Base Trim"/><Calloutanchor={[HX, 0.4, HZ]}label={[HX - 2, 0.5, HZ + 5]}text="Sheeting Notch with Closures"/></group><OrbitControlsmakeDefaultenableZoomenablePan={false}enableDampingdampingFactor={0.1}autoRotate={autoRotate}autoRotateSpeed={0.5}target={[0, 1, 0]}minDistance={zoom.min}maxDistance={zoom.max}/></>)}/** Vertical trim wrapping each of the four wall corners, base top to eave. */function CornerTrims({ material }: { material: THREE.Material }) {const top = 0.6 // base-plinth topconst h = EY - topconst cy = (top + EY) / 2const c = HX + 0.05const d = HZ + 0.05const corners: [number, number][] = [[c, d],[c, -d],[-c, d],[-c, -d],// Two intermediate trims along each longer (±Z) side wall.[HX / 3, d],[-HX / 3, d],[HX / 3, -d],[-HX / 3, -d],]return (<group>{corners.map(([x, z]) => (<meshkey={`${x},${z}`}material={material}position={[x, cy, z]}><boxGeometry args={[0.3, h, 0.3]} /></mesh>))}</group>)}/** Steel columns spaced along both side walls' inner faces. */function InteriorColumns() {const cols: React.ReactElement[] = []const zPos = HZ - WALL_T - 0.3 // +Z inner faceconst zNeg = -HZ + WALL_T + 0.3 // -Z inner facefor (let x = -HX + 4; x <= HX - 4 + 0.01; x += (2 * HX - 8) / 3) {cols.push(<meshkey={`cp${x.toFixed(1)}`}position={[x, EY / 2, zPos]}><boxGeometry args={[0.25, EY, 0.6]} /><meshStandardMaterialcolor={STEEL_COLOR}metalness={0.6}roughness={0.5}/></mesh>,<meshkey={`cn${x.toFixed(1)}`}position={[x, EY / 2, zNeg]}><boxGeometry args={[0.25, EY, 0.6]} /><meshStandardMaterialcolor={STEEL_COLOR}metalness={0.6}roughness={0.5}/></mesh>)}return <group>{cols}</group>}/** Vertical standing-seam ribs across all four red walls (inside and out). */function WallRibs({ material }: { material: THREE.Material }) {const ribs: React.ReactElement[] = []const step = RIB_STEP / 2 // tighter rib spacing on the red wallsconst push = (key: string,position: [number, number, number],size: [number, number, number]) =>ribs.push(<meshkey={key}material={material}position={position}><boxGeometry args={size} /></mesh>)// Side walls (±Z): ribs run full eave height, stepping along X. The +Z wall// has the walk door, so above it the rib is only the header-to-eave segment.for (let x = -HX + 0.3; x <= HX - 0.3; x += step) {const inWalk = x > WALK_X0 - 0.3 && x < WALK_X1 + 0.3const pzH = inWalk ? EY - WALK_H : EYconst pzY = inWalk ? (WALK_H + EY) / 2 : EY / 2// +Z wall: exterior rib stands out, interior rib stands in off the inner face.push(`pz${x.toFixed(1)}`, [x, pzY, HZ + RIB_OUT / 2], [RIB_W, pzH, RIB_OUT])push(`pzi${x.toFixed(1)}`,[x, pzY, HZ - WALL_T - RIB_OUT / 2],[RIB_W, pzH, RIB_OUT])// -Z wall (no opening): full-height ribs on both faces.push(`nz${x.toFixed(1)}`,[x, EY / 2, -HZ - RIB_OUT / 2],[RIB_W, EY, RIB_OUT])push(`nzi${x.toFixed(1)}`,[x, EY / 2, -HZ + WALL_T + RIB_OUT / 2],[RIB_W, EY, RIB_OUT])}// Gable walls (±X): rib height follows the roofline, stepping along Z.for (let z = -HZ + 0.9; z <= HZ - 0.9; z += step) {const h = gableTopY(z)const inDoor = z > DOOR_Z0 - 0.3 && z < DOOR_Z1 + 0.3const pxH = inDoor ? h - DOOR_H : hconst pxY = inDoor ? (DOOR_H + h) / 2 : h / 2// +X gable (main entrance): exterior + interior ribs.push(`px${z.toFixed(1)}`, [HX + RIB_OUT / 2, pxY, z], [RIB_OUT, pxH, RIB_W])push(`pxi${z.toFixed(1)}`,[HX - WALL_T - RIB_OUT / 2, pxY, z],[RIB_OUT, pxH, RIB_W])// -X gable (no opening): full-height ribs on both faces.push(`mx${z.toFixed(1)}`,[-HX - RIB_OUT / 2, h / 2, z],[RIB_OUT, h, RIB_W])push(`mxi${z.toFixed(1)}`,[-HX + WALL_T + RIB_OUT / 2, h / 2, z],[RIB_OUT, h, RIB_W])}return <group>{ribs}</group>}/** A sloped roof panel slab with raised standing-seam ribs running up-slope. */function RoofPanel({material,position,rotation,length,}: {material: THREE.Materialposition: [number, number, number]rotation: [number, number, number]length: number}) {const panelW = 2 * HX + 2 * RAKEconst seams: React.ReactElement[] = []for (let x = -panelW / 2 + 0.7; x <= panelW / 2 - 0.7; x += RIB_STEP) {seams.push(<meshkey={`t${x.toFixed(1)}`}material={material}position={[x, 0.105, 0]}><boxGeometry args={[RIB_W, 0.03, length]} /></mesh>,<meshkey={`b${x.toFixed(1)}`}material={material}position={[x, -0.105, 0]}><boxGeometry args={[RIB_W, 0.03, length]} /></mesh>)}return (<groupposition={position}rotation={rotation}><mesh material={material}><boxGeometry args={[panelW, 0.18, length]} /></mesh>{seams}</group>)}/** White slatted roll-up shutter filling the head of the +X gable opening. */function Shutter({ material }: { material: THREE.Material }) {const width = DOOR_Z1 - DOOR_Z0 - 0.1const slatH = 0.32const gap = 0.06const top = DOOR_H - 0.15 // just under the headerconst count = 3 // partly-rolled shutter covering the upper openingconst slats: React.ReactElement[] = []for (let i = 0; i < count; i++) {const y = top - i * (slatH + gap) - slatH / 2slats.push(<meshkey={i}material={material}position={[HX - 0.18, y, (DOOR_Z0 + DOOR_Z1) / 2]}><boxGeometry args={[0.16, slatH, width]} /></mesh>)}return <group>{slats}</group>}/** Grey frame (left jamb, right jamb, header) around the big +X gable opening. */function DoorFrame({ material, x }: { material: THREE.Material; x: number }) {const w = DOOR_Z1 - DOOR_Z0// Jambs run from the base top (world y = 0.6) up to just above the header.const jambTop = DOOR_H + 0.2 // world-space top of each jambconst jambH = jambTop - 0.6const jambCenter = EY / 2 + (0.6 + jambTop) / 2 // group-space centerreturn (<group position={[x, -EY / 2, 0]}><meshmaterial={material}position={[0, jambCenter, DOOR_Z0 - 0.2]}><boxGeometry args={[0.5, jambH, 0.4]} /></mesh><meshmaterial={material}position={[0, jambCenter, DOOR_Z1 + 0.2]}><boxGeometry args={[0.5, jambH, 0.4]} /></mesh><meshmaterial={material}position={[0, EY / 2 + DOOR_H + 0.2, 0]}><boxGeometry args={[0.5, 0.4, w + 0.8]} /></mesh></group>)}/** Leader line from a feature anchor to a floating uppercase text label. */function Callout({anchor,label,text,}: {anchor: [number, number, number]label: [number, number, number]text: string}) {return (<group><Linepoints={[anchor, label]}color={LEADER_COLOR}lineWidth={1.5}/><Htmlposition={label}centerzIndexRange={[20, 0]}><div className="pointer-events-none select-none whitespace-nowrap rounded-sm bg-body2/90 px-1 py-0.5 text-[10px] font-semibold uppercase leading-none tracking-wide text-contrast shadow-sm sm:px-1.5 sm:text-xs">{text}</div></Html></group>)}// --- Structural-frame views: bare pre-engineered frames, parameterized so// multiple building sizes share one builder. Ridge runs along X; rigid frames// ("bents") span Z. Modeled at a ~2:1 length:span ratio. ---const MEM = 0.5 // main frame member (column / rafter) sizeconst FRAME_COLOR = '#8f2a2a' // barn-red steel (every frame member shares it)interface FrameCfg {hz: number // half span (Z)ey: number // eave heightrr: number // ridge rise above the eavenBays: number // bays between the end frameswall: 'x' | 'girts' // long-wall members: square X-braces or horizontal girtspurlins: number // roof purlins per slopeendPosts: number // gable endwall posts per endgirtRows?: number // interior girts per long wall (wall === 'girts')endGirtRows?: number // interior girts per gable endwall (defaults to girtRows)ratio?: number // length:span (default 2); ignored for wall === 'x'interiorCols?: number[] // interior support columns per frame, as span fractionsspanFt: number // real-world width across the span (ft), for the dimension labellengthFt: number // real-world length along the ridge (ft), for the dimension label}const FRAME_200x400: FrameCfg = {hz: 12,ey: 5,rr: 2.2,nBays: 10,wall: 'x',purlins: 8,endPosts: 10,spanFt: 200,lengthFt: 400,}const FRAME_80x140: FrameCfg = {hz: 13,ey: 7.5,rr: 3.8,nBays: 7,wall: 'girts',purlins: 13,endPosts: 0, // open gable ends, no endwall post/girt meshgirtRows: 3,endGirtRows: 0,ratio: 1.75, // 140 length : 80 spaninteriorCols: [0], // center support line, a two-span frame, not clear-spanspanFt: 80,lengthFt: 140,}const FRAME_100x100: FrameCfg = {hz: 15,ey: 6,rr: 2.5,ratio: 1, // square footprintnBays: 5,wall: 'girts',purlins: 12,endPosts: 4,girtRows: 2,endGirtRows: 3,spanFt: 100,lengthFt: 100,}/** Derive all geometry for a frame config. */function frameGeom(cfg: FrameCfg) {const { hz, ey, rr, nBays } = cfgconst ridgeY = ey + rr// X-braced walls need square bays (bay width === eave height); girt walls keep// a length:span of `ratio` (default 2).const hx = cfg.wall === 'x' ? (nBays * ey) / 2 : (cfg.ratio ?? 2) * hzconst step = (2 * hx) / nBaysconst slope = Math.atan2(rr, hz)const rafLen = Math.hypot(hz, rr)// Extend each rafter to the column outer face so the knee reads as connected.const knee = (MEM / 2) * (rafLen / hz)const rafFull = rafLen + kneeconst eaveZ = hz + knee * (hz / rafLen)const eaveY = ey - knee * (rr / rafLen)const rafCz = eaveZ / 2const rafCy = (ridgeY + eaveY) / 2const bentXs = Array.from({ length: nBays + 1 }, (_, i) => -hx + i * step)const topY = (z: number) => ey + rr * (1 - Math.abs(z) / hz)const interiorZ = (cfg.interiorCols ?? []).map((f) => f * hz)return {hz,ey,rr,ridgeY,step,hx,slope,rafFull,rafCz,rafCy,bentXs,topY,nBays,interiorZ,}}type Geom = ReturnType<typeof frameGeom>/** Bare structural steel frame rendered from a config. */function SteelFrame({cfg,autoRotate,zoom,}: {cfg: FrameCfgautoRotate: booleanzoom: { min: number; max: number }}) {const g = useMemo(() => frameGeom(cfg), [cfg])const frameMat = useMemo(() =>new THREE.MeshStandardMaterial({color: FRAME_COLOR,metalness: 0.5,roughness: 0.5,envMapIntensity: 0.3,}),[])useEffect(() => () => frameMat.dispose(), [frameMat])// Rigid frames: solid box columns + peaked rafters.const bents: React.ReactElement[] = []g.bentXs.forEach((x, i) => {bents.push(<meshkey={`cn${i}`}material={frameMat}position={[x, g.ey / 2, -g.hz]}><boxGeometry args={[MEM, g.ey, MEM]} /></mesh>,<meshkey={`cp${i}`}material={frameMat}position={[x, g.ey / 2, g.hz]}><boxGeometry args={[MEM, g.ey, MEM]} /></mesh>,<meshkey={`rn${i}`}material={frameMat}position={[x, g.rafCy, -g.rafCz]}rotation={[-g.slope, 0, 0]}><boxGeometry args={[MEM, MEM, g.rafFull]} /></mesh>,<meshkey={`rp${i}`}material={frameMat}position={[x, g.rafCy, g.rafCz]}rotation={[g.slope, 0, 0]}><boxGeometry args={[MEM, MEM, g.rafFull]} /></mesh>)// Interior support columns, base to roofline.g.interiorZ.forEach((z, j) => {const h = g.topY(z)bents.push(<meshkey={`ic${i}-${j}`}material={frameMat}position={[x, h / 2, z]}><boxGeometry args={[MEM * 0.55, h, MEM * 0.55]} /></mesh>)})})return (<><Lights /><group position={[0, -g.ey / 2 + 4, 0]}>{/* Slab */}<mesh position={[0, 0, 0]}><boxGeometry args={[2 * g.hx + 2, 0.1, 2 * g.hz + 2]} /><meshStandardMaterialcolor={FLOOR_COLOR}roughness={0.95}/></mesh><group>{bents}</group><FrameDimensionsg={g}cfg={cfg}/><FrameEndPostsg={g}count={cfg.endPosts}/><FramePurlinsg={g}count={cfg.purlins}/>{cfg.wall === 'x' ? (<FrameXBraces g={g} />) : (<><FrameGirtsg={g}rows={cfg.girtRows ?? 3}/><FrameEndGirtsg={g}rows={cfg.endGirtRows ?? cfg.girtRows ?? 3}/></>)}</group><OrbitControlsmakeDefaultenableZoomenablePan={false}enableDampingdampingFactor={0.1}autoRotate={autoRotate}autoRotateSpeed={0.5}target={[0, 2, 0]}minDistance={zoom.min}maxDistance={zoom.max}/></>)}/** Gable endwall posts, base to roofline, on both short ends. */function FrameEndPosts({ g, count }: { g: Geom; count: number }) {const lines: React.ReactElement[] = [];[-g.hx, g.hx].forEach((x, xi) => {for (let k = 1; k <= count; k++) {const z = -g.hz + (k * (2 * g.hz)) / (count + 1)lines.push(<Linekey={`ep${xi}-${k}`}points={[[x, 0, z],[x, g.topY(z), z],]}color={FRAME_COLOR}lineWidth={1.5}/>)}})return <group>{lines}</group>}// Solid-triangle arrowhead sizing (mirrors the trim-viewer dimension style).const DIM_ARROW_LEN = 0.8 // triangle length back along the lineconst DIM_ARROW_W = 0.7 // triangle base widthconst DIM_ARROW_REF = 52 // camera distance that renders the arrow at unit scaleconst _dimWorld = new THREE.Vector3()/*** A filled triangle arrowhead whose tip sits at `tip`, pointing outward along* `dir`, with its base spread along `perp` (both in the horizontal plane). Its* scale is refreshed each frame so the arrow keeps a constant on-screen size* regardless of how far the frame is framed, the same trick as the trim viewer.*/function DimArrowhead({tip,dir,perp,}: {tip: [number, number, number]dir: [number, number, number]perp: [number, number, number]}) {const ref = useRef<THREE.Mesh>(null)const geom = useMemo(() => {const w = DIM_ARROW_W / 2const bx = -dir[0] * DIM_ARROW_LENconst by = -dir[1] * DIM_ARROW_LENconst bz = -dir[2] * DIM_ARROW_LENconst g = new THREE.BufferGeometry()g.setAttribute('position',new THREE.Float32BufferAttribute([0,0,0,bx + perp[0] * w,by + perp[1] * w,bz + perp[2] * w,bx - perp[0] * w,by - perp[1] * w,bz - perp[2] * w,],3))return g}, [dir, perp])useEffect(() => () => geom.dispose(), [geom])useFrame(({ camera }) => {const mesh = ref.currentif (!mesh) returnmesh.getWorldPosition(_dimWorld)mesh.scale.setScalar(camera.position.distanceTo(_dimWorld) / DIM_ARROW_REF)})return (<meshref={ref}geometry={geom}position={tip}><meshBasicMaterialcolor={LEADER_COLOR}side={THREE.DoubleSide}transparentopacity={0.75}/></mesh>)}/** A dimension line with a solid arrowhead at each end and a measurement label* at the midpoint, in the horizontal ground plane. */function DimensionLine({p0,p1,label,}: {p0: [number, number, number]p1: [number, number, number]label: string}) {const dx = p1[0] - p0[0]const dy = p1[1] - p0[1]const dz = p1[2] - p0[2]const len = Math.hypot(dx, dy, dz) || 1// Unit direction from p0→p1, and its horizontal perpendicular (dir × up).const d: [number, number, number] = [dx / len, dy / len, dz / len]const perp: [number, number, number] = [d[2], 0, -d[0]]const mid: [number, number, number] = [(p0[0] + p1[0]) / 2,(p0[1] + p1[1]) / 2,(p0[2] + p1[2]) / 2,]return (<group><Linepoints={[p0, p1]}color={LEADER_COLOR}lineWidth={0.75}transparentopacity={0.75}/>{/* Arrowheads point outward (away from the midpoint) at each end. */}<DimArrowheadtip={p0}dir={[-d[0], -d[1], -d[2]]}perp={perp}/><DimArrowheadtip={p1}dir={d}perp={perp}/><Htmlposition={mid}centerzIndexRange={[30, 0]}><div className="select-none rounded-sm bg-body2 p-1 text-[10px] leading-none text-contrast sm:text-sm">{label}</div></Html></group>)}/** Length and span dimension lines along the footprint edges of a frame. */function FrameDimensions({ g, cfg }: { g: Geom; cfg: FrameCfg }) {const off = 2.5 // stand the dimension line off the building edgereturn (<group>{/* Length along the ridge, in front of the +Z long wall */}<DimensionLinep0={[-g.hx, 0, g.hz + off]}p1={[g.hx, 0, g.hz + off]}label={`${cfg.lengthFt}'`}/>{/* Span across the gable end, off the +X end */}<DimensionLinep0={[g.hx + off, 0, -g.hz]}p1={[g.hx + off, 0, g.hz]}label={`${cfg.spanFt}'`}/></group>)}/** Roof purlins along both slopes + the ridge, running the full length. */function FramePurlins({ g, count }: { g: Geom; count: number }) {const lines: React.ReactElement[] = []lines.push(<Linekey="ridge"points={[[-g.hx, g.ridgeY, 0],[g.hx, g.ridgeY, 0],]}color={FRAME_COLOR}lineWidth={1.5}/>)Array.from({ length: count }, (_, i) => (i + 1) / count).forEach((frac, i) => {const z = frac * g.hzconst y = g.topY(z)lines.push(<Linekey={`pn${i}`}points={[[-g.hx, y, -z],[g.hx, y, -z],]}color={FRAME_COLOR}lineWidth={1.5}/>,<Linekey={`pp${i}`}points={[[-g.hx, y, z],[g.hx, y, z],]}color={FRAME_COLOR}lineWidth={1.5}/>)})return <group>{lines}</group>}/** Square X cross-bracing in every bay of both long walls. */function FrameXBraces({ g }: { g: Geom }) {const lines: React.ReactElement[] = [];[-g.hz, g.hz].forEach((z, wi) => {for (let i = 0; i < g.nBays; i++) {const x0 = -g.hx + i * g.stepconst x1 = x0 + g.steplines.push(<Linekey={`x1-${wi}-${i}`}points={[[x0, 0, z],[x1, g.ey, z],]}color={FRAME_COLOR}lineWidth={1.5}/>,<Linekey={`x2-${wi}-${i}`}points={[[x1, 0, z],[x0, g.ey, z],]}color={FRAME_COLOR}lineWidth={1.5}/>)}})return <group>{lines}</group>}/** Horizontal girts across both gable (short) endwalls, spanning the width. The* top row sits at the eave (top of the endwall posts). */function FrameEndGirts({ g, rows }: { g: Geom; rows: number }) {const ys = Array.from({ length: rows }, (_, i) => (g.ey * (i + 1)) / rows)const lines: React.ReactElement[] = [];[-g.hx, g.hx].forEach((x, xi) => {ys.forEach((y, ri) => {lines.push(<Linekey={`eg${xi}-${ri}`}points={[[x, y, -g.hz],[x, y, g.hz],]}color={FRAME_COLOR}lineWidth={1.5}/>)})})return <group>{lines}</group>}/** Horizontal girts along both long walls (base, evenly spaced rows, eave). */function FrameGirts({ g, rows }: { g: Geom; rows: number }) {const ys = [0,...Array.from({ length: rows }, (_, i) => (g.ey * (i + 1)) / (rows + 1)),g.ey,]const lines: React.ReactElement[] = [];[-g.hz, g.hz].forEach((z, wi) => {ys.forEach((y, ri) => {lines.push(<Linekey={`g${wi}-${ri}`}points={[[-g.hx, y, z],[g.hx, y, z],]}color={FRAME_COLOR}lineWidth={1.5}/>)})})return <group>{lines}</group>}/** Pentagon gable wall in the (span, height) plane, extruded along the length. */function buildGable(door: boolean): THREE.BufferGeometry {const shape = new THREE.Shape()shape.moveTo(-HZ, 0)shape.lineTo(HZ, 0)shape.lineTo(HZ, EY)shape.lineTo(0, RIDGE_Y)shape.lineTo(-HZ, EY)shape.closePath()if (door) {const hole = new THREE.Path()hole.moveTo(DOOR_Z0, 0)hole.lineTo(DOOR_Z1, 0)hole.lineTo(DOOR_Z1, DOOR_H)hole.lineTo(DOOR_Z0, DOOR_H)hole.closePath()shape.holes.push(hole)}const geo = new THREE.ExtrudeGeometry(shape, {depth: WALL_T,bevelEnabled: false,})geo.computeVertexNormals()return geo}/** Rectangular side wall in the (length, height) plane, extruded in thickness. */function buildSideWall(door: boolean): THREE.BufferGeometry {const shape = new THREE.Shape()shape.moveTo(-HX, 0)shape.lineTo(HX, 0)shape.lineTo(HX, EY)shape.lineTo(-HX, EY)shape.closePath()if (door) {const hole = new THREE.Path()hole.moveTo(WALK_X0, 0)hole.lineTo(WALK_X1, 0)hole.lineTo(WALK_X1, WALK_H)hole.lineTo(WALK_X0, WALK_H)hole.closePath()shape.holes.push(hole)}const geo = new THREE.ExtrudeGeometry(shape, {depth: WALL_T,bevelEnabled: false,})geo.computeVertexNormals()return geo}