feat: add 3D digital twin visualization

This commit is contained in:
root
2026-07-04 12:33:20 +02:00
parent a7025ca50c
commit b749cef14e
30 changed files with 1897 additions and 41 deletions

View File

@@ -1,4 +1,4 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { forwardRef, lazy, Suspense, useImperativeHandle, useState } from 'react';
import StateMachinePanel from './StateMachinePanel';
import TimelinePanel from './TimelinePanel';
import PidPanel from './PidPanel';
@@ -12,6 +12,8 @@ import { DEMO_STEPS } from '../data/demoSteps';
import { SCENARIOS } from '../data/scenarios';
import type { ScenarioId, SimulationState } from '../domain/types';
const ThreeCapabilityCheck = lazy(() => import('./ThreeD/ThreeCapabilityCheck'));
export interface EngineeringDetailsHandle {
open: () => void;
}
@@ -63,6 +65,12 @@ const EngineeringDetails = forwardRef<EngineeringDetailsHandle, Props>(
<SorterScene simulation={simulation} variant="full" />
</div>
<div className="eng-panel eng-panel-wide">
<Suspense fallback={<p className="three-loading">Loading 3D capability check…</p>}>
<ThreeCapabilityCheck />
</Suspense>
</div>
<div className="engineering-grid">
<div className="eng-panel">
<StateMachinePanel currentState={simulation.machineState} />

View File

@@ -1,7 +1,13 @@
import SorterScene from './SorterScene';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import CurrentProofCard from './CurrentProofCard';
import ThreeFallback from './ThreeD/ThreeFallback';
import { prefer3DByDefault, useWebGLSupport } from './ThreeD/useWebGL';
import type { SimulationState } from '../domain/types';
const SorterDigitalTwin = lazy(() => import('./ThreeD/SorterDigitalTwin'));
type ViewMode = '3d' | '2d';
interface Props {
simulation: SimulationState;
demoStepTitle: string;
@@ -19,6 +25,21 @@ export default function ProductDemoSection({
onReset,
onOpenEngineering,
}: Props) {
const webgl = useWebGLSupport();
const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth));
const [viewMode, setViewMode] = useState<ViewMode>('3d');
const [contextLost, setContextLost] = useState(false);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
setViewMode(prefer3DByDefault(width, webgl && !contextLost) ? '3d' : '2d');
}, [webgl, width, contextLost]);
const item = simulation.currentItem;
const category = item?.classification.category;
const command = simulation.machineState.startsWith('ROUTE_TO_')
@@ -27,6 +48,15 @@ export default function ProductDemoSection({
? `ROUTE_TO_${item.classification.category}`
: '—';
const fallbackReason = useMemo(() => {
if (!webgl || contextLost) return 'webgl' as const;
if (width < 640) return 'mobile' as const;
return 'user' as const;
}, [webgl, contextLost, width]);
const show3D = viewMode === '3d' && webgl && !contextLost;
const simplified = width < 900;
return (
<section className="product-demo-section" id="demo" aria-labelledby="demo-title">
<div className="demo-header">
@@ -34,7 +64,8 @@ export default function ProductDemoSection({
<p className="section-eyebrow">Live product demo</p>
<h2 id="demo-title">Главное демо</h2>
<p className="demo-lead">
Слева — упрощённая сцена конвейера. Справа — товар, категория, причина решения и команда маршрутизации.
3D digital twin показывает физическую маршрутизацию: A → конвейер → CV → накопитель → gate → B/C/D.
Справа — товар, категория, причина и команда ROUTE_TO_*.
</p>
</div>
<div className="demo-status">
@@ -45,13 +76,47 @@ export default function ProductDemoSection({
</div>
</div>
<div className="view-mode-toggle" role="group" aria-label="Режим сцены">
<button
type="button"
className={show3D ? 'btn-primary' : 'btn-secondary'}
onClick={() => {
setContextLost(false);
setViewMode('3d');
}}
disabled={!webgl}
>
3D Digital Twin
</button>
<button
type="button"
className={!show3D ? 'btn-primary' : 'btn-secondary'}
onClick={() => setViewMode('2d')}
>
2D fallback
</button>
</div>
<div className="demo-layout">
<div className="demo-scene-column">
<p className="demo-mobile-hint">
Товар проходит камеру, классификатор и gate, затем уходит в зону B, C или D.
Товар проходит камеру, накопитель и gate, затем уходит в зону B, C или D.
</p>
<div className="demo-scene-container">
<SorterScene simulation={simulation} variant="simple" />
<div className="demo-scene-container demo-scene-3d">
{show3D ? (
<Suspense fallback={<div className="three-loading">Загрузка 3D digital twin…</div>}>
<SorterDigitalTwin
simulation={simulation}
simplified={simplified}
onContextLost={() => {
setContextLost(true);
setViewMode('2d');
}}
/>
</Suspense>
) : (
<ThreeFallback simulation={simulation} reason={fallbackReason} />
)}
</div>
</div>

View File

@@ -0,0 +1,52 @@
import type { SimulationState } from '../../domain/types';
import { pusherOffset, TWIN_LAYOUT } from './itemMotion';
interface Props {
gate: SimulationState['gate'];
actuators: SimulationState['actuators'];
machineState: SimulationState['machineState'];
}
export default function Actuator3D({ gate, actuators, machineState }: Props) {
const gateOpen = gate.open;
const pusherC = pusherOffset(actuators.pusherC);
const pusherD = pusherOffset(actuators.pusherD);
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
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>
{/* 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 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>
</group>
);
}

View File

@@ -0,0 +1,32 @@
import { TWIN_LAYOUT } from './itemMotion';
interface Props {
stopped: boolean;
}
export default function Conveyor3D({ stopped }: Props) {
const color = stopped ? '#7f1d1d' : '#1e3a54';
const beltColor = stopped ? '#991b1b' : '#334155';
return (
<group>
{/* Feed conveyor body */}
<mesh position={[0, TWIN_LAYOUT.beltY - 0.12, 0]} receiveShadow={false} castShadow={false}>
<boxGeometry args={[8.6, 0.18, 0.55]} />
<meshStandardMaterial color={color} roughness={0.85} metalness={0.15} />
</mesh>
{/* Belt surface */}
<mesh position={[0, TWIN_LAYOUT.beltY - 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[8.4, 0.48]} />
<meshStandardMaterial color={beltColor} roughness={0.9} />
</mesh>
{/* Accumulator / buffer at end of feed conveyor */}
<mesh position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY - 0.05, 0]}>
<boxGeometry args={[0.9, 0.28, 0.7]} />
<meshStandardMaterial color={stopped ? '#7f1d1d' : '#0f766e'} roughness={0.7} />
</mesh>
</group>
);
}

View File

@@ -0,0 +1,61 @@
import type { SimulatedItem, SimulationState } from '../../domain/types';
import { itemPosition3D, ROUTE_COLORS } from './itemMotion';
interface Props {
simulation: SimulationState;
currentItem?: SimulatedItem;
}
export default function Item3D({ simulation, currentItem }: Props) {
if (!currentItem) {
return null;
}
const [x, y, z] = itemPosition3D(simulation);
const category = currentItem.classification.category;
const color = ROUTE_COLORS[category] ?? '#38bdf8';
const dims = currentItem.item.dimensionsMm;
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 >= 0.8;
const detecting = simulation.machineState === 'DETECTING';
const fault =
simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
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}
emissiveIntensity={0.2}
roughness={0.45}
/>
</mesh>
) : (
<mesh>
<boxGeometry args={[sx, sy, sz]} />
<meshStandardMaterial
color={fault ? '#fb3d4e' : color}
emissive={fault ? '#fb3d4e' : color}
emissiveIntensity={0.2}
roughness={0.5}
/>
</mesh>
)}
{detecting ? (
<mesh>
<boxGeometry args={[sx + 0.08, sy + 0.08, sz + 0.08]} />
<meshStandardMaterial color="#38bdf8" wireframe transparent opacity={0.8} />
</mesh>
) : null}
</group>
);
}

View File

@@ -0,0 +1,73 @@
import type { Category, MachineState } from '../../domain/types';
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
interface Props {
category?: Category;
machineState: MachineState;
}
function RouteBeam({
start,
end,
color,
active,
}: {
start: [number, number, number];
end: [number, number, number];
color: string;
active: boolean;
}) {
const mid: [number, number, number] = [
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2,
(start[2] + end[2]) / 2,
];
const dx = end[0] - start[0];
const dz = end[2] - start[2];
const length = Math.sqrt(dx * dx + dz * dz) || 0.1;
const angle = Math.atan2(dz, dx);
return (
<mesh position={mid} rotation={[0, -angle, 0]}>
<boxGeometry args={[length, 0.04, active ? 0.12 : 0.06]} />
<meshStandardMaterial
color={color}
transparent
opacity={active ? 0.95 : 0.25}
emissive={color}
emissiveIntensity={active ? 0.5 : 0.05}
/>
</mesh>
);
}
export default function RouteArrows3D({ category, machineState }: Props) {
const routing = machineState.startsWith('ROUTE_TO_');
const classifying = machineState === 'CLASSIFYING';
const preview = classifying || routing;
const y = TWIN_LAYOUT.beltY + 0.05;
const from: [number, number, number] = [TWIN_LAYOUT.gateX, y, 0];
return (
<group>
<RouteBeam
start={from}
end={[TWIN_LAYOUT.zoneBX, y, 0]}
color={ROUTE_COLORS.B}
active={preview && category === 'B'}
/>
<RouteBeam
start={from}
end={[TWIN_LAYOUT.gateX + 0.4, y, TWIN_LAYOUT.zoneCZ]}
color={ROUTE_COLORS.C}
active={preview && category === 'C'}
/>
<RouteBeam
start={from}
end={[TWIN_LAYOUT.gateX + 0.4, y, TWIN_LAYOUT.zoneDZ]}
color={ROUTE_COLORS.D}
active={preview && category === 'D'}
/>
</group>
);
}

View File

@@ -0,0 +1,92 @@
import { Html } from '@react-three/drei';
import type { Category, MachineState, SimulatedItem } from '../../domain/types';
import { TWIN_LAYOUT } from './itemMotion';
interface Props {
machineState: MachineState;
currentItem?: SimulatedItem;
simplified?: boolean;
}
function Badge({
position,
text,
color = '#e5f2ff',
}: {
position: [number, number, number];
text: string;
color?: string;
}) {
return (
<Html position={position} center distanceFactor={10} style={{ pointerEvents: 'none' }}>
<div
style={{
padding: '4px 8px',
borderRadius: 8,
background: 'rgba(5, 9, 16, 0.82)',
border: '1px solid rgba(56, 189, 248, 0.35)',
color,
fontSize: 11,
fontWeight: 700,
whiteSpace: 'nowrap',
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
{text}
</div>
</Html>
);
}
export default function SceneLabels3D({ machineState, currentItem, simplified }: Props) {
const category = currentItem?.classification.category as Category | undefined;
const item = currentItem?.item;
const result = currentItem?.classification;
const command = machineState.startsWith('ROUTE_TO_')
? machineState
: category
? `ROUTE_TO_${category}`
: null;
return (
<group>
<Badge position={[TWIN_LAYOUT.startX, 0.7, 0]} text="A feed" color="#7dd3fc" />
<Badge position={[TWIN_LAYOUT.cameraX, 1.15, -0.55]} text="Camera / CV" color="#38bdf8" />
{!simplified ? (
<>
<Badge position={[TWIN_LAYOUT.laserX, 1.15, -0.55]} text="Laser" color="#22d3ee" />
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.0, -0.55]} text="Ultrasonic" color="#67e8f9" />
</>
) : null}
<Badge position={[TWIN_LAYOUT.accumulatorX, 0.85, 0.55]} text="Накопитель" color="#5eead4" />
<Badge position={[TWIN_LAYOUT.gateX, 1.0, 0]} text="Stop-gate" color="#fda4af" />
<Badge position={[TWIN_LAYOUT.zoneBX, 0.9, 0]} text="B" color="#4ade80" />
<Badge position={[TWIN_LAYOUT.gateX + 0.4, 1.0, TWIN_LAYOUT.zoneCZ]} text="C roll-cage" color="#f59e0b" />
<Badge position={[TWIN_LAYOUT.gateX + 0.4, 1.0, TWIN_LAYOUT.zoneDZ]} text="D roll-cage" color="#c084fc" />
{item && result && !simplified ? (
<Badge
position={[0, 1.6, 0]}
text={`${item.dimensionsMm.width}×${item.dimensionsMm.depth}×${item.dimensionsMm.height} · K=${item.roundness.toFixed(2)} · ${result.dimensionsPass ? 'DIM PASS' : 'DIM FAIL'} · ${category}`}
color={category === 'B' ? '#4ade80' : category === 'C' ? '#f59e0b' : '#c084fc'}
/>
) : null}
{command ? (
<Badge
position={[TWIN_LAYOUT.gateX + 1.2, 1.35, 0]}
text={command}
color={category === 'B' ? '#4ade80' : category === 'C' ? '#f59e0b' : '#c084fc'}
/>
) : null}
{machineState === 'DETECTING' ? (
<Badge position={[TWIN_LAYOUT.cameraX, 1.45, 0]} text="CV detection" color="#38bdf8" />
) : null}
{machineState === 'FAULT' || machineState === 'EMERGENCY_STOP' ? (
<Badge position={[0, 1.8, 0]} text={machineState} color="#fb3d4e" />
) : null}
</group>
);
}

View File

@@ -0,0 +1,55 @@
import type { SimulationState } from '../../domain/types';
import { TWIN_LAYOUT } from './itemMotion';
interface Props {
sensors: SimulationState['sensors'];
machineState: SimulationState['machineState'];
}
function SensorPole({
x,
active,
labelColor,
height = 0.9,
}: {
x: number;
active: boolean;
labelColor: string;
height?: number;
}) {
return (
<group position={[x, 0, -0.55]}>
<mesh position={[0, height / 2, 0]}>
<boxGeometry args={[0.08, height, 0.08]} />
<meshStandardMaterial color="#475569" />
</mesh>
<mesh position={[0, height, 0.2]}>
<boxGeometry args={[0.28, 0.16, 0.22]} />
<meshStandardMaterial
color={active ? labelColor : '#1e293b'}
emissive={active ? labelColor : '#000000'}
emissiveIntensity={active ? 0.55 : 0}
/>
</mesh>
</group>
);
}
export default function SensorRig3D({ sensors, machineState }: Props) {
const detecting = machineState === 'DETECTING';
return (
<group>
<SensorPole x={TWIN_LAYOUT.cameraX} active={sensors.camera.active || detecting} labelColor="#38bdf8" />
<SensorPole x={TWIN_LAYOUT.laserX} active={sensors.laser.active} labelColor="#22d3ee" />
<SensorPole x={TWIN_LAYOUT.ultrasonicX} active={sensors.ultrasound.active} labelColor="#67e8f9" height={0.75} />
{detecting ? (
<mesh position={[TWIN_LAYOUT.cameraX, TWIN_LAYOUT.beltY + 0.35, 0]}>
<boxGeometry args={[0.7, 0.02, 0.55]} />
<meshStandardMaterial color="#38bdf8" transparent opacity={0.35} emissive="#38bdf8" emissiveIntensity={0.4} />
</mesh>
) : null}
</group>
);
}

View File

@@ -0,0 +1,171 @@
import { Canvas, useFrame } from '@react-three/fiber';
import { Grid, OrbitControls } from '@react-three/drei';
import { Suspense, useRef, useState } from 'react';
import type { SimulationState } from '../../domain/types';
import Conveyor3D from './Conveyor3D';
import SensorRig3D from './SensorRig3D';
import Actuator3D from './Actuator3D';
import SortingZones3D from './SortingZones3D';
import Item3D from './Item3D';
import RouteArrows3D from './RouteArrows3D';
import SceneLabels3D from './SceneLabels3D';
import { PHYSICS_ENGINE_ENABLED } from './itemMotion';
export interface SorterDigitalTwinProps {
simulation: SimulationState;
simplified?: boolean;
showFps?: boolean;
onContextLost?: () => void;
}
function FpsMeter({ onFps }: { onFps: (fps: number) => void }) {
const frames = useRef<number[]>([]);
const last = useRef(performance.now());
useFrame(() => {
const now = performance.now();
const dt = now - last.current;
last.current = now;
if (dt <= 0) return;
frames.current.push(1000 / dt);
if (frames.current.length > 45) frames.current.shift();
const avg = frames.current.reduce((a, b) => a + b, 0) / frames.current.length;
onFps(Math.round(avg));
});
return null;
}
function TwinScene({
simulation,
simplified,
onFps,
}: {
simulation: SimulationState;
simplified?: boolean;
onFps: (fps: number) => void;
}) {
const stopped =
simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
const category = simulation.currentItem?.classification.category;
const activeRoute =
simulation.machineState === 'ROUTE_TO_B'
? 'B'
: simulation.machineState === 'ROUTE_TO_C'
? 'C'
: simulation.machineState === 'ROUTE_TO_D'
? 'D'
: simulation.activeRoute ?? category;
return (
<>
<color attach="background" args={['#050910']} />
<ambientLight intensity={0.55} />
<directionalLight position={[5, 8, 4]} intensity={0.9} />
<directionalLight position={[-4, 4, -2]} intensity={0.25} />
<Grid
args={[16, 16]}
cellSize={0.5}
cellThickness={0.6}
cellColor="#1e3a54"
sectionSize={2}
sectionThickness={1}
sectionColor="#284762"
fadeDistance={18}
infiniteGrid={false}
position={[0, 0.001, 0]}
/>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[14, 10]} />
<meshStandardMaterial color="#07111d" />
</mesh>
<Conveyor3D stopped={stopped} />
<SensorRig3D sensors={simulation.sensors} machineState={simulation.machineState} />
<Actuator3D
gate={simulation.gate}
actuators={simulation.actuators}
machineState={simulation.machineState}
/>
<SortingZones3D activeCategory={category} activeRoute={activeRoute} />
<RouteArrows3D category={category} machineState={simulation.machineState} />
<Item3D simulation={simulation} currentItem={simulation.currentItem} />
<SceneLabels3D
machineState={simulation.machineState}
currentItem={simulation.currentItem}
simplified={simplified}
/>
{stopped ? (
<mesh position={[0, 1.2, 0]}>
<planeGeometry args={[6, 1.2]} />
<meshStandardMaterial color="#7f1d1d" transparent opacity={0.35} />
</mesh>
) : null}
<OrbitControls
enablePan={!simplified}
enableZoom
maxPolarAngle={Math.PI / 2.1}
minDistance={4}
maxDistance={14}
target={[0.4, 0.3, 0]}
/>
<FpsMeter onFps={onFps} />
</>
);
}
export default function SorterDigitalTwin({
simulation,
simplified = false,
showFps = true,
onContextLost,
}: SorterDigitalTwinProps) {
const [fps, setFps] = useState(0);
const category = simulation.currentItem?.classification.category;
const command = simulation.machineState.startsWith('ROUTE_TO_')
? simulation.machineState
: category
? `ROUTE_TO_${category}`
: 'IDLE';
return (
<div className={`digital-twin-wrap ${simplified ? 'digital-twin-simplified' : ''}`}>
<div className="digital-twin-hud" aria-live="polite">
<span className="twin-chip">{simulation.machineState}</span>
<span className="twin-chip">{simulation.scenario.name}</span>
{category ? <span className={`twin-chip category-${category}`}>Zone {category}</span> : null}
<span className="twin-chip">{command}</span>
{showFps ? <span className="twin-chip">FPS ~{fps}</span> : null}
<span className="twin-chip muted-chip">
Motion: {PHYSICS_ENGINE_ENABLED ? 'physics' : 'state-machine'}
</span>
</div>
<div className="digital-twin-canvas">
<Canvas
camera={{ position: simplified ? [5.5, 4.2, 5.5] : [6.2, 4.8, 6.2], fov: 42 }}
dpr={simplified ? [1, 1.25] : [1, 1.75]}
gl={{ antialias: !simplified, powerPreference: 'high-performance' }}
onCreated={({ gl }) => {
const canvas = gl.domElement;
const handleLost = (event: Event) => {
event.preventDefault();
// Ignore teardown when switching to 2D / unmounting the canvas.
if (!canvas.isConnected) return;
onContextLost?.();
};
canvas.addEventListener('webglcontextlost', handleLost, false);
}}
>
<Suspense fallback={null}>
<TwinScene simulation={simulation} simplified={simplified} onFps={setFps} />
</Suspense>
</Canvas>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import type { Category } from '../../domain/types';
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
interface Props {
activeCategory?: Category;
activeRoute?: Category;
}
function ZoneBox({
position,
size,
color,
active,
}: {
position: [number, number, number];
size: [number, number, number];
color: string;
active: boolean;
}) {
return (
<mesh position={position}>
<boxGeometry args={size} />
<meshStandardMaterial
color={color}
transparent
opacity={active ? 0.45 : 0.18}
emissive={color}
emissiveIntensity={active ? 0.35 : 0.05}
/>
</mesh>
);
}
export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
const active = activeRoute ?? activeCategory;
const cage = TWIN_LAYOUT.rollCageSize;
return (
<group>
{/* Zone A feed */}
<ZoneBox
position={[TWIN_LAYOUT.startX, 0.2, 0]}
size={[0.9, 0.4, 0.9]}
color="#38bdf8"
active={!active}
/>
{/* Zone B main sorter — straight */}
<ZoneBox
position={[TWIN_LAYOUT.zoneBX, 0.25, 0]}
size={[1.1, 0.5, 1.1]}
color={ROUTE_COLORS.B}
active={active === 'B'}
/>
{/* Roll-cage C 1200×800×800 mm */}
<ZoneBox
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 */}
<ZoneBox
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneDZ]}
size={[cage.x, cage.y, cage.z]}
color={ROUTE_COLORS.D}
active={active === 'D'}
/>
</group>
);
}

