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 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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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
|
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 ?
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue