/** * 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 } from '@react-three/fiber'; import { Grid, OrbitControls, Html, Line } from '@react-three/drei'; import { Suspense, useRef, useMemo, useState } from 'react'; import type { Mesh, Group, BufferGeometry } 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 { getItemPosition, isItemVisible, getActiveRoute, getConveyorSpeedFactor } from '../../domain/conveyorPath'; 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 { 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, } from '../../domain/physicalLayout'; export interface SorterDigitalTwinContinuousProps { playback: ContinuousPlaybackState; simplified?: boolean; onContextLost?: () => void; } /** Light color palette */ const COLORS = { background: '#f6f9ff', floor: '#eaf2ff', gridCell: '#c9d8ee', gridSection: '#a8c0de', conveyorFrame: '#d8e6f8', belt: '#7aa2d8', beltStripe: '#9bc0eb', sideGuards: '#9bb7d8', rollers: '#b7c8dc', supports: '#c7d8ef', sensorAccent: '#2563eb', sensorActive: '#38bdf8', gateFrame: '#94a3b8', routeB: '#22c55e', routeC: '#f97316', routeD: '#8b5cf6', }; // 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; /** 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 - animates at belt surface */ function BeltStripe({ offset, speedFactor }: { offset: number; speedFactor: number }) { const meshRef = useRef(null); const posRef = useRef(offset); useFrame((_, delta) => { if (meshRef.current && speedFactor > 0) { posRef.current += delta * speedFactor * 1.0; if (posRef.current > CONVEYOR_END_X) posRef.current = CONVEYOR_START_X; meshRef.current.position.x = posRef.current; } }); 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 }: { speedFactor: number; pulseActive: boolean }) { 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 - top working surface at 0.7m */} {/* Belt stripes (animated) - moving on belt surface */} {[-4, -2.5, -1, 0.5, 2, 3.5].map((offset, i) => ( ))} {/* Side guards - above belt level */} {/* Frame rails under the belt */} {/* 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}
); } /** 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 */} {/* 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, emissiveIntensity, }: { path: string; scale: [number, number, number]; color: string; emissiveIntensity: 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, emissiveIntensity, w, h, d, }: { type: 'box' | 'cylinder' | 'sphere'; color: string; emissiveIntensity: number; w: number; h: number; d: number; }) { if (type === 'cylinder' || type === 'sphere') { return ( ); } return ( ); } /** * Animated item based on playback state. * Uses real STL models with true physical scale (1 unit = 1 meter). * Item sits ON the belt surface (bottom of item at BELT_TOP_Y). */ function PlaybackItem({ playback }: { playback: ContinuousPlaybackState }) { const visible = isItemVisible(playback); const category = playback.targetCategory; const phase = playback.currentPhase; const currentCase = playback.currentCase; const itemId = currentCase.itemId.replace('-LC', ''); const itemData = useMemo(() => { try { return ITEMS.find(i => i.id === itemId) ?? ITEMS[0]; } catch { return ITEMS[0]; } }, [itemId]); const asset = getModelAsset(itemId); const isRound = itemData.roundness >= 0.7 || asset?.fallbackPrimitive === 'cylinder'; // Get rendered dimensions in meters (true physical scale) const dims = getRenderedItemDimensions(itemData.dimensionsMm); const w = dims.width; const d = dims.depth; const h = dims.height; // Calculate Y position so item sits ON belt const itemCenterY = getItemYOnBelt(h); // Get XZ position from playback const basePosition = getItemPosition(playback, h); const pos: [number, number, number] = [basePosition.x, itemCenterY, basePosition.z]; const colors: Record = { B: COLORS.routeB, C: COLORS.routeC, D: COLORS.routeD, }; const color = category ? colors[category] : COLORS.sensorAccent; const isRouting = isRoutingActive(playback); const emissiveIntensity = isRouting ? 0.3 : 0.1; const showBBox = shouldShowBoundingBox(phase); const showShape = shouldShowShapeOutline(phase); if (!visible) return null; // Determine if we should use STL const useSTL = asset?.loaderType === 'stl' && asset?.frontendAssetPath; const stlPath = asset?.frontendAssetPath ?? ''; const fallbackPrimitive = asset?.fallbackPrimitive ?? 'box'; // Scale for STL models (STL files are in mm, need to convert to meters) const stlScale: [number, number, number] = [0.001, 0.001, 0.001]; const visualScale = Math.max(w, d, h); return ( <> {useSTL ? ( }> ) : ( )} {/* Shadow on belt */} {/* Bounding box during measurement */} {/* Shape outline during classification */} ); } /** Main continuous scene - light warehouse style with correct physical dimensions */ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybackState; simplified: boolean }) { const speedFactor = getConveyorSpeedFactor(playback); const activeRoute = getActiveRoute(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); // Get item position for measurement visualization const itemPosition = getItemPosition(playback); const itemPos: [number, number, number] = [itemPosition.x, itemPosition.y, itemPosition.z]; // Get item data for point cloud (use true physical scale) const currentCase = playback.currentCase; 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); return ( <> {/* Light background */} {/* Bright lighting */} {/* Grid */} {/* Floor */} {/* Conveyor - belt top at 0.7m */} {/* Zone A - spawn point */} {/* Zone B - main sorter exit */} {/* 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 */} {/* Item riding ON the belt */} ); } export default function SorterDigitalTwinContinuous({ playback, simplified = false, onContextLost, }: SorterDigitalTwinContinuousProps) { return (
{ const canvas = gl.domElement; const handleLost = (event: Event) => { event.preventDefault(); if (!canvas.isConnected) return; onContextLost?.(); }; canvas.addEventListener('webglcontextlost', handleLost, false); }} >
); }