View File

@@ -0,0 +1,92 @@
import { Canvas, useFrame } from '@react-three/fiber';
import { useEffect, useMemo, useRef, useState } from 'react';
import { detectWebGL, useWebGLSupport } from './useWebGL';
import { PHYSICS_ENGINE_ENABLED } from './itemMotion';
function SpikeScene({ onFps }: { onFps: (fps: number) => void }) {
const frames = useRef<number[]>([]);
const last = useRef(performance.now());
useFrame(() => {
const now = performance.now();
const dt = now - last.current;
last.current = now;
if (dt <= 0) return;
const fps = 1000 / dt;
frames.current.push(fps);
if (frames.current.length > 60) {
frames.current.shift();
}
const avg = frames.current.reduce((a, b) => a + b, 0) / frames.current.length;
onFps(Math.round(avg));
});
const boxes = useMemo(
() =>
Array.from({ length: 16 }, (_, index) => ({
key: index,
position: [((index % 4) - 1.5) * 0.45, 0.2 + Math.floor(index / 4) * 0.35, -0.4] as [
number,
number,
number,
],
})),
[],
);
return (
<>
<ambientLight intensity={0.55} />
<directionalLight position={[4, 6, 2]} intensity={0.85} />
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[6, 4]} />
<meshStandardMaterial color="#0b1725" />
</mesh>
<mesh position={[0, 0.15, 0]}>
<boxGeometry args={[3.5, 0.2, 0.5]} />
<meshStandardMaterial color="#1e3a54" />
</mesh>
{boxes.map((box) => (
<mesh key={box.key} position={box.position}>
<boxGeometry args={[0.28, 0.28, 0.28]} />
<meshStandardMaterial color={box.key % 3 === 0 ? '#4ade80' : box.key % 3 === 1 ? '#f59e0b' : '#c084fc'} />
</mesh>
))}
</>
);
}
/**
* Diagnostic utility: WebGL probe + lightweight scene FPS.
* Physics engine is intentionally not loaded (see PHYSICS_ENGINE_ENABLED).
*/
export default function ThreeCapabilityCheck() {
const webgl = useWebGLSupport();
const [fps, setFps] = useState(0);
const [ready, setReady] = useState(false);
useEffect(() => {
setReady(true);
}, []);
return (
<div className="three-capability-check">
<div className="three-capability-meta">
<strong>3D capability check</strong>
<span>WebGL: {webgl ? 'OK' : 'unavailable'}</span>
<span>Physics engine: {PHYSICS_ENGINE_ENABLED ? 'enabled' : 'disabled (state-machine motion)'}</span>
<span>Avg FPS: {webgl && ready ? fps : '—'}</span>
<span>Runtime detect: {detectWebGL() ? 'yes' : 'no'}</span>
</div>
{webgl && ready ? (
<div className="three-capability-canvas">
<Canvas camera={{ position: [2.5, 2.2, 3.2], fov: 45 }} dpr={[1, 1.5]}>
<SpikeScene onFps={setFps} />
</Canvas>
</div>
) : (
<p className="three-fallback-note">WebGL недоступен — 3D digital twin будет в 2D fallback.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import SorterScene from '../SorterScene';
import type { SimulationState } from '../../domain/types';
interface Props {
simulation: SimulationState;
reason?: 'webgl' | 'user' | 'mobile';
}
const MESSAGES: Record<NonNullable<Props['reason']>, string> = {
webgl: '3D недоступен (WebGL), включён 2D fallback. Логика симуляции та же.',
user: 'Включён 2D fallback. Логика симуляции та же.',
mobile: 'На узком экране включён 2D fallback. Логика симуляции та же.',
};
export default function ThreeFallback({ simulation, reason = 'user' }: Props) {
return (
<div className="three-fallback">
<p className="three-fallback-note" role="status">
{MESSAGES[reason]}
</p>
<SorterScene simulation={simulation} variant="simple" />
</div>
);
}

View File

@@ -0,0 +1,72 @@
import type { Category, MachineState, SimulationState } from '../../domain/types';
/** Compact digital-twin coordinates (1 unit ≈ 1 m, scaled layout). */
export const TWIN_LAYOUT = {
beltY: 0.35,
startX: -4.2,
endX: 4.2,
cameraX: -1.2,
laserX: -0.2,
ultrasonicX: 1.0,
gateX: 1.6,
accumulatorX: 1.4,
zoneBX: 3.8,
zoneCZ: 2.4,
zoneDZ: -2.4,
rollCageSize: { x: 1.2, y: 0.8, z: 0.8 },
} as const;
export const ROUTE_COLORS: Record<Category, string> = {
B: '#4ade80',
C: '#f59e0b',
D: '#c084fc',
};
export function progressForState(state: MachineState, elapsedMs: number): number {
const ratios: Partial<Record<MachineState, [number, number, number]>> = {
MOVING_TO_CAMERA: [0.06, 0.34, 1200],
DETECTING: [0.34, 0.36, 900],
MOVING_TO_GATE: [0.36, 0.64, 1300],
WAITING_AT_GATE: [0.64, 0.65, 800],
CLASSIFYING: [0.65, 0.66, 700],
ROUTE_TO_B: [0.66, 0.93, 1200],
ROUTE_TO_C: [0.66, 0.82, 1200],
ROUTE_TO_D: [0.66, 0.82, 1200],
RETURN_HOME: [0.93, 0.94, 700],
};
const segment = ratios[state];
if (!segment) {
return state === 'IDLE' ? 0.04 : 0.65;
}
const [from, to, duration] = segment;
return from + (to - from) * Math.min(elapsedMs / duration, 1);
}
export function itemPosition3D(simulation: SimulationState): [number, number, number] {
const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs);
const x = TWIN_LAYOUT.startX + progress * (TWIN_LAYOUT.endX - TWIN_LAYOUT.startX);
const y = TWIN_LAYOUT.beltY + 0.12;
let z = 0;
if (simulation.machineState === 'ROUTE_TO_C') {
z = Math.min(simulation.elapsedInStateMs / 1200, 1) * TWIN_LAYOUT.zoneCZ;
} else if (simulation.machineState === 'ROUTE_TO_D') {
z = -Math.min(simulation.elapsedInStateMs / 1200, 1) * Math.abs(TWIN_LAYOUT.zoneDZ);
}
return [x, y, z];
}
export function pusherOffset(state: 'idle' | 'extended' | 'retracting'): number {
if (state === 'extended') return 0.55;
if (state === 'retracting') return 0.25;
return 0;
}
/**
* Motion layer is state-machine driven (no physics engine).
* Future physics integration can replace this module with kinematic bodies.
*/
export const PHYSICS_ENGINE_ENABLED = false;

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
export function detectWebGL(): boolean {
try {
const canvas = document.createElement('canvas');
return Boolean(
canvas.getContext('webgl2') ||
canvas.getContext('webgl') ||
canvas.getContext('experimental-webgl'),
);
} catch {
return false;
}
}
export function useWebGLSupport(): boolean {
const [supported, setSupported] = useState(true);
useEffect(() => {
setSupported(detectWebGL());
}, []);
return supported;
}
export function prefer3DByDefault(width: number, webgl: boolean): boolean {
return webgl && width >= 640;
}

View File

@@ -16,7 +16,7 @@ export const CATEGORY_DEFINITIONS: Record<Category, CategoryDefinition> = {
};
export const DIMENSION_LIMITS = {
min: { width: 10, depth: 10, height: 2 },
min: { width: 10, depth: 10, height: 10 },
max: { width: 450, depth: 320, height: 320 },
roundnessThreshold: 0.8,
};

View File

@@ -12,7 +12,7 @@ export function createMetrics(): Metrics {
queueLength: 0,
queueDelayMs: 0,
conveyorSpeedMps: 0,
pidTargetSpeedMps: 0.42,
pidTargetSpeedMps: 1,
pidActualSpeedMps: 0,
};
}

View File

@@ -3,21 +3,21 @@ import { createPidState, updatePid } from './pid';
describe('PID conveyor speed imitation', () => {
it('moves actual speed toward target in normal mode', () => {
let pid = createPidState(0.42);
let pid = createPidState(1);
for (let index = 0; index < 20; index += 1) {
pid = updatePid(pid, 0.42, 250);
pid = updatePid(pid, 1, 250);
}
expect(pid.actualSpeedMps).toBeGreaterThan(0.3);
expect(Math.abs(pid.pidError)).toBeLessThan(0.15);
expect(pid.actualSpeedMps).toBeGreaterThan(0.7);
expect(Math.abs(pid.pidError)).toBeLessThan(0.35);
});
it('decays actual speed toward zero when target is stopped', () => {
let pid = createPidState(0.42);
let pid = createPidState(1);
for (let index = 0; index < 20; index += 1) {
pid = updatePid(pid, 0.42, 250);
pid = updatePid(pid, 1, 250);
}
const runningSpeed = pid.actualSpeedMps;
@@ -26,6 +26,6 @@ describe('PID conveyor speed imitation', () => {
}
expect(pid.actualSpeedMps).toBeLessThan(runningSpeed);
expect(pid.actualSpeedMps).toBeLessThan(0.05);
expect(pid.actualSpeedMps).toBeLessThan(0.12);
});
});

View File

@@ -2,7 +2,7 @@ import type { PidState } from './types';
const PID_HISTORY_LIMIT = 40;
export function createPidState(targetSpeedMps = 0.42): PidState {
export function createPidState(targetSpeedMps = 1): PidState {
return {
targetSpeedMps,
actualSpeedMps: 0,

View File

@@ -223,7 +223,7 @@ function targetSpeedFor(state: SimulationState): number {
if (!state.running) {
return 0;
}
return state.scenario.id === 'close_items' ? 0.32 : 0.42;
return state.scenario.id === 'close_items' ? 0.75 : 1;
}
function deriveSensorsAndActuators(state: SimulationState): SimulationState {

View File

@@ -488,6 +488,108 @@ button:disabled {
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}
.demo-scene-3d {
min-height: 420px;
}
.view-mode-toggle {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.digital-twin-wrap {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
min-width: 0;
}
.digital-twin-canvas {
width: 100%;
min-width: 0;
height: min(52vh, 520px);
min-height: 360px;
border-radius: 14px;
overflow: hidden;
background: #050910;
border: 1px solid #1e3a54;
}
.digital-twin-simplified .digital-twin-canvas {
height: min(46vh, 420px);
min-height: 300px;
}
.digital-twin-canvas canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.digital-twin-hud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.twin-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border: 1px solid #284762;
border-radius: 999px;
background: #091522;
color: #c8d7e8;
font-size: 12px;
font-weight: 700;
}
.muted-chip {
color: var(--muted);
}
.three-fallback-note,
.three-loading {
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 12px;
background: rgba(56, 189, 248, 0.08);
color: #bfe9ff;
font-size: 14px;
}
.three-capability-check {
display: grid;
gap: 12px;
}
.three-capability-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 13px;
color: var(--muted);
}
.three-capability-meta strong {
color: var(--text);
width: 100%;
}
.three-capability-canvas {
width: 100%;
height: 220px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--line);
background: #050910;
}
.demo-result-column {
display: flex;
flex-direction: column;