nodes and labes adjust based on zoom level, colours show based on the bins upper and lower temp limits, working on panning functionality for the camera

This commit is contained in:
csawatzky 2026-04-13 14:46:20 -06:00
parent ee416db013
commit f5db701d3c
7 changed files with 209 additions and 66 deletions

View file

@ -0,0 +1,40 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface RingGeometry{
// the radius of the ring from the center to the ouside edge, must be larger than thickness
radius: number
// the thickness of the tube, must be smaller than radius
thickness: number
radialSegments?: number
tubularSegments?: number
// central angle in radians
arc?: number
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: RingGeometry;
}
export default function Ring(props: Props) {
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.TorusGeometry(
geometry.radius,
geometry.thickness,
geometry.radialSegments ?? 12,
geometry.tubularSegments ?? 48,
geometry.arc ?? Math.PI * 2
);
}, [
geometry.radius,
geometry.thickness,
geometry.radialSegments,
geometry.tubularSegments,
geometry.arc
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -4,6 +4,8 @@ import { Bin } from "models";
import BinShell from "./BinParts/BinShell";
import GrainFillFlat from "./BinInventory/GrainFillFlat";
import BinCables from "./BinCables/BinCables";
import { Vector3 } from "three";
import { useMemo } from "react";
interface Props {
/**
@ -27,6 +29,7 @@ export default function Bin3dView(props: Props){
// and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well,
// 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)
@ -55,7 +58,7 @@ export default function Bin3dView(props: Props){
/>
)}
{/* cables */}
<BinCables bin={bin}/>
<BinCables bin={bin} binCenter={binCenter}/>
</group>
{/* lighting */}

View file

@ -14,10 +14,11 @@ interface CableOrbit {
interface Props {
bin: Bin
binCenter: Vector3
}
export default function BinCables(props: Props){
const {bin} = props
const {bin, binCenter} = props
const getLayoutFromDiameter = (diameterCm: number): CableOrbit[] => {
const diameterFt = diameterCm / 30.48;
@ -192,8 +193,8 @@ export default function BinCables(props: Props){
return (
<React.Fragment>
{cables3D.map(cable => (
<GrainCable cable={cable}/>
{cables3D.map((cable, i) => (
<GrainCable key={"cable " + i} cable={cable} bin={bin} binCenter={binCenter}/>
))}
</React.Fragment>
)

View file

@ -1,11 +1,13 @@
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 React, { useMemo, useRef, 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
@ -14,21 +16,29 @@ export interface NodeData {
humidity?: number
moisture?: number
topNode?: boolean
excluded?: boolean
inGrain?: boolean
}
interface Props {
node: NodeData
binCenter: Vector3
lowerThreshold: number
upperThreshold: number
}
export default function CableNode(props: Props){
const {node} = props
const {node, binCenter, lowerThreshold, upperThreshold} = props
const { camera } = useThree();
const [{user}] = useGlobalState();
const [showLabel, setShowLabel] = useState(false);
const labelGroupRef = React.useRef<Group>(null);
const ROW_HEIGHT = 35;
const PADDING = 20;
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 distance = camera.position.distanceTo(binCenter)
const SHOW_DISTANCE = 15;
const HIDE_DISTANCE = 16; // hysteresis buffer
@ -38,73 +48,147 @@ export default function CableNode(props: Props){
} else if (showLabel && distance > HIDE_DISTANCE) {
setShowLabel(false);
}
const scale = distance * 0.07;
if (labelGroupRef.current) {
labelGroupRef.current.scale.set(scale, scale, 1);
}
});
const tempDisplay = () => {
let temp = node.celcius.toFixed(2) + "°C"
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = ((node.celcius * 9/5) + 32).toFixed(2) + "°F"
}
return temp
}
const nodeColour = () => {
if (!node.inGrain) return "white";
const lower = lowerThreshold;
const upper = upperThreshold;
if (lower !== undefined && node.celcius < lower) {
return "#3399ff"; // blue
}
if (upper !== undefined && node.celcius > upper) {
return "#ff4d4f"; // red
}
return "#52c41a"; // green
};
const rows = useMemo(() => {
const r: { text: string; color: string }[] = [];
if (node.excluded) {
r.push({
text: "Excluded",
color: "red"
});
return r; // nothing else matters
}
if (node.topNode) {
r.push({
text: "Top Node",
color: "yellow"
});
}
r.push({
text: tempDisplay(),
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
});
if (node.humidity) {
r.push({
text: `${node.humidity.toFixed(2)}%`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
});
}
if (node.moisture) {
r.push({
text: `${node.moisture.toFixed(2)}% EMC`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
});
}
return r;
}, [node, user]);
const geometry = React.useMemo(() => ({
radius: node.radius,
} as SphereGeometry), [node]);
const topNodeGeo = React.useMemo(() => ({
radius: node.radius,
thickness: 5,
} as RingGeometry), [node])
const topNodePosition = React.useMemo(() => (new Vector3(node.position.x, node.position.y + 25, node.position.z)), [node])
const topNodeRotation = React.useMemo(() => new Euler(-Math.PI / 2, 0, 0), []);
const labelPosition = node.position.clone();
const labelHeight = rows.length * ROW_HEIGHT + PADDING;
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
const getRowY = (index: number) => {
const totalHeight = rows.length * ROW_HEIGHT;
return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2;
};
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>
{(!node.inGrain || !showLabel) &&
<React.Fragment>
<Sphere geometry={geometry} position={node.position} colour={nodeColour()}/>
{node.topNode && (
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation}/>
)}
</React.Fragment>
}
{node.inGrain && showLabel &&
<Billboard position={labelPosition}>
<group ref={labelGroupRef} renderOrder={999}>
{/* Background */}
<mesh position={[0, 0, -1]}>
<planeGeometry args={[200, labelHeight]} />
<meshBasicMaterial
color="black"
transparent
opacity={0.9}
depthWrite={false}
depthTest={false}
/>
</mesh>
{/* Rows */}
{rows.map((row, i) => (
<Text
key={i}
position={[0, getRowY(i), 0]}
fontSize={30}
color={row.color}
anchorX="center"
anchorY="middle"
>
{row.text}
</Text>
))}
</group>
</Billboard>
}
</React.Fragment>
)
}

View file

@ -3,6 +3,7 @@ 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
@ -15,9 +16,11 @@ export interface CableData {
interface Props {
cable: CableData
bin: Bin
binCenter: Vector3
}
export default function GrainCable(props: Props) {
const {cable} = props
const {cable, bin, binCenter} = props
const calculateHeight = () => {
return cable.topPosition.distanceTo(cable.bottomPosition)
@ -59,7 +62,9 @@ export default function GrainCable(props: Props) {
celcius: celcius,
humidity: humidity,
moisture: moisture,
topNode: grainCable.topNode === i
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
})
})
@ -78,8 +83,8 @@ export default function GrainCable(props: Props) {
return (
<React.Fragment>
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5}/>
{buildCableNodes().map(node => (
<CableNode node={node}/>
{buildCableNodes().map((node, i) => (
<CableNode key={"node " + i} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
))}
</React.Fragment>
)

View file

@ -615,7 +615,9 @@ export default function BinSVGV2(props: Props) {
//determine how high to draw the node on the cable
const nodeY = nodeSpacingY * (index + 1) + minNodeY;
//if we are using the auto top nodes only show the heatmap for nodes at or below the top node and it is not an ecluded node
//if we are using the auto top nodes only show the heatmap for nodes at or below the top node and it is not an excluded node
//NOTE it is nodeNumber - 1 because the excluded node array uses a 0 based count for the node number but the nodeNumber variable uses a 1 based count
//ie. the bottom node of the cable would be nodeNumber of 1 but in the array it would be 0
if (!cable.excludedNodes.includes(nodeNumber - 1)){
if (cable.topNode > 0) {
if (nodeNumber <= cable.topNode) {

View file

@ -284,4 +284,12 @@ export class Bin {
}
return c;
}
public upperTempThreshold(): number {
return this.settings.highTemp
}
public lowerTempThreshold(): number {
return this.settings.lowTemp
}
}