feat: 3D digital twin major rework - real models, clean labels, improved geometry

- Real STL Models: 6 models loaded (55%), fallback for 5 (45%)
- Clean Labels: 12+ → 5 default, step-based, technical toggle
- Geometry: Gate lift, pusher plates, conveyor rollers, roll-cage wireframes
- Demo Director: 12 steps, camera presets, auto-play logic (not integrated)
- Tests: +16 new (43/43 green), Build ✅, Docker ✅, Domains ✅

Remaining: Auto demo UI integration, camera transitions, docs updates, Visual QA
This commit is contained in:
root
2026-07-04 16:45:43 +02:00
parent a700a146a3
commit 3e1f10a01a
20 changed files with 3584 additions and 158 deletions

View File

@@ -12,41 +12,76 @@ export default function Actuator3D({ gate, actuators, machineState }: Props) {
const pusherC = pusherOffset(actuators.pusherC);
const pusherD = pusherOffset(actuators.pusherD);
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
// Gate vertical lift: closed (Y = 0.2) → open (Y = 0.65)
const gateY = gateOpen ? 0.65 : 0.2;
return (
<group>
{/* Stop-gate */}
<mesh
position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY + 0.2, 0]}
rotation={[0, 0, gateOpen ? -0.7 : 0]}
>
<boxGeometry args={[0.06, 0.55, 0.62]} />
<meshStandardMaterial
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
emissive={fault ? '#fb3d4e' : '#000000'}
emissiveIntensity={fault ? 0.4 : 0}
/>
</mesh>
{/* Stop-gate — vertical lift mechanism */}
<group position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY, 0]}>
{/* Gate support posts (left and right) */}
<mesh position={[0, 0.35, -0.35]}>
<boxGeometry args={[0.04, 0.7, 0.04]} />
<meshStandardMaterial color="#475569" metalness={0.3} roughness={0.7} />
</mesh>
<mesh position={[0, 0.35, 0.35]}>
<boxGeometry args={[0.04, 0.7, 0.04]} />
<meshStandardMaterial color="#475569" metalness={0.3} roughness={0.7} />
</mesh>
{/* Gate plate — moves up/down */}
<mesh position={[0, gateY, 0]}>
<boxGeometry args={[0.08, 0.05, 0.65]} />
<meshStandardMaterial
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
emissive={fault ? '#fb3d4e' : '#000000'}
emissiveIntensity={fault ? 0.4 : gateOpen ? 0.15 : 0}
metalness={0.2}
roughness={0.6}
/>
</mesh>
</group>
{/* Pusher C → roll-cage C */}
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, pusherC]}>
<boxGeometry args={[0.35, 0.18, 0.18]} />
<meshStandardMaterial
color={actuators.pusherC === 'extended' ? '#f59e0b' : '#78350f'}
emissive={actuators.pusherC === 'extended' ? '#f59e0b' : '#000000'}
emissiveIntensity={actuators.pusherC === 'extended' ? 0.35 : 0}
/>
</mesh>
{/* Pusher C → roll-cage C (extended plate) */}
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
{/* Pusher base (stationary) */}
<mesh position={[0, 0.12, 0.1]}>
<boxGeometry args={[0.15, 0.18, 0.12]} />
<meshStandardMaterial color="#78350f" roughness={0.8} />
</mesh>
{/* Pusher plate (moves) */}
<mesh position={[0, 0.12, pusherC]}>
<boxGeometry args={[0.3, 0.15, 0.2]} />
<meshStandardMaterial
color={actuators.pusherC === 'extended' ? '#f59e0b' : '#92400e'}
emissive={actuators.pusherC === 'extended' ? '#f59e0b' : '#000000'}
emissiveIntensity={actuators.pusherC === 'extended' ? 0.35 : 0}
roughness={0.65}
/>
</mesh>
</group>
{/* Pusher D → roll-cage D */}
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, -pusherD]}>
<boxGeometry args={[0.35, 0.18, 0.18]} />
<meshStandardMaterial
color={actuators.pusherD === 'extended' ? '#c084fc' : '#4c1d95'}
emissive={actuators.pusherD === 'extended' ? '#c084fc' : '#000000'}
emissiveIntensity={actuators.pusherD === 'extended' ? 0.35 : 0}
/>
</mesh>
{/* Pusher D → roll-cage D (extended plate) */}
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
{/* Pusher base (stationary) */}
<mesh position={[0, 0.12, -0.1]}>
<boxGeometry args={[0.15, 0.18, 0.12]} />
<meshStandardMaterial color="#4c1d95" roughness={0.8} />
</mesh>
{/* Pusher plate (moves) */}
<mesh position={[0, 0.12, -pusherD]}>
<boxGeometry args={[0.3, 0.15, 0.2]} />
<meshStandardMaterial
color={actuators.pusherD === 'extended' ? '#c084fc' : '#581c87'}
emissive={actuators.pusherD === 'extended' ? '#c084fc' : '#000000'}
emissiveIntensity={actuators.pusherD === 'extended' ? 0.35 : 0}
roughness={0.65}
/>
</mesh>
</group>
</group>
);
}

View File

@@ -8,6 +8,7 @@ export default function Conveyor3D({ stopped }: Props) {
const color = stopped ? '#7f1d1d' : '#1e3a54';
const beltColor = stopped ? '#991b1b' : '#334155';
const accColor = stopped ? '#7f1d1d' : '#0f766e';
const sideGuardColor = '#475569';
return (
<group>
@@ -22,25 +23,56 @@ export default function Conveyor3D({ stopped }: Props) {
<planeGeometry args={[8.4, 0.48]} />
<meshStandardMaterial color={beltColor} roughness={0.9} />
</mesh>
{/* Conveyor side guards (borders) */}
<mesh position={[0, TWIN_LAYOUT.beltY + 0.05, 0.28]}>
<boxGeometry args={[8.4, 0.1, 0.03]} />
<meshStandardMaterial color={sideGuardColor} metalness={0.2} roughness={0.8} />
</mesh>
<mesh position={[0, TWIN_LAYOUT.beltY + 0.05, -0.28]}>
<boxGeometry args={[8.4, 0.1, 0.03]} />
<meshStandardMaterial color={sideGuardColor} metalness={0.2} roughness={0.8} />
</mesh>
{/* Rollers (visual indication of conveyor mechanism) — spaced every 1.2m */}
{[-3.6, -2.4, -1.2, 0, 1.2].map((x) => (
<mesh key={x} position={[x, TWIN_LAYOUT.beltY - 0.08, 0]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.04, 0.04, 0.5, 8]} />
<meshStandardMaterial color="#334155" metalness={0.4} roughness={0.6} />
</mesh>
))}
{/* Accumulator / buffer at end of feed conveyor — explicit raised pocket */}
<group position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY, 0]}>
{/* Accumulator floor (raised pocket) */}
<mesh position={[0, -0.02, 0]}>
<boxGeometry args={[1.05, 0.12, 0.82]} />
<meshStandardMaterial color={accColor} roughness={0.65} emissive={accColor} emissiveIntensity={0.15} />
</mesh>
{/* Side walls of accumulator */}
{/* Accumulator walls — three sides (back + left + right) */}
<mesh position={[0, 0.12, 0.38]}>
<boxGeometry args={[1.05, 0.28, 0.06]} />
<meshStandardMaterial color="#14b8a6" />
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
</mesh>
<mesh position={[0, 0.12, -0.38]}>
<boxGeometry args={[1.05, 0.28, 0.06]} />
<meshStandardMaterial color="#14b8a6" />
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
</mesh>
<mesh position={[-0.48, 0.12, 0]}>
<boxGeometry args={[0.06, 0.28, 0.82]} />
<meshStandardMaterial color="#14b8a6" />
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
</mesh>
{/* Direction arrow (subtle indication of flow) */}
<mesh position={[0.2, 0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.3, 0.2]} />
<meshStandardMaterial
color="#5eead4"
transparent
opacity={0.4}
side={2}
/>
</mesh>
</group>
</group>

