diff --git a/docs/CINEMATIC_CAMERA_REPORT.md b/docs/CINEMATIC_CAMERA_REPORT.md new file mode 100644 index 0000000..2fb0009 --- /dev/null +++ b/docs/CINEMATIC_CAMERA_REPORT.md @@ -0,0 +1,135 @@ +# Cinematic Camera Report + +## Summary + +Added cinematic camera playback system that automatically transitions between camera angles during the 8-case demo, creating a video-like experience. + +## Camera Modes Implemented + +| Mode | Phase(s) | Description | +|------|----------|-------------| +| `feedCloseup` | spawn | Close-up view of item appearing on belt at zone A | +| `overview` | move_to_detection | Wide view following item along conveyor | +| `inspectionTop` | detection | Top-down view showing camera rig and inspection zone | +| `measurementSide` | measurement | Side view showing laser beam and item height | +| `classificationTop` | classification | Top view for shape analysis visualization | +| `routingWide` | command_sent | Wide view showing entire routing area | +| `chuteCloseup` | routing | Close-up of chute/deflector and item routing | +| `resultZone` | exit | View of destination zone (B/C/D roll-cage) | +| `nextItemReset` | clear_gap | Reset view preparing for next item | + +## How Camera Follows Item + +1. **Target following**: During movement phases (`move_to_detection`, `routing`, `exit`), camera target partially follows item position with configurable weight (0.3-0.5) + +2. **Smooth transitions**: Uses lerp (linear interpolation) with smoothing factor of 0.04 for gradual transitions between camera positions, targets, and FOV + +3. **Category-aware routing**: Camera adjusts view based on target category: + - B: Main conveyor line view + - C: Rotates toward positive Z (orange roll-cage) + - D: Rotates toward negative Z (purple roll-cage) + +4. **Viewport adaptation**: Camera positions adjusted for screen size: + - Desktop: Standard positions + - Laptop: 15% higher, 10% further back + - Mobile: 30% higher, 25% further back, +8° FOV + +## Files Created/Modified + +### Created +- `src/domain/cinematicCamera.ts` - Camera director logic +- `src/domain/cinematicCamera.test.ts` - Unit tests (20 tests) +- `scripts/test_cinematic_camera.py` - Browser QA script + +### Modified +- `src/components/ThreeD/SorterDigitalTwinContinuous.tsx` - Added: + - `CinematicCameraController` component + - `MotionTrail` component for movement feedback + - Integration with OrbitControls (disabled during cinematic mode) +- `src/pages/MainPage.tsx` - Added: + - Auto camera toggle button + - Viewport type detection + - Props passing to 3D component +- `src/styles.css` - Added `.auto-camera-toggle` styles + +## Features + +### Auto Camera Toggle +- Button in bottom-left corner: "AUTO CAM: ON/OFF" +- When ON: Cinematic camera active during playback +- When OFF: Manual OrbitControls enabled +- Toggle persists during session + +### Motion Trail +- Subtle visual trail behind moving items +- Shows during `move_to_detection`, `routing`, `exit` phases +- Direction matches item movement (X for main belt, Z for C/D routing) +- Color matches target category + +### Viewport Adaptations +- Desktop (≥1200px): Full cinematic experience +- Laptop (768-1199px): Higher/wider camera, no overlay collision +- Mobile (<768px): Fallback or simplified view, compact overlay + +## Phase Coverage + +All 9 playback phases have associated camera modes: +- ✅ spawn → feedCloseup +- ✅ move_to_detection → overview +- ✅ detection → inspectionTop +- ✅ measurement → measurementSide +- ✅ classification → classificationTop +- ✅ command_sent → routingWide +- ✅ routing → chuteCloseup +- ✅ exit → resultZone +- ✅ clear_gap → nextItemReset + +## Screenshots + +Location: `docs/cinematic_camera_screenshots/` + +- `overview.png` - Initial overview +- `feed_closeup.png` - Item spawn close-up +- `inspection_top.png` - Detection from above +- `measurement_side.png` - Side measurement view +- `routing_wide.png` - Wide routing view +- `chute_closeup.png` - Chute close-up +- `result_zone.png` - Exit to destination +- `c_routing.png` - C-zone routing +- `d_routing.png` - D-zone routing +- `mobile_view.png` - Mobile layout + +## Test Results + +``` +npm run build - SUCCESS +npm run test - 113 tests passed (11 test files) + - cinematicCamera.test.ts: 20 tests +docker compose up -d --build - SUCCESS +Browser QA - No console errors +``` + +## Limitations + +1. **Mobile WebGL**: In headless browser testing, mobile shows fallback due to WebGL not available. Real mobile browsers with WebGL will show the 3D scene. + +2. **Camera shake**: Very fast movements may show minor camera jitter. Smoothing factor (0.04) balances responsiveness vs stability. + +3. **Manual control resume**: After pausing and manually rotating camera, resuming playback snaps back to cinematic angle. + +## Commit Commands + +```bash +git add -A +git commit -m "feat: cinematic camera playback for 3D demo + +- Add cinematicCamera.ts with 9 camera modes +- Smooth camera transitions with lerp +- Camera follows item during movement +- Category-aware routing views (B/C/D) +- Viewport adaptation (desktop/laptop/mobile) +- Motion trail for movement feedback +- Auto camera toggle button +- 20 unit tests for camera logic" +git push origin dan_branch +``` diff --git a/docs/cinematic_camera_screenshots/c_routing.png b/docs/cinematic_camera_screenshots/c_routing.png new file mode 100644 index 0000000..a66bc14 Binary files /dev/null and b/docs/cinematic_camera_screenshots/c_routing.png differ diff --git a/docs/cinematic_camera_screenshots/chute_closeup.png b/docs/cinematic_camera_screenshots/chute_closeup.png new file mode 100644 index 0000000..bd1a038 Binary files /dev/null and b/docs/cinematic_camera_screenshots/chute_closeup.png differ diff --git a/docs/cinematic_camera_screenshots/d_routing.png b/docs/cinematic_camera_screenshots/d_routing.png new file mode 100644 index 0000000..f643801 Binary files /dev/null and b/docs/cinematic_camera_screenshots/d_routing.png differ diff --git a/docs/cinematic_camera_screenshots/details_page.png b/docs/cinematic_camera_screenshots/details_page.png new file mode 100644 index 0000000..739ba0c Binary files /dev/null and b/docs/cinematic_camera_screenshots/details_page.png differ diff --git a/docs/cinematic_camera_screenshots/feed_closeup.png b/docs/cinematic_camera_screenshots/feed_closeup.png new file mode 100644 index 0000000..26e26a6 Binary files /dev/null and b/docs/cinematic_camera_screenshots/feed_closeup.png differ diff --git a/docs/cinematic_camera_screenshots/inspection_top.png b/docs/cinematic_camera_screenshots/inspection_top.png new file mode 100644 index 0000000..b66a30c Binary files /dev/null and b/docs/cinematic_camera_screenshots/inspection_top.png differ diff --git a/docs/cinematic_camera_screenshots/measurement_side.png b/docs/cinematic_camera_screenshots/measurement_side.png new file mode 100644 index 0000000..61b435b Binary files /dev/null and b/docs/cinematic_camera_screenshots/measurement_side.png differ diff --git a/docs/cinematic_camera_screenshots/mobile_view.png b/docs/cinematic_camera_screenshots/mobile_view.png new file mode 100644 index 0000000..1e01348 Binary files /dev/null and b/docs/cinematic_camera_screenshots/mobile_view.png differ diff --git a/docs/cinematic_camera_screenshots/overview.png b/docs/cinematic_camera_screenshots/overview.png new file mode 100644 index 0000000..5ae64e1 Binary files /dev/null and b/docs/cinematic_camera_screenshots/overview.png differ diff --git a/docs/cinematic_camera_screenshots/result_zone.png b/docs/cinematic_camera_screenshots/result_zone.png new file mode 100644 index 0000000..688dfa6 Binary files /dev/null and b/docs/cinematic_camera_screenshots/result_zone.png differ diff --git a/docs/cinematic_camera_screenshots/routing_wide.png b/docs/cinematic_camera_screenshots/routing_wide.png new file mode 100644 index 0000000..c4048c4 Binary files /dev/null and b/docs/cinematic_camera_screenshots/routing_wide.png differ diff --git a/scripts/test_cinematic_camera.py b/scripts/test_cinematic_camera.py new file mode 100644 index 0000000..5d4b10d --- /dev/null +++ b/scripts/test_cinematic_camera.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Browser QA script for cinematic camera. +Takes screenshots at different camera angles during playback. +""" + +import asyncio +from pathlib import Path +from playwright.async_api import async_playwright + +BASE_URL = "https://arhipovdan.ru" +SCREENSHOT_DIR = Path(__file__).parent.parent / "docs" / "cinematic_camera_screenshots" + +async def main(): + SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + + # Desktop viewport + context = await browser.new_context(viewport={"width": 1920, "height": 1080}) + page = await context.new_page() + + # Collect console errors + errors = [] + page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None) + + print("Loading production site...") + await page.goto(BASE_URL, wait_until="networkidle", timeout=30000) + await asyncio.sleep(2) + + # Screenshot 1: Overview (initial state) + print("Screenshot: overview...") + await page.screenshot(path=str(SCREENSHOT_DIR / "overview.png")) + + # Start playback + print("Starting playback...") + play_button = page.locator(".play-button, button:has-text('Play')") + if await play_button.count() > 0: + await play_button.first.click() + + # Screenshot 2: Feed closeup (spawn phase) + await asyncio.sleep(0.5) + print("Screenshot: feed_closeup (spawn)...") + await page.screenshot(path=str(SCREENSHOT_DIR / "feed_closeup.png")) + + # Screenshot 3: Inspection top (detection phase) + await asyncio.sleep(1.5) + print("Screenshot: inspection_top (detection)...") + await page.screenshot(path=str(SCREENSHOT_DIR / "inspection_top.png")) + + # Screenshot 4: Measurement side (measurement phase) + await asyncio.sleep(0.8) + print("Screenshot: measurement_side (measurement)...") + await page.screenshot(path=str(SCREENSHOT_DIR / "measurement_side.png")) + + # Screenshot 5: Routing wide (command sent + routing) + await asyncio.sleep(1.5) + print("Screenshot: routing_wide (routing)...") + await page.screenshot(path=str(SCREENSHOT_DIR / "routing_wide.png")) + + # Screenshot 6: Chute closeup (during routing) + await asyncio.sleep(0.5) + print("Screenshot: chute_closeup...") + await page.screenshot(path=str(SCREENSHOT_DIR / "chute_closeup.png")) + + # Screenshot 7: Result zone (exit phase) + await asyncio.sleep(1.0) + print("Screenshot: result_zone (exit)...") + await page.screenshot(path=str(SCREENSHOT_DIR / "result_zone.png")) + + # Wait for a few more cases to see C/D routing + print("Waiting for C/D routing cases...") + await asyncio.sleep(15) + await page.screenshot(path=str(SCREENSHOT_DIR / "c_routing.png")) + + await asyncio.sleep(10) + await page.screenshot(path=str(SCREENSHOT_DIR / "d_routing.png")) + + # Screenshot 8: Mobile viewport + print("Testing mobile viewport...") + await context.close() + context = await browser.new_context(viewport={"width": 390, "height": 844}) + page = await context.new_page() + + await page.goto(BASE_URL, wait_until="networkidle", timeout=30000) + await asyncio.sleep(2) + + # Start playback on mobile + play_button = page.locator(".play-button, button:has-text('Play')") + if await play_button.count() > 0: + await play_button.first.click() + await asyncio.sleep(3) + + await page.screenshot(path=str(SCREENSHOT_DIR / "mobile_view.png")) + + # Check details page + print("Checking /details...") + await page.goto(f"{BASE_URL}/details", wait_until="networkidle", timeout=30000) + await asyncio.sleep(1) + await page.screenshot(path=str(SCREENSHOT_DIR / "details_page.png")) + + await context.close() + await browser.close() + + # Report + print(f"\nScreenshots saved to: {SCREENSHOT_DIR}") + if errors: + print(f"\nConsole errors found ({len(errors)}):") + for e in errors[:10]: + print(f" - {e[:100]}") + else: + print("\nNo console errors found.") + + print("\nCinematic Camera QA Complete!") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index 3c9c717..a51e761 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -8,10 +8,11 @@ * - Items ride ON the belt surface */ -import { Canvas, useFrame, useLoader } from '@react-three/fiber'; +import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'; import { Grid, OrbitControls, Html, Line } from '@react-three/drei'; -import { Suspense, useRef, useMemo, useState } from 'react'; +import { Suspense, useRef, useMemo, useState, useEffect } from 'react'; import type { Mesh, Group, BufferGeometry } from 'three'; +import * as THREE from 'three'; import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; import type { ContinuousPlaybackState, CasePhase } from '../../domain/continuousPlayback'; import { isDetectionActive, isRoutingActive, getPhaseProgress } from '../../domain/continuousPlayback'; @@ -21,6 +22,13 @@ import { getMeasurementData, shouldShowLaserBeam, shouldShowStepperPulse, should import { getModelAsset } from '../../data/modelAssets'; import { ITEMS } from '../../data/items'; import type { Category } from '../../domain/types'; +import { + getCameraConfig, + smoothCameraTransition, + getInitialCameraConfig, + type CameraConfig, + type ViewportType, +} from '../../domain/cinematicCamera'; import { BELT_TOP_Y, BELT_THICKNESS_M, @@ -47,6 +55,8 @@ export interface SorterDigitalTwinContinuousProps { playback: ContinuousPlaybackState; simplified?: boolean; onContextLost?: () => void; + autoCameraEnabled?: boolean; + viewportType?: ViewportType; } /** Light color palette */ @@ -79,6 +89,93 @@ const CONVEYOR_END_X = 4.3; const CONVEYOR_LENGTH = CONVEYOR_END_X - CONVEYOR_START_X; const CONVEYOR_CENTER_X = (CONVEYOR_START_X + CONVEYOR_END_X) / 2; +/** Cinematic camera controller - smoothly transitions between camera angles */ +function CinematicCameraController({ + playback, + enabled, + viewportType = 'desktop' +}: { + playback: ContinuousPlaybackState; + enabled: boolean; + viewportType: ViewportType; +}) { + const { camera } = useThree(); + const cameraStateRef = useRef(getInitialCameraConfig(viewportType)); + const isRunning = playback.status === 'running'; + + // Get item position for camera following + const itemPosition = useMemo(() => { + if (playback.status === 'idle') return null; + const pos = getItemPosition(playback); + return [pos.x, pos.y, pos.z] as [number, number, number]; + }, [playback]); + + useFrame(() => { + if (!enabled || !isRunning) return; + + // Get target camera config for current phase + const targetConfig = getCameraConfig( + playback.currentPhase, + playback.targetCategory, + itemPosition, + viewportType + ); + + // Smooth transition + cameraStateRef.current = smoothCameraTransition( + cameraStateRef.current, + targetConfig, + 0.04 // Smooth factor - lower = smoother + ); + + // Apply to camera + const state = cameraStateRef.current; + camera.position.set(state.position[0], state.position[1], state.position[2]); + camera.lookAt(state.target[0], state.target[1], state.target[2]); + + // Update FOV if perspective camera + if (camera instanceof THREE.PerspectiveCamera) { + camera.fov = state.fov; + camera.updateProjectionMatrix(); + } + }); + + return null; +} + +/** Subtle motion trail behind moving items */ +function MotionTrail({ + position, + visible, + color, + direction +}: { + position: [number, number, number]; + visible: boolean; + color: string; + direction: 'x' | 'z'; +}) { + if (!visible) return null; + + const trailLength = 0.15; + const offset = direction === 'x' ? [-trailLength, 0, 0] : [0, 0, -trailLength * Math.sign(position[2] || 1)]; + + return ( + + + + + ); +} + /** Single animated roller that rotates around its own axis */ function Roller({ position, speedFactor }: { position: [number, number, number]; speedFactor: number }) { const meshRef = useRef(null); @@ -1137,7 +1234,17 @@ function PlaybackItem({ playback }: { playback: ContinuousPlaybackState }) { } /** Main continuous scene - light warehouse style with correct physical dimensions */ -function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybackState; simplified: boolean }) { +function ContinuousScene({ + playback, + simplified, + autoCameraEnabled, + viewportType +}: { + playback: ContinuousPlaybackState; + simplified: boolean; + autoCameraEnabled: boolean; + viewportType: ViewportType; +}) { const speedFactor = getConveyorSpeedFactor(playback); const activeRoute = getActiveRoute(playback); const category = playback.targetCategory; @@ -1164,6 +1271,17 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac const isRound = itemData.roundness >= 0.7; const dims = getRenderedItemDimensions(itemData.dimensionsMm); const itemScale = Math.max(dims.width, dims.depth, dims.height); + + // Motion trail visibility - show during movement phases + const showMotionTrail = ['move_to_detection', 'routing', 'exit'].includes(phase) && + playback.status === 'running'; + const trailDirection = phase === 'routing' || phase === 'exit' + ? (activeRoute === 'C' || activeRoute === 'D' ? 'z' : 'x') + : 'x'; + const itemColor = category ? COLORS[`route${category}` as keyof typeof COLORS] : COLORS.sensorAccent; + + // Cinematic camera active only when running and enabled + const cinematicActive = autoCameraEnabled && playback.status === 'running'; return ( <> @@ -1279,10 +1397,27 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac {/* Item riding ON the belt */} + + {/* Motion trail for visual movement feedback */} + + {/* Cinematic camera controller */} + + + {/* OrbitControls - enabled when not in cinematic mode */} @@ -1316,7 +1453,12 @@ export default function SorterDigitalTwinContinuous({ }} > - + diff --git a/src/domain/cinematicCamera.test.ts b/src/domain/cinematicCamera.test.ts new file mode 100644 index 0000000..5ab7dd9 --- /dev/null +++ b/src/domain/cinematicCamera.test.ts @@ -0,0 +1,168 @@ +/** + * Tests for cinematicCamera module. + */ + +import { describe, it, expect } from 'vitest'; +import { + getCameraModeForPhase, + getCameraConfig, + smoothCameraTransition, + isValidCameraConfig, + phaseHasCameraMode, + getInitialCameraConfig, + getAllCameraModes, + getViewportType, + lerpVector3, + lerp, +} from './cinematicCamera'; +import { CASE_PHASES } from './continuousPlayback'; +import type { Category } from './types'; + +describe('getCameraModeForPhase', () => { + it('returns a mode for every playback phase', () => { + for (const phaseConfig of CASE_PHASES) { + const mode = getCameraModeForPhase(phaseConfig.phase); + expect(mode).toBeTruthy(); + expect(typeof mode).toBe('string'); + } + }); + + it('spawn returns feedCloseup', () => { + expect(getCameraModeForPhase('spawn')).toBe('feedCloseup'); + }); + + it('detection returns inspectionTop', () => { + expect(getCameraModeForPhase('detection')).toBe('inspectionTop'); + }); + + it('routing returns chuteCloseup', () => { + expect(getCameraModeForPhase('routing')).toBe('chuteCloseup'); + }); +}); + +describe('getCameraConfig', () => { + it('returns valid config for all phases', () => { + for (const phaseConfig of CASE_PHASES) { + const config = getCameraConfig(phaseConfig.phase, 'B', null, 'desktop'); + expect(isValidCameraConfig(config)).toBe(true); + } + }); + + it('camera position/target are finite numbers', () => { + const config = getCameraConfig('detection', 'B', [0, 0.8, 0], 'desktop'); + expect(config.position.every(n => Number.isFinite(n))).toBe(true); + expect(config.target.every(n => Number.isFinite(n))).toBe(true); + }); + + it('fov within reasonable range (10-120)', () => { + for (const phaseConfig of CASE_PHASES) { + const config = getCameraConfig(phaseConfig.phase, 'C', null, 'desktop'); + expect(config.fov).toBeGreaterThan(10); + expect(config.fov).toBeLessThan(120); + } + }); + + it('target category B/C/D maps to routing camera', () => { + const categories: Category[] = ['B', 'C', 'D']; + for (const cat of categories) { + const config = getCameraConfig('routing', cat, null, 'desktop'); + expect(config.mode).toBe('chuteCloseup'); + expect(isValidCameraConfig(config)).toBe(true); + } + }); + + it('adjusts for viewport type', () => { + const desktop = getCameraConfig('move_to_detection', 'B', null, 'desktop'); + const mobile = getCameraConfig('move_to_detection', 'B', null, 'mobile'); + + // Mobile should have higher camera and wider FOV + expect(mobile.position[1]).toBeGreaterThan(desktop.position[1]); + expect(mobile.fov).toBeGreaterThan(desktop.fov); + }); +}); + +describe('smoothCameraTransition', () => { + it('interpolates between configs', () => { + const current = getCameraConfig('spawn', 'B', null, 'desktop'); + const target = getCameraConfig('detection', 'B', null, 'desktop'); + + const result = smoothCameraTransition(current, target, 0.5); + + // Result should be between current and target + expect(result.position[0]).toBeGreaterThanOrEqual( + Math.min(current.position[0], target.position[0]) + ); + expect(result.position[0]).toBeLessThanOrEqual( + Math.max(current.position[0], target.position[0]) + ); + }); +}); + +describe('isValidCameraConfig', () => { + it('returns true for valid config', () => { + const config = getInitialCameraConfig(); + expect(isValidCameraConfig(config)).toBe(true); + }); + + it('returns false for invalid fov', () => { + const config = { ...getInitialCameraConfig(), fov: 5 }; + expect(isValidCameraConfig(config)).toBe(false); + }); + + it('returns false for NaN position', () => { + const config = { ...getInitialCameraConfig(), position: [NaN, 0, 0] as [number, number, number] }; + expect(isValidCameraConfig(config)).toBe(false); + }); +}); + +describe('phaseHasCameraMode', () => { + it('returns true for all phases', () => { + for (const phaseConfig of CASE_PHASES) { + expect(phaseHasCameraMode(phaseConfig.phase)).toBe(true); + } + }); +}); + +describe('getViewportType', () => { + it('desktop for width >= 1200', () => { + expect(getViewportType(1920)).toBe('desktop'); + expect(getViewportType(1200)).toBe('desktop'); + }); + + it('laptop for width 768-1199', () => { + expect(getViewportType(1024)).toBe('laptop'); + expect(getViewportType(768)).toBe('laptop'); + }); + + it('mobile for width < 768', () => { + expect(getViewportType(390)).toBe('mobile'); + expect(getViewportType(767)).toBe('mobile'); + }); +}); + +describe('lerp utilities', () => { + it('lerp returns midpoint at t=0.5', () => { + expect(lerp(0, 10, 0.5)).toBe(5); + }); + + it('lerpVector3 works correctly', () => { + const a: [number, number, number] = [0, 0, 0]; + const b: [number, number, number] = [10, 20, 30]; + const result = lerpVector3(a, b, 0.5); + expect(result).toEqual([5, 10, 15]); + }); +}); + +describe('getAllCameraModes', () => { + it('returns all defined modes', () => { + const modes = getAllCameraModes(); + expect(modes).toContain('overview'); + expect(modes).toContain('feedCloseup'); + expect(modes).toContain('inspectionTop'); + expect(modes).toContain('measurementSide'); + expect(modes).toContain('routingWide'); + expect(modes).toContain('chuteCloseup'); + expect(modes).toContain('resultZone'); + expect(modes.length).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/src/domain/cinematicCamera.ts b/src/domain/cinematicCamera.ts new file mode 100644 index 0000000..1210b53 --- /dev/null +++ b/src/domain/cinematicCamera.ts @@ -0,0 +1,301 @@ +/** + * Cinematic Camera Director — controls camera angles during demo playback. + * Returns camera position, target, and FOV based on current phase and item state. + */ + +import type { CasePhase } from './continuousPlayback'; +import type { Category } from './types'; +import { ZONES, BELT_TOP_Y, CAMERA_RIG } from './physicalLayout'; + +/** Camera mode names for different shot types. */ +export type CameraMode = + | 'overview' + | 'feedCloseup' + | 'inspectionTop' + | 'measurementSide' + | 'classificationTop' + | 'routingWide' + | 'chuteCloseup' + | 'resultZone' + | 'nextItemReset'; + +/** Camera configuration for a specific mode. */ +export interface CameraConfig { + position: [number, number, number]; + target: [number, number, number]; + fov: number; + mode: CameraMode; +} + +/** Viewport type for adaptive camera positions. */ +export type ViewportType = 'desktop' | 'laptop' | 'mobile'; + +/** + * Get viewport type based on screen width. + */ +export function getViewportType(width: number): ViewportType { + if (width >= 1200) return 'desktop'; + if (width >= 768) return 'laptop'; + return 'mobile'; +} + +/** + * Viewport adjustments for camera height/distance. + */ +const VIEWPORT_ADJUSTMENTS: Record = { + desktop: { heightMult: 1.0, distMult: 1.0 }, + laptop: { heightMult: 1.15, distMult: 1.1 }, + mobile: { heightMult: 1.3, distMult: 1.25 }, +}; + +/** + * Base camera configurations for each mode. + * Positions are relative to the scene center or specific zones. + */ +const BASE_CAMERA_CONFIGS: Record> = { + overview: { + position: [5.0, 3.5, 5.5], + target: [0, BELT_TOP_Y, 0], + fov: 50, + }, + feedCloseup: { + position: [ZONES.A.x + 1.5, 2.0, 2.0], + target: [ZONES.A.x, BELT_TOP_Y + 0.1, 0], + fov: 45, + }, + inspectionTop: { + position: [ZONES.CAMERA.x, CAMERA_RIG.cameraY + 1.5, 2.5], + target: [ZONES.CAMERA.x, BELT_TOP_Y + 0.1, 0], + fov: 40, + }, + measurementSide: { + position: [ZONES.CAMERA.x + 1.8, 1.2, 2.0], + target: [ZONES.CAMERA.x, BELT_TOP_Y + 0.2, 0], + fov: 42, + }, + classificationTop: { + position: [ZONES.CAMERA.x + 0.5, 2.5, 2.2], + target: [ZONES.CAMERA.x, BELT_TOP_Y + 0.15, 0], + fov: 45, + }, + routingWide: { + position: [2.5, 3.0, 4.5], + target: [ZONES.GATE.x, BELT_TOP_Y, 0], + fov: 55, + }, + chuteCloseup: { + position: [ZONES.GATE.x + 0.5, 1.8, 2.8], + target: [ZONES.GATE.x + 0.3, BELT_TOP_Y, 0.8], + fov: 48, + }, + resultZone: { + position: [3.5, 2.5, 3.5], + target: [ZONES.B.x - 0.5, 0.5, 0], + fov: 52, + }, + nextItemReset: { + position: [4.0, 3.0, 4.5], + target: [0, BELT_TOP_Y, 0], + fov: 50, + }, +}; + +/** + * Get camera mode for a given phase. + */ +export function getCameraModeForPhase(phase: CasePhase): CameraMode { + switch (phase) { + case 'spawn': + return 'feedCloseup'; + case 'move_to_detection': + return 'overview'; + case 'detection': + return 'inspectionTop'; + case 'measurement': + return 'measurementSide'; + case 'classification': + return 'classificationTop'; + case 'command_sent': + return 'routingWide'; + case 'routing': + return 'chuteCloseup'; + case 'exit': + return 'resultZone'; + case 'clear_gap': + return 'nextItemReset'; + default: + return 'overview'; + } +} + +/** + * Adjust camera position for target category (C/D routing). + */ +function adjustForCategory( + config: CameraConfig, + category: Category | null, + phase: CasePhase +): CameraConfig { + if (!category || !['routing', 'exit'].includes(phase)) { + return config; + } + + const adjusted = { ...config, position: [...config.position] as [number, number, number], target: [...config.target] as [number, number, number] }; + + if (category === 'C') { + // Look toward C zone (positive Z) + adjusted.target[2] = 1.2; + adjusted.position[2] = 3.5; + } else if (category === 'D') { + // Look toward D zone (negative Z) + adjusted.target[2] = -1.2; + adjusted.position[2] = -2.5; + adjusted.position[0] = 3.0; + } + // B stays on main line, no adjustment needed + + return adjusted; +} + +/** + * Adjust camera for viewport size. + */ +function adjustForViewport( + config: CameraConfig, + viewport: ViewportType +): CameraConfig { + const adj = VIEWPORT_ADJUSTMENTS[viewport]; + + return { + ...config, + position: [ + config.position[0] * adj.distMult, + config.position[1] * adj.heightMult, + config.position[2] * adj.distMult, + ], + fov: config.fov + (viewport === 'mobile' ? 8 : viewport === 'laptop' ? 4 : 0), + }; +} + +/** + * Get camera configuration for the current playback state. + * @param phase Current case phase + * @param category Target category (B/C/D) + * @param itemPosition Current item position [x, y, z] + * @param viewport Viewport type for adaptive positioning + * @returns Camera configuration + */ +export function getCameraConfig( + phase: CasePhase, + category: Category | null, + itemPosition: [number, number, number] | null, + viewport: ViewportType = 'desktop' +): CameraConfig { + const mode = getCameraModeForPhase(phase); + const baseConfig = BASE_CAMERA_CONFIGS[mode]; + + let config: CameraConfig = { + ...baseConfig, + mode, + }; + + // Adjust for category-specific routing + config = adjustForCategory(config, category, phase); + + // Adjust for viewport + config = adjustForViewport(config, viewport); + + // Follow item during movement phases + if (itemPosition && ['move_to_detection', 'routing', 'exit'].includes(phase)) { + // Partially follow item with smoothing factor + const followWeight = phase === 'move_to_detection' ? 0.3 : 0.5; + config.target = [ + config.target[0] * (1 - followWeight) + itemPosition[0] * followWeight, + config.target[1] * (1 - followWeight) + itemPosition[1] * followWeight, + config.target[2] * (1 - followWeight) + itemPosition[2] * followWeight, + ]; + } + + return config; +} + +/** + * Lerp (linear interpolation) between two values. + */ +export function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** + * Lerp between two 3D vectors. + */ +export function lerpVector3( + a: [number, number, number], + b: [number, number, number], + t: number +): [number, number, number] { + return [ + lerp(a[0], b[0], t), + lerp(a[1], b[1], t), + lerp(a[2], b[2], t), + ]; +} + +/** + * Smoothly interpolate camera configuration. + * @param current Current camera state + * @param target Target camera state + * @param smoothing Smoothing factor (0-1, lower = smoother) + */ +export function smoothCameraTransition( + current: CameraConfig, + target: CameraConfig, + smoothing: number = 0.08 +): CameraConfig { + return { + position: lerpVector3(current.position, target.position, smoothing), + target: lerpVector3(current.target, target.target, smoothing), + fov: lerp(current.fov, target.fov, smoothing), + mode: target.mode, + }; +} + +/** + * Get initial camera config (overview). + */ +export function getInitialCameraConfig(viewport: ViewportType = 'desktop'): CameraConfig { + return getCameraConfig('spawn', null, null, viewport); +} + +/** + * Validate camera config has finite numbers. + */ +export function isValidCameraConfig(config: CameraConfig): boolean { + const allFinite = (arr: number[]) => arr.every(n => Number.isFinite(n)); + return ( + allFinite(config.position) && + allFinite(config.target) && + Number.isFinite(config.fov) && + config.fov > 10 && + config.fov < 120 + ); +} + +/** + * Get all camera modes. + */ +export function getAllCameraModes(): CameraMode[] { + return Object.keys(BASE_CAMERA_CONFIGS) as CameraMode[]; +} + +/** + * Check if phase has associated camera mode. + */ +export function phaseHasCameraMode(phase: CasePhase): boolean { + try { + const mode = getCameraModeForPhase(phase); + return !!mode && !!BASE_CAMERA_CONFIGS[mode]; + } catch { + return false; + } +} diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 412769e..4804c3f 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -7,6 +7,7 @@ import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../domain/demoPlaylist'; import type { ContinuousPlaybackState } from '../domain/continuousPlayback'; import { getCaseProgress, getCurrentPhaseConfig } from '../domain/continuousPlayback'; import { getMeasurementData, shouldShowMeasurement } from '../domain/measurementSystem'; +import { getViewportType, type ViewportType } from '../domain/cinematicCamera'; import CVInspectionOverlay from '../components/CVInspectionOverlay'; const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous')); @@ -27,6 +28,7 @@ export default function MainPage({ const webgl = useWebGLSupport(); const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth)); const [contextLost, setContextLost] = useState(false); + const [autoCameraEnabled, setAutoCameraEnabled] = useState(true); useEffect(() => { const onResize = () => setWidth(window.innerWidth); @@ -36,6 +38,7 @@ export default function MainPage({ const show3D = prefer3DByDefault(width, webgl && !contextLost); const simplified = width < 900; + const viewportType: ViewportType = getViewportType(width); const isRunning = playback.status === 'running'; const isPaused = playback.status === 'paused'; @@ -74,6 +77,8 @@ export default function MainPage({ playback={playback} simplified={simplified} onContextLost={() => setContextLost(true)} + autoCameraEnabled={autoCameraEnabled} + viewportType={viewportType} /> @@ -188,6 +193,19 @@ export default function MainPage({ )} + {/* Auto Camera Toggle - bottom left */} + {show3D && ( + + )} + {/* Details link - bottom right */} Details → diff --git a/src/styles.css b/src/styles.css index b3e9089..378b484 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2248,6 +2248,55 @@ button:disabled { transition: background 160ms ease, border-color 160ms ease; } +/* Auto Camera Toggle */ +.auto-camera-toggle { + position: absolute; + bottom: 16px; + left: 16px; + z-index: 10; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 999px; + background: rgba(5, 9, 16, 0.75); + backdrop-filter: blur(8px); + color: var(--muted); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.auto-camera-toggle:hover { + background: rgba(5, 9, 16, 0.9); + border-color: rgba(56, 189, 248, 0.4); + color: var(--cyan); +} + +.auto-camera-toggle .toggle-icon { + font-size: 14px; +} + +.auto-camera-toggle .toggle-label { + text-transform: uppercase; + letter-spacing: 0.03em; +} + +@media (max-width: 767px) { + .auto-camera-toggle { + bottom: 80px; + left: 10px; + padding: 6px 10px; + font-size: 10px; + } + + .auto-camera-toggle .toggle-icon { + font-size: 12px; + } +} + .details-link:hover { background: rgba(56, 189, 248, 0.1); border-color: var(--cyan);