'use client' import { type AnyNodeId, emitter, type RoofEvent, type RoofNode, SolarPanelNode, sceneRegistry, useScene, } from '@pascal-app/core' import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfacePlacementGuides, roofSurfaceFootprintFromNode, } from '../shared/roof-surface-placement-guides' import { solarPanelDefinition } from './definition' import SolarPanelPreview from './preview' const worldPoint = new THREE.Vector3() /** * Solar panel placement tool. The preview shows the array at the * cursor with the analytical roof-surface tilt applied (no raycast in * the placement preview — uses `getAnalyticalNormal` derived from the * segment's roof type + dimensions). On commit, snaps the position's * Y to the segment's surface height and stores the analytical normal * in the node so the renderer reproduces the same orientation. */ const SolarPanelTool = () => { const activeBuildingId = useViewer((s) => s.selection.buildingId) const setSelection = useViewer((s) => s.setSelection) const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewYaw, setPreviewYaw] = useState(0) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) const lastSnapRef = useRef<[number, number] | null>(null) // Compact 2×3 ghost (rows × columns) — small enough to read as a // pointer, large enough to show the array's orientation/aspect. // The committed panel still uses the full residential defaults (4×5). const previewNode = useMemo( () => SolarPanelNode.parse({ ...solarPanelDefinition.defaults(), name: 'Solar Panel', position: [0, 0, 0], rotation: 0, rows: 2, columns: 3, }), [], ) useEffect(() => { if (!activeBuildingId) return const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) if (!buildingObj) return [wx, wy, wz] worldPoint.set(wx, wy, wz) buildingObj.worldToLocal(worldPoint) return [worldPoint.x, worldPoint.y, worldPoint.z] } const updatePreview = (event: RoofEvent) => { const wx = event.position[0] const wy = event.position[1] const wz = event.position[2] const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) if (!hit) return const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) usePlacementPreview.getState().set( SolarPanelNode.parse({ ...previewNode, parentId: hit.segment.id, position: [hit.localX, hit.localY, hit.localZ], roofSegmentId: hit.segment.id, surfaceNormal: [normal.x, normal.y, normal.z], }), hit.segment, ) publishRoofSurfacePlacementGuides({ roof: event.node as RoofNode, segment: hit.segment, center: [hit.localX, hit.localY, hit.localZ], footprint: roofSurfaceFootprintFromNode(previewNode), }) event.stopPropagation() } const onClick = (event: RoofEvent) => { const hit = resolveRoofSegmentHit( event.node as RoofNode, event.position[0], event.position[1], event.position[2], ) if (!hit) return const state = useScene.getState() // Use the raycast hit Y (segment-local) and analytical normal so the // committed panel sits exactly where the ghost was rendered. The // analytical `getSurfaceY` is the bare-rafter height — it ignores // deck/shingle layers and sinks the panel into the roof, producing // a visible jump between ghost and committed mesh. const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) const panel = SolarPanelNode.parse({ ...solarPanelDefinition.defaults(), name: 'Solar Panel', roofSegmentId: hit.segment.id, position: [hit.localX, hit.localY, hit.localZ], rotation: 0, surfaceNormal: [normal.x, normal.y, normal.z], }) state.createNode(panel, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [panel.id] }) triggerSFX('sfx:item-place') const placementPreview = usePlacementPreview.getState() if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() event.stopPropagation() } emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) return () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) const placementPreview = usePlacementPreview.getState() if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() } }, [activeBuildingId, setSelection, previewNode]) return ( <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) const placementPreview = usePlacementPreview.getState() if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( )} ) } export default SolarPanelTool