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

This commit is contained in:
csawatzky 2026-05-01 13:11:12 -06:00
parent b3cf5b5e83
commit 4b2c67dfd9
11 changed files with 974 additions and 220 deletions

View file

@ -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 <Canvas> 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<HTMLDivElement | null>(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(
<div style={{
position: "absolute",
bottom: 12,
right: 12,
display: "flex",
flexDirection: "column",
gap: 8,
pointerEvents: "auto",
}}>
{showResetButton && (
<button
onClick={onReset}
title="Reset camera"
style={{
width: 36,
height: 36,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(0,0,0,0.45)",
color: "white",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
backdropFilter: "blur(4px)",
transition: "background 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(0,0,0,0.7)")}
onMouseLeave={e => (e.currentTarget.style.background = "rgba(0,0,0,0.45)")}
>
</button>
)}
</div>,
container
);
}

View file

@ -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]);