started work on the bin summary part of the new bin page, built the framework and the new 3d visualizer, still doing some work on the camera to create an overlay with a reset button

This commit is contained in:
csawatzky 2026-05-01 13:11:12 -06:00
parent b3cf5b5e83
commit 4b2c67dfd9
11 changed files with 974 additions and 220 deletions

View file

@ -1,5 +1,6 @@
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";
@ -13,28 +14,47 @@ 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
showHeatmap?: 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, showHeatmap, showGrain, showHotspots } = 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.
//
@ -42,21 +62,24 @@ export default function Bin3dView(props: Props) {
// 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 (
@ -81,7 +104,7 @@ export default function Bin3dView(props: Props) {
)
}
}
return (
<Canvas>
<group scale={[1 / scale, 1 / scale, 1 / scale]}>
@ -97,10 +120,12 @@ export default function Bin3dView(props: Props) {
hopperHeight={bin.hopperHeight()}
renderOrder={4}
/>
{showGrain && grainInventory()}
<BinCables
displayTemp={showTemp}
displayMoisture={showMoisture}
cableData={cableData}
nodeData={nodeData}
bin={bin}
@ -110,24 +135,34 @@ export default function Bin3dView(props: Props) {
}}
renderOrder={1}
/>
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
{showHeatmap && (
{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 />
<OrbitCameraControls
clampVerticalRotation
initialRadius={initialCameraRadius}
maxRadius={initialCameraRadius * 2}
onReset={fn => { resetCameraFn.current = fn; }}
/>
{/* <CameraOverlay
showResetButton={showResetButton}
onReset={() => resetCameraFn.current?.()}
/> */}
</Canvas>
)
}

View file

@ -11,11 +11,13 @@ interface Props {
bin: Bin
binCenter: Vector3,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
onNodeClick?: (node: NodeData, cable: CableData) => void
}
export default function BinCables(props: Props){
const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props
const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
return (
<React.Fragment>
{cableData.map((cable, i) => {
@ -28,6 +30,8 @@ export default function BinCables(props: Props){
nodes={cableNodes}
bin={bin}
binCenter={binCenter}
displayTemp={displayTemp}
displayMoisture={displayMoisture}
onNodeClick={(node) => {
if(onNodeClick){
onNodeClick(node, cable)

View file

@ -17,36 +17,39 @@ interface Props {
lowerThreshold: number
upperThreshold: number,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
onNodeClick?: (node: NodeData) => void
}
export default function CableNode(props: Props) {
const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder } = props
const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder, displayTemp, displayMoisture } = props
const { camera } = useThree();
const [{ user }] = useGlobalState();
const [showLabel, setShowLabel] = useState(false);
// const [showLabel, setShowLabel] = useState(false);
const labelGroupRef = React.useRef<Group>(null);
const ROW_HEIGHT = 35;
const PADDING = 20;
useFrame(() => {
const distance = camera.position.distanceTo(binCenter)
//this can be used to control label visibility through the cameras zoom level
// useFrame(() => {
// const distance = camera.position.distanceTo(binCenter)
const SHOW_DISTANCE = 15;
const HIDE_DISTANCE = 16;
// const SHOW_DISTANCE = 15;
// const HIDE_DISTANCE = 16;
if (!showLabel && distance < SHOW_DISTANCE) {
setShowLabel(true);
} else if (showLabel && distance > HIDE_DISTANCE) {
setShowLabel(false);
}
// if (!showLabel && distance < SHOW_DISTANCE) {
// setShowLabel(true);
// } else if (showLabel && distance > HIDE_DISTANCE) {
// setShowLabel(false);
// }
const scale = distance * 0.07;
// const scale = distance * 0.07;
if (labelGroupRef.current) {
labelGroupRef.current.scale.set(scale, scale, 1);
}
});
// if (labelGroupRef.current) {
// labelGroupRef.current.scale.set(scale, scale, 1);
// }
// });
const tempDisplay = useMemo(() => {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
@ -56,36 +59,47 @@ export default function CableNode(props: Props) {
}, [node.celcius, user]);
const rows = useMemo(() => {
const r: { text: string; color: string }[] = [];
const anyToggleOn = displayTemp || displayMoisture;
if (node.excluded) {
r.push({ text: "Excluded", color: "red" });
return r;
if (!anyToggleOn) return [];
return [{ text: "Excluded", color: "red" }];
}
if (node.topNode) {
r.push({ text: "Top Node", color: "yellow" });
}
r.push({
text: tempDisplay,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
});
if (node.moisture) {
const r: { text: string; color: string }[] = [];
// TEMP
if (displayTemp && node.celcius !== 0) {
r.push({
text: `${node.moisture.toFixed(2)}% EMC`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
});
} else if (node.humidity) {
r.push({
text: `${node.humidity.toFixed(2)}%`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
text: tempDisplay,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
});
}
// MOISTURE
if (displayMoisture) {
if (node.moisture && node.moisture > 0) {
r.push({
text: `${node.moisture.toFixed(2)}% EMC`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
});
} else if (node.humidity && node.humidity > 0) {
r.push({
text: `${node.humidity.toFixed(2)}%`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
});
}
}
const hasData = r.length > 0;
// Only show top node if there's actual data
if (node.topNode && hasData) {
r.unshift({ text: "Top Node", color: "yellow" });
}
return r;
}, [node, tempDisplay]);
}, [node, tempDisplay, displayTemp, displayMoisture]);
const geometry = React.useMemo(() => ({
radius: node.radius,
@ -126,6 +140,8 @@ export default function CableNode(props: Props) {
onNodeClick?.(node);
};
const showLabel = rows.length > 0;
return (
<React.Fragment>
{(!node.inGrain || !showLabel) &&

View file

@ -12,10 +12,12 @@ interface Props {
bin: Bin
binCenter: Vector3,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean
onNodeClick?: (node: NodeData) => void
}
export default function GrainCable(props: Props) {
const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props
const {cable, nodes, bin, binCenter, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
const calculateHeight = () => {
return cable.topPosition.distanceTo(cable.bottomPosition)
@ -42,7 +44,7 @@ export default function GrainCable(props: Props) {
<React.Fragment>
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
{nodes.map((node, i) => (
<CableNode onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
))}
</React.Fragment>
)