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 BinShell from "./BinParts/BinShell";
import GrainFillFlat from "./BinInventory/GrainFillFlat"; import GrainFillFlat from "./BinInventory/GrainFillFlat";
import BinCables from "./BinCables/BinCables"; import BinCables from "./BinCables/BinCables";
import { Vector3 } from "three";
import { useMemo } from "react";
interface Props { 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, // 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 // 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 {bin, scale = 100, fillPercent} = props
const binCenter = useMemo(() => new Vector3(0, 0, 0), []);
console.log(bin) console.log(bin)
@ -55,7 +58,7 @@ export default function Bin3dView(props: Props){
/> />
)} )}
{/* cables */} {/* cables */}
<BinCables bin={bin}/> <BinCables bin={bin} binCenter={binCenter}/>
</group> </group>
{/* lighting */} {/* lighting */}

View file

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

View file

@ -1,11 +1,13 @@
import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere" import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere"
import { useFrame, useThree } from "@react-three/fiber" import { useFrame, useThree } from "@react-three/fiber"
import { Billboard, Text } from "@react-three/drei" import { Billboard, Text } from "@react-three/drei"
import React, { useMemo, useState } from "react" import React, { useMemo, useRef, useState } from "react"
import { Vector3 } from "three" import { Euler, Group, Vector3 } from "three"
import { cloneDeep } from "lodash"
import { describeMeasurement } from "pbHelpers/MeasurementDescriber" import { describeMeasurement } from "pbHelpers/MeasurementDescriber"
import { quack } from "protobuf-ts/quack" 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 { export interface NodeData {
radius: number radius: number
@ -14,21 +16,29 @@ export interface NodeData {
humidity?: number humidity?: number
moisture?: number moisture?: number
topNode?: boolean topNode?: boolean
excluded?: boolean
inGrain?: boolean
} }
interface Props { interface Props {
node: NodeData node: NodeData
binCenter: Vector3
lowerThreshold: number
upperThreshold: number
} }
export default function CableNode(props: Props){ export default function CableNode(props: Props){
const {node} = props const {node, binCenter, lowerThreshold, upperThreshold} = props
const { camera } = useThree(); const { camera } = useThree();
const [{user}] = useGlobalState();
const [showLabel, setShowLabel] = useState(false); const [showLabel, setShowLabel] = useState(false);
const labelGroupRef = React.useRef<Group>(null);
const ROW_HEIGHT = 35;
const PADDING = 20;
useFrame(() => { useFrame(() => {
//use the cameras distance to the center of the bin //use the cameras distance to the center of the bin
const distance = camera.position.distanceTo(new Vector3(0, 0, 0)) const distance = camera.position.distanceTo(binCenter)
console.log(distance)
const SHOW_DISTANCE = 15; const SHOW_DISTANCE = 15;
const HIDE_DISTANCE = 16; // hysteresis buffer const HIDE_DISTANCE = 16; // hysteresis buffer
@ -38,73 +48,147 @@ export default function CableNode(props: Props){
} else if (showLabel && distance > HIDE_DISTANCE) { } else if (showLabel && distance > HIDE_DISTANCE) {
setShowLabel(false); 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(() => ({ const geometry = React.useMemo(() => ({
radius: node.radius, radius: node.radius,
} as SphereGeometry), [node]); } 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 labelPosition = node.position.clone();
const labelHeight = rows.length * ROW_HEIGHT + PADDING;
const dir = camera.position.clone().sub(node.position).normalize(); const dir = camera.position.clone().sub(node.position).normalize();
// push label slightly toward camera // push label slightly toward camera
labelPosition.add(dir.multiplyScalar(1)); // small offset like 0.52 units 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 ( return (
<React.Fragment> <React.Fragment>
{!showLabel ? {(!node.inGrain || !showLabel) &&
<Sphere geometry={geometry} position={node.position}/> <React.Fragment>
: <Sphere geometry={geometry} position={node.position} colour={nodeColour()}/>
<Billboard position={labelPosition}> {node.topNode && (
<group renderOrder={999}> <Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation}/>
{/* Background */} )}
<mesh position={[0, 0, -1]}> </React.Fragment>
<planeGeometry args={[200, 100]} /> }
<meshBasicMaterial {node.inGrain && showLabel &&
color="black" <Billboard position={labelPosition}>
transparent <group ref={labelGroupRef} renderOrder={999}>
opacity={0.7} {/* Background */}
depthWrite={false} <mesh position={[0, 0, -1]}>
depthTest={false} <planeGeometry args={[200, labelHeight]} />
/> <meshBasicMaterial
</mesh> color="black"
transparent
{/* Temp Text */} opacity={0.9}
<Text depthWrite={false}
position={[0, 35, 0]} depthTest={false}
fontSize={30} />
color={describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()} </mesh>
anchorX="center" {/* Rows */}
anchorY="middle"> {rows.map((row, i) => (
{node.celcius.toFixed(2)}°C <Text
</Text> key={i}
{/* Humidity Text */} position={[0, getRowY(i), 0]}
{node.humidity && fontSize={30}
<Text color={row.color}
position={[0, 0, 0]} anchorX="center"
fontSize={30} anchorY="middle"
color={describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()} >
anchorX="center" {row.text}
anchorY="middle"> </Text>
{node.humidity.toFixed(2)}% ))}
</Text> </group>
} </Billboard>
{/* 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> </React.Fragment>
) )
} }

View file

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

View file

@ -615,7 +615,9 @@ export default function BinSVGV2(props: Props) {
//determine how high to draw the node on the cable //determine how high to draw the node on the cable
const nodeY = nodeSpacingY * (index + 1) + minNodeY; 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.excludedNodes.includes(nodeNumber - 1)){
if (cable.topNode > 0) { if (cable.topNode > 0) {
if (nodeNumber <= cable.topNode) { if (nodeNumber <= cable.topNode) {

View file

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