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
135
docs/CINEMATIC_CAMERA_REPORT.md
Normal file
@@ -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
|
||||
```
|
||||
BIN
docs/cinematic_camera_screenshots/c_routing.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
docs/cinematic_camera_screenshots/chute_closeup.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/cinematic_camera_screenshots/d_routing.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
docs/cinematic_camera_screenshots/details_page.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
docs/cinematic_camera_screenshots/feed_closeup.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
docs/cinematic_camera_screenshots/inspection_top.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
docs/cinematic_camera_screenshots/measurement_side.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/cinematic_camera_screenshots/mobile_view.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/cinematic_camera_screenshots/overview.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
docs/cinematic_camera_screenshots/result_zone.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
docs/cinematic_camera_screenshots/routing_wide.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
118
scripts/test_cinematic_camera.py
Normal file
@@ -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())
|
||||
@@ -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<CameraConfig>(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 (
|
||||
<mesh position={[position[0] + offset[0], position[1], position[2] + offset[2]]}>
|
||||
<boxGeometry args={[
|
||||
direction === 'x' ? trailLength : 0.08,
|
||||
0.02,
|
||||
direction === 'z' ? trailLength : 0.08
|
||||
]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single animated roller that rotates around its own axis */
|
||||
function Roller({ position, speedFactor }: { position: [number, number, number]; speedFactor: number }) {
|
||||
const meshRef = useRef<Mesh>(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 */}
|
||||
<PlaybackItem playback={playback} />
|
||||
|
||||
{/* Motion trail for visual movement feedback */}
|
||||
<MotionTrail
|
||||
position={itemPos}
|
||||
visible={showMotionTrail}
|
||||
color={itemColor}
|
||||
direction={trailDirection as 'x' | 'z'}
|
||||
/>
|
||||
|
||||
{/* Cinematic camera controller */}
|
||||
<CinematicCameraController
|
||||
playback={playback}
|
||||
enabled={cinematicActive}
|
||||
viewportType={viewportType}
|
||||
/>
|
||||
|
||||
{/* OrbitControls - enabled when not in cinematic mode */}
|
||||
<OrbitControls
|
||||
enablePan={!simplified}
|
||||
enableZoom
|
||||
enablePan={!simplified && !cinematicActive}
|
||||
enableZoom={!cinematicActive}
|
||||
enableRotate={!cinematicActive}
|
||||
maxPolarAngle={Math.PI / 2.1}
|
||||
minDistance={3}
|
||||
maxDistance={14}
|
||||
@@ -1297,6 +1432,8 @@ export default function SorterDigitalTwinContinuous({
|
||||
playback,
|
||||
simplified = false,
|
||||
onContextLost,
|
||||
autoCameraEnabled = true,
|
||||
viewportType = 'desktop',
|
||||
}: SorterDigitalTwinContinuousProps) {
|
||||
return (
|
||||
<div className="digital-twin-wrap continuous-twin">
|
||||
@@ -1316,7 +1453,12 @@ export default function SorterDigitalTwinContinuous({
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<ContinuousScene playback={playback} simplified={simplified} />
|
||||
<ContinuousScene
|
||||
playback={playback}
|
||||
simplified={simplified}
|
||||
autoCameraEnabled={autoCameraEnabled}
|
||||
viewportType={viewportType}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
168
src/domain/cinematicCamera.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
301
src/domain/cinematicCamera.ts
Normal file
@@ -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<ViewportType, { heightMult: number; distMult: number }> = {
|
||||
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<CameraMode, Omit<CameraConfig, 'mode'>> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</Suspense>
|
||||
</ThreeErrorBoundary>
|
||||
@@ -188,6 +193,19 @@ export default function MainPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto Camera Toggle - bottom left */}
|
||||
{show3D && (
|
||||
<button
|
||||
type="button"
|
||||
className="auto-camera-toggle"
|
||||
onClick={() => setAutoCameraEnabled(!autoCameraEnabled)}
|
||||
title={autoCameraEnabled ? 'Disable auto camera to control manually' : 'Enable cinematic auto camera'}
|
||||
>
|
||||
<span className="toggle-icon">{autoCameraEnabled ? '🎬' : '🎥'}</span>
|
||||
<span className="toggle-label">Auto Cam: {autoCameraEnabled ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Details link - bottom right */}
|
||||
<Link to="/details" className="details-link">
|
||||
Details →
|
||||
|
||||
@@ -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);
|
||||
|
||||