did some refactoring so the node and cable data is computed in the data layer so that it is accessible by anything in the bin to make way for a heatmap
This commit is contained in:
parent
ff64f8594c
commit
6c87ed03ed
13 changed files with 572 additions and 363 deletions
|
|
@ -26,9 +26,9 @@ interface Props {
|
|||
export default function OrbitCameraControls(props: Props) {
|
||||
const { target, clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01 } = props;
|
||||
const { camera, gl } = useThree();
|
||||
const panPlane = useRef(new Plane());
|
||||
const panStartPoint = useRef(new Vector3());
|
||||
const raycaster = useRef(new Raycaster());
|
||||
// const panPlane = useRef(new Plane());
|
||||
// const panStartPoint = useRef(new Vector3());
|
||||
// const raycaster = useRef(new Raycaster());
|
||||
const mouse = useRef(new Vector2());
|
||||
const targetRef = useRef({
|
||||
x: target?.x ?? 0,
|
||||
|
|
@ -39,6 +39,7 @@ export default function OrbitCameraControls(props: Props) {
|
|||
const isDragging = useRef(false);
|
||||
const isPanning = useRef(false);
|
||||
const lastPos = useRef({ x: 0, y: 0 });
|
||||
const panCameraPosition = useRef(new Vector3());
|
||||
|
||||
// Spherical coords
|
||||
const spherical = useRef({
|
||||
|
|
@ -57,7 +58,11 @@ export default function OrbitCameraControls(props: Props) {
|
|||
const y = radius * Math.cos(phi);
|
||||
const z = radius * Math.sin(phi) * Math.cos(theta);
|
||||
|
||||
camera.position.set(x, y, z);
|
||||
camera.position.set(
|
||||
targetRef.current.x + x,
|
||||
targetRef.current.y + y,
|
||||
targetRef.current.z + z
|
||||
);
|
||||
camera.lookAt(
|
||||
targetRef.current.x,
|
||||
targetRef.current.y,
|
||||
|
|
@ -71,6 +76,7 @@ export default function OrbitCameraControls(props: Props) {
|
|||
if (e.target !== canvas) return;
|
||||
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
panCameraPosition.current.copy(camera.position);
|
||||
|
||||
// Left click = rotate
|
||||
if (e.button === 0) {
|
||||
|
|
@ -87,28 +93,29 @@ export default function OrbitCameraControls(props: Props) {
|
|||
const camDir = new Vector3();
|
||||
camera.getWorldDirection(camDir);
|
||||
|
||||
panPlane.current.setFromNormalAndCoplanarPoint(
|
||||
camDir,
|
||||
new Vector3(
|
||||
targetRef.current.x,
|
||||
targetRef.current.y,
|
||||
targetRef.current.z
|
||||
)
|
||||
);
|
||||
// panPlane.current.setFromNormalAndCoplanarPoint(
|
||||
// camDir,
|
||||
// new Vector3(
|
||||
// targetRef.current.x,
|
||||
// targetRef.current.y,
|
||||
// targetRef.current.z
|
||||
// )
|
||||
// );
|
||||
|
||||
// starting intersection point
|
||||
raycaster.current.setFromCamera(
|
||||
new Vector2(
|
||||
(e.clientX / window.innerWidth) * 2 - 1,
|
||||
-(e.clientY / window.innerHeight) * 2 + 1
|
||||
),
|
||||
camera
|
||||
);
|
||||
// const rect = canvas.getBoundingClientRect();
|
||||
|
||||
raycaster.current.ray.intersectPlane(
|
||||
panPlane.current,
|
||||
panStartPoint.current
|
||||
);
|
||||
// raycaster.current.setFromCamera(
|
||||
// new Vector2(
|
||||
// ((e.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
// -((e.clientY - rect.top) / rect.height) * 2 + 1
|
||||
// ),
|
||||
// camera
|
||||
// );
|
||||
|
||||
// raycaster.current.ray.intersectPlane(
|
||||
// panPlane.current,
|
||||
// panStartPoint.current
|
||||
// );
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -132,28 +139,61 @@ export default function OrbitCameraControls(props: Props) {
|
|||
}
|
||||
|
||||
// PAN
|
||||
// if (isPanning.current) {
|
||||
// const rect = canvas.getBoundingClientRect();
|
||||
|
||||
// mouse.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
// mouse.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
// const currentPoint = new Vector3();
|
||||
|
||||
// const tempCamera = camera.clone();
|
||||
// tempCamera.position.copy(panCameraPosition.current);
|
||||
// tempCamera.updateMatrixWorld();
|
||||
|
||||
// raycaster.current.setFromCamera(mouse.current, tempCamera);
|
||||
|
||||
// raycaster.current.ray.intersectPlane(panPlane.current, currentPoint);
|
||||
|
||||
// const delta = new Vector3().subVectors(
|
||||
// panStartPoint.current,
|
||||
// currentPoint
|
||||
// );
|
||||
|
||||
// targetRef.current.x += delta.x;
|
||||
// targetRef.current.y += delta.y;
|
||||
// targetRef.current.z += delta.z;
|
||||
|
||||
// panStartPoint.current.copy(currentPoint);
|
||||
// }
|
||||
if (isPanning.current) {
|
||||
const raycaster = new Raycaster();
|
||||
const deltaX = e.clientX - lastPos.current.x;
|
||||
const deltaY = e.clientY - lastPos.current.y;
|
||||
|
||||
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
// distance-based scaling (important for consistent feel)
|
||||
const distance = spherical.current.radius;
|
||||
|
||||
raycaster.setFromCamera(mouse.current, camera);
|
||||
const panSpeed = 0.002 * distance;
|
||||
|
||||
const currentPoint = new Vector3();
|
||||
const offset = new Vector3();
|
||||
|
||||
raycaster.ray.intersectPlane(panPlane.current, currentPoint);
|
||||
// get camera basis vectors
|
||||
const right = new Vector3();
|
||||
const up = new Vector3();
|
||||
|
||||
const delta = new Vector3().subVectors(
|
||||
panStartPoint.current,
|
||||
currentPoint
|
||||
);
|
||||
camera.getWorldDirection(offset); // forward
|
||||
right.crossVectors(offset, camera.up).normalize(); // right
|
||||
up.copy(camera.up).normalize();
|
||||
|
||||
targetRef.current.x += delta.x;
|
||||
targetRef.current.y += delta.y;
|
||||
targetRef.current.z += delta.z;
|
||||
// apply movement
|
||||
right.multiplyScalar(-deltaX * panSpeed);
|
||||
up.multiplyScalar(deltaY * panSpeed);
|
||||
|
||||
panStartPoint.current.copy(currentPoint);
|
||||
const pan = new Vector3().addVectors(right, up);
|
||||
|
||||
targetRef.current.x += pan.x;
|
||||
targetRef.current.y += pan.y;
|
||||
targetRef.current.z += pan.z;
|
||||
}
|
||||
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
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
|
||||
binCenter: Vector3
|
||||
}
|
||||
|
||||
export default function BinCables(props: Props){
|
||||
const {bin, binCenter} = 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, i) => (
|
||||
<GrainCable key={"cable " + i} cable={cable} bin={bin} binCenter={binCenter}/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
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"
|
||||
import { Bin } from "models"
|
||||
|
||||
export interface CableData {
|
||||
radius: number
|
||||
topPosition: Vector3
|
||||
bottomPosition: Vector3
|
||||
grainCable: pond.GrainCable
|
||||
radialSegments?: number
|
||||
heightSegments?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cable: CableData
|
||||
bin: Bin
|
||||
binCenter: Vector3
|
||||
}
|
||||
export default function GrainCable(props: Props) {
|
||||
const {cable, bin, binCenter} = 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+1, //top node tracking starts at 1 not 0 so add one to the index
|
||||
excluded: grainCable.excludedNodes.includes(i),
|
||||
inGrain: i+1 <= grainCable.topNode
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
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, i) => (
|
||||
<CableNode key={"node " + i} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
194
src/bin/3dView/Data/BuildCableData.ts
Normal file
194
src/bin/3dView/Data/BuildCableData.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { Bin } from "models";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { Vector3 } from "three";
|
||||
import { avg } from "utils";
|
||||
|
||||
export interface CableData {
|
||||
cableIndex: number
|
||||
radius: number
|
||||
topPosition: Vector3
|
||||
bottomPosition: Vector3
|
||||
grainCable: pond.GrainCable
|
||||
radialSegments?: number
|
||||
heightSegments?: number
|
||||
}
|
||||
|
||||
export interface CableOrbit {
|
||||
orbit: number;
|
||||
radius: number; // in cm
|
||||
expectedCount: number;
|
||||
assignedCount?: number;
|
||||
}
|
||||
|
||||
|
||||
function 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
|
||||
}));
|
||||
};
|
||||
|
||||
function 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;
|
||||
};
|
||||
|
||||
function getTopY(radiusFromCenter: number, bin: Bin) {
|
||||
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;
|
||||
};
|
||||
|
||||
function getBottomY(radiusFromCenter: number, bin: Bin) {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
export function BuildCableData(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, bin)
|
||||
const bottomY = getBottomY(orbit.radius, bin)
|
||||
|
||||
if (orbit.orbit === 0 && count > 0) {
|
||||
const cable = cables[cableIndex];
|
||||
if (!cable) return
|
||||
result.push({
|
||||
cableIndex: cableIndex,
|
||||
radius: cableRadius,
|
||||
topPosition: new Vector3(0, topY, 0),
|
||||
bottomPosition: new Vector3(0, bottomY, 0),
|
||||
grainCable: cable
|
||||
});
|
||||
cableIndex++
|
||||
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({
|
||||
cableIndex: cableIndex,
|
||||
radius: cableRadius,
|
||||
topPosition: new Vector3(x,topY,z),
|
||||
bottomPosition: new Vector3(x,bottomY,z),
|
||||
grainCable: cable
|
||||
});
|
||||
cableIndex++
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
60
src/bin/3dView/Data/BuildNodeData.ts
Normal file
60
src/bin/3dView/Data/BuildNodeData.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Vector3 } from "three"
|
||||
import { CableData } from "./BuildCableData"
|
||||
|
||||
export interface NodeData {
|
||||
cableIndex: number
|
||||
nodeIndex: number
|
||||
radius: number
|
||||
position: Vector3
|
||||
celcius: number
|
||||
humidity?: number
|
||||
moisture?: number
|
||||
topNode?: boolean
|
||||
excluded?: boolean
|
||||
inGrain?: boolean
|
||||
}
|
||||
|
||||
export function BuildNodeData(cables: CableData[]): NodeData[] {
|
||||
const nodeRadius = 20
|
||||
let nodeData: NodeData[] = []
|
||||
|
||||
cables.forEach((cable, cableIndex) => {
|
||||
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;
|
||||
|
||||
|
||||
grainCable.celcius.forEach((celcius,i) => {
|
||||
const nodeY = bottomY + (i * spacing);
|
||||
|
||||
const humidity = grainCable.relativeHumidity[i] || undefined;
|
||||
const moisture = grainCable.moisture[i] || undefined;
|
||||
|
||||
nodeData.push({
|
||||
cableIndex: cableIndex,
|
||||
nodeIndex: i,
|
||||
radius: nodeRadius,
|
||||
position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z),
|
||||
celcius: celcius,
|
||||
humidity: humidity,
|
||||
moisture: moisture,
|
||||
topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index
|
||||
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
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
return nodeData
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Bin } from "models";
|
||||
import BinShell from "./BinParts/BinShell";
|
||||
import GrainFillFlat from "./BinInventory/GrainFillFlat";
|
||||
import BinCables from "./BinCables/BinCables";
|
||||
import BinShell from "../BinParts/BinShell";
|
||||
import GrainFillFlat from "../Systems/Inventory/GrainFillFlat";
|
||||
import BinCables from "../Systems/Cables/BinCables";
|
||||
import { Vector3 } from "three";
|
||||
import { useMemo } from "react";
|
||||
import { BuildCableData } from "../Data/BuildCableData";
|
||||
import { BuildNodeData } from "../Data/BuildNodeData";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
|
@ -30,8 +32,8 @@ export default function Bin3dView(props: Props){
|
|||
// it might even work better because we can control the smoothness easier with it being only one mesh rather than 3
|
||||
const {bin, scale = 100, fillPercent} = props
|
||||
const binCenter = useMemo(() => new Vector3(0, 0, 0), []);
|
||||
|
||||
console.log(bin)
|
||||
const cableData = useMemo(() => BuildCableData(bin), [bin]);
|
||||
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
|
||||
|
||||
return (
|
||||
<Canvas>
|
||||
|
|
@ -46,6 +48,7 @@ export default function Bin3dView(props: Props){
|
|||
sidewallHeight={bin.sidewallHeight()}
|
||||
roofHeight={bin.roofHeight()}
|
||||
hopperHeight={bin.hopperHeight()}
|
||||
|
||||
/>
|
||||
|
||||
{/* grain - cylinder*/}
|
||||
|
|
@ -58,7 +61,7 @@ export default function Bin3dView(props: Props){
|
|||
/>
|
||||
)}
|
||||
{/* cables */}
|
||||
<BinCables bin={bin} binCenter={binCenter}/>
|
||||
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}/>
|
||||
</group>
|
||||
|
||||
{/* lighting */}
|
||||
28
src/bin/3dView/Systems/Cables/BinCables.tsx
Normal file
28
src/bin/3dView/Systems/Cables/BinCables.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import GrainCable from "./GrainCable";
|
||||
import { Vector3 } from "three";
|
||||
import { Bin } from "models";
|
||||
import React from "react";
|
||||
import { CableData } from "../../Data/BuildCableData";
|
||||
import { NodeData } from "../../Data/BuildNodeData";
|
||||
|
||||
interface Props {
|
||||
cableData: CableData[]
|
||||
nodeData: NodeData[]
|
||||
bin: Bin
|
||||
binCenter: Vector3
|
||||
}
|
||||
|
||||
export default function BinCables(props: Props){
|
||||
const {bin, binCenter, cableData, nodeData} = props
|
||||
return (
|
||||
<React.Fragment>
|
||||
{cableData.map((cable, i) => {
|
||||
const cableNodes = nodeData.filter(n => n.cableIndex === cable.cableIndex);
|
||||
return (
|
||||
<GrainCable key={"cable " + i} cable={cable} nodes={cableNodes} bin={bin} binCenter={binCenter}/>
|
||||
)}
|
||||
)}
|
||||
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +1,16 @@
|
|||
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, useRef, useState } from "react"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Euler, Group, Vector3 } from "three"
|
||||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber"
|
||||
import { quack } from "protobuf-ts/quack"
|
||||
import { useGlobalState } from "providers"
|
||||
import { pond } from "protobuf-ts/pond"
|
||||
import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring"
|
||||
|
||||
export interface NodeData {
|
||||
radius: number
|
||||
position: Vector3
|
||||
celcius: number
|
||||
humidity?: number
|
||||
moisture?: number
|
||||
topNode?: boolean
|
||||
excluded?: boolean
|
||||
inGrain?: boolean
|
||||
}
|
||||
// import { Color } from "three";
|
||||
import { NodeData } from "../../Data/BuildNodeData"
|
||||
import { tempToColour } from "bin/3dView/utils/tempToColour"
|
||||
|
||||
interface Props {
|
||||
node: NodeData
|
||||
|
|
@ -36,6 +28,13 @@ export default function CableNode(props: Props){
|
|||
const ROW_HEIGHT = 35;
|
||||
const PADDING = 20;
|
||||
|
||||
//node colour varables for adjusting lighness and saturation for the thresholds
|
||||
// const colourFade = 20 //this number is the temp diff above or below the thresholds that the node will reach its maximum red or blue intensity
|
||||
// const minimumLightness = 0.3 //the minimum brightness of the colour, to low would approach black
|
||||
// const lightnessRange = 0.2 //how much can the node brighten, to high will allow the node to turn white
|
||||
// const minimumSaturation = 0.7
|
||||
// const saturationRange = 0.8
|
||||
|
||||
useFrame(() => {
|
||||
//use the cameras distance to the center of the bin
|
||||
const distance = camera.position.distanceTo(binCenter)
|
||||
|
|
@ -64,22 +63,68 @@ export default function CableNode(props: Props){
|
|||
return temp
|
||||
}
|
||||
|
||||
const nodeColour = () => {
|
||||
if (!node.inGrain) return "white";
|
||||
// const nodeColour = () => {
|
||||
// if (!node.inGrain) return "white";
|
||||
// if (node.excluded) return "grey";
|
||||
|
||||
const lower = lowerThreshold;
|
||||
const upper = upperThreshold;
|
||||
// const lower = lowerThreshold;
|
||||
// const upper = upperThreshold;
|
||||
// const temp = node.celcius;
|
||||
|
||||
if (lower !== undefined && node.celcius < lower) {
|
||||
return "#3399ff"; // blue
|
||||
}
|
||||
// const GREEN = new Color("#52c41a");
|
||||
// const BLUE = new Color("#3399ff");
|
||||
// const RED = new Color("#ff4d4f");
|
||||
|
||||
if (upper !== undefined && node.celcius > upper) {
|
||||
return "#ff4d4f"; // red
|
||||
}
|
||||
// if (temp >= lower && temp <= upper) {
|
||||
// return GREEN.getStyle();
|
||||
// }
|
||||
|
||||
return "#52c41a"; // green
|
||||
};
|
||||
// // shared helper logic concept:
|
||||
// const getCloseness = (distance: number) => {
|
||||
// const normalized = Math.min(1, distance / colourFade);
|
||||
// return 1 - normalized; // 1 = near threshold, 0 = far
|
||||
// };
|
||||
|
||||
// const color = new Color();
|
||||
|
||||
// // BELOW lower (blue)
|
||||
// if (temp < lower) {
|
||||
// const distance = lower - temp;
|
||||
// const closeness = getCloseness(distance);
|
||||
|
||||
// const hsl = { h: 0, s: 1, l: 1 };
|
||||
// BLUE.getHSL(hsl);
|
||||
|
||||
// const lightness = (lightnessRange * closeness) + minimumLightness;
|
||||
|
||||
// // 🔥 NEW: saturation as second intensity layer
|
||||
// const saturation = (saturationRange * (1 - closeness)) + minimumSaturation;
|
||||
|
||||
// color.setHSL(hsl.h, saturation, lightness);
|
||||
|
||||
// return color.getStyle();
|
||||
// }
|
||||
|
||||
// // ABOVE upper (red)
|
||||
// if (temp > upper) {
|
||||
// const distance = temp - upper;
|
||||
// const closeness = getCloseness(distance);
|
||||
|
||||
// const hsl = { h: 0, s: 1, l: 1 };
|
||||
// RED.getHSL(hsl);
|
||||
|
||||
// const lightness = (lightnessRange * closeness) + minimumLightness;
|
||||
|
||||
// // 🔥 NEW: saturation as second intensity layer
|
||||
// const saturation = (saturationRange * (1 - closeness)) + minimumSaturation;
|
||||
|
||||
// color.setHSL(hsl.h, saturation, lightness);
|
||||
|
||||
// return color.getStyle();
|
||||
// }
|
||||
|
||||
// return GREEN.getStyle();
|
||||
// };
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const r: { text: string; color: string }[] = [];
|
||||
|
|
@ -153,7 +198,7 @@ export default function CableNode(props: Props){
|
|||
<React.Fragment>
|
||||
{(!node.inGrain || !showLabel) &&
|
||||
<React.Fragment>
|
||||
<Sphere geometry={geometry} position={node.position} colour={nodeColour()}/>
|
||||
<Sphere geometry={geometry} position={node.position} colour={tempToColour(node, lowerThreshold, upperThreshold, "node")?.getStyle()}/>
|
||||
{node.topNode && (
|
||||
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation}/>
|
||||
)}
|
||||
47
src/bin/3dView/Systems/Cables/GrainCable.tsx
Normal file
47
src/bin/3dView/Systems/Cables/GrainCable.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder"
|
||||
import React from "react"
|
||||
import { Vector3 } from "three"
|
||||
import CableNode from "./CableNode"
|
||||
import { Bin } from "models"
|
||||
import { CableData } from "../../Data/BuildCableData"
|
||||
import { NodeData } from "../../Data/BuildNodeData"
|
||||
|
||||
interface Props {
|
||||
cable: CableData
|
||||
nodes: NodeData[]
|
||||
bin: Bin
|
||||
binCenter: Vector3
|
||||
}
|
||||
export default function GrainCable(props: Props) {
|
||||
const {cable, nodes, bin, binCenter} = 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 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}/>
|
||||
{nodes.map((node, i) => (
|
||||
<CableNode key={"node " + i} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
16
src/bin/3dView/Systems/Heatmap/Heatmap.tsx
Normal file
16
src/bin/3dView/Systems/Heatmap/Heatmap.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||
import { Bin } from "models";
|
||||
|
||||
interface Props{
|
||||
bin: Bin
|
||||
nodes: NodeData
|
||||
}
|
||||
|
||||
export default function Heatmap(props: Props){
|
||||
const {bin, nodes} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
src/bin/3dView/utils/tempToColour.ts
Normal file
68
src/bin/3dView/utils/tempToColour.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Color } from "three";
|
||||
import { NodeData } from "../Data/BuildNodeData";
|
||||
|
||||
type ColourMode = "node" | "heatmap";
|
||||
|
||||
const GREEN = new Color("#52c41a");
|
||||
const BLUE = new Color("#3399ff");
|
||||
const RED = new Color("#ff4d4f");
|
||||
|
||||
const colourFade = 20;
|
||||
const minimumLightness = 0.3;
|
||||
const lightnessRange = 0.2;
|
||||
const minimumSaturation = 0.7;
|
||||
const saturationRange = 0.8;
|
||||
|
||||
const getTemperatureIntensity = (temp: number, lower: number, upper: number) => {
|
||||
if (temp >= lower && temp <= upper) return 0;
|
||||
if (temp < lower) return lower - temp;
|
||||
return temp - upper;
|
||||
}
|
||||
|
||||
const getVisualIntensity = (distance: number, mode: ColourMode) => {
|
||||
// 1 = near threshold, 0 = far
|
||||
const intensity = Math.min(1, distance / colourFade);
|
||||
switch(mode){
|
||||
case "node":
|
||||
return 1 - intensity
|
||||
case "heatmap":
|
||||
return intensity
|
||||
}
|
||||
};
|
||||
|
||||
export function tempToColour(
|
||||
node: NodeData,
|
||||
lower: number,
|
||||
upper: number,
|
||||
mode: ColourMode
|
||||
): Color | null {
|
||||
|
||||
if (!node.inGrain || node.excluded) return null;
|
||||
|
||||
const temp = node.celcius;
|
||||
|
||||
// in-threshold behavior
|
||||
if (temp >= lower && temp <= upper) {
|
||||
return mode === "heatmap" ? null : GREEN;
|
||||
}
|
||||
|
||||
const distance = getTemperatureIntensity(temp, lower, upper);
|
||||
const intensity = getVisualIntensity(distance, mode);
|
||||
|
||||
const color = new Color();
|
||||
const hsl = { h: 0, s: 1, l: 1 };
|
||||
|
||||
if (temp < lower) {
|
||||
BLUE.getHSL(hsl);
|
||||
} else {
|
||||
RED.getHSL(hsl);
|
||||
}
|
||||
|
||||
color.setHSL(
|
||||
hsl.h,
|
||||
(saturationRange * intensity) + minimumSaturation,
|
||||
(lightnessRange * intensity) + minimumLightness
|
||||
);
|
||||
|
||||
return color;
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ import ButtonGroup from "common/ButtonGroup";
|
|||
import BinTransactions from "bin/BinTransactions";
|
||||
import { CO2 } from "models/CO2";
|
||||
import ObjectInteractions from "objects/objectInteractions/ObjectInteractions";
|
||||
import Bin3dView from "bin/3dView/Bin3dView";
|
||||
import Bin3dView from "bin/3dView/Scene/Bin3dView";
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue