added the fill system for cable controlled inventory

This commit is contained in:
csawatzky 2026-04-23 13:03:32 -06:00
parent d33c7505d2
commit 0701be2539
3 changed files with 366 additions and 26 deletions

View file

@ -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,
})
})
})

View file

@ -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 (
<GrainCableFill
diameter={bin.diameter()}
nodes={nodeData}
sidewallHeight={bin.sidewallHeight()}
fallbackFillPercent={fillPercent}
hopperHeight={bin.hopperHeight()}
grainOpacity={0.3}
/>
)
}else if (fillPercent){
<GrainFillFlat
diameter={bin.diameter()}
sidewallHeight={bin.sidewallHeight()}
hopperHeight={bin.hopperHeight()}
fillPercent={fillPercent}
/>
}
}
return (
<Canvas>
<group scale={[1/scale, 1/scale, 1/scale]}>
@ -54,14 +78,7 @@ export default function Bin3dView(props: Props){
/>
{/* grain - cylinder*/}
{fillPercent !== undefined && (
<GrainFillFlat
diameter={bin.diameter()}
sidewallHeight={bin.sidewallHeight()}
hopperHeight={bin.hopperHeight()}
fillPercent={fillPercent}
/>
)}
{grainInventory()}
{/* cables */}
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}/>
<NodePointCloud bin={bin} nodes={nodeData} />

View file

@ -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 (01) 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 && (
<Cone
geometry={{
radius: fbRadius * (hopperFillHeight / hopperHeight),
height: hopperFillHeight,
radialSegments: 20,
openEnded: false,
}}
position={new Vector3(0, -(sidewallHeight / 2 + hopperHeight) + hopperFillHeight / 2, 0)}
rotation={new Euler(Math.PI, 0, 0)}
colour={grainColour}
roughness={1}
metalness={0}
opacity={grainOpacity}
/>
)}
{cylinderFillHeight > 0 && (
<mesh position={new Vector3(0, -sidewallHeight / 2 + cylinderFillHeight / 2, 0)}>
<cylinderGeometry args={[fbRadius, fbRadius, cylinderFillHeight, 20, 1, false]} />
<meshStandardMaterial color={grainColour} roughness={1} metalness={0} opacity={grainOpacity}/>
</mesh>
)}
</>
);
}
// --- Render cable-driven surface ---
return (
<>
{/* Interpolated grain surface */}
{surfaceGeometry && (
<mesh geometry={surfaceGeometry}>
<meshStandardMaterial
color={grainColour}
roughness={1}
metalness={0}
side={THREE.DoubleSide}
transparent
opacity={grainOpacity}
depthWrite={false}
/>
</mesh>
)}
{/* Cylindrical body of grain below the surface down to the bin floor / hopper top */}
<Cylinder
position={new Vector3(0, -sidewallHeight / 2 + wallY / 2 + sidewallHeight / 4, 0)}
geometry={{
height: sidewallHeight / 2 + wallY,
radiusBottom: grainRadius,
radiusTop: grainRadius,
radialSegments: THETA_SEGMENTS,
heightSegments: 1,
openEnded: false
}}
colour={grainColour}
roughness={1}
opacity={grainOpacity}
depthWrite={false}
/>
{/* Hopper fill — always full when grain surface exists above the floor */}
{hopperIsActive && (
<Cone
geometry={{
radius: grainRadius,
height: hopperHeight,
radialSegments: THETA_SEGMENTS,
openEnded: false,
}}
position={hopperPosition}
rotation={hopperRotation}
colour={grainColour}
roughness={1}
metalness={0}
opacity={grainOpacity}
/>
)}
</>
);
}