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
This commit is contained in:
csawatzky 2026-04-08 09:39:42 -06:00
parent 3d1c11646f
commit cb6609bdb1
13 changed files with 995 additions and 0 deletions

264
package-lock.json generated
View file

@ -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
}
}
}
}
}

View file

@ -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"
},

View file

@ -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;
}

View file

@ -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<BaseMeshProps, "geometry"> {
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 <BaseMesh {...props} geometry={geo} />;
}

View file

@ -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<BaseMeshProps, "geometry"> {
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 <BaseMesh {...props} geometry={geo} />;
}

View file

@ -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<BaseMeshProps, "geometry"> {
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 <BaseMesh {...props} geometry={geo} />;
}

View file

@ -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<BaseMeshProps, "geometry"> {
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 <BaseMesh {...props} geometry={geo} />;
}

View file

@ -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<MaterialParameters>;
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 (
<meshBasicMaterial
color={colour}
transparent={isTransparent}
opacity={opacity}
depthWrite={!isTransparent}
side={side}
{...materialProps}
/>
);
case "standard":
default:
return (
<meshStandardMaterial
color={colour}
metalness={metalness}
roughness={roughness}
transparent={isTransparent}
opacity={opacity}
depthWrite={!isTransparent}
side={side}
{...materialProps}
/>
);
}
};
return (
<group position={position} rotation={rotation}>
{/* Main surface */}
<mesh geometry={geometry}>
{buildMaterial()}
</mesh>
{/* Optional wireframe overlay */}
{wireframe && (
<mesh geometry={geometry}>
<meshStandardMaterial
wireframe
color={frameColour}
/>
</mesh>
)}
</group>
);
}

View file

@ -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 (
<Canvas>
<group scale={[1/scale, 1/scale, 1/scale]}>
<BinShell
binBodyColour="#fff"
radialSegments={20}
binMetalness={0.5}
binRoughness={0.7}
binOpacity={0.5}
diameter={bin.diameter()}
sidewallHeight={bin.sidewallHeight()}
roofHeight={bin.roofHeight()}
hopperHeight={bin.hopperHeight()}
/>
{/* grain - cylinder*/}
{fillPercent !== undefined && (
<GrainFillFlat
diameter={bin.diameter()}
sidewallHeight={bin.sidewallHeight()}
hopperHeight={bin.hopperHeight()}
fillPercent={fillPercent}
/>
)}
</group>
{/* cables */}
{/* lighting */}
<ambientLight intensity={0.2} color={"white"}/>
<directionalLight intensity={0.2} color={"white"} position={[0,0,5]}/>
<directionalLight intensity={0.2} color={"white"} position={[0,0,-5]}/>
<directionalLight intensity={0.2} color={"white"} position={[5,0,0]}/>
<directionalLight intensity={0.2} color={"white"} position={[-5,0,0]}/>
{/* <hemisphereLight
color={"white"}
groundColor={"#444"}
intensity={0.7}
/> */}
{/* camera controls */}
<OrbitCameraControls clampVerticalRotation />
</Canvas>
)
}

View file

@ -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 (
<React.Fragment>
{/* bin roof - cone */}
<Cone
geometry={roofGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
position={roofPosition}
roughness={binRoughness}
side={2}
opacity={binOpacity}/>
{/* bin sidewall - cylinder (open ended for the roof and bottom)*/}
<Cylinder
geometry={cylinderGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
roughness={binRoughness}
opacity={binOpacity}/>
{/* bin bottom - cone -OR- circle depending on the bins shape*/}
{hopperHeight !== undefined && hopperHeight > 0 ?
<Cone
geometry={hopperGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
position={bottomPosition}
rotation={hopperRotation}
roughness={binRoughness}
opacity={binOpacity}
side={2}
/>
:
<Circle
geometry={flatBottomGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
position={bottomPosition}
rotation={flatBottomRotation}
roughness={binRoughness}
opacity={binOpacity}
side={2}
/>
}
</React.Fragment>
)
}

View file

@ -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 && (
<Cone
geometry={{
radius: grainRadius * (hopperFillHeight / hopperHeight),
height: hopperFillHeight,
radialSegments: 20,
openEnded: false
}}
position={hopperPosition}
rotation={hopperRotation}
colour={grainColour}
roughness={1}
metalness={0}
/>
)}
{/* Cylinder fill */}
{cylinderFillHeight > 0 && (
<Cylinder
geometry={{
radiusTop: grainRadius,
radiusBottom: grainRadius,
height: cylinderFillHeight,
radialSegments: 20,
openEnded: false
}}
position={cylinderPosition}
colour={grainColour}
roughness={1}
metalness={0}
/>
)}
</>
);
}

View file

@ -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;
}

View file

@ -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 (
<Card sx={{ padding: 1 }}>
@ -802,6 +806,17 @@ export default function Bin(props: Props) {
</Box>
{overview()}
{preferences && binComponents(preferences)}
<Card>
<Box height={1000}>
<TextField
type="number"
onChange={e => {
setFillPercent(+e.target.value)
}}
/>
<Bin3dView bin={bin} scale={100} fillPercent={fillPercent/100}/>
</Box>
</Card>
</Grid>
<Grid id="tour-conditions" size={{ xs: 12, sm: 12, md: 12, lg: 2.6 }}>
{(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING ||