From cb6609bdb1350e010f4193c40ebc80a65cc68ad3 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 8 Apr 2026 09:39:42 -0600 Subject: [PATCH] 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 ||