fixed the hopper fill only filling it completely and not doing the partial fill

This commit is contained in:
csawatzky 2026-06-03 12:49:03 -06:00
parent 13c15ec195
commit 1d19a7e993
5 changed files with 108 additions and 44 deletions

View file

@ -16,6 +16,7 @@ import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; 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 { GrainCable } from "models/GrainCable"; // import { GrainCable } from "models/GrainCable";
interface Props { interface Props {
@ -66,23 +67,15 @@ export default function Bin3dView(props: Props) {
// For cable inventory, TempHeatMap derives the wavy surface directly from // For cable inventory, TempHeatMap derives the wavy surface directly from
// the top nodes in nodeData — no scalar needed. // the top nodes in nodeData — no scalar needed.
// //
// For flat fill percent, we convert the fill percent to a Y coordinate // Flat fill: Y bounds from the same volume math as GrainFillFlat.
// using the same volume math as GrainFillFlat and pass it as flatMaxY. const flatGrainBounds = useMemo(() => {
const flatMaxY = useMemo(() => {
if (isCableInventory || fillPercent === undefined) return undefined; if (isCableInventory || fillPercent === undefined) return undefined;
return grainYBoundsFromFillPercent(
const radius = bin.diameter() / 2; bin.diameter(),
const sidewallH = bin.sidewallHeight(); bin.sidewallHeight(),
const hopperH = bin.hopperHeight() ?? 0; bin.hopperHeight() ?? 0,
const cylVolume = Math.PI * radius * radius * sidewallH; fillPercent
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]); }, [isCableInventory, fillPercent, bin]);
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay // Internal ref — wired to OrbitCameraControls and called by CameraOverlay
@ -157,14 +150,16 @@ export default function Bin3dView(props: Props) {
<TempHeatMap <TempHeatMap
bin={bin} bin={bin}
nodes={nodeData} nodes={nodeData}
flatMaxY={flatMaxY} flatMaxY={flatGrainBounds?.maxY}
flatMinY={flatGrainBounds?.minY}
/> />
)} )}
{showMoistureHeatmap && ( {showMoistureHeatmap && (
<MoistureHeatMap <MoistureHeatMap
bin={bin} bin={bin}
nodes={nodeData} nodes={nodeData}
flatMaxY={flatMaxY} flatMaxY={flatGrainBounds?.maxY}
flatMinY={flatGrainBounds?.minY}
/> />
)} )}

View file

