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:
parent
3d1c11646f
commit
cb6609bdb1
13 changed files with 995 additions and 0 deletions
119
src/3dModels/CameraControls/OrbitCameraControls.tsx
Normal file
119
src/3dModels/CameraControls/OrbitCameraControls.tsx
Normal 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;
|
||||
}
|
||||
34
src/3dModels/Shapes/3D/Circle.tsx
Normal file
34
src/3dModels/Shapes/3D/Circle.tsx
Normal 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} />;
|
||||
}
|
||||
43
src/3dModels/Shapes/3D/Cone.tsx
Normal file
43
src/3dModels/Shapes/3D/Cone.tsx
Normal 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} />;
|
||||
}
|
||||
40
src/3dModels/Shapes/3D/Cube.tsx
Normal file
40
src/3dModels/Shapes/3D/Cube.tsx
Normal 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} />;
|
||||
}
|
||||
46
src/3dModels/Shapes/3D/Cylinder.tsx
Normal file
46
src/3dModels/Shapes/3D/Cylinder.tsx
Normal 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} />;
|
||||
}
|
||||
87
src/3dModels/Shapes/BaseMesh.tsx
Normal file
87
src/3dModels/Shapes/BaseMesh.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue