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 ?