From 0701be2539fdf8e8bc7b1888ebb10c9e1187cff1 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 23 Apr 2026 13:03:32 -0600 Subject: [PATCH] added the fill system for cable controlled inventory --- src/bin/3dView/Data/BuildNodeData.ts | 28 +- src/bin/3dView/Scene/Bin3dView.tsx | 35 +- .../Systems/Inventory/GrainCableFill.tsx | 329 ++++++++++++++++++ 3 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 src/bin/3dView/Systems/Inventory/GrainCableFill.tsx diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index bf6fad3..4196820 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -12,6 +12,12 @@ export interface NodeData { topNode?: boolean excluded?: boolean inGrain?: boolean + /** + * The vertical spacing between nodes on this cable (cm). + * Used by GrainCableFill to offset the grain surface half a spacing above the top node, + * matching the 2D bin view behaviour. + */ + nodeSpacing: number } export function BuildNodeData(cables: CableData[]): NodeData[] { @@ -31,23 +37,13 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const spacing = height / nodeCount; - - grainCable.celcius.forEach((celcius,i) => { - + grainCable.celcius.forEach((celcius, i) => { + const nodeY = bottomY + (i * spacing); const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius - //this is for testing to force a single node to a specific temp - // if (i === 0) { - // t = -10 - // } - // if (i === 1) { - // t = 30 - // } - - nodeData.push({ cableIndex: cableIndex, @@ -57,13 +53,11 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { celcius: t, humidity: humidity, moisture: moisture, - topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index + topNode: grainCable.topNode === i + 1, excluded: grainCable.excludedNodes?.includes(i) ?? false, - inGrain: i+1 <= grainCable.topNode - // optional: (may want to add this to the node data later) - // normalizedHeight: (nodeY - bottomY) / height + inGrain: i + 1 <= grainCable.topNode || !grainCable.topNode, + nodeSpacing: spacing, }) - }) }) diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 8c0c377..45fcdb6 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,8 +8,9 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData } from "../Data/BuildCableData"; import { BuildNodeData } from "../Data/BuildNodeData"; -import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import { pond } from "protobuf-ts/pond"; +import GrainCableFill from "../Systems/Inventory/GrainCableFill"; interface Props { /** @@ -37,6 +38,29 @@ export default function Bin3dView(props: Props){ const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); + const grainInventory = () => { + if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || + bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE){ + return ( + + ) + }else if (fillPercent){ + + } + } + return ( @@ -54,14 +78,7 @@ export default function Bin3dView(props: Props){ /> {/* grain - cylinder*/} - {fillPercent !== undefined && ( - - )} + {grainInventory()} {/* cables */} diff --git a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx new file mode 100644 index 0000000..0ae8a2e --- /dev/null +++ b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx @@ -0,0 +1,329 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import { NodeData } from "../../Data/BuildNodeData"; +import Cone from "3dModels/Shapes/3D/Cone"; +import { Vector3, Euler } from "three"; +import Cylinder from "3dModels/Shapes/3D/Cylinder"; + +interface Props { + diameter: number; + sidewallHeight: number; + hopperHeight?: number; + nodes: NodeData[]; + grainOpacity?: number + /** + * Fallback flat fill percent (0–1) used when no top nodes are available. + * If undefined and no top nodes exist, nothing is rendered. + */ + fallbackFillPercent?: number; +} + +// Tuning knobs +const RADIAL_RINGS = 24; // vertex rings radiating outward from center +const THETA_SEGMENTS = 36; // vertices around each ring + +/** + * Inverse-distance weighted interpolation of Y height at a given (x, z) point. + * Each top node contributes a weighted Y based on its horizontal distance from the point. + * The power parameter controls how sharply nearer nodes dominate (2 = standard IDW). + */ +function idwHeight( + x: number, + z: number, + anchors: { x: number; z: number; y: number }[], + power = 2 +): number { + let totalWeight = 0; + let weightedY = 0; + + for (const anchor of anchors) { + const dx = x - anchor.x; + const dz = z - anchor.z; + const distSq = dx * dx + dz * dz; + + // If we're sitting exactly on an anchor, return its Y immediately + if (distSq < 0.001) return anchor.y; + + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedY += anchor.y * weight; + } + + return weightedY / totalWeight; +} + +export default function GrainCableFill(props: Props) { + const { + diameter, + sidewallHeight, + hopperHeight = 0, + nodes, + fallbackFillPercent, + grainOpacity + } = props; + + const binRadius = diameter / 2; + // Slightly inset to avoid z-fighting with the shell + const grainRadius = binRadius * 0.98; + const grainColour = "#fff302"; + + // --- Collect top nodes (non-excluded, inGrain) --- + const topNodes = useMemo(() => + nodes.filter(n => n.topNode && n.inGrain && !n.excluded), + [nodes] + ); + + // --- Build surface anchors: top node Y + half spacing offset --- + // This places the grain line halfway between the top node and the node above it, + // matching the 2D bin view convention. + const anchors = useMemo(() => + topNodes.map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })), + [topNodes] + ); + + // --- Wall clamp: average of all anchor Y values --- + // Prevents grain from piling up at the wall where there are no cables. + const wallY = useMemo(() => { + if (anchors.length === 0) return -sidewallHeight / 2; + return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length; + }, [anchors, sidewallHeight]); + + // --- Build the polar surface mesh --- + const surfaceGeometry = useMemo(() => { + if (anchors.length === 0) return null; + + // Total vertices: center point + (RADIAL_RINGS * THETA_SEGMENTS) ring vertices + const ringCount = RADIAL_RINGS; + const segCount = THETA_SEGMENTS; + const vertexCount = 1 + ringCount * segCount; + + const positions = new Float32Array(vertexCount * 3); + const normals = new Float32Array(vertexCount * 3); + const uvs = new Float32Array(vertexCount * 2); + + // Center vertex + const centerY = idwHeight(0, 0, anchors); + positions[0] = 0; + positions[1] = centerY; + positions[2] = 0; + normals[0] = 0; normals[1] = 1; normals[2] = 0; + uvs[0] = 0.5; uvs[1] = 0.5; + + // Ring vertices + for (let ring = 0; ring < ringCount; ring++) { + // sqrt distribution for even area density across rings + const t = Math.sqrt((ring + 1) / ringCount); + const r = t * grainRadius; + + for (let seg = 0; seg < segCount; seg++) { + const angle = (seg / segCount) * Math.PI * 2; + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + // Outermost ring clamps to wall average; inner rings interpolate + //const isOuterRing = ring === ringCount - 1; + // const y = idwHeight(x, z, anchors); + const rawY = idwHeight(x, z, anchors); + + // Blend outer 20% of radius toward wallY + const edgeStart = 0.8; // start taper at 80% radius + const blendT = Math.max(0, (t - edgeStart) / (1 - edgeStart)); + + // smoothstep + const s = blendT * blendT * (3 - 2 * blendT); + + const y = rawY * (1 - s) + wallY * s; + + // Clamp Y to valid range: no higher than roof base, no lower than bin floor + const clampedY = Math.max( + -sidewallHeight / 2, + Math.min(sidewallHeight / 2, y) + ); + + const vi = (1 + ring * segCount + seg) * 3; + positions[vi] = x; + positions[vi + 1] = clampedY; + positions[vi + 2] = z; + + // Approximate normals — pointing up (will look fine for grain) + normals[vi] = 0; normals[vi + 1] = 1; normals[vi + 2] = 0; + + const ui = (1 + ring * segCount + seg) * 2; + uvs[ui] = (x / grainRadius) * 0.5 + 0.5; + uvs[ui + 1] = (z / grainRadius) * 0.5 + 0.5; + } + } + + // --- Build triangle indices --- + // Center fan: triangles from center point to first ring + const indexList: number[] = []; + + for (let seg = 0; seg < segCount; seg++) { + const next = (seg + 1) % segCount; + indexList.push(0, 1 + seg, 1 + next); + } + + // Ring quads: two triangles per quad between adjacent rings + for (let ring = 0; ring < ringCount - 1; ring++) { + for (let seg = 0; seg < segCount; seg++) { + const next = (seg + 1) % segCount; + + const a = 1 + ring * segCount + seg; + const b = 1 + ring * segCount + next; + const c = 1 + (ring + 1) * segCount + seg; + const d = 1 + (ring + 1) * segCount + next; + + indexList.push(a, c, b); + indexList.push(b, c, d); + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); + geo.setIndex(indexList); + geo.computeVertexNormals(); // smooth out the approximated normals + + return geo; + }, [anchors, wallY, grainRadius, sidewallHeight]); + + // --- Hopper fill (reuse existing cone approach) --- + // The surface mesh handles the cylindrical portion. + // The hopper below is always fully filled if the surface is above the bin floor. + const lowestSurfaceY = useMemo(() => { + if (anchors.length === 0) return -sidewallHeight / 2; + return Math.min(...anchors.map(a => a.y), wallY); + }, [anchors, wallY, sidewallHeight]); + + const hopperIsActive = hopperHeight > 0 && lowestSurfaceY > -sidewallHeight / 2; + + const hopperPosition = useMemo( + () => new Vector3(0, -(sidewallHeight / 2 + hopperHeight / 2), 0), + [sidewallHeight, hopperHeight] + ); + + const hopperRotation = useMemo(() => new Euler(Math.PI, 0, 0), []); + + // --- Fallback: flat fill when no top nodes --- + const fallbackGeometry = useMemo(() => { + if (anchors.length > 0 || fallbackFillPercent === undefined) return null; + + const radius = diameter / 2; + const fbRadius = radius * 0.98; + 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 * fallbackFillPercent; + + let hopperFillHeight = 0; + let cylinderFillHeight = 0; + + if (hopperHeight > 0 && filledVolume <= hopperVolume) { + const ratio = filledVolume / hopperVolume; + hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3); + cylinderFillHeight = 0; + } else { + hopperFillHeight = hopperHeight; + const remaining = filledVolume - hopperVolume; + cylinderFillHeight = Math.max(0, remaining / (Math.PI * radius * radius)); + } + + return { fbRadius, hopperFillHeight, cylinderFillHeight }; + }, [anchors.length, fallbackFillPercent, diameter, sidewallHeight, hopperHeight]); + + // --- Render fallback --- + if (anchors.length === 0) { + if (!fallbackGeometry) return null; + + const { fbRadius, hopperFillHeight, cylinderFillHeight } = fallbackGeometry; + + return ( + <> + {hopperHeight > 0 && hopperFillHeight > 0 && ( + + )} + {cylinderFillHeight > 0 && ( + + + + + )} + + ); + } + + // --- Render cable-driven surface --- + return ( + <> + {/* Interpolated grain surface */} + {surfaceGeometry && ( + + + + )} + + {/* Cylindrical body of grain below the surface down to the bin floor / hopper top */} + + + {/* Hopper fill — always full when grain surface exists above the floor */} + {hopperIsActive && ( + + )} + + ); +} \ No newline at end of file