View File

@@ -1,12 +1,74 @@
import { Suspense } from 'react';
import type { SimulatedItem, SimulationState } from '../../domain/types';
import { itemPosition3D, ROUTE_COLORS } from './itemMotion';
import { DIMENSION_LIMITS } from '../../domain/classifier';
import { getModelAsset } from '../../data/modelAssets';
import STLModel from './STLModel';
interface Props {
simulation: SimulationState;
currentItem?: SimulatedItem;
}
function FallbackPrimitive({
primitive,
sx,
sy,
sz,
color,
emissive,
roughness,
}: {
primitive: 'box' | 'cylinder' | 'sphere';
sx: number;
sy: number;
sz: number;
color: string;
emissive: string;
roughness: number;
}) {
if (primitive === 'cylinder') {
return (
<mesh>
<cylinderGeometry args={[Math.min(sx, sz) / 2, Math.min(sx, sz) / 2, sy, 16]} />
<meshStandardMaterial
color={color}
emissive={emissive}
emissiveIntensity={0.2}
roughness={roughness}
/>
</mesh>
);
}
if (primitive === 'sphere') {
return (
<mesh>
<sphereGeometry args={[Math.max(sx, sy, sz) / 2, 16, 12]} />
<meshStandardMaterial
color={color}
emissive={emissive}
emissiveIntensity={0.2}
roughness={roughness}
/>
</mesh>
);
}
// default: box
return (
<mesh>
<boxGeometry args={[sx, sy, sz]} />
<meshStandardMaterial
color={color}
emissive={emissive}
emissiveIntensity={0.2}
roughness={roughness}
/>
</mesh>
);
}
export default function Item3D({ simulation, currentItem }: Props) {
if (!currentItem) {
return null;
@@ -16,39 +78,72 @@ export default function Item3D({ simulation, currentItem }: Props) {
const category = currentItem.classification.category;
const color = ROUTE_COLORS[category] ?? '#38bdf8';
const dims = currentItem.item.dimensionsMm;
// Scale to Three.js units (1 unit = 1 meter, dimensions in mm)
const sx = Math.max(0.12, Math.min(0.45, dims.width / 1000));
const sy = Math.max(0.1, Math.min(0.4, dims.height / 1000));
const sz = Math.max(0.12, Math.min(0.45, dims.depth / 1000));
const isRound =
currentItem.item.shape.includes('round') ||
currentItem.item.shape.includes('cylinder') ||
currentItem.item.roundness >= DIMENSION_LIMITS.roundnessThreshold;
const detecting = simulation.machineState === 'DETECTING';
const fault =
simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
const fault = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
// Get model asset for this item
const asset = getModelAsset(currentItem.item.id);
const finalColor = fault ? '#fb3d4e' : color;
const finalEmissive = fault ? '#fb3d4e' : color;
const finalRoughness = asset?.loaderType === 'stl' ? 0.45 : 0.5;
// Fallback primitive
const fallbackPrimitive = asset?.fallbackPrimitive ?? 'box';
return (
<group position={[x, y, z]}>
{isRound ? (
<mesh>
<cylinderGeometry args={[Math.min(sx, sz) / 2, Math.min(sx, sz) / 2, sy, 16]} />
<meshStandardMaterial
color={fault ? '#fb3d4e' : color}
emissive={fault ? '#fb3d4e' : color}
{asset && asset.loaderType === 'stl' && asset.frontendAssetPath ? (
<Suspense
fallback={
<FallbackPrimitive
primitive={fallbackPrimitive}
sx={sx}
sy={sy}
sz={sz}
color={finalColor}
emissive={finalEmissive}
roughness={finalRoughness}
/>
}
>
<STLModel
path={asset.frontendAssetPath}
scale={[sx, sy, sz]}
rotation={[0, 0, 0]}
position={[0, 0, 0]}
color={finalColor}
emissive={finalEmissive}
emissiveIntensity={0.2}
roughness={0.45}
roughness={finalRoughness}
fallback={
<FallbackPrimitive
primitive={fallbackPrimitive}
sx={sx}
sy={sy}
sz={sz}
color={finalColor}
emissive={finalEmissive}
roughness={finalRoughness}
/>
}
/>
</mesh>
</Suspense>
) : (
<mesh>
<boxGeometry args={[sx, sy, sz]} />
<meshStandardMaterial
color={fault ? '#fb3d4e' : color}
emissive={fault ? '#fb3d4e' : color}
emissiveIntensity={0.2}
roughness={0.5}
/>
</mesh>
<FallbackPrimitive
primitive={fallbackPrimitive}
sx={sx}
sy={sy}
sz={sz}
color={finalColor}
emissive={finalEmissive}
roughness={finalRoughness}
/>
)}
{detecting ? (

View File

@@ -0,0 +1,88 @@
/**
* STLModel component — loads and displays STL models.
* Uses STLLoader from three/examples.
*/
import { useLoader } from '@react-three/fiber';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
import { Center } from '@react-three/drei';
import type { BufferGeometry, Mesh as ThreeMesh } from 'three';
import { useRef, useEffect } from 'react';
interface STLModelProps {
/** Path to STL file in public folder */
path: string;
/** Scale factor [x, y, z] */
scale: [number, number, number];
/** Rotation [x, y, z] in radians */
rotation?: [number, number, number];
/** Position offset [x, y, z] */
position?: [number, number, number];
/** Material color */
color: string;
/** Emissive color (for glow effect) */
emissive?: string;
/** Emissive intensity */
emissiveIntensity?: number;
/** Roughness */
roughness?: number;
/** Fallback component if loading fails */
fallback?: React.ReactNode;
}
export default function STLModel({
path,
scale,
rotation = [0, 0, 0],
position = [0, 0, 0],
color,
emissive,
emissiveIntensity = 0.2,
roughness = 0.5,
fallback,
}: STLModelProps) {
const meshRef = useRef<ThreeMesh>(null);
let geometry: BufferGeometry | null = null;
try {
// Load STL geometry
geometry = useLoader(STLLoader, path);
} catch (error) {
console.warn(`Failed to load STL model: ${path}`, error);
return fallback ? <>{fallback}</> : null;
}
// Center the geometry on load
useEffect(() => {
if (meshRef.current && geometry) {
geometry.center();
geometry.computeVertexNormals();
}
}, [geometry]);
if (!geometry) {
return fallback ? <>{fallback}</> : null;
}
return (
<Center position={position} rotation={rotation}>
<mesh ref={meshRef} geometry={geometry} scale={scale}>
<meshStandardMaterial
color={color}
emissive={emissive ?? color}
emissiveIntensity={emissiveIntensity}
roughness={roughness}
/>
</mesh>
</Center>
);
}

View File

@@ -8,6 +8,8 @@ interface Props {
currentItem?: SimulatedItem;
simplified?: boolean;
conveyorTargetMps?: number;
technicalLabelsEnabled?: boolean;
cleanView?: boolean;
}
function Badge({
@@ -44,77 +46,32 @@ function Badge({
);
}
function ItemProofPanel({
currentItem,
machineState,
}: {
currentItem: SimulatedItem;
machineState: MachineState;
}) {
const item = currentItem.item;
const result = currentItem.classification;
const category = result.category as Category;
const color = ROUTE_COLORS[category];
const command = machineState.startsWith('ROUTE_TO_')
? machineState
: `ROUTE_TO_${category}`;
const dims = item.dimensionsMm;
const dimStatus = result.dimensionsPass ? 'PASS' : 'FAIL';
const roundStatus = result.roundnessPass ? 'PASS' : 'DETECTED';
return (
<Html position={[0, 2.05, 0]} center distanceFactor={9} style={{ pointerEvents: 'none' }}>
<div
style={{
minWidth: 260,
padding: '12px 14px',
borderRadius: 14,
background: 'rgba(5, 9, 16, 0.94)',
border: `2px solid ${color}`,
color: '#e5f2ff',
fontFamily: 'Inter, system-ui, sans-serif',
boxShadow: `0 0 24px ${color}44`,
}}
>
<div style={{ fontSize: 11, color: '#91a4b8', fontWeight: 800, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
Current item
</div>
<div style={{ fontSize: 16, fontWeight: 800, margin: '4px 0 10px' }}>{item.name}</div>
<div style={{ display: 'grid', gap: 6, fontSize: 14, fontWeight: 700 }}>
<div>
Dimensions: {dims.width}×{dims.depth}×{dims.height} mm{' '}
<span style={{ color: result.dimensionsPass ? '#4ade80' : '#fb3d4e' }}>{dimStatus}</span>
</div>
<div>
Roundness K = {item.roundness.toFixed(2)}{' '}
<span style={{ color: result.roundnessPass ? '#4ade80' : '#c084fc' }}>{roundStatus}</span>
</div>
<div style={{ color }}>
Category: {category}
</div>
<div style={{ color }}>
Command: {command}
</div>
<div style={{ color }}>
Target zone: {category}
</div>
</div>
</div>
</Html>
);
}
// ItemProofPanel removed — info now displayed in 2D proof card only
export default function SceneLabels3D({
machineState,
currentItem,
conveyorTargetMps = 1,
technicalLabelsEnabled = false,
cleanView = true,
}: Props) {
const category = currentItem?.classification.category as Category | undefined;
const cage = TWIN_LAYOUT.rollCageSize;
const detecting = machineState === 'DETECTING';
const atGate = machineState === 'WAITING_AT_GATE' || machineState === 'CLASSIFYING';
const routing = machineState.startsWith('ROUTE_TO_');
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
// C-priority: dimensions failed + round detected → only C active, D not active
const isCPriority = Boolean(
category === 'C' && currentItem && !currentItem.classification.dimensionsPass && !currentItem.classification.roundnessPass
);
return (
<group>
{/* Zones A/B/C/D — always explicit */}
{/* === CLEAN VIEW (always visible) === */}
{/* Zones A/B/C/D — always visible */}
<Badge position={[TWIN_LAYOUT.startX, 0.85, 0]} text="A" color="#38bdf8" large />
<Badge position={[TWIN_LAYOUT.zoneBX, 0.95, 0]} text="B" color={ROUTE_COLORS.B} large />
<Badge
@@ -130,7 +87,7 @@ export default function SceneLabels3D({
large
/>
{/* Accumulator at end of feed conveyor */}
{/* Накопитель — always visible */}
<Badge
position={[TWIN_LAYOUT.accumulatorX, 0.95, 0.65]}
text="Накопитель"
@@ -138,51 +95,109 @@ export default function SceneLabels3D({
large
/>
{/* Sensors & actuators — always visible */}
<Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera / CV" color="#38bdf8" />
<Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.05, -0.55]} text="Ultrasonic" color="#67e8f9" />
<Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
{/* === STEP-BASED LABELS (shown only when active) === */}
{/* Detection: Camera/CV + Laser */}
{detecting && (
<>
<Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />
<Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />
</>
)}
{/* Route labels */}
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="Route B" color={ROUTE_COLORS.B} />
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
text="Route C"
color={ROUTE_COLORS.C}
/>
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
text="Route D"
color={ROUTE_COLORS.D}
/>
{/* At gate: Stop-gate + Ultrasonic */}
{atGate && (
<>
<Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />
</>
)}
<Badge
position={[-3.2, 1.55, 1.4]}
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
color="#91a4b8"
/>
{/* Routing: active pusher only */}
{routing && category === 'C' && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
)}
{routing && category === 'D' && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
)}
{currentItem ? <ItemProofPanel currentItem={currentItem} machineState={machineState} /> : null}
{/* Active route only (not all three routes) */}
{routing && category === 'B' && (
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="→ B" color={ROUTE_COLORS.B} />
)}
{routing && category === 'C' && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
text="→ C"
color={ROUTE_COLORS.C}
/>
)}
{routing && category === 'D' && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
text="→ D"
color={ROUTE_COLORS.D}
/>
)}
{category === 'C' && currentItem && !currentItem.classification.roundnessPass ? (
{/* C-priority badge (only when applicable) */}
{isCPriority && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.7, TWIN_LAYOUT.zoneCZ]}
text="C-priority (dims first)"
text="C-priority"
color={ROUTE_COLORS.C}
large
/>
) : null}
)}
{machineState === 'DETECTING' ? (
<Badge position={[TWIN_LAYOUT.cameraX, 1.55, 0]} text="CV detection" color="#38bdf8" />
) : null}
{machineState === 'FAULT' || machineState === 'EMERGENCY_STOP' ? (
{/* Fault / Emergency stop */}
{fault && (
<Badge position={[0, 2.5, 0]} text={machineState} color="#fb3d4e" large />
) : null}
)}
{/* === TECHNICAL LABELS (opt-in) === */}
{technicalLabelsEnabled && !cleanView && (
<>
{/* Always show all sensors when technical mode is on */}
{!detecting && <Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />}
{!detecting && <Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />}
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.05, -0.55]} text="Ultrasonic" color="#67e8f9" />
{/* Always show all actuators */}
{!atGate && <Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />}
{!(routing && category === 'C') && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
)}
{!(routing && category === 'D') && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
)}
{/* All route beams */}
{!(routing && category === 'B') && (
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="Route B" color={ROUTE_COLORS.B} />
)}
{!(routing && category === 'C') && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
text="Route C"
color={ROUTE_COLORS.C}
/>
)}
{!(routing && category === 'D') && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
text="Route D"
color={ROUTE_COLORS.D}
/>
)}
{/* Conveyor info */}
<Badge
position={[-3.2, 1.55, 1.4]}
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
color="#91a4b8"
/>
</>
)}
</group>
);
}

View File

@@ -17,6 +17,8 @@ export interface SorterDigitalTwinProps {
simulation: SimulationState;
simplified?: boolean;
showFps?: boolean;
technicalLabelsEnabled?: boolean;
cleanView?: boolean;
onContextLost?: () => void;
}
@@ -41,10 +43,14 @@ function FpsMeter({ onFps }: { onFps: (fps: number) => void }) {
function TwinScene({
simulation,
simplified,
technicalLabelsEnabled,
cleanView,
onFps,
}: {
simulation: SimulationState;
simplified?: boolean;
technicalLabelsEnabled?: boolean;
cleanView?: boolean;
onFps: (fps: number) => void;
}) {
const stopped =
@@ -99,6 +105,8 @@ function TwinScene({
currentItem={simulation.currentItem}
simplified={simplified}
conveyorTargetMps={NOMINAL_CONVEYOR_SPEED_MPS}
technicalLabelsEnabled={technicalLabelsEnabled}
cleanView={cleanView}
/>
{stopped ? (
@@ -125,6 +133,8 @@ export default function SorterDigitalTwin({
simulation,
simplified = false,
showFps = true,
technicalLabelsEnabled = false,
cleanView = true,
onContextLost,
}: SorterDigitalTwinProps) {
const [fps, setFps] = useState(0);
@@ -169,7 +179,13 @@ export default function SorterDigitalTwin({
}}
>
<Suspense fallback={null}>
<TwinScene simulation={simulation} simplified={simplified} onFps={setFps} />
<TwinScene
simulation={simulation}
simplified={simplified}
technicalLabelsEnabled={technicalLabelsEnabled}
cleanView={cleanView}
onFps={setFps}
/>
</Suspense>
</Canvas>
</div>

View File

@@ -1,3 +1,4 @@
import * as THREE from 'three';
import type { Category } from '../../domain/types';
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
@@ -31,6 +32,54 @@ function ZoneBox({
);
}
/** Roll-cage with wireframe edges for C/D zones */
function RollCage({
position,
size,
color,
active,
}: {
position: [number, number, number];
size: [number, number, number];
color: string;
active: boolean;
}) {
return (
<group position={position}>
{/* Cage body (transparent) */}
<mesh>
<boxGeometry args={size} />
<meshStandardMaterial
color={color}
transparent
opacity={active ? 0.25 : 0.1}
emissive={color}
emissiveIntensity={active ? 0.3 : 0.08}
/>
</mesh>
{/* Cage wireframe edges (visible frame) */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(...size)]} />
<lineBasicMaterial color={color} linewidth={active ? 2 : 1} opacity={active ? 1 : 0.6} transparent />
</lineSegments>
{/* Vertical posts (corners) for visual emphasis */}
{[
[-size[0] / 2, 0, -size[2] / 2],
[size[0] / 2, 0, -size[2] / 2],
[-size[0] / 2, 0, size[2] / 2],
[size[0] / 2, 0, size[2] / 2],
].map((offset, i) => (
<mesh key={i} position={offset as [number, number, number]}>
<boxGeometry args={[0.04, size[1], 0.04]} />
<meshStandardMaterial color={color} metalness={0.3} roughness={0.7} />
</mesh>
))}
</group>
);
}
export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
const active = activeRoute ?? activeCategory;
const cage = TWIN_LAYOUT.rollCageSize;
@@ -53,16 +102,16 @@ export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
active={active === 'B'}
/>
{/* Roll-cage C 1200×800×800 mm — orange */}
<ZoneBox
{/* Roll-cage C 1200×800×800 mm — orange with wireframe */}
<RollCage
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneCZ]}
size={[cage.x, cage.y, cage.z]}
color={ROUTE_COLORS.C}
active={active === 'C'}
/>
{/* Roll-cage D 1200×800×800 mm — purple */}
<ZoneBox
{/* Roll-cage D 1200×800×800 mm — purple with wireframe */}
<RollCage
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneDZ]}
size={[cage.x, cage.y, cage.z]}
color={ROUTE_COLORS.D}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest';
import {
MODEL_ASSETS,
getModelAsset,
getSTLAssets,
getProceduralAssets,
getManifestStats,
} from './modelAssets';
describe('modelAssets', () => {
describe('MODEL_ASSETS', () => {
it('should contain assets for all scenario items', () => {
const expectedIds = [
'SKU-001',
'SKU-002',
'SKU-003',
'SKU-004',
'SKU-005',
'SKU-006',
'SKU-007',
'SKU-008',
'SKU-009',
'SKU-010',
'SKU-011',
];
const actualIds = MODEL_ASSETS.map((asset) => asset.itemId);
expectedIds.forEach((id) => {
expect(actualIds).toContain(id);
});
});
it('should have fallback primitive for every asset', () => {
MODEL_ASSETS.forEach((asset) => {
expect(asset.fallbackPrimitive).toMatch(/^(box|cylinder|sphere)$/);
});
});
it('should have valid loader type for every asset', () => {
MODEL_ASSETS.forEach((asset) => {
expect(asset.loaderType).toMatch(/^(stl|glb|procedural)$/);
});
});
it('should have frontendAssetPath only for stl/glb loaders', () => {
MODEL_ASSETS.forEach((asset) => {
if (asset.loaderType === 'procedural') {
expect(asset.frontendAssetPath).toBeUndefined();
} else {
expect(asset.frontendAssetPath).toBeTruthy();
}
});
});
});
describe('getModelAsset', () => {
it('should return asset for valid item ID', () => {
const asset = getModelAsset('SKU-006');
expect(asset).toBeDefined();
expect(asset?.displayName).toBe('Тарелка');
expect(asset?.categoryScenario).toBe('D');
});
it('should return undefined for invalid item ID', () => {
const asset = getModelAsset('SKU-999');
expect(asset).toBeUndefined();
});
});
describe('getSTLAssets', () => {
it('should return only assets with loaderType stl', () => {
const stlAssets = getSTLAssets();
stlAssets.forEach((asset) => {
expect(asset.loaderType).toBe('stl');
});
});
it('should contain at least some STL assets', () => {
const stlAssets = getSTLAssets();
expect(stlAssets.length).toBeGreaterThan(0);
});
});
describe('getProceduralAssets', () => {
it('should return only assets with loaderType procedural', () => {
const proceduralAssets = getProceduralAssets();
proceduralAssets.forEach((asset) => {
expect(asset.loaderType).toBe('procedural');
});
});
});
describe('getManifestStats', () => {
it('should return correct totals', () => {
const stats = getManifestStats();
expect(stats.total).toBe(MODEL_ASSETS.length);
expect(stats.stl).toBe(getSTLAssets().length);
expect(stats.procedural).toBe(getProceduralAssets().length);
expect(stats.stl + stats.procedural).toBe(stats.total);
});
it('should calculate correct percentage', () => {
const stats = getManifestStats();
expect(stats.stlPercentage).toBeGreaterThanOrEqual(0);
expect(stats.stlPercentage).toBeLessThanOrEqual(100);
expect(stats.stlPercentage).toBe(Math.round((stats.stl / stats.total) * 100));
});
});
});

202
src/data/modelAssets.ts Normal file
View File

@@ -0,0 +1,202 @@
/**
* Model Assets Manifest — real 3D models for product items.
* Maps simulation items to real STL/GLB models from input_info.
*/
import type { Category, DimensionsMm } from '../domain/types';
export interface ModelAsset {
/** Item ID from items.ts (e.g. 'SKU-006') */
itemId: string;
/** Display name (Russian) */
displayName: string;
/** Expected category scenario */
categoryScenario: Category;
/** Dimensions in mm */
dimensions: DimensionsMm;
/** Source file from input_info (for reference) */
sourceFile: string;
/** Frontend asset path (public/models or undefined if not used) */
frontendAssetPath?: string;
/** Loader type: 'stl' = use STLLoader, 'glb' = use GLTFLoader, 'procedural' = fallback primitive */
loaderType: 'stl' | 'glb' | 'procedural';
/** Fallback primitive shape if model fails to load */
fallbackPrimitive: 'box' | 'cylinder' | 'sphere';
/** Notes about why model is used/not used */
notes?: string;
}
/**
* Model assets manifest.
*
* Strategy:
* - Lightweight STL (< 700 KB): use directly with STLLoader.
* - Heavy STL (> 1 MB): use procedural fallback, mark as "too heavy".
* - Missing models: use procedural fallback.
*/
export const MODEL_ASSETS: ModelAsset[] = [
{
itemId: 'SKU-001',
displayName: 'Короб 300×200×200',
categoryScenario: 'B',
dimensions: { width: 300, depth: 200, height: 200 },
sourceFile: 'input_info/extracted/Stl/Короб 300х200х200.stl',
frontendAssetPath: '/models/box-300.stl',
loaderType: 'stl',
fallbackPrimitive: 'box',
notes: 'Lightweight STL (29 KB), used directly.',
},
{
itemId: 'SKU-002',
displayName: 'ЛанчБокс',
categoryScenario: 'B',
dimensions: { width: 201, depth: 152, height: 62 },
sourceFile: 'input_info/extracted/Stl/ЛанчБокс.stl',
frontendAssetPath: '/models/lunchbox.stl',
loaderType: 'stl',
fallbackPrimitive: 'box',
notes: 'Medium STL (566 KB), acceptable for browser loading.',
},
{
itemId: 'SKU-003',
displayName: 'Моющее средство',
categoryScenario: 'B',
dimensions: { width: 259, depth: 179, height: 278 },
sourceFile: 'input_info/extracted/Stl/Моющее средство.STL',
frontendAssetPath: undefined,
loaderType: 'procedural',
fallbackPrimitive: 'box',
notes: 'Heavy STL (3.5 MB), using fallback box to avoid performance impact.',
},
{
itemId: 'SKU-004',
displayName: 'Короб 400×400×300 (негабарит)',
categoryScenario: 'C',
dimensions: { width: 401, depth: 300, height: 400 },
sourceFile: 'input_info/extracted/Stl/Короб 400х400х300.stl',
frontendAssetPath: '/models/box-400.stl',
loaderType: 'stl',
fallbackPrimitive: 'box',
notes: 'Lightweight STL (27 KB), used directly.',
},
{
itemId: 'SKU-005',
displayName: 'Пуфик',
categoryScenario: 'C',
dimensions: { width: 489, depth: 264, height: 489 },
sourceFile: 'input_info/extracted/Stl/Пуфик.stl',
frontendAssetPath: undefined,
loaderType: 'procedural',
fallbackPrimitive: 'cylinder',
notes: 'Medium-heavy STL (629 KB), using fallback cylinder for soft bulky item representation.',
},
{
itemId: 'SKU-006',
displayName: 'Тарелка',
categoryScenario: 'D',
dimensions: { width: 210, depth: 209, height: 27 },
sourceFile: 'input_info/extracted/Stl/Тарелка.stl',
frontendAssetPath: '/models/plate.stl',
loaderType: 'stl',
fallbackPrimitive: 'cylinder',
notes: 'Lightweight STL (123 KB), used directly. Round plate is visually distinctive.',
},
{
itemId: 'SKU-007',
displayName: 'Бутылка',
categoryScenario: 'D',
dimensions: { width: 91, depth: 91, height: 305 },
sourceFile: 'input_info/extracted/Stl/Бутылка.stl',
frontendAssetPath: '/models/bottle.stl',
loaderType: 'stl',
fallbackPrimitive: 'cylinder',
notes: 'Medium STL (319 KB), acceptable. Bottle is visually recognizable.',
},
{
itemId: 'SKU-008',
displayName: 'Цилиндр',
categoryScenario: 'D',
dimensions: { width: 435, depth: 50, height: 43 },
sourceFile: 'input_info/extracted/Stl/Цилиндр.stl',
frontendAssetPath: '/models/cylinder.stl',
loaderType: 'stl',
fallbackPrimitive: 'cylinder',
notes: 'Lightweight STL (106 KB), used directly.',
},
{
itemId: 'SKU-009',
displayName: 'Ручка',
categoryScenario: 'C',
dimensions: { width: 9, depth: 13, height: 148 },
sourceFile: 'input_info/extracted/Stl/Ручка.stl',
frontendAssetPath: undefined,
loaderType: 'procedural',
fallbackPrimitive: 'box',
notes: 'Heavy STL (2.0 MB), using fallback thin box for pen representation.',
},
{
itemId: 'SKU-010',
displayName: 'Boundary box 450×320×320',
categoryScenario: 'B',
dimensions: { width: 450, depth: 320, height: 320 },
sourceFile: 'N/A',
frontendAssetPath: undefined,
loaderType: 'procedural',
fallbackPrimitive: 'box',
notes: 'No STL available for boundary box, using procedural box.',
},
{
itemId: 'SKU-011',
displayName: 'Oversized round',
categoryScenario: 'C',
dimensions: { width: 500, depth: 300, height: 300 },
sourceFile: 'N/A',
frontendAssetPath: undefined,
loaderType: 'procedural',
fallbackPrimitive: 'cylinder',
notes: 'No STL available for oversized round, using procedural cylinder.',
},
];
/**
* Get model asset for item ID.
*/
export function getModelAsset(itemId: string): ModelAsset | undefined {
return MODEL_ASSETS.find((asset) => asset.itemId === itemId);
}
/**
* Get all assets that use real STL models.
*/
export function getSTLAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.loaderType === 'stl');
}
/**
* Get all assets that use procedural fallbacks.
*/
export function getProceduralAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.loaderType === 'procedural');
}
/**
* Summary stats for manifest.
*/
export function getManifestStats() {
const stlCount = getSTLAssets().length;
const proceduralCount = getProceduralAssets().length;
return {
total: MODEL_ASSETS.length,
stl: stlCount,
procedural: proceduralCount,
stlPercentage: Math.round((stlCount / MODEL_ASSETS.length) * 100),
};
}

View File

@@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest';
import {
DemoStep,
createDemoDirectorState,
startAutoDemo,
pauseAutoDemo,
resumeAutoDemo,
stopAutoDemo,
updateAutoDemo,
getDemoStepSequence,
getDemoStepConfig,
setPlaybackSpeed,
toggleLoopMode,
} from './demoDirector';
import type { Scenario } from './types';
import { ITEMS } from '../data/items';
const mockScenario: Scenario = {
id: 'normal_flow',
name: 'Normal Flow',
description: 'Test scenario',
goal: 'Test auto demo',
expectedCategorySummary: 'B',
demonstrates: 'Demo director test',
items: [ITEMS[0]],
};
describe('demoDirector', () => {
describe('createDemoDirectorState', () => {
it('should create initial state', () => {
const state = createDemoDirectorState(mockScenario);
expect(state.isAutoDemoRunning).toBe(false);
expect(state.currentStep).toBe(DemoStep.intro);
expect(state.stepElapsedMs).toBe(0);
expect(state.totalElapsedMs).toBe(0);
expect(state.playbackSpeed).toBe(1);
expect(state.loopMode).toBe(false);
expect(state.paused).toBe(false);
});
});
describe('startAutoDemo', () => {
it('should start auto demo from intro', () => {
const initial = createDemoDirectorState(mockScenario);
const started = startAutoDemo(initial);
expect(started.isAutoDemoRunning).toBe(true);
expect(started.currentStep).toBe(DemoStep.intro);
expect(started.paused).toBe(false);
});
});
describe('pauseAutoDemo', () => {
it('should pause running demo', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
state = pauseAutoDemo(state);
expect(state.paused).toBe(true);
expect(state.isAutoDemoRunning).toBe(true);
});
});
describe('resumeAutoDemo', () => {
it('should resume paused demo', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
state = pauseAutoDemo(state);
state = resumeAutoDemo(state);
expect(state.paused).toBe(false);
expect(state.isAutoDemoRunning).toBe(true);
});
});
describe('stopAutoDemo', () => {
it('should stop demo and reset to intro', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
state = stopAutoDemo(state);
expect(state.isAutoDemoRunning).toBe(false);
expect(state.paused).toBe(false);
expect(state.currentStep).toBe(DemoStep.intro);
});
});
describe('updateAutoDemo', () => {
it('should not update if not running', () => {
const state = createDemoDirectorState(mockScenario);
const updated = updateAutoDemo(state, 100);
expect(updated.stepElapsedMs).toBe(0);
});
it('should not update if paused', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
state = pauseAutoDemo(state);
const updated = updateAutoDemo(state, 100);
expect(updated.stepElapsedMs).toBe(0);
});
it('should increment elapsed time', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
const updated = updateAutoDemo(state, 100);
expect(updated.stepElapsedMs).toBe(100);
expect(updated.totalElapsedMs).toBe(100);
});
it('should transition to next step when duration exceeded', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
const config = getDemoStepConfig(DemoStep.intro);
const updated = updateAutoDemo(state, config.durationMs + 100);
expect(updated.currentStep).toBe(DemoStep.spawn_item);
expect(updated.stepElapsedMs).toBe(0);
});
it('should stop demo when all steps completed (no loop)', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
state = { ...state, currentStep: DemoStep.reset_or_next };
const config = getDemoStepConfig(DemoStep.reset_or_next);
const updated = updateAutoDemo(state, config.durationMs + 100);
expect(updated.isAutoDemoRunning).toBe(false);
});
it('should restart demo when all steps completed (loop mode)', () => {
let state = createDemoDirectorState(mockScenario);
state = startAutoDemo(state);
state = toggleLoopMode(state);
state = { ...state, currentStep: DemoStep.reset_or_next };
const config = getDemoStepConfig(DemoStep.reset_or_next);
const updated = updateAutoDemo(state, config.durationMs + 100);
expect(updated.currentStep).toBe(DemoStep.intro);
expect(updated.isAutoDemoRunning).toBe(true);
});
});
describe('getDemoStepSequence', () => {
it('should contain all key steps in order', () => {
const sequence = getDemoStepSequence();
expect(sequence).toContain(DemoStep.intro);
expect(sequence).toContain(DemoStep.spawn_item);
expect(sequence).toContain(DemoStep.detection_scan);
expect(sequence).toContain(DemoStep.dimensions_check);
expect(sequence).toContain(DemoStep.roundness_check);
expect(sequence).toContain(DemoStep.decision);
expect(sequence).toContain(DemoStep.command_sent);
expect(sequence).toContain(DemoStep.actuator_move);
expect(sequence).toContain(DemoStep.route_item);
expect(sequence).toContain(DemoStep.result);
// Verify order: command_sent must come before actuator_move
const commandIndex = sequence.indexOf(DemoStep.command_sent);
const actuatorIndex = sequence.indexOf(DemoStep.actuator_move);
expect(commandIndex).toBeLessThan(actuatorIndex);
// Verify order: actuator_move must come before route_item
const routeIndex = sequence.indexOf(DemoStep.route_item);
expect(actuatorIndex).toBeLessThan(routeIndex);
});
});
describe('getDemoStepConfig', () => {
it('should return config for each step', () => {
const config = getDemoStepConfig(DemoStep.detection_scan);
expect(config.step).toBe(DemoStep.detection_scan);
expect(config.durationMs).toBeGreaterThan(0);
expect(config.label).toBeTruthy();
expect(config.description).toBeTruthy();
expect(config.cameraPreset).toBeDefined();
expect(config.activeLabels).toBeInstanceOf(Array);
});
it('should use category for routing steps', () => {
const configC = getDemoStepConfig(DemoStep.route_item, 'C');
expect(configC.activeLabels).toContain('C');
const configD = getDemoStepConfig(DemoStep.route_item, 'D');
expect(configD.activeLabels).toContain('D');
});
});
describe('setPlaybackSpeed', () => {
it('should set playback speed', () => {
const state = createDemoDirectorState(mockScenario);
const updated = setPlaybackSpeed(state, 2);
expect(updated.playbackSpeed).toBe(2);
});
});
describe('toggleLoopMode', () => {
it('should toggle loop mode', () => {
const state = createDemoDirectorState(mockScenario);
expect(state.loopMode).toBe(false);
const toggled = toggleLoopMode(state);
expect(toggled.loopMode).toBe(true);
const toggledAgain = toggleLoopMode(toggled);
expect(toggledAgain.loopMode).toBe(false);
});
});
});

413
src/domain/demoDirector.ts Normal file
View File

@@ -0,0 +1,413 @@
/**
* Demo Director — автоматическая демонстрация работы ПАК.
* Управляет step-by-step показом: intro → spawn → detection → decision → routing → result.
*/
import type { Scenario, MachineState, Category } from './types';
export enum DemoStep {
intro = 'intro',
spawn_item = 'spawn_item',
move_to_detection = 'move_to_detection',
detection_scan = 'detection_scan',
dimensions_check = 'dimensions_check',
roundness_check = 'roundness_check',
decision = 'decision',
command_sent = 'command_sent',
actuator_move = 'actuator_move',
route_item = 'route_item',
result = 'result',
reset_or_next = 'reset_or_next',
}
export interface DemoStepConfig {
step: DemoStep;
durationMs: number;
label: string;
description: string;
cameraPreset: CameraPreset;
activeLabels: string[];
}
export interface CameraPreset {
position: [number, number, number];
target: [number, number, number];
fov: number;
}
export interface DemoDirectorState {
isAutoDemoRunning: boolean;
currentScenario: Scenario;
currentStep: DemoStep;
stepElapsedMs: number;
totalElapsedMs: number;
playbackSpeed: 1 | 2 | 0.5;
loopMode: boolean;
paused: boolean;
}
/**
* Camera presets for demo steps.
*/
export const CAMERA_PRESETS: Record<string, CameraPreset> = {
overview: {
position: [6.2, 4.8, 6.2],
target: [0, 0.3, 0],
fov: 42,
},
detectionCloseup: {
position: [-1.2, 2.5, 3.5],
target: [-1.2, 0.3, 0],
fov: 38,
},
gateCloseup: {
position: [1.6, 2.0, 3.0],
target: [1.6, 0.3, 0],
fov: 36,
},
routingB: {
position: [2.5, 3.5, 0],
target: [3.0, 0.5, 0],
fov: 40,
},
routingC: {
position: [2.5, 3.5, 2.4],
target: [2.0, 0.5, 2.4],
fov: 40,
},
routingD: {
position: [2.5, 3.5, -2.4],
target: [2.0, 0.5, -2.4],
fov: 40,
},
resultB: {
position: [3.8, 3.0, 0],
target: [3.8, 0.5, 0],
fov: 38,
},
resultC: {
position: [2.0, 2.5, 2.4],
target: [2.0, 0.5, 2.4],
fov: 38,
},
resultD: {
position: [2.0, 2.5, -2.4],
target: [2.0, 0.5, -2.4],
fov: 38,
},
};
/**
* Demo step configurations.
*/
export function getDemoStepConfig(step: DemoStep, category?: Category): DemoStepConfig {
const configs: Record<DemoStep, DemoStepConfig> = {
[DemoStep.intro]: {
step: DemoStep.intro,
durationMs: 1500,
label: 'Введение',
description: 'Обзор сортировочной ячейки A/B/C/D',
cameraPreset: CAMERA_PRESETS.overview,
activeLabels: ['A', 'B', 'C', 'D', 'Накопитель'],
},
[DemoStep.spawn_item]: {
step: DemoStep.spawn_item,
durationMs: 800,
label: 'Появление товара',
description: 'Товар входит в зону A',
cameraPreset: CAMERA_PRESETS.overview,
activeLabels: ['A'],
},
[DemoStep.move_to_detection]: {
step: DemoStep.move_to_detection,
durationMs: 1200,
label: 'Движение к камере',
description: 'Товар едет по конвейеру к зоне детекции',
cameraPreset: CAMERA_PRESETS.detectionCloseup,
activeLabels: ['A', 'Camera CV'],
},
[DemoStep.detection_scan]: {
step: DemoStep.detection_scan,
durationMs: 900,
label: 'CV детекция',
description: 'Камера сканирует товар, определяет bbox',
cameraPreset: CAMERA_PRESETS.detectionCloseup,
activeLabels: ['Camera CV', 'Laser'],
},
[DemoStep.dimensions_check]: {
step: DemoStep.dimensions_check,
durationMs: 700,
label: 'Проверка габаритов',
description: 'Dimensions: PASS or FAIL',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Накопитель', 'Stop-gate'],
},
[DemoStep.roundness_check]: {
step: DemoStep.roundness_check,
durationMs: 700,
label: 'Проверка круглости',
description: 'Roundness K ≥ 0.7 or not',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Накопитель', 'Stop-gate'],
},
[DemoStep.decision]: {
step: DemoStep.decision,
durationMs: 700,
label: 'Решение',
description: 'Category: B/C/D определён',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Stop-gate', 'B', 'C', 'D'],
},
[DemoStep.command_sent]: {
step: DemoStep.command_sent,
durationMs: 600,
label: 'Команда отправлена',
description: 'ROUTE_TO_* команда передана исполнительной части',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Stop-gate', category ?? 'B'],
},
[DemoStep.actuator_move]: {
step: DemoStep.actuator_move,
durationMs: 1000,
label: 'Исполнительная часть',
description: 'Gate открывается или Pusher выдвигается',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Stop-gate', category === 'C' ? 'Pusher C' : category === 'D' ? 'Pusher D' : 'B'],
},
[DemoStep.route_item]: {
step: DemoStep.route_item,
durationMs: 1200,
label: 'Маршрутизация',
description: 'Товар физически уходит в целевую зону',
cameraPreset: category === 'C' ? CAMERA_PRESETS.routingC : category === 'D' ? CAMERA_PRESETS.routingD : CAMERA_PRESETS.routingB,
activeLabels: [category ?? 'B'],
},
[DemoStep.result]: {
step: DemoStep.result,
durationMs: 1500,
label: 'Результат',
description: 'Товар в целевой зоне, цикл завершён',
cameraPreset: category === 'C' ? CAMERA_PRESETS.resultC : category === 'D' ? CAMERA_PRESETS.resultD : CAMERA_PRESETS.resultB,
activeLabels: [category ?? 'B'],
},
[DemoStep.reset_or_next]: {
step: DemoStep.reset_or_next,
durationMs: 500,
label: 'Сброс',
description: 'Подготовка к следующему циклу',
cameraPreset: CAMERA_PRESETS.overview,
activeLabels: ['A', 'B', 'C', 'D'],
},
};
return configs[step];
}
/**
* Get demo step sequence for a scenario.
*/
export function getDemoStepSequence(): DemoStep[] {
return [
DemoStep.intro,
DemoStep.spawn_item,
DemoStep.move_to_detection,
DemoStep.detection_scan,
DemoStep.dimensions_check,
DemoStep.roundness_check,
DemoStep.decision,
DemoStep.command_sent,
DemoStep.actuator_move,
DemoStep.route_item,
DemoStep.result,
DemoStep.reset_or_next,
];
}
/**
* Map demo step to machine state (approximation).
*/
export function mapDemoStepToMachineState(step: DemoStep): MachineState | undefined {
const mapping: Partial<Record<DemoStep, MachineState>> = {
[DemoStep.move_to_detection]: 'MOVING_TO_CAMERA',
[DemoStep.detection_scan]: 'DETECTING',
[DemoStep.dimensions_check]: 'MOVING_TO_GATE',
[DemoStep.roundness_check]: 'WAITING_AT_GATE',
[DemoStep.decision]: 'CLASSIFYING',
[DemoStep.command_sent]: 'CLASSIFYING',
[DemoStep.actuator_move]: 'ROUTE_TO_B', // will be overridden by category
[DemoStep.route_item]: 'ROUTE_TO_B', // will be overridden by category
};
return mapping[step];
}
/**
* Create initial demo director state.
*/
export function createDemoDirectorState(scenario: Scenario): DemoDirectorState {
return {
isAutoDemoRunning: false,
currentScenario: scenario,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
playbackSpeed: 1,
loopMode: false,
paused: false,
};
}
/**
* Start auto demo.
*/
export function startAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
isAutoDemoRunning: true,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
paused: false,
};
}
/**
* Pause auto demo.
*/
export function pauseAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
paused: true,
};
}
/**
* Resume auto demo.
*/
export function resumeAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
paused: false,
};
}
/**
* Stop auto demo.
*/
export function stopAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
isAutoDemoRunning: false,
paused: false,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
};
}
/**
* Update auto demo state (call on each animation frame).
*/
export function updateAutoDemo(
state: DemoDirectorState,
deltaMs: number,
category?: Category,
): DemoDirectorState {
if (!state.isAutoDemoRunning || state.paused) {
return state;
}
const adjustedDelta = deltaMs * state.playbackSpeed;
const newStepElapsed = state.stepElapsedMs + adjustedDelta;
const newTotalElapsed = state.totalElapsedMs + adjustedDelta;
const currentConfig = getDemoStepConfig(state.currentStep, category);
if (newStepElapsed >= currentConfig.durationMs) {
// Move to next step
const sequence = getDemoStepSequence();
const currentIndex = sequence.indexOf(state.currentStep);
const nextIndex = currentIndex + 1;
if (nextIndex >= sequence.length) {
// Demo completed
if (state.loopMode) {
// Restart from intro
return {
...state,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
};
} else {
// Stop demo
return stopAutoDemo(state);
}
}
// Move to next step
return {
...state,
currentStep: sequence[nextIndex],
stepElapsedMs: 0,
totalElapsedMs: newTotalElapsed,
};
}
// Continue current step
return {
...state,
stepElapsedMs: newStepElapsed,
totalElapsedMs: newTotalElapsed,
};
}
/**
* Get camera preset for current demo step.
*/
export function getCameraPresetForStep(step: DemoStep, category?: Category): CameraPreset {
const config = getDemoStepConfig(step, category);
return config.cameraPreset;
}
/**
* Get active labels for current demo step.
*/
export function getActiveLabelsForStep(step: DemoStep, category?: Category): string[] {
const config = getDemoStepConfig(step, category);
return config.activeLabels;
}
/**
* Set playback speed.
*/
export function setPlaybackSpeed(state: DemoDirectorState, speed: 1 | 2 | 0.5): DemoDirectorState {
return {
...state,
playbackSpeed: speed,
};
}
/**
* Toggle loop mode.
*/
export function toggleLoopMode(state: DemoDirectorState): DemoDirectorState {
return {
...state,
loopMode: !state.loopMode,
};
}
/**
* Select scenario (reset demo).
*/
export function selectScenario(state: DemoDirectorState, scenario: Scenario): DemoDirectorState {
return {
...state,
currentScenario: scenario,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
isAutoDemoRunning: false,
paused: false,
};
}