feat: add camera presets (Overhead/Isometric/Room Focus)
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Create CameraPresets.tsx with preset views and UI selector
- Replace free camera controls with preset-based system
- Add smooth animated transitions between camera positions
- Update Facility3DViewerPage with preset selector buttons
- Remove unused WASD keyboard controls
This commit is contained in:
fullsizemalt 2025-12-18 19:30:32 -08:00
parent d56b7f0b11
commit dc0b357638
3 changed files with 211 additions and 87 deletions

View file

@ -0,0 +1,154 @@
import { useEffect, useRef } from 'react';
import { useThree } from '@react-three/fiber';
import { CameraControls } from '@react-three/drei';
import * as THREE from 'three';
export type CameraPreset = 'OVERHEAD' | 'ISOMETRIC' | 'ROOM_FOCUS';
interface CameraPresetsProps {
preset: CameraPreset;
floorWidth: number;
floorHeight: number;
focusTarget?: { x: number; z: number } | null;
onReady?: (controls: CameraControls) => void;
}
// Camera preset configurations
const getPresetConfig = (
preset: CameraPreset,
floorWidth: number,
floorHeight: number,
focusTarget?: { x: number; z: number } | null
) => {
const centerX = 0;
const centerZ = 0;
const maxDim = Math.max(floorWidth, floorHeight);
switch (preset) {
case 'OVERHEAD':
// Top-down view - looking straight down
return {
position: [centerX, maxDim * 1.2, centerZ] as [number, number, number],
target: [centerX, 0, centerZ] as [number, number, number],
};
case 'ISOMETRIC':
// 3/4 isometric view - classic strategy game angle
const isoDistance = maxDim * 0.8;
return {
position: [centerX + isoDistance, isoDistance * 0.7, centerZ + isoDistance] as [number, number, number],
target: [centerX, 0, centerZ] as [number, number, number],
};
case 'ROOM_FOCUS':
// Focus on a specific room/area
const fx = focusTarget?.x ?? centerX;
const fz = focusTarget?.z ?? centerZ;
return {
position: [fx + 15, 12, fz + 15] as [number, number, number],
target: [fx, 0, fz] as [number, number, number],
};
default:
return {
position: [centerX + 40, 30, centerZ + 40] as [number, number, number],
target: [centerX, 0, centerZ] as [number, number, number],
};
}
};
export function CameraPresets({
preset,
floorWidth,
floorHeight,
focusTarget,
onReady,
}: CameraPresetsProps) {
const controlsRef = useRef<CameraControls>(null);
const lastPresetRef = useRef<string>('');
// Notify parent when controls are ready
useEffect(() => {
if (controlsRef.current && onReady) {
onReady(controlsRef.current);
}
}, [onReady]);
// Apply preset when it changes
useEffect(() => {
if (!controlsRef.current) return;
const presetKey = `${preset}-${focusTarget?.x ?? 0}-${focusTarget?.z ?? 0}`;
if (presetKey === lastPresetRef.current) return;
lastPresetRef.current = presetKey;
const config = getPresetConfig(preset, floorWidth, floorHeight, focusTarget);
// Smooth transition to new position
controlsRef.current.setLookAt(
config.position[0],
config.position[1],
config.position[2],
config.target[0],
config.target[1],
config.target[2],
true // Enable smooth transition
);
}, [preset, floorWidth, floorHeight, focusTarget]);
return (
<CameraControls
ref={controlsRef}
// Restrict interactions - no free orbiting
enabled={true}
// Allow zoom but restrict rotation
minDistance={5}
maxDistance={Math.max(floorWidth, floorHeight) * 2}
// Limit polar angle (prevent going under the floor)
minPolarAngle={0}
maxPolarAngle={Math.PI / 2.2}
// Disable pan/truck for preset mode - camera is fixed to presets
dollySpeed={0.5}
truckSpeed={0}
// Smooth damping
smoothTime={0.3}
makeDefault
/>
);
}
// UI Component for preset selector buttons
interface CameraPresetSelectorProps {
current: CameraPreset;
onChange: (preset: CameraPreset) => void;
}
export function CameraPresetSelector({ current, onChange }: CameraPresetSelectorProps) {
const presets: { id: CameraPreset; label: string; icon: string }[] = [
{ id: 'OVERHEAD', label: 'Overhead', icon: '⬇️' },
{ id: 'ISOMETRIC', label: 'Isometric', icon: '📐' },
{ id: 'ROOM_FOCUS', label: 'Room', icon: '🔍' },
];
return (
<div className="flex gap-1 bg-slate-800/80 rounded-lg p-1">
{presets.map(({ id, label, icon }) => (
<button
key={id}
onClick={() => onChange(id)}
className={`
px-3 py-1.5 rounded text-xs font-medium transition-all
${current === id
? 'bg-blue-600 text-white'
: 'text-slate-300 hover:bg-slate-700'
}
`}
title={label}
>
<span className="mr-1">{icon}</span>
{label}
</button>
))}
</div>
);
}

View file

@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useFrame } from '@react-three/fiber';
import { CameraControls, Environment, ContactShadows } from '@react-three/drei';
import { useEffect } from 'react';
import { Environment, ContactShadows } from '@react-three/drei';
import { CameraControls } from '@react-three/drei';
import type { Floor3DData, Room3D } from '../../lib/layoutApi';
import { RoomObject } from './RoomObject';
import { Beacon } from './Beacon';
import { PlantPosition, VisMode } from './types';
import { CameraPresets, CameraPreset } from './CameraPresets';
// Convert pixel coordinates to world units (same as RoomObject/SmartRack)
const SCALE = 0.1;
@ -12,9 +13,10 @@ const SCALE = 0.1;
interface FacilitySceneProps {
data: Floor3DData;
visMode: VisMode;
targetView: { x: number; y: number; z: number; zoom?: boolean } | null;
cameraPreset: CameraPreset;
focusTarget?: { x: number; z: number } | null;
onPlantClick: (plant: PlantPosition) => void;
onControlsReady: (controls: CameraControls) => void;
onControlsReady?: (controls: CameraControls) => void;
highlightedTags?: string[];
dimMode?: boolean;
beaconPosition?: [number, number, number] | null;
@ -23,69 +25,20 @@ interface FacilitySceneProps {
export function FacilityScene({
data,
visMode,
targetView,
cameraPreset,
focusTarget,
onPlantClick,
onControlsReady,
highlightedTags = [],
dimMode = false,
beaconPosition = null,
}: FacilitySceneProps) {
const controlsRef = useRef<CameraControls>(null);
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
// Scale floor dimensions for centering
// Scale floor dimensions for centering and camera
const scaledFloor = {
width: data.floor.width * SCALE,
height: data.floor.height * SCALE,
};
useEffect(() => {
if (controlsRef.current) onControlsReady(controlsRef.current);
}, [controlsRef.current, onControlsReady]);
useEffect(() => {
if (targetView && controlsRef.current) {
const { x, z, zoom } = targetView;
const dist = zoom ? 12 : 35;
const height = zoom ? 10 : 30;
controlsRef.current.setLookAt(x + dist, height, z + dist, x, 0, z, true);
}
}, [targetView]);
useEffect(() => {
const handleDown = (e: KeyboardEvent) => {
// Don't capture keys when an input/textarea is focused
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {
return;
}
if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
e.preventDefault();
setKeysPressed(k => ({ ...k, [e.code]: true }));
}
};
const handleUp = (e: KeyboardEvent) => {
setKeysPressed(k => ({ ...k, [e.code]: false }));
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
window.removeEventListener('keydown', handleDown);
window.removeEventListener('keyup', handleUp);
};
}, []);
useFrame((_, delta) => {
if (!controlsRef.current) return;
const speed = 18 * delta;
if (keysPressed['KeyW'] || keysPressed['ArrowUp']) controlsRef.current.forward(speed, true);
if (keysPressed['KeyS'] || keysPressed['ArrowDown']) controlsRef.current.forward(-speed, true);
if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) controlsRef.current.truck(-speed, 0, true);
if (keysPressed['KeyD'] || keysPressed['ArrowRight']) controlsRef.current.truck(speed, 0, true);
});
return (
<>
<Environment preset="city" background={false} />
@ -123,13 +76,13 @@ export function FacilityScene({
color="#000"
/>
<CameraControls
ref={controlsRef}
minDistance={4}
maxDistance={120}
dollySpeed={0.8}
truckSpeed={1.5}
makeDefault
{/* Preset-based camera system */}
<CameraPresets
preset={cameraPreset}
floorWidth={scaledFloor.width}
floorHeight={scaledFloor.height}
focusTarget={focusTarget}
onReady={onControlsReady}
/>
</>
);

View file

@ -2,12 +2,13 @@ import { useEffect, useState, Suspense, Component, ReactNode, useCallback } from
import { Canvas } from '@react-three/fiber';
import { Html, CameraControls } from '@react-three/drei';
import { layoutApi, Floor3DData, Room3D, Position3D } from '../lib/layoutApi';
import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf, Eye, EyeOff, Clock } from 'lucide-react';
import { Loader2, ArrowLeft, Maximize, MousePointer2 } from 'lucide-react';
import { Link, useSearchParams } from 'react-router-dom';
import { FacilityScene } from '../components/facility3d/FacilityScene';
import { PlantSearch } from '../components/facility3d/PlantSearch';
import { TimelineSlider } from '../components/facility3d/TimelineSlider';
import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets';
// --- Error Boundary ---
interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
@ -37,8 +38,6 @@ export default function Facility3DViewerPage() {
const [status, setStatus] = useState('Initializing...');
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
const [selectedPlant, setSelectedPlant] = useState<PlantPosition | null>(null);
const [targetView, setTargetView] = useState<{ x: number; y: number; z: number; zoom?: boolean } | null>(null);
const [_controls, setControls] = useState<CameraControls | null>(null);
const [visMode, setVisMode] = useState<VisMode>('STANDARD');
// Phase 2: Search state
@ -55,6 +54,10 @@ export default function Facility3DViewerPage() {
const [allFloors, setAllFloors] = useState<{ id: string; name: string; buildingName: string }[]>([]);
const [selectedFloorId, setSelectedFloorId] = useState<string | null>(null);
// Camera preset state
const [cameraPreset, setCameraPreset] = useState<CameraPreset>('ISOMETRIC');
const [focusTarget, setFocusTarget] = useState<{ x: number; z: number } | null>(null);
const [searchParams] = useSearchParams();
const targetedPlantTag = searchParams.get('plant');
@ -76,15 +79,16 @@ export default function Facility3DViewerPage() {
for (const section of room.sections) {
const match = section.positions.find((p: Position3D) => p.plant?.tagNumber === targetedPlantTag);
if (match) {
const spacing = 0.5;
const x = room.posX + section.posX + (match.column * spacing) - floorData.floor.width / 2;
const z = room.posY + section.posY + (match.row * spacing) - floorData.floor.height / 2;
const SCALE = 0.1;
const x = (room.posX + section.posX + (match.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2);
const z = (room.posY + section.posY + (match.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2);
const y = 0.4 + (match.tier * 0.6);
const plant: PlantPosition = { ...match, x, y, z };
setSelectedPlant(plant);
setTargetView({ x, y: 0, z, zoom: true });
setBeaconPosition([x, 0, z]);
setFocusTarget({ x, z });
setCameraPreset('ROOM_FOCUS');
setBeaconPosition([x, y, z]);
setHighlightedTags([targetedPlantTag]);
setDimMode(true);
return;
@ -138,14 +142,12 @@ export default function Facility3DViewerPage() {
const focusRoom = (room: Room3D) => {
if (!floorData) return;
const offsetX = -floorData.floor.width / 2;
const offsetZ = -floorData.floor.height / 2;
setTargetView({
x: room.posX + room.width / 2 + offsetX,
y: 0,
z: room.posY + room.height / 2 + offsetZ,
zoom: true,
});
const SCALE = 0.1;
// Calculate focus target in scaled world coordinates
const x = (room.posX + room.width / 2) * SCALE - (floorData.floor.width * SCALE / 2);
const z = (room.posY + room.height / 2) * SCALE - (floorData.floor.height * SCALE / 2);
setFocusTarget({ x, z });
setCameraPreset('ROOM_FOCUS');
// Clear search state when navigating by room
setBeaconPosition(null);
setHighlightedTags([]);
@ -153,7 +155,8 @@ export default function Facility3DViewerPage() {
};
const resetView = () => {
setTargetView({ x: 0, y: 0, z: 0, zoom: false });
setCameraPreset('ISOMETRIC');
setFocusTarget(null);
setBeaconPosition(null);
setHighlightedTags([]);
setDimMode(false);
@ -164,12 +167,13 @@ export default function Facility3DViewerPage() {
const handleSearchSelect = useCallback((result: any) => {
if (!floorData) return;
const spacing = 0.5;
const x = result.position.roomX + result.position.sectionX + (result.position.column * spacing) - floorData.floor.width / 2;
const z = result.position.roomY + result.position.sectionY + (result.position.row * spacing) - floorData.floor.height / 2;
const SCALE = 0.1;
const x = (result.position.roomX + result.position.sectionX + (result.position.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2);
const z = (result.position.roomY + result.position.sectionY + (result.position.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2);
const y = 0.4 + (result.position.tier * 0.6);
setTargetView({ x, y: 0, z, zoom: true });
setFocusTarget({ x, z });
setCameraPreset('ROOM_FOCUS');
setBeaconPosition([x, y, z]);
setHighlightedTags([result.label]);
setDimMode(true);
@ -236,6 +240,19 @@ export default function Facility3DViewerPage() {
</div>
)}
{/* Camera Preset Selector */}
<div className="pointer-events-auto">
<CameraPresetSelector
current={cameraPreset}
onChange={(preset) => {
setCameraPreset(preset);
if (preset !== 'ROOM_FOCUS') {
setFocusTarget(null);
}
}}
/>
</div>
{/* Legend */}
<div className="flex gap-3 text-xs bg-black/60 p-2 rounded-lg backdrop-blur border border-white/10 pointer-events-auto">
{visMode === 'STANDARD' && (
@ -409,9 +426,9 @@ export default function Facility3DViewerPage() {
<FacilityScene
data={floorData}
visMode={visMode}
targetView={targetView}
cameraPreset={cameraPreset}
focusTarget={focusTarget}
onPlantClick={setSelectedPlant}
onControlsReady={setControls}
highlightedTags={highlightedTags}
dimMode={dimMode}
beaconPosition={beaconPosition}