From cb6609bdb1350e010f4193c40ebc80a65cc68ad3 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 8 Apr 2026 09:39:42 -0600 Subject: [PATCH 01/44] built out a simple framework for building 3d objects an orbital camera and some basic shapes are used to build the 3d bin shell and the flat inventory when using manual or lidar are built --- package-lock.json | 264 ++++++++++++++++++ package.json | 3 + .../CameraControls/OrbitCameraControls.tsx | 119 ++++++++ src/3dModels/Shapes/3D/Circle.tsx | 34 +++ src/3dModels/Shapes/3D/Cone.tsx | 43 +++ src/3dModels/Shapes/3D/Cube.tsx | 40 +++ src/3dModels/Shapes/3D/Cylinder.tsx | 46 +++ src/3dModels/Shapes/BaseMesh.tsx | 87 ++++++ src/bin/3dView/Bin3dView.tsx | 74 +++++ src/bin/3dView/BinParts/BinShell.tsx | 127 +++++++++ src/bin/3dView/grainFill/GrainFillFlat.tsx | 123 ++++++++ src/models/Bin.ts | 20 ++ src/pages/Bin.tsx | 15 + 13 files changed, 995 insertions(+) create mode 100644 src/3dModels/CameraControls/OrbitCameraControls.tsx create mode 100644 src/3dModels/Shapes/3D/Circle.tsx create mode 100644 src/3dModels/Shapes/3D/Cone.tsx create mode 100644 src/3dModels/Shapes/3D/Cube.tsx create mode 100644 src/3dModels/Shapes/3D/Cylinder.tsx create mode 100644 src/3dModels/Shapes/BaseMesh.tsx create mode 100644 src/bin/3dView/Bin3dView.tsx create mode 100644 src/bin/3dView/BinParts/BinShell.tsx create mode 100644 src/bin/3dView/grainFill/GrainFillFlat.tsx diff --git a/package-lock.json b/package-lock.json index 2cd53e8..348bfdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,10 +25,12 @@ "@mui/styles": "^6.1.6", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.3.0", + "@react-three/fiber": "^8.18.0", "@sentry/react": "^8.38.0", "@turf/area": "^7.2.0", "@turf/turf": "^7.2.0", "@types/classnames": "^2.3.0", + "@types/three": "^0.183.1", "axios": "^1.7.7", "crisp-sdk-web": "^1.0.26", "dayjs": "^1.11.13", @@ -62,6 +64,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", + "three": "^0.183.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, @@ -1776,6 +1779,12 @@ } } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -3223,6 +3232,64 @@ "@react-pdf/stylesheet": "^6.1.2" } }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -5534,6 +5601,12 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5930,6 +6003,15 @@ "@types/viewport-mercator-project": "*" } }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-redux": { "version": "7.1.34", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", @@ -5984,6 +6066,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -5993,6 +6081,21 @@ "@types/geojson": "*" } }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -6010,6 +6113,12 @@ "gl-matrix": "^3.2.0" } }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -6450,6 +6559,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -6882,6 +6997,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8386,6 +8525,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -9211,6 +9356,26 @@ "dev": true, "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9793,6 +9958,27 @@ "node": ">=0.10.0" } }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -10414,6 +10600,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -11663,6 +11855,31 @@ "react-dom": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0" } }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-redux": { "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", @@ -11767,6 +11984,21 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-virtualized-auto-sizer": { "version": "1.0.26", "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", @@ -12995,6 +13227,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/svg-arc-to-cubic-bezier": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", @@ -13071,6 +13312,12 @@ "node": ">=10" } }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -15278,6 +15525,23 @@ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" + }, + "node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 90ac71b..32de8f2 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,12 @@ "@mui/styles": "^6.1.6", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.3.0", + "@react-three/fiber": "^8.18.0", "@sentry/react": "^8.38.0", "@turf/area": "^7.2.0", "@turf/turf": "^7.2.0", "@types/classnames": "^2.3.0", + "@types/three": "^0.183.1", "axios": "^1.7.7", "crisp-sdk-web": "^1.0.26", "dayjs": "^1.11.13", @@ -74,6 +76,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", + "three": "^0.183.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx new file mode 100644 index 0000000..20768b4 --- /dev/null +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -0,0 +1,119 @@ +import { useThree } from "@react-three/fiber"; +import { useEffect, useRef } from "react"; + +interface CameraTarget { + x: number; + y: number; + z: number; +} + +interface Props { + target?: CameraTarget; + clampVerticalRotation?: boolean; + /** + * The minimum vertical rotation in radians + * @default 0.01 + */ + minPhi?: number; + /** + * The maximum vertical rotation in radians + * @default Math.PI - 0.01 + */ + maxPhi?: number; +} + +export default function OrbitCameraControls(props: Props) { + const { target, clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01 } = props; + const { camera, gl } = useThree(); + + const isDragging = useRef(false); + const lastPos = useRef({ x: 0, y: 0 }); + + // Spherical coords + const spherical = useRef({ + radius: 10, + theta: 0, // horizontal + phi: Math.PI / 2, // vertical + }); + + useEffect(() => { + const canvas = gl.domElement; + + const updateCamera = () => { + const { radius, theta, phi } = spherical.current; + + const x = radius * Math.sin(phi) * Math.sin(theta); + const y = radius * Math.cos(phi); + const z = radius * Math.sin(phi) * Math.cos(theta); + + camera.position.set(x, y, z); + camera.lookAt(target?.x ?? 0, target?.y ?? 0, target?.z ?? 0); + }; + + updateCamera(); + + const handleMouseDown = (e: MouseEvent) => { + if (e.target !== canvas) return; + isDragging.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging.current) return; + + const deltaX = e.clientX - lastPos.current.x; + const deltaY = e.clientY - lastPos.current.y; + + spherical.current.theta -= deltaX * 0.005; + spherical.current.phi -= deltaY * 0.005; + + // Clamp vertical rotation (prevents flipping) + if (clampVerticalRotation) { + spherical.current.phi = Math.max( + minPhi, + Math.min(maxPhi, spherical.current.phi) + ); + } + + lastPos.current = { x: e.clientX, y: e.clientY }; + + updateCamera(); + }; + + const handleMouseUp = () => { + isDragging.current = false; + }; + + const handleWheel = (e: WheelEvent) => { + if (e.target !== canvas) return; + + e.preventDefault(); + + spherical.current.radius += e.deltaY * 0.01; + + // Clamp zoom + spherical.current.radius = Math.max( + 3, + Math.min(30, spherical.current.radius) + ); + + updateCamera(); + }; + + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + canvas.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + + canvas.removeEventListener("wheel", handleWheel); + }; + }, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi]); + + return null; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Circle.tsx b/src/3dModels/Shapes/3D/Circle.tsx new file mode 100644 index 0000000..8204a29 --- /dev/null +++ b/src/3dModels/Shapes/3D/Circle.tsx @@ -0,0 +1,34 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import BaseMesh, { BaseMeshProps } from "../BaseMesh"; + +export interface CircleGeometry{ + radius: number + radialSegments?: number + thetaStart?: number + thetaLength?: number +} + +interface Props extends Omit { + geometry: CircleGeometry; +} + +export default function Circle(props: Props) { + const { geometry } = props; + + const geo = useMemo(() => { + return new THREE.CircleGeometry( + geometry.radius, + geometry.radialSegments ?? 24, + geometry.thetaStart ?? 0, + geometry.thetaLength ?? Math.PI * 2 + ); + }, [ + geometry.radius, + geometry.radialSegments, + geometry.thetaStart, + geometry.thetaLength + ]); + + return ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Cone.tsx b/src/3dModels/Shapes/3D/Cone.tsx new file mode 100644 index 0000000..90d3e81 --- /dev/null +++ b/src/3dModels/Shapes/3D/Cone.tsx @@ -0,0 +1,43 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import BaseMesh, { BaseMeshProps } from "../BaseMesh"; + +export interface ConeGeometry { + radius: number; + height: number; + radialSegments?: number; + heightSegments?: number; + openEnded?: boolean; + thetaStart?: number; + thetaLength?: number; +} + +interface Props extends Omit { + geometry: ConeGeometry; +} + +export default function Cone(props: Props) { + const { geometry } = props; + + const geo = useMemo(() => { + return new THREE.ConeGeometry( + geometry.radius, + geometry.height, + geometry.radialSegments ?? 24, + geometry.heightSegments ?? 1, + geometry.openEnded ?? false, + geometry.thetaStart ?? 0, + geometry.thetaLength ?? Math.PI * 2 + ); + }, [ + geometry.radius, + geometry.height, + geometry.radialSegments, + geometry.heightSegments, + geometry.openEnded, + geometry.thetaStart, + geometry.thetaLength + ]); + + return ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Cube.tsx b/src/3dModels/Shapes/3D/Cube.tsx new file mode 100644 index 0000000..94ae03b --- /dev/null +++ b/src/3dModels/Shapes/3D/Cube.tsx @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import BaseMesh, { BaseMeshProps } from "../BaseMesh"; + +export interface CubeGeometry { + height: number; + width: number; + depth: number; + widthSegments?: number; + heightSegments?: number; + depthSegments?: number; +} + +interface Props extends Omit { + geometry: CubeGeometry; +} + +export default function Cube(props: Props) { + const { geometry } = props; + + const geo = useMemo(() => { + return new THREE.BoxGeometry( + geometry.height, + geometry.width, + geometry.depth, + geometry.widthSegments ?? 2, + geometry.heightSegments ?? 2, + geometry.depthSegments ?? 2 + ); + }, [ + geometry.height, + geometry.width, + geometry.depth, + geometry.widthSegments, + geometry.heightSegments, + geometry.depthSegments + ]); + + return ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Cylinder.tsx b/src/3dModels/Shapes/3D/Cylinder.tsx new file mode 100644 index 0000000..ee074df --- /dev/null +++ b/src/3dModels/Shapes/3D/Cylinder.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import BaseMesh, { BaseMeshProps } from "../BaseMesh"; + +export interface CylinderGeometry { + radiusTop: number; + radiusBottom: number; + height: number; + radialSegments?: number; + heightSegments?: number; + openEnded?: boolean; + thetaStart?: number; + thetaLength?: number; +} + +interface Props extends Omit { + geometry: CylinderGeometry; +} + +export default function Cylinder(props: Props) { + const { geometry } = props; + + const geo = useMemo(() => { + return new THREE.CylinderGeometry( + geometry.radiusTop, + geometry.radiusBottom, + geometry.height, + geometry.radialSegments ?? 24, + geometry.heightSegments ?? 1, + geometry.openEnded ?? false, + geometry.thetaStart ?? 0, + geometry.thetaLength ?? Math.PI * 2 + ); + }, [ + geometry.radiusTop, + geometry.radiusBottom, + geometry.height, + geometry.radialSegments, + geometry.heightSegments, + geometry.openEnded, + geometry.thetaStart, + geometry.thetaLength + ]); + + return ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx new file mode 100644 index 0000000..d83716a --- /dev/null +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -0,0 +1,87 @@ +import { Euler, Vector3, BufferGeometry, MaterialParameters, Side } from "three"; + +export interface BaseMeshProps { + geometry: BufferGeometry; + position?: Vector3; + rotation?: Euler; + colour?: string; + opacity?: number; + metalness?: number; + roughness?: number; + wireframe?: boolean; + frameColour?: string; + materialType?: "standard" | "basic"; + materialProps?: Partial; + materialOverride?: React.ReactNode; + side?: Side +} + +export default function BaseMesh(props: BaseMeshProps) { + const { + geometry, + position, + rotation, + colour = "#fff", + opacity = 1, + metalness = 0, + roughness = 1, + wireframe = false, + frameColour = "black", + materialType, + materialProps, + materialOverride, + side + } = props; + + const isTransparent = opacity < 1; + const buildMaterial = () => { + if (materialOverride) return materialOverride; + switch (materialType) { + case "basic": + return ( + + ); + + case "standard": + default: + return ( + + ); + } + }; + + return ( + + {/* Main surface */} + + {buildMaterial()} + + + {/* Optional wireframe overlay */} + {wireframe && ( + + + + )} + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Bin3dView.tsx new file mode 100644 index 0000000..0f700a4 --- /dev/null +++ b/src/bin/3dView/Bin3dView.tsx @@ -0,0 +1,74 @@ +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"; + +interface Props { + /** + * The bin to generate a 3D model of + */ + bin: Bin + /** + * The scale to apply to the bin dimensions + * ie 100 would make the bin 1:100 scale + * @default 100 + */ + scale: number + /** + * optional: the percent of the bin to fill using a level top, this will be used with manual and lidar controls + */ + fillPercent?: number +} + +export default function Bin3dView(props: Props){ + //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof + // 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 + + console.log(bin) + + return ( + + + + + {/* grain - cylinder*/} + {fillPercent !== undefined && ( + + )} + + {/* cables */} + + {/* lighting */} + + + + + + {/* */} + {/* camera controls */} + + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinParts/BinShell.tsx b/src/bin/3dView/BinParts/BinShell.tsx new file mode 100644 index 0000000..d259bb7 --- /dev/null +++ b/src/bin/3dView/BinParts/BinShell.tsx @@ -0,0 +1,127 @@ +import Circle, { CircleGeometry } from "3dModels/Shapes/3D/Circle" +import Cone, { ConeGeometry } from "3dModels/Shapes/3D/Cone" +import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder" +import React from "react" +import { Euler, Vector3 } from "three" + +interface Props { + radialSegments: number + binBodyColour: string + binMetalness: number + binRoughness: number + binOpacity: number + diameter: number + sidewallHeight: number + roofHeight: number + hopperHeight?: number + wireframe?: boolean +} + +export default function BinShell(props: Props) { + const { radialSegments, binBodyColour, binMetalness, binRoughness, binOpacity, diameter, sidewallHeight, roofHeight, hopperHeight, wireframe } = props + const cylinderGeometry = React.useMemo(() => ({ + radiusTop: diameter / 2, + radiusBottom: diameter / 2, + height: sidewallHeight, + radialSegments, + heightSegments: 2, + openEnded: true, + } as CylinderGeometry), [diameter, sidewallHeight, radialSegments]); + + const roofGeometry = React.useMemo(() => ({ + height: roofHeight, + radius: diameter /2, + radialSegments: radialSegments, + openEnded: true + } as ConeGeometry), [diameter, roofHeight, radialSegments]) + + const hopperGeometry = React.useMemo(() => ({ + height: hopperHeight, + radius: diameter /2, + radialSegments: radialSegments, + openEnded: true + } as ConeGeometry),[hopperHeight, diameter, radialSegments]) + + const flatBottomGeometry = React.useMemo(() => ({ + radius: diameter/2, + radialSegments: radialSegments + } as CircleGeometry),[diameter, radialSegments]) + + const roofPosition = React.useMemo( + () => new Vector3(0, sidewallHeight/2 + roofHeight/2, 0), + [sidewallHeight, roofHeight] + ); + + const bottomPosition = React.useMemo( + () => { + if(hopperHeight !== undefined && hopperHeight > 0){ + //this returns the position for the center of the cone for the hopper + return new Vector3(0, (sidewallHeight/2 + hopperHeight/2)*-1, 0) + } else { + //this returns the position for the circle for the flat bottom + return new Vector3(0, -sidewallHeight/2, 0) + } + }, + [sidewallHeight, hopperHeight] + ) + + const hopperRotation = React.useMemo( + () => new Euler(Math.PI, 0, 0), + [] + ); + + const flatBottomRotation = React.useMemo( + () => new Euler(-Math.PI / 2, 0, 0), + [] + ); + + return ( + + {/* bin roof - cone */} + + {/* bin sidewall - cylinder (open ended for the roof and bottom)*/} + + {/* bin bottom - cone -OR- circle depending on the bins shape*/} + {hopperHeight !== undefined && hopperHeight > 0 ? + + : + + } + + ) + +} \ No newline at end of file diff --git a/src/bin/3dView/grainFill/GrainFillFlat.tsx b/src/bin/3dView/grainFill/GrainFillFlat.tsx new file mode 100644 index 0000000..29e0db3 --- /dev/null +++ b/src/bin/3dView/grainFill/GrainFillFlat.tsx @@ -0,0 +1,123 @@ +import React, { useMemo } from "react"; +import Cylinder from "3dModels/Shapes/3D/Cylinder"; +import Cone from "3dModels/Shapes/3D/Cone"; +import { Vector3, Euler } from "three"; + +interface Props { + diameter: number; + sidewallHeight: number; + hopperHeight?: number; + fillPercent: number; +} + +export default function GrainFillFlat(props: Props) { + const { + diameter, + sidewallHeight, + hopperHeight = 0, + fillPercent + } = props; + + const radius = diameter / 2; + + // Slightly smaller to avoid z-fighting with shell + const grainRadius = radius * 0.98; + + // --- volumes --- + const cylinderVolume = Math.PI * radius * radius * sidewallHeight; + const hopperVolume = hopperHeight > 0 + ? (1 / 3) * Math.PI * radius * radius * hopperHeight + : 0; + + const totalVolume = cylinderVolume + hopperVolume; + const filledVolume = totalVolume * fillPercent; + + // --- result values --- + let hopperFillHeight = 0; + let cylinderFillHeight = 0; + + if (hopperHeight > 0 && filledVolume <= hopperVolume) { + // ONLY hopper filling + const ratio = filledVolume / hopperVolume; + + hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3); + cylinderFillHeight = 0; + } else { + // hopper full (or no hopper) + hopperFillHeight = hopperHeight; + + const remainingVolume = filledVolume - hopperVolume; + + cylinderFillHeight = Math.max( + 0, + remainingVolume / (Math.PI * radius * radius) + ); + } + + // --- positions --- + + const hopperPosition = useMemo( + () => new Vector3( + 0, + -(sidewallHeight / 2 + hopperHeight) + hopperFillHeight / 2, //adding the hopper height so it starts at the cone and not the flat side + 0 + ), + [sidewallHeight, hopperFillHeight] + ); + + //flip the cone so it matches the hoppers rotation with the cone pointed down + const hopperRotation = useMemo( + () => new Euler(Math.PI, 0, 0), + [] + ); + + const cylinderPosition = useMemo( + () => new Vector3( + 0, + -sidewallHeight / 2 + cylinderFillHeight / 2, + 0 + ), + [sidewallHeight, cylinderFillHeight] + ); + + // --- material --- + const grainColour = "#fff302"; + + return ( + <> + {/* Hopper fill */} + {hopperHeight > 0 && hopperFillHeight > 0 && ( + + )} + + {/* Cylinder fill */} + {cylinderFillHeight > 0 && ( + + )} + + ); +} \ No newline at end of file diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 5ad8635..7f9676d 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -52,6 +52,26 @@ export class Bin { return this.settings.specs?.shape; } + public diameter(): number { + return this.settings.specs?.diameterCm ?? 0; + } + + public height(): number { + return this.settings.specs?.heightCm ?? 0; + } + + public sidewallHeight(): number { + return this.settings.specs?.advancedDimensions?.sidewallHeight ?? 0; + } + + public roofHeight() : number { + return this.settings.specs?.advancedDimensions?.topConeHeight ?? 0; + } + + public hopperHeight() : number { + return this.settings.specs?.advancedDimensions?.hopperHeight ?? 0; + } + public key(): string { return this.settings.key; } diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 4cbbba7..5eafbf5 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -21,6 +21,7 @@ import { AccordionSummary, AccordionDetails, Typography, + TextField, } from "@mui/material"; import BinActions from "bin/BinActions"; import BinHistory from "bin/BinHistory"; @@ -65,6 +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"; interface TabPanelProps { children?: React.ReactNode; @@ -773,6 +775,8 @@ export default function Bin(props: Props) { ); }; + const [fillPercent, setFillPercent] = useState(0) + const desktopView = () => { return ( @@ -802,6 +806,17 @@ export default function Bin(props: Props) { {overview()} {preferences && binComponents(preferences)} + + + { + setFillPercent(+e.target.value) + }} + /> + + + {(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING || From ee416db01302d793e05f7e14a9436d8ef8279ce7 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 10 Apr 2026 16:22:48 -0600 Subject: [PATCH 02/44] 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 --- package-lock.json | 468 +++++++++++++++++- package.json | 3 +- src/3dModels/Shapes/3D/Sphere.tsx | 43 ++ src/bin/3dView/Bin3dView.tsx | 12 +- src/bin/3dView/BinCables/BinCables.tsx | 200 ++++++++ src/bin/3dView/BinCables/CableNode.tsx | 110 ++++ src/bin/3dView/BinCables/GrainCable.tsx | 86 ++++ .../GrainFillFlat.tsx | 0 src/models/Bin.ts | 8 + 9 files changed, 911 insertions(+), 19 deletions(-) create mode 100644 src/3dModels/Shapes/3D/Sphere.tsx create mode 100644 src/bin/3dView/BinCables/BinCables.tsx create mode 100644 src/bin/3dView/BinCables/CableNode.tsx create mode 100644 src/bin/3dView/BinCables/GrainCable.tsx rename src/bin/3dView/{grainFill => BinInventory}/GrainFillFlat.tsx (100%) diff --git a/package-lock.json b/package-lock.json index 348bfdd..2b0371b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@mui/styles": "^6.1.6", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.3.0", + "@react-three/drei": "^9.105.6", "@react-three/fiber": "^8.18.0", "@sentry/react": "^8.38.0", "@turf/area": "^7.2.0", @@ -64,7 +65,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", - "three": "^0.183.2", + "three": "^0.152.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, @@ -2480,6 +2481,12 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.8.tgz", + "integrity": "sha512-Rp7ll8BHrKB3wXaRFKhrltwZl1CiXGdibPxuWXvqGnKTnv8fqa/nvftYNuSbf+pbJWKYCXdBtYTITdAUTGGh0Q==", + "license": "Apache-2.0" + }, "node_modules/@mui/core-downloads-tracker": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", @@ -3232,6 +3239,134 @@ "@react-pdf/stylesheet": "^6.1.2" } }, + "node_modules/@react-spring/animated": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz", + "integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz", + "integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/rafz": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz", + "integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz", + "integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.6.1.tgz", + "integrity": "sha512-Tyw2YhZPKJAX3t2FcqvpLRb71CyTe1GvT3V+i+xJzfALgpk10uPGdGaQQ5Xrzmok1340DAeg2pR/MCfaW7b8AA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/core": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz", + "integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.105.6", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.105.6.tgz", + "integrity": "sha512-JBgYeV36N9N9f1c3o1ZfLYW4rXZA7UQTq32Y8s3DEF6lwj1/y+RP/yq2VG5I8OzUPl7gsmWdy8fpWZgrlAqUpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@mediapipe/tasks-vision": "0.10.8", + "@monogrid/gainmap-js": "^3.0.5", + "@react-spring/three": "~9.6.1", + "@use-gesture/react": "^10.2.24", + "camera-controls": "^2.4.2", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.28", + "glsl-noise": "^0.0.0", + "hls.js": "1.3.5", + "maath": "^0.10.7", + "meshline": "^3.1.6", + "react-composer": "^5.0.3", + "stats-gl": "^2.0.0", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.0", + "three-stdlib": "^2.29.9", + "troika-three-text": "^0.49.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.10.0", + "uuid": "^9.0.1", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=8.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/drei/node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@react-three/fiber": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", @@ -5760,6 +5895,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5919,6 +6060,12 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -6388,6 +6535,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vis.gl/react-mapbox": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.0.tgz", @@ -7177,6 +7342,15 @@ "node": ">=8" } }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001770", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", @@ -7417,11 +7591,28 @@ "integrity": "sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7921,6 +8112,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -7955,6 +8155,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9099,6 +9305,12 @@ "dev": true, "license": "MIT" }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/goober": { "version": "2.1.18", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", @@ -9258,6 +9470,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.3.5.tgz", + "integrity": "sha512-uybAvKS6uDe0MnWNEPnO0krWVr+8m2R0hJ/viql8H3MVK+itq8gGQuIYoFHL3rECkIpNH98Lw8YuuWMKZxp3Ew==", + "license": "Apache-2.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -9386,6 +9604,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9761,6 +9985,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9946,7 +10176,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -10309,6 +10538,15 @@ "integrity": "sha512-rDU6bkpuMs8YRt/UpkuYEAsYSoNuDEbrE41I3KNvmXREGH6DGBJ8Wbak4by29wNOQ27zk4g4HL82zf0OGhwRuw==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -10456,6 +10694,16 @@ "yallist": "^3.0.2" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -10600,6 +10848,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, "node_modules/meshoptimizer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", @@ -11137,7 +11394,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11378,6 +11634,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11585,6 +11851,18 @@ "react": "*" } }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-day-picker": { "version": "9.13.2", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", @@ -12635,7 +12913,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -12648,7 +12925,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12973,6 +13249,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -13313,11 +13615,49 @@ } }, "node_modules/three": { - "version": "0.183.2", - "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", - "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "version": "0.152.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.152.2.tgz", + "integrity": "sha512-Ff9zIpSfkkqcBcpdiFo2f35vA9ZucO+N8TNacJOqaEE6DrB0eufItVMib8bK8Pcju/ZNT6a7blE1GhTpkdsILw==", "license": "MIT" }, + "node_modules/three-mesh-bvh": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.6.tgz", + "integrity": "sha512-rCjsnxEqR9r1/C/lCqzGLS67NDty/S/eT6rAJfDvsanrIctTWdNoR4ZOGWewCB13h1QkVo2BpmC0wakj1+0m8A==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/three-stdlib/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -13460,6 +13800,36 @@ "node": ">=8" } }, + "node_modules/troika-three-text": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.49.1.tgz", + "integrity": "sha512-lXGWxgjJP9kw4i4Wh+0k0Q/7cRfS6iOME4knKht/KozPu9GcFA9NnNpRvehIhrUawq9B0ZRw+0oiFHgRO+4Wig==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.49.0", + "troika-worker-utils": "^0.49.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.49.0.tgz", + "integrity": "sha512-umitFL4cT+Fm/uONmaQEq4oZlyRHWwVClaS6ZrdcueRvwc2w+cpNQ47LlJKJswpqtMFWbEhOLy0TekmcPZOdYA==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.49.0.tgz", + "integrity": "sha512-1xZHoJrG0HFfCvT/iyN41DvI/nRykiBtHqFkGaGgJwq5iXfIZFBiPPEHFpPpgyKM3Oo5ITHXP5wM2TNQszYdVg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -13500,6 +13870,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13850,12 +14257,43 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -14890,6 +15328,17 @@ "integrity": "sha512-VlLyZb9rb0Ir/NvC6T3YOcnftzUMg8hDYMb9xf1pzzM5z5wKogL6q1wZeqE5e+oHSw0CVcTFtYCbSa1ZEwnDZQ==", "license": "MIT" }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -14916,7 +15365,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 32de8f2..9cb57a3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@mui/styles": "^6.1.6", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.3.0", + "@react-three/drei": "^9.105.6", "@react-three/fiber": "^8.18.0", "@sentry/react": "^8.38.0", "@turf/area": "^7.2.0", @@ -76,7 +77,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", - "three": "^0.183.2", + "three": "^0.152.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, diff --git a/src/3dModels/Shapes/3D/Sphere.tsx b/src/3dModels/Shapes/3D/Sphere.tsx new file mode 100644 index 0000000..6104369 --- /dev/null +++ b/src/3dModels/Shapes/3D/Sphere.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Bin3dView.tsx index 0f700a4..9322e48 100644 --- a/src/bin/3dView/Bin3dView.tsx +++ b/src/bin/3dView/Bin3dView.tsx @@ -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} /> )} - {/* cables */} + + {/* lighting */} @@ -62,12 +64,6 @@ export default function Bin3dView(props: Props){ - {/* */} - {/* camera controls */} ) diff --git a/src/bin/3dView/BinCables/BinCables.tsx b/src/bin/3dView/BinCables/BinCables.tsx new file mode 100644 index 0000000..a4026d2 --- /dev/null +++ b/src/bin/3dView/BinCables/BinCables.tsx @@ -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 ( + + {cables3D.map(cable => ( + + ))} + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/CableNode.tsx b/src/bin/3dView/BinCables/CableNode.tsx new file mode 100644 index 0000000..ac3235e --- /dev/null +++ b/src/bin/3dView/BinCables/CableNode.tsx @@ -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 ( + + {!showLabel ? + + : + + + {/* Background */} + + + + + + {/* Temp Text */} + + {node.celcius.toFixed(2)}°C + + {/* Humidity Text */} + {node.humidity && + + {node.humidity.toFixed(2)}% + + } + {/* Moisture Text */} + {node.moisture && + + {node.moisture.toFixed(2)}% EMC + + } + + + } + + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/GrainCable.tsx b/src/bin/3dView/BinCables/GrainCable.tsx new file mode 100644 index 0000000..d7c70e7 --- /dev/null +++ b/src/bin/3dView/BinCables/GrainCable.tsx @@ -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 ( + + + {buildCableNodes().map(node => ( + + ))} + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/grainFill/GrainFillFlat.tsx b/src/bin/3dView/BinInventory/GrainFillFlat.tsx similarity index 100% rename from src/bin/3dView/grainFill/GrainFillFlat.tsx rename to src/bin/3dView/BinInventory/GrainFillFlat.tsx diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 7f9676d..485358e 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -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; } From f5db701d3c3c3ab700f479c3798593e4a3df204a Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 13 Apr 2026 14:46:20 -0600 Subject: [PATCH 03/44] 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 --- src/3dModels/Shapes/3D/Ring.tsx | 40 +++++ src/bin/3dView/Bin3dView.tsx | 5 +- src/bin/3dView/BinCables/BinCables.tsx | 7 +- src/bin/3dView/BinCables/CableNode.tsx | 198 +++++++++++++++++------- src/bin/3dView/BinCables/GrainCable.tsx | 13 +- src/bin/BinSVGV2.tsx | 4 +- src/models/Bin.ts | 8 + 7 files changed, 209 insertions(+), 66 deletions(-) create mode 100644 src/3dModels/Shapes/3D/Ring.tsx diff --git a/src/3dModels/Shapes/3D/Ring.tsx b/src/3dModels/Shapes/3D/Ring.tsx new file mode 100644 index 0000000..1a79d40 --- /dev/null +++ b/src/3dModels/Shapes/3D/Ring.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Bin3dView.tsx index 9322e48..6b6213d 100644 --- a/src/bin/3dView/Bin3dView.tsx +++ b/src/bin/3dView/Bin3dView.tsx @@ -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 */} - + {/* lighting */} diff --git a/src/bin/3dView/BinCables/BinCables.tsx b/src/bin/3dView/BinCables/BinCables.tsx index a4026d2..e7b5684 100644 --- a/src/bin/3dView/BinCables/BinCables.tsx +++ b/src/bin/3dView/BinCables/BinCables.tsx @@ -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 ( - {cables3D.map(cable => ( - + {cables3D.map((cable, i) => ( + ))} ) diff --git a/src/bin/3dView/BinCables/CableNode.tsx b/src/bin/3dView/BinCables/CableNode.tsx index ac3235e..a9803fc 100644 --- a/src/bin/3dView/BinCables/CableNode.tsx +++ b/src/bin/3dView/BinCables/CableNode.tsx @@ -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(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.5–2 units + const getRowY = (index: number) => { + const totalHeight = rows.length * ROW_HEIGHT; + return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2; + }; + + return ( - {!showLabel ? - - : - - - {/* Background */} - - - - - - {/* Temp Text */} - - {node.celcius.toFixed(2)}°C - - {/* Humidity Text */} - {node.humidity && - - {node.humidity.toFixed(2)}% - - } - {/* Moisture Text */} - {node.moisture && - - {node.moisture.toFixed(2)}% EMC - - } - - + {(!node.inGrain || !showLabel) && + + + {node.topNode && ( + + )} + + } + {node.inGrain && showLabel && + + + {/* Background */} + + + + + {/* Rows */} + {rows.map((row, i) => ( + + {row.text} + + ))} + + } - ) } \ No newline at end of file diff --git a/src/bin/3dView/BinCables/GrainCable.tsx b/src/bin/3dView/BinCables/GrainCable.tsx index d7c70e7..e889893 100644 --- a/src/bin/3dView/BinCables/GrainCable.tsx +++ b/src/bin/3dView/BinCables/GrainCable.tsx @@ -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 ( - {buildCableNodes().map(node => ( - + {buildCableNodes().map((node, i) => ( + ))} ) diff --git a/src/bin/BinSVGV2.tsx b/src/bin/BinSVGV2.tsx index ecbeb53..0a24d5c 100644 --- a/src/bin/BinSVGV2.tsx +++ b/src/bin/BinSVGV2.tsx @@ -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) { diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 485358e..475958c 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -284,4 +284,12 @@ export class Bin { } return c; } + + public upperTempThreshold(): number { + return this.settings.highTemp + } + + public lowerTempThreshold(): number { + return this.settings.lowTemp + } } From e7ab6a2d76f356178f56cbaf05ed9640977a0f91 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 13 Apr 2026 14:46:39 -0600 Subject: [PATCH 04/44] working on panning, currently kinda glitchy --- .../CameraControls/OrbitCameraControls.tsx | 119 +++++++++++++++--- 1 file changed, 100 insertions(+), 19 deletions(-) diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index 20768b4..5d707c3 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -1,5 +1,6 @@ import { useThree } from "@react-three/fiber"; import { useEffect, useRef } from "react"; +import { Plane, Raycaster, Vector2, Vector3 } from "three"; interface CameraTarget { x: number; @@ -25,8 +26,18 @@ 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 mouse = useRef(new Vector2()); + const targetRef = useRef({ + x: target?.x ?? 0, + y: target?.y ?? 0, + z: target?.z ?? 0, + }); const isDragging = useRef(false); + const isPanning = useRef(false); const lastPos = useRef({ x: 0, y: 0 }); // Spherical coords @@ -38,7 +49,7 @@ export default function OrbitCameraControls(props: Props) { useEffect(() => { const canvas = gl.domElement; - + canvas.addEventListener("contextmenu", e => e.preventDefault()); const updateCamera = () => { const { radius, theta, phi } = spherical.current; @@ -47,41 +58,111 @@ export default function OrbitCameraControls(props: Props) { const z = radius * Math.sin(phi) * Math.cos(theta); camera.position.set(x, y, z); - camera.lookAt(target?.x ?? 0, target?.y ?? 0, target?.z ?? 0); + camera.lookAt( + targetRef.current.x, + targetRef.current.y, + targetRef.current.z + ); }; updateCamera(); const handleMouseDown = (e: MouseEvent) => { if (e.target !== canvas) return; - isDragging.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; - }; + + // Left click = rotate + if (e.button === 0) { + isDragging.current = true; + isPanning.current = false; + } + + // Right click = pan + if (e.button === 2) { + isPanning.current = true; + isDragging.current = false; - const handleMouseMove = (e: MouseEvent) => { - if (!isDragging.current) return; - - const deltaX = e.clientX - lastPos.current.x; - const deltaY = e.clientY - lastPos.current.y; - - spherical.current.theta -= deltaX * 0.005; - spherical.current.phi -= deltaY * 0.005; - - // Clamp vertical rotation (prevents flipping) - if (clampVerticalRotation) { - spherical.current.phi = Math.max( - minPhi, - Math.min(maxPhi, spherical.current.phi) + // plane facing camera through target + const camDir = new Vector3(); + camera.getWorldDirection(camDir); + + 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 + ); + + raycaster.current.ray.intersectPlane( + panPlane.current, + panStartPoint.current ); } + }; + + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - lastPos.current.x; + const deltaY = e.clientY - lastPos.current.y; + + // ROTATE + if (isDragging.current) { + spherical.current.theta -= deltaX * 0.005; + spherical.current.phi -= deltaY * 0.005; + + if (clampVerticalRotation) { + spherical.current.phi = Math.max( + minPhi, + Math.min(maxPhi, spherical.current.phi) + ); + } + } + + // PAN + if (isPanning.current) { + const raycaster = new Raycaster(); + + mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1; + mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1; + + raycaster.setFromCamera(mouse.current, camera); + + const currentPoint = new Vector3(); + + raycaster.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); + } + lastPos.current = { x: e.clientX, y: e.clientY }; - updateCamera(); }; const handleMouseUp = () => { isDragging.current = false; + isPanning.current = false; }; const handleWheel = (e: WheelEvent) => { From 6c87ed03edc7b656f91787117ec86ea27acd7642 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 16 Apr 2026 16:27:08 -0600 Subject: [PATCH 05/44] 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 --- .../CameraControls/OrbitCameraControls.tsx | 118 ++++++---- src/bin/3dView/BinCables/BinCables.tsx | 201 ------------------ src/bin/3dView/BinCables/GrainCable.tsx | 91 -------- src/bin/3dView/Data/BuildCableData.ts | 194 +++++++++++++++++ src/bin/3dView/Data/BuildNodeData.ts | 60 ++++++ src/bin/3dView/{ => Scene}/Bin3dView.tsx | 15 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 28 +++ .../Cables}/CableNode.tsx | 95 ++++++--- src/bin/3dView/Systems/Cables/GrainCable.tsx | 47 ++++ src/bin/3dView/Systems/Heatmap/Heatmap.tsx | 16 ++ .../Inventory}/GrainFillFlat.tsx | 0 src/bin/3dView/utils/tempToColour.ts | 68 ++++++ src/pages/Bin.tsx | 2 +- 13 files changed, 572 insertions(+), 363 deletions(-) delete mode 100644 src/bin/3dView/BinCables/BinCables.tsx delete mode 100644 src/bin/3dView/BinCables/GrainCable.tsx create mode 100644 src/bin/3dView/Data/BuildCableData.ts create mode 100644 src/bin/3dView/Data/BuildNodeData.ts rename src/bin/3dView/{ => Scene}/Bin3dView.tsx (83%) create mode 100644 src/bin/3dView/Systems/Cables/BinCables.tsx rename src/bin/3dView/{BinCables => Systems/Cables}/CableNode.tsx (65%) create mode 100644 src/bin/3dView/Systems/Cables/GrainCable.tsx create mode 100644 src/bin/3dView/Systems/Heatmap/Heatmap.tsx rename src/bin/3dView/{BinInventory => Systems/Inventory}/GrainFillFlat.tsx (100%) create mode 100644 src/bin/3dView/utils/tempToColour.ts diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index 5d707c3..0385cdd 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -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 + // ) + // ); + + // const rect = canvas.getBoundingClientRect(); + + // raycaster.current.setFromCamera( + // new Vector2( + // ((e.clientX - rect.left) / rect.width) * 2 - 1, + // -((e.clientY - rect.top) / rect.height) * 2 + 1 + // ), + // camera + // ); - // starting intersection point - raycaster.current.setFromCamera( - new Vector2( - (e.clientX / window.innerWidth) * 2 - 1, - -(e.clientY / window.innerHeight) * 2 + 1 - ), - camera - ); - - raycaster.current.ray.intersectPlane( - panPlane.current, - panStartPoint.current - ); + // 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 }; diff --git a/src/bin/3dView/BinCables/BinCables.tsx b/src/bin/3dView/BinCables/BinCables.tsx deleted file mode 100644 index e7b5684..0000000 --- a/src/bin/3dView/BinCables/BinCables.tsx +++ /dev/null @@ -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 ( - - {cables3D.map((cable, i) => ( - - ))} - - ) -} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/GrainCable.tsx b/src/bin/3dView/BinCables/GrainCable.tsx deleted file mode 100644 index e889893..0000000 --- a/src/bin/3dView/BinCables/GrainCable.tsx +++ /dev/null @@ -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 ( - - - {buildCableNodes().map((node, i) => ( - - ))} - - ) -} \ No newline at end of file diff --git a/src/bin/3dView/Data/BuildCableData.ts b/src/bin/3dView/Data/BuildCableData.ts new file mode 100644 index 0000000..f9493a0 --- /dev/null +++ b/src/bin/3dView/Data/BuildCableData.ts @@ -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; +}; \ No newline at end of file diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts new file mode 100644 index 0000000..de14d23 --- /dev/null +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -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 +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx similarity index 83% rename from src/bin/3dView/Bin3dView.tsx rename to src/bin/3dView/Scene/Bin3dView.tsx index 6b6213d..5be25d8 100644 --- a/src/bin/3dView/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -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 ( @@ -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 */} - + {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx new file mode 100644 index 0000000..452a471 --- /dev/null +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -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 ( + + {cableData.map((cable, i) => { + const cableNodes = nodeData.filter(n => n.cableIndex === cable.cableIndex); + return ( + + )} + )} + + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx similarity index 65% rename from src/bin/3dView/BinCables/CableNode.tsx rename to src/bin/3dView/Systems/Cables/CableNode.tsx index a9803fc..b0b7307 100644 --- a/src/bin/3dView/BinCables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -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){ {(!node.inGrain || !showLabel) && - + {node.topNode && ( )} diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx new file mode 100644 index 0000000..44c83ff --- /dev/null +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -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 ( + + + {nodes.map((node, i) => ( + + ))} + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/Heatmap.tsx b/src/bin/3dView/Systems/Heatmap/Heatmap.tsx new file mode 100644 index 0000000..0434581 --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/Heatmap.tsx @@ -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 ( + <> + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinInventory/GrainFillFlat.tsx b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx similarity index 100% rename from src/bin/3dView/BinInventory/GrainFillFlat.tsx rename to src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx diff --git a/src/bin/3dView/utils/tempToColour.ts b/src/bin/3dView/utils/tempToColour.ts new file mode 100644 index 0000000..c2c6d79 --- /dev/null +++ b/src/bin/3dView/utils/tempToColour.ts @@ -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; +} \ No newline at end of file diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 9a938bc..78e6c38 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -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; From 6cc40e159a8a86cad526b4dfa81d2cc620d658fa Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 21 Apr 2026 12:24:04 -0600 Subject: [PATCH 06/44] moved from a general heatmap to a more spatial hot cold point around nodes that the density and brightness are controlled by how far past the threshold the nodes temp is --- src/3dModels/Shapes/BaseMesh.tsx | 6 +- src/bin/3dView/BinParts/BinShell.tsx | 4 +- src/bin/3dView/Data/BuildNodeData.ts | 13 +- src/bin/3dView/Scene/Bin3dView.tsx | 5 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 18 +- .../3dView/Systems/Heatmap/HeatMapAlpha.tsx | 238 ++++++++++++++++++ src/bin/3dView/Systems/Heatmap/Heatmap.tsx | 16 -- .../3dView/Systems/Heatmap/NodePointCloud.tsx | 215 ++++++++++++++++ src/bin/3dView/utils/tempToColour.ts | 4 +- 9 files changed, 493 insertions(+), 26 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx delete mode 100644 src/bin/3dView/Systems/Heatmap/Heatmap.tsx create mode 100644 src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index d83716a..00e7ad2 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -14,6 +14,7 @@ export interface BaseMeshProps { materialProps?: Partial; materialOverride?: React.ReactNode; side?: Side + depthWrite?: boolean } export default function BaseMesh(props: BaseMeshProps) { @@ -30,7 +31,8 @@ export default function BaseMesh(props: BaseMeshProps) { materialType, materialProps, materialOverride, - side + side, + depthWrite = true } = props; const isTransparent = opacity < 1; @@ -43,7 +45,7 @@ export default function BaseMesh(props: BaseMeshProps) { color={colour} transparent={isTransparent} opacity={opacity} - depthWrite={!isTransparent} + depthWrite={depthWrite} side={side} {...materialProps} /> diff --git a/src/bin/3dView/BinParts/BinShell.tsx b/src/bin/3dView/BinParts/BinShell.tsx index d259bb7..fb40eb0 100644 --- a/src/bin/3dView/BinParts/BinShell.tsx +++ b/src/bin/3dView/BinParts/BinShell.tsx @@ -84,7 +84,7 @@ export default function BinShell(props: Props) { wireframe= {wireframe} metalness={binMetalness} position={roofPosition} - roughness={binRoughness} + roughness={binRoughness} side={2} opacity={binOpacity}/> {/* bin sidewall - cylinder (open ended for the roof and bottom)*/} @@ -93,7 +93,7 @@ export default function BinShell(props: Props) { colour={binBodyColour} wireframe= {wireframe} metalness={binMetalness} - roughness={binRoughness} + roughness={binRoughness} opacity={binOpacity}/> {/* bin bottom - cone -OR- circle depending on the bins shape*/} {hopperHeight !== undefined && hopperHeight > 0 ? diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index de14d23..0cb0c4f 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -33,17 +33,28 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { grainCable.celcius.forEach((celcius,i) => { + const nodeY = bottomY + (i * spacing); const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; + let t = celcius + //this is for testing to force a single node to a specific temp + // if (i === 0) { + // t = 30 + // } + // if (i === 1) { + // t = -30 + // } + + nodeData.push({ cableIndex: cableIndex, nodeIndex: i, radius: nodeRadius, position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z), - celcius: celcius, + celcius: t, humidity: humidity, moisture: moisture, topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 5be25d8..8c0c377 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,6 +8,8 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData } from "../Data/BuildCableData"; import { BuildNodeData } from "../Data/BuildNodeData"; +import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; +import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; interface Props { /** @@ -43,7 +45,7 @@ export default function Bin3dView(props: Props){ radialSegments={20} binMetalness={0.5} binRoughness={0.7} - binOpacity={0.5} + binOpacity={0.2} diameter={bin.diameter()} sidewallHeight={bin.sidewallHeight()} roofHeight={bin.roofHeight()} @@ -62,6 +64,7 @@ export default function Bin3dView(props: Props){ )} {/* cables */} + {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index b0b7307..a5c410f 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -10,7 +10,7 @@ import { pond } from "protobuf-ts/pond" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" // import { Color } from "three"; import { NodeData } from "../../Data/BuildNodeData" -import { tempToColour } from "bin/3dView/utils/tempToColour" +import { TempToColour } from "bin/3dView/utils/tempToColour" interface Props { node: NodeData @@ -193,12 +193,26 @@ export default function CableNode(props: Props){ return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2; }; + const nodeColour = () => { + if (node.excluded){ + return "grey" + } + if (!node.inGrain){ + return "white" + } + let c = TempToColour(node, lowerThreshold, upperThreshold, "node") + if (c !== null){ + return c.getStyle() + } + return "white" + } + return ( {(!node.inGrain || !showLabel) && - + {node.topNode && ( )} diff --git a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx new file mode 100644 index 0000000..65fa1c1 --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx @@ -0,0 +1,238 @@ +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { colourFade, TempToColour } from "bin/3dView/utils/tempToColour"; +import { Bin } from "models"; +import { pond } from "protobuf-ts/pond"; +import { useMemo } from "react"; +import { AdditiveBlending, Vector3 } from "three"; + +interface Props{ + bin: Bin + nodes: NodeData[] +} + +/** + * this is a work in progress, the heatmap generated may not be accurate so avoid using this component for now + * @param props + * @returns + */ +export default function Heatmap(props: Props){ + const {bin, nodes} = props + //the steps that control how many 'layers' the heatmap will generate for each direction + const radialSteps = 18; // number of points across the diameter of the pin + const heightSteps = 20; // number of points up the side of the bin + const angleSteps = 40; // number of points around the circumfrence of the bin + + const getHeatIntensity = ( + temp: number, + lower: number, + upper: number, + fade: number + ) => { + if (temp >= lower && temp <= upper) return 0; + + const distance = + temp < lower ? lower - temp : temp - upper; + + // clamp 0 → 1 + return Math.min(1, distance / fade); + } + + const getTemperatureAtPoint = ( + point: Vector3, + nodes: NodeData[] + ) => { + let totalWeight = 0; + let weightedTemp = 0; + + //not sure if we should use a hard coded value or use a percentage of the bins diameter + //const maxDistance = 450; //tweak this (cm), it is the max distance that a node can influence the heatmap points + const maxDistance = bin.diameter() * 0.25; + + nodes.forEach(node => { + if (!node.inGrain || node.excluded) return; + + const distance = point.distanceTo(node.position); + + const intensity = getHeatIntensity( + node.celcius, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + colourFade + ); + + const nodeMaxDistance = maxDistance * (0.5 + intensity); + // tweakable: 0.5–1.5 range + + if (distance > nodeMaxDistance) return; + + const weight = 1 / (distance * distance + 1); + + totalWeight += weight; + weightedTemp += node.celcius * weight; + }); + + if (totalWeight === 0) return null; + + return weightedTemp / totalWeight; + } + + //this gets the highest top node to prevent points from being rendered above it + //we could in the future us the multple top nodes to clamp within a cloumn around that cable which would give us a more realistic grain area + const maxGrainY = useMemo(() => { + let maxY = -Infinity; + + nodes.forEach(node => { + if (!node.inGrain || node.excluded) return; + + if (node.position.y > maxY) { + maxY = node.position.y; + } + }); + + return maxY; + }, [nodes]); + + const samplePoints = useMemo(() => { + const points = []; + + const radius = bin.diameter() / 2; + const height = bin.sidewallHeight(); + + + + for (let yStep = 0; yStep < heightSteps; yStep++) { + const y = -height / 2 + (yStep / heightSteps) * height; + if (y > maxGrainY) continue; + + for (let rStep = 0; rStep < radialSteps; rStep++) { + // const r = (rStep / radialSteps) * radius; + const r = Math.sqrt(rStep / radialSteps) * radius; + + for (let aStep = 0; aStep < angleSteps; aStep++) { + const angle = (aStep / angleSteps) * Math.PI * 2; + + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + const position = new Vector3(x, y, z); + + const temp = getTemperatureAtPoint(position, nodes); + + points.push({ + position, + temp + }); + } + } + } + + return points; + }, [bin, nodes]); + + const { positions, colors, alphas } = useMemo(() => { + const positions: number[] = []; + const colors: number[] = []; + const alphas: number[] = []; + + samplePoints.forEach((p) => { + if (p.temp === null) return; + const intensity = getHeatIntensity( + p.temp, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + colourFade + ); + const alpha = Math.pow(intensity, 2); // try 2 → 3 for stronger fade + + const virtualNode: NodeData = { + cableIndex: -1, + nodeIndex: -1, + radius: 0, + position: p.position, + celcius: p.temp, + inGrain: true, + excluded: false + }; + + const color = TempToColour( + virtualNode, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + "heatmap" + ); + + if (!color) return; + + // position + positions.push(p.position.x, p.position.y, p.position.z); + + // color (normalized 0–1) + colors.push(color.r, color.g, color.b); + alphas.push(alpha) + + }); + + return { + positions: new Float32Array(positions), + colors: new Float32Array(colors), + alphas: new Float32Array(alphas) + }; + }, [samplePoints, bin]); + + return ( + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/Heatmap.tsx b/src/bin/3dView/Systems/Heatmap/Heatmap.tsx deleted file mode 100644 index 0434581..0000000 --- a/src/bin/3dView/Systems/Heatmap/Heatmap.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 ( - <> - - ) -} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx new file mode 100644 index 0000000..65768de --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -0,0 +1,215 @@ +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { colourFade } from "bin/3dView/utils/tempToColour"; +import { Bin } from "models"; +import { useMemo } from "react"; +import { AdditiveBlending, CanvasTexture } from "three"; + +interface Props { + bin: Bin; + nodes: NodeData[]; +} + +export default function NodePointCloud(props: Props) { + const { bin, nodes } = props; + // 🎛️ TUNING KNOBS + const BASE_RADIUS_FACTOR = 0.15; + const INTENSITY_DENSITY_MULT = 1.25; + const INTENSITY_DENSITY_BASE = 0.3; + + const RADIAL_BASE = 6; + const THETA_BASE = 10; + const PHI_BASE = 10; + + const MIN_EDGE_INTENSITY = 0.2; + + const BASE_BRIGHTNESS = 0.2; + const INTENSITY_BRIGHTNESS_MULT = 0.5; + + const OPACITY = 0.7; + const POINT_SIZE = 1; + + const getHeatIntensity = ( + temp: number, + lower: number, + upper: number, + fade: number + ) => { + if (temp >= lower && temp <= upper) return 0; + + const distance = + temp < lower ? lower - temp : temp - upper; + + return Math.min(1, distance / fade); + }; + + // ----------------------------- + // GRAIN LIMIT + // ----------------------------- + const maxGrainY = useMemo(() => { + let maxY = -Infinity; + + nodes.forEach((node) => { + if (!node.inGrain || node.excluded) return; + if (node.position.y > maxY) maxY = node.position.y; + }); + + return maxY; + }, [nodes]); + + // ----------------------------- + // BUILD POINT CLOUD FROM NODES + // ----------------------------- + const { positions, colors } = useMemo(() => { + const positions: number[] = []; + const colors: number[] = []; + + const baseRadius = bin.diameter() * BASE_RADIUS_FACTOR; + + nodes.forEach((node) => { + if (!node.inGrain || node.excluded) return; + + const intensity = getHeatIntensity( + node.celcius, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + colourFade + ); + + // skip normal nodes + if (intensity === 0) return; + + const isHot = node.celcius > bin.upperTempThreshold(); + + // expand cloud based on severity + const radius = baseRadius * (0.5 + intensity); + + //these control the number of points along that axis + const densityMultiplier = INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT; + const radialSteps = Math.floor(RADIAL_BASE * densityMultiplier);//points along the radius to the edge + const thetaSteps = Math.floor(THETA_BASE * densityMultiplier);//points along the azimuth (horizontal angle) + const phiSteps = Math.floor(PHI_BASE * densityMultiplier);//points along the vertical angle + + const radiusLimit = bin.diameter() / 2; + const minY = -bin.sidewallHeight() / 2; + + for (let rStep = 0; rStep < radialSteps; rStep++) { + // sqrt for even density + const r = Math.sqrt(rStep / radialSteps) * radius; + + for (let pStep = 0; pStep < phiSteps; pStep++) { + // avoid poles clustering by not hitting exact 0/PI + const phi = + ((pStep + 0.5) / phiSteps) * Math.PI; + + for (let tStep = 0; tStep < thetaSteps; tStep++) { + const theta = + (tStep / thetaSteps) * Math.PI * 2; + + const x = + node.position.x + + r * Math.sin(phi) * Math.cos(theta); + + const y = + node.position.y + + r * Math.cos(phi); + + const z = + node.position.z + + r * Math.sin(phi) * Math.sin(theta); + + // cylindrical bin bounds + const distXZ = Math.sqrt(x * x + z * z); + if (distXZ > radiusLimit) continue; + if (y > maxGrainY || y < minY) continue; + + positions.push(x, y, z); + + const falloff = 1 - r / radius; + + // shape of the curve + const spatialFalloff = Math.pow(falloff, 2); + + const adjustedFalloff = + MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff; + + const intensityCurve = intensity * intensity; // quadratic easing + const baseBrightness = + BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve; + + const brightness = adjustedFalloff * baseBrightness; + + if (isHot) { + colors.push(brightness, 0, 0); + } else { + colors.push(0, 0, brightness); + } + } + } + } + }); + + return { + positions: new Float32Array(positions), + colors: new Float32Array(colors), + }; + }, [nodes, bin, maxGrainY]); + + const circleTexture = useMemo(() => { + const size = 64; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + + const ctx = canvas.getContext("2d")!; + const gradient = ctx.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2 + ); + + gradient.addColorStop(0, "rgba(255,255,255,1)"); + gradient.addColorStop(1, "rgba(255,255,255,0)"); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + + const texture = new CanvasTexture(canvas); + texture.needsUpdate = true; + return texture; + }, []); + + // ----------------------------- + // RENDER + // ----------------------------- + return ( + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/utils/tempToColour.ts b/src/bin/3dView/utils/tempToColour.ts index c2c6d79..b8317df 100644 --- a/src/bin/3dView/utils/tempToColour.ts +++ b/src/bin/3dView/utils/tempToColour.ts @@ -7,7 +7,7 @@ const GREEN = new Color("#52c41a"); const BLUE = new Color("#3399ff"); const RED = new Color("#ff4d4f"); -const colourFade = 20; +export const colourFade = 20; const minimumLightness = 0.3; const lightnessRange = 0.2; const minimumSaturation = 0.7; @@ -30,7 +30,7 @@ const getVisualIntensity = (distance: number, mode: ColourMode) => { } }; -export function tempToColour( +export function TempToColour( node: NodeData, lower: number, upper: number, From f548559a2304c7cc804cfaeb1525169e5b673560 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 21 Apr 2026 14:49:10 -0600 Subject: [PATCH 07/44] package updates --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 2b0371b..73cc5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11663,7 +11663,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#0aa61453c8641cf5ede545949a5f2f89f6c393ec", "dependencies": { "protobufjs": "^6.8.8" } From d33c7505d2200905a1703728de3584633cfae190 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 22 Apr 2026 09:13:30 -0600 Subject: [PATCH 08/44] commenting out test data --- src/bin/3dView/Data/BuildNodeData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 0cb0c4f..bf6fad3 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -41,10 +41,10 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { let t = celcius //this is for testing to force a single node to a specific temp // if (i === 0) { - // t = 30 + // t = -10 // } // if (i === 1) { - // t = -30 + // t = 30 // } From 0701be2539fdf8e8bc7b1888ebb10c9e1187cff1 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 23 Apr 2026 13:03:32 -0600 Subject: [PATCH 09/44] added the fill system for cable controlled inventory --- src/bin/3dView/Data/BuildNodeData.ts | 28 +- src/bin/3dView/Scene/Bin3dView.tsx | 35 +- .../Systems/Inventory/GrainCableFill.tsx | 329 ++++++++++++++++++ 3 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 src/bin/3dView/Systems/Inventory/GrainCableFill.tsx diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index bf6fad3..4196820 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -12,6 +12,12 @@ export interface NodeData { topNode?: boolean excluded?: boolean inGrain?: boolean + /** + * The vertical spacing between nodes on this cable (cm). + * Used by GrainCableFill to offset the grain surface half a spacing above the top node, + * matching the 2D bin view behaviour. + */ + nodeSpacing: number } export function BuildNodeData(cables: CableData[]): NodeData[] { @@ -31,23 +37,13 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const spacing = height / nodeCount; - - grainCable.celcius.forEach((celcius,i) => { - + grainCable.celcius.forEach((celcius, i) => { + const nodeY = bottomY + (i * spacing); const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius - //this is for testing to force a single node to a specific temp - // if (i === 0) { - // t = -10 - // } - // if (i === 1) { - // t = 30 - // } - - nodeData.push({ cableIndex: cableIndex, @@ -57,13 +53,11 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { celcius: t, humidity: humidity, moisture: moisture, - topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index + topNode: grainCable.topNode === i + 1, 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 + inGrain: i + 1 <= grainCable.topNode || !grainCable.topNode, + nodeSpacing: spacing, }) - }) }) diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 8c0c377..45fcdb6 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,8 +8,9 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData } from "../Data/BuildCableData"; import { BuildNodeData } from "../Data/BuildNodeData"; -import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import { pond } from "protobuf-ts/pond"; +import GrainCableFill from "../Systems/Inventory/GrainCableFill"; interface Props { /** @@ -37,6 +38,29 @@ export default function Bin3dView(props: Props){ const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); + const grainInventory = () => { + if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || + bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE){ + return ( + + ) + }else if (fillPercent){ + + } + } + return ( @@ -54,14 +78,7 @@ export default function Bin3dView(props: Props){ /> {/* grain - cylinder*/} - {fillPercent !== undefined && ( - - )} + {grainInventory()} {/* cables */} diff --git a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx new file mode 100644 index 0000000..0ae8a2e --- /dev/null +++ b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx @@ -0,0 +1,329 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import { NodeData } from "../../Data/BuildNodeData"; +import Cone from "3dModels/Shapes/3D/Cone"; +import { Vector3, Euler } from "three"; +import Cylinder from "3dModels/Shapes/3D/Cylinder"; + +interface Props { + diameter: number; + sidewallHeight: number; + hopperHeight?: number; + nodes: NodeData[]; + grainOpacity?: number + /** + * Fallback flat fill percent (0–1) used when no top nodes are available. + * If undefined and no top nodes exist, nothing is rendered. + */ + fallbackFillPercent?: number; +} + +// Tuning knobs +const RADIAL_RINGS = 24; // vertex rings radiating outward from center +const THETA_SEGMENTS = 36; // vertices around each ring + +/** + * Inverse-distance weighted interpolation of Y height at a given (x, z) point. + * Each top node contributes a weighted Y based on its horizontal distance from the point. + * The power parameter controls how sharply nearer nodes dominate (2 = standard IDW). + */ +function idwHeight( + x: number, + z: number, + anchors: { x: number; z: number; y: number }[], + power = 2 +): number { + let totalWeight = 0; + let weightedY = 0; + + for (const anchor of anchors) { + const dx = x - anchor.x; + const dz = z - anchor.z; + const distSq = dx * dx + dz * dz; + + // If we're sitting exactly on an anchor, return its Y immediately + if (distSq < 0.001) return anchor.y; + + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedY += anchor.y * weight; + } + + return weightedY / totalWeight; +} + +export default function GrainCableFill(props: Props) { + const { + diameter, + sidewallHeight, + hopperHeight = 0, + nodes, + fallbackFillPercent, + grainOpacity + } = props; + + const binRadius = diameter / 2; + // Slightly inset to avoid z-fighting with the shell + const grainRadius = binRadius * 0.98; + const grainColour = "#fff302"; + + // --- Collect top nodes (non-excluded, inGrain) --- + const topNodes = useMemo(() => + nodes.filter(n => n.topNode && n.inGrain && !n.excluded), + [nodes] + ); + + // --- Build surface anchors: top node Y + half spacing offset --- + // This places the grain line halfway between the top node and the node above it, + // matching the 2D bin view convention. + const anchors = useMemo(() => + topNodes.map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })), + [topNodes] + ); + + // --- Wall clamp: average of all anchor Y values --- + // Prevents grain from piling up at the wall where there are no cables. + const wallY = useMemo(() => { + if (anchors.length === 0) return -sidewallHeight / 2; + return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length; + }, [anchors, sidewallHeight]); + + // --- Build the polar surface mesh --- + const surfaceGeometry = useMemo(() => { + if (anchors.length === 0) return null; + + // Total vertices: center point + (RADIAL_RINGS * THETA_SEGMENTS) ring vertices + const ringCount = RADIAL_RINGS; + const segCount = THETA_SEGMENTS; + const vertexCount = 1 + ringCount * segCount; + + const positions = new Float32Array(vertexCount * 3); + const normals = new Float32Array(vertexCount * 3); + const uvs = new Float32Array(vertexCount * 2); + + // Center vertex + const centerY = idwHeight(0, 0, anchors); + positions[0] = 0; + positions[1] = centerY; + positions[2] = 0; + normals[0] = 0; normals[1] = 1; normals[2] = 0; + uvs[0] = 0.5; uvs[1] = 0.5; + + // Ring vertices + for (let ring = 0; ring < ringCount; ring++) { + // sqrt distribution for even area density across rings + const t = Math.sqrt((ring + 1) / ringCount); + const r = t * grainRadius; + + for (let seg = 0; seg < segCount; seg++) { + const angle = (seg / segCount) * Math.PI * 2; + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + // Outermost ring clamps to wall average; inner rings interpolate + //const isOuterRing = ring === ringCount - 1; + // const y = idwHeight(x, z, anchors); + const rawY = idwHeight(x, z, anchors); + + // Blend outer 20% of radius toward wallY + const edgeStart = 0.8; // start taper at 80% radius + const blendT = Math.max(0, (t - edgeStart) / (1 - edgeStart)); + + // smoothstep + const s = blendT * blendT * (3 - 2 * blendT); + + const y = rawY * (1 - s) + wallY * s; + + // Clamp Y to valid range: no higher than roof base, no lower than bin floor + const clampedY = Math.max( + -sidewallHeight / 2, + Math.min(sidewallHeight / 2, y) + ); + + const vi = (1 + ring * segCount + seg) * 3; + positions[vi] = x; + positions[vi + 1] = clampedY; + positions[vi + 2] = z; + + // Approximate normals — pointing up (will look fine for grain) + normals[vi] = 0; normals[vi + 1] = 1; normals[vi + 2] = 0; + + const ui = (1 + ring * segCount + seg) * 2; + uvs[ui] = (x / grainRadius) * 0.5 + 0.5; + uvs[ui + 1] = (z / grainRadius) * 0.5 + 0.5; + } + } + + // --- Build triangle indices --- + // Center fan: triangles from center point to first ring + const indexList: number[] = []; + + for (let seg = 0; seg < segCount; seg++) { + const next = (seg + 1) % segCount; + indexList.push(0, 1 + seg, 1 + next); + } + + // Ring quads: two triangles per quad between adjacent rings + for (let ring = 0; ring < ringCount - 1; ring++) { + for (let seg = 0; seg < segCount; seg++) { + const next = (seg + 1) % segCount; + + const a = 1 + ring * segCount + seg; + const b = 1 + ring * segCount + next; + const c = 1 + (ring + 1) * segCount + seg; + const d = 1 + (ring + 1) * segCount + next; + + indexList.push(a, c, b); + indexList.push(b, c, d); + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); + geo.setIndex(indexList); + geo.computeVertexNormals(); // smooth out the approximated normals + + return geo; + }, [anchors, wallY, grainRadius, sidewallHeight]); + + // --- Hopper fill (reuse existing cone approach) --- + // The surface mesh handles the cylindrical portion. + // The hopper below is always fully filled if the surface is above the bin floor. + const lowestSurfaceY = useMemo(() => { + if (anchors.length === 0) return -sidewallHeight / 2; + return Math.min(...anchors.map(a => a.y), wallY); + }, [anchors, wallY, sidewallHeight]); + + const hopperIsActive = hopperHeight > 0 && lowestSurfaceY > -sidewallHeight / 2; + + const hopperPosition = useMemo( + () => new Vector3(0, -(sidewallHeight / 2 + hopperHeight / 2), 0), + [sidewallHeight, hopperHeight] + ); + + const hopperRotation = useMemo(() => new Euler(Math.PI, 0, 0), []); + + // --- Fallback: flat fill when no top nodes --- + const fallbackGeometry = useMemo(() => { + if (anchors.length > 0 || fallbackFillPercent === undefined) return null; + + const radius = diameter / 2; + const fbRadius = radius * 0.98; + const cylinderVolume = Math.PI * radius * radius * sidewallHeight; + const hopperVolume = hopperHeight > 0 + ? (1 / 3) * Math.PI * radius * radius * hopperHeight + : 0; + const totalVolume = cylinderVolume + hopperVolume; + const filledVolume = totalVolume * fallbackFillPercent; + + let hopperFillHeight = 0; + let cylinderFillHeight = 0; + + if (hopperHeight > 0 && filledVolume <= hopperVolume) { + const ratio = filledVolume / hopperVolume; + hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3); + cylinderFillHeight = 0; + } else { + hopperFillHeight = hopperHeight; + const remaining = filledVolume - hopperVolume; + cylinderFillHeight = Math.max(0, remaining / (Math.PI * radius * radius)); + } + + return { fbRadius, hopperFillHeight, cylinderFillHeight }; + }, [anchors.length, fallbackFillPercent, diameter, sidewallHeight, hopperHeight]); + + // --- Render fallback --- + if (anchors.length === 0) { + if (!fallbackGeometry) return null; + + const { fbRadius, hopperFillHeight, cylinderFillHeight } = fallbackGeometry; + + return ( + <> + {hopperHeight > 0 && hopperFillHeight > 0 && ( + + )} + {cylinderFillHeight > 0 && ( + + + + + )} + + ); + } + + // --- Render cable-driven surface --- + return ( + <> + {/* Interpolated grain surface */} + {surfaceGeometry && ( + + + + )} + + {/* Cylindrical body of grain below the surface down to the bin floor / hopper top */} + + + {/* Hopper fill — always full when grain surface exists above the floor */} + {hopperIsActive && ( + + )} + + ); +} \ No newline at end of file From f4d1167e4d3b5760d1bf680bbcd1d9e0cf555871 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 23 Apr 2026 15:56:59 -0600 Subject: [PATCH 10/44] added functions to return the node/cable clicked on all the way up to the parent --- src/3dModels/Shapes/BaseMesh.tsx | 14 +- src/bin/3dView/Scene/Bin3dView.tsx | 16 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 17 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 226 ++++++------------- src/bin/3dView/Systems/Cables/GrainCable.tsx | 5 +- 5 files changed, 113 insertions(+), 165 deletions(-) diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index 00e7ad2..d92601d 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -1,4 +1,5 @@ import { Euler, Vector3, BufferGeometry, MaterialParameters, Side } from "three"; +import { ThreeEvent } from "@react-three/fiber"; export interface BaseMeshProps { geometry: BufferGeometry; @@ -13,8 +14,9 @@ export interface BaseMeshProps { materialType?: "standard" | "basic"; materialProps?: Partial; materialOverride?: React.ReactNode; - side?: Side - depthWrite?: boolean + side?: Side; + depthWrite?: boolean; + onClick?: (event: ThreeEvent) => void; } export default function BaseMesh(props: BaseMeshProps) { @@ -32,10 +34,12 @@ export default function BaseMesh(props: BaseMeshProps) { materialProps, materialOverride, side, - depthWrite = true + depthWrite = true, + onClick, } = props; const isTransparent = opacity < 1; + const buildMaterial = () => { if (materialOverride) return materialOverride; switch (materialType) { @@ -50,7 +54,7 @@ export default function BaseMesh(props: BaseMeshProps) { {...materialProps} /> ); - + case "standard": default: return ( @@ -71,7 +75,7 @@ export default function BaseMesh(props: BaseMeshProps) { return ( {/* Main surface */} - + {buildMaterial()} diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 45fcdb6..403dc8f 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -6,8 +6,8 @@ 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"; +import { BuildCableData, CableData } from "../Data/BuildCableData"; +import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; @@ -27,13 +27,14 @@ interface Props { * optional: the percent of the bin to fill using a level top, this will be used with manual and lidar controls */ fillPercent?: number + nodeClick?: (node: NodeData, cable: CableData) => void } export default function Bin3dView(props: Props){ //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof // 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 {bin, scale = 100, fillPercent, nodeClick} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); @@ -80,7 +81,14 @@ export default function Bin3dView(props: Props){ {/* grain - cylinder*/} {grainInventory()} {/* cables */} - + { + // console.log(node) + // console.log(cable) + if(nodeClick){ + nodeClick(node, cable) + } + }}/> diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 452a471..1c526f3 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -10,16 +10,29 @@ interface Props { nodeData: NodeData[] bin: Bin binCenter: Vector3 + onNodeClick?: (node: NodeData, cable: CableData) => void } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData} = props + const {bin, binCenter, cableData, nodeData, onNodeClick} = props return ( {cableData.map((cable, i) => { const cableNodes = nodeData.filter(n => n.cableIndex === cable.cableIndex); + return ( - + { + if(onNodeClick){ + onNodeClick(node, cable) + } + }} + /> )} )} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index a5c410f..daf7744 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -8,7 +8,6 @@ import { quack } from "protobuf-ts/quack" import { useGlobalState } from "providers" import { pond } from "protobuf-ts/pond" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" -// import { Color } from "three"; import { NodeData } from "../../Data/BuildNodeData" import { TempToColour } from "bin/3dView/utils/tempToColour" @@ -17,31 +16,24 @@ interface Props { binCenter: Vector3 lowerThreshold: number upperThreshold: number + onNodeClick?: (node: NodeData) => void } -export default function CableNode(props: Props){ - const {node, binCenter, lowerThreshold, upperThreshold} = props +export default function CableNode(props: Props) { + const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick } = props const { camera } = useThree(); - const [{user}] = useGlobalState(); + const [{ user }] = useGlobalState(); const [showLabel, setShowLabel] = useState(false); const labelGroupRef = React.useRef(null); 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) - + const SHOW_DISTANCE = 15; - const HIDE_DISTANCE = 16; // hysteresis buffer - + const HIDE_DISTANCE = 16; + if (!showLabel && distance < SHOW_DISTANCE) { setShowLabel(true); } else if (showLabel && distance > HIDE_DISTANCE) { @@ -55,116 +47,46 @@ export default function CableNode(props: Props){ } }); - const tempDisplay = () => { - let temp = node.celcius.toFixed(2) + "°C" + const tempDisplay = useMemo(() => { if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { - temp = ((node.celcius * 9/5) + 32).toFixed(2) + "°F" + return ((node.celcius * 9 / 5) + 32).toFixed(2) + "°F" } - return temp - } - - // const nodeColour = () => { - // if (!node.inGrain) return "white"; - // if (node.excluded) return "grey"; - - // const lower = lowerThreshold; - // const upper = upperThreshold; - // const temp = node.celcius; - - // const GREEN = new Color("#52c41a"); - // const BLUE = new Color("#3399ff"); - // const RED = new Color("#ff4d4f"); - - // if (temp >= lower && temp <= upper) { - // return GREEN.getStyle(); - // } - - // // 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(); - // }; + return node.celcius.toFixed(2) + "°C" + }, [node.celcius, user]); const rows = useMemo(() => { const r: { text: string; color: string }[] = []; - + if (node.excluded) { - r.push({ - text: "Excluded", - color: "red" - }); - return r; // nothing else matters + r.push({ text: "Excluded", color: "red" }); + return r; } - + if (node.topNode) { - r.push({ - text: "Top Node", - color: "yellow" - }); + r.push({ text: "Top Node", color: "yellow" }); } - + r.push({ - text: tempDisplay(), + 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]); + }, [node, tempDisplay]); const geometry = React.useMemo(() => ({ radius: node.radius, @@ -173,20 +95,20 @@ export default function CableNode(props: Props){ const topNodeGeo = React.useMemo(() => ({ radius: node.radius, thickness: 5, - } as RingGeometry), [node]) + } as RingGeometry), [node]); - const topNodePosition = React.useMemo(() => (new Vector3(node.position.x, node.position.y + 25, node.position.z)), [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.5–2 units + labelPosition.add(dir.multiplyScalar(1)); const getRowY = (index: number) => { const totalHeight = rows.length * ROW_HEIGHT; @@ -194,59 +116,59 @@ export default function CableNode(props: Props){ }; const nodeColour = () => { - if (node.excluded){ - return "grey" - } - if (!node.inGrain){ - return "white" - } - let c = TempToColour(node, lowerThreshold, upperThreshold, "node") - if (c !== null){ - return c.getStyle() - } - return "white" - } + if (node.excluded) return "grey"; + if (!node.inGrain) return "white"; + const c = TempToColour(node, lowerThreshold, upperThreshold, "node"); + return c !== null ? c.getStyle() : "white"; + }; + const handleClick = (e: any) => { + e.stopPropagation(); + onNodeClick?.(node); + }; return ( {(!node.inGrain || !showLabel) && - - - {node.topNode && ( - - )} - + + + {node.topNode && ( + + )} + } {node.inGrain && showLabel && - - - {/* Background */} - - - - - {/* Rows */} - {rows.map((row, i) => ( - - {row.text} - - ))} - - + + + + + + + {rows.map((row, i) => ( + + {row.text} + + ))} + + } ) diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 44c83ff..f8ab324 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -11,9 +11,10 @@ interface Props { nodes: NodeData[] bin: Bin binCenter: Vector3 + onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter} = props + const {cable, nodes, bin, binCenter, onNodeClick} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -40,7 +41,7 @@ export default function GrainCable(props: Props) { {nodes.map((node, i) => ( - + ))} ) From 6f6a062106cb16e3d3e574e1854626b964152203 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 24 Apr 2026 11:43:56 -0600 Subject: [PATCH 11/44] trying to fix overlaping temp clouds --- src/bin/3dView/Data/BuildNodeData.ts | 6 + .../3dView/Systems/Heatmap/NodePointCloud.tsx | 235 ++++++++++-------- 2 files changed, 143 insertions(+), 98 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 4196820..ac8717c 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -44,6 +44,12 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius + if(i===0){ + t = 30 + } + if (i===1){ + t = 0 + } nodeData.push({ cableIndex: cableIndex, diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx index 65768de..75d7944 100644 --- a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -15,31 +15,40 @@ export default function NodePointCloud(props: Props) { const BASE_RADIUS_FACTOR = 0.15; const INTENSITY_DENSITY_MULT = 1.25; const INTENSITY_DENSITY_BASE = 0.3; - + const RADIAL_BASE = 6; const THETA_BASE = 10; const PHI_BASE = 10; - + const MIN_EDGE_INTENSITY = 0.2; - + const BASE_BRIGHTNESS = 0.2; const INTENSITY_BRIGHTNESS_MULT = 0.5; - + const OPACITY = 0.7; const POINT_SIZE = 1; - const getHeatIntensity = ( - temp: number, - lower: number, - upper: number, - fade: number - ) => { - if (temp >= lower && temp <= upper) return 0; + // IDW power — how sharply nearer nodes dominate the field + const IDW_POWER = 2; - const distance = - temp < lower ? lower - temp : temp - upper; + // Minimum net signed magnitude to emit a point. + // Keeps near-zero cancellation zones empty rather than noisy. + const EMIT_THRESHOLD = 0.05; - return Math.min(1, distance / fade); + // ----------------------------- + // BIN GEOMETRY + // ----------------------------- + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + const maxRadiusAtY = (y: number): number => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + const t = (y - hopperTipY) / hopperHeight; + return binRadius * t; }; // ----------------------------- @@ -47,101 +56,135 @@ export default function NodePointCloud(props: Props) { // ----------------------------- const maxGrainY = useMemo(() => { let maxY = -Infinity; - nodes.forEach((node) => { if (!node.inGrain || node.excluded) return; if (node.position.y > maxY) maxY = node.position.y; }); - return maxY; }, [nodes]); // ----------------------------- - // BUILD POINT CLOUD FROM NODES + // PRE-COMPUTE SIGNED NODE CONTRIBUTIONS + // Only nodes outside threshold contribute to the field. + // Hot nodes carry a positive signed intensity, cold nodes negative. + // ----------------------------- + const signedNodes = useMemo(() => { + return nodes + .filter(n => n.inGrain && !n.excluded) + .map(n => { + const lower = bin.lowerTempThreshold(); + const upper = bin.upperTempThreshold(); + + if (n.celcius >= lower && n.celcius <= upper) return null; + + const distance = n.celcius < lower + ? lower - n.celcius + : n.celcius - upper; + + const intensity = Math.min(1, distance / colourFade); + // positive = hot, negative = cold + const signedIntensity = n.celcius > upper ? intensity : -intensity; + + return { + x: n.position.x, + y: n.position.y, + z: n.position.z, + signedIntensity, + cloudRadius: bin.diameter() * BASE_RADIUS_FACTOR * (0.5 + intensity), + densityMultiplier: INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT, + }; + }) + .filter(Boolean) as { + x: number; y: number; z: number; + signedIntensity: number; + cloudRadius: number; + densityMultiplier: number; + }[]; + }, [nodes, bin]); + + /** + * Evaluates the net signed field at (px, py, pz) by summing + * IDW-weighted signed intensities from all contributing nodes. + * + * Returns a value in [-1, 1]: + * > 0 → net hot + * < 0 → net cold + * ~0 → cancelled out — point will not be emitted + */ + const evaluateField = (px: number, py: number, pz: number): number => { + let totalWeight = 0; + let weightedSum = 0; + + for (const node of signedNodes) { + const dx = px - node.x; + const dy = py - node.y; + const dz = pz - node.z; + const distSq = dx * dx + dy * dy + dz * dz; + + const weight = distSq < 0.001 + ? 1e6 + : 1 / Math.pow(distSq, IDW_POWER / 2); + + totalWeight += weight; + weightedSum += node.signedIntensity * weight; + } + + if (totalWeight === 0) return 0; + return weightedSum / totalWeight; + }; + + // ----------------------------- + // BUILD POINT CLOUD // ----------------------------- const { positions, colors } = useMemo(() => { const positions: number[] = []; const colors: number[] = []; - const baseRadius = bin.diameter() * BASE_RADIUS_FACTOR; + signedNodes.forEach((node) => { + const radialSteps = Math.floor(RADIAL_BASE * node.densityMultiplier); + const thetaSteps = Math.floor(THETA_BASE * node.densityMultiplier); + const phiSteps = Math.floor(PHI_BASE * node.densityMultiplier); - nodes.forEach((node) => { - if (!node.inGrain || node.excluded) return; - - const intensity = getHeatIntensity( - node.celcius, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - - // skip normal nodes - if (intensity === 0) return; - - const isHot = node.celcius > bin.upperTempThreshold(); - - // expand cloud based on severity - const radius = baseRadius * (0.5 + intensity); - - //these control the number of points along that axis - const densityMultiplier = INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT; - const radialSteps = Math.floor(RADIAL_BASE * densityMultiplier);//points along the radius to the edge - const thetaSteps = Math.floor(THETA_BASE * densityMultiplier);//points along the azimuth (horizontal angle) - const phiSteps = Math.floor(PHI_BASE * densityMultiplier);//points along the vertical angle - - const radiusLimit = bin.diameter() / 2; - const minY = -bin.sidewallHeight() / 2; - for (let rStep = 0; rStep < radialSteps; rStep++) { - // sqrt for even density - const r = Math.sqrt(rStep / radialSteps) * radius; - + const r = Math.sqrt(rStep / radialSteps) * node.cloudRadius; + for (let pStep = 0; pStep < phiSteps; pStep++) { - // avoid poles clustering by not hitting exact 0/PI - const phi = - ((pStep + 0.5) / phiSteps) * Math.PI; - + const phi = ((pStep + 0.5) / phiSteps) * Math.PI; + for (let tStep = 0; tStep < thetaSteps; tStep++) { - const theta = - (tStep / thetaSteps) * Math.PI * 2; - - const x = - node.position.x + - r * Math.sin(phi) * Math.cos(theta); - - const y = - node.position.y + - r * Math.cos(phi); - - const z = - node.position.z + - r * Math.sin(phi) * Math.sin(theta); - - // cylindrical bin bounds + const theta = (tStep / thetaSteps) * Math.PI * 2; + + const x = node.x + r * Math.sin(phi) * Math.cos(theta); + const y = node.y + r * Math.cos(phi); + const z = node.z + r * Math.sin(phi) * Math.sin(theta); + + // Bin boundary checks + if (y < hopperTipY || y > maxGrainY) continue; const distXZ = Math.sqrt(x * x + z * z); - if (distXZ > radiusLimit) continue; - if (y > maxGrainY || y < minY) continue; - + if (distXZ > maxRadiusAtY(y)) continue; + + // Evaluate the full signed field at this point. + // A point in the overlap of a hot and cold cloud will have + // a net value near zero and will be skipped rather than + // rendered pink. + const netField = evaluateField(x, y, z); + + if (Math.abs(netField) < EMIT_THRESHOLD) continue; + positions.push(x, y, z); - const falloff = 1 - r / radius; + // Brightness driven by net magnitude, not per-node intensity + const netMagnitude = Math.abs(netField); + const falloff = 1 - r / node.cloudRadius; + const spatialFalloff = Math.pow(Math.max(0, falloff), 2); + const adjustedFalloff = MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff; + const intensityCurve = netMagnitude * netMagnitude; + const brightness = adjustedFalloff * (BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve); - // shape of the curve - const spatialFalloff = Math.pow(falloff, 2); - - const adjustedFalloff = - MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff; - - const intensityCurve = intensity * intensity; // quadratic easing - const baseBrightness = - BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve; - - const brightness = adjustedFalloff * baseBrightness; - - if (isHot) { - colors.push(brightness, 0, 0); + if (netField > 0) { + colors.push(brightness, 0, 0); // hot → red } else { - colors.push(0, 0, brightness); + colors.push(0, 0, brightness); // cold → blue } } } @@ -152,30 +195,26 @@ export default function NodePointCloud(props: Props) { positions: new Float32Array(positions), colors: new Float32Array(colors), }; - }, [nodes, bin, maxGrainY]); + }, [signedNodes, maxGrainY, hopperTipY]); const circleTexture = useMemo(() => { const size = 64; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; - + const ctx = canvas.getContext("2d")!; const gradient = ctx.createRadialGradient( - size / 2, - size / 2, - 0, - size / 2, - size / 2, - size / 2 + size / 2, size / 2, 0, + size / 2, size / 2, size / 2 ); - + gradient.addColorStop(0, "rgba(255,255,255,1)"); gradient.addColorStop(1, "rgba(255,255,255,0)"); - + ctx.fillStyle = gradient; ctx.fillRect(0, 0, size, size); - + const texture = new CanvasTexture(canvas); texture.needsUpdate = true; return texture; From 61aa93aabd5a465fff8a54b720557a19450a56c6 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 28 Apr 2026 17:00:09 -0600 Subject: [PATCH 12/44] various tweaks and now trying to build heatmaps in two different ways --- src/3dModels/Shapes/BaseMesh.tsx | 12 +- src/bin/3dView/BinParts/BinShell.tsx | 19 +- src/bin/3dView/Data/BuildNodeData.ts | 1 + src/bin/3dView/Scene/Bin3dView.tsx | 21 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 6 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 9 +- src/bin/3dView/Systems/Cables/GrainCable.tsx | 9 +- .../3dView/Systems/Heatmap/HeatMapAlpha.tsx | 634 ++++++++++++------ .../3dView/Systems/Heatmap/NodePointCloud.tsx | 12 +- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 406 +++++++++++ .../Systems/Inventory/GrainCableFill.tsx | 35 +- .../Systems/Inventory/GrainFillFlat.tsx | 12 +- 12 files changed, 932 insertions(+), 244 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index d92601d..e06d622 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -16,6 +16,8 @@ export interface BaseMeshProps { materialOverride?: React.ReactNode; side?: Side; depthWrite?: boolean; + depthTest?: boolean; + renderOrder?: number; onClick?: (event: ThreeEvent) => void; } @@ -35,6 +37,8 @@ export default function BaseMesh(props: BaseMeshProps) { materialOverride, side, depthWrite = true, + depthTest = true, + renderOrder = 0, onClick, } = props; @@ -50,6 +54,7 @@ export default function BaseMesh(props: BaseMeshProps) { transparent={isTransparent} opacity={opacity} depthWrite={depthWrite} + depthTest={depthTest} side={side} {...materialProps} /> @@ -64,7 +69,8 @@ export default function BaseMesh(props: BaseMeshProps) { roughness={roughness} transparent={isTransparent} opacity={opacity} - depthWrite={!isTransparent} + depthWrite={depthWrite} + depthTest={depthTest} side={side} {...materialProps} /> @@ -73,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} {buildMaterial()} @@ -81,7 +87,7 @@ export default function BaseMesh(props: BaseMeshProps) { {/* Optional wireframe overlay */} {wireframe && ( - + ({ radiusTop: diameter / 2, radiusBottom: diameter / 2, @@ -86,7 +87,10 @@ export default function BinShell(props: Props) { position={roofPosition} roughness={binRoughness} side={2} - opacity={binOpacity}/> + opacity={binOpacity} + depthWrite={false} + depthTest={false} + renderOrder={renderOrder}/> {/* bin sidewall - cylinder (open ended for the roof and bottom)*/} + opacity={binOpacity} + depthWrite={false} + depthTest={false} + renderOrder={renderOrder}/> {/* bin bottom - cone -OR- circle depending on the bins shape*/} {hopperHeight !== undefined && hopperHeight > 0 ? : } diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index ac8717c..9d7de98 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -44,6 +44,7 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius + // for testing if(i===0){ t = 30 } diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 403dc8f..a704007 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,9 +8,11 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; -import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; +import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; +// import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; interface Props { /** @@ -28,13 +30,17 @@ interface Props { */ fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void + /** + * When true, renders the heatmap instead of the grain fill. + */ + showHeatmap?: boolean } export default function Bin3dView(props: Props){ //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof // 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, nodeClick} = props + const {bin, scale = 100, fillPercent, nodeClick, showHeatmap = false} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); @@ -58,6 +64,7 @@ export default function Bin3dView(props: Props){ sidewallHeight={bin.sidewallHeight()} hopperHeight={bin.hopperHeight()} fillPercent={fillPercent} + grainOpacity={0.3} /> } } @@ -75,11 +82,11 @@ export default function Bin3dView(props: Props){ sidewallHeight={bin.sidewallHeight()} roofHeight={bin.roofHeight()} hopperHeight={bin.hopperHeight()} - + renderOrder={4} /> {/* grain - cylinder*/} - {grainInventory()} + {/* {!showHeatmap && grainInventory()} */} {/* cables */} { @@ -88,8 +95,10 @@ export default function Bin3dView(props: Props){ if(nodeClick){ nodeClick(node, cable) } - }}/> - + }} + renderOrder={1}/> + {/* */} + {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 1c526f3..6adedd1 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -9,12 +9,13 @@ interface Props { cableData: CableData[] nodeData: NodeData[] bin: Bin - binCenter: Vector3 + binCenter: Vector3, + renderOrder?: number, onNodeClick?: (node: NodeData, cable: CableData) => void } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData, onNodeClick} = props + const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props return ( {cableData.map((cable, i) => { @@ -32,6 +33,7 @@ export default function BinCables(props: Props){ onNodeClick(node, cable) } }} + renderOrder={renderOrder} /> )} )} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index daf7744..782b40f 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -15,12 +15,13 @@ interface Props { node: NodeData binCenter: Vector3 lowerThreshold: number - upperThreshold: number + upperThreshold: number, + renderOrder?: number, onNodeClick?: (node: NodeData) => void } export default function CableNode(props: Props) { - const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick } = props + const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder } = props const { camera } = useThree(); const [{ user }] = useGlobalState(); const [showLabel, setShowLabel] = useState(false); @@ -136,9 +137,11 @@ export default function CableNode(props: Props) { position={node.position} colour={nodeColour()} onClick={handleClick} + renderOrder={renderOrder} + /> {node.topNode && ( - + )} } diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index f8ab324..938dc63 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -10,11 +10,12 @@ interface Props { cable: CableData nodes: NodeData[] bin: Bin - binCenter: Vector3 + binCenter: Vector3, + renderOrder?: number, onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter, onNodeClick} = props + const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -39,9 +40,9 @@ export default function GrainCable(props: Props) { return ( - + {nodes.map((node, i) => ( - + ))} ) diff --git a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx index 65fa1c1..a67e44b 100644 --- a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx +++ b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx @@ -1,13 +1,71 @@ -import { NodeData } from "bin/3dView/Data/BuildNodeData"; -import { colourFade, TempToColour } from "bin/3dView/utils/tempToColour"; import { Bin } from "models"; -import { pond } from "protobuf-ts/pond"; import { useMemo } from "react"; -import { AdditiveBlending, Vector3 } from "three"; +import { + Color, + ShaderMaterial, +} from "three"; +import { useThree } from "@react-three/fiber"; + +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { colourFade } from "bin/3dView/utils/tempToColour"; interface Props{ bin: Bin nodes: NodeData[] + opacity?: number + /** + * Point opacity (lower = see deeper). + */ + pointOpacity?: number + /** + * Point size in world units (scaled with your Bin3dView scale group). + */ + pointSize?: number + /** + * Enables MSAA alpha coverage smoothing (WebGL2 + MSAA). + * Helps look continuous without additive blending. + */ + alphaToCoverage?: boolean + /** + * Density along Y (vertical slices). + */ + ySlices?: number + /** + * Radial rings per slice. + */ + radialRings?: number + /** + * Angular segments per ring. + */ + thetaSegments?: number + /** + * Inset to avoid z-fighting with the shell. + */ + wallInsetFactor?: number + /** + * Adds jittered samples inside each polar cell to better fill the volume. + */ + samplesPerCell?: number + /** + * 0..1 jitter amount within a cell (0 = none). + */ + jitter?: number + /** + * Enables screen-door alpha hashing. This fixes incorrect transparency sorting + * (points popping in front when tilted) without additive blending. + */ + alphaHash?: boolean + /** + * Makes in-threshold (green) points more transparent so hot/cold pockets show through. + * 0..1 where 0 = invisible green, 1 = same opacity as out-of-threshold. + */ + greenOpacityFactor?: number + /** + * Curves how strongly out-of-threshold points become visible. + * >1 makes only strong deviations pop; <1 makes small deviations pop more. + */ + deviationPower?: number + // (reverted) extra perf knobs removed } /** @@ -16,223 +74,379 @@ interface Props{ * @returns */ export default function Heatmap(props: Props){ - const {bin, nodes} = props - //the steps that control how many 'layers' the heatmap will generate for each direction - const radialSteps = 18; // number of points across the diameter of the pin - const heightSteps = 20; // number of points up the side of the bin - const angleSteps = 40; // number of points around the circumfrence of the bin + const { + bin, + nodes, + opacity = 0.65, // kept for backward compatibility + pointOpacity, + pointSize = 5, + ySlices = 22, + radialRings = 16, + thetaSegments = 28, + wallInsetFactor = 0.99, + samplesPerCell = 1, + jitter = 0.75, + alphaHash = true, + greenOpacityFactor = 0.18, + deviationPower = 0.6, + } = props; - const getHeatIntensity = ( - temp: number, - lower: number, - upper: number, - fade: number - ) => { - if (temp >= lower && temp <= upper) return 0; - - const distance = - temp < lower ? lower - temp : temp - upper; - - // clamp 0 → 1 - return Math.min(1, distance / fade); - } + useThree(); // keep fiber context available if needed later - const getTemperatureAtPoint = ( - point: Vector3, - nodes: NodeData[] - ) => { - let totalWeight = 0; - let weightedTemp = 0; - - //not sure if we should use a hard coded value or use a percentage of the bins diameter - //const maxDistance = 450; //tweak this (cm), it is the max distance that a node can influence the heatmap points - const maxDistance = bin.diameter() * 0.25; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; - nodes.forEach(node => { - if (!node.inGrain || node.excluded) return; - - const distance = point.distanceTo(node.position); + const inGrainNodes = useMemo( + () => nodes.filter((n) => n.inGrain && !n.excluded), + [nodes], + ); - const intensity = getHeatIntensity( - node.celcius, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - - const nodeMaxDistance = maxDistance * (0.5 + intensity); - // tweakable: 0.5–1.5 range - - if (distance > nodeMaxDistance) return; - - const weight = 1 / (distance * distance + 1); - - totalWeight += weight; - weightedTemp += node.celcius * weight; - }); - - if (totalWeight === 0) return null; - - return weightedTemp / totalWeight; + const maxGrainY = useMemo(() => { + let maxY = -Infinity; + for (const n of inGrainNodes) maxY = Math.max(maxY, n.position.y); + return Number.isFinite(maxY) ? maxY : sidewallBaseY; + }, [inGrainNodes, sidewallBaseY]); + + const topNodes = useMemo( + () => nodes.filter((n) => n.topNode && n.inGrain && !n.excluded), + [nodes], + ); + + const anchors = useMemo( + () => + topNodes.map((n) => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })), + [topNodes], + ); + + const wallY = useMemo(() => { + if (anchors.length === 0) return -sidewallHeight / 2; + return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length; + }, [anchors, sidewallHeight]); + + const idwHeight = ( + x: number, + z: number, + inputAnchors: { x: number; z: number; y: number }[], + power = 2, + ): number => { + let totalWeight = 0; + let weightedY = 0; + + for (const a of inputAnchors) { + const dx = x - a.x; + const dz = z - a.z; + const distSq = dx * dx + dz * dz; + if (distSq < 0.001) return a.y; + const w = 1 / Math.pow(distSq, power / 2); + totalWeight += w; + weightedY += a.y * w; } - //this gets the highest top node to prevent points from being rendered above it - //we could in the future us the multple top nodes to clamp within a cloumn around that cable which would give us a more realistic grain area - const maxGrainY = useMemo(() => { - let maxY = -Infinity; - - nodes.forEach(node => { - if (!node.inGrain || node.excluded) return; - - if (node.position.y > maxY) { - maxY = node.position.y; - } - }); - - return maxY; - }, [nodes]); + return weightedY / totalWeight; + }; - const samplePoints = useMemo(() => { - const points = []; - - const radius = bin.diameter() / 2; - const height = bin.sidewallHeight(); - + const maxRadiusAtY = (y: number, maxR: number): number => { + if (y >= sidewallBaseY) return maxR; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + const t = (y - hopperTipY) / hopperHeight; // 0..1 + return maxR * t; + }; - - for (let yStep = 0; yStep < heightSteps; yStep++) { - const y = -height / 2 + (yStep / heightSteps) * height; - if (y > maxGrainY) continue; - - for (let rStep = 0; rStep < radialSteps; rStep++) { - // const r = (rStep / radialSteps) * radius; - const r = Math.sqrt(rStep / radialSteps) * radius; - - for (let aStep = 0; aStep < angleSteps; aStep++) { - const angle = (aStep / angleSteps) * Math.PI * 2; - - const x = Math.cos(angle) * r; - const z = Math.sin(angle) * r; - - const position = new Vector3(x, y, z); - - const temp = getTemperatureAtPoint(position, nodes); - - points.push({ - position, - temp - }); - } + const grainSurfaceY = (x: number, z: number, rNorm: number): number => { + // If we don't have top nodes, use a flat surface at maxGrainY. + if (anchors.length === 0) return maxGrainY; + + const rawY = idwHeight(x, z, anchors); + + // Match `GrainCableFill` outer-wall taper so switching isn't jarring. + const edgeStart = 0.8; + const blendT = Math.max(0, (rNorm - edgeStart) / (1 - edgeStart)); + const s = blendT * blendT * (3 - 2 * blendT); + const y = rawY * (1 - s) + wallY * s; + return Math.max(-sidewallHeight / 2, Math.min(sidewallHeight / 2, y)); + }; + + const evaluateTemp = (px: number, py: number, pz: number): number | null => { + if (inGrainNodes.length === 0) return null; + + // Inverse-distance weighted interpolation. + // Keep power modest so the field stays smooth. + const IDW_POWER = 2; + let totalWeight = 0; + let weightedSum = 0; + + for (const n of inGrainNodes) { + const dx = px - n.position.x; + const dy = py - n.position.y; + const dz = pz - n.position.z; + const distSq = dx * dx + dy * dy + dz * dz; + const weight = distSq < 0.001 ? 1e6 : 1 / Math.pow(distSq, IDW_POWER / 2); + totalWeight += weight; + weightedSum += n.celcius * weight; + } + + if (totalWeight === 0) return null; + return weightedSum / totalWeight; + }; + + const tempToHeatColor = (temp: number): Color => { + // Match your 2D/point visuals: green in-threshold, fade to red/blue as distance grows. + const lower = bin.lowerTempThreshold(); + const upper = bin.upperTempThreshold(); + + const GREEN = new Color("#52c41a"); + const BLUE = new Color("#3399ff"); + const RED = new Color("#ff4d4f"); + + if (temp >= lower && temp <= upper) return GREEN; + + const distance = temp < lower ? lower - temp : temp - upper; + const intensity = Math.min(1, distance / colourFade); // 0..1 + + // Similar HSL shaping as `TempToColour`, but always returns a color. + const minimumLightness = 0.3; + const lightnessRange = 0.2; + const minimumSaturation = 0.7; + const saturationRange = 0.8; + + const hsl = { h: 0, s: 1, l: 1 }; + (temp < lower ? BLUE : RED).getHSL(hsl); + + const c = new Color(); + c.setHSL( + hsl.h, + saturationRange * intensity + minimumSaturation, + lightnessRange * intensity + minimumLightness, + ); + return c; + }; + + const tempToDeviation = (temp: number): number => { + const lower = bin.lowerTempThreshold(); + const upper = bin.upperTempThreshold(); + if (temp >= lower && temp <= upper) return 0; + const distance = temp < lower ? lower - temp : temp - upper; + return Math.min(1, distance / colourFade); + }; + + const { positions, colors, deviations } = useMemo(() => { + const binR = bin.diameter() / 2; + // Important: points are rendered as *sprites*, so even if the center is inside the wall, + // the visible circle can extend outside. Shrink the sampling radius by ~half pointSize + // so the rendered splats stay within the bin. + const maxR = Math.max(0, binR * wallInsetFactor - pointSize * 0.55); + + const y0 = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const y1 = Math.max(y0, maxGrainY); + + const pos: number[] = []; + const col: number[] = []; + const dev: number[] = []; + const tmpColor = new Color(); + + const safeYSlices = Math.max(6, Math.floor(ySlices)); + const safeRings = Math.max(4, Math.floor(radialRings)); + const safeTheta = Math.max(12, Math.floor(thetaSegments)); + const safeSamples = Math.max(1, Math.floor(samplesPerCell)); + const j = Math.min(1, Math.max(0, jitter)); + // Deterministic "random" so the cloud doesn't shimmer every render. + const rand01 = (seed: number) => { + // xorshift32 + let x = seed | 0; + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + // convert to [0,1) + return ((x >>> 0) % 1000000) / 1000000; + }; + + for (let yi = 0; yi < safeYSlices; yi++) { + const ty = safeYSlices === 1 ? 0 : yi / (safeYSlices - 1); + const y = y0 + (y1 - y0) * ty; + + const rAtY = maxRadiusAtY(y, maxR); + if (rAtY <= 0.001) continue; + + for (let ring = 0; ring < safeRings; ring++) { + for (let seg = 0; seg < safeTheta; seg++) { + // Cell bounds in polar space + const ring0 = ring / safeRings; + const ring1 = (ring + 1) / safeRings; + const r0 = Math.sqrt(ring0) * rAtY; + const r1 = Math.sqrt(ring1) * rAtY; + + const theta0 = (seg / safeTheta) * Math.PI * 2; + const theta1 = ((seg + 1) / safeTheta) * Math.PI * 2; + + for (let s = 0; s < safeSamples; s++) { + const seed = yi * 73856093 + ring * 19349663 + seg * 83492791 + s * 2654435761; + const u = rand01(seed); + const v = rand01(seed ^ 0x9e3779b9); + + // Jitter inside the cell + const rr = r0 + (r1 - r0) * (j === 0 ? 0.5 : (0.5 + (u - 0.5) * j)); + const tt = theta0 + (theta1 - theta0) * (j === 0 ? 0.5 : (0.5 + (v - 0.5) * j)); + + const x = Math.cos(tt) * rr; + const z = Math.sin(tt) * rr; + + const rNorm = rAtY <= 0 ? 0 : rr / rAtY; + const surfaceY = grainSurfaceY(x, z, rNorm); + if (y > surfaceY) continue; + + const temp = evaluateTemp(x, y, z); + const d0 = temp == null ? 0 : tempToDeviation(temp); + + pos.push(x, y, z); + const c = temp == null ? tmpColor.set("#52c41a") : tempToHeatColor(temp); + col.push(c.r, c.g, c.b); + dev.push(d0); } } - - return points; - }, [bin, nodes]); + } + } - const { positions, colors, alphas } = useMemo(() => { - const positions: number[] = []; - const colors: number[] = []; - const alphas: number[] = []; - - samplePoints.forEach((p) => { - if (p.temp === null) return; - const intensity = getHeatIntensity( - p.temp, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - const alpha = Math.pow(intensity, 2); // try 2 → 3 for stronger fade - - const virtualNode: NodeData = { - cableIndex: -1, - nodeIndex: -1, - radius: 0, - position: p.position, - celcius: p.temp, - inGrain: true, - excluded: false - }; - - const color = TempToColour( - virtualNode, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - "heatmap" - ); - - if (!color) return; - - // position - positions.push(p.position.x, p.position.y, p.position.z); - - // color (normalized 0–1) - colors.push(color.r, color.g, color.b); - alphas.push(alpha) + return { + positions: new Float32Array(pos), + colors: new Float32Array(col), + deviations: new Float32Array(dev), + }; + }, [ + bin, + wallInsetFactor, + hopperHeight, + hopperTipY, + sidewallBaseY, + maxGrainY, + ySlices, + radialRings, + thetaSegments, + anchors, + wallY, + inGrainNodes, + samplesPerCell, + jitter, + deviationPower, + ]); - }); - - return { - positions: new Float32Array(positions), - colors: new Float32Array(colors), - alphas: new Float32Array(alphas) - }; - }, [samplePoints, bin]); + const alphaHashedMaterial = useMemo(() => { + return new ShaderMaterial({ + transparent: !alphaHash, + depthTest: true, + depthWrite: alphaHash, + uniforms: { + uOpacity: { value: pointOpacity ?? opacity }, + uSize: { value: pointSize }, + uMaxRadius: { value: (bin.diameter() / 2) * wallInsetFactor }, + uSidewallBaseY: { value: -bin.sidewallHeight() / 2 }, + uHopperHeight: { value: bin.hopperHeight() ?? 0 }, + uAlphaHash: { value: alphaHash ? 1 : 0 }, + uGreenOpacityFactor: { value: Math.min(1, Math.max(0, greenOpacityFactor)) }, + uDeviationPower: { value: Math.max(0.05, deviationPower) }, + }, + vertexShader: ` + uniform float uSize; + varying vec3 vWorldPos; + varying vec3 vColor; + varying float vDev; + attribute vec3 color; + attribute float deviation; + void main() { + vColor = color; + vDev = deviation; + vec4 world = modelMatrix * vec4(position, 1.0); + vWorldPos = world.xyz; + vec4 mvPosition = viewMatrix * world; + float attn = 300.0 / max(1.0, -mvPosition.z); + gl_PointSize = uSize * attn; + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + precision highp float; + uniform float uOpacity; + uniform float uMaxRadius; + uniform float uSidewallBaseY; + uniform float uHopperHeight; + uniform float uAlphaHash; + uniform float uGreenOpacityFactor; + uniform float uDeviationPower; + varying vec3 vColor; + varying float vDev; + varying vec3 vWorldPos; + // interleaved gradient noise + float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); + } + void main() { + // Hard clip pixels to bin radius at this Y (prevents splats outside wall). + float y = vWorldPos.y; + float sidewallBaseY = uSidewallBaseY; + float hopperHeight = uHopperHeight; + float hopperTipY = sidewallBaseY - hopperHeight; + float maxR; + if (y >= sidewallBaseY) { + maxR = uMaxRadius; + } else if (hopperHeight <= 0.0 || y <= hopperTipY) { + maxR = 0.0; + } else { + float t = (y - hopperTipY) / hopperHeight; + maxR = uMaxRadius * t; + } + float r = length(vWorldPos.xz); + if (r > maxR) discard; - return ( - - - - - - - - - - ); + vec2 p = gl_PointCoord - vec2(0.5); + float d = length(p) * 2.0; + float mask = smoothstep(1.0, 0.0, d); + + float dev = clamp(vDev, 0.0, 1.0); + float devCurve = pow(dev, uDeviationPower); + // 0 => green/in-threshold, 1 => strong deviation + float localOpacityFactor = mix(uGreenOpacityFactor, 1.0, devCurve); + float a = clamp(mask * uOpacity * localOpacityFactor, 0.0, 1.0); + + if (uAlphaHash > 0.5) { + float n = ign(gl_FragCoord.xy); + if (n > a) discard; + gl_FragColor = vec4(vColor, 1.0); + } else { + gl_FragColor = vec4(vColor, a); + } + } + `, + }); + }, [alphaHash, bin, deviationPower, greenOpacityFactor, opacity, pointOpacity, pointSize, wallInsetFactor]); + + // Fallback: normal points (no OIT) + return ( + + + + + + + + ); } \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx index 75d7944..1af8ba8 100644 --- a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -2,7 +2,7 @@ import { NodeData } from "bin/3dView/Data/BuildNodeData"; import { colourFade } from "bin/3dView/utils/tempToColour"; import { Bin } from "models"; import { useMemo } from "react"; -import { AdditiveBlending, CanvasTexture } from "three"; +import { CanvasTexture, NormalBlending } from "three"; interface Props { bin: Bin; @@ -22,10 +22,10 @@ export default function NodePointCloud(props: Props) { const MIN_EDGE_INTENSITY = 0.2; - const BASE_BRIGHTNESS = 0.2; - const INTENSITY_BRIGHTNESS_MULT = 0.5; + const BASE_BRIGHTNESS = 0.7; + const INTENSITY_BRIGHTNESS_MULT = 0.7; - const OPACITY = 0.7; + const OPACITY = 0.3; const POINT_SIZE = 1; // IDW power — how sharply nearer nodes dominate the field @@ -224,7 +224,7 @@ export default function NodePointCloud(props: Props) { // RENDER // ----------------------------- return ( - + ); diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx new file mode 100644 index 0000000..cbb9d0d --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -0,0 +1,406 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import { Bin } from "models"; +import { NodeData } from "../../Data/BuildNodeData"; + +interface Props { + bin: Bin; + nodes: NodeData[]; +} + +// ----------------------------------------------------------------------- +// 🎛️ TUNING KNOBS +// ----------------------------------------------------------------------- + +// Grid resolution — more = smoother but heavier +const RADIAL_RINGS = 12; // rings of sample points from center outward +const THETA_SEGMENTS = 24; // points around each ring +const HEIGHT_STEPS = 20; // vertical layers + +// Colour thresholds — degrees °C above the bin's upper threshold +const YELLOW_DELTA = 5; // at this far above threshold → full yellow +const RED_DELTA = 10; // at this far above threshold → full red + +// IDW power — higher = sharper transitions between nodes (2 is standard) +const IDW_POWER = 2; + +// Mesh appearance +const OPACITY = 0.55; + +// ----------------------------------------------------------------------- +// COLOUR HELPERS +// ----------------------------------------------------------------------- + +// Returns 0 (green) → 1 (yellow) → 2 (red) based on how far above +// the upper threshold the interpolated temperature is. +// Everything at or below upper threshold = 0. +function tempToHeat(temp: number, upper: number): number { + if (temp <= upper) return 0; + const delta = temp - upper; + if (delta >= RED_DELTA) return 2; + if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA); + return delta / YELLOW_DELTA; +} + +// Maps heat value [0–2] to RGB. +// 0 = green (#52c41a) +// 1 = yellow (#fadb14) +// 2 = red (#ff4d4f) +function heatToRGB(heat: number): [number, number, number] { + if (heat <= 0) return [0.322, 0.761, 0.102]; // green + + if (heat <= 1) { + // green → yellow + const t = heat; + return [ + 0.322 + (0.980 - 0.322) * t, // R + 0.761 + (0.859 - 0.761) * t, // G + 0.102 + (0.078 - 0.102) * t, // B + ]; + } + + // yellow → red + const t = heat - 1; + return [ + 0.980 + (1.000 - 0.980) * t, // R + 0.859 + (0.302 - 0.859) * t, // G + 0.078 + (0.310 - 0.078) * t, // B + ]; +} + +// ----------------------------------------------------------------------- +// IDW TEMPERATURE INTERPOLATION +// ----------------------------------------------------------------------- + +interface TempAnchor { + x: number; y: number; z: number; + celcius: number; +} + +function idwTemp( + px: number, py: number, pz: number, + anchors: TempAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedSum = 0; + + for (const a of anchors) { + const dx = px - a.x; + const dy = py - a.y; + const dz = pz - a.z; + const distSq = dx * dx + dy * dy + dz * dz; + + if (distSq < 0.001) return a.celcius; // exactly on a node + + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedSum += a.celcius * weight; + } + + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} + +// ----------------------------------------------------------------------- +// COMPONENT +// ----------------------------------------------------------------------- + +export default function TempHeatMap(props: Props) { + const { bin, nodes } = props; + + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const upperThreshold = bin.upperTempThreshold(); + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + // Taper radius inside the hopper cone + const maxRadiusAtY = (y: number): number => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + return binRadius * ((y - hopperTipY) / hopperHeight); + }; + + // Only use in-grain, non-excluded nodes as temperature anchors + const anchors = useMemo(() => + nodes + .filter(n => n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + y: n.position.y, + z: n.position.z, + celcius: n.celcius, + })), + [nodes] + ); + + // Top of grain — highest in-grain node Y + const maxGrainY = useMemo(() => { + let maxY = sidewallBaseY; + for (const a of anchors) { + if (a.y > maxY) maxY = a.y; + } + return maxY; + }, [anchors, sidewallBaseY]); + + // ----------------------------------------------------------------------- + // BUILD GEOMETRY + // ----------------------------------------------------------------------- + const geometry = useMemo(() => { + if (anchors.length === 0) return null; + + // ------------------------------------------------------------------- + // 1. Sample the cylindrical grid + // ------------------------------------------------------------------- + // Layout: center column + RADIAL_RINGS rings, each with THETA_SEGMENTS + // vertices, stacked HEIGHT_STEPS times vertically. + // + // Vertex index scheme: + // layer * pointsPerLayer + ringOffset + // where ringOffset: 0 = center, 1..N = ring vertices + + const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; + // +1 for the optional hopper tip vertex (unused for flat-bottom bins) + const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; + + const positions = new Float32Array(totalVerts * 3); + const colors = new Float32Array(totalVerts * 3); + + // Grain bottom Y — bottom of the grain, either hopper tip or sidewall base + // For hopper bins, starting exactly at hopperTipY causes the entire + // bottom layer to collapse to radius=0 (degenerate triangles that + // disappear when viewed from below). Instead start one HEIGHT_STEPS + // increment above the tip so the bottom layer always has a visible + // radius, then add a separate tip vertex that fans down to a point. + const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const grainBottomY = hopperHeight > 0 + ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS + : sidewallBaseY; + const grainHeight = maxGrainY - grainBottomY; + + if (grainHeight <= 0) return null; + + for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) { + const t = hStep / (HEIGHT_STEPS - 1); + const y = grainBottomY + t * grainHeight; + + const allowedRadius = maxRadiusAtY(y) * 0.97; // slight inset + const layerBase = hStep * pointsPerLayer; + + // Center vertex + const cx = 0, cz = 0; + const centerTemp = idwTemp(cx, y, cz, anchors, IDW_POWER); + const centerHeat = tempToHeat(centerTemp, upperThreshold); + const [cr, cg, cb] = heatToRGB(centerHeat); + + positions[layerBase * 3 + 0] = cx; + positions[layerBase * 3 + 1] = y; + positions[layerBase * 3 + 2] = cz; + colors[layerBase * 3 + 0] = cr; + colors[layerBase * 3 + 1] = cg; + colors[layerBase * 3 + 2] = cb; + + // Ring vertices + for (let ring = 0; ring < RADIAL_RINGS; ring++) { + // sqrt distribution keeps area density even across rings + const rFrac = Math.sqrt((ring + 1) / RADIAL_RINGS); + const r = rFrac * allowedRadius; + + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const angle = (seg / THETA_SEGMENTS) * Math.PI * 2; + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + const temp = idwTemp(x, y, z, anchors, IDW_POWER); + const heat = tempToHeat(temp, upperThreshold); + const [vr, vg, vb] = heatToRGB(heat); + + const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg; + positions[vi * 3 + 0] = x; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = z; + colors[vi * 3 + 0] = vr; + colors[vi * 3 + 1] = vg; + colors[vi * 3 + 2] = vb; + } + } + } + + // ------------------------------------------------------------------- + // 2. Build triangle indices + // ------------------------------------------------------------------- + // For each pair of adjacent height layers, connect: + // (a) center fan for the innermost ring + // (b) quad strips between adjacent rings + // (c) quad strips between outermost ring top/bottom caps + // We also cap the top and bottom with fans. + + const indices: number[] = []; + + const idx = (hStep: number, ring: number, seg: number): number => { + // ring -1 = center vertex + if (ring < 0) return hStep * pointsPerLayer; + const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; + return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; + }; + + // Side walls — connect each layer to the next + for (let h = 0; h < HEIGHT_STEPS - 1; h++) { + // Center → first ring quads (actually triangles since one side is a point) + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + // tri: center(h), ring0(h,seg), ring0(h,next) + indices.push(idx(h, -1, 0), idx(h, 0, seg), idx(h, 0, next)); + // tri: center(h+1), ring0(h+1,next), ring0(h+1,seg) + indices.push(idx(h + 1, -1, 0), idx(h + 1, 0, next), idx(h + 1, 0, seg)); + // quad connecting the two center fans + indices.push( + idx(h, -1, 0), idx(h + 1, -1, 0), idx(h, 0, seg), + ); + indices.push( + idx(h + 1, -1, 0), idx(h + 1, 0, seg), idx(h, 0, seg), + ); + } + + // Ring-to-ring quads + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + + // quad between ring and ring+1 at layer h + const a = idx(h, ring, seg); + const b = idx(h, ring, next); + const c = idx(h, ring + 1, seg); + const d = idx(h, ring + 1, next); + + // quad between ring and ring+1 at layer h+1 + const e = idx(h + 1, ring, seg); + const f = idx(h + 1, ring, next); + const g = idx(h + 1, ring + 1, seg); + const hh = idx(h + 1, ring + 1, next); + + // side face (h → h+1 for this quad) + indices.push(a, e, b); + indices.push(e, f, b); + + // inner ring cap face at layer h + indices.push(a, b, c); + indices.push(b, d, c); + + // inner ring cap face at layer h+1 + indices.push(e, g, f); + indices.push(f, g, hh); + } + } + + // Outermost ring side faces + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(h, outerRing, seg); + const b = idx(h, outerRing, next); + const c = idx(h + 1, outerRing, seg); + const d = idx(h + 1, outerRing, next); + indices.push(a, c, b); + indices.push(b, c, d); + } + } + + // Bottom cap — fan from center to outermost ring + const hBottom = 0; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + indices.push( + idx(hBottom, -1, 0), + idx(hBottom, RADIAL_RINGS - 1, next), + idx(hBottom, RADIAL_RINGS - 1, seg), + ); + } + + // Top cap + const hTop = HEIGHT_STEPS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + indices.push( + idx(hTop, -1, 0), + idx(hTop, RADIAL_RINGS - 1, seg), + idx(hTop, RADIAL_RINGS - 1, next), + ); + } + + // ------------------------------------------------------------------- + // 2b. Hopper tip vertex + fan (only for hopper bins) + // ------------------------------------------------------------------- + // The tip vertex sits at the very last slot in the buffer. + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + + if (hopperHeight > 0) { + const tipTemp = idwTemp(0, rawBottomY, 0, anchors, IDW_POWER); + const tipHeat = tempToHeat(tipTemp, upperThreshold); + const [tr, tg, tb] = heatToRGB(tipHeat); + + positions[tipVertexIndex * 3 + 0] = 0; + positions[tipVertexIndex * 3 + 1] = rawBottomY; // hopperTipY + positions[tipVertexIndex * 3 + 2] = 0; + colors[tipVertexIndex * 3 + 0] = tr; + colors[tipVertexIndex * 3 + 1] = tg; + colors[tipVertexIndex * 3 + 2] = tb; + + // Fan from bottom layer's outermost ring down to the tip point. + // This fills the gap between grainBottomY and hopperTipY. + const hBottom = 0; + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(hBottom, outerRing, seg); + const b = idx(hBottom, outerRing, next); + // Wind so the face is visible from below (tip → b → a) + indices.push(tipVertexIndex, b, a); + } + + // Also fan the bottom layer rings down to the tip for the + // interior of the hopper cone + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(hBottom, ring, seg); + const b = idx(hBottom, ring, next); + indices.push(tipVertexIndex, b, a); + } + } + // Center to tip + indices.push(tipVertexIndex, idx(hBottom, -1, 0), idx(hBottom, 0, 0)); + } + + // ------------------------------------------------------------------- + // 3. Assemble BufferGeometry + // ------------------------------------------------------------------- + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geo.setIndex(indices); + return geo; + }, [anchors, maxGrainY, hopperTipY, sidewallBaseY, binRadius, upperThreshold, hopperHeight]); + + if (!geometry) return null; + + // meshBasicMaterial is used intentionally here instead of meshStandardMaterial: + // - No lighting/normal calculations means face winding direction does not affect + // visibility, so the mesh looks identical from all camera angles including + // below and inside the volume. + // - vertexColors drives all colour — lighting would wash out the green/yellow/red + // gradient anyway depending on light angle. + return ( + + + + ); +} diff --git a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx index 0ae8a2e..3cb8cd7 100644 --- a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx +++ b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx @@ -260,13 +260,30 @@ export default function GrainCableFill(props: Props) { roughness={1} metalness={0} opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} {cylinderFillHeight > 0 && ( - - - - + )} ); @@ -275,9 +292,9 @@ export default function GrainCableFill(props: Props) { // --- Render cable-driven surface --- return ( <> - {/* Interpolated grain surface */} + {/* Interpolated grain surface - i have not made a react component for this shape, not exactly a basic shape, so am just using mesh as is */} {surfaceGeometry && ( - + )} @@ -305,6 +323,8 @@ export default function GrainCableFill(props: Props) { roughness={1} opacity={grainOpacity} depthWrite={false} + depthTest={false} + renderOrder={0} /> {/* Hopper fill — always full when grain surface exists above the floor */} @@ -322,6 +342,9 @@ export default function GrainCableFill(props: Props) { roughness={1} metalness={0} opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} diff --git a/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx index 29e0db3..e954a1d 100644 --- a/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx +++ b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx @@ -8,6 +8,7 @@ interface Props { sidewallHeight: number; hopperHeight?: number; fillPercent: number; + grainOpacity?: number; } export default function GrainFillFlat(props: Props) { @@ -15,7 +16,8 @@ export default function GrainFillFlat(props: Props) { diameter, sidewallHeight, hopperHeight = 0, - fillPercent + fillPercent, + grainOpacity } = props; const radius = diameter / 2; @@ -99,6 +101,10 @@ export default function GrainFillFlat(props: Props) { colour={grainColour} roughness={1} metalness={0} + opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} @@ -116,6 +122,10 @@ export default function GrainFillFlat(props: Props) { colour={grainColour} roughness={1} metalness={0} + opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} From b1c676987e7ddc0e52cc089566a73fc57ce6bf43 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 29 Apr 2026 15:33:35 -0600 Subject: [PATCH 13/44] updated the heatmap and added toggles --- src/bin/3dView/Data/BuildNodeData.ts | 4 +- src/bin/3dView/Scene/Bin3dView.tsx | 23 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 14 +- .../3dView/Systems/Heatmap/HeatMapAlpha.tsx | 452 ---------- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 841 ++++++++++-------- src/pages/Bin.tsx | 46 +- 6 files changed, 531 insertions(+), 849 deletions(-) delete mode 100644 src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 9d7de98..2aed727 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -46,9 +46,9 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { let t = celcius // for testing if(i===0){ - t = 30 + t = 40 } - if (i===1){ + if (i===5){ t = 0 } diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index a704007..a9124c7 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,11 +8,10 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; -import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; -import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; -// import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; //grain heat map +import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; //hot spots interface Props { /** @@ -31,16 +30,24 @@ interface Props { fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void /** - * When true, renders the heatmap instead of the grain fill. + * toggles the grain in the bin + */ + showGrain?: boolean + /** + * toggles the heatmap overlay */ showHeatmap?: boolean + /** + * toggles the hotspots in the bin + */ + showHotspots?: boolean } export default function Bin3dView(props: Props){ //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof // 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, nodeClick, showHeatmap = false} = props + const {bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); @@ -86,7 +93,7 @@ export default function Bin3dView(props: Props){ /> {/* grain - cylinder*/} - {/* {!showHeatmap && grainInventory()} */} + {showGrain && grainInventory()} {/* cables */} { @@ -97,8 +104,8 @@ export default function Bin3dView(props: Props){ } }} renderOrder={1}/> - {/* */} - + {showHotspots && } + {showHeatmap && } {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index 782b40f..080422a 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -72,18 +72,16 @@ export default function CableNode(props: Props) { 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() }); + } else if (node.humidity) { + r.push({ + text: `${node.humidity.toFixed(2)}%`, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + }); } return r; @@ -151,7 +149,7 @@ export default function CableNode(props: Props) { 1 makes only strong deviations pop; <1 makes small deviations pop more. - */ - deviationPower?: number - // (reverted) extra perf knobs removed -} - -/** - * this is a work in progress, the heatmap generated may not be accurate so avoid using this component for now - * @param props - * @returns - */ -export default function Heatmap(props: Props){ - const { - bin, - nodes, - opacity = 0.65, // kept for backward compatibility - pointOpacity, - pointSize = 5, - ySlices = 22, - radialRings = 16, - thetaSegments = 28, - wallInsetFactor = 0.99, - samplesPerCell = 1, - jitter = 0.75, - alphaHash = true, - greenOpacityFactor = 0.18, - deviationPower = 0.6, - } = props; - - useThree(); // keep fiber context available if needed later - - const sidewallHeight = bin.sidewallHeight(); - const hopperHeight = bin.hopperHeight() ?? 0; - const sidewallBaseY = -sidewallHeight / 2; - const hopperTipY = sidewallBaseY - hopperHeight; - - const inGrainNodes = useMemo( - () => nodes.filter((n) => n.inGrain && !n.excluded), - [nodes], - ); - - const maxGrainY = useMemo(() => { - let maxY = -Infinity; - for (const n of inGrainNodes) maxY = Math.max(maxY, n.position.y); - return Number.isFinite(maxY) ? maxY : sidewallBaseY; - }, [inGrainNodes, sidewallBaseY]); - - const topNodes = useMemo( - () => nodes.filter((n) => n.topNode && n.inGrain && !n.excluded), - [nodes], - ); - - const anchors = useMemo( - () => - topNodes.map((n) => ({ - x: n.position.x, - z: n.position.z, - y: n.position.y + n.nodeSpacing * 0.5, - })), - [topNodes], - ); - - const wallY = useMemo(() => { - if (anchors.length === 0) return -sidewallHeight / 2; - return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length; - }, [anchors, sidewallHeight]); - - const idwHeight = ( - x: number, - z: number, - inputAnchors: { x: number; z: number; y: number }[], - power = 2, - ): number => { - let totalWeight = 0; - let weightedY = 0; - - for (const a of inputAnchors) { - const dx = x - a.x; - const dz = z - a.z; - const distSq = dx * dx + dz * dz; - if (distSq < 0.001) return a.y; - const w = 1 / Math.pow(distSq, power / 2); - totalWeight += w; - weightedY += a.y * w; - } - - return weightedY / totalWeight; - }; - - const maxRadiusAtY = (y: number, maxR: number): number => { - if (y >= sidewallBaseY) return maxR; - if (hopperHeight <= 0 || y <= hopperTipY) return 0; - const t = (y - hopperTipY) / hopperHeight; // 0..1 - return maxR * t; - }; - - const grainSurfaceY = (x: number, z: number, rNorm: number): number => { - // If we don't have top nodes, use a flat surface at maxGrainY. - if (anchors.length === 0) return maxGrainY; - - const rawY = idwHeight(x, z, anchors); - - // Match `GrainCableFill` outer-wall taper so switching isn't jarring. - const edgeStart = 0.8; - const blendT = Math.max(0, (rNorm - edgeStart) / (1 - edgeStart)); - const s = blendT * blendT * (3 - 2 * blendT); - const y = rawY * (1 - s) + wallY * s; - return Math.max(-sidewallHeight / 2, Math.min(sidewallHeight / 2, y)); - }; - - const evaluateTemp = (px: number, py: number, pz: number): number | null => { - if (inGrainNodes.length === 0) return null; - - // Inverse-distance weighted interpolation. - // Keep power modest so the field stays smooth. - const IDW_POWER = 2; - let totalWeight = 0; - let weightedSum = 0; - - for (const n of inGrainNodes) { - const dx = px - n.position.x; - const dy = py - n.position.y; - const dz = pz - n.position.z; - const distSq = dx * dx + dy * dy + dz * dz; - const weight = distSq < 0.001 ? 1e6 : 1 / Math.pow(distSq, IDW_POWER / 2); - totalWeight += weight; - weightedSum += n.celcius * weight; - } - - if (totalWeight === 0) return null; - return weightedSum / totalWeight; - }; - - const tempToHeatColor = (temp: number): Color => { - // Match your 2D/point visuals: green in-threshold, fade to red/blue as distance grows. - const lower = bin.lowerTempThreshold(); - const upper = bin.upperTempThreshold(); - - const GREEN = new Color("#52c41a"); - const BLUE = new Color("#3399ff"); - const RED = new Color("#ff4d4f"); - - if (temp >= lower && temp <= upper) return GREEN; - - const distance = temp < lower ? lower - temp : temp - upper; - const intensity = Math.min(1, distance / colourFade); // 0..1 - - // Similar HSL shaping as `TempToColour`, but always returns a color. - const minimumLightness = 0.3; - const lightnessRange = 0.2; - const minimumSaturation = 0.7; - const saturationRange = 0.8; - - const hsl = { h: 0, s: 1, l: 1 }; - (temp < lower ? BLUE : RED).getHSL(hsl); - - const c = new Color(); - c.setHSL( - hsl.h, - saturationRange * intensity + minimumSaturation, - lightnessRange * intensity + minimumLightness, - ); - return c; - }; - - const tempToDeviation = (temp: number): number => { - const lower = bin.lowerTempThreshold(); - const upper = bin.upperTempThreshold(); - if (temp >= lower && temp <= upper) return 0; - const distance = temp < lower ? lower - temp : temp - upper; - return Math.min(1, distance / colourFade); - }; - - const { positions, colors, deviations } = useMemo(() => { - const binR = bin.diameter() / 2; - // Important: points are rendered as *sprites*, so even if the center is inside the wall, - // the visible circle can extend outside. Shrink the sampling radius by ~half pointSize - // so the rendered splats stay within the bin. - const maxR = Math.max(0, binR * wallInsetFactor - pointSize * 0.55); - - const y0 = hopperHeight > 0 ? hopperTipY : sidewallBaseY; - const y1 = Math.max(y0, maxGrainY); - - const pos: number[] = []; - const col: number[] = []; - const dev: number[] = []; - const tmpColor = new Color(); - - const safeYSlices = Math.max(6, Math.floor(ySlices)); - const safeRings = Math.max(4, Math.floor(radialRings)); - const safeTheta = Math.max(12, Math.floor(thetaSegments)); - const safeSamples = Math.max(1, Math.floor(samplesPerCell)); - const j = Math.min(1, Math.max(0, jitter)); - // Deterministic "random" so the cloud doesn't shimmer every render. - const rand01 = (seed: number) => { - // xorshift32 - let x = seed | 0; - x ^= x << 13; - x ^= x >>> 17; - x ^= x << 5; - // convert to [0,1) - return ((x >>> 0) % 1000000) / 1000000; - }; - - for (let yi = 0; yi < safeYSlices; yi++) { - const ty = safeYSlices === 1 ? 0 : yi / (safeYSlices - 1); - const y = y0 + (y1 - y0) * ty; - - const rAtY = maxRadiusAtY(y, maxR); - if (rAtY <= 0.001) continue; - - for (let ring = 0; ring < safeRings; ring++) { - for (let seg = 0; seg < safeTheta; seg++) { - // Cell bounds in polar space - const ring0 = ring / safeRings; - const ring1 = (ring + 1) / safeRings; - const r0 = Math.sqrt(ring0) * rAtY; - const r1 = Math.sqrt(ring1) * rAtY; - - const theta0 = (seg / safeTheta) * Math.PI * 2; - const theta1 = ((seg + 1) / safeTheta) * Math.PI * 2; - - for (let s = 0; s < safeSamples; s++) { - const seed = yi * 73856093 + ring * 19349663 + seg * 83492791 + s * 2654435761; - const u = rand01(seed); - const v = rand01(seed ^ 0x9e3779b9); - - // Jitter inside the cell - const rr = r0 + (r1 - r0) * (j === 0 ? 0.5 : (0.5 + (u - 0.5) * j)); - const tt = theta0 + (theta1 - theta0) * (j === 0 ? 0.5 : (0.5 + (v - 0.5) * j)); - - const x = Math.cos(tt) * rr; - const z = Math.sin(tt) * rr; - - const rNorm = rAtY <= 0 ? 0 : rr / rAtY; - const surfaceY = grainSurfaceY(x, z, rNorm); - if (y > surfaceY) continue; - - const temp = evaluateTemp(x, y, z); - const d0 = temp == null ? 0 : tempToDeviation(temp); - - pos.push(x, y, z); - const c = temp == null ? tmpColor.set("#52c41a") : tempToHeatColor(temp); - col.push(c.r, c.g, c.b); - dev.push(d0); - } - } - } - } - - return { - positions: new Float32Array(pos), - colors: new Float32Array(col), - deviations: new Float32Array(dev), - }; - }, [ - bin, - wallInsetFactor, - hopperHeight, - hopperTipY, - sidewallBaseY, - maxGrainY, - ySlices, - radialRings, - thetaSegments, - anchors, - wallY, - inGrainNodes, - samplesPerCell, - jitter, - deviationPower, - ]); - - const alphaHashedMaterial = useMemo(() => { - return new ShaderMaterial({ - transparent: !alphaHash, - depthTest: true, - depthWrite: alphaHash, - uniforms: { - uOpacity: { value: pointOpacity ?? opacity }, - uSize: { value: pointSize }, - uMaxRadius: { value: (bin.diameter() / 2) * wallInsetFactor }, - uSidewallBaseY: { value: -bin.sidewallHeight() / 2 }, - uHopperHeight: { value: bin.hopperHeight() ?? 0 }, - uAlphaHash: { value: alphaHash ? 1 : 0 }, - uGreenOpacityFactor: { value: Math.min(1, Math.max(0, greenOpacityFactor)) }, - uDeviationPower: { value: Math.max(0.05, deviationPower) }, - }, - vertexShader: ` - uniform float uSize; - varying vec3 vWorldPos; - varying vec3 vColor; - varying float vDev; - attribute vec3 color; - attribute float deviation; - void main() { - vColor = color; - vDev = deviation; - vec4 world = modelMatrix * vec4(position, 1.0); - vWorldPos = world.xyz; - vec4 mvPosition = viewMatrix * world; - float attn = 300.0 / max(1.0, -mvPosition.z); - gl_PointSize = uSize * attn; - gl_Position = projectionMatrix * mvPosition; - } - `, - fragmentShader: ` - precision highp float; - uniform float uOpacity; - uniform float uMaxRadius; - uniform float uSidewallBaseY; - uniform float uHopperHeight; - uniform float uAlphaHash; - uniform float uGreenOpacityFactor; - uniform float uDeviationPower; - varying vec3 vColor; - varying float vDev; - varying vec3 vWorldPos; - // interleaved gradient noise - float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); - } - void main() { - // Hard clip pixels to bin radius at this Y (prevents splats outside wall). - float y = vWorldPos.y; - float sidewallBaseY = uSidewallBaseY; - float hopperHeight = uHopperHeight; - float hopperTipY = sidewallBaseY - hopperHeight; - float maxR; - if (y >= sidewallBaseY) { - maxR = uMaxRadius; - } else if (hopperHeight <= 0.0 || y <= hopperTipY) { - maxR = 0.0; - } else { - float t = (y - hopperTipY) / hopperHeight; - maxR = uMaxRadius * t; - } - float r = length(vWorldPos.xz); - if (r > maxR) discard; - - vec2 p = gl_PointCoord - vec2(0.5); - float d = length(p) * 2.0; - float mask = smoothstep(1.0, 0.0, d); - - float dev = clamp(vDev, 0.0, 1.0); - float devCurve = pow(dev, uDeviationPower); - // 0 => green/in-threshold, 1 => strong deviation - float localOpacityFactor = mix(uGreenOpacityFactor, 1.0, devCurve); - float a = clamp(mask * uOpacity * localOpacityFactor, 0.0, 1.0); - - if (uAlphaHash > 0.5) { - float n = ign(gl_FragCoord.xy); - if (n > a) discard; - gl_FragColor = vec4(vColor, 1.0); - } else { - gl_FragColor = vec4(vColor, a); - } - } - `, - }); - }, [alphaHash, bin, deviationPower, greenOpacityFactor, opacity, pointOpacity, pointSize, wallInsetFactor]); - - // Fallback: normal points (no OIT) - return ( - - - - - - - - ); -} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index cbb9d0d..a2ab743 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -2,405 +2,490 @@ import { useMemo } from "react"; import * as THREE from "three"; import { Bin } from "models"; import { NodeData } from "../../Data/BuildNodeData"; - +import React from "react"; + interface Props { bin: Bin; nodes: NodeData[]; } - -// ----------------------------------------------------------------------- -// 🎛️ TUNING KNOBS -// ----------------------------------------------------------------------- - -// Grid resolution — more = smoother but heavier -const RADIAL_RINGS = 12; // rings of sample points from center outward -const THETA_SEGMENTS = 24; // points around each ring -const HEIGHT_STEPS = 20; // vertical layers - -// Colour thresholds — degrees °C above the bin's upper threshold -const YELLOW_DELTA = 5; // at this far above threshold → full yellow -const RED_DELTA = 10; // at this far above threshold → full red - -// IDW power — higher = sharper transitions between nodes (2 is standard) -const IDW_POWER = 2; - -// Mesh appearance -const OPACITY = 0.55; - -// ----------------------------------------------------------------------- -// COLOUR HELPERS -// ----------------------------------------------------------------------- - -// Returns 0 (green) → 1 (yellow) → 2 (red) based on how far above -// the upper threshold the interpolated temperature is. -// Everything at or below upper threshold = 0. -function tempToHeat(temp: number, upper: number): number { + +const RADIAL_RINGS = 20; +const THETA_SEGMENTS = 40; +const HEIGHT_STEPS = 28; + +const YELLOW_DELTA = 5; +const RED_DELTA = 10; + +const IDW_POWER = 4; +const RED_OPACITY = 0.3; +const GREEN_OPACITY = 0.3; +const YELLOW_OPACITY = 0.8; + +// New tuning knobs +const ANGLE_JITTER = 0.2; +const RADIAL_JITTER = 0.05; +const LAYER_TWIST = 0.22; + +function tempToHeat(temp:number, upper:number):number { if (temp <= upper) return 0; + const delta = temp - upper; + if (delta >= RED_DELTA) return 2; - if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA); + + if (delta >= YELLOW_DELTA) { + return 1 + + (delta - YELLOW_DELTA) / + (RED_DELTA - YELLOW_DELTA); + } + return delta / YELLOW_DELTA; } - -// Maps heat value [0–2] to RGB. -// 0 = green (#52c41a) -// 1 = yellow (#fadb14) -// 2 = red (#ff4d4f) -function heatToRGB(heat: number): [number, number, number] { - if (heat <= 0) return [0.322, 0.761, 0.102]; // green - + +function heatToRGB(heat:number): number[] { + + const GREEN = [0,0.5,0.02]; + const YELLOW = [0.7,0.86,0.0]; + const RED = [0.8,0.0,0.01]; + + if (heat <= 0) + return GREEN; + if (heat <= 1) { - // green → yellow - const t = heat; - return [ - 0.322 + (0.980 - 0.322) * t, // R - 0.761 + (0.859 - 0.761) * t, // G - 0.102 + (0.078 - 0.102) * t, // B - ]; + + const t = Math.pow(heat,0.75); + + return [ + GREEN[0] + (YELLOW[0]-GREEN[0])*t, + GREEN[1] + (YELLOW[1]-GREEN[1])*t, + GREEN[2] + (YELLOW[2]-GREEN[2])*t, + ]; } - - // yellow → red - const t = heat - 1; + + const t = Math.pow(heat-1,0.75); + return [ - 0.980 + (1.000 - 0.980) * t, // R - 0.859 + (0.302 - 0.859) * t, // G - 0.078 + (0.310 - 0.078) * t, // B + YELLOW[0] + (RED[0]-YELLOW[0])*t, + YELLOW[1] + (RED[1]-YELLOW[1])*t, + YELLOW[2] + (RED[2]-YELLOW[2])*t, ]; -} - -// ----------------------------------------------------------------------- -// IDW TEMPERATURE INTERPOLATION -// ----------------------------------------------------------------------- - + } + interface TempAnchor { - x: number; y: number; z: number; - celcius: number; -} - -function idwTemp( - px: number, py: number, pz: number, - anchors: TempAnchor[], - power: number -): number { - let totalWeight = 0; - let weightedSum = 0; - - for (const a of anchors) { - const dx = px - a.x; - const dy = py - a.y; - const dz = pz - a.z; - const distSq = dx * dx + dy * dy + dz * dz; - - if (distSq < 0.001) return a.celcius; // exactly on a node - - const weight = 1 / Math.pow(distSq, power / 2); - totalWeight += weight; - weightedSum += a.celcius * weight; + x:number; + y:number; + z:number; + celcius:number; + } + + function idwTemp( + px:number, + py:number, + pz:number, + anchors:TempAnchor[], + power:number + ):number { + + let totalWeight=0; + let weightedSum=0; + + for (const a of anchors){ + + const dx=px-a.x; + const dy=py-a.y; + const dz=pz-a.z; + + const distSq=dx*dx+dy*dy+dz*dz; + + if (distSq < 0.001) + return a.celcius; + + const weight = + 1 / Math.pow(distSq, power/2); + + totalWeight += weight; + weightedSum += a.celcius * weight; } + + return totalWeight===0 + ? 0 + : weightedSum/totalWeight; + } - return totalWeight === 0 ? 0 : weightedSum / totalWeight; -} + // deterministic pseudo-random based on indices + function hashNoise(a:number,b:number,c:number){ + const x = Math.sin( + a*127.1 + b*311.7 + c*74.7 + ) * 43758.5453; -// ----------------------------------------------------------------------- -// COMPONENT -// ----------------------------------------------------------------------- - -export default function TempHeatMap(props: Props) { - const { bin, nodes } = props; - - const binRadius = bin.diameter() / 2; - const sidewallHeight = bin.sidewallHeight(); - const hopperHeight = bin.hopperHeight() ?? 0; - const upperThreshold = bin.upperTempThreshold(); - const sidewallBaseY = -sidewallHeight / 2; - const hopperTipY = sidewallBaseY - hopperHeight; - - // Taper radius inside the hopper cone - const maxRadiusAtY = (y: number): number => { - if (y >= sidewallBaseY) return binRadius; - if (hopperHeight <= 0 || y <= hopperTipY) return 0; - return binRadius * ((y - hopperTipY) / hopperHeight); + return (x - Math.floor(x))*2 -1; + } + export default function TempHeatMapGPT({bin,nodes}:Props){ + + const binRadius=bin.diameter()/2; + const sidewallHeight=bin.sidewallHeight(); + const hopperHeight=bin.hopperHeight() ?? 0; + const upperThreshold=bin.upperTempThreshold(); + + const sidewallBaseY=-sidewallHeight/2; + const hopperTipY=sidewallBaseY-hopperHeight; + + const maxRadiusAtY=(y:number)=>{ + + if(y>=sidewallBaseY) + return binRadius; + + if(hopperHeight<=0 || y<=hopperTipY) + return 0; + + return binRadius* + ((y-hopperTipY)/hopperHeight); }; - - // Only use in-grain, non-excluded nodes as temperature anchors - const anchors = useMemo(() => - nodes - .filter(n => n.inGrain && !n.excluded) - .map(n => ({ - x: n.position.x, - y: n.position.y, - z: n.position.z, - celcius: n.celcius, - })), + const anchors=useMemo( + ()=>nodes + .filter(n=>n.inGrain && !n.excluded) + .map(n=>({ + x:n.position.x, + y:n.position.y, + z:n.position.z, + celcius:n.celcius, + })), [nodes] - ); - - // Top of grain — highest in-grain node Y - const maxGrainY = useMemo(() => { - let maxY = sidewallBaseY; - for (const a of anchors) { - if (a.y > maxY) maxY = a.y; - } - return maxY; - }, [anchors, sidewallBaseY]); - - // ----------------------------------------------------------------------- - // BUILD GEOMETRY - // ----------------------------------------------------------------------- - const geometry = useMemo(() => { - if (anchors.length === 0) return null; - - // ------------------------------------------------------------------- - // 1. Sample the cylindrical grid - // ------------------------------------------------------------------- - // Layout: center column + RADIAL_RINGS rings, each with THETA_SEGMENTS - // vertices, stacked HEIGHT_STEPS times vertically. - // - // Vertex index scheme: - // layer * pointsPerLayer + ringOffset - // where ringOffset: 0 = center, 1..N = ring vertices - - const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; - // +1 for the optional hopper tip vertex (unused for flat-bottom bins) - const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; - - const positions = new Float32Array(totalVerts * 3); - const colors = new Float32Array(totalVerts * 3); - - // Grain bottom Y — bottom of the grain, either hopper tip or sidewall base - // For hopper bins, starting exactly at hopperTipY causes the entire - // bottom layer to collapse to radius=0 (degenerate triangles that - // disappear when viewed from below). Instead start one HEIGHT_STEPS - // increment above the tip so the bottom layer always has a visible - // radius, then add a separate tip vertex that fans down to a point. - const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; - const grainBottomY = hopperHeight > 0 - ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS + ); + + const maxGrainY=useMemo(()=>{ + let maxY=sidewallBaseY; + + for(const a of anchors) + if(a.y>maxY) + maxY=a.y; + + return maxY; + },[anchors,sidewallBaseY]); + + const geometry=useMemo(()=>{ + + if(!anchors.length) + return null; + + const pointsPerLayer= + 1 + RADIAL_RINGS*THETA_SEGMENTS; + + const totalVerts= + HEIGHT_STEPS*pointsPerLayer +1; + + const positions= + new Float32Array(totalVerts*3); + + const colors= + new Float32Array(totalVerts*3); + + const heats=new Float32Array(totalVerts); + + const rawBottomY= + hopperHeight>0 + ? hopperTipY + : sidewallBaseY; + + const grainBottomY= + hopperHeight>0 + ? hopperTipY + + (maxGrainY-hopperTipY)/HEIGHT_STEPS : sidewallBaseY; - const grainHeight = maxGrainY - grainBottomY; + + const grainHeight= + maxGrainY-grainBottomY; + + if(grainHeight<=0) + return null; + + for(let hStep=0; hStep{ + + if(ring<0) + return hStep*pointsPerLayer; + + const s= + ((seg%THETA_SEGMENTS) + +THETA_SEGMENTS) + %THETA_SEGMENTS; + + return hStep*pointsPerLayer+ + 1+ + ring*THETA_SEGMENTS+ + s; + }; + + const green:number[]=[]; + const yellow:number[]=[]; + const red:number[]=[]; + + function pushTri(a:number,b:number,c:number){ + + const avg=( + heats[a]+heats[b]+heats[c] + )/3; + + if(avg<1) + green.push(a,b,c); + else if(avg<2) + yellow.push(a,b,c); + else + red.push(a,b,c); + } + + for(let h=0; h { - // ring -1 = center vertex - if (ring < 0) return hStep * pointsPerLayer; - const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; - return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; - }; - - // Side walls — connect each layer to the next - for (let h = 0; h < HEIGHT_STEPS - 1; h++) { - // Center → first ring quads (actually triangles since one side is a point) - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - // tri: center(h), ring0(h,seg), ring0(h,next) - indices.push(idx(h, -1, 0), idx(h, 0, seg), idx(h, 0, next)); - // tri: center(h+1), ring0(h+1,next), ring0(h+1,seg) - indices.push(idx(h + 1, -1, 0), idx(h + 1, 0, next), idx(h + 1, 0, seg)); - // quad connecting the two center fans - indices.push( - idx(h, -1, 0), idx(h + 1, -1, 0), idx(h, 0, seg), - ); - indices.push( - idx(h + 1, -1, 0), idx(h + 1, 0, seg), idx(h, 0, seg), - ); - } - - // Ring-to-ring quads - for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - - // quad between ring and ring+1 at layer h - const a = idx(h, ring, seg); - const b = idx(h, ring, next); - const c = idx(h, ring + 1, seg); - const d = idx(h, ring + 1, next); - - // quad between ring and ring+1 at layer h+1 - const e = idx(h + 1, ring, seg); - const f = idx(h + 1, ring, next); - const g = idx(h + 1, ring + 1, seg); - const hh = idx(h + 1, ring + 1, next); - - // side face (h → h+1 for this quad) - indices.push(a, e, b); - indices.push(e, f, b); - - // inner ring cap face at layer h - indices.push(a, b, c); - indices.push(b, d, c); - - // inner ring cap face at layer h+1 - indices.push(e, g, f); - indices.push(f, g, hh); - } - } - - // Outermost ring side faces - const outerRing = RADIAL_RINGS - 1; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - const a = idx(h, outerRing, seg); - const b = idx(h, outerRing, next); - const c = idx(h + 1, outerRing, seg); - const d = idx(h + 1, outerRing, next); - indices.push(a, c, b); - indices.push(b, c, d); - } - } - - // Bottom cap — fan from center to outermost ring - const hBottom = 0; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - indices.push( - idx(hBottom, -1, 0), - idx(hBottom, RADIAL_RINGS - 1, next), - idx(hBottom, RADIAL_RINGS - 1, seg), - ); - } - - // Top cap - const hTop = HEIGHT_STEPS - 1; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - indices.push( - idx(hTop, -1, 0), - idx(hTop, RADIAL_RINGS - 1, seg), - idx(hTop, RADIAL_RINGS - 1, next), - ); - } - - // ------------------------------------------------------------------- - // 2b. Hopper tip vertex + fan (only for hopper bins) - // ------------------------------------------------------------------- - // The tip vertex sits at the very last slot in the buffer. - const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; - - if (hopperHeight > 0) { - const tipTemp = idwTemp(0, rawBottomY, 0, anchors, IDW_POWER); - const tipHeat = tempToHeat(tipTemp, upperThreshold); - const [tr, tg, tb] = heatToRGB(tipHeat); - - positions[tipVertexIndex * 3 + 0] = 0; - positions[tipVertexIndex * 3 + 1] = rawBottomY; // hopperTipY - positions[tipVertexIndex * 3 + 2] = 0; - colors[tipVertexIndex * 3 + 0] = tr; - colors[tipVertexIndex * 3 + 1] = tg; - colors[tipVertexIndex * 3 + 2] = tb; - - // Fan from bottom layer's outermost ring down to the tip point. - // This fills the gap between grainBottomY and hopperTipY. - const hBottom = 0; - const outerRing = RADIAL_RINGS - 1; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - const a = idx(hBottom, outerRing, seg); - const b = idx(hBottom, outerRing, next); - // Wind so the face is visible from below (tip → b → a) - indices.push(tipVertexIndex, b, a); - } - - // Also fan the bottom layer rings down to the tip for the - // interior of the hopper cone - for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - const a = idx(hBottom, ring, seg); - const b = idx(hBottom, ring, next); - indices.push(tipVertexIndex, b, a); - } - } - // Center to tip - indices.push(tipVertexIndex, idx(hBottom, -1, 0), idx(hBottom, 0, 0)); - } - - // ------------------------------------------------------------------- - // 3. Assemble BufferGeometry - // ------------------------------------------------------------------- - const geo = new THREE.BufferGeometry(); - geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geo.setAttribute("color", new THREE.BufferAttribute(colors, 3)); - geo.setIndex(indices); - return geo; - }, [anchors, maxGrainY, hopperTipY, sidewallBaseY, binRadius, upperThreshold, hopperHeight]); - - if (!geometry) return null; - - // meshBasicMaterial is used intentionally here instead of meshStandardMaterial: - // - No lighting/normal calculations means face winding direction does not affect - // visibility, so the mesh looks identical from all camera angles including - // below and inside the volume. - // - vertexColors drives all colour — lighting would wash out the green/yellow/red - // gradient anyway depending on light angle. - return ( - - - + // indices.push(a,e,b); + // indices.push(e,f,b); + pushTri(a,e,b); + pushTri(e,f,b); + } + } + + //center fill + for (let seg=0; seg0){ + + const tipTemp=idwTemp( + 0, + rawBottomY, + 0, + anchors, + IDW_POWER + ); + + const tipHeat= + tempToHeat( + tipTemp, + upperThreshold + ); + + const [tr,tg,tb]= + heatToRGB(tipHeat); + + positions[tipVertexIndex*3]=0; + positions[tipVertexIndex*3+1]=rawBottomY; + positions[tipVertexIndex*3+2]=0; + + colors[tipVertexIndex*3]=tr; + colors[tipVertexIndex*3+1]=tg; + colors[tipVertexIndex*3+2]=tb; + const outerRing=RADIAL_RINGS-1; + + for(let seg=0; seg + + + + + + + + + + + + + ); + +} \ No newline at end of file diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 78e6c38..7531ab0 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -22,6 +22,9 @@ import { AccordionDetails, Typography, TextField, + RadioGroup, + Checkbox, + FormControlLabel, } from "@mui/material"; import BinActions from "bin/BinActions"; import BinHistory from "bin/BinHistory"; @@ -212,6 +215,11 @@ export default function Bin(props: Props) { const [binPresets, setBinPresets] = useState([]); const [missedReadings, setMissedReadings] = useState(0); + //3d bin variables/toggle + const [showGrain, setShowGrain] = useState(false) + const [showHotspots, setShowHotspots] = useState(false) + const [showHeatmap, setShowHeatmap] = useState(false) + const handleChange = (_event: React.ChangeEvent<{}>, newValue: number) => { setValue(newValue); }; @@ -816,7 +824,43 @@ export default function Bin(props: Props) { setFillPercent(+e.target.value) }} /> - + { + setShowGrain(checked); + }} + /> + } + label={"Inventory Toggle"} + /> + { + setShowHotspots(checked); + }} + /> + } + label={"Hot Spots"} + /> + { + setShowHeatmap(checked); + }} + /> + } + label={"Heatmap"} + /> + From 874be1d8b7213d042145738d3cb468902821ab27 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 30 Apr 2026 09:42:02 -0600 Subject: [PATCH 14/44] changed the heatmap limit to use the grain height --- src/3dModels/Shapes/BaseMesh.tsx | 4 +- src/bin/3dView/Scene/Bin3dView.tsx | 159 ++-- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 851 +++++++++--------- 3 files changed, 489 insertions(+), 525 deletions(-) diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index e06d622..73ea434 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -79,9 +79,9 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} - + {buildMaterial()} diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index a9124c7..68b5373 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -1,3 +1,4 @@ + import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; import { Canvas } from "@react-three/fiber"; import { Bin } from "models"; @@ -10,53 +11,56 @@ import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; -import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; //grain heat map -import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; //hot spots - +import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; +import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; + interface Props { - /** - * The bin to generate a 3D model of - */ bin: Bin - /** - * The scale to apply to the bin dimensions - * ie 100 would make the bin 1:100 scale - * @default 100 - */ scale: number - /** - * optional: the percent of the bin to fill using a level top, this will be used with manual and lidar controls - */ fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void - /** - * toggles the grain in the bin - */ showGrain?: boolean - /** - * toggles the heatmap overlay - */ showHeatmap?: boolean - /** - * toggles the hotspots in the bin - */ showHotspots?: boolean } - -export default function Bin3dView(props: Props){ - //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof - // 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, nodeClick, showHeatmap, showGrain, showHotspots} = props + +export default function Bin3dView(props: Props) { + const { bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots } = props + const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); - const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); - + const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); + + const isCableInventory = + bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || + bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE; + + // For cable inventory, TempHeatMap derives the wavy surface directly from + // the top nodes in nodeData — no scalar needed. + // + // For flat fill percent, we convert the fill percent to a Y coordinate + // using the same volume math as GrainFillFlat and pass it as flatMaxY. + const flatMaxY = useMemo(() => { + if (isCableInventory || fillPercent === undefined) return undefined; + + const radius = bin.diameter() / 2; + const sidewallH = bin.sidewallHeight(); + const hopperH = bin.hopperHeight() ?? 0; + const cylVolume = Math.PI * radius * radius * sidewallH; + const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0; + const filled = (cylVolume + hopperVol) * fillPercent; + + const cylinderFillH = hopperH > 0 && filled <= hopperVol + ? 0 + : Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius)); + + return -sidewallH / 2 + cylinderFillH; + }, [isCableInventory, fillPercent, bin]); + const grainInventory = () => { - if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || - bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE){ + if (isCableInventory) { return ( - ) - }else if (fillPercent){ - + } else if (fillPercent) { + return ( + + ) } } - + return ( - - - + + - - {/* grain - cylinder*/} + {showGrain && grainInventory()} - {/* cables */} - { - // console.log(node) - // console.log(cable) - if(nodeClick){ - nodeClick(node, cable) - } + if (nodeClick) nodeClick(node, cable) }} - renderOrder={1}/> - {showHotspots && } - {showHeatmap && } - - - {/* lighting */} - - - - - - - + renderOrder={1} + /> + + {showHotspots && } + + {showHeatmap && ( + + )} + + + + + + + + + ) -} \ No newline at end of file +} diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index a2ab743..ee6e46c 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -3,489 +3,440 @@ import * as THREE from "three"; import { Bin } from "models"; import { NodeData } from "../../Data/BuildNodeData"; import React from "react"; - + interface Props { bin: Bin; nodes: NodeData[]; + /** + * For flat fill percent inventory — a scalar Y ceiling for the heatmap top. + * Used when there are no cable top nodes to drive a surface. + */ + flatMaxY?: number; } - + const RADIAL_RINGS = 20; const THETA_SEGMENTS = 40; const HEIGHT_STEPS = 28; - + const YELLOW_DELTA = 5; const RED_DELTA = 10; - + const IDW_POWER = 4; const RED_OPACITY = 0.3; const GREEN_OPACITY = 0.3; const YELLOW_OPACITY = 0.8; - -// New tuning knobs + const ANGLE_JITTER = 0.2; const RADIAL_JITTER = 0.05; const LAYER_TWIST = 0.22; - -function tempToHeat(temp:number, upper:number):number { + +// IDW power for the horizontal surface interpolation — +// matches GrainCableFill's default of 2 +const SURFACE_IDW_POWER = 2; + +function tempToHeat(temp: number, upper: number): number { if (temp <= upper) return 0; - const delta = temp - upper; - if (delta >= RED_DELTA) return 2; - - if (delta >= YELLOW_DELTA) { - return 1 + - (delta - YELLOW_DELTA) / - (RED_DELTA - YELLOW_DELTA); - } - + if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA); return delta / YELLOW_DELTA; } - -function heatToRGB(heat:number): number[] { - - const GREEN = [0,0.5,0.02]; - const YELLOW = [0.7,0.86,0.0]; - const RED = [0.8,0.0,0.01]; - - if (heat <= 0) - return GREEN; - + +function heatToRGB(heat: number): number[] { + const GREEN = [0, 0.5, 0.02]; + const YELLOW = [0.7, 0.86, 0.0]; + const RED = [0.8, 0.0, 0.01]; + + if (heat <= 0) return GREEN; + if (heat <= 1) { - - const t = Math.pow(heat,0.75); - - return [ - GREEN[0] + (YELLOW[0]-GREEN[0])*t, - GREEN[1] + (YELLOW[1]-GREEN[1])*t, - GREEN[2] + (YELLOW[2]-GREEN[2])*t, - ]; + const t = Math.pow(heat, 0.75); + return [ + GREEN[0] + (YELLOW[0] - GREEN[0]) * t, + GREEN[1] + (YELLOW[1] - GREEN[1]) * t, + GREEN[2] + (YELLOW[2] - GREEN[2]) * t, + ]; } - - const t = Math.pow(heat-1,0.75); - + + const t = Math.pow(heat - 1, 0.75); return [ - YELLOW[0] + (RED[0]-YELLOW[0])*t, - YELLOW[1] + (RED[1]-YELLOW[1])*t, - YELLOW[2] + (RED[2]-YELLOW[2])*t, + YELLOW[0] + (RED[0] - YELLOW[0]) * t, + YELLOW[1] + (RED[1] - YELLOW[1]) * t, + YELLOW[2] + (RED[2] - YELLOW[2]) * t, ]; - } - +} + interface TempAnchor { - x:number; - y:number; - z:number; - celcius:number; - } - - function idwTemp( - px:number, - py:number, - pz:number, - anchors:TempAnchor[], - power:number - ):number { - - let totalWeight=0; - let weightedSum=0; - - for (const a of anchors){ - - const dx=px-a.x; - const dy=py-a.y; - const dz=pz-a.z; - - const distSq=dx*dx+dy*dy+dz*dz; - - if (distSq < 0.001) - return a.celcius; - - const weight = - 1 / Math.pow(distSq, power/2); - - totalWeight += weight; - weightedSum += a.celcius * weight; + x: number; y: number; z: number; + celcius: number; +} + +interface SurfaceAnchor { + x: number; z: number; y: number; +} + +// 3D IDW for temperature interpolation +function idwTemp( + px: number, py: number, pz: number, + anchors: TempAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedSum = 0; + + for (const a of anchors) { + const dx = px - a.x; + const dy = py - a.y; + const dz = pz - a.z; + const distSq = dx * dx + dy * dy + dz * dz; + if (distSq < 0.001) return a.celcius; + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedSum += a.celcius * weight; } - - return totalWeight===0 - ? 0 - : weightedSum/totalWeight; - } - // deterministic pseudo-random based on indices - function hashNoise(a:number,b:number,c:number){ - const x = Math.sin( - a*127.1 + b*311.7 + c*74.7 - ) * 43758.5453; + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} - return (x - Math.floor(x))*2 -1; - } - export default function TempHeatMapGPT({bin,nodes}:Props){ - - const binRadius=bin.diameter()/2; - const sidewallHeight=bin.sidewallHeight(); - const hopperHeight=bin.hopperHeight() ?? 0; - const upperThreshold=bin.upperTempThreshold(); - - const sidewallBaseY=-sidewallHeight/2; - const hopperTipY=sidewallBaseY-hopperHeight; - - const maxRadiusAtY=(y:number)=>{ - - if(y>=sidewallBaseY) - return binRadius; - - if(hopperHeight<=0 || y<=hopperTipY) - return 0; - - return binRadius* - ((y-hopperTipY)/hopperHeight); +// 2D IDW for surface height interpolation — matches GrainCableFill exactly +function idwSurfaceY( + x: number, z: number, + anchors: SurfaceAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedY = 0; + + for (const a of anchors) { + const dx = x - a.x; + const dz = z - a.z; + const distSq = dx * dx + dz * dz; + if (distSq < 0.001) return a.y; + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedY += a.y * weight; + } + + return totalWeight === 0 ? 0 : weightedY / totalWeight; +} + +function hashNoise(a: number, b: number, c: number) { + const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} + +export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) { + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const upperThreshold = bin.upperTempThreshold(); + + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + const maxRadiusAtY = (y: number) => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + return binRadius * ((y - hopperTipY) / hopperHeight); }; - const anchors=useMemo( - ()=>nodes - .filter(n=>n.inGrain && !n.excluded) - .map(n=>({ - x:n.position.x, - y:n.position.y, - z:n.position.z, - celcius:n.celcius, - })), + + // Temperature anchors — all in-grain nodes + const tempAnchors = useMemo( + () => + nodes + .filter(n => n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + y: n.position.y, + z: n.position.z, + celcius: n.celcius, + })), [nodes] - ); - - const maxGrainY=useMemo(()=>{ - let maxY=sidewallBaseY; - - for(const a of anchors) - if(a.y>maxY) - maxY=a.y; - - return maxY; - },[anchors,sidewallBaseY]); - - const geometry=useMemo(()=>{ - - if(!anchors.length) - return null; - - const pointsPerLayer= - 1 + RADIAL_RINGS*THETA_SEGMENTS; - - const totalVerts= - HEIGHT_STEPS*pointsPerLayer +1; - - const positions= - new Float32Array(totalVerts*3); - - const colors= - new Float32Array(totalVerts*3); - - const heats=new Float32Array(totalVerts); - - const rawBottomY= - hopperHeight>0 - ? hopperTipY - : sidewallBaseY; - - const grainBottomY= - hopperHeight>0 - ? hopperTipY + - (maxGrainY-hopperTipY)/HEIGHT_STEPS + ); + + // Surface anchors — top nodes only, Y offset by half spacing + // Matches GrainCableFill's anchor computation exactly. + const surfaceAnchors = useMemo( + () => + nodes + .filter(n => n.topNode && n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })), + [nodes] + ); + + // Wall clamp Y — average of surface anchor heights. + // Prevents the surface from piling up at the bin wall where there are no cables. + // Matches GrainCableFill's wallY computation. + const wallY = useMemo(() => { + if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY; + return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length; + }, [surfaceAnchors, flatMaxY, sidewallBaseY]); + + // For each (x, z) position, returns the Y ceiling of the grain surface. + // Uses cable surface IDW when top nodes exist, falls back to flatMaxY. + const getSurfaceY = (x: number, z: number): number => { + if (surfaceAnchors.length > 0) { + // Outermost ring clamps to wallY — matches GrainCableFill + const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER); + return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, raw)); + } + return flatMaxY ?? sidewallBaseY; + }; + + // Global max Y — highest point of the surface (used for grid bottom calc) + const maxGrainY = useMemo(() => { + if (surfaceAnchors.length > 0) { + return Math.max(...surfaceAnchors.map(a => a.y), wallY); + } + return flatMaxY ?? sidewallBaseY; + }, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]); + + const geometry = useMemo(() => { + if (!tempAnchors.length) return null; + + const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; + const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; + + const positions = new Float32Array(totalVerts * 3); + const colors = new Float32Array(totalVerts * 3); + const heats = new Float32Array(totalVerts); + + const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const grainBottomY = hopperHeight > 0 + ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS : sidewallBaseY; - - const grainHeight= - maxGrainY-grainBottomY; - - if(grainHeight<=0) - return null; - - for(let hStep=0; hStep{ - - if(ring<0) - return hStep*pointsPerLayer; - - const s= - ((seg%THETA_SEGMENTS) - +THETA_SEGMENTS) - %THETA_SEGMENTS; - - return hStep*pointsPerLayer+ - 1+ - ring*THETA_SEGMENTS+ - s; - }; - - const green:number[]=[]; - const yellow:number[]=[]; - const red:number[]=[]; - - function pushTri(a:number,b:number,c:number){ - - const avg=( - heats[a]+heats[b]+heats[c] - )/3; - - if(avg<1) - green.push(a,b,c); - else if(avg<2) - yellow.push(a,b,c); - else - red.push(a,b,c); - } - - for(let h=0; h0){ - - const tipTemp=idwTemp( - 0, - rawBottomY, - 0, - anchors, - IDW_POWER - ); - - const tipHeat= - tempToHeat( - tipTemp, - upperThreshold - ); - - const [tr,tg,tb]= - heatToRGB(tipHeat); - - positions[tipVertexIndex*3]=0; - positions[tipVertexIndex*3+1]=rawBottomY; - positions[tipVertexIndex*3+2]=0; - - colors[tipVertexIndex*3]=tr; - colors[tipVertexIndex*3+1]=tg; - colors[tipVertexIndex*3+2]=tb; - const outerRing=RADIAL_RINGS-1; - - for(let seg=0; seg { + if (ring < 0) return hStep * pointsPerLayer; + const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; + return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; + }; + + const green: number[] = []; + const yellow: number[] = []; + const red: number[] = []; + + function pushTri(a: number, b: number, c: number) { + const avg = (heats[a] + heats[b] + heats[c]) / 3; + if (avg < 1) green.push(a, b, c); + else if (avg < 2) yellow.push(a, b, c); + else red.push(a, b, c); + } + + for (let h = 0; h < HEIGHT_STEPS - 1; h++) { + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(h, ring, seg); + const b = idx(h, ring, next); + const e = idx(h + 1, ring, seg); + const f = idx(h + 1, ring, next); + pushTri(a, e, b); + pushTri(e, f, b); + } + } + + // center fill + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const center0 = idx(h, -1, 0); + const center1 = idx(h + 1, -1, 0); + const a = idx(h, 0, seg); + const b = idx(h, 0, next); + const c = idx(h + 1, 0, seg); + const d = idx(h + 1, 0, next); + pushTri(center0, c, a); + pushTri(center0, center1, c); + pushTri(a, c, b); + pushTri(b, c, d); + } + } + + // Hopper tip + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + if (hopperHeight > 0) { + const tipTemp = idwTemp(0, rawBottomY, 0, tempAnchors, IDW_POWER); + const tipHeat = tempToHeat(tipTemp, upperThreshold); + const [tr, tg, tb] = heatToRGB(tipHeat); + + positions[tipVertexIndex * 3] = 0; + positions[tipVertexIndex * 3 + 1] = rawBottomY; + positions[tipVertexIndex * 3 + 2] = 0; + colors[tipVertexIndex * 3] = tr; + colors[tipVertexIndex * 3 + 1] = tg; + colors[tipVertexIndex * 3 + 2] = tb; + heats[tipVertexIndex] = tipHeat; + + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg)); + } + } + + function makeGeo(indices: number[]) { + const g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + g.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + g.setIndex(indices); + return g; + } + + return { + green: makeGeo(green), + yellow: makeGeo(yellow), + red: makeGeo(red), + }; + }, [ + tempAnchors, + surfaceAnchors, + wallY, + maxGrainY, + hopperTipY, + sidewallBaseY, + binRadius, + upperThreshold, + hopperHeight, + flatMaxY, + ]); + + if (!geometry) return null; + + return ( + + + + + + + + + + + + + ); - g.setAttribute( - 'color', - new THREE.BufferAttribute(colors,3) - ); - g.setIndex(indices); - return g; - } - - return { - green:makeGeo(green), - yellow:makeGeo(yellow), - red:makeGeo(red) - }; - - },[ - anchors, - maxGrainY, - hopperTipY, - sidewallBaseY, - binRadius, - upperThreshold, - hopperHeight, - ]); - - if(!geometry) - return null; - return ( - - - - - - - - - - - - - - ); - -} \ No newline at end of file +} From b3cf5b5e83b47f52d7df2f7f7e784ce032898d95 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 30 Apr 2026 09:43:19 -0600 Subject: [PATCH 15/44] took out hardcoding of nodes for testing --- src/bin/3dView/Data/BuildNodeData.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 2aed727..4103ce5 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -45,12 +45,12 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const moisture = grainCable.moisture[i] || undefined; let t = celcius // for testing - if(i===0){ - t = 40 - } - if (i===5){ - t = 0 - } + // if(i===0){ + // t = 40 + // } + // if (i===5){ + // t = 0 + // } nodeData.push({ cableIndex: cableIndex, From 4b2c67dfd9dd8ded8d67f1f6a434e773cea31a3a Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 1 May 2026 13:11:12 -0600 Subject: [PATCH 16/44] started work on the bin summary part of the new bin page, built the framework and the new 3d visualizer, still doing some work on the camera to create an overlay with a reset button --- src/3dModels/CameraControls/CameraOverlay.tsx | 90 ++++ .../CameraControls/OrbitCameraControls.tsx | 203 ++++---- src/bin/3dView/Scene/Bin3dView.tsx | 75 ++- src/bin/3dView/Systems/Cables/BinCables.tsx | 6 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 96 ++-- src/bin/3dView/Systems/Cables/GrainCable.tsx | 6 +- src/bin/binSummary/BinSummary.tsx | 76 +++ .../binSummary/components/bin3dVisualizer.tsx | 105 ++++ src/navigation/Router.tsx | 8 + src/pages/Bin.tsx | 47 -- src/pages/BinV2.tsx | 482 ++++++++++++++++++ 11 files changed, 974 insertions(+), 220 deletions(-) create mode 100644 src/3dModels/CameraControls/CameraOverlay.tsx create mode 100644 src/bin/binSummary/BinSummary.tsx create mode 100644 src/bin/binSummary/components/bin3dVisualizer.tsx create mode 100644 src/pages/BinV2.tsx diff --git a/src/3dModels/CameraControls/CameraOverlay.tsx b/src/3dModels/CameraControls/CameraOverlay.tsx new file mode 100644 index 0000000..080aab9 --- /dev/null +++ b/src/3dModels/CameraControls/CameraOverlay.tsx @@ -0,0 +1,90 @@ +import { useThree } from "@react-three/fiber"; +import { useEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; + +interface Props { + showResetButton?: boolean; + onReset?: () => void; +} + +/** + * Renders camera control UI buttons as a DOM overlay on top of the R3F canvas. + * Must be placed inside a so it has access to useThree(). + * + * Uses a portal into a div that is absolutely positioned over the canvas element, + * so the buttons sit in 2D screen space rather than 3D world space. + */ +export default function CameraOverlay({ showResetButton, onReset }: Props) { + const { gl } = useThree(); + const [container, setContainer] = useState(null); + + useEffect(() => { + const canvas = gl.domElement; + const parent = canvas.parentElement; + if (!parent) return; + + // Ensure the parent is positioned so absolute children are relative to it + const parentPosition = getComputedStyle(parent).position; + if (parentPosition === "static") { + parent.style.position = "relative"; + } + + const div = document.createElement("div"); + div.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; + `; + parent.appendChild(div); + setContainer(div); + + return () => { + parent.removeChild(div); + }; + }, [gl]); + + if (!container) return null; + + return ReactDOM.createPortal( +
+ {showResetButton && ( + + )} +
, + container + ); +} \ No newline at end of file diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index 0385cdd..d145caa 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -1,6 +1,7 @@ +import React from "react"; import { useThree } from "@react-three/fiber"; import { useEffect, useRef } from "react"; -import { Plane, Raycaster, Vector2, Vector3 } from "three"; +import { Vector2, Vector3 } from "three"; interface CameraTarget { x: number; @@ -21,14 +22,37 @@ interface Props { * @default Math.PI - 0.01 */ maxPhi?: number; + /** + * Starting camera distance from the target. + * Pass a value computed from the bin dimensions so the whole bin + * fits in frame on first render. If omitted defaults to 10. + */ + initialRadius?: number; + /** + * Maximum zoom-out distance. Defaults to 30 but should be set + * larger when initialRadius is large. + */ + maxRadius?: number; + /** + * Called when the camera is reset to its initial position. + * OrbitCameraControls manages the reset internally — use this to + * trigger it from outside (e.g. from CameraOverlay's reset button). + * Wire it by passing a ref setter: onReset={fn => resetFn.current = fn} + */ + onReset?: (resetFn: () => void) => void; } export default function OrbitCameraControls(props: Props) { - const { target, clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01 } = props; + const { + target, + clampVerticalRotation, + minPhi = 0.01, + maxPhi = Math.PI - 0.01, + onReset, + } = props; + const { camera, gl } = useThree(); - // 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, @@ -43,14 +67,15 @@ export default function OrbitCameraControls(props: Props) { // Spherical coords const spherical = useRef({ - radius: 10, - theta: 0, // horizontal - phi: Math.PI / 2, // vertical + radius: props.initialRadius ?? 10, + theta: 0, // horizontal angle + phi: Math.PI / 2, // vertical angle }); useEffect(() => { const canvas = gl.domElement; canvas.addEventListener("contextmenu", e => e.preventDefault()); + const updateCamera = () => { const { radius, theta, phi } = spherical.current; @@ -70,66 +95,50 @@ export default function OrbitCameraControls(props: Props) { ); }; + // Hand the reset function to the caller so they can trigger it + // (e.g. from a button in CameraOverlay) without needing an external ref. + if (onReset) { + onReset(() => { + spherical.current.radius = props.initialRadius ?? 10; + spherical.current.theta = 0; + spherical.current.phi = Math.PI / 2; + targetRef.current = { + x: target?.x ?? 0, + y: target?.y ?? 0, + z: target?.z ?? 0, + }; + updateCamera(); + }); + } + updateCamera(); const handleMouseDown = (e: MouseEvent) => { 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) { isDragging.current = true; - isPanning.current = false; + isPanning.current = false; } - - // Right click = pan + if (e.button === 2) { - isPanning.current = true; + isPanning.current = true; isDragging.current = false; - - // plane facing camera through target - const camDir = new Vector3(); - camera.getWorldDirection(camDir); - - // panPlane.current.setFromNormalAndCoplanarPoint( - // camDir, - // new Vector3( - // targetRef.current.x, - // targetRef.current.y, - // targetRef.current.z - // ) - // ); - - // const rect = canvas.getBoundingClientRect(); - - // 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 - // ); } }; - - const handleMouseMove = (e: MouseEvent) => { const deltaX = e.clientX - lastPos.current.x; const deltaY = e.clientY - lastPos.current.y; - - // ROTATE + + // ROTATE — left drag if (isDragging.current) { spherical.current.theta -= deltaX * 0.005; - spherical.current.phi -= deltaY * 0.005; - + spherical.current.phi -= deltaY * 0.005; + if (clampVerticalRotation) { spherical.current.phi = Math.max( minPhi, @@ -137,72 +146,50 @@ 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); - // } + + // PAN — right drag + // Uses screen-space axes derived from the camera so panning is + // correct at any angle including top-down. if (isPanning.current) { - const deltaX = e.clientX - lastPos.current.x; - const deltaY = e.clientY - lastPos.current.y; - - // distance-based scaling (important for consistent feel) const distance = spherical.current.radius; - const panSpeed = 0.002 * distance; - - const offset = new Vector3(); - - // get camera basis vectors + + const forward = new Vector3(); + camera.getWorldDirection(forward); + + // Right vector — perpendicular to forward in the horizontal plane const right = new Vector3(); - const up = new Vector3(); - - camera.getWorldDirection(offset); // forward - right.crossVectors(offset, camera.up).normalize(); // right - up.copy(camera.up).normalize(); - - // apply movement + right.crossVectors(forward, new Vector3(0, 1, 0)).normalize(); + + // Degenerate guard: when camera is nearly straight up or down, + // fall back to camera.up + if (right.lengthSq() < 0.001) { + right.crossVectors(forward, camera.up).normalize(); + } + + // screenUp — perpendicular to both forward and right. + // This is the actual "up" direction on screen regardless of + // camera tilt, avoiding the zoom-while-panning bug. + const screenUp = new Vector3(); + screenUp.crossVectors(right, forward).normalize(); + right.multiplyScalar(-deltaX * panSpeed); - up.multiplyScalar(deltaY * panSpeed); - - const pan = new Vector3().addVectors(right, up); - + screenUp.multiplyScalar(deltaY * panSpeed); + + const pan = new Vector3().addVectors(right, screenUp); + targetRef.current.x += pan.x; targetRef.current.y += pan.y; targetRef.current.z += pan.z; } - + lastPos.current = { x: e.clientX, y: e.clientY }; updateCamera(); }; const handleMouseUp = () => { isDragging.current = false; - isPanning.current = false; + isPanning.current = false; }; const handleWheel = (e: WheelEvent) => { @@ -211,11 +198,9 @@ export default function OrbitCameraControls(props: Props) { e.preventDefault(); spherical.current.radius += e.deltaY * 0.01; - - // Clamp zoom - spherical.current.radius = Math.max( + spherical.current.radius = Math.max( 3, - Math.min(30, spherical.current.radius) + Math.min(props.maxRadius ?? 30, spherical.current.radius) ); updateCamera(); @@ -223,16 +208,14 @@ export default function OrbitCameraControls(props: Props) { window.addEventListener("mousedown", handleMouseDown); window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - canvas.addEventListener("wheel", handleWheel, { passive: false }); + window.addEventListener("mouseup", handleMouseUp); + canvas.addEventListener("wheel", handleWheel, { passive: false }); return () => { window.removeEventListener("mousedown", handleMouseDown); window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - - canvas.removeEventListener("wheel", handleWheel); + window.removeEventListener("mouseup", handleMouseUp); + canvas.removeEventListener("wheel", handleWheel); }; }, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi]); diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 68b5373..41ef6d6 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -1,5 +1,6 @@ - +import React from "react"; import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; +import CameraOverlay from "3dModels/CameraControls/CameraOverlay"; import { Canvas } from "@react-three/fiber"; import { Bin } from "models"; import BinShell from "../BinParts/BinShell"; @@ -13,28 +14,47 @@ import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; - + interface Props { bin: Bin scale: number fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void showGrain?: boolean - showHeatmap?: boolean + showTempHeatmap?: boolean + showMoistureHeatmap?: boolean showHotspots?: boolean + /** + * When true renders a reset camera button overlaid on the 3D view. + */ + showResetButton?: boolean + showTemp?: boolean + showMoisture?: boolean } - + export default function Bin3dView(props: Props) { - const { bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots } = props - + const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props + const binCenter = useMemo(() => new Vector3(0, 0, 0), []); + + // Compute the initial camera radius so the full bin fits in frame. + // Uses the perspective camera FOV (default 75°) with a padding factor. + // Both the diameter and total height are considered so neither is clipped. + const initialCameraRadius = useMemo(() => { + const totalHeight = (bin.sidewallHeight() + bin.roofHeight() + (bin.hopperHeight() ?? 0)) / scale; + const diameter = bin.diameter() / scale; + const largestDim = Math.max(totalHeight, diameter); + const fovRad = (75 * Math.PI) / 180; + const padding = 1.3; // 30% breathing room + return (largestDim / (2 * Math.tan(fovRad / 2))) * padding; + }, [bin, scale]); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); - + const isCableInventory = bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE; - + // For cable inventory, TempHeatMap derives the wavy surface directly from // the top nodes in nodeData — no scalar needed. // @@ -42,21 +62,24 @@ export default function Bin3dView(props: Props) { // using the same volume math as GrainFillFlat and pass it as flatMaxY. const flatMaxY = useMemo(() => { if (isCableInventory || fillPercent === undefined) return undefined; - + const radius = bin.diameter() / 2; const sidewallH = bin.sidewallHeight(); const hopperH = bin.hopperHeight() ?? 0; const cylVolume = Math.PI * radius * radius * sidewallH; const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0; const filled = (cylVolume + hopperVol) * fillPercent; - + const cylinderFillH = hopperH > 0 && filled <= hopperVol ? 0 : Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius)); - + return -sidewallH / 2 + cylinderFillH; }, [isCableInventory, fillPercent, bin]); - + + // Internal ref — wired to OrbitCameraControls and called by CameraOverlay + const resetCameraFn = React.useRef<(() => void) | null>(null); + const grainInventory = () => { if (isCableInventory) { return ( @@ -81,7 +104,7 @@ export default function Bin3dView(props: Props) { ) } } - + return ( @@ -97,10 +120,12 @@ export default function Bin3dView(props: Props) { hopperHeight={bin.hopperHeight()} renderOrder={4} /> - + {showGrain && grainInventory()} - + - + {showHotspots && } - - {showHeatmap && ( + + {showTempHeatmap && ( )} + - + - + { resetCameraFn.current = fn; }} + /> + {/* resetCameraFn.current?.()} + /> */} ) } diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 6adedd1..7480e46 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -11,11 +11,13 @@ interface Props { bin: Bin binCenter: Vector3, renderOrder?: number, + displayTemp?: boolean, + displayMoisture?: boolean, onNodeClick?: (node: NodeData, cable: CableData) => void } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props + const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props return ( {cableData.map((cable, i) => { @@ -28,6 +30,8 @@ export default function BinCables(props: Props){ nodes={cableNodes} bin={bin} binCenter={binCenter} + displayTemp={displayTemp} + displayMoisture={displayMoisture} onNodeClick={(node) => { if(onNodeClick){ onNodeClick(node, cable) diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index 080422a..99c5824 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -17,36 +17,39 @@ interface Props { lowerThreshold: number upperThreshold: number, renderOrder?: number, + displayTemp?: boolean, + displayMoisture?: boolean, onNodeClick?: (node: NodeData) => void } export default function CableNode(props: Props) { - const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder } = props + const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder, displayTemp, displayMoisture } = props const { camera } = useThree(); const [{ user }] = useGlobalState(); - const [showLabel, setShowLabel] = useState(false); + // const [showLabel, setShowLabel] = useState(false); const labelGroupRef = React.useRef(null); const ROW_HEIGHT = 35; const PADDING = 20; - useFrame(() => { - const distance = camera.position.distanceTo(binCenter) + //this can be used to control label visibility through the cameras zoom level + // useFrame(() => { + // const distance = camera.position.distanceTo(binCenter) - const SHOW_DISTANCE = 15; - const HIDE_DISTANCE = 16; + // const SHOW_DISTANCE = 15; + // const HIDE_DISTANCE = 16; - if (!showLabel && distance < SHOW_DISTANCE) { - setShowLabel(true); - } else if (showLabel && distance > HIDE_DISTANCE) { - setShowLabel(false); - } + // if (!showLabel && distance < SHOW_DISTANCE) { + // setShowLabel(true); + // } else if (showLabel && distance > HIDE_DISTANCE) { + // setShowLabel(false); + // } - const scale = distance * 0.07; + // const scale = distance * 0.07; - if (labelGroupRef.current) { - labelGroupRef.current.scale.set(scale, scale, 1); - } - }); + // if (labelGroupRef.current) { + // labelGroupRef.current.scale.set(scale, scale, 1); + // } + // }); const tempDisplay = useMemo(() => { if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { @@ -56,36 +59,47 @@ export default function CableNode(props: Props) { }, [node.celcius, user]); const rows = useMemo(() => { - const r: { text: string; color: string }[] = []; - + const anyToggleOn = displayTemp || displayMoisture; + if (node.excluded) { - r.push({ text: "Excluded", color: "red" }); - return r; + if (!anyToggleOn) return []; + return [{ text: "Excluded", color: "red" }]; } - - if (node.topNode) { - r.push({ text: "Top Node", color: "yellow" }); - } - - r.push({ - text: tempDisplay, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() - }); - - if (node.moisture) { + + const r: { text: string; color: string }[] = []; + + // TEMP + if (displayTemp && node.celcius !== 0) { r.push({ - text: `${node.moisture.toFixed(2)}% EMC`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() - }); - } else if (node.humidity) { - r.push({ - text: `${node.humidity.toFixed(2)}%`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + text: tempDisplay, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() }); } - + + // MOISTURE + if (displayMoisture) { + if (node.moisture && node.moisture > 0) { + r.push({ + text: `${node.moisture.toFixed(2)}% EMC`, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() + }); + } else if (node.humidity && node.humidity > 0) { + r.push({ + text: `${node.humidity.toFixed(2)}%`, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + }); + } + } + + const hasData = r.length > 0; + + // Only show top node if there's actual data + if (node.topNode && hasData) { + r.unshift({ text: "Top Node", color: "yellow" }); + } + return r; - }, [node, tempDisplay]); + }, [node, tempDisplay, displayTemp, displayMoisture]); const geometry = React.useMemo(() => ({ radius: node.radius, @@ -126,6 +140,8 @@ export default function CableNode(props: Props) { onNodeClick?.(node); }; + const showLabel = rows.length > 0; + return ( {(!node.inGrain || !showLabel) && diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 938dc63..73add6a 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -12,10 +12,12 @@ interface Props { bin: Bin binCenter: Vector3, renderOrder?: number, + displayTemp?: boolean, + displayMoisture?: boolean onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props + const {cable, nodes, bin, binCenter, onNodeClick, renderOrder, displayTemp, displayMoisture} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -42,7 +44,7 @@ export default function GrainCable(props: Props) { {nodes.map((node, i) => ( - + ))} ) diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx new file mode 100644 index 0000000..88f9b51 --- /dev/null +++ b/src/bin/binSummary/BinSummary.tsx @@ -0,0 +1,76 @@ +import { Box, Card, Grid2 } from "@mui/material"; +import { useMobile } from "hooks"; +import { Bin } from "models"; +import Bin3dVisualizer from "./components/bin3dVisualizer"; + +interface Props { + bin: Bin +} +export default function BinSummary(props: Props){ + const {bin} = props + const isMobile = useMobile() + //need to decide if the vies shoulw both be displayed or toggled between + const bin3dView = () => { + return ( + + ) + } + + const tableView = () => { + return ( + table View + ) + } + + const inventoryOverView = () => { + return ( + inventory overview + ) + } + + const sensorData = () => { + return ( + sensor data + ) + } + + const controllerStatus = () => { + return ( + controller status + ) + } + + + + return ( + + + + + {bin3dView()} + + + + + {tableView()} + + + + + {inventoryOverView()} + + + + + {sensorData()} + + + + + {controllerStatus()} + + + + + ) +} \ No newline at end of file diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx new file mode 100644 index 0000000..62cf814 --- /dev/null +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -0,0 +1,105 @@ +import { Box, Checkbox, Grid2, FormControlLabel } from "@mui/material"; +import Bin3dView from "bin/3dView/Scene/Bin3dView"; +import { Bin } from "models"; +import { useState } from "react"; + +interface Props { + bin: Bin +} +export default function bin3dVisualizer(props: Props){ + const {bin} = props + const [showHeatmap, setShowHeatmap] = useState(true) + const [showHotspots, setShowHotspots] = useState(false) + const [showFill, setShowFill] = useState(false) + const [showTemp, setShowTemp] = useState(false) + const [showMoisture, setShowMoisture] = useState(false) + + const getFill = () => { + //calculate the fill percent of the bin when using manual/lidar for inventory + + return 0 + } + + + return ( + + + + { + setShowHeatmap(checked); + }} + /> + } + label={"Toggle Heatmap"} + /> + { + setShowHotspots(checked); + }} + /> + } + label={"Toggle Hotspots"} + /> + { + setShowFill(checked); + }} + /> + } + label={"Toggle Hotspots"} + /> + { + setShowTemp(checked); + }} + /> + } + label={"Toggle Temp"} + /> + { + setShowMoisture(checked); + }} + /> + } + label={"Toggle Moisture"} + /> + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx index 94ce101..066b5be 100644 --- a/src/navigation/Router.tsx +++ b/src/navigation/Router.tsx @@ -20,6 +20,7 @@ const Teams = lazy(() => import("pages/Teams")); const Users = lazy(() => import("pages/Users")); const TeamPage = lazy(() => import("pages/Team")); const BinPage = lazy(() => import("pages/Bin")); +const BinPageV2 = lazy(()=> import("pages/BinV2")); const Bins = lazy(() => import("pages/Bins")); const Mines = lazy(() => import("pages/Mines")); const DeviceComponent = lazy(() => import("pages/DeviceComponent")); @@ -139,10 +140,17 @@ export default function Router() { path="" // "/settings/basic" element={} /> + {user.hasFeature("beta") ? + } + /> + : } /> + } {/* } diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 7531ab0..f410bd2 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -816,53 +816,6 @@ export default function Bin(props: Props) { {overview()} {preferences && binComponents(preferences)} - - - { - setFillPercent(+e.target.value) - }} - /> - { - setShowGrain(checked); - }} - /> - } - label={"Inventory Toggle"} - /> - { - setShowHotspots(checked); - }} - /> - } - label={"Hot Spots"} - /> - { - setShowHeatmap(checked); - }} - /> - } - label={"Heatmap"} - /> - - - {(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING || diff --git a/src/pages/BinV2.tsx b/src/pages/BinV2.tsx new file mode 100644 index 0000000..39691e6 --- /dev/null +++ b/src/pages/BinV2.tsx @@ -0,0 +1,482 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import PageContainer from "./PageContainer"; +import { DevicePreset } from "models/DevicePreset"; +import { Controller } from "models/Controller"; +import { useNavigate, useParams } from "react-router-dom"; +import { useMobile } from "hooks"; +import { Component, Device, Bin as IBin, Interaction } from "models"; +import { useBinAPI, useGlobalState } from "providers"; +import { pond } from "protobuf-ts/pond"; +import { Plenum } from "models/Plenum"; +import { Ambient } from "models/Ambient"; +import { GrainCable } from "models/GrainCable"; +import { Pressure } from "models/Pressure"; +import { CO2 } from "models/CO2"; +import moment from "moment"; +import { quack } from "protobuf-ts/quack"; +import { Box, Drawer, Grid2 as Grid, IconButton, Theme, Tooltip } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import NotesIcon from "@mui/icons-material/Notes"; +import TasksIcon from "products/Construction/TasksIcon"; +import BindaptIcon from "products/Bindapt/BindaptIcon"; +import FieldsIcon from "products/AgIcons/FieldsIcon"; +import BinActions from "bin/BinActions"; +import BinSummary from "bin/binSummary/BinSummary"; + +const useStyles = makeStyles((theme: Theme) => { + const themeType = theme.palette.mode; + const activeBG = theme.palette.secondary.main; + return ({ + spacer: { + width: "32px", + height: "32px", + padding: "auto", + display: "flex", + justifyContent: "center", + alignItems: "center" + }, + avatar: { + color: themeType === "light" ? theme.palette.common.black : theme.palette.common.white, + backgroundColor: "transparent", + width: theme.spacing(5), + height: theme.spacing(5), + border: "1px solid", + borderColor: theme.palette.divider + }, + }) +}) + + +export default function BinV2(){ + const warningThreshold = 6; + const isMobile = useMobile(); + const binID = useParams<{ binID: string }>()?.binID ?? ""; + const binAPI = useBinAPI(); + const classes = useStyles() + const navigate = useNavigate() + const [{ user, as, showErrors }] = useGlobalState(); + const [binLoading, setBinLoading] = useState(false); + const loadRef = useRef(false); + const [bin, setBin] = useState(IBin.create()); + const [devices, setDevices] = useState([]); + const [components, setComponents] = useState>(new Map()); + const [interactions, setInteractions] = useState([]); + const [permissions, setPermissions] = useState([]); + const [preferences, setPreferences] = useState>(); + const [plenums, setPlenums] = useState([]); + const [ambients, setAmbients] = useState([]); + const [grainCables, setGrainCables] = useState([]); + const [pressures, setPressures] = useState([]); + const [headspaceCO2, setHeadspaceCO2] = useState([]); + const [heaters, setHeaters] = useState([]); + const [fans, setFans] = useState([]); + const [compositionNameMap, setCompositionNameMap] = useState>(new Map()); + const [anchorEl, setAnchorEl] = useState(null); + + + const [componentDevices, setComponentDevices] = useState>( + new Map() + ); + const [interactionDevices, setInteractionDevices] = useState>( + new Map() + ); + const [binPresets, setBinPresets] = useState([]); + const [missedReadings, setMissedReadings] = useState(0); + + const [currentSection, setCurrentSection] = useState<"binSum">("binSum") + + //Drawer states + const [noteDrawerOpen, setNoteDrawerOpen] = useState(false) + const [taskDrawerOpen, setTaskDrawerOpen] = useState(false) + + //DATA LOAD/UPDATE FUNCTIONS + + const load = useCallback(() => { + if (loadRef.current || user.id() === "") return; + setBinLoading(true); + loadRef.current = true; + //add the presets to the bin page data load + binAPI + .getBinPageData(binID, user.id(), showErrors, as) + .then(resp => { + if (resp.data.grainCompositionNames) { + let tempMap: Map = new Map(); + Object.keys(resp.data.grainCompositionNames).forEach(key => { + tempMap.set(key, resp.data.grainCompositionNames[key]); + }); + tempMap.set("correction", "Unknown Source"); + setCompositionNameMap(tempMap); + } + + let devs: Device[] = []; + let p = new Map(); + Object.keys(resp.data.preferences).forEach(k => { + let prefKey = k.split(":")[1] ? k.split(":")[1] : k; + p.set(prefKey, pond.BinComponentPreferences.fromObject(resp.data.preferences[k])); + }); + setPreferences(p); + let r: pond.GetBinPageDataResponse = pond.GetBinPageDataResponse.fromObject(resp.data); + setBin(IBin.any(resp.data.bin)); + let newComponentDevices = new Map(); + let attachedDevIds: number[] = []; + if (resp.data.componentDevices) + Object.keys(resp.data.componentDevices).forEach(k => { + newComponentDevices.set(k, resp.data.componentDevices[k]); + //make a list of all of the ids for devices that components are currently attached + if (!attachedDevIds.includes(resp.data.componentDevices[k])) { + attachedDevIds.push(resp.data.componentDevices[k]); + } + }); + setComponentDevices(newComponentDevices); + + let newInteractionDevices = new Map(); + if (r.interactionDevices) + Object.keys(r.interactionDevices).forEach(k => { + newInteractionDevices.set(k, r.interactionDevices[k]); + }); + setInteractionDevices(newInteractionDevices); + + if (resp.data.interactions) { + setInteractions(resp.data.interactions.map((i: pond.Interaction) => Interaction.any(i))); + } else { + setInteractions([]); + } + //go through the devices and if the device has an attached component include it in the device list + if (resp.data.devices && resp.data.devices[0]) { + resp.data.devices.forEach((dev: any) => { + let device = Device.create(dev); + if (attachedDevIds.includes(device.id())) { + devs.push(device); + } + }); + setDevices(devs); + } + if (resp.data.components) { + let compMap: Map = new Map(); + resp.data.components.forEach((comp: pond.Component) => { + if (comp && comp.settings) { + let c = Component.any(comp); + compMap.set(comp.settings.key, c); + } + }); + setComponents(compMap); + } + if (resp.data.presets) { + setBinPresets( + resp.data.presets.map((preset: pond.DevicePreset) => DevicePreset.create(preset)) + ); + } + setPermissions(r.permissions ? r.permissions : []); + setBinLoading(false); + loadRef.current = false; + }) + .finally(() => { + setBinLoading(false); + loadRef.current = false; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [binID, user.id(), binAPI, showErrors, as]); + + useEffect(() => { + load(); + }, [load]); + + const setBinComponents = useCallback(() => { + if (!preferences) return; + var unassigned: Component[] = []; + var plenums: Plenum[] = []; + var ambients: Ambient[] = []; + var grainCables: GrainCable[] = []; + var fans: Controller[] = []; + var heaters: Controller[] = []; + var pressures: Pressure[] = []; + var headspaceCo2: CO2[] = []; + var mostMissed: number = 0; + let now = moment(); + + components.forEach(comp => { + let pref = preferences.get(comp.key()); + if (pref) { + if (pref.type) { + if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM) + plenums.push(Plenum.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT) + ambients.push(Ambient.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE) { + //check cable for missed measurements + let cable = GrainCable.create(comp); + let lastRead = moment(cable.lastReading); + let elapsedMS = now.diff(lastRead); + if (elapsedMS > cable.settings.measurementPeriodMs) { + let missedReadings = Math.floor(elapsedMS / cable.settings.measurementPeriodMs); + if (missedReadings > mostMissed) { + mostMissed = missedReadings; + } + } + grainCables.push(cable); + } + if (pref.type === pond.BinComponent.BIN_COMPONENT_HEATER) + heaters.push(Controller.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) fans.push(Controller.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_PRESSURE) + pressures.push(Pressure.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_HEADSPACE) { + if (comp.type() === quack.ComponentType.COMPONENT_TYPE_CO2) { + //check if there are missed readings from the co2 and compare them to what we already had + let coComp = CO2.create(comp) + let lastRead = moment(coComp.lastReading); + let elapsedMS = now.diff(lastRead); + if (elapsedMS > coComp.settings.measurementPeriodMs) { + let missedReadings = Math.floor(elapsedMS / coComp.settings.measurementPeriodMs); + if (missedReadings > mostMissed) { + mostMissed = missedReadings; + } + } + headspaceCo2.push(coComp); + } + } + } else { + unassigned.push(comp); + } + } + }); + setMissedReadings(mostMissed); + setPlenums(plenums); + setGrainCables(grainCables); + setPressures(pressures); + setAmbients(ambients); + setHeaters(heaters); + setFans(fans); + setHeadspaceCO2(headspaceCo2); + }, [components, preferences]); + + useEffect(() => { + setBinComponents(); + }, [setBinComponents]); + + const updateStatus = (componentKeys: string[], removed?: boolean) => { + componentKeys.forEach(compKey => { + let comp = components.get(compKey); + if (comp) { + //determine what part of the status to update based on the component + //for lidar + if (comp.type() === quack.ComponentType.COMPONENT_TYPE_LIDAR) { + //determine how to update the status based on if added or removed + if (removed) { + bin.status.distance = 0; + } else { + if ( + comp.lastMeasurement[0] && + comp.lastMeasurement[0].values[0] && + comp.lastMeasurement[0].values[0].values[0] + ) { + bin.status.distance = comp.lastMeasurement[0].values[0].values[0]; + } + } + } + } + }); + //update the bins status with the new values based on the components changed + binAPI.updateBinStatus(bin.key(), bin.status, as); + }; + + //NAVIGATION FUNCTIONS + const goToDevice = (dev: Device) => { + navigate(window.location.pathname + "/devices/" + dev.id(), { replace: true }); + }; + + const goToYard = (bin: IBin) => { + navigate("/visualFarm", { state: { + long: bin.getLocation()?.longitude, + lat: bin.getLocation()?.latitude + } + }); + }; + + + /** + * UI STUFF STARTS HERE + */ + + /** + * Header - consists of the actions and things that are always visible like + * opening the note/history and tasks drawers, navigating to the device and map pages, opening binsensors and settings etc. + * also has the bin name/model and activity and the dropdown for changing sections + */ + const pageHeader = () => { + return ( + + + + + setNoteDrawerOpen(true) + } + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> +
+ +
+
+ + setTaskDrawerOpen(true) + } + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> +
+ +
+
+ {devices.length > 1 ? ( + + ) => + setAnchorEl(event.currentTarget) + } + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> + + + + ) : ( + devices.map(dev => ( + + goToDevice(dev)} + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> +
+ +
+
+
+ )) + )} + {bin.binMapped() && ( + + { + goToYard(bin); + }} + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> + + + + )} +
+ + { + load(); + }} + userID={user.id()} + components={components} + setComponents={setComponents} + componentDevices={componentDevices} + setComponentDevices={setComponentDevices} + updateBinStatus={updateStatus} + /> + +
+
+ ) + } + + //PAGE SECTIONS + + /** + * Contains the main summary of your bin as it currently is + * has the 3D bin model, a switch to go to the table view + * the bin conditions if it is in a mode other than storage (the do nothing mode) + * a box underneath to display sensor data, for the plenums and such + * as well as the controls for attached controllers + */ + const binSummarySection = () => { + return ( + + ) + } + + /** + * this sections is for showing attached bin components and assigning them to bin positions (see old pages bin components) + * as well as seeing the graph data for attached sensors + */ + const sensorsSection = () => {} + + //Inventory + /** + * this section is for seeing information relating to the bins inventory for its fill level over time and transactions related to inventory of the bin + */ + const InventorySection = () => {} + + //Analysis + /** + * this section is for the analytical charts (drying/hydrating, Moisture trending, Grain Water Content) + */ + const analysisSection = () => {} + + //Interactions + /** + * this is the section for alerts and controls on the bin, if presets get re-implemented they can go here too + */ + const interactionsSection = () => {} + + + //DRAWERS + const noteDrawer = () => { + return ( + + + + ) + } + + const taskDrawer = () => { + return ( + + + + ) + } + + return ( + + {pageHeader()} + {currentSection === "binSum" && binSummarySection()} + {/* render drawers */} + {noteDrawer()} + {taskDrawer()} + + ) +} \ No newline at end of file From 325d3ea7a0a468114e47b524ba4ff1172aea5c3c Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 4 May 2026 09:25:43 -0600 Subject: [PATCH 17/44] made binTableView component, and am starting to average node levels --- src/bin/binSummary/BinSummary.tsx | 3 +- .../binSummary/components/binTableView.tsx | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/bin/binSummary/components/binTableView.tsx diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 88f9b51..a4777e7 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -2,6 +2,7 @@ import { Box, Card, Grid2 } from "@mui/material"; import { useMobile } from "hooks"; import { Bin } from "models"; import Bin3dVisualizer from "./components/bin3dVisualizer"; +import BinTableView from "./components/binTableView"; interface Props { bin: Bin @@ -18,7 +19,7 @@ export default function BinSummary(props: Props){ const tableView = () => { return ( - table View + ) } diff --git a/src/bin/binSummary/components/binTableView.tsx b/src/bin/binSummary/components/binTableView.tsx new file mode 100644 index 0000000..42d4fb9 --- /dev/null +++ b/src/bin/binSummary/components/binTableView.tsx @@ -0,0 +1,37 @@ +import { Box } from "@mui/material"; +import { Bin } from "models"; +import { useMemo } from "react"; + +interface Props { + bin: Bin +} + +interface LevelAvg { + temp: number + moisture: number + inGrain: boolean + +} + + +export default function BinTableView(props: Props) { + const {bin} = props + + const cableLevelAverages = useMemo(() => { + const cables = bin.status.grainCables + let levels: LevelAvg[] = [] + console.log(cables) + //note for future self, the first item in the measurement arrays is the lowest node on the cable + //the zones we are creating here will match with the node number because most cables will either have the same number of nodes or only have a difference of 1 or 2 + //example: cable 1 has 5 nodes cable 2 has 7 nodes, the first five nodes of the cables will match up and average together for each zone + //and then zones 6 and 7 will be made using only cable 2 because cable 1 doesn't have them + cables.forEach(cable => { + + }) + return levels + },[bin]) + + return ( + Table View + ) +} \ No newline at end of file From 619f9b7bf45b8f6d7fa4bcb7449e8e175653e5ce Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 6 May 2026 13:08:04 -0600 Subject: [PATCH 18/44] some tweaks to the 3d bin and nodes, finished up the table view --- src/bin/3dView/Data/BuildNodeData.ts | 2 +- src/bin/3dView/Scene/Bin3dView.tsx | 4 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 11 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 170 +++++++++--------- src/bin/3dView/Systems/Cables/GrainCable.tsx | 13 +- src/bin/binSummary/BinSummary.tsx | 4 +- .../binSummary/components/bin3dVisualizer.tsx | 79 +++++--- .../binSummary/components/binTableView.tsx | 150 ++++++++++++++-- src/models/Bin.ts | 4 + 9 files changed, 291 insertions(+), 146 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 4103ce5..fd3f5dd 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -21,7 +21,7 @@ export interface NodeData { } export function BuildNodeData(cables: CableData[]): NodeData[] { - const nodeRadius = 20 + const nodeRadius = 15 let nodeData: NodeData[] = [] cables.forEach((cable, cableIndex) => { diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 41ef6d6..cc2ae26 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -14,6 +14,7 @@ import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import { Box } from "@mui/material"; interface Props { bin: Bin @@ -35,8 +36,6 @@ interface Props { export default function Bin3dView(props: Props) { const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props - const binCenter = useMemo(() => new Vector3(0, 0, 0), []); - // Compute the initial camera radius so the full bin fits in frame. // Uses the perspective camera FOV (default 75°) with a padding factor. // Both the diameter and total height are considered so neither is clipped. @@ -129,7 +128,6 @@ export default function Bin3dView(props: Props) { cableData={cableData} nodeData={nodeData} bin={bin} - binCenter={binCenter} onNodeClick={(node, cable) => { if (nodeClick) nodeClick(node, cable) }} diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 7480e46..1901178 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -1,15 +1,13 @@ 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, + cableData: CableData[], + nodeData: NodeData[], + bin: Bin, renderOrder?: number, displayTemp?: boolean, displayMoisture?: boolean, @@ -17,7 +15,7 @@ interface Props { } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props + const {bin, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props return ( {cableData.map((cable, i) => { @@ -29,7 +27,6 @@ export default function BinCables(props: Props){ cable={cable} nodes={cableNodes} bin={bin} - binCenter={binCenter} displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={(node) => { diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index 99c5824..ecaaf18 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -1,21 +1,18 @@ 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 { Text } from "@react-three/drei" +import React, { useMemo } from "react" import { Euler, Group, Vector3 } from "three" -import { describeMeasurement } from "pbHelpers/MeasurementDescriber" -import { quack } from "protobuf-ts/quack" +import { green, grey, orange, red } from "@mui/material/colors"; import { useGlobalState } from "providers" import { pond } from "protobuf-ts/pond" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" import { NodeData } from "../../Data/BuildNodeData" -import { TempToColour } from "bin/3dView/utils/tempToColour" interface Props { node: NodeData - binCenter: Vector3 - lowerThreshold: number upperThreshold: number, + targetMoisture: number, renderOrder?: number, displayTemp?: boolean, displayMoisture?: boolean, @@ -23,33 +20,31 @@ interface Props { } export default function CableNode(props: Props) { - const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder, displayTemp, displayMoisture } = props + const { node, upperThreshold, targetMoisture, onNodeClick, renderOrder, displayTemp, displayMoisture } = props const { camera } = useThree(); const [{ user }] = useGlobalState(); // const [showLabel, setShowLabel] = useState(false); - const labelGroupRef = React.useRef(null); - const ROW_HEIGHT = 35; - const PADDING = 20; + const ROW_HEIGHT = 45; + const PADDING = 15; + const LABEL_WIDTH = 220; + const flagRef = React.useRef(null); - //this can be used to control label visibility through the cameras zoom level - // useFrame(() => { - // const distance = camera.position.distanceTo(binCenter) - - // const SHOW_DISTANCE = 15; - // const HIDE_DISTANCE = 16; - - // if (!showLabel && distance < SHOW_DISTANCE) { - // setShowLabel(true); - // } else if (showLabel && distance > HIDE_DISTANCE) { - // setShowLabel(false); - // } - - // const scale = distance * 0.07; - - // if (labelGroupRef.current) { - // labelGroupRef.current.scale.set(scale, scale, 1); - // } - // }); + useFrame(() => { + if (!flagRef.current || !showLabel) return; + + // Get camera's right direction in world space + const cameraRight = new Vector3(); + camera.getWorldDirection(cameraRight); // forward + const up = new Vector3(0, 1, 0); + cameraRight.crossVectors(cameraRight, up).normalize(); // right = forward × up + + const pos = node.position.clone(); + pos.addScaledVector(cameraRight, LABEL_WIDTH / 2); // offset right in screen space + pos.addScaledVector(camera.position.clone().sub(node.position).normalize(), 1); // nudge toward camera + + flagRef.current.position.copy(pos); + flagRef.current.quaternion.copy(camera.quaternion); + }); const tempDisplay = useMemo(() => { if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { @@ -63,7 +58,7 @@ export default function CableNode(props: Props) { if (node.excluded) { if (!anyToggleOn) return []; - return [{ text: "Excluded", color: "red" }]; + return [{ text: "Excluded", color: grey[700] }]; } const r: { text: string; color: string }[] = []; @@ -72,7 +67,9 @@ export default function CableNode(props: Props) { if (displayTemp && node.celcius !== 0) { r.push({ text: tempDisplay, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() + // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() + //will be green when below threshold, orange when it goeas above but by less than 5 degrees, and red if more than 5 over + color: node.celcius > upperThreshold ? node.celcius > upperThreshold + 5 ? red[700] : orange[800]: green[800] }); } @@ -81,21 +78,25 @@ export default function CableNode(props: Props) { if (node.moisture && node.moisture > 0) { r.push({ text: `${node.moisture.toFixed(2)}% EMC`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() + // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() + color: node.moisture > targetMoisture ? node.moisture > targetMoisture + 0.5 ? red[700] : orange[800]: green[800] }); - } else if (node.humidity && node.humidity > 0) { - r.push({ - text: `${node.humidity.toFixed(2)}%`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() - }); - } + } + //apparently we dont want to display humidity at all + // else if (node.humidity && node.humidity > 0) { + // r.push({ + // text: `${node.humidity.toFixed(2)}%`, + // // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + // color: "black" + // }); + // } } const hasData = r.length > 0; // Only show top node if there's actual data if (node.topNode && hasData) { - r.unshift({ text: "Top Node", color: "yellow" }); + r.unshift({ text: "Top Node", color: grey[700] }); } return r; @@ -117,12 +118,8 @@ export default function CableNode(props: Props) { 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(); - labelPosition.add(dir.multiplyScalar(1)); - const getRowY = (index: number) => { const totalHeight = rows.length * ROW_HEIGHT; return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2; @@ -130,9 +127,7 @@ export default function CableNode(props: Props) { const nodeColour = () => { if (node.excluded) return "grey"; - if (!node.inGrain) return "white"; - const c = TempToColour(node, lowerThreshold, upperThreshold, "node"); - return c !== null ? c.getStyle() : "white"; + return "white"; }; const handleClick = (e: any) => { @@ -144,49 +139,46 @@ export default function CableNode(props: Props) { return ( - {(!node.inGrain || !showLabel) && - - - {node.topNode && ( - - )} - - } + + + {node.topNode && ( + + )} + {node.inGrain && showLabel && - - - - - - - {rows.map((row, i) => ( - - {row.text} - - ))} - - - } + + + + + + {rows.map((row, i) => ( + + {row.text} + + ))} + +} ) } \ No newline at end of file diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 73add6a..7c3aa3b 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -7,17 +7,16 @@ import { CableData } from "../../Data/BuildCableData" import { NodeData } from "../../Data/BuildNodeData" interface Props { - cable: CableData - nodes: NodeData[] - bin: Bin - binCenter: Vector3, + cable: CableData, + nodes: NodeData[], + bin: Bin, renderOrder?: number, displayTemp?: boolean, - displayMoisture?: boolean + displayMoisture?: boolean, onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter, onNodeClick, renderOrder, displayTemp, displayMoisture} = props + const {cable, nodes, bin, onNodeClick, renderOrder, displayTemp, displayMoisture} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -44,7 +43,7 @@ export default function GrainCable(props: Props) { {nodes.map((node, i) => ( - + ))} ) diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index a4777e7..737f125 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -47,12 +47,12 @@ export default function BinSummary(props: Props){ - + {bin3dView()} - + {tableView()} diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx index 62cf814..5da0a3e 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -1,6 +1,7 @@ -import { Box, Checkbox, Grid2, FormControlLabel } from "@mui/material"; +import { Box, Checkbox, FormControlLabel, List, ListItem } from "@mui/material"; import Bin3dView from "bin/3dView/Scene/Bin3dView"; import { Bin } from "models"; +import React from "react"; import { useState } from "react"; interface Props { @@ -19,13 +20,13 @@ export default function bin3dVisualizer(props: Props){ return 0 } - - return ( - - - - { + return ( + + + + - + + - + + - + + - + + + - - - - - - - + + + + + + + + ) + } + + const binViewer = () => { + return ( + + ) + } + + return ( + + + {binViewer()} + + {controlsOverlay()} + + ) } \ No newline at end of file diff --git a/src/bin/binSummary/components/binTableView.tsx b/src/bin/binSummary/components/binTableView.tsx index 42d4fb9..daa76ff 100644 --- a/src/bin/binSummary/components/binTableView.tsx +++ b/src/bin/binSummary/components/binTableView.tsx @@ -1,37 +1,165 @@ -import { Box } from "@mui/material"; +import { Box, Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material"; +import { green, grey, orange, red } from "@mui/material/colors"; import { Bin } from "models"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; +import React from "react"; import { useMemo } from "react"; +import { avg, celsiusToFahrenheit } from "utils"; interface Props { bin: Bin } -interface LevelAvg { - temp: number - moisture: number - inGrain: boolean - +interface BinLevel { + grainTemps: number[] + airTemps: number[] + grainMoistures: number[] + airMoistures: number[] } export default function BinTableView(props: Props) { const {bin} = props + const [{user}] = useGlobalState() - const cableLevelAverages = useMemo(() => { + const binLevels = useMemo(() => { const cables = bin.status.grainCables - let levels: LevelAvg[] = [] - console.log(cables) + let levels: BinLevel[] = [] //note for future self, the first item in the measurement arrays is the lowest node on the cable //the zones we are creating here will match with the node number because most cables will either have the same number of nodes or only have a difference of 1 or 2 //example: cable 1 has 5 nodes cable 2 has 7 nodes, the first five nodes of the cables will match up and average together for each zone //and then zones 6 and 7 will be made using only cable 2 because cable 1 doesn't have them cables.forEach(cable => { - + cable.celcius.forEach((c, index) => { + //remember index will be 0 based but the top node starts at 1 for the lowest cable + let inGrain = index < cable.topNode + if (levels[index]){ + if(cable.excludedNodes.includes(index+1)) return + if(inGrain){ + levels[index].grainTemps.push(c) + //need to check that the cable not only has the moisture mutation on it, but also that it reads humidity + //cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + //based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case + //also note that we are only doing moisture and not humidity, it seems like users dont care about humidity + levels[index].grainMoistures.push(cable.moisture[index] ?? 0) + } + }else{ + levels[index].airTemps.push(c) + //need to check that the cable not only has the moisture mutation on it, but also that it reads humidity + //cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + //based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case + levels[index].airMoistures.push(cable.moisture[index] ?? 0) + } + } + }else{ + let newlevel: BinLevel = { + grainTemps: [], + grainMoistures: [], + airTemps: [], + airMoistures: [] + } + if(!cable.excludedNodes.includes(index)){ + if(inGrain){ + newlevel.grainTemps.push(c) + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + newlevel.grainMoistures.push(cable.moisture[index] ?? 0) + } + }else{ + newlevel.airTemps.push(c) + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + newlevel.airMoistures.push(cable.moisture[index] ?? 0) + } + } + } + levels.push(newlevel) + } + + }) }) + //flip the levels so that the highest nodes are on the top + levels.reverse() return levels },[bin]) + const levelDisplay = (level: BinLevel) => { + let tempDisplay = "--" + let moistureDisplay = "--" + let lvlTemp + let lvlMoisture + let tempColour: string = grey[600] + let moistureColour: string = grey[600] + + //determine the temp + if(level.grainTemps.length > 0){ + lvlTemp = avg(level.grainTemps) + //if the average temp is 5 degrees above the bins threshold + if(lvlTemp > (bin.upperTempThreshold() + 5)){ + tempColour = red[700] + }else if(lvlTemp > bin.upperTempThreshold()){ + //if the average temp is greater than 5 degrees over + tempColour = orange[600] + }else { + tempColour = green[300] + } + }else if (level.airTemps.length > 0){ + lvlTemp = avg(level.airTemps) + } + if (lvlTemp){ + if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){ + lvlTemp = celsiusToFahrenheit(lvlTemp) + } + tempDisplay = lvlTemp.toFixed(2) + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " F" : " C") + } + + //determine the moisture to show + if(level.grainMoistures.length > 0){ + lvlMoisture = avg(level.grainMoistures) + if(lvlMoisture > (bin.targetMoisture() + 1.5)){ + moistureColour = red[700] + }else if(lvlMoisture > bin.targetMoisture()){ + //if the average temp is greater than 5 degrees over + moistureColour = orange[600] + }else { + moistureColour = green[300] + } + }else if (level.airMoistures.length > 0){ + lvlMoisture = avg(level.airMoistures) + } + if(lvlMoisture){ + moistureDisplay = lvlMoisture.toFixed(2) + "%" + } + return ( + + {tempDisplay} + {moistureDisplay} + + ) + } + + + return ( - Table View + + + + + Level + Temperature + Moisture + + + + {binLevels.map((level, i) => ( + + {binLevels.length - i} + {levelDisplay(level)} + + ))} + +
+
) } \ No newline at end of file diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 475958c..6aa2389 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -292,4 +292,8 @@ export class Bin { public lowerTempThreshold(): number { return this.settings.lowTemp } + + public targetMoisture(): number { + return this.settings.inventory?.targetMoisture ?? 0 + } } From f54d4b53758b466242f19657ef2b970373b2edec Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 13 May 2026 14:32:52 -0600 Subject: [PATCH 19/44] changed the orbital camera to allow for an offset without messing with the rotation, added a moisture heatmap, updated the bin3dvisualizer to look cleaner, re-implemented a new version of the dialog box that pops up when clicking a node --- .../CameraControls/OrbitCameraControls.tsx | 35 +- src/3dModels/Shapes/BaseMesh.tsx | 2 +- src/bin/3dView/Data/BuildCableData.ts | 1 + src/bin/3dView/Scene/Bin3dView.tsx | 41 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 6 +- src/bin/3dView/Systems/Cables/GrainCable.tsx | 2 +- .../Systems/Heatmap/MoistureHeatmap.tsx | 446 ++++++++++++++++++ .../3dView/Systems/Heatmap/TempHeatMap.tsx | 31 +- src/bin/binSummary/BinSummary.tsx | 152 +++++- .../binSummary/components/bin3dVisualizer.tsx | 364 ++++++++++---- .../binSummary/components/binTableView.tsx | 1 + .../binSummary/components/nodeControls.tsx | 406 ++++++++++++++++ src/models/Bin.ts | 19 +- src/pages/BinV2.tsx | 45 +- src/pbHelpers/Component.ts | 12 +- 15 files changed, 1349 insertions(+), 214 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx create mode 100644 src/bin/binSummary/components/nodeControls.tsx diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index d145caa..a64fc80 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -33,6 +33,11 @@ interface Props { * larger when initialRadius is large. */ maxRadius?: number; + /** + * used to control the offset of the target from the center that the camera rotates around + */ + offset?: { x: number; y: number; z: number }; + viewOffset?: number; /** * Called when the camera is reset to its initial position. * OrbitCameraControls manages the reset internally — use this to @@ -48,6 +53,10 @@ export default function OrbitCameraControls(props: Props) { clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01, + offset, + viewOffset, + initialRadius, + maxRadius, onReset, } = props; @@ -55,9 +64,9 @@ export default function OrbitCameraControls(props: Props) { const mouse = useRef(new Vector2()); const targetRef = useRef({ - x: target?.x ?? 0, - y: target?.y ?? 0, - z: target?.z ?? 0, + x: (target?.x ?? 0) + (offset?.x ?? 0), + y: (target?.y ?? 0) + (offset?.y ?? 0), + z: (target?.z ?? 0) + (offset?.z ?? 0), }); const isDragging = useRef(false); @@ -67,7 +76,7 @@ export default function OrbitCameraControls(props: Props) { // Spherical coords const spherical = useRef({ - radius: props.initialRadius ?? 10, + radius: initialRadius ?? 10, theta: 0, // horizontal angle phi: Math.PI / 2, // vertical angle }); @@ -78,11 +87,11 @@ export default function OrbitCameraControls(props: Props) { const updateCamera = () => { const { radius, theta, phi } = spherical.current; - + const x = radius * Math.sin(phi) * Math.sin(theta); const y = radius * Math.cos(phi); const z = radius * Math.sin(phi) * Math.cos(theta); - + camera.position.set( targetRef.current.x + x, targetRef.current.y + y, @@ -93,19 +102,23 @@ export default function OrbitCameraControls(props: Props) { targetRef.current.y, targetRef.current.z ); + + // Shift the projected image without affecting orbit + const { width, height } = gl.domElement.getBoundingClientRect(); + (camera as any).setViewOffset(width, height, viewOffset ?? 0, 0, width, height); }; // Hand the reset function to the caller so they can trigger it // (e.g. from a button in CameraOverlay) without needing an external ref. if (onReset) { onReset(() => { - spherical.current.radius = props.initialRadius ?? 10; + spherical.current.radius = initialRadius ?? 10; spherical.current.theta = 0; spherical.current.phi = Math.PI / 2; targetRef.current = { - x: target?.x ?? 0, - y: target?.y ?? 0, - z: target?.z ?? 0, + x: (target?.x ?? 0) + (offset?.x ?? 0), + y: (target?.y ?? 0) + (offset?.y ?? 0), + z: (target?.z ?? 0) + (offset?.z ?? 0), }; updateCamera(); }); @@ -200,7 +213,7 @@ export default function OrbitCameraControls(props: Props) { spherical.current.radius += e.deltaY * 0.01; spherical.current.radius = Math.max( 3, - Math.min(props.maxRadius ?? 30, spherical.current.radius) + Math.min(maxRadius ?? 30, spherical.current.radius) ); updateCamera(); diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index 73ea434..e468c0c 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -79,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} {buildMaterial()} diff --git a/src/bin/3dView/Data/BuildCableData.ts b/src/bin/3dView/Data/BuildCableData.ts index f9493a0..3b2c673 100644 --- a/src/bin/3dView/Data/BuildCableData.ts +++ b/src/bin/3dView/Data/BuildCableData.ts @@ -1,4 +1,5 @@ import { Bin } from "models"; +import { GrainCable } from "models/GrainCable"; import { pond } from "protobuf-ts/pond"; import { Vector3 } from "three"; import { avg } from "utils"; diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index cc2ae26..551e98e 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -15,10 +15,18 @@ import GrainCableFill from "../Systems/Inventory/GrainCableFill"; import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; import { Box } from "@mui/material"; +import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap"; +// import { GrainCable } from "models/GrainCable"; interface Props { bin: Bin scale: number + /** + * cables can come from the bins status when not passed in, however data may be out of synce from when the cables read to when the bins status updates + * they may need to be passed in but for the moment we wont worry about it due to the last active on the bin page can be used to explain being out of sync + * if it becomes an issue I can make the changes necessary to pass the cable components in + */ + // cables?: GrainCable[] fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void showGrain?: boolean @@ -31,10 +39,11 @@ interface Props { showResetButton?: boolean showTemp?: boolean showMoisture?: boolean + xOffset?: number } export default function Bin3dView(props: Props) { - const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props + const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture, xOffset } = props // Compute the initial camera radius so the full bin fits in frame. // Uses the perspective camera FOV (default 75°) with a padding factor. @@ -105,7 +114,14 @@ export default function Bin3dView(props: Props) { } return ( - + + { resetCameraFn.current = fn; }} + viewOffset={xOffset} + /> { if (nodeClick) nodeClick(node, cable) }} - renderOrder={1} + renderOrder={5} /> {showHotspots && } + {/* note that the heatmaps insternally use render order 1,2, and 3 for the three seperate coloured meshes */} {showTempHeatmap && ( )} + {showMoistureHeatmap && ( + + )} @@ -151,16 +175,7 @@ export default function Bin3dView(props: Props) { - { resetCameraFn.current = fn; }} - /> - {/* resetCameraFn.current?.()} - /> */} + ) } diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index ecaaf18..08fa571 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -146,10 +146,12 @@ export default function CableNode(props: Props) { colour={nodeColour()} onClick={handleClick} renderOrder={renderOrder} - + depthTest={false} + depthWrite={false} + opacity={0.99} /> {node.topNode && ( - + )}
{node.inGrain && showLabel && diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 7c3aa3b..62ca26f 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -41,7 +41,7 @@ export default function GrainCable(props: Props) { return ( - + {nodes.map((node, i) => ( ))} diff --git a/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx new file mode 100644 index 0000000..290afce --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx @@ -0,0 +1,446 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import { Bin } from "models"; +import { NodeData } from "../../Data/BuildNodeData"; +import React from "react"; + +interface Props { + bin: Bin; + nodes: NodeData[]; + /** + * For flat fill percent inventory — a scalar Y ceiling for the heatmap top. + * Used when there are no cable top nodes to drive a surface. + */ + flatMaxY?: number; +} + +const RADIAL_RINGS = 20; +const THETA_SEGMENTS = 40; +const HEIGHT_STEPS = 28; + +const YELLOW_DELTA = 2; +const RED_DELTA = 4; + +const IDW_POWER = 4; +const RED_OPACITY = 0.3; +const GREEN_OPACITY = 0.3; +const YELLOW_OPACITY = 0.8; + +const ANGLE_JITTER = 0.2; +const RADIAL_JITTER = 0.05; +const LAYER_TWIST = 0.22; + +// IDW power for the horizontal surface interpolation — +// matches GrainCableFill's default of 2 +const SURFACE_IDW_POWER = 2; + +function moistureToHeat(moisture: number, target: number): number { + if (moisture <= target) return 0; + const delta = moisture - target; + if (delta >= RED_DELTA) return 2; + if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA); + return delta / YELLOW_DELTA; +} + +function heatToRGB(heat: number): number[] { + const GREEN = [0, 0.5, 0.02]; + const YELLOW = [0.7, 0.86, 0.0]; + const RED = [0.8, 0.0, 0.01]; + + if (heat <= 0) return GREEN; + + if (heat <= 1) { + const t = Math.pow(heat, 0.75); + return [ + GREEN[0] + (YELLOW[0] - GREEN[0]) * t, + GREEN[1] + (YELLOW[1] - GREEN[1]) * t, + GREEN[2] + (YELLOW[2] - GREEN[2]) * t, + ]; + } + + const t = Math.pow(heat - 1, 0.75); + return [ + YELLOW[0] + (RED[0] - YELLOW[0]) * t, + YELLOW[1] + (RED[1] - YELLOW[1]) * t, + YELLOW[2] + (RED[2] - YELLOW[2]) * t, + ]; +} + +interface MoistureAnchor { + x: number; y: number; z: number; + moisture: number; +} + +interface SurfaceAnchor { + x: number; z: number; y: number; +} + +// 3D IDW for moisture interpolation +function idwMoisture( + px: number, py: number, pz: number, + anchors: MoistureAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedSum = 0; + + for (const a of anchors) { + const dx = px - a.x; + const dy = py - a.y; + const dz = pz - a.z; + const distSq = dx * dx + dy * dy + dz * dz; + if (distSq < 0.001) return a.moisture; + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedSum += a.moisture * weight; + } + + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} + +// 2D IDW for surface height interpolation — matches GrainCableFill exactly +function idwSurfaceY( + x: number, z: number, + anchors: SurfaceAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedY = 0; + + for (const a of anchors) { + const dx = x - a.x; + const dz = z - a.z; + const distSq = dx * dx + dz * dz; + if (distSq < 0.001) return a.y; + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedY += a.y * weight; + } + + return totalWeight === 0 ? 0 : weightedY / totalWeight; +} + +function hashNoise(a: number, b: number, c: number) { + const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} + +export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) { + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const targetMoisture = bin.targetMoisture(); + + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + const maxRadiusAtY = (y: number) => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + return binRadius * ((y - hopperTipY) / hopperHeight); + }; + + // Moisture anchors — all in-grain nodes that have a moisture reading + const moistureAnchors = useMemo( + () => + nodes + .filter(n => n.inGrain && !n.excluded && n.moisture !== undefined) + .map(n => ({ + x: n.position.x, + y: n.position.y, + z: n.position.z, + moisture: n.moisture as number, + })), + [nodes] + ); + + // Surface anchors — top nodes only, Y offset by half spacing. + // Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable + // inventory control). For any other control type the caller passes flatMaxY + // and we must use a flat surface instead of the wavy cable-driven one. + const surfaceAnchors = useMemo(() => { + if (flatMaxY !== undefined) { + // Non-cable inventory: force flat surface — ignore cable top nodes + return []; + } + return nodes + .filter(n => n.topNode && n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })); + }, [nodes, flatMaxY]); + + // Wall clamp Y — average of surface anchor heights. + // Prevents the surface from piling up at the bin wall where there are no cables. + // Matches GrainCableFill's wallY computation. + const wallY = useMemo(() => { + if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY; + return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length; + }, [surfaceAnchors, flatMaxY, sidewallBaseY]); + + // For each (x, z) position, returns the Y ceiling of the grain surface. + // Uses cable surface IDW when top nodes exist, falls back to flatMaxY. + const getSurfaceY = (x: number, z: number): number => { + if (surfaceAnchors.length > 0) { + // Outermost ring clamps to wallY — matches GrainCableFill + const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER); + return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, raw)); + } + return flatMaxY ?? sidewallBaseY; + }; + + // Global max Y — highest point of the surface (used for grid bottom calc) + const maxGrainY = useMemo(() => { + if (surfaceAnchors.length > 0) { + return Math.max(...surfaceAnchors.map(a => a.y), wallY); + } + return flatMaxY ?? sidewallBaseY; + }, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]); + + const geometry = useMemo(() => { + if (!moistureAnchors.length) return null; + + const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; + const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; + + const positions = new Float32Array(totalVerts * 3); + const colors = new Float32Array(totalVerts * 3); + const heats = new Float32Array(totalVerts); + + const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const grainBottomY = hopperHeight > 0 + ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS + : sidewallBaseY; + const grainHeight = maxGrainY - grainBottomY; + + if (grainHeight <= 0) return null; + + // Pre-compute the (x, z) position for each (ring, seg) slot so we + // can look up the surface Y ceiling per column + const colX: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS); + const colZ: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS); + const colSurfaceY: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS); + + // Use the outermost layer's radius for surface Y lookup (no jitter/twist) + // so the surface shape matches GrainCableFill cleanly + const topLayerAllowedRadius = maxRadiusAtY(maxGrainY) * 0.97; + + for (let ring = 0; ring < RADIAL_RINGS; ring++) { + const u = (ring + 1) / RADIAL_RINGS; + const rFrac = 1 - Math.pow(1 - u, 2.2); + const r = rFrac * topLayerAllowedRadius; + + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const angle = (seg / THETA_SEGMENTS) * Math.PI * 2; + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + const ci = ring * THETA_SEGMENTS + seg; + colX[ci] = x; + colZ[ci] = z; + // Outermost ring uses wallY, inner rings use full IDW surface + colSurfaceY[ci] = ring === RADIAL_RINGS - 1 + ? wallY + : getSurfaceY(x, z); + } + } + // Center column surface Y + const centerSurfaceY = getSurfaceY(0, 0); + + for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) { + const t = hStep / (HEIGHT_STEPS - 1); + + const layerBase = hStep * pointsPerLayer; + const allowedRadius = maxRadiusAtY(grainBottomY + t * grainHeight) * 0.97; + + // Center vertex — Y is lerped from bottom to its column surface ceiling + const centerY = grainBottomY + t * (centerSurfaceY - grainBottomY); + + const centerMoisture = idwMoisture(0, centerY, 0, moistureAnchors, IDW_POWER); + const centerHeat = moistureToHeat(centerMoisture, targetMoisture); + const [cr, cg, cb] = heatToRGB(centerHeat); + + positions[layerBase * 3] = 0; + positions[layerBase * 3 + 1] = centerY; + positions[layerBase * 3 + 2] = 0; + colors[layerBase * 3] = cr; + colors[layerBase * 3 + 1] = cg; + colors[layerBase * 3 + 2] = cb; + heats[layerBase] = centerHeat; + + for (let ring = 0; ring < RADIAL_RINGS; ring++) { + const u = (ring + 1) / RADIAL_RINGS; + const rFrac = 1 - Math.pow(1 - u, 2.2); + + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const noiseA = hashNoise(hStep, ring, seg); + const noiseR = hashNoise(seg, hStep, ring + 11); + + const baseRadius = rFrac * allowedRadius; + const jitterRadius = baseRadius + noiseR * RADIAL_JITTER * allowedRadius; + const r = Math.max(0, Math.min(jitterRadius, allowedRadius)); + + const layerPhase = t * LAYER_TWIST; + const angle = + (seg / THETA_SEGMENTS) * Math.PI * 2 + + layerPhase + + noiseA * ANGLE_JITTER; + + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + // Per-column surface Y ceiling — this is what gives the wavy top + const ci = ring * THETA_SEGMENTS + seg; + const surfaceY = colSurfaceY[ci]; + + // Lerp this vertex Y from grainBottomY up to its column surface ceiling + const y = grainBottomY + t * (surfaceY - grainBottomY); + + const moisture = idwMoisture(x, y, z, moistureAnchors, IDW_POWER); + const heat = moistureToHeat(moisture, targetMoisture); + const [vr, vg, vb] = heatToRGB(heat); + + const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg; + + heats[vi] = heat; + positions[vi * 3] = x; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = z; + colors[vi * 3] = vr; + colors[vi * 3 + 1] = vg; + colors[vi * 3 + 2] = vb; + } + } + } + + const idx = (hStep: number, ring: number, seg: number) => { + if (ring < 0) return hStep * pointsPerLayer; + const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; + return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; + }; + + const green: number[] = []; + const yellow: number[] = []; + const red: number[] = []; + + function pushTri(a: number, b: number, c: number) { + const avg = (heats[a] + heats[b] + heats[c]) / 3; + if (avg < 1) green.push(a, b, c); + else if (avg < 2) yellow.push(a, b, c); + else red.push(a, b, c); + } + + for (let h = 0; h < HEIGHT_STEPS - 1; h++) { + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(h, ring, seg); + const b = idx(h, ring, next); + const e = idx(h + 1, ring, seg); + const f = idx(h + 1, ring, next); + pushTri(a, e, b); + pushTri(e, f, b); + } + } + + // center fill + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const center0 = idx(h, -1, 0); + const center1 = idx(h + 1, -1, 0); + const a = idx(h, 0, seg); + const b = idx(h, 0, next); + const c = idx(h + 1, 0, seg); + const d = idx(h + 1, 0, next); + pushTri(center0, c, a); + pushTri(center0, center1, c); + pushTri(a, c, b); + pushTri(b, c, d); + } + } + + // Hopper tip + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + if (hopperHeight > 0) { + const tipMoisture = idwMoisture(0, rawBottomY, 0, moistureAnchors, IDW_POWER); + const tipHeat = moistureToHeat(tipMoisture, targetMoisture); + const [tr, tg, tb] = heatToRGB(tipHeat); + + positions[tipVertexIndex * 3] = 0; + positions[tipVertexIndex * 3 + 1] = rawBottomY; + positions[tipVertexIndex * 3 + 2] = 0; + colors[tipVertexIndex * 3] = tr; + colors[tipVertexIndex * 3 + 1] = tg; + colors[tipVertexIndex * 3 + 2] = tb; + heats[tipVertexIndex] = tipHeat; + + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg)); + } + } + + function makeGeo(indices: number[]) { + const g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + g.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + g.setIndex(indices); + return g; + } + + return { + green: makeGeo(green), + yellow: makeGeo(yellow), + red: makeGeo(red), + }; + }, [ + moistureAnchors, + surfaceAnchors, + wallY, + maxGrainY, + hopperTipY, + sidewallBaseY, + binRadius, + targetMoisture, + hopperHeight, + flatMaxY, + ]); + + if (!geometry) return null; + + return ( + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index ee6e46c..8e70a79 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -154,19 +154,23 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) { [nodes] ); - // Surface anchors — top nodes only, Y offset by half spacing - // Matches GrainCableFill's anchor computation exactly. - const surfaceAnchors = useMemo( - () => - nodes - .filter(n => n.topNode && n.inGrain && !n.excluded) - .map(n => ({ - x: n.position.x, - z: n.position.z, - y: n.position.y + n.nodeSpacing * 0.5, - })), - [nodes] - ); + // Surface anchors — top nodes only, Y offset by half spacing. + // Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable + // inventory control). For any other control type the caller passes flatMaxY + // and we must use a flat surface instead of the wavy cable-driven one. + const surfaceAnchors = useMemo(() => { + if (flatMaxY !== undefined) { + // Non-cable inventory: force flat surface — ignore cable top nodes + return []; + } + return nodes + .filter(n => n.topNode && n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })); + }, [nodes, flatMaxY]); // Wall clamp Y — average of surface anchor heights. // Prevents the surface from piling up at the bin wall where there are no cables. @@ -440,3 +444,4 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) { ); } + diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 737f125..9621d8b 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -1,31 +1,67 @@ -import { Box, Card, Grid2 } from "@mui/material"; +import { Box, Card, Grid2, LinearProgress, Typography } from "@mui/material"; import { useMobile } from "hooks"; -import { Bin } from "models"; +import { Bin, Component, Device } from "models"; import Bin3dVisualizer from "./components/bin3dVisualizer"; import BinTableView from "./components/binTableView"; +// import GrassIcon from "@mui/icons-material/Grass" +import { grey, orange, red } from "@mui/material/colors"; +import { AccessTime, Spa } from "@mui/icons-material"; +import moment from "moment"; +import { useEffect, useState } from "react"; +import { Controller } from "models/Controller"; +import { GrainCable } from "models/GrainCable"; +import { pond } from "protobuf-ts/pond"; interface Props { bin: Bin + devices: Device[] + cables?: GrainCable[] + fans?: Controller[] + heaters?: Controller[] + permissions: pond.Permission[] + componentDevices: Map + componentMap: Map } export default function BinSummary(props: Props){ - const {bin} = props + const {bin, devices, fans, heaters, permissions, componentDevices, componentMap} = props + const [currentDevice, setCurrentDevice] = useState(undefined) const isMobile = useMobile() - //need to decide if the vies shoulw both be displayed or toggled between - const bin3dView = () => { - return ( - - ) - } - const tableView = () => { - return ( - - ) - } + useEffect(() => { + if(devices.length > 0){ + setCurrentDevice(devices[0]) + } + },[devices]) + + const activity = (reading: string) => { + //if the device hasnt checked in in 6 hours = missing 12 hours = inactive within 6 hours = live + + let status = "Live" + let colour = "#4caf50" + let diff = moment().diff(moment(reading), "hour") + console.log(diff) + if(diff > 12){ + status = "Inactive" + colour = red[700] + }else if (diff > 6){ + status = "Missing" + colour = orange[300] + } - const inventoryOverView = () => { return ( - inventory overview + + + + {status} + + ) } @@ -45,20 +81,86 @@ export default function BinSummary(props: Props){ return ( + + + + + Grain Type + + + + {/* */} + {bin.grainName()} + + + + + Bin Fill + + + + {bin.binFillCap()} + + + + + + + Last Updated + + {/* this would be to show the last time the device checked in */} + {/* {currentDevice ? + + + + + {moment(currentDevice.status.lastActive).fromNow()} + + + {activity(currentDevice.status.lastActive)} + + : + + + No Connected Devices Found + + + } */} + {/* this shows the last time the bins status updated */} + + + + + {moment(bin.status.timestamp).fromNow()} + + + {activity(bin.status.timestamp)} + + + + - + - {bin3dView()} + - + - {tableView()} - - - - - {inventoryOverView()} + diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx index 5da0a3e..295374c 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -1,130 +1,288 @@ -import { Box, Checkbox, FormControlLabel, List, ListItem } from "@mui/material"; +import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack } from "@mui/material"; import Bin3dView from "bin/3dView/Scene/Bin3dView"; -import { Bin } from "models"; -import React from "react"; +import { Bin, Component, Device } from "models"; +import { Controller } from "models/Controller"; +import { pond, quack } from "protobuf-ts/pond"; +import React, { useEffect } from "react"; import { useState } from "react"; +import NodeControls from "./nodeControls"; +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { CableData } from "bin/3dView/Data/BuildCableData"; interface Props { bin: Bin + // cables?: GrainCable[] + devices: Device[] + fans?: Controller[] + heaters?: Controller[] + permissions: pond.Permission[] + componentDevices: Map + componentMap: Map } export default function bin3dVisualizer(props: Props){ - const {bin} = props + const {bin, fans, heaters, permissions, devices, componentDevices, componentMap} = props const [showHeatmap, setShowHeatmap] = useState(true) const [showHotspots, setShowHotspots] = useState(false) const [showFill, setShowFill] = useState(false) - const [showTemp, setShowTemp] = useState(false) + const [showTemp, setShowTemp] = useState(true) const [showMoisture, setShowMoisture] = useState(false) + const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false) + const [binDisplay, setBinDisplay] = useState("temp") + const [binMode, setBinMode] = useState(pond.BinMode.BIN_MODE_NONE) + const [selectedCable, setSelectedCable] = useState(undefined) + const [selectedNode, setSelectedNode] = useState(undefined) + const [selectedCableDevice, setSelectedCableDevice] = useState(Device.create()) //this is the device that the cable that has the node that was clicked on belongs to + const [filteredComponents, setFilteredComponents] = useState([]) //components that are filtered to only include ones on the same device + const [openNodeControls, setOpenNodeControls] = useState(false) + const [devMap, setDevMap] = useState>(new Map()) - const getFill = () => { - //calculate the fill percent of the bin when using manual/lidar for inventory + useEffect(()=>{ + let newMap: Map = new Map() + devices.forEach(d => { + newMap.set(d.id(), d) + }) + setDevMap(newMap) + },[devices]) - return 0 - } + useEffect(()=>{ + setBinMode(bin.settings.mode) + },[bin]) - const controlsOverlay = () => { + const binModeControl = () => { return ( - - - - { - setShowHeatmap(checked); - }} - /> - } - label={"Toggle Heatmap"} - /> - - - { - setShowHotspots(checked); - }} - /> - } - label={"Toggle Hotspots"} - /> - - - { - setShowFill(checked); - }} - /> - } - label={"Toggle Hotspots"} - /> - - - { - setShowTemp(checked); - }} - /> - } - label={"Toggle Temp"} - /> - - - - { - setShowMoisture(checked); - }} - /> - } - label={"Toggle Moisture"} - /> - - - - - - - + + {bin.name()} + + + + ) } - const binViewer = () => { + const heatmapDisplay = () => { return ( - + + Heatmap Display + + {/* Display */} + + + + ) + } + + const controllerState = (controller: Controller) => { + let state = false + controller.status.lastGoodMeasurement.forEach(um => { + if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ + if(um.values.length > 0){ + let readings = um.values + if(readings[readings.length-1].values.length > 0){ + let nodes = readings[readings.length-1].values + if (nodes[nodes.length -1] === 1){ + state = true + } + } + } + } + }) + return state + } + + const controllerDisplay = () => { + return ( + + + Controllers + {fans && fans.map(fan => { + let isOn = controllerState(fan) + return ( + + {fan.name()} + + + {isOn ? "ON" : "OFF"} + + + + + )})} + {heaters && heaters.map(heater => { + let isOn = controllerState(heater) + return ( + + {heater.name()} + + + {isOn ? "ON" : "OFF"} + + + + + )})} + + + // loop through controllers displaying each one + ) } return ( - - {binViewer()} - - {controlsOverlay()} + {(selectedCable && selectedNode) && + { + setOpenNodeControls(false) + }} + /> + } + + { + // console.log(node) + // console.log(cable) + //this will be how to control the dialog to update the top nodes and node exclusion etc (make new component called NodeControls for this) + //use the cable data to get the device it belongs to + let d = devMap.get(cable.grainCable.device) + if(d !== undefined){ + setSelectedNode(node) + setSelectedCable(cable) + setSelectedCableDevice(d) + //filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status + //is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear + let filtered: Component[] = [] + if(fans){ + fans.forEach((f) => { + if(componentDevices.get(f.key()) === d.id()){ + filtered.push(f.asComponent()) + } + }) + } + if(heaters){ + heaters.forEach((h) => { + if(componentDevices.get(h.key()) === d.id()){ + filtered.push(h.asComponent()) + } + }) + } + //also make sure to push the cable into the list of filtered components for the source + let c = componentMap.get(cable.grainCable.key) + if(c !== undefined){ + filtered.push(c) + } + setFilteredComponents(filtered) + setOpenNodeControls(true) + } + }} + scale={100} + showTempHeatmap={showHeatmap} + showMoistureHeatmap={showMoistureHeatmap} + showTemp={showTemp} + showMoisture={showMoisture} + showGrain={showFill} + showHotspots={showHotspots} + xOffset={-150} + /> + + + {binModeControl()} + {heatmapDisplay()} + {controllerDisplay()} + diff --git a/src/bin/binSummary/components/binTableView.tsx b/src/bin/binSummary/components/binTableView.tsx index daa76ff..94721dd 100644 --- a/src/bin/binSummary/components/binTableView.tsx +++ b/src/bin/binSummary/components/binTableView.tsx @@ -94,6 +94,7 @@ export default function BinTableView(props: Props) { //determine the temp if(level.grainTemps.length > 0){ + console.log(level.grainTemps) lvlTemp = avg(level.grainTemps) //if the average temp is 5 degrees above the bins threshold if(lvlTemp > (bin.upperTempThreshold() + 5)){ diff --git a/src/bin/binSummary/components/nodeControls.tsx b/src/bin/binSummary/components/nodeControls.tsx new file mode 100644 index 0000000..2a8966a --- /dev/null +++ b/src/bin/binSummary/components/nodeControls.tsx @@ -0,0 +1,406 @@ +import { AppBar, Box, Button, Card, CardActionArea, Checkbox, DialogActions, DialogContent, DialogTitle, FormControlLabel, Grid2, IconButton, Typography } from "@mui/material"; +import { green } from "@mui/material/colors"; +import { makeStyles } from "@mui/styles"; +import { CableData } from "bin/3dView/Data/BuildCableData"; +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import HumidityIcon from "component/HumidityIcon"; +import TemperatureIcon from "component/TemperatureIcon"; +import { useComponentAPI, useMobile, useSnackbar } from "hooks"; +import InteractionsOverview from "interactions/InteractionsOverview"; +import { Bin, Component, Device, Interaction } from "models"; +import moment from "moment"; +import AddIcon from "@mui/icons-material/AddCircle"; +import GraphIcon from "products/CommonIcons/graphIcon"; +import { pond } from "protobuf-ts/pond"; +import { useBinAPI, useGlobalState, useInteractionsAPI } from "providers"; +import React from "react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { canWrite } from "pbHelpers/Permission"; +import InteractionSettings from "interactions/InteractionSettings"; + +const useStyles = makeStyles(() => { + return ({ + dialog: { + maxWidth: 350 + }, + appBar: { + position: "static", + borderRadius: 30 + }, + readingCard: { + padding: 10 + }, + greenButton: { + color: green["600"], + padding: 0 + } + }); + }); + +interface Props { + open: boolean + onClose: () => void + node: NodeData + cable: CableData + device: Device + bin: Bin + /** + * the possible options for controllers when creating new interactions, these controllers must be on the same device as the cable for interactions to work + */ + filteredComponents?: Component[] + componentMap: Map + permissions: pond.Permission[] + /** + * callback function for updating the cable with a new top node or excluded node + * @returns void + */ + updateComponentCallback?: () => void +} + +export default function NodeControls(props: Props){ + const {open, onClose, node, cable, device, bin, filteredComponents, permissions, updateComponentCallback, componentMap} = props + const classes = useStyles() + const [isTopNode, setIsTopNode] = useState(false) + const [isExcluded, setIsExcluded] = useState(false) + const [cableComponent, setCableComponent] = useState(Component.create()) + const [cableInteractions, setCableInteractions] = useState([]) + const isMobile = useMobile() + const navigate = useNavigate() + const [{as, user}] = useGlobalState() + const interactionAPI = useInteractionsAPI() + const binAPI = useBinAPI() + const componentAPI = useComponentAPI() + const {openSnack} = useSnackbar() + const [newInteraction, setNewInteraction] = useState(false); + + //will need to list the interactions for the cable + useEffect(() => { + let component = componentMap.get(cable.grainCable.key) ?? Component.create() + setCableComponent(component) + interactionAPI.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as).then(resp => { + setCableInteractions(resp); + }); + },[cable, device, interactionAPI]) + + //determine if the node is excluded/the top node + useEffect(()=>{ + if(node.topNode !== undefined){ + setIsTopNode(node.topNode) + } + if(cable.grainCable.excludedNodes.includes(node.nodeIndex)){ + setIsExcluded(true) + }else( + setIsExcluded(false) + ) + },[node, cable]) + + //navigation function to go to the component page + const goToComponent = () => { + navigate("/devices/" + device.id() + "/components/" + cable.grainCable.key); + } + + const close = ()=>{ + onClose() + } + + const setEMC = () => { + let settings = cableComponent.settings; + //make sure the mutation is not there first + if(!settings.defaultMutations.includes(pond.Mutator.MUTATOR_EMC)){ + settings.defaultMutations.push(pond.Mutator.MUTATOR_EMC) + } + settings.grainType = bin.grain(); + settings.customGrain = bin.customGrain(); + componentAPI + .update(device.id(), settings, [bin.key()], ["bin"], as) + .then(resp => { + openSnack("EMC set on cable " + cableComponent.name()); + }) + .catch(err => {}); + } + + const submit = () => { + //should update the component and then update the bin prefs if successful + componentAPI.update(device.id(), cableComponent.settings).then(resp => { + let pref = pond.BinComponentPreferences.create({ + type: pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE, + node: cableComponent.settings.grainFilledTo + }); + //update the bins preferences to have the new top node for the cable + binAPI + .updateComponentPreferences(bin.key(), cableComponent.key(), pref, as) + .then(resp => { + openSnack("Changes will be reflected once the bins status updates") + if(updateComponentCallback){ + updateComponentCallback() + } + }) + .catch(err => {}); + }).catch(err => { + + }) + close() + } + + const updateTopNode = (checked: boolean) => { + if (isTopNode && !checked) { + cableComponent.settings.grainFilledTo = 0; + cableInteractions.forEach(interaction => { + interaction.settings.subtype = 0; + interaction.settings.nodeOne = 0; + interaction.settings.nodeTwo = 0; + }); + } else if (checked) { + cableComponent.settings.grainFilledTo = node.nodeIndex+1; + cableInteractions.forEach(interaction => { + interaction.settings.subtype = Interaction.upToSubtype(node.nodeIndex+1); + interaction.settings.nodeOne = node.nodeIndex+1; + }); + } + setIsTopNode(checked); + } + + const updateNodeExclusion = (checked: boolean) => { + if (checked) { + cableComponent.settings.excludedNodes.push(node.nodeIndex) + } else { + cableComponent.settings.excludedNodes.splice(cableComponent.settings.excludedNodes.indexOf(node.nodeIndex), 1) + } + setIsExcluded(checked) + } + + const displayTemp = () => { + let isF = user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + let tempFinal = node.celcius ?? 0; + if (isF) { + tempFinal = (tempFinal * 1.8 + 32); + } + return tempFinal.toFixed(2) + (isF ? " F" : "C"); + }; + + const latestReading = () => { + return ( + + Latest Reading + {moment(cable.grainCable.lastRead).fromNow()} + + + + + + + {displayTemp()} + + + + Temperature + + + + + + + + + {node.humidity} % + + + + Humidity + + + + + {/* only show the emc if the bin grain type and the cable grain type match */} + {node.moisture ? ( + + + + + {node.moisture} % + + + + Grain EMC + + + ) : ( + + { + setEMC(); + }}> + + + + + Set EMC + + + + )} + + + + goToComponent()}> + + + + + See Graphs + + + + + + + ); + }; + + const nodeControl = () => { + return ( + + Node Control + { + updateNodeExclusion(e.target.checked); + }} + name="excludedNode" + /> + } + label={ + + + Exclude Node + + + If checked this will set the node to be disabled and will not be used for any calculations done for the bin + + + } + /> + { + updateTopNode(e.target.checked); + }} + name="topNode" + /> + } + label={ + + + Set as top node in grain + + + If checked this will set interactions to ignore all nodes above it. Interactions can + still be adjusted manually to use all nodes + + + } + /> + + ); + }; + + const interactionDisplay = () => { + return ( + + Interactions + + { + interactionAPI + .listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as) + .then(resp => { + setCableInteractions(resp); + }); + }} + /> + + + { + setNewInteraction(true); + }} + className={classes.greenButton}> + + + + + ); + }; + + return ( + + + + + {cable.grainCable.name} + + + + + Node {node.nodeIndex+1} + + + + + + {latestReading()} + {nodeControl()} + {interactionDisplay()} + {/* + */} + + + + + + + { + setNewInteraction(false); + }} + refreshCallback={() => { + interactionAPI.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as).then(resp => { + setCableInteractions(resp); + }); + }} + canEdit={canWrite(permissions)} + /> + + ) +} \ No newline at end of file diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 6aa2389..1041d86 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -190,7 +190,12 @@ export class Bin { public grainName(): string { if (this.grain() !== pond.Grain.GRAIN_INVALID) { if (this.grain() === pond.Grain.GRAIN_CUSTOM) { - return this.customType(); + //this will prioritize the new style of custom grain types + let cg = this.customGrain() + if(cg !== undefined){ + return cg.name + } + return this.customType(); //this is still here for bins that are using the old custom grain where it was just a string } else { return GrainDescriber(this.grain()).name; } @@ -199,6 +204,18 @@ export class Bin { } } + public grainGroup(): string { + if (this.grain() !== pond.Grain.GRAIN_INVALID) { + let cg = this.customGrain() + if (this.grain() === pond.Grain.GRAIN_CUSTOM && cg) { + return cg.group + } else { + return GrainDescriber(this.grain()).group; + } + } + return "None"; + } + public binFillCap(): string { let fillCap = ""; if (this.settings.specs && this.settings.inventory) { diff --git a/src/pages/BinV2.tsx b/src/pages/BinV2.tsx index 39691e6..98b0a3c 100644 --- a/src/pages/BinV2.tsx +++ b/src/pages/BinV2.tsx @@ -83,8 +83,6 @@ export default function BinV2(){ const [binPresets, setBinPresets] = useState([]); const [missedReadings, setMissedReadings] = useState(0); - const [currentSection, setCurrentSection] = useState<"binSum">("binSum") - //Drawer states const [noteDrawerOpen, setNoteDrawerOpen] = useState(false) const [taskDrawerOpen, setTaskDrawerOpen] = useState(false) @@ -305,7 +303,7 @@ export default function BinV2(){ */ const pageHeader = () => { return ( - + { - return ( - - ) - } - - /** - * this sections is for showing attached bin components and assigning them to bin positions (see old pages bin components) - * as well as seeing the graph data for attached sensors - */ - const sensorsSection = () => {} - - //Inventory - /** - * this section is for seeing information relating to the bins inventory for its fill level over time and transactions related to inventory of the bin - */ - const InventorySection = () => {} - - //Analysis - /** - * this section is for the analytical charts (drying/hydrating, Moisture trending, Grain Water Content) - */ - const analysisSection = () => {} - - //Interactions - /** - * this is the section for alerts and controls on the bin, if presets get re-implemented they can go here too - */ - const interactionsSection = () => {} - //DRAWERS const noteDrawer = () => { @@ -473,7 +432,7 @@ export default function BinV2(){ return ( {pageHeader()} - {currentSection === "binSum" && binSummarySection()} + {/* render drawers */} {noteDrawer()} {taskDrawer()} diff --git a/src/pbHelpers/Component.ts b/src/pbHelpers/Component.ts index e809649..b357813 100644 --- a/src/pbHelpers/Component.ts +++ b/src/pbHelpers/Component.ts @@ -65,10 +65,20 @@ export function stringToComponentId(componentIDString: string): quack.ComponentI return componentID; } - const componentIDFragments = componentIDString.split("-", 3); + //first split on a colon to get the mux line seperated + const componentSplit = componentIDString.split(":", 2); + if(componentSplit[1]){ + componentID.muxLine = Number(componentSplit[1]) + } + + const componentIDFragments = componentIDString.split("-", 4); let type: string = quack.ComponentType[Number(componentIDFragments[0])]; let addressType: string = quack.AddressType[Number(componentIDFragments[1])]; let address: number = Number(componentIDFragments[2]); + if(componentIDFragments[3]){ + componentID.expansionLine = Number(componentIDFragments[3]) + } + componentID.type = quack.ComponentType[type as keyof typeof quack.ComponentType]; componentID.addressType = quack.AddressType[addressType as keyof typeof quack.AddressType]; componentID.address = address; From 0f4d706e76c24d557d9301559919a54657af17eb Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 14 May 2026 10:39:21 -0600 Subject: [PATCH 20/44] hooked up the mode change stuff for the new summary --- src/bin/binSummary/BinSummary.tsx | 16 ++++- .../binSummary/components/bin3dVisualizer.tsx | 67 ++++++++++++++++--- src/bin/binSummary/components/binDetails.tsx | 17 +++++ src/pages/BinV2.tsx | 20 +++++- 4 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 src/bin/binSummary/components/binDetails.tsx diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 9621d8b..6327cba 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -21,9 +21,11 @@ interface Props { permissions: pond.Permission[] componentDevices: Map componentMap: Map + binPrefs?: Map + updateBinCallback?: (bin: Bin) => void } export default function BinSummary(props: Props){ - const {bin, devices, fans, heaters, permissions, componentDevices, componentMap} = props + const {bin, devices, fans, heaters, permissions, componentDevices, componentMap, binPrefs, updateBinCallback} = props const [currentDevice, setCurrentDevice] = useState(undefined) const isMobile = useMobile() @@ -155,7 +157,17 @@ export default function BinSummary(props: Props){ - + diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx index 295374c..67c4a67 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -8,6 +8,8 @@ import { useState } from "react"; import NodeControls from "./nodeControls"; import { NodeData } from "bin/3dView/Data/BuildNodeData"; import { CableData } from "bin/3dView/Data/BuildCableData"; +import ModeChangeDialog from "bin/conditioning/modeChangeDialog"; +import { cloneDeep } from "lodash"; interface Props { bin: Bin @@ -18,23 +20,27 @@ interface Props { permissions: pond.Permission[] componentDevices: Map componentMap: Map + binPrefs?: Map + updateBinCallback?: (bin: Bin) => void } export default function bin3dVisualizer(props: Props){ - const {bin, fans, heaters, permissions, devices, componentDevices, componentMap} = props + const {bin, fans, heaters, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props const [showHeatmap, setShowHeatmap] = useState(true) - const [showHotspots, setShowHotspots] = useState(false) - const [showFill, setShowFill] = useState(false) + // const [showHotspots, setShowHotspots] = useState(false) + // const [showFill, setShowFill] = useState(false) const [showTemp, setShowTemp] = useState(true) const [showMoisture, setShowMoisture] = useState(false) const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false) const [binDisplay, setBinDisplay] = useState("temp") const [binMode, setBinMode] = useState(pond.BinMode.BIN_MODE_NONE) + const [openModeChange, setOpenModeChange] = useState(false) const [selectedCable, setSelectedCable] = useState(undefined) const [selectedNode, setSelectedNode] = useState(undefined) const [selectedCableDevice, setSelectedCableDevice] = useState(Device.create()) //this is the device that the cable that has the node that was clicked on belongs to const [filteredComponents, setFilteredComponents] = useState([]) //components that are filtered to only include ones on the same device const [openNodeControls, setOpenNodeControls] = useState(false) const [devMap, setDevMap] = useState>(new Map()) + const [modeChangeInProgress, setModeChangeInProgress] = useState(false) useEffect(()=>{ let newMap: Map = new Map() @@ -48,6 +54,14 @@ export default function bin3dVisualizer(props: Props){ setBinMode(bin.settings.mode) },[bin]) + const updateBin = () => { + if(updateBinCallback){ + let clone = cloneDeep(bin) + clone.settings.mode = binMode + updateBinCallback(clone) + } + }; + const binModeControl = () => { return ( @@ -55,6 +69,7 @@ export default function bin3dVisualizer(props: Props){ { return ( - Heatmap Display + Heatmap Display {/* Display */} { + setDateSelect(event.target.value as number); + let currentDate = moment(); + setEndDate(currentDate); + switch (event.target.value) { + case 0: + setStartDate(currentDate.clone().subtract(1, 'week')); + break; + case 1: + setStartDate(currentDate.clone().subtract(2, 'weeks')); + break; + case 2: + setStartDate(currentDate.clone().subtract(4, 'weeks')); + break; + + } + }} + > + 1 Week + 2 Weeks + 4 Weeks + Custom + + + {dateSelect === 3 && ( + + )} + + ) +} + return ( - + {datePickerDialog()} + + {/* start/stop button */} + + {isPlayingState ? ( + + ) : ( + + )} + + + {/* progress bar and date display */} + + {currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")} + + + {startDate.format("MMM D, YYYY")} + {endDate.format("MMM D, YYYY")} + + + {/* date range selector */} + + {dateSelector()} + + ); } diff --git a/src/bin/bin3dVisualizer.tsx b/src/bin/bin3dVisualizer.tsx index 0ff9766..54a99ac 100644 --- a/src/bin/bin3dVisualizer.tsx +++ b/src/bin/bin3dVisualizer.tsx @@ -1,4 +1,4 @@ -import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack } from "@mui/material"; +import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack, FormControlLabel } from "@mui/material"; import Bin3dView from "bin/3dView/Scene/Bin3dView"; import { Bin, Component, Device } from "models"; import { Controller } from "models/Controller"; @@ -42,6 +42,7 @@ export default function bin3dVisualizer(props: Props){ const [openNodeControls, setOpenNodeControls] = useState(false) const [devMap, setDevMap] = useState>(new Map()) const [modeChangeInProgress, setModeChangeInProgress] = useState(false) + const [showLabels, setShowLabels] = useState(true) const isMobile = useMobile() useEffect(()=>{ @@ -102,7 +103,21 @@ export default function bin3dVisualizer(props: Props){ const heatmapDisplay = () => { return ( - Heatmap Display + + Heatmap Display + { + setShowLabels(checked) + }} + /> + } + label={Show Values} + /> + {/* Display */} { - let selection = event.target.value - setBinDisplay(selection) - if(selection === "temp"){ - setShowHeatmap(true) - setShowTemp(true) - setShowMoisture(false) - setShowMoistureHeatmap(false) - } - if(selection === "moisture"){ - setShowHeatmap(false) - setShowTemp(false) - setShowMoisture(true) - setShowMoistureHeatmap(true) - } - }}> - Temperature - Moisture - - + + Heatmap + + + + + {/* Checkbox moved below dropdown — no longer affects top alignment */} + setShowLabels(checked)} + /> + } + label={Show Values} + sx={{ mt: 0.5, ml: '-9px' }} + /> ) } diff --git a/src/bin/binSummary/components/binDetails.tsx b/src/bin/binSummary/components/binDetails.tsx index 1e42eee..fc54bd2 100644 --- a/src/bin/binSummary/components/binDetails.tsx +++ b/src/bin/binSummary/components/binDetails.tsx @@ -73,8 +73,15 @@ export default function BinDetails (props: Props) { onChange={(_, value) => setCurrentTab(value)} indicatorColor="primary" textColor="primary" - variant="fullWidth" - sx={{ flexShrink: 0 }}> + variant="scrollable" + scrollButtons="auto" + allowScrollButtonsMobile + sx={{ + flexShrink: 0, + '& .MuiTabs-scroller': { + overflowX: 'auto !important', + } + }}> {isMobile && } From f04213af1e3f86483bcd5089eec72910efb2da37 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 10 Jun 2026 16:42:00 -0600 Subject: [PATCH 39/44] full width on desktop scollable on mobile --- src/bin/binSummary/components/binDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/binSummary/components/binDetails.tsx b/src/bin/binSummary/components/binDetails.tsx index fc54bd2..c4abea1 100644 --- a/src/bin/binSummary/components/binDetails.tsx +++ b/src/bin/binSummary/components/binDetails.tsx @@ -73,7 +73,7 @@ export default function BinDetails (props: Props) { onChange={(_, value) => setCurrentTab(value)} indicatorColor="primary" textColor="primary" - variant="scrollable" + variant={isMobile ? "scrollable" : "fullWidth"} scrollButtons="auto" allowScrollButtonsMobile sx={{ From ea13f97c5779c64424c8939944a1b5b9f73faf37 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 11 Jun 2026 16:48:00 -0600 Subject: [PATCH 40/44] more visual tweaks and design, fixed a coule bugs and removed some console logs --- src/bin/3dView/Scene/Bin3dView.tsx | 4 +- src/bin/BinPlayer.tsx | 23 ++-- src/bin/BinTableExpanded.tsx | 136 +++++++++------------- src/bin/bin3dVisualizer.tsx | 47 +++++--- src/bin/binSensorsDisplay.tsx | 38 +++--- src/bin/binSummary/BinSummary.tsx | 178 ++++++++++------------------- src/bin/binTableView.tsx | 120 ++++++++++++------- src/theme/theme.ts | 4 +- 8 files changed, 257 insertions(+), 293 deletions(-) diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index f8107f4..36d6f27 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -138,8 +138,8 @@ export default function Bin3dView(props: Props) { { isPlaying.current = false; }; const originalBin = useRef(null); @@ -350,8 +351,6 @@ export default function BinPlayer(props: Props) { * then builds `statusCheckpoints` for playback over [startDate, endDate]. */ const startPlayer = async () => { - console.log(startDate.toISOString()) - console.log(endDate.toISOString()) originalBin.current = cloneDeep(bin); // capture before async work begins isPlaying.current = true; setIsPlayingState(true) @@ -369,8 +368,6 @@ const startPlayer = async () => { endDate, checkpointCountFromRange(startDate, endDate) ); - console.log(responses) - console.log(checkpoints) for (let i = 0; i < checkpoints.length; i++) { if (!isPlaying.current) { @@ -387,7 +384,6 @@ const startPlayer = async () => { setBin(newBin); setCurrentCheckpointTime(checkpoints[i].time) await new Promise(res => setTimeout(res, PLAYBACK_INTERVAL)); - console.log("next step") } isPlaying.current = false; setIsPlayingState(false) @@ -455,8 +451,10 @@ const dateSelector = () => { { return ( - + Heatmap @@ -111,8 +113,11 @@ export default function bin3dVisualizer(props: Props){ value={binDisplay} sx={{ borderRadius: 1, - bgcolor: 'rgba(255,255,255,0.08)', - '& .MuiSelect-select': { py: 1.5 }, + bgcolor: '#151E27', + '& .MuiSelect-select': { + py: isMobile ? 0.5 : 1.5, + fontSize: 13 + }, '& .MuiOutlinedInput-notchedOutline': { border: 'none' }, '&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' }, '&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' }, @@ -173,9 +178,12 @@ export default function bin3dVisualizer(props: Props){ } const controllerDisplay = () => { + if (fans && fans.length > 1 || heaters && heaters.length > 1){ + + return ( - - + + Controllers {fans && fans.map(fan => { @@ -187,15 +195,16 @@ export default function bin3dVisualizer(props: Props){ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - bgcolor: 'rgba(255,255,255,0.05)', + // bgcolor: 'rgba(255,255,255,0.05)', + bgcolor: '#151E27', borderRadius: 1, px: 2, py: 1, }} > - {fan.name()} + {fan.name()} - + {isOn ? "ON" : "OFF"} - {heater.name()} + {heater.name()} - + {isOn ? "ON" : "OFF"} { return ( - + {binModeControl()} {heatmapDisplay()} @@ -397,7 +410,7 @@ export default function bin3dVisualizer(props: Props){ showMoisture={showMoisture && showLabels} // showGrain={showFill} // showHotspots={showHotspots} - xOffset={isMobile ? undefined : -150} + xOffset={isMobile ? undefined : -120} /> {isMobile ? mobileHUD() : desktopHUD()} diff --git a/src/bin/binSensorsDisplay.tsx b/src/bin/binSensorsDisplay.tsx index a2085e1..9785b5e 100644 --- a/src/bin/binSensorsDisplay.tsx +++ b/src/bin/binSensorsDisplay.tsx @@ -734,7 +734,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(plenum.type(), plenum.subType(), themeType) return( - + + {tempHumidCard(plenum)} ) @@ -765,7 +765,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(pressure.type(), pressure.subType(), themeType) return( - + + {pressureCard(pressure)} ) @@ -795,7 +795,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(ambient.type(), ambient.subType(), themeType) return( - + + {tempHumidCard(ambient)} ) @@ -826,7 +826,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(h.type(), h.subType(), themeType) return( - + + {tempHumidCard(h)} ) @@ -857,7 +857,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(co2.type(), co2.subType(), themeType) return( - + + {co2Card(co2)} ) @@ -887,7 +887,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(lidar.type(), lidar.subType(), themeType) return( - + + {lidarCard(lidar)} ) @@ -917,7 +917,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(cable.type(), cable.subType(), themeType) return( - + + {cableCard(cable)} ) @@ -957,7 +957,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(heater.type(), heater.subType(), themeType) return( - + + {controllerCard(heater)} ) @@ -987,7 +987,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(fan.type(), fan.subType(), themeType) return( - + + {controllerCard(fan)} ) @@ -1020,7 +1020,7 @@ export default function BinSensorsDisplay(props: Props){ Unassigned Components {unassignedComponents.map(comp => ( - + {unassignedComponentCard(comp)} ))} diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index f868712..6d39c25 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -113,125 +113,69 @@ export default function BinSummary(props: Props){ return bin.grainInventory(user) } - const inventorySummaryMobile = () => { - return ( - - - - {/* Grain Type */} + const inventorySummaryMobile = () => ( + + {/* Header row */} + + + setOpenGrainDialog(true)}> + + - - Grain Type - - setOpenGrainDialog(true)} - sx={{ - cursor: "pointer", - mt: 0.5, - px: 1, - py: 0.5, - borderRadius: 1, - border: "1px solid", - borderColor: grey[700], - "&:hover": { - borderColor: grey[500], - backgroundColor: "rgba(255,255,255,0.05)", - }, - }} - > - - {bin.grainName()} - + Grain type + {bin.grainName()} - - {/* Bin Fill */} - - - Bin Fill - - - {/* Top line: current / capacity */} - - {currentFill()} / {bin.grainCapacity(user)} - - {/* Bottom line: bar/slider + percent */} - - {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? ( - setOpenNewTransaction(true)} - onChange={(_, val) => { - let fillPercent = val as number - setManualFillPercent(fillPercent) - setManualBushels((fillPercent / 100) * bin.bushelCapacity()) - }} - min={0} - max={100} - sx={{ - flexGrow: 1, - color: "#4caf50", - "& .MuiSlider-thumb": { width: 16, height: 16 }, - "& .MuiSlider-rail": { backgroundColor: "rgba(255,255,255,0.1)" }, - }} - /> - ) : ( - - )} - - {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL - ? manualFillPercent - : bin.fillPercent()}% - - - - - - {/* Last Updated */} - - - Last Updated - - - - {/* Top line: time */} - - - - {moment(bin.status.timestamp).fromNow()} - - - {/* Bottom line: live/inactive status */} - - {activity(bin.status.timestamp)} - - - - - - - ); - } + + + + Last updated + {moment(bin.status.timestamp).fromNow()} + + + + + {/* Fill row */} + + + Bin fill + + + {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL + ? manualFillPercent : bin.fillPercent()}% + + + {currentFill().toLocaleString()} / {bin.grainCapacity(user).toLocaleString()} + + + + {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? ( + + setOpenNewTransaction(true)} + onChange={(_, val) => { setManualFillPercent(val as number); setManualBushels((val as number / 100) * bin.bushelCapacity()); }} + min={0} max={100} sx={{ + color: "#4caf50", + py: 0, + mt: 0, + mb: 0, + height: 4, + "& .MuiSlider-root": { py: 0 }, + "& .MuiSlider-thumb": { width: 16, height: 16 }, + "& .MuiSlider-rail": { backgroundColor: "rgba(255,255,255,0.1)" }, + }} /> + + ) : ( + + )} + + + ); const inventorySummaryDesktop = () => { return ( - + {/* Grain Type */} @@ -395,7 +339,7 @@ export default function BinSummary(props: Props){ {loading ? : - + {setBin && - + } @@ -420,7 +364,7 @@ export default function BinSummary(props: Props){ {loading ? : - + : - + } diff --git a/src/bin/binTableView.tsx b/src/bin/binTableView.tsx index 33fcf6a..0659335 100644 --- a/src/bin/binTableView.tsx +++ b/src/bin/binTableView.tsx @@ -1,5 +1,5 @@ import { Edit, GppGood, Info, Save } from "@mui/icons-material"; -import { Box, Button, darken, DialogActions, IconButton, Table, TableBody, TableCell, TableHead, TableRow, TextField, Theme, Tooltip, Typography } from "@mui/material"; +import { Box, Button, darken, DialogActions, IconButton, Table, TableBody, TableCell, TableHead, TableRow, TextField, Theme, Tooltip, Typography, useTheme } from "@mui/material"; import { green, grey, orange, red, yellow } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import ResponsiveDialog from "common/ResponsiveDialog"; @@ -22,34 +22,40 @@ const useStyles = makeStyles((theme: Theme) => ({ }, headerCellStyle: { fontWeight: 650, - backgroundColor: darken(theme.palette.background.paper, 0.2), + backgroundColor: darken(theme.palette.background.paper, 0.3), textAlign: "center", - borderTop: "1px solid black", - borderLeft: "1px solid black", - borderBottom: "1px solid black", - "&:last-child": { - borderRight: "1px solid black", - } + padding: 10 }, mobileHeaderCellStyle: { fontWeight: 650, - backgroundColor: darken(theme.palette.background.paper, 0.2), + backgroundColor: darken(theme.palette.background.paper, 0.3), textAlign: "center", - borderTop: "1px solid black", - borderLeft: "1px solid black", - borderBottom: "1px solid black", - "&:last-child": { - borderRight: "1px solid black", - }, padding: 2 }, - cellStyle: { - padding: 2, - fontWeight: 650, - fontSize: 17, + targetCellStyle: { textAlign: "center", - border: "1px solid black", - + backgroundColor: theme.palette.background.paper + }, + mobileTargetCellStyle: { + textAlign: "center", + backgroundColor: theme.palette.background.paper, + padding: 2 + }, + defaultCellStyle: { + padding: 2, + fontSize: 15, + textAlign: "center", + backgroundColor: darken(theme.palette.background.paper, 0.1), + borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4), + borderBottom: "none" + }, + colouredCellStyle: { + padding: 2, + borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4), + // fontWeight: 650, + fontSize: 15, + textAlign: "center", + borderBottom: "none" } }) ); @@ -71,6 +77,7 @@ export default function BinTableView(props: Props) { const classes = useStyles() const {bin, updateBinCallback} = props const [{user}] = useGlobalState() + const theme = useTheme() const [enterTemp, setEnterTemp] = useState(false) const [enterMoisture, setEnterMoisture] = useState(false) const [tempTarget, setTempTarget] = useState(0) @@ -163,13 +170,13 @@ export default function BinTableView(props: Props) { return levels },[bin]) - const levelDisplay = (level: BinLevel) => { + const levelDisplay = (level: BinLevel, lastRow?: boolean) => { let tempDisplay = "--" let moistureDisplay = "--" let lvlTemp let lvlMoisture - let tempColour: string = "" - let moistureColour: string = "" + let tempColour: string = darken(theme.palette.background.paper, 0.1) + let moistureColour: string = darken(theme.palette.background.paper, 0.1) //determine the temp if(level.grainTemps.length > 0){ @@ -207,12 +214,12 @@ export default function BinTableView(props: Props) { lvlMoisture = avg(level.airMoistures) } if(lvlMoisture){ - moistureDisplay = lvlMoisture.toFixed(2) + "%" + moistureDisplay = lvlMoisture.toFixed(2) } return ( - {tempDisplay} - {moistureDisplay} + {tempDisplay} + {moistureDisplay} ) } @@ -301,10 +308,11 @@ export default function BinTableView(props: Props) { {expandedTableDialog()} {/* Level Table */} - Grain Table + Grain Table - - - - ); -}; + isPlaying.current = false; + setIsPlayingState(false); + setBin(originalBin.current!); + setCurrentCheckpointTime(undefined); + }; -const dateSelector = () => { - return ( - - - { - setDateSelect(event.target.value as number); - let currentDate = moment(); - setEndDate(currentDate); - switch (event.target.value) { - case 0: - setStartDate(currentDate.clone().subtract(1, 'week')); - break; - case 1: - setStartDate(currentDate.clone().subtract(2, 'weeks')); - break; - case 2: - setStartDate(currentDate.clone().subtract(4, 'weeks')); - break; - - } - }} - > - 1 Week - 2 Weeks - 4 Weeks - Custom - - - {dateSelect === 3 && ( - - )} - - ) -} + }, + '& .MuiOutlinedInput-notchedOutline': { border: 'none' }, + '&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' }, + }} + onChange={event => { + setDateSelect(event.target.value as number); + let currentDate = moment(); + setEndDate(currentDate); + switch (event.target.value) { + case 0: + setStartDate(currentDate.clone().subtract(1, 'week')); + break; + case 1: + setStartDate(currentDate.clone().subtract(2, 'weeks')); + break; + case 2: + setStartDate(currentDate.clone().subtract(4, 'weeks')); + break; + } + }} + > + 1 Week + 2 Weeks + 4 Weeks + Custom + + + {dateSelect === 3 && ( + + )} + + ); + }; return ( @@ -500,18 +694,18 @@ const dateSelector = () => { {/* start/stop button */} {isPlayingState ? ( - + ) : ( - + )} {/* progress bar and date display */} - {currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")} + {currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")} - {startDate.format("MMM D, YYYY")} - {endDate.format("MMM D, YYYY")} + {startDate.format("MMM D, YYYY")} + {endDate.format("MMM D, YYYY")} {/* date range selector */} diff --git a/src/bin/bin3dVisualizer.tsx b/src/bin/bin3dVisualizer.tsx index 50df08e..06bc722 100644 --- a/src/bin/bin3dVisualizer.tsx +++ b/src/bin/bin3dVisualizer.tsx @@ -16,8 +16,8 @@ interface Props { bin: Bin // cables?: GrainCable[] devices: Device[] - fans?: Controller[] - heaters?: Controller[] + // fans?: Controller[] + // heaters?: Controller[] permissions: pond.Permission[] componentDevices: Map componentMap: Map @@ -25,7 +25,7 @@ interface Props { updateBinCallback?: (bin: Bin) => void } export default function bin3dVisualizer(props: Props){ - const {bin, fans, heaters, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props + const {bin, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props const theme = useTheme() const [showHeatmap, setShowHeatmap] = useState(true) // const [showHotspots, setShowHotspots] = useState(false) @@ -86,7 +86,6 @@ export default function bin3dVisualizer(props: Props){ '&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' }, }} onChange={event => { - console.log("change bin mode") setBinMode(event.target.value as pond.BinMode) setOpenModeChange(true) }} @@ -159,38 +158,37 @@ export default function bin3dVisualizer(props: Props){ ) } - const controllerState = (controller: Controller) => { - let state = false - controller.status.lastGoodMeasurement.forEach(um => { - if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ - if(um.values.length > 0){ - let readings = um.values - if(readings[readings.length-1].values.length > 0){ - let nodes = readings[readings.length-1].values - if (nodes[nodes.length -1] === 1){ - state = true - } - } - } - } - }) - return state - } + // const controllerState = (controller: Controller) => { + // let state = false + // controller.status.lastGoodMeasurement.forEach(um => { + // if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ + // if(um.values.length > 0){ + // let readings = um.values + // if(readings[readings.length-1].values.length > 0){ + // let nodes = readings[readings.length-1].values + // if (nodes[nodes.length -1] === 1){ + // state = true + // } + // } + // } + // } + // }) + // return state + // } const controllerDisplay = () => { - if (fans && fans.length > 1 || heaters && heaters.length > 1){ - - + if ((bin.status.fans && bin.status.fans.length > 0) || (bin.status.heaters && bin.status.heaters.length > 0)){ return ( Controllers - {fans && fans.map(fan => { - let isOn = controllerState(fan) + {bin.status.fans && bin.status.fans.map(fan => { + // let isOn = controllerState(fan) + return ( - {fan.name()} + {fan.name} - {isOn ? "ON" : "OFF"} + {fan.state ? "ON" : "OFF"} )})} - {heaters && heaters.map(heater => { - let isOn = controllerState(heater) + {bin.status.heaters && bin.status.heaters.map(heater => { + // let isOn = controllerState(heater) return ( - {heater.name()} + {heater.name} - {isOn ? "ON" : "OFF"} + {heater.state ? "ON" : "OFF"} @@ -382,17 +380,23 @@ export default function bin3dVisualizer(props: Props){ //filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status //is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear let filtered: Component[] = [] - if(fans){ - fans.forEach((f) => { - if(componentDevices.get(f.key()) === d.id()){ - filtered.push(f.asComponent()) + if(bin.status.fans){ + bin.status.fans.forEach((f) => { + if(componentDevices.get(f.key) === d.id()){ + let comp = componentMap.get(f.key) + if(comp){ + filtered.push(comp) + } } }) } - if(heaters){ - heaters.forEach((h) => { - if(componentDevices.get(h.key()) === d.id()){ - filtered.push(h.asComponent()) + if(bin.status.heaters){ + bin.status.heaters.forEach((h) => { + if(componentDevices.get(h.key) === d.id()){ + let comp = componentMap.get(h.key) + if(comp){ + filtered.push(comp) + } } }) } diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 6d39c25..293a720 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -341,9 +341,7 @@ export default function BinSummary(props: Props){ : Date: Fri, 12 Jun 2026 14:14:12 -0600 Subject: [PATCH 44/44] commented out unused parts of the new bin page, dont know if they will be used in the future so that is why they are commented and not removed --- src/pages/BinV2.tsx | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/pages/BinV2.tsx b/src/pages/BinV2.tsx index 832c4a5..a0f9135 100644 --- a/src/pages/BinV2.tsx +++ b/src/pages/BinV2.tsx @@ -94,18 +94,18 @@ export default function BinV2(){ const [bin, setBin] = useState(IBin.create()); const [devices, setDevices] = useState([]); const [components, setComponents] = useState>(new Map()); - const [interactions, setInteractions] = useState([]); + // const [interactions, setInteractions] = useState([]); const [permissions, setPermissions] = useState([]); const [preferences, setPreferences] = useState>(); const [plenums, setPlenums] = useState([]); - const [ambients, setAmbients] = useState([]); + // const [ambients, setAmbients] = useState([]); const [grainCables, setGrainCables] = useState([]); - const [pressures, setPressures] = useState([]); - const [headspaceCO2, setHeadspaceCO2] = useState([]); + // const [pressures, setPressures] = useState([]); + // const [headspaceCO2, setHeadspaceCO2] = useState([]); const [heaters, setHeaters] = useState([]); const {openSnack} = useSnackbar() const [fans, setFans] = useState([]); - const [compositionNameMap, setCompositionNameMap] = useState>(new Map()); + // const [compositionNameMap, setCompositionNameMap] = useState>(new Map()); const [anchorEl, setAnchorEl] = useState(null); const [noteTab, setNoteTab] = useState(0) @@ -133,14 +133,14 @@ export default function BinV2(){ binAPI .getBinPageData(binID, user.id(), showErrors, as) .then(resp => { - if (resp.data.grainCompositionNames) { - let tempMap: Map = new Map(); - Object.keys(resp.data.grainCompositionNames).forEach(key => { - tempMap.set(key, resp.data.grainCompositionNames[key]); - }); - tempMap.set("correction", "Unknown Source"); - setCompositionNameMap(tempMap); - } + // if (resp.data.grainCompositionNames) { + // let tempMap: Map = new Map(); + // Object.keys(resp.data.grainCompositionNames).forEach(key => { + // tempMap.set(key, resp.data.grainCompositionNames[key]); + // }); + // tempMap.set("correction", "Unknown Source"); + // setCompositionNameMap(tempMap); + // } let devs: Device[] = []; let p = new Map(); @@ -171,11 +171,11 @@ export default function BinV2(){ }); setInteractionDevices(newInteractionDevices); - if (resp.data.interactions) { - setInteractions(resp.data.interactions.map((i: pond.Interaction) => Interaction.any(i))); - } else { - setInteractions([]); - } + // if (resp.data.interactions) { + // setInteractions(resp.data.interactions.map((i: pond.Interaction) => Interaction.any(i))); + // } else { + // setInteractions([]); + // } //go through the devices and if the device has an attached component include it in the device list if (resp.data.devices && resp.data.devices[0]) { resp.data.devices.forEach((dev: any) => { @@ -278,11 +278,11 @@ export default function BinV2(){ setMissedReadings(mostMissed); setPlenums(plenums); setGrainCables(grainCables); - setPressures(pressures); - setAmbients(ambients); + // setPressures(pressures); + // setAmbients(ambients); setHeaters(heaters); setFans(fans); - setHeadspaceCO2(headspaceCo2); + // setHeadspaceCO2(headspaceCo2); }, [components, preferences]); useEffect(() => {