@ -12,6 +12,8 @@ interface Props {
* Used when there are no cable top nodes to drive a surface. * Used when there are no cable top nodes to drive a surface.
*/ */
flatMaxY?: number; flatMaxY?: number;
/** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
flatMinY?: number;
} }
const RADIAL_RINGS = 20; const RADIAL_RINGS = 20;
@ -125,7 +127,7 @@ 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 }: Props) { export default function MoistureHeatMap({ bin, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = bin.diameter() / 2; const binRadius = bin.diameter() / 2;
const sidewallHeight = bin.sidewallHeight(); const sidewallHeight = bin.sidewallHeight();
const hopperHeight = bin.hopperHeight() ?? 0; 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; return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
}, [surfaceAnchors, flatMaxY, sidewallBaseY]); }, [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. // 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. // Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
const getSurfaceY = (x: number, z: number): number => { const getSurfaceY = (x: number, z: number): number => {
if (surfaceAnchors.length > 0) { if (surfaceAnchors.length > 0) {
// Outermost ring clamps to wallY — matches GrainCableFill
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER); 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; return flatMaxY ?? sidewallBaseY;
}; };
@ -199,6 +203,13 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
return flatMaxY ?? sidewallBaseY; return flatMaxY ?? sidewallBaseY;
}, [surfaceAnchors, wallY, 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(() => { const geometry = useMemo(() => {
if (!moistureAnchors.length) return null; if (!moistureAnchors.length) return null;
@ -209,10 +220,7 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
const colors = new Float32Array(totalVerts * 3); const colors = new Float32Array(totalVerts * 3);
const heats = new Float32Array(totalVerts); const heats = new Float32Array(totalVerts);
const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; const grainBottomY = minGrainY;
const grainBottomY = hopperHeight > 0
? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS
: sidewallBaseY;
const grainHeight = maxGrainY - grainBottomY; const grainHeight = maxGrainY - grainBottomY;
if (grainHeight <= 0) return null; 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; const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
if (hopperHeight > 0) { if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
const tipMoisture = idwMoisture(0, rawBottomY, 0, moistureAnchors, IDW_POWER); const tipMoisture = idwMoisture(0, hopperTipY, 0, moistureAnchors, IDW_POWER);
const tipHeat = moistureToHeat(tipMoisture, targetMoisture); const tipHeat = moistureToHeat(tipMoisture, targetMoisture);
const [tr, tg, tb] = heatToRGB(tipHeat); const [tr, tg, tb] = heatToRGB(tipHeat);
positions[tipVertexIndex * 3] = 0; positions[tipVertexIndex * 3] = 0;
positions[tipVertexIndex * 3 + 1] = rawBottomY; positions[tipVertexIndex * 3 + 1] = hopperTipY;
positions[tipVertexIndex * 3 + 2] = 0; positions[tipVertexIndex * 3 + 2] = 0;
colors[tipVertexIndex * 3] = tr; colors[tipVertexIndex * 3] = tr;
colors[tipVertexIndex * 3 + 1] = tg; colors[tipVertexIndex * 3 + 1] = tg;
@ -400,12 +408,14 @@ export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) {
surfaceAnchors, surfaceAnchors,
wallY, wallY,
maxGrainY, maxGrainY,
minGrainY,
hopperTipY, hopperTipY,
sidewallBaseY, sidewallBaseY,
binRadius, binRadius,
targetMoisture, targetMoisture,
hopperHeight, hopperHeight,
flatMaxY, flatMaxY,
flatMinY,
]); ]);
if (!geometry) return null; if (!geometry) return null;

View file

@ -12,6 +12,8 @@ interface Props {
* Used when there are no cable top nodes to drive a surface. * Used when there are no cable top nodes to drive a surface.
*/ */
flatMaxY?: number; flatMaxY?: number;
/** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
flatMinY?: number;
} }
const RADIAL_RINGS = 20; const RADIAL_RINGS = 20;
@ -125,7 +127,7 @@ 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 }: Props) { export default function TempHeatMap({ bin, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = bin.diameter() / 2; const binRadius = bin.diameter() / 2;
const sidewallHeight = bin.sidewallHeight(); const sidewallHeight = bin.sidewallHeight();
const hopperHeight = bin.hopperHeight() ?? 0; 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; return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
}, [surfaceAnchors, flatMaxY, sidewallBaseY]); }, [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. // 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. // Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
const getSurfaceY = (x: number, z: number): number => { const getSurfaceY = (x: number, z: number): number => {
if (surfaceAnchors.length > 0) { if (surfaceAnchors.length > 0) {
// Outermost ring clamps to wallY — matches GrainCableFill
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER); 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; return flatMaxY ?? sidewallBaseY;
}; };
@ -199,6 +203,13 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
return flatMaxY ?? sidewallBaseY; return flatMaxY ?? sidewallBaseY;
}, [surfaceAnchors, wallY, 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(() => { const geometry = useMemo(() => {
if (!tempAnchors.length) return null; if (!tempAnchors.length) return null;
@ -209,10 +220,7 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
const colors = new Float32Array(totalVerts * 3); const colors = new Float32Array(totalVerts * 3);
const heats = new Float32Array(totalVerts); const heats = new Float32Array(totalVerts);
const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; const grainBottomY = minGrainY;
const grainBottomY = hopperHeight > 0
? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS
: sidewallBaseY;
const grainHeight = maxGrainY - grainBottomY; const grainHeight = maxGrainY - grainBottomY;
if (grainHeight <= 0) return null; if (grainHeight <= 0) return null;
@ -360,15 +368,15 @@ 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; const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
if (hopperHeight > 0) { if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
const tipTemp = idwTemp(0, rawBottomY, 0, tempAnchors, IDW_POWER); const tipTemp = idwTemp(0, hopperTipY, 0, tempAnchors, IDW_POWER);
const tipHeat = tempToHeat(tipTemp, upperThreshold); const tipHeat = tempToHeat(tipTemp, upperThreshold);
const [tr, tg, tb] = heatToRGB(tipHeat); const [tr, tg, tb] = heatToRGB(tipHeat);
positions[tipVertexIndex * 3] = 0; positions[tipVertexIndex * 3] = 0;
positions[tipVertexIndex * 3 + 1] = rawBottomY; positions[tipVertexIndex * 3 + 1] = hopperTipY;
positions[tipVertexIndex * 3 + 2] = 0; positions[tipVertexIndex * 3 + 2] = 0;
colors[tipVertexIndex * 3] = tr; colors[tipVertexIndex * 3] = tr;
colors[tipVertexIndex * 3 + 1] = tg; colors[tipVertexIndex * 3 + 1] = tg;
@ -400,12 +408,14 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
surfaceAnchors, surfaceAnchors,
wallY, wallY,
maxGrainY, maxGrainY,
minGrainY,
hopperTipY, hopperTipY,
sidewallBaseY, sidewallBaseY,
binRadius, binRadius,
upperThreshold, upperThreshold,
hopperHeight, hopperHeight,
flatMaxY, flatMaxY,
flatMinY,
]); ]);
if (!geometry) return null; if (!geometry) return null;

View file

@ -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,
};
}

View file

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