feat: add camera presets (Overhead/Isometric/Room Focus)
- 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:
parent
d56b7f0b11
commit
dc0b357638
3 changed files with 211 additions and 87 deletions
154
frontend/src/components/facility3d/CameraPresets.tsx
Normal file
154
frontend/src/components/facility3d/CameraPresets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue