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 { 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) {
<TempHeatMap
bin={bin}
nodes={nodeData}
flatMaxY={flatMaxY}
flatMaxY={flatGrainBounds?.maxY}
flatMinY={flatGrainBounds?.minY}
/>
)}
{showMoistureHeatmap && (
<MoistureHeatMap
bin={bin}
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.
*/
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;

View file

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

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
}}
/>
<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)}
</Typography>
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ?