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
|
topNode?: boolean
|
||||||
excluded?: boolean
|
excluded?: boolean
|
||||||
inGrain?: 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[] {
|
export function BuildNodeData(cables: CableData[]): NodeData[] {
|
||||||
|
|
@ -31,23 +37,13 @@ export function BuildNodeData(cables: CableData[]): NodeData[] {
|
||||||
|
|
||||||
const spacing = height / nodeCount;
|
const spacing = height / nodeCount;
|
||||||
|
|
||||||
|
grainCable.celcius.forEach((celcius, i) => {
|
||||||
grainCable.celcius.forEach((celcius,i) => {
|
|
||||||
|
|
||||||
const nodeY = bottomY + (i * spacing);
|
const nodeY = bottomY + (i * spacing);
|
||||||
|
|
||||||
const humidity = grainCable.relativeHumidity[i] || undefined;
|
const humidity = grainCable.relativeHumidity[i] || undefined;
|
||||||
const moisture = grainCable.moisture[i] || undefined;
|
const moisture = grainCable.moisture[i] || undefined;
|
||||||
let t = celcius
|
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({
|
nodeData.push({
|
||||||
cableIndex: cableIndex,
|
cableIndex: cableIndex,
|
||||||
|
|
@ -57,13 +53,11 @@ export function BuildNodeData(cables: CableData[]): NodeData[] {
|
||||||
celcius: t,
|
celcius: t,
|
||||||
humidity: humidity,
|
humidity: humidity,
|
||||||
moisture: moisture,
|
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,
|
excluded: grainCable.excludedNodes?.includes(i) ?? false,
|
||||||
inGrain: i+1 <= grainCable.topNode
|
inGrain: i + 1 <= grainCable.topNode || !grainCable.topNode,
|
||||||
// optional: (may want to add this to the node data later)
|
nodeSpacing: spacing,
|
||||||
// normalizedHeight: (nodeY - bottomY) / height
|
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import { Vector3 } from "three";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BuildCableData } from "../Data/BuildCableData";
|
import { BuildCableData } from "../Data/BuildCableData";
|
||||||
import { BuildNodeData } from "../Data/BuildNodeData";
|
import { BuildNodeData } from "../Data/BuildNodeData";
|
||||||
import Heatmap from "../Systems/Heatmap/HeatMapAlpha";
|
|
||||||
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
|
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
|
||||||
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
import GrainCableFill from "../Systems/Inventory/GrainCableFill";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,6 +38,29 @@ export default function Bin3dView(props: Props){
|
||||||
const cableData = useMemo(() => BuildCableData(bin), [bin]);
|
const cableData = useMemo(() => BuildCableData(bin), [bin]);
|
||||||
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
|
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 (
|
return (
|
||||||
<Canvas>
|
<Canvas>
|
||||||
<group scale={[1/scale, 1/scale, 1/scale]}>
|
<group scale={[1/scale, 1/scale, 1/scale]}>
|
||||||
|
|
@ -54,14 +78,7 @@ export default function Bin3dView(props: Props){
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* grain - cylinder*/}
|
{/* grain - cylinder*/}
|
||||||
{fillPercent !== undefined && (
|
{grainInventory()}
|
||||||
<GrainFillFlat
|
|
||||||
diameter={bin.diameter()}
|
|
||||||
sidewallHeight={bin.sidewallHeight()}
|
|
||||||
hopperHeight={bin.hopperHeight()}
|
|
||||||
fillPercent={fillPercent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* cables */}
|
{/* cables */}
|
||||||
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}/>
|
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}/>
|
||||||
<NodePointCloud bin={bin} nodes={nodeData} />
|
<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