From dc0b357638769b81b5cd7902765abe78028f4314 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:30:32 -0800 Subject: [PATCH] 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 --- .../components/facility3d/CameraPresets.tsx | 154 ++++++++++++++++++ .../components/facility3d/FacilityScene.tsx | 81 ++------- frontend/src/pages/Facility3DViewerPage.tsx | 63 ++++--- 3 files changed, 211 insertions(+), 87 deletions(-) create mode 100644 frontend/src/components/facility3d/CameraPresets.tsx diff --git a/frontend/src/components/facility3d/CameraPresets.tsx b/frontend/src/components/facility3d/CameraPresets.tsx new file mode 100644 index 0000000..483b6e9 --- /dev/null +++ b/frontend/src/components/facility3d/CameraPresets.tsx @@ -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(null); + const lastPresetRef = useRef(''); + + // 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 ( + + ); +} + +// 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 ( +
+ {presets.map(({ id, label, icon }) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/facility3d/FacilityScene.tsx b/frontend/src/components/facility3d/FacilityScene.tsx index d229871..2910715 100644 --- a/frontend/src/components/facility3d/FacilityScene.tsx +++ b/frontend/src/components/facility3d/FacilityScene.tsx @@ -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(null); - const [keysPressed, setKeysPressed] = useState>({}); - - // 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 ( <> @@ -123,13 +76,13 @@ export function FacilityScene({ color="#000" /> - ); diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx index 5e048ba..c8d0ea2 100644 --- a/frontend/src/pages/Facility3DViewerPage.tsx +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -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(null); const [selectedPlant, setSelectedPlant] = useState(null); - const [targetView, setTargetView] = useState<{ x: number; y: number; z: number; zoom?: boolean } | null>(null); - const [_controls, setControls] = useState(null); const [visMode, setVisMode] = useState('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(null); + // Camera preset state + const [cameraPreset, setCameraPreset] = useState('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() { )} + {/* Camera Preset Selector */} +
+ { + setCameraPreset(preset); + if (preset !== 'ROOM_FOCUS') { + setFocusTarget(null); + } + }} + /> +
+ {/* Legend */}
{visMode === 'STANDARD' && ( @@ -409,9 +426,9 @@ export default function Facility3DViewerPage() {