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

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