set up some defaults for bins that are not using advanced dimensions so that they can still have a visual of some sort

This commit is contained in:
csawatzky 2026-06-03 15:59:14 -06:00
parent 1d19a7e993
commit 160d2a9d31
5 changed files with 102 additions and 56 deletions

View file

@ -1,3 +1,4 @@
import { RadiansToDegrees } from "common/TrigFunctions";
import { Bin } from "models"; import { Bin } from "models";
import { GrainCable } from "models/GrainCable"; import { GrainCable } from "models/GrainCable";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
@ -21,6 +22,19 @@ export interface CableOrbit {
assignedCount?: number; assignedCount?: number;
} }
/**
* Effective bin dimensions used for cable placement.
* Passed in from Bin3dView so that default fallback values are applied
* consistently cables will always match the rendered shell even when
* the bin has no advanced dimensions configured.
*/
export interface BinDims {
diameter: number
sidewallHeight: number
roofHeight: number
hopperHeight: number
}
function getLayoutFromDiameter(diameterCm: number): CableOrbit[] { function getLayoutFromDiameter(diameterCm: number): CableOrbit[] {
const diameterFt = diameterCm / 30.48; const diameterFt = diameterCm / 30.48;
@ -93,34 +107,36 @@ function distributeCables(cableCount: number, layout: CableOrbit[]) {
return assigned; return assigned;
}; };
function getTopY(radiusFromCenter: number, bin: Bin) { function getTopY(radiusFromCenter: number, dims: BinDims) {
const binRadius = bin.diameter() / 2; const binRadius = dims.diameter / 2;
const distanceFromEdge = binRadius - radiusFromCenter; const distanceFromEdge = binRadius - radiusFromCenter;
const angleRad = bin.roofAngle() * (Math.PI / 180); // Use the real roof angle when available; when dimensions aren't configured
// the angle will be 0 which gives a flat cable top — correct for a default shape.
const angleRad = Math.atan((dims.roofHeight) / (binRadius));
const roofRise = Math.tan(angleRad) * distanceFromEdge; const roofRise = Math.tan(angleRad) * distanceFromEdge;
return bin.sidewallHeight() / 2 + roofRise; return dims.sidewallHeight / 2 + roofRise;
}; };
function getBottomY(radiusFromCenter: number, bin: Bin) { function getBottomY(radiusFromCenter: number, dims: BinDims) {
const binRadius = bin.diameter() / 2; const binRadius = dims.diameter / 2;
const distanceFromCenter = binRadius - radiusFromCenter; const distanceFromCenter = binRadius - radiusFromCenter;
const angleRad = bin.hopperAngle() * (Math.PI / 180); const angleRad = Math.atan((dims.hopperHeight) / (binRadius))
const hopperDrop = Math.tan(angleRad) * distanceFromCenter; const hopperDrop = Math.tan(angleRad) * distanceFromCenter;
const clearance = 60 //this is in cm const clearance = 60; // cm
return -bin.sidewallHeight() / 2 - hopperDrop + clearance; return -dims.sidewallHeight / 2 - hopperDrop + clearance;
}; };
export function BuildCableData(bin: Bin): CableData[] { export function BuildCableData(bin: Bin, dims: BinDims): CableData[] {
const cables = [...(bin.status.grainCables ?? [])]; const cables = [...(bin.status.grainCables ?? [])];
if (cables.length === 0) return []; if (cables.length === 0) return [];
@ -144,10 +160,9 @@ export function BuildCableData(bin: Bin): CableData[] {
return b.celcius.length - a.celcius.length; return b.celcius.length - a.celcius.length;
}); });
const layout = getLayoutFromDiameter(bin.diameter()); const layout = getLayoutFromDiameter(dims.diameter);
const distributed = distributeCables(cables.length, layout); const distributed = distributeCables(cables.length, layout);
const cableRadius = 5; const cableRadius = 5;
const result: CableData[] = []; const result: CableData[] = [];
@ -157,12 +172,12 @@ export function BuildCableData(bin: Bin): CableData[] {
distributed.forEach(orbit => { distributed.forEach(orbit => {
const count = orbit.assignedCount ?? 0; const count = orbit.assignedCount ?? 0;
const topY = getTopY(orbit.radius, bin) const topY = getTopY(orbit.radius, dims);
const bottomY = getBottomY(orbit.radius, bin) const bottomY = getBottomY(orbit.radius, dims);
if (orbit.orbit === 0 && count > 0) { if (orbit.orbit === 0 && count > 0) {
const cable = cables[cableIndex]; const cable = cables[cableIndex];
if (!cable) return if (!cable) return;
result.push({ result.push({
cableIndex: cableIndex, cableIndex: cableIndex,
radius: cableRadius, radius: cableRadius,
@ -170,7 +185,7 @@ export function BuildCableData(bin: Bin): CableData[] {
bottomPosition: new Vector3(0, bottomY, 0), bottomPosition: new Vector3(0, bottomY, 0),
grainCable: cable grainCable: cable
}); });
cableIndex++ cableIndex++;
return; return;
} }
@ -187,7 +202,7 @@ export function BuildCableData(bin: Bin): CableData[] {
bottomPosition: new Vector3(x, bottomY, z), bottomPosition: new Vector3(x, bottomY, z),
grainCable: cable grainCable: cable
}); });
cableIndex++ cableIndex++;
} }
}); });

