cables and noes now render in the bins, the label to show readinds appears when zooming in and inventory using fill percent works, still need to work on top node fill level for cable inventory control, and node heatmap/gradient similar to the bin SVG

This commit is contained in:
csawatzky 2026-04-10 16:22:48 -06:00
parent cb6609bdb1
commit ee416db013
9 changed files with 911 additions and 19 deletions

View file

@ -0,0 +1,43 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface SphereGeometry {
radius: number
widthSegments?: number
heightSegments?: number
phiStart?: number
phiLength?: number
thetaStart?: number
thetaLength?: number
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: SphereGeometry;
}
export default function Sphere(props: Props){
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.SphereGeometry(
geometry.radius,
geometry.widthSegments,
geometry.heightSegments,
geometry.phiStart,
geometry.phiLength,
geometry.thetaStart,
geometry.thetaLength
);
}, [
geometry.radius,
geometry.widthSegments,
geometry.heightSegments,
geometry.phiStart,
geometry.phiLength,
geometry.thetaStart,
geometry.thetaLength
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -2,7 +2,8 @@ import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls";
import { Canvas } from "@react-three/fiber";
import { Bin } from "models";
import BinShell from "./BinParts/BinShell";
import GrainFillFlat from "./grainFill/GrainFillFlat";
import GrainFillFlat from "./BinInventory/GrainFillFlat";
import BinCables from "./BinCables/BinCables";
interface Props {
/**
@ -53,8 +54,9 @@ export default function Bin3dView(props: Props){
fillPercent={fillPercent}
/>
)}
</group>
{/* cables */}
<BinCables bin={bin}/>
</group>
{/* lighting */}
<ambientLight intensity={0.2} color={"white"}/>
@ -62,12 +64,6 @@ export default function Bin3dView(props: Props){
<directionalLight intensity={0.2} color={"white"} position={[0,0,-5]}/>
<directionalLight intensity={0.2} color={"white"} position={[5,0,0]}/>
<directionalLight intensity={0.2} color={"white"} position={[-5,0,0]}/>
{/* <hemisphereLight
color={"white"}
groundColor={"#444"}
intensity={0.7}
/> */}
{/* camera controls */}
<OrbitCameraControls clampVerticalRotation />
</Canvas>
)

View file

@ -0,0 +1,200 @@
import GrainCable, { CableData } from "./GrainCable";
import { avg } from "utils";
import { Vector3 } from "three";
import { useMemo } from "react";
import { Bin } from "models";
import React from "react";
interface CableOrbit {
orbit: number;
radius: number; // in cm
expectedCount: number;
assignedCount?: number;
}
interface Props {
bin: Bin
}
export default function BinCables(props: Props){
const {bin} = props
const getLayoutFromDiameter = (diameterCm: number): CableOrbit[] => {
const diameterFt = diameterCm / 30.48;
let summaries: { Orbit: number; DistanceFromCenter: number; NumberOfCables: number }[] = [];
if (diameterFt < 24) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
} else if (diameterFt < 36) {
summaries.push({ Orbit: 1, DistanceFromCenter: 7, NumberOfCables: 3 });
} else if (diameterFt < 42) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 9, NumberOfCables: 3 });
} else if (diameterFt < 48) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 14, NumberOfCables: 4 });
} else if (diameterFt < 54) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 16, NumberOfCables: 5 });
} else if (diameterFt < 60) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 18, NumberOfCables: 6 });
} else if (diameterFt < 72) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 20, NumberOfCables: 7 });
} else if (diameterFt < 78) {
summaries.push({ Orbit: 1, DistanceFromCenter: 10, NumberOfCables: 4 });
summaries.push({ Orbit: 2, DistanceFromCenter: 27, NumberOfCables: 9 });
} else if (diameterFt < 90) {
summaries.push({ Orbit: 1, DistanceFromCenter: 10, NumberOfCables: 4 });
summaries.push({ Orbit: 2, DistanceFromCenter: 29, NumberOfCables: 10 });
} else if (diameterFt < 105) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 18, NumberOfCables: 6 });
summaries.push({ Orbit: 2, DistanceFromCenter: 36, NumberOfCables: 12 });
} else {
summaries.push({ Orbit: 1, DistanceFromCenter: 9, NumberOfCables: 3 });
summaries.push({ Orbit: 2, DistanceFromCenter: 26.5, NumberOfCables: 9 });
summaries.push({ Orbit: 3, DistanceFromCenter: 44, NumberOfCables: 14 });
}
return summaries.map(s => ({
orbit: s.Orbit,
radius: s.DistanceFromCenter * 30.48, // ft → cm
expectedCount: s.NumberOfCables
}));
};
const distributeCables = (cableCount: number, layout: CableOrbit[]) => {
const totalExpected = layout.reduce((sum, o) => sum + o.expectedCount, 0);
let assigned = layout.map(o => ({
...o,
assignedCount: Math.round((o.expectedCount / totalExpected) * cableCount)
}));
// normalize rounding
let currentTotal = assigned.reduce((sum, o) => sum + (o.assignedCount ?? 0), 0);
while (currentTotal < cableCount) {
assigned[assigned.length - 1].assignedCount!++;
currentTotal++;
}
while (currentTotal > cableCount) {
assigned[assigned.length - 1].assignedCount!--;
currentTotal--;
}
return assigned;
};
const getTopY = (radiusFromCenter: number) => {
const binRadius = bin.diameter() / 2;
const distanceFromEdge = binRadius - radiusFromCenter;
const angleRad = bin.roofAngle() * (Math.PI / 180);
const roofRise = Math.tan(angleRad) * distanceFromEdge;
return bin.sidewallHeight() / 2 + roofRise;
};
const getBottomY = (radiusFromCenter: number) => {
const binRadius = bin.diameter() / 2;
const distanceFromCenter = binRadius - radiusFromCenter;
const angleRad = bin.hopperAngle() * (Math.PI / 180);
const hopperDrop = Math.tan(angleRad) * distanceFromCenter;
const clearance = 60 //this is in cm
return -bin.sidewallHeight() / 2 - hopperDrop + clearance;
};
const buildCablePositions = (bin: Bin): CableData[] => {
const cables = [...(bin.status.grainCables ?? [])];
if (cables.length === 0) return [];
// optional: sort cables (better visual consistency later)
//cables.sort((a, b) => b.celcius.length - a.celcius.length);
cables.sort((a, b) => {
//if the cables have the same number of nodes
if (b.celcius.length === a.celcius.length) {
//sort by temp only last and any type that has humidity first
if ((avg(a.relativeHumidity) === 0) && (avg(b.relativeHumidity) !== 0)) {
return 1;
} else if ((avg(b.relativeHumidity) === 0) && (avg(a.relativeHumidity) !== 0)) {
return -1;
}
else {
return a.key > b.key ? 1 : -1;
}
}
//otherwise sort by the longest cable first
return b.celcius.length - a.celcius.length;
});
const layout = getLayoutFromDiameter(bin.diameter());
const distributed = distributeCables(cables.length, layout);
const cableRadius = 5;
const result: CableData[] = [];
let cableIndex = 0;
distributed.forEach(orbit => {
const count = orbit.assignedCount ?? 0;
const topY = getTopY(orbit.radius)
const bottomY = getBottomY(orbit.radius)
if (orbit.orbit === 0 && count > 0) {
const cable = cables[cableIndex++];
result.push({
radius: cableRadius,
topPosition: new Vector3(0, topY, 0),
bottomPosition: new Vector3(0, bottomY, 0),
grainCable: cable
// optionally attach cable data later
});
return;
}
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const x = Math.cos(angle) * orbit.radius;
const z = Math.sin(angle) * orbit.radius;
const cable = cables[cableIndex++];
result.push({
radius: cableRadius,
topPosition: new Vector3(x,topY,z),
bottomPosition: new Vector3(x,bottomY,z),
grainCable: cable
});
}
});
return result;
};
const cables3D = useMemo(() => {
return buildCablePositions(bin);
}, [bin]);
return (
<React.Fragment>
{cables3D.map(cable => (
<GrainCable cable={cable}/>
))}
</React.Fragment>
)
}

View file

@ -0,0 +1,110 @@
import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere"
import { useFrame, useThree } from "@react-three/fiber"
import { Billboard, Text } from "@react-three/drei"
import React, { useMemo, useState } from "react"
import { Vector3 } from "three"
import { cloneDeep } from "lodash"
import { describeMeasurement } from "pbHelpers/MeasurementDescriber"
import { quack } from "protobuf-ts/quack"
export interface NodeData {
radius: number
position: Vector3
celcius: number
humidity?: number
moisture?: number
topNode?: boolean
}
interface Props {
node: NodeData
}
export default function CableNode(props: Props){
const {node} = props
const { camera } = useThree();
const [showLabel, setShowLabel] = useState(false);
useFrame(() => {
//use the cameras distance to the center of the bin
const distance = camera.position.distanceTo(new Vector3(0, 0, 0))
console.log(distance)
const SHOW_DISTANCE = 15;
const HIDE_DISTANCE = 16; // hysteresis buffer
if (!showLabel && distance < SHOW_DISTANCE) {
setShowLabel(true);
} else if (showLabel && distance > HIDE_DISTANCE) {
setShowLabel(false);
}
});
const geometry = React.useMemo(() => ({
radius: node.radius,
} as SphereGeometry), [node]);
const labelPosition = node.position.clone();
const dir = camera.position.clone().sub(node.position).normalize();
// push label slightly toward camera
labelPosition.add(dir.multiplyScalar(1)); // small offset like 0.52 units
return (
<React.Fragment>
{!showLabel ?
<Sphere geometry={geometry} position={node.position}/>
:
<Billboard position={labelPosition}>
<group renderOrder={999}>
{/* Background */}
<mesh position={[0, 0, -1]}>
<planeGeometry args={[200, 100]} />
<meshBasicMaterial
color="black"
transparent
opacity={0.7}
depthWrite={false}
depthTest={false}
/>
</mesh>
{/* Temp Text */}
<Text
position={[0, 35, 0]}
fontSize={30}
color={describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()}
anchorX="center"
anchorY="middle">
{node.celcius.toFixed(2)}°C
</Text>
{/* Humidity Text */}
{node.humidity &&
<Text
position={[0, 0, 0]}
fontSize={30}
color={describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()}
anchorX="center"
anchorY="middle">
{node.humidity.toFixed(2)}%
</Text>
}
{/* Moisture Text */}
{node.moisture &&
<Text
position={[0, -35, 0]}
fontSize={30}
color={describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()}
anchorX="center"
anchorY="middle">
{node.moisture.toFixed(2)}% EMC
</Text>
}
</group>
</Billboard>
}
</React.Fragment>
)
}

View file

@ -0,0 +1,86 @@
import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder"
import { pond } from "protobuf-ts/pond"
import React from "react"
import { Vector3 } from "three"
import CableNode, { NodeData } from "./CableNode"
export interface CableData {
radius: number
topPosition: Vector3
bottomPosition: Vector3
grainCable: pond.GrainCable
radialSegments?: number
heightSegments?: number
}
interface Props {
cable: CableData
}
export default function GrainCable(props: Props) {
const {cable} = props
const calculateHeight = () => {
return cable.topPosition.distanceTo(cable.bottomPosition)
}
const calculateCenter = () => {
return cable.bottomPosition.clone()
.add(
cable.topPosition.clone()
.sub(cable.bottomPosition)
.multiplyScalar(0.5)
);
};
const buildCableNodes = () => {
const nodeRadius = 20
const grainCable = cable.grainCable
const nodeCount = grainCable.celcius.length;
if (nodeCount === 0) return [];
const bottomY = cable.bottomPosition.y;
const topY = cable.topPosition.y;
const height = topY - bottomY;
const spacing = height / nodeCount;
let nodeData: NodeData[] = []
grainCable.celcius.forEach((celcius,i) => {
const nodeY = bottomY + (i * spacing);
const humidity = grainCable.relativeHumidity[i] || undefined;
const moisture = grainCable.moisture[i] || undefined;
nodeData.push({
radius: nodeRadius,
position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z),
celcius: celcius,
humidity: humidity,
moisture: moisture,
topNode: grainCable.topNode === i
})
})
return nodeData
}
const geometry = React.useMemo(() => ({
radiusTop: cable.radius,
radiusBottom: cable.radius,
height: calculateHeight(),
radialSegments: cable.radialSegments ?? 10,
heightSegments: cable.heightSegments ?? 2
} as CylinderGeometry), [cable]);
return (
<React.Fragment>
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5}/>
{buildCableNodes().map(node => (
<CableNode node={node}/>
))}
</React.Fragment>
)
}

View file

@ -72,6 +72,14 @@ export class Bin {
return this.settings.specs?.advancedDimensions?.hopperHeight ?? 0;
}
public roofAngle() : number {
return this.settings.specs?.advancedDimensions?.roofAngle ?? 0;
}
public hopperAngle() : number {
return this.settings.specs?.advancedDimensions?.hopperAngle ?? 0;
}
public key(): string {
return this.settings.key;
}