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 { useEffect } from 'react';
import { useFrame } from '@react-three/fiber'; import { Environment, ContactShadows } from '@react-three/drei';
import { CameraControls, Environment, ContactShadows } from '@react-three/drei'; import { CameraControls } from '@react-three/drei';
import type { Floor3DData, Room3D } from '../../lib/layoutApi'; import type { Floor3DData, Room3D } from '../../lib/layoutApi';
import { RoomObject } from './RoomObject'; import { RoomObject } from './RoomObject';
import { Beacon } from './Beacon'; import { Beacon } from './Beacon';
import { PlantPosition, VisMode } from './types'; import { PlantPosition, VisMode } from './types';
import { CameraPresets, CameraPreset } from './CameraPresets';
// Convert pixel coordinates to world units (same as RoomObject/SmartRack) // Convert pixel coordinates to world units (same as RoomObject/SmartRack)
const SCALE = 0.1; const SCALE = 0.1;
@ -12,9 +13,10 @@ const SCALE = 0.1;
interface FacilitySceneProps { interface FacilitySceneProps {
data: Floor3DData; data: Floor3DData;
visMode: VisMode; visMode: VisMode;
targetView: { x: number; y: number; z: number; zoom?: boolean } | null; cameraPreset: CameraPreset;
focusTarget?: { x: number; z: number } | null;
onPlantClick: (plant: PlantPosition) => void; onPlantClick: (plant: PlantPosition) => void;
onControlsReady: (controls: CameraControls) => void; onControlsReady?: (controls: CameraControls) => void;
highlightedTags?: string[]; highlightedTags?: string[];
dimMode?: boolean; dimMode?: boolean;
beaconPosition?: [number, number, number] | null; beaconPosition?: [number, number, number] | null;
@ -23,69 +25,20 @@ interface FacilitySceneProps {
export function FacilityScene({ export function FacilityScene({
data, data,
visMode, visMode,
targetView, cameraPreset,
focusTarget,
onPlantClick, onPlantClick,
onControlsReady, onControlsReady,
highlightedTags = [], highlightedTags = [],
dimMode = false, dimMode = false,
beaconPosition = null, beaconPosition = null,
}: FacilitySceneProps) { }: FacilitySceneProps) {
const controlsRef = useRef<CameraControls>(null); // Scale floor dimensions for centering and camera
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
// Scale floor dimensions for centering
const scaledFloor = { const scaledFloor = {
width: data.floor.width * SCALE, width: data.floor.width * SCALE,
height: data.floor.height * 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 ( return (
<> <>
<Environment preset="city" background={false} /> <Environment preset="city" background={false} />
@ -123,13 +76,13 @@ export function FacilityScene({
color="#000" color="#000"
/> />
<CameraControls {/* Preset-based camera system */}
ref={controlsRef} <CameraPresets
minDistance={4} preset={cameraPreset}
maxDistance={120} floorWidth={scaledFloor.width}
dollySpeed={0.8} floorHeight={scaledFloor.height}
truckSpeed={1.5} focusTarget={focusTarget}
makeDefault onReady={onControlsReady}
/> />
</> </>
); );

View file

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