View file

@ -17,7 +17,14 @@ import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap"; import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap";
import { grainYBoundsFromFillPercent } from "../utils/grainFillBounds"; import { grainYBoundsFromFillPercent } from "../utils/grainFillBounds";
// import { GrainCable } from "models/GrainCable"; 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 { interface Props {
bin: Bin bin: Bin
@ -46,18 +53,28 @@ interface Props {
export default function Bin3dView(props: Props) { export default function Bin3dView(props: Props) {
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture, xOffset } = 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. // Compute the initial camera radius so the full bin fits in frame.
// Uses the perspective camera FOV (default 75°) with a padding factor. // Uses the perspective camera FOV (default 75°) with a padding factor.
// Both the diameter and total height are considered so neither is clipped. // Both the diameter and total height are considered so neither is clipped.
const initialCameraRadius = useMemo(() => { const initialCameraRadius = useMemo(() => {
const totalHeight = (bin.sidewallHeight() + bin.roofHeight() + (bin.hopperHeight() ?? 0)) / scale; const totalHeight = (dims.sidewallHeight + dims.roofHeight + dims.hopperHeight) / scale;
const diameter = bin.diameter() / scale; const diameter = dims.diameter / scale;
const largestDim = Math.max(totalHeight, diameter); const largestDim = Math.max(totalHeight, diameter);
const fovRad = (75 * Math.PI) / 180; const fovRad = (75 * Math.PI) / 180;
const padding = 1.3; // 30% breathing room const padding = 1.3; // 30% breathing room
return (largestDim / (2 * Math.tan(fovRad / 2))) * padding; return (largestDim / (2 * Math.tan(fovRad / 2))) * padding;
}, [bin, scale]); }, [dims, scale]);
const cableData = useMemo(() => BuildCableData(bin), [bin]);
const cableData = useMemo(() => BuildCableData(bin, dims), [bin, dims]);
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
const isCableInventory = const isCableInventory =
@ -71,12 +88,12 @@ export default function Bin3dView(props: Props) {
const flatGrainBounds = useMemo(() => { const flatGrainBounds = useMemo(() => {
if (isCableInventory || fillPercent === undefined) return undefined; if (isCableInventory || fillPercent === undefined) return undefined;
return grainYBoundsFromFillPercent( return grainYBoundsFromFillPercent(
bin.diameter(), dims.diameter,
bin.sidewallHeight(), dims.sidewallHeight,
bin.hopperHeight() ?? 0, dims.hopperHeight,
fillPercent fillPercent
); );
}, [isCableInventory, fillPercent, bin]); }, [isCableInventory, fillPercent, dims]);
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay // Internal ref — wired to OrbitCameraControls and called by CameraOverlay
const resetCameraFn = React.useRef<(() => void) | null>(null); const resetCameraFn = React.useRef<(() => void) | null>(null);
@ -85,20 +102,20 @@ export default function Bin3dView(props: Props) {
if (isCableInventory) { if (isCableInventory) {
return ( return (
<GrainCableFill <GrainCableFill
diameter={bin.diameter()} diameter={dims.diameter}
nodes={nodeData} nodes={nodeData}
sidewallHeight={bin.sidewallHeight()} sidewallHeight={dims.sidewallHeight}
fallbackFillPercent={fillPercent} fallbackFillPercent={fillPercent}
hopperHeight={bin.hopperHeight()} hopperHeight={dims.hopperHeight}
grainOpacity={0.3} grainOpacity={0.3}
/> />
) )
} else if (fillPercent) { } else if (fillPercent) {
return ( return (
<GrainFillFlat <GrainFillFlat
diameter={bin.diameter()} diameter={dims.diameter}
sidewallHeight={bin.sidewallHeight()} sidewallHeight={dims.sidewallHeight}
hopperHeight={bin.hopperHeight()} hopperHeight={dims.hopperHeight}
fillPercent={fillPercent} fillPercent={fillPercent}
grainOpacity={0.3} grainOpacity={0.3}
/> />
@ -122,10 +139,10 @@ export default function Bin3dView(props: Props) {
binMetalness={0.5} binMetalness={0.5}
binRoughness={0.7} binRoughness={0.7}
binOpacity={0.2} binOpacity={0.2}
diameter={bin.diameter()} diameter={dims.diameter}
sidewallHeight={bin.sidewallHeight()} sidewallHeight={dims.sidewallHeight}
roofHeight={bin.roofHeight()} roofHeight={dims.roofHeight}
hopperHeight={bin.hopperHeight()} hopperHeight={dims.hopperHeight}
renderOrder={4} renderOrder={4}
/> />
@ -145,10 +162,11 @@ export default function Bin3dView(props: Props) {
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />} {showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
{/* note that the heatmaps insternally use render order 1,2, and 3 for the three seperate coloured meshes */} {/* note that the heatmaps internally use render order 1,2, and 3 for the three separate coloured meshes */}
{showTempHeatmap && ( {showTempHeatmap && (
<TempHeatMap <TempHeatMap
bin={bin} binDimensions={dims}
targetTemp={bin.targetTemp()}
nodes={nodeData} nodes={nodeData}
flatMaxY={flatGrainBounds?.maxY} flatMaxY={flatGrainBounds?.maxY}
flatMinY={flatGrainBounds?.minY} flatMinY={flatGrainBounds?.minY}
@ -156,7 +174,8 @@ export default function Bin3dView(props: Props) {
)} )}
{showMoistureHeatmap && ( {showMoistureHeatmap && (
<MoistureHeatMap <MoistureHeatMap
bin={bin} binDimensions={dims}
targetMoisture={bin.targetMoisture()}
nodes={nodeData} nodes={nodeData}
flatMaxY={flatGrainBounds?.maxY} flatMaxY={flatGrainBounds?.maxY}
flatMinY={flatGrainBounds?.minY} flatMinY={flatGrainBounds?.minY}
@ -171,6 +190,10 @@ export default function Bin3dView(props: Props) {
<directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} /> <directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} />
<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> </Canvas>
) )
} }

View file

@ -3,9 +3,11 @@ import * as THREE from "three";
import { Bin } from "models"; import { Bin } from "models";
import { NodeData } from "../../Data/BuildNodeData"; import { NodeData } from "../../Data/BuildNodeData";
import React from "react"; import React from "react";
import { BinDims } from "bin/3dView/Data/BuildCableData";
interface Props { interface Props {
bin: Bin; binDimensions: BinDims
targetMoisture: number
nodes: NodeData[]; nodes: NodeData[];
/** /**
* For flat fill percent inventory a scalar Y ceiling for the heatmap top. * For flat fill percent inventory a scalar Y ceiling for the heatmap top.
@ -127,11 +129,10 @@ function hashNoise(a: number, b: number, c: number) {
return (x - Math.floor(x)) * 2 - 1; return (x - Math.floor(x)) * 2 - 1;
} }
export default function MoistureHeatMap({ bin, nodes, flatMaxY, flatMinY }: Props) { export default function MoistureHeatMap({ binDimensions, targetMoisture, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = bin.diameter() / 2; const binRadius = binDimensions.diameter / 2;
const sidewallHeight = bin.sidewallHeight(); const sidewallHeight = binDimensions.sidewallHeight;
const hopperHeight = bin.hopperHeight() ?? 0; const hopperHeight = binDimensions.hopperHeight ?? 0;
const targetMoisture = bin.targetMoisture();
const sidewallBaseY = -sidewallHeight / 2; const sidewallBaseY = -sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight; const hopperTipY = sidewallBaseY - hopperHeight;

View file

@ -3,9 +3,11 @@ import * as THREE from "three";
import { Bin } from "models"; import { Bin } from "models";
import { NodeData } from "../../Data/BuildNodeData"; import { NodeData } from "../../Data/BuildNodeData";
import React from "react"; import React from "react";
import { BinDims } from "bin/3dView/Data/BuildCableData";
interface Props { interface Props {
bin: Bin; binDimensions: BinDims
targetTemp: number
nodes: NodeData[]; nodes: NodeData[];
/** /**
* For flat fill percent inventory a scalar Y ceiling for the heatmap top. * For flat fill percent inventory a scalar Y ceiling for the heatmap top.
@ -127,11 +129,11 @@ function hashNoise(a: number, b: number, c: number) {
return (x - Math.floor(x)) * 2 - 1; return (x - Math.floor(x)) * 2 - 1;
} }
export default function TempHeatMap({ bin, nodes, flatMaxY, flatMinY }: Props) { export default function TempHeatMap({ binDimensions, targetTemp, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = bin.diameter() / 2; const binRadius = binDimensions.diameter / 2;
const sidewallHeight = bin.sidewallHeight(); const sidewallHeight = binDimensions.sidewallHeight;
const hopperHeight = bin.hopperHeight() ?? 0; const hopperHeight = binDimensions.hopperHeight ?? 0;
const upperThreshold = bin.targetTemp(); const upperThreshold = targetTemp;
const sidewallBaseY = -sidewallHeight / 2; const sidewallBaseY = -sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight; const hopperTipY = sidewallBaseY - hopperHeight;

View file

@ -161,7 +161,12 @@ export default function BinSummary(props: Props){
fontSize: "0.875rem", // match body2 fontSize: "0.875rem", // match body2
}} }}
/> />
<Typography variant="body2" noWrap sx={{ width: bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? fillCapWidth : undefined, flexShrink: 0 }}> <Typography
variant="body2"
noWrap
sx={{
width: (bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL || bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_UNKNOWN) ? fillCapWidth : undefined, flexShrink: 0
}}>
{currentFill()}/{bin.grainCapacity(user)} {currentFill()}/{bin.grainCapacity(user)}
</Typography> </Typography>
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ?