fixed the hopper fill only filling it completely and not doing the partial fill
This commit is contained in:
parent
13c15ec195
commit
1d19a7e993
5 changed files with 108 additions and 44 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
49
src/bin/3dView/utils/grainFillBounds.ts
Normal file
49
src/bin/3dView/utils/grainFillBounds.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 ?
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue