168 lines
6.8 KiB
TypeScript
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>
|
|
)
|
|
}
|