'use client' import { type AnyNodeId, type GutterNode, type RoofSegmentNode, useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' import { type ColorPreset, createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre' import { computeSharedEaveY } from './eave-align' import { computeEaveY } from './eave-snap' import { buildGutterGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.7, metalness: 0.25, }) /** * Gutter renderer. Mounts at the eave of the host roof-segment — the * gutter hangs level off the eave line (gravity wins; no slope tilt). * Transform stack: * * segment.position → segment.rotation (Y) → gutter.position * → gutter.rotation (Y) → mesh * * The registered ref sits on the inner group that applies position + * rotation, so `NodeArrowHandles` reads gutter-mesh-local coords for * its chevron placements (same pattern as ridge-vent). * * `useLiveNodeOverrides` merges in-flight handle drags onto the store * node so the mesh tracks the drag without flushing zustand each * frame. */ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { const ref = useRef(null!) useRegistry(storeNode.id, 'gutter', ref) const handlers = useNodeEvents(storeNode, 'gutter') const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, ) const node: GutterNode = overrides ? ({ ...storeNode, ...overrides } as GutterNode) : storeNode const segment = useScene((state) => node.roofSegmentId ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) : undefined, ) // While the user is dragging the segment's wall-height / overhang / // pitch handle, the drag pipeline writes to useLiveNodeOverrides // instead of the scene store — the scene entry above stays at the // pre-drag value until pointer-up. Subscribing to the segment's live // overrides too lets the gutter's `computeEaveY` see the in-flight // height and slide up/down on every frame of the drag. const segmentOverrides = useLiveNodeOverrides((s) => node.roofSegmentId ? (s.get(node.roofSegmentId as AnyNodeId) as Partial | undefined) : undefined, ) const effectiveSegment: RoofSegmentNode | undefined = segment ? segmentOverrides ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) : segment : undefined // Corner-mitre inputs: every other gutter under the SAME ROOF, plus // the roof's segments (for the frame lift). A flat array of node refs // keeps `useShallow` stable — it only re-renders when one of those // nodes actually changes. Cross-segment so gutters on different // segments mitre where their segments meet (the mitres useMemo pairs // each gutter back to its segment). const mitreNodes = useScene( useShallow((state) => { const segmentId = node.roofSegmentId as AnyNodeId | undefined const seg = segmentId ? (state.nodes[segmentId] as RoofSegmentNode | undefined) : undefined const roofId = seg?.parentId as AnyNodeId | undefined const roof = roofId ? (state.nodes[roofId] as { children?: readonly string[] } | undefined) : undefined if (!roof) return [] as (GutterNode | RoofSegmentNode)[] const out: (GutterNode | RoofSegmentNode)[] = [] for (const sid of roof.children ?? []) { const s = state.nodes[sid as AnyNodeId] if (s?.type !== 'roof-segment') continue out.push(s as RoofSegmentNode) for (const gid of (s as RoofSegmentNode).children ?? []) { const g = state.nodes[gid as AnyNodeId] if (g?.type === 'gutter' && g.id !== storeNode.id) out.push(g as GutterNode) } } return out }), ) // Mitres AND the run's shared eave height come from the same sibling // walk: both key off which gutters meet at corners. `siblings` carries // the FULL host segment (the alignment needs wallHeight / overhang / // pitch / roofType to derive each eave Y), which is a superset of what // the mitre detector reads — so one list feeds both. const { mitres, sharedEaveY } = useMemo(() => { if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined } const segById = new Map() for (const n of mitreNodes) { if (n.type === 'roof-segment') segById.set(n.id, n as RoofSegmentNode) } const siblings: GutterWithSegment[] = [] for (const n of mitreNodes) { if (n.type !== 'gutter') continue const g = n as GutterNode const seg = g.roofSegmentId ? segById.get(g.roofSegmentId) : undefined if (seg) siblings.push({ gutter: g, segment: seg }) } return { mitres: computeGutterMitres(node, effectiveSegment, siblings), // `siblings` is typed for the mitre detector (position/rotation), // but the segment objects are the full RoofSegmentNodes from // `mitreNodes`, so `computeSharedEaveY` gets the eave-Y inputs it // needs at runtime. sharedEaveY: computeSharedEaveY( node, effectiveSegment, siblings as unknown as Parameters[2], ), } }, [ node.position[0], node.position[1], node.position[2], node.rotation, node.length, effectiveSegment?.position?.[0], effectiveSegment?.position?.[2], effectiveSegment?.rotation, effectiveSegment?.wallHeight, effectiveSegment?.overhang, effectiveSegment?.pitch, effectiveSegment?.roofType, mitreNodes, ]) const geometry = useMemo( () => buildGutterGeometry(node, mitres), [ node.length, node.size, node.thickness, node.profile, node.endCapLeft, node.endCapRight, node.hangerStyle, node.hangerSpacing, // Value-compare the outlets array so the CSG drills only rebuild // when an outlet's offset / diameter changes or one is added. JSON.stringify(node.outlets), mitres.left, mitres.right, ], ) useEffect(() => () => geometry.dispose(), [geometry]) // Paint surface: explicit material wins, then preset, then the cached // default. FrontSide everywhere — DoubleSide on any NodeMaterial inside // the MRT scenePass compiles a back-face shader variant that doesn't // declare outputs for every MRT target and poisons the render context // (see `materials.ts` line 77, and the glazing FrontSide fix in // 9400f1c5). The U-channel cross-section in `geometry.ts` is traced as // a single closed polygon around the material — both the exterior shell // and the interior trough walls are part of the same outward-wound // boundary, so ExtrudeGeometry produces outward-facing normals on every // visible face. FrontSide is therefore sufficient and DoubleSide is not // needed. const material = useMemo(() => { if (!textures || (!node.material && !node.materialPreset)) { return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) if (!segment || !effectiveSegment) return null // `node.position` is segment-local — the placement tool resolves the // eave click via `segObj.worldToLocal`. The renderer mounts under // `roof-elements` (only the roof transform inherited), so we // re-apply the segment's roof-local transform here. Mirrors the // ridge-vent / box-vent pattern; without this gutters on rotated // segments would land on the first segment instead. // // Y is derived live from `effectiveSegment` (scene + drag overrides) // instead of trusting `node.position[1]` — so changing wallHeight, // overhang, or pitch on the parent segment moves the gutter on the // very next frame, including while a segment-height handle is // mid-drag. Matches the chimney/box-vent pattern of pulling host- // segment geometry at draw time rather than caching it at placement. const segPos = segment.position ?? [0, 0, 0] const segRotY = segment.rotation ?? 0 // Prefer the connected run's shared height (aligns gutters meeting at // a corner whose segments derive different eave Ys); fall back to this // segment's own eave Y for an isolated gutter. const liveEaveY = sharedEaveY ?? computeEaveY(effectiveSegment) return ( ) } export default GutterRenderer