/** * 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, }; }