/** * SorterDigitalTwinContinuous — 3D scene for continuous playback on main page. * Light warehouse-style scene with white-blue palette. * * Physical dimensions (1 unit = 1 meter): * - Belt top surface: 0.7m from floor * - Belt width: 0.5m * - Items ride ON the belt surface */ import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'; import { Grid, OrbitControls, Html, Line } from '@react-three/drei'; 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'; import { getConveyorSpeedFactor } from '../../domain/conveyorPath'; import { getPhysicalItemPose } from '../../domain/physicalItemMotion'; import { shouldShowBoundingBox, shouldShowScanEffect, shouldShowShapeOutline, shouldHighlightCamera } from '../../domain/inspectionViewModel'; import { getMeasurementData, shouldShowLaserBeam, shouldShowStepperPulse, shouldShowPointCloud, shouldShowActuator } from '../../domain/measurementSystem'; import { getModelAsset } from '../../data/modelAssets'; import { ITEMS } from '../../data/items'; import type { Category } from '../../domain/types'; import { PhysicalPlaybackItem } from './PhysicalPlaybackItem'; import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../../domain/demoPlaylist'; import { CASE_DURATION_MS } from '../../domain/continuousPlayback'; import { getCameraConfig, smoothCameraTransition, getInitialCameraConfig, type CameraConfig, type ViewportType, } from '../../domain/cinematicCamera'; import { BELT_TOP_Y, BELT_THICKNESS_M, CONVEYOR_WIDTH_M, ROLLER_RADIUS_M, ROLLER_SPACING_M, FRAME_HEIGHT_M, SIDE_GUARD_HEIGHT_M, LEG_WIDTH_M, MOTOR_WIDTH_M, MOTOR_HEIGHT_M, MOTOR_DEPTH_M, DRIVE_ROLLER_RADIUS_M, ZONES, CAMERA_RIG, LASER_HEIGHT_M, STEREO_CAMERA, ROLL_CAGE, getRenderedItemDimensions, getItemYOnBelt, B_RECEIVER, CAGE_FLOOR_Y, CONVEYOR_SPEED_MPS, } from '../../domain/physicalLayout'; export interface SorterDigitalTwinContinuousProps { playback: ContinuousPlaybackState; simplified?: boolean; onContextLost?: () => void; autoCameraEnabled?: boolean; viewportType?: ViewportType; } /** * Refined color palette - warehouse aesthetic * Belt: matte PVC/tarpaulin look (blue-gray, not glossy) * Frame: industrial metal gray * Accents: subtle, not overly bright */ const COLORS = { background: '#f4f7fb', floor: '#e8eef6', gridCell: '#d0dae8', gridSection: '#b8c8dc', conveyorFrame: '#8a9bb0', // Industrial metal gray belt: '#6b8298', // Matte PVC blue-gray beltStripe: '#7d96ad', // Subtle stripe sideGuards: '#7a8fa3', // Metal guards rollers: '#9aa8b8', // Brushed metal supports: '#a0afc0', // Support legs motor: '#5a6a7a', // Dark motor housing sensorAccent: '#3b82f6', // Blue sensor (less saturated) sensorActive: '#60a5fa', // Active state gateFrame: '#7a8a9a', // Gate metal routeB: '#16a34a', // Green (softer) routeC: '#ea580c', // Orange (softer) routeD: '#7c3aed', // Purple (softer) itemShadow: '#3a4a5a', // Contact shadow }; const ITEM_MATERIALS: Record = { 'SKU-001': { color: '#b68b58', roughness: 0.82 }, // cardboard box 'SKU-002': { color: '#e8eef6', roughness: 0.5 }, // lunchbox plastic 'SKU-004': { color: '#c49a6c', roughness: 0.82 }, // oversized cardboard 'SKU-006': { color: '#f8fafc', roughness: 0.42 }, // ceramic plate 'SKU-007': { color: '#7dd3fc', roughness: 0.28 }, // bottle plastic 'SKU-008': { color: '#cbd5e1', roughness: 0.35, metalness: 0.15 }, // cylinder }; function getItemMaterial(itemId: string) { return ITEM_MATERIALS[itemId] ?? { color: '#d8c3a5', roughness: 0.75 }; } // Physical layout constants const BELT_Y = BELT_TOP_Y; // 0.7m - top of belt where items ride const BELT_UNDERSIDE_Y = BELT_TOP_Y - BELT_THICKNESS_M; // 0.685m const ROLLER_Y = BELT_UNDERSIDE_Y - ROLLER_RADIUS_M; // ~0.645m - roller center const FRAME_TOP_Y = ROLLER_Y - ROLLER_RADIUS_M - 0.02; // Top of frame structure const CONVEYOR_START_X = -4.2; const CONVEYOR_END_X = 4.3; const CONVEYOR_LENGTH = CONVEYOR_END_X - CONVEYOR_START_X; const CONVEYOR_CENTER_X = (CONVEYOR_START_X + CONVEYOR_END_X) / 2; /** Cinematic camera controller - smoothly transitions between camera angles */ function CinematicCameraController({ playback, enabled, viewportType = 'desktop' }: { playback: ContinuousPlaybackState; enabled: boolean; viewportType: ViewportType; }) { const { camera } = useThree(); const cameraStateRef = useRef(getInitialCameraConfig(viewportType)); const isRunning = playback.status === 'running'; // Get item position for camera following const itemPosition = useMemo(() => { if (playback.status === 'idle') return null; const currentItemElapsed = playback.totalElapsedMs - (playback.currentCaseIndex * CASE_DURATION_MS); const pose = getPhysicalItemPose({ caseId: playback.currentCase.id, dimensionsMm: { width: 300, depth: 200, height: 200 }, // rough approx for camera target targetCategory: playback.targetCategory, elapsedMs: currentItemElapsed, slotIndex: playback.currentCaseIndex }); return pose.position; }, [playback]); useFrame(() => { if (!enabled || !isRunning) return; // Get target camera config for current phase const targetConfig = getCameraConfig( playback.currentPhase, playback.targetCategory, itemPosition, viewportType ); // Smooth transition cameraStateRef.current = smoothCameraTransition( cameraStateRef.current, targetConfig, 0.04 // Smooth factor - lower = smoother ); // Apply to camera const state = cameraStateRef.current; camera.position.set(state.position[0], state.position[1], state.position[2]); camera.lookAt(state.target[0], state.target[1], state.target[2]); // Update FOV if perspective camera if (camera instanceof THREE.PerspectiveCamera) { camera.fov = state.fov; camera.updateProjectionMatrix(); } }); return null; } /** Subtle motion trail behind moving items */ function MotionTrail({ position, visible, color, direction }: { position: [number, number, number]; visible: boolean; color: string; direction: 'x' | 'z'; }) { if (!visible) return null; const trailLength = 0.15; const offset = direction === 'x' ? [-trailLength, 0, 0] : [0, 0, -trailLength * Math.sign(position[2] || 1)]; return ( ); } /** Single animated roller that rotates around its own axis */ function Roller({ position, speedFactor }: { position: [number, number, number]; speedFactor: number }) { const meshRef = useRef(null); useFrame((_, delta) => { if (meshRef.current && speedFactor > 0) { meshRef.current.rotation.x += delta * speedFactor * 4; } }); return ( ); } /** * Moving stripe on conveyor belt. * Position is DETERMINISTIC from playback time (offset = time * 1 m/s), so the * belt animation shares the exact tempo/direction of the item, is FPS-independent, * freezes on pause and resets on stop. */ function BeltStripe({ baseOffset, elapsedMs }: { baseOffset: number; elapsedMs: number }) { const beltLen = CONVEYOR_END_X - CONVEYOR_START_X; const shift = ((elapsedMs / 1000) * CONVEYOR_SPEED_MPS) % beltLen; let x = baseOffset + shift; if (x > CONVEYOR_END_X) x -= beltLen; return ( ); } /** Stepper motor drive unit with pulse indicator */ function StepperMotor({ position, pulseActive }: { position: [number, number, number]; pulseActive: boolean }) { const pulseRef = useRef(null); const rotationRef = useRef(0); useFrame((_, delta) => { if (pulseRef.current && pulseActive) { rotationRef.current += delta * 8; pulseRef.current.rotation.z = rotationRef.current; } }); return ( {/* Motor body */} {/* Motor shaft - rotates when active */} {/* Drive pulley */} {/* Belt to drive roller */} {/* Pulse indicator LED */} ); } /** Laser beam from rangefinder to item */ function LaserBeam({ active, itemY }: { active: boolean; itemY: number }) { const beamRef = useRef(null); const opacityRef = useRef(0.6); useFrame((_, delta) => { if (beamRef.current && active) { opacityRef.current = 0.4 + Math.sin(Date.now() / 100) * 0.3; } }); if (!active) return null; const beamLength = LASER_HEIGHT_M - itemY; const beamCenterY = itemY + beamLength / 2; return ( {/* Main laser beam */} {/* Laser dot on item */} {/* Laser emitter */} ); } /** Stereo camera lenses with view cones */ function StereoCameraLenses({ active, itemPosition }: { active: boolean; itemPosition: [number, number, number] }) { const baseline = STEREO_CAMERA.baseline; const mountY = STEREO_CAMERA.mountY; const cameraX = ZONES.CAMERA.x; return ( {/* Left lens */} {/* Right lens */} {/* View cones when active */} {active && ( <> )} ); } /** Point cloud dots around item during stereo analysis */ function PointCloud({ active, itemPosition, scale, isRound }: { active: boolean; itemPosition: [number, number, number]; scale: number; isRound: boolean; }) { if (!active) return null; const points = useMemo(() => { const pts: [number, number, number][] = []; const count = 12; const radius = scale * 0.4; for (let i = 0; i < count; i++) { const angle = (i / count) * Math.PI * 2; const r = isRound ? radius : radius * (1 + Math.cos(angle * 2) * 0.3); pts.push([ Math.cos(angle) * r, 0, Math.sin(angle) * r, ]); } return pts; }, [scale, isRound]); return ( {points.map((pt, i) => ( ))} ); } /** Actuator/pusher animation during routing */ function ActuatorPusher({ active, category }: { active: boolean; category: Category | null }) { const pusherRef = useRef(null); const extendRef = useRef(0); useFrame((_, delta) => { if (pusherRef.current) { const target = active && (category === 'C' || category === 'D') ? 0.15 : 0; extendRef.current += (target - extendRef.current) * delta * 5; const direction = category === 'C' ? 1 : -1; pusherRef.current.position.z = direction * extendRef.current; } }); const gateX = ZONES.GATE.x; const color = category === 'C' ? COLORS.routeC : category === 'D' ? COLORS.routeD : COLORS.gateFrame; return ( ); } /** Support leg from floor to frame */ function SupportLeg({ x }: { x: number }) { const legHeight = FRAME_TOP_Y; return ( {/* Main vertical leg - front */} {/* Main vertical leg - back */} {/* Cross brace */} ); } /** * Conveyor belt - realistic roller conveyor * Belt top surface at 0.7m (BELT_TOP_Y) */ function ConveyorBelt({ speedFactor, pulseActive, elapsedMs }: { speedFactor: number; pulseActive: boolean; elapsedMs: number }) { const rollerCount = Math.floor(CONVEYOR_LENGTH / ROLLER_SPACING_M); const rollerPositions = useMemo(() => { const positions: [number, number, number][] = []; for (let i = 0; i < rollerCount; i++) { positions.push([CONVEYOR_START_X + ROLLER_SPACING_M / 2 + i * ROLLER_SPACING_M, ROLLER_Y, 0]); } return positions; }, [rollerCount]); const legPositions = useMemo(() => { const positions: number[] = []; for (let x = CONVEYOR_START_X + 0.5; x < CONVEYOR_END_X - 0.3; x += 2.0) { positions.push(x); } return positions; }, []); return ( {/* Main belt surface - matte PVC/tarpaulin look at 0.7m */} {/* Belt stripes — deterministic movement synced to item (offset = time * 1 m/s) */} {[-4, -2.5, -1, 0.5, 2, 3.5].map((offset, i) => ( ))} {/* Side guards - brushed metal above belt */} {/* Frame rails - industrial metal */} {/* Rollers - rotating under the belt */} {rollerPositions.map((pos, i) => ( ))} {/* Drive roller at end (larger) */} {/* Tension roller at start (larger) */} {/* Support legs from floor */} {legPositions.map((x, i) => ( ))} {/* Stepper motor at drive end */} {/* End caps / guards */} ); } /** Zone marker for A and B (simple floor marker) */ function ZoneMarker({ position, label, color, active }: { position: [number, number, number]; label: string; color: string; active: boolean; }) { return (
{label}
); } /** * B receiving zone — physical downstream receiving tray after the sorter. * A short 0.5m-wide tray at belt height (0.7m) with low side walls and an end * stop, so B items visibly land and remain instead of vanishing into thin air. */ function BReceiver({ active }: { active: boolean }) { const { startX, endX, y, width, wallHeight } = B_RECEIVER; const len = endX - startX; const centerX = (startX + endX) / 2; const emissive = active ? 0.35 : 0.05; const legY = y / 2; return ( {/* Tray floor at belt height */} {/* Side walls */} {[-1, 1].map((s) => ( ))} {/* End stop wall */} {/* Support legs */} {[startX + 0.2, endX - 0.2].map((lx) => ( [-1, 1].map((s) => ( )) ))} {/* Label */}
B
); } /** Roll cage for C/D zones - realistic wireframe cage with wheels */ function RollCage({ position, label, color, active }: { position: [number, number, number]; label: 'C' | 'D'; color: string; active: boolean; }) { const { width, depth, height, wheelRadius, frameThickness } = ROLL_CAGE; const ft = frameThickness; const emissiveIntensity = active ? 0.4 : 0; return ( {/* Floor marker */} {/* Solid interior floor where items rest */} {/* Cage frame - bottom rectangle */} {/* Cage frame - top rectangle */} {/* Vertical posts (corners) */} {[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sz], i) => ( ))} {/* Caster wheels */} {[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sz], i) => ( ))} {/* Wire mesh sides (simplified - just vertical lines) */} {[-1, 1].map((sz) => ( {[0.2, 0.4, 0.6, 0.8].map((t, i) => ( ))} ))} {/* Label */}
{label}
); } /** Chute/deflector for routing items to C/D */ function RouteChute({ gateX, targetZ, color, active }: { gateX: number; targetZ: number; color: string; active: boolean; }) { const chuteLength = Math.abs(targetZ) - CONVEYOR_WIDTH_M / 2 - 0.1; const chuteWidth = 0.4; const direction = targetZ > 0 ? 1 : -1; const midZ = (CONVEYOR_WIDTH_M / 2 + 0.1) * direction + (chuteLength / 2) * direction; return ( {/* Chute surface - angled slightly down */} {/* Side rails */} ); } /** Camera rig with overhead structure - positioned above belt at 0.7m */ function CameraRig({ active }: { active: boolean }) { const cameraX = ZONES.CAMERA.x; const poleSpacing = CAMERA_RIG.poleSpacing; const rigHeight = CAMERA_RIG.height; const cameraY = CAMERA_RIG.cameraY; const poleHeight = rigHeight; return ( {/* Support poles from floor */} {/* Cross beam */} {/* Camera unit */} {/* Camera lens */} {/* Laser emitters on support poles */} ); } /** Inspection zone on belt - at belt surface height */ function InspectionZone({ active }: { active: boolean }) { return ( {/* Inspection area rectangle on belt */} {/* Corner markers */} {[[-0.3, (CONVEYOR_WIDTH_M - 0.1) / 2], [0.3, (CONVEYOR_WIDTH_M - 0.1) / 2], [-0.3, -(CONVEYOR_WIDTH_M - 0.1) / 2], [0.3, -(CONVEYOR_WIDTH_M - 0.1) / 2]].map(([x, z], i) => ( ))} ); } /** Animated scan line - sweeps across belt at surface height */ function ScanLine({ active }: { active: boolean }) { const lineRef = useRef(null); const halfWidth = (CONVEYOR_WIDTH_M - 0.08) / 2; const posRef = useRef(halfWidth); const dirRef = useRef(-1); useFrame((_, delta) => { if (lineRef.current && active) { posRef.current += dirRef.current * delta * 0.6; if (posRef.current < -halfWidth) { posRef.current = -halfWidth; dirRef.current = 1; } if (posRef.current > halfWidth) { posRef.current = halfWidth; dirRef.current = -1; } lineRef.current.position.z = posRef.current; } }); if (!active) return null; return ( ); } /** Bounding box wireframe around item */ function BoundingBoxVisual({ position, scale, visible, isRound }: { position: [number, number, number]; scale: number; visible: boolean; isRound: boolean; }) { if (!visible) return null; const w = scale * 0.5; const h = scale * 0.4; const d = scale * 0.5; const corners = [ [-w, -h, -d], [w, -h, -d], [w, -h, d], [-w, -h, d], [-w, h, -d], [w, h, -d], [w, h, d], [-w, h, d], ]; const edges = [ [0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7], ]; return ( {edges.map(([a, b], i) => ( ))} {/* Dimension lines - subtle */} ); } /** Shape outline - circle for round, square for box */ function ShapeOutline({ position, scale, visible, isRound, category }: { position: [number, number, number]; scale: number; visible: boolean; isRound: boolean; category: Category | null; }) { if (!visible) return null; const color = category === 'B' ? COLORS.routeB : category === 'C' ? COLORS.routeC : category === 'D' ? COLORS.routeD : COLORS.sensorActive; return ( {isRound ? ( ) : ( <> {[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([x, z], i, arr) => { const next = arr[(i + 1) % 4]; return ( ); })} )} ); } /** Gate/accumulator zone - positioned at belt height */ function GateZone({ category }: { category: Category | null }) { const gateColor = category === 'B' ? COLORS.routeB : category === 'C' ? COLORS.routeC : category === 'D' ? COLORS.routeD : COLORS.gateFrame; const gateX = ZONES.GATE.x; const postSpacing = CONVEYOR_WIDTH_M / 2 + 0.08; const postHeight = 0.4; return ( {/* Gate posts from floor */} {/* Gate bar */} {/* Diverter indicator on belt */} ); } /** Route arrows showing active path - at belt height */ function RouteArrows({ activeRoute }: { activeRoute: Category | null }) { const gateX = ZONES.GATE.x; const routes = [ { category: 'B' as Category, color: COLORS.routeB, endX: ZONES.B.x, endZ: 0 }, { category: 'C' as Category, color: COLORS.routeC, endX: gateX + 0.5, endZ: ZONES.C.z }, { category: 'D' as Category, color: COLORS.routeD, endX: gateX + 0.5, endZ: ZONES.D.z }, ]; return ( {routes.map(({ category, color, endX, endZ }) => { const isActive = activeRoute === category; if (!isActive) return null; return ( ); })} ); } /** STL geometry loader - always loads the geometry */ function STLGeometry({ path, scale, color, accentColor, emissiveIntensity, roughness, metalness, }: { path: string; scale: [number, number, number]; color: string; accentColor: string; emissiveIntensity: number; roughness: number; metalness: number; }) { const geometry = useLoader(STLLoader, path); // Center and compute normals on first render useMemo(() => { if (geometry) { geometry.center(); geometry.computeVertexNormals(); } }, [geometry]); return ( ); } /** Fallback primitive when STL is not available */ function FallbackPrimitive({ type, color, accentColor, emissiveIntensity, roughness, metalness, w, h, d, }: { type: 'box' | 'cylinder' | 'sphere'; color: string; accentColor: string; emissiveIntensity: number; roughness: number; metalness: number; w: number; h: number; d: number; }) { if (type === 'cylinder' || type === 'sphere') { return ( ); } return ( ); } /** Main continuous scene - light warehouse style with correct physical dimensions */ function ContinuousScene({ playback, simplified, autoCameraEnabled, viewportType }: { playback: ContinuousPlaybackState; simplified: boolean; autoCameraEnabled: boolean; viewportType: ViewportType; }) { const speedFactor = getConveyorSpeedFactor(playback); const category = playback.targetCategory; const phase = playback.currentPhase; const cameraHighlight = shouldHighlightCamera(phase); const showScan = shouldShowScanEffect(phase); // Measurement system states const measurementData = getMeasurementData(playback); const showLaser = shouldShowLaserBeam(phase); const showPulse = shouldShowStepperPulse(phase); const showCloud = shouldShowPointCloud(phase); const showActuator = shouldShowActuator(phase); // Compute all physical items based on elapsed time to keep them in roll-cages const { totalElapsedMs, currentCase, currentCaseIndex } = playback; const casesSpawned = Math.floor(totalElapsedMs / CASE_DURATION_MS) + 1; const startIndex = Math.max(0, casesSpawned - 20); // Keep last 20 items const sceneItems = useMemo(() => { const items = []; for (let i = startIndex; i < casesSpawned; i++) { const playlistIndex = i % PLAYLIST_LENGTH; const caseData = DEMO_PLAYLIST[playlistIndex]; const elapsedMs = totalElapsedMs - (i * CASE_DURATION_MS); items.push({ id: `item-${i}-${caseData.id}`, slotIndex: i, caseData, elapsedMs, }); } return items; }, [totalElapsedMs, casesSpawned, startIndex]); // Get item data for the current case const itemId = currentCase.itemId.replace('-LC', ''); const itemData = ITEMS.find(i => i.id === itemId) ?? ITEMS[0]; const isRound = itemData.roundness >= 0.7; const dims = getRenderedItemDimensions(itemData.dimensionsMm); const itemScale = Math.max(dims.width, dims.depth, dims.height); // Get item position for measurement visualization using physical model const currentItemElapsed = totalElapsedMs - (currentCaseIndex * CASE_DURATION_MS); const currentPose = getPhysicalItemPose({ caseId: currentCase.id, dimensionsMm: itemData.dimensionsMm, targetCategory: category, elapsedMs: currentItemElapsed, slotIndex: currentCaseIndex }); const itemPos = currentPose.position; const activeRoute = currentPose.activeRoute; // 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 ( <> {/* Light background */} {/* Soft natural lighting - warehouse aesthetic */} {/* Main directional light with soft shadow */} {/* Fill light from opposite side */} {/* Grid */} {/* Floor with shadow receiving */} {/* Conveyor - belt top at 0.7m */} {/* Zone A - spawn point */} {/* Zone B - physical receiving tray at end of sorter */} {/* Zone C - roll cage for oversized items */} {/* Zone D - roll cage for round items */} {/* Chutes for routing to C/D */} {/* Camera rig - overhead above belt */} {/* Stereo camera lenses */} {/* Laser beam for height measurement */} {/* Point cloud for stereo analysis */} {/* Inspection zone on belt surface */} {/* Animated scan line on belt */} {/* Gate/diverter */} {/* Actuator pusher for routing */} {/* Route arrows on belt surface */} {/* Physically simulated items */} {sceneItems.map(item => ( ))} {/* Outline/BBox for the CURRENT item only */} {/* Motion trail for visual movement feedback */} {/* Cinematic camera controller */} {/* OrbitControls - enabled when not in cinematic mode */} ); } export default function SorterDigitalTwinContinuous({ playback, simplified = false, onContextLost, autoCameraEnabled = true, viewportType = 'desktop', }: SorterDigitalTwinContinuousProps) { return (
{ const canvas = gl.domElement; const handleLost = (event: Event) => { event.preventDefault(); if (!canvas.isConnected) return; onContextLost?.(); }; canvas.addEventListener('webglcontextlost', handleLost, false); }} >
); }