199 lines
8.5 KiB
TypeScript
199 lines
8.5 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";
|
|
import { Box } from "@mui/material";
|
|
import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap";
|
|
import { grainYBoundsFromFillPercent } from "../utils/grainFillBounds";
|
|
import { RadiansToDegrees } from "common/TrigFunctions";
|
|
// import { GrainCable } from "models/GrainCable"
|
|
|
|
// Fallback dimensions used when a bin has no advanced dimensions configured.
|
|
// Chosen to resemble a typical mid-size grain bin.
|
|
const DEFAULT_ROOF_HEIGHT_CM = 200; // 2 m
|
|
const DEFAULT_FLAT_HEIGHT_CM = 0; // flat bottom
|
|
const DEFAULT_HOPPER_HEIGHT_CM = 280; // hopper bottom
|
|
|
|
interface Props {
|
|
bin: Bin
|
|
scale: number
|
|
/**
|
|
* cables can come from the bins status when not passed in, however data may be out of synce from when the cables read to when the bins status updates
|
|
* they may need to be passed in but for the moment we wont worry about it due to the last active on the bin page can be used to explain being out of sync
|
|
* if it becomes an issue I can make the changes necessary to pass the cable components in
|
|
*/
|
|
// cables?: GrainCable[]
|
|
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
|
|
xOffset?: number
|
|
}
|
|
|
|
export default function Bin3dView(props: Props) {
|
|
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture, xOffset } = props
|
|
|
|
// Resolve effective dimensions, falling back to defaults when advanced
|
|
// dimensions have not been configured (bin methods return 0 in that case).
|
|
const dims = useMemo(() => ({
|
|
diameter: bin.diameter(),
|
|
sidewallHeight: bin.sidewallHeight() || bin.diameter() + 50, //calculated using the diameter so 'default' bins always have the same shape
|
|
roofHeight: bin.roofHeight() || DEFAULT_ROOF_HEIGHT_CM,
|
|
hopperHeight: bin.hopperHeight() || (bin.binShape() === pond.BinShape.BIN_SHAPE_HOPPER_BOTTOM ? DEFAULT_HOPPER_HEIGHT_CM : DEFAULT_FLAT_HEIGHT_CM),
|
|
}), [bin]);
|
|
|
|
// 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 = (dims.sidewallHeight + dims.roofHeight + dims.hopperHeight) / scale;
|
|
const diameter = dims.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;
|
|
}, [dims, scale]);
|
|
|
|
const cableData = useMemo(() => BuildCableData(bin, dims), [bin, dims]);
|
|
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.
|
|
//
|
|
// Flat fill: Y bounds from the same volume math as GrainFillFlat.
|
|
const flatGrainBounds = useMemo(() => {
|
|
if (isCableInventory || fillPercent === undefined) return undefined;
|
|
return grainYBoundsFromFillPercent(
|
|
dims.diameter,
|
|
dims.sidewallHeight,
|
|
dims.hopperHeight,
|
|
fillPercent
|
|
);
|
|
}, [isCableInventory, fillPercent, dims]);
|
|
|
|
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay
|
|
const resetCameraFn = React.useRef<(() => void) | null>(null);
|
|
|
|
const grainInventory = () => {
|
|
if (isCableInventory) {
|
|
return (
|
|
<GrainCableFill
|
|
diameter={dims.diameter}
|
|
nodes={nodeData}
|
|
sidewallHeight={dims.sidewallHeight}
|
|
fallbackFillPercent={fillPercent}
|
|
hopperHeight={dims.hopperHeight}
|
|
grainOpacity={0.3}
|
|
/>
|
|
)
|
|
} else if (fillPercent) {
|
|
return (
|
|
<GrainFillFlat
|
|
diameter={dims.diameter}
|
|
sidewallHeight={dims.sidewallHeight}
|
|
hopperHeight={dims.hopperHeight}
|
|
fillPercent={fillPercent}
|
|
grainOpacity={0.3}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Canvas style={{position: "absolute", inset: 0}}>
|
|
<OrbitCameraControls
|
|
clampVerticalRotation
|
|
initialRadius={initialCameraRadius}
|
|
maxRadius={initialCameraRadius * 2}
|
|
onReset={fn => { resetCameraFn.current = fn; }}
|
|
viewOffset={xOffset}
|
|
/>
|
|
<group scale={[1 / scale, 1 / scale, 1 / scale]}>
|
|
<BinShell
|
|
binBodyColour="#fff"
|
|
radialSegments={20}
|
|
binMetalness={0.5}
|
|
binRoughness={0.7}
|
|
binOpacity={0.2}
|
|
diameter={dims.diameter}
|
|
sidewallHeight={dims.sidewallHeight}
|
|
roofHeight={dims.roofHeight}
|
|
hopperHeight={dims.hopperHeight}
|
|
renderOrder={4}
|
|
/>
|
|
|
|
{showGrain && grainInventory()}
|
|
|
|
<BinCables
|
|
displayTemp={showTemp}
|
|
displayMoisture={showMoisture}
|
|
cableData={cableData}
|
|
nodeData={nodeData}
|
|
bin={bin}
|
|
onNodeClick={(node, cable) => {
|
|
if (nodeClick) nodeClick(node, cable)
|
|
}}
|
|
renderOrder={5}
|
|
/>
|
|
|
|
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
|
|
|
|
{/* note that the heatmaps internally use render order 1,2, and 3 for the three separate coloured meshes */}
|
|
{showTempHeatmap && (
|
|
<TempHeatMap
|
|
binDimensions={dims}
|
|
targetTemp={bin.targetTemp()}
|
|
nodes={nodeData}
|
|
flatMaxY={flatGrainBounds?.maxY}
|
|
flatMinY={flatGrainBounds?.minY}
|
|
/>
|
|
)}
|
|
{showMoistureHeatmap && (
|
|
<MoistureHeatMap
|
|
binDimensions={dims}
|
|
targetMoisture={bin.targetMoisture()}
|
|
nodes={nodeData}
|
|
flatMaxY={flatGrainBounds?.maxY}
|
|
flatMinY={flatGrainBounds?.minY}
|
|
/>
|
|
)}
|
|
|
|
</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]} />
|
|
|
|
{/* Overlay — must be inside <Canvas> so it shares the same stacking context */}
|
|
{showResetButton && (
|
|
<CameraOverlay onReset={() => resetCameraFn.current?.()} />
|
|
)}
|
|
</Canvas>
|
|
)
|
|
}
|