diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx
index 551e98e..3db6980 100644
--- a/src/bin/3dView/Scene/Bin3dView.tsx
+++ b/src/bin/3dView/Scene/Bin3dView.tsx
@@ -16,6 +16,7 @@ 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 { GrainCable } from "models/GrainCable";
interface Props {
@@ -66,23 +67,15 @@ export default function Bin3dView(props: Props) {
// 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(() => {
+ // Flat fill: Y bounds from the same volume math as GrainFillFlat.
+ const flatGrainBounds = 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;
+ return grainYBoundsFromFillPercent(
+ bin.diameter(),
+ bin.sidewallHeight(),
+ bin.hopperHeight() ?? 0,
+ fillPercent
+ );
}, [isCableInventory, fillPercent, bin]);
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay
@@ -157,14 +150,16 @@ export default function Bin3dView(props: Props) {
)}
{showMoistureHeatmap && (
)}
diff --git a/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx
index 290afce..19157af 100644
--- a/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx
+++ b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx
@@ -12,6 +12,8 @@ interface Props {
* Used when there are no cable top nodes to drive a surface.
*/
flatMaxY?: number;
+ /** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
+ flatMinY?: number;
}
const RADIAL_RINGS = 20;
@@ -125,7 +127,7 @@ function hashNoise(a: number, b: number, c: number) {
return (x - Math.floor(x)) * 2 - 1;
}
-export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
+export default function MoistureHeatMap({ bin, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = bin.diameter() / 2;
const sidewallHeight = bin.sidewallHeight();
const hopperHeight = bin.hopperHeight() ?? 0;
@@ -180,13 +182,15 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
}, [surfaceAnchors, flatMaxY, sidewallBaseY]);
+ // Lowest Y the grain surface or heatmap may occupy (hopper tip or cylinder floor).
+ const grainFloorY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
+
// For each (x, z) position, returns the Y ceiling of the grain surface.
// Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
const getSurfaceY = (x: number, z: number): number => {
if (surfaceAnchors.length > 0) {
- // Outermost ring clamps to wallY — matches GrainCableFill
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER);
- return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, raw));
+ return Math.max(grainFloorY, Math.min(sidewallHeight / 2, raw));
}
return flatMaxY ?? sidewallBaseY;
};
@@ -199,6 +203,13 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
return flatMaxY ?? sidewallBaseY;
}, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]);
+ // Bottom of filled grain. Flat fill uses volume bounds; cable fill rests on
+ // the hopper tip (matches GrainCableFill) rather than the lowest cable node.
+ const minGrainY = useMemo(() => {
+ if (flatMinY !== undefined) return flatMinY;
+ return grainFloorY;
+ }, [flatMinY, grainFloorY]);
+
const geometry = useMemo(() => {
if (!moistureAnchors.length) return null;
@@ -209,10 +220,7 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
const colors = new Float32Array(totalVerts * 3);
const heats = new Float32Array(totalVerts);
- const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
- const grainBottomY = hopperHeight > 0
- ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS
- : sidewallBaseY;
+ const grainBottomY = minGrainY;
const grainHeight = maxGrainY - grainBottomY;
if (grainHeight <= 0) return null;
@@ -360,15 +368,15 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
}
}
- // Hopper tip
+ // Close the bottom at the hopper tip only when grain reaches the tip.
const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
- if (hopperHeight > 0) {
- const tipMoisture = idwMoisture(0, rawBottomY, 0, moistureAnchors, IDW_POWER);
+ if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
+ const tipMoisture = idwMoisture(0, hopperTipY, 0, moistureAnchors, IDW_POWER);
const tipHeat = moistureToHeat(tipMoisture, targetMoisture);
const [tr, tg, tb] = heatToRGB(tipHeat);
positions[tipVertexIndex * 3] = 0;
- positions[tipVertexIndex * 3 + 1] = rawBottomY;
+ positions[tipVertexIndex * 3 + 1] = hopperTipY;
positions[tipVertexIndex * 3 + 2] = 0;
colors[tipVertexIndex * 3] = tr;
colors[tipVertexIndex * 3 + 1] = tg;
@@ -400,12 +408,14 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
surfaceAnchors,
wallY,
maxGrainY,
+ minGrainY,
hopperTipY,
sidewallBaseY,
binRadius,
targetMoisture,
hopperHeight,
flatMaxY,
+ flatMinY,
]);
if (!geometry) return null;
diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx
index dfacfce..d64b744 100644
--- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx
+++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx
@@ -12,6 +12,8 @@ interface Props {
* Used when there are no cable top nodes to drive a surface.
*/
flatMaxY?: number;
+ /** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
+ flatMinY?: number;
}
const RADIAL_RINGS = 20;
@@ -125,7 +127,7 @@ function hashNoise(a: number, b: number, c: number) {
return (x - Math.floor(x)) * 2 - 1;
}
-export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
+export default function TempHeatMap({ bin, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = bin.diameter() / 2;
const sidewallHeight = bin.sidewallHeight();
const hopperHeight = bin.hopperHeight() ?? 0;
@@ -180,13 +182,15 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
}, [surfaceAnchors, flatMaxY, sidewallBaseY]);
+ // Lowest Y the grain surface or heatmap may occupy (hopper tip or cylinder floor).
+ const grainFloorY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
+
// For each (x, z) position, returns the Y ceiling of the grain surface.
// Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
const getSurfaceY = (x: number, z: number): number => {
if (surfaceAnchors.length > 0) {
- // Outermost ring clamps to wallY — matches GrainCableFill
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER);
- return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, raw));
+ return Math.max(grainFloorY, Math.min(sidewallHeight / 2, raw));
}
return flatMaxY ?? sidewallBaseY;
};
@@ -198,7 +202,14 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
}
return flatMaxY ?? sidewallBaseY;
}, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]);
-
+
+ // Bottom of filled grain. Flat fill uses volume bounds; cable fill rests on
+ // the hopper tip (matches GrainCableFill) rather than the lowest cable node.
+ const minGrainY = useMemo(() => {
+ if (flatMinY !== undefined) return flatMinY;
+ return grainFloorY;
+ }, [flatMinY, grainFloorY]);
+
const geometry = useMemo(() => {
if (!tempAnchors.length) return null;
@@ -209,10 +220,7 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
const colors = new Float32Array(totalVerts * 3);
const heats = new Float32Array(totalVerts);
- const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
- const grainBottomY = hopperHeight > 0
- ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS
- : sidewallBaseY;
+ const grainBottomY = minGrainY;
const grainHeight = maxGrainY - grainBottomY;
if (grainHeight <= 0) return null;
@@ -360,21 +368,21 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
}
}
- // Hopper tip
+ // Close the bottom at the hopper tip only when grain reaches the tip.
const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
- if (hopperHeight > 0) {
- const tipTemp = idwTemp(0, rawBottomY, 0, tempAnchors, IDW_POWER);
+ if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
+ const tipTemp = idwTemp(0, hopperTipY, 0, tempAnchors, IDW_POWER);
const tipHeat = tempToHeat(tipTemp, upperThreshold);
const [tr, tg, tb] = heatToRGB(tipHeat);
-
+
positions[tipVertexIndex * 3] = 0;
- positions[tipVertexIndex * 3 + 1] = rawBottomY;
+ positions[tipVertexIndex * 3 + 1] = hopperTipY;
positions[tipVertexIndex * 3 + 2] = 0;
colors[tipVertexIndex * 3] = tr;
colors[tipVertexIndex * 3 + 1] = tg;
colors[tipVertexIndex * 3 + 2] = tb;
heats[tipVertexIndex] = tipHeat;
-
+
const outerRing = RADIAL_RINGS - 1;
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const next = (seg + 1) % THETA_SEGMENTS;
@@ -400,12 +408,14 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
surfaceAnchors,
wallY,
maxGrainY,
+ minGrainY,
hopperTipY,
sidewallBaseY,
binRadius,
upperThreshold,
hopperHeight,
flatMaxY,
+ flatMinY,
]);
if (!geometry) return null;
diff --git a/src/bin/3dView/utils/grainFillBounds.ts b/src/bin/3dView/utils/grainFillBounds.ts
new file mode 100644
index 0000000..698fcb0
--- /dev/null
+++ b/src/bin/3dView/utils/grainFillBounds.ts
@@ -0,0 +1,49 @@
+/**
+ * Y bounds of filled grain volume — matches GrainFillFlat / GrainCableFill fallback volume math.
+ */
+export interface GrainYBounds {
+ minY: number;
+ maxY: number;
+}
+
+export function grainYBoundsFromFillPercent(
+ diameter: number,
+ sidewallHeight: number,
+ hopperHeight: number,
+ fillPercent: number
+): GrainYBounds {
+ const radius = diameter / 2;
+ const sidewallBaseY = -sidewallHeight / 2;
+ const hopperTipY = sidewallBaseY - hopperHeight;
+
+ if (fillPercent <= 0) {
+ return { minY: sidewallBaseY, maxY: sidewallBaseY };
+ }
+
+ const cylinderVolume = Math.PI * radius * radius * sidewallHeight;
+ const hopperVolume =
+ hopperHeight > 0 ? (1 / 3) * Math.PI * radius * radius * hopperHeight : 0;
+ const totalVolume = cylinderVolume + hopperVolume;
+ const filledVolume = totalVolume * fillPercent;
+
+ if (hopperHeight > 0 && filledVolume <= hopperVolume) {
+ const ratio = filledVolume / hopperVolume;
+ const hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3);
+ return {
+ minY: hopperTipY,
+ maxY: hopperTipY + hopperFillHeight,
+ };
+ }
+
+ const hopperFillHeight = hopperHeight;
+ const remainingVolume = filledVolume - hopperVolume;
+ const cylinderFillHeight = Math.max(
+ 0,
+ remainingVolume / (Math.PI * radius * radius)
+ );
+
+ return {
+ minY: hopperHeight > 0 ? hopperTipY : sidewallBaseY,
+ maxY: sidewallBaseY + cylinderFillHeight,
+ };
+}
diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx
index d637c05..3bfc671 100644
--- a/src/bin/binSummary/BinSummary.tsx
+++ b/src/bin/binSummary/BinSummary.tsx
@@ -161,7 +161,7 @@ export default function BinSummary(props: Props){
fontSize: "0.875rem", // match body2
}}
/>
-
+
{currentFill()}/{bin.grainCapacity(user)}
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ?