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:
parent
cb6609bdb1
commit
ee416db013
9 changed files with 911 additions and 19 deletions
43
src/3dModels/Shapes/3D/Sphere.tsx
Normal file
43
src/3dModels/Shapes/3D/Sphere.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
200
src/bin/3dView/BinCables/BinCables.tsx
Normal file
200
src/bin/3dView/BinCables/BinCables.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
src/bin/3dView/BinCables/CableNode.tsx
Normal file
110
src/bin/3dView/BinCables/CableNode.tsx
Normal 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.5–2 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>
|
||||
)
|
||||
}
|
||||
86
src/bin/3dView/BinCables/GrainCable.tsx
Normal file
86
src/bin/3dView/BinCables/GrainCable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue