'use client' import { type AnyNodeId, type RoofSegmentNode, type SolarPanelNode, useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' import { type ColorPreset, createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, getRoofOuterSurfaceFrameAtPoint, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { MeshStandardNodeMaterial } from 'three/webgpu' import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildSolarPanelGeometry, getDefaultPanelMaterial } from './geometry' // Module-scope scratch vectors and quaternions for composing the panel's // local orientation each render — surfaceQuat · Y(rotation) · X(tilt). // Reused so we don't allocate four objects per frame. const yAxis = new THREE.Vector3(0, 1, 0) const xAxis = new THREE.Vector3(1, 0, 0) const panelYawQuat = new THREE.Quaternion() const panelTiltQuat = new THREE.Quaternion() // MeshStandardNodeMaterial: WebGPU-native so it integrates correctly with // the MRT pass (normal + roughness attachments). The legacy WebGL // MeshStandardMaterial triggers "Color target has no corresponding fragment // stage output / writeMask not zero" when the renderer switches pipelines // during a segment-reparent re-render. const defaultFrameMaterial = new MeshStandardNodeMaterial({ color: new THREE.Color(0.6, 0.6, 0.65), roughness: 0.4, metalness: 0.8, }) /** * Solar panel renderer. The panel's Y and surface tilt are derived * live from the parent roof-segment's finished (deck + shingle) surface * on every render via `getRoofOuterSurfaceFrameAtPoint` — the same * authoritative helper skylights use. Deriving rather than reading the * stored `position[1]`/`surfaceNormal` snapshot is what lets the panel * re-seat and re-tilt automatically when the roof's wall height or * pitch changes; a cached snapshot would leave it floating or buried. * * The segment's live overrides are merged too, so the panel tracks the * surface continuously during a wall-height/pitch drag, not just after * the value commits to the scene store. * * The surface orientation is applied as a quaternion on an inner * group computed once per render (not per frame). This matches the * static-transform pattern used by the other roof accessories and * gives up the legacy `useFrame` quaternion smoothing — segment yaw * changes still propagate immediately through the outer `rotation-y` * binding. */ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { const ref = useRef(null!) useRegistry(storeNode.id, 'solar-panel', ref) const handlers = useNodeEvents(storeNode, 'solar-panel') 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) // Merge live overrides written by slider drags so the mesh updates in // real time before the value is committed to the scene store. const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id) as Partial | undefined, ) const node = overrides ? ({ ...storeNode, ...overrides } as SolarPanelNode) : storeNode const segment = useScene((state) => node.roofSegmentId ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) : undefined, ) // Merge the segment's live overrides (written by wall-height / pitch // slider drags before they commit) so the panel re-seats and re-tilts // in real time as the roof changes, mirroring the gutter's eave-snap. 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 // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildSolarPanelGeometry(node), [ node.rows, node.columns, node.panelWidth, node.panelHeight, node.gapX, node.gapY, node.frameThickness, node.frameDepth, node.standoffHeight, ], ) useEffect(() => () => geometry?.dispose(), [geometry]) // Only the structural frame/mount surface is themed (→ 'roof'). The // panel/cell face below is intentionally dark + product-specific and is // left untouched in both texture modes. const frameMaterial = useMemo(() => { if (!textures || (!node.material && !node.materialPreset)) { return createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme) } if (node.material) return createMaterial(node.material, shading) return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultFrameMaterial }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) const panelMaterial = useMemo(() => { if (node.panelMaterial) return createMaterial(node.panelMaterial, shading) return ( createMaterialFromPresetRef(node.panelMaterialPreset, shading) ?? getDefaultPanelMaterial() ) }, [shading, node.panelMaterial, node.panelMaterialPreset]) // Finished-surface frame (deck + shingle top) at the panel's local // X/Z, recomputed from the live segment each render — both the Y and // the tilt normal flow from here, so a wall-height or pitch change // re-seats and re-orients the panel automatically. `segmentOverrides` // is in the deps so a live drag re-derives the frame mid-drag. // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const surfaceFrame = useMemo(() => { if (!effectiveSegment) { return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) } } return getRoofOuterSurfaceFrameAtPoint( effectiveSegment, node.position[0] ?? 0, node.position[2] ?? 0, ) }, [segment, segmentOverrides, node.position[0], node.position[2]]) const surfaceQuat = useMemo( () => surfaceQuatFromNormal(surfaceFrame.normal, new THREE.Quaternion()), [surfaceFrame.normal], ) // Map panel-local geometry into the host segment's local frame (where the // trim cut prisms live). Recompose the same pose the inner mesh group is // mounted with (position [x, surfaceY, z] + surfaceQuat·yaw·tilt) so the clip // matches the rendered placement exactly. Computed before the early return so // the hook order stays stable. const localToSegment = useMemo(() => { const surfaceY = surfaceFrame.point.y const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 const quat = new THREE.Quaternion() .copy(surfaceQuat) .multiply(new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)) .multiply(new THREE.Quaternion().setFromAxisAngle(xAxis, tiltRad)) return new THREE.Matrix4().compose( new THREE.Vector3(node.position[0] ?? 0, surfaceY, node.position[2] ?? 0), quat, new THREE.Vector3(1, 1, 1), ) }, [ surfaceFrame.point.y, surfaceQuat, node.mountingType, node.tiltAngle, node.rotation, node.position[0], node.position[2], ]) const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) if (!effectiveSegment || !geometry) return null const surfaceY = surfaceFrame.point.y const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 // Compose surfaceQuat · Y(rotation) · X(tilt) into a single quaternion // so the registered group below carries the panel's complete local pose // (position + orientation) as its own *local* matrix. Registry handles // (`portal: 'grandparent'`) read this Object3D's local position + // quaternion to ride the panel; splitting the rotation across nested // groups would leave the registered group with only the position and // an identity quaternion, so the arrows would land on the segment-flat // axes instead of on the tilted panel. const composedQuat = new THREE.Quaternion() .copy(surfaceQuat) .multiply(panelYawQuat.setFromAxisAngle(yAxis, node.rotation ?? 0)) .multiply(panelTiltQuat.setFromAxisAngle(xAxis, tiltRad)) // Roof accessories are mounted under `` // in the roof renderer — that group has NO transform, so the segment // frame is NOT inherited from the React tree. Apply segment.position // and segment.rotation here, then the panel's segment-local offset + // composed orientation on a single registered group. return ( ) } export default SolarPanelRenderer