added the fill system for cable controlled inventory
This commit is contained in:
parent
d33c7505d2
commit
0701be2539
3 changed files with 366 additions and 26 deletions
|
|
@ -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,
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
329
src/bin/3dView/Systems/Inventory/GrainCableFill.tsx
Normal file
329
src/bin/3dView/Systems/Inventory/GrainCableFill.tsx
Normal 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 (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 && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue