frontend/src/bin/3dView/Scene/Bin3dView.tsx

168 lines
6.8 KiB
TypeScript

import React from "react";
import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls";
import CameraOverlay from "3dModels/CameraControls/CameraOverlay";
import { Canvas } from "@react-three/fiber";
import { Bin } from "models";
import BinShell from "../BinParts/BinShell";
import GrainFillFlat from "../Systems/Inventory/GrainFillFlat";
import BinCables from "../Systems/Cables/BinCables";
import { Vector3 } from "three";
import { useMemo } from "react";
import { BuildCableData, CableData } from "../Data/BuildCableData";
import { BuildNodeData, NodeData } from "../Data/BuildNodeData";
import { pond } from "protobuf-ts/pond";
import GrainCableFill from "../Systems/Inventory/GrainCableFill";
import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
interface Props {
bin: Bin
scale: number
fillPercent?: number
nodeClick?: (node: NodeData, cable: CableData) => void
showGrain?: boolean
showTempHeatmap?: boolean
showMoistureHeatmap?: boolean
showHotspots?: boolean
/**
* When true renders a reset camera button overlaid on the 3D view.
*/
showResetButton?: boolean
showTemp?: boolean
showMoisture?: boolean
}
export default function Bin3dView(props: Props) {
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props
const binCenter = useMemo(() => new Vector3(0, 0, 0), []);
// Compute the initial camera radius so the full bin fits in frame.
// Uses the perspective camera FOV (default 75°) with a padding factor.
// Both the diameter and total height are considered so neither is clipped.
const initialCameraRadius = useMemo(() => {
const totalHeight = (bin.sidewallHeight() + bin.roofHeight() + (bin.hopperHeight() ?? 0)) / scale;
const diameter = bin.diameter() / scale;
const largestDim = Math.max(totalHeight, diameter);
const fovRad = (75 * Math.PI) / 180;
const padding = 1.3; // 30% breathing room
return (largestDim / (2 * Math.tan(fovRad / 2))) * padding;
}, [bin, scale]);
const cableData = useMemo(() => BuildCableData(bin), [bin]);
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
const isCableInventory =
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC ||
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE;
// For cable inventory, TempHeatMap derives the wavy surface directly from
// the top nodes in nodeData — no scalar needed.
//
// For flat fill percent, we convert the fill percent to a Y coordinate
// using the same volume math as GrainFillFlat and pass it as flatMaxY.
const flatMaxY = useMemo(() => {
if (isCableInventory || fillPercent === undefined) return undefined;
const radius = bin.diameter() / 2;
const sidewallH = bin.sidewallHeight();
const hopperH = bin.hopperHeight() ?? 0;
const cylVolume = Math.PI * radius * radius * sidewallH;
const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0;
const filled = (cylVolume + hopperVol) * fillPercent;
const cylinderFillH = hopperH > 0 && filled <= hopperVol
? 0
: Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius));
return -sidewallH / 2 + cylinderFillH;
}, [isCableInventory, fillPercent, bin]);
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay
const resetCameraFn = React.useRef<(() => void) | null>(null);
const grainInventory = () => {
if (isCableInventory) {
return (
<GrainCableFill
diameter={bin.diameter()}
nodes={nodeData}
sidewallHeight={bin.sidewallHeight()}
fallbackFillPercent={fillPercent}
hopperHeight={bin.hopperHeight()}
grainOpacity={0.3}
/>
)
} else if (fillPercent) {
return (
<GrainFillFlat
diameter={bin.diameter()}
sidewallHeight={bin.sidewallHeight()}
hopperHeight={bin.hopperHeight()}
fillPercent={fillPercent}
grainOpacity={0.3}
/>
)
}
}
return (
<Canvas>
<group scale={[1 / scale, 1 / scale, 1 / scale]}>
<BinShell
binBodyColour="#fff"
radialSegments={20}
binMetalness={0.5}
binRoughness={0.7}
binOpacity={0.2}
diameter={bin.diameter()}
sidewallHeight={bin.sidewallHeight()}
roofHeight={bin.roofHeight()}
hopperHeight={bin.hopperHeight()}
renderOrder={4}
/>
{showGrain && grainInventory()}
<BinCables
displayTemp={showTemp}
displayMoisture={showMoisture}
cableData={cableData}
nodeData={nodeData}
bin={bin}
binCenter={binCenter}
onNodeClick={(node, cable) => {
if (nodeClick) nodeClick(node, cable)
}}
renderOrder={1}
/>
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
{showTempHeatmap && (
<TempHeatMap
bin={bin}
nodes={nodeData}
flatMaxY={flatMaxY}
/>
)}
</group>
<ambientLight intensity={0.2} color={"white"} />
<directionalLight intensity={0.2} color={"white"} position={[0, 0, 5]} />
<directionalLight intensity={0.2} color={"white"} position={[0, 0, -5]} />
<directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} />
<directionalLight intensity={0.2} color={"white"} position={[-5, 0, 0]} />
<OrbitCameraControls
clampVerticalRotation
initialRadius={initialCameraRadius}
maxRadius={initialCameraRadius * 2}
onReset={fn => { resetCameraFn.current = fn; }}
/>
{/* <CameraOverlay
showResetButton={showResetButton}
onReset={() => resetCameraFn.current?.()}
/> */}
</Canvas>
)
}