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
This commit is contained in:
root
2026-07-09 15:53:50 +02:00
parent 717a992b98
commit a078d296e3
18 changed files with 937 additions and 6 deletions

View 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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View 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())

View File

@@ -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>

View 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);
});
});

View 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;
}
}

View File

@@ -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 →

View File

@@ -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);