Files
editor/packages/nodes/src/solar-panel/tool.tsx
T

188 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<THREE.Quaternion | null>(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 (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
ghost={<SolarPanelPreview node={previewNode} invalid />}
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
const placementPreview = usePlacementPreview.getState()
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
clearRoofSurfacePlacementGuides()
}}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
<group position={previewPos}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<SolarPanelPreview node={previewNode} />
</group>
</group>
</group>
)}
</>
)
}
export default SolarPanelTool