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
|
* - 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 { 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 type { Mesh, Group, BufferGeometry } from 'three';
|
||||||
|
import * as THREE from 'three';
|
||||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||||
import type { ContinuousPlaybackState, CasePhase } from '../../domain/continuousPlayback';
|
import type { ContinuousPlaybackState, CasePhase } from '../../domain/continuousPlayback';
|
||||||
import { isDetectionActive, isRoutingActive, getPhaseProgress } 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 { getModelAsset } from '../../data/modelAssets';
|
||||||
import { ITEMS } from '../../data/items';
|
import { ITEMS } from '../../data/items';
|
||||||
import type { Category } from '../../domain/types';
|
import type { Category } from '../../domain/types';
|
||||||
|
import {
|
||||||
|
getCameraConfig,
|
||||||
|
smoothCameraTransition,
|
||||||
|
getInitialCameraConfig,
|
||||||
|
type CameraConfig,
|
||||||
|
type ViewportType,
|
||||||
|
} from '../../domain/cinematicCamera';
|
||||||
import {
|
import {
|
||||||
BELT_TOP_Y,
|
BELT_TOP_Y,
|
||||||
BELT_THICKNESS_M,
|
BELT_THICKNESS_M,
|
||||||
@@ -47,6 +55,8 @@ export interface SorterDigitalTwinContinuousProps {
|
|||||||
playback: ContinuousPlaybackState;
|
playback: ContinuousPlaybackState;
|
||||||
simplified?: boolean;
|
simplified?: boolean;
|
||||||
onContextLost?: () => void;
|
onContextLost?: () => void;
|
||||||
|
autoCameraEnabled?: boolean;
|
||||||
|
viewportType?: ViewportType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Light color palette */
|
/** Light color palette */
|
||||||
@@ -79,6 +89,93 @@ const CONVEYOR_END_X = 4.3;
|
|||||||
const CONVEYOR_LENGTH = CONVEYOR_END_X - CONVEYOR_START_X;
|
const CONVEYOR_LENGTH = CONVEYOR_END_X - CONVEYOR_START_X;
|
||||||
const CONVEYOR_CENTER_X = (CONVEYOR_START_X + CONVEYOR_END_X) / 2;
|
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 */
|
/** Single animated roller that rotates around its own axis */
|
||||||
function Roller({ position, speedFactor }: { position: [number, number, number]; speedFactor: number }) {
|
function Roller({ position, speedFactor }: { position: [number, number, number]; speedFactor: number }) {
|
||||||
const meshRef = useRef<Mesh>(null);
|
const meshRef = useRef<Mesh>(null);
|
||||||
@@ -1137,7 +1234,17 @@ function PlaybackItem({ playback }: { playback: ContinuousPlaybackState }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Main continuous scene - light warehouse style with correct physical dimensions */
|
/** 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 speedFactor = getConveyorSpeedFactor(playback);
|
||||||
const activeRoute = getActiveRoute(playback);
|
const activeRoute = getActiveRoute(playback);
|
||||||
const category = playback.targetCategory;
|
const category = playback.targetCategory;
|
||||||
@@ -1164,6 +1271,17 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
|
|||||||
const isRound = itemData.roundness >= 0.7;
|
const isRound = itemData.roundness >= 0.7;
|
||||||
const dims = getRenderedItemDimensions(itemData.dimensionsMm);
|
const dims = getRenderedItemDimensions(itemData.dimensionsMm);
|
||||||
const itemScale = Math.max(dims.width, dims.depth, dims.height);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1279,10 +1397,27 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
|
|||||||
|
|
||||||
{/* Item riding ON the belt */}
|
{/* Item riding ON the belt */}
|
||||||
<PlaybackItem playback={playback} />
|
<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
|
<OrbitControls
|
||||||
enablePan={!simplified}
|
enablePan={!simplified && !cinematicActive}
|
||||||
enableZoom
|
enableZoom={!cinematicActive}
|
||||||
|
enableRotate={!cinematicActive}
|
||||||
maxPolarAngle={Math.PI / 2.1}
|
maxPolarAngle={Math.PI / 2.1}
|
||||||
minDistance={3}
|
minDistance={3}
|
||||||
maxDistance={14}
|
maxDistance={14}
|
||||||
@@ -1297,6 +1432,8 @@ export default function SorterDigitalTwinContinuous({
|
|||||||
playback,
|
playback,
|
||||||
simplified = false,
|
simplified = false,
|
||||||
onContextLost,
|
onContextLost,
|
||||||
|
autoCameraEnabled = true,
|
||||||
|
viewportType = 'desktop',
|
||||||
}: SorterDigitalTwinContinuousProps) {
|
}: SorterDigitalTwinContinuousProps) {
|
||||||
return (
|
return (
|
||||||
<div className="digital-twin-wrap continuous-twin">
|
<div className="digital-twin-wrap continuous-twin">
|
||||||
@@ -1316,7 +1453,12 @@ export default function SorterDigitalTwinContinuous({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ContinuousScene playback={playback} simplified={simplified} />
|
<ContinuousScene
|
||||||
|
playback={playback}
|
||||||
|
simplified={simplified}
|
||||||
|
autoCameraEnabled={autoCameraEnabled}
|
||||||
|
viewportType={viewportType}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</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 type { ContinuousPlaybackState } from '../domain/continuousPlayback';
|
||||||
import { getCaseProgress, getCurrentPhaseConfig } from '../domain/continuousPlayback';
|
import { getCaseProgress, getCurrentPhaseConfig } from '../domain/continuousPlayback';
|
||||||
import { getMeasurementData, shouldShowMeasurement } from '../domain/measurementSystem';
|
import { getMeasurementData, shouldShowMeasurement } from '../domain/measurementSystem';
|
||||||
|
import { getViewportType, type ViewportType } from '../domain/cinematicCamera';
|
||||||
import CVInspectionOverlay from '../components/CVInspectionOverlay';
|
import CVInspectionOverlay from '../components/CVInspectionOverlay';
|
||||||
|
|
||||||
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
|
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
|
||||||
@@ -27,6 +28,7 @@ export default function MainPage({
|
|||||||
const webgl = useWebGLSupport();
|
const webgl = useWebGLSupport();
|
||||||
const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth));
|
const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth));
|
||||||
const [contextLost, setContextLost] = useState(false);
|
const [contextLost, setContextLost] = useState(false);
|
||||||
|
const [autoCameraEnabled, setAutoCameraEnabled] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onResize = () => setWidth(window.innerWidth);
|
const onResize = () => setWidth(window.innerWidth);
|
||||||
@@ -36,6 +38,7 @@ export default function MainPage({
|
|||||||
|
|
||||||
const show3D = prefer3DByDefault(width, webgl && !contextLost);
|
const show3D = prefer3DByDefault(width, webgl && !contextLost);
|
||||||
const simplified = width < 900;
|
const simplified = width < 900;
|
||||||
|
const viewportType: ViewportType = getViewportType(width);
|
||||||
|
|
||||||
const isRunning = playback.status === 'running';
|
const isRunning = playback.status === 'running';
|
||||||
const isPaused = playback.status === 'paused';
|
const isPaused = playback.status === 'paused';
|
||||||
@@ -74,6 +77,8 @@ export default function MainPage({
|
|||||||
playback={playback}
|
playback={playback}
|
||||||
simplified={simplified}
|
simplified={simplified}
|
||||||
onContextLost={() => setContextLost(true)}
|
onContextLost={() => setContextLost(true)}
|
||||||
|
autoCameraEnabled={autoCameraEnabled}
|
||||||
|
viewportType={viewportType}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ThreeErrorBoundary>
|
</ThreeErrorBoundary>
|
||||||
@@ -188,6 +193,19 @@ export default function MainPage({
|
|||||||
</div>
|
</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 */}
|
{/* Details link - bottom right */}
|
||||||
<Link to="/details" className="details-link">
|
<Link to="/details" className="details-link">
|
||||||
Details →
|
Details →
|
||||||
|
|||||||
@@ -2248,6 +2248,55 @@ button:disabled {
|
|||||||
transition: background 160ms ease, border-color 160ms ease;
|
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 {
|
.details-link:hover {
|
||||||
background: rgba(56, 189, 248, 0.1);
|
background: rgba(56, 189, 248, 0.1);
|
||||||
border-color: var(--cyan);
|
border-color: var(--cyan);
|
||||||
|
|||||||