feat: add 3D digital twin visualization
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
52
src/components/ThreeD/Actuator3D.tsx
Normal file
52
src/components/ThreeD/Actuator3D.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/ThreeD/Conveyor3D.tsx
Normal file
32
src/components/ThreeD/Conveyor3D.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/ThreeD/Item3D.tsx
Normal file
61
src/components/ThreeD/Item3D.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/ThreeD/RouteArrows3D.tsx
Normal file
73
src/components/ThreeD/RouteArrows3D.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/ThreeD/SceneLabels3D.tsx
Normal file
92
src/components/ThreeD/SceneLabels3D.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/ThreeD/SensorRig3D.tsx
Normal file
55
src/components/ThreeD/SensorRig3D.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/ThreeD/SorterDigitalTwin.tsx
Normal file
171
src/components/ThreeD/SorterDigitalTwin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/ThreeD/SortingZones3D.tsx
Normal file
73
src/components/ThreeD/SortingZones3D.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/ThreeD/ThreeCapabilityCheck.tsx
Normal file
92
src/components/ThreeD/ThreeCapabilityCheck.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/ThreeD/ThreeFallback.tsx
Normal file
24
src/components/ThreeD/ThreeFallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/ThreeD/itemMotion.ts
Normal file
72
src/components/ThreeD/itemMotion.ts
Normal 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;
|
||||
28
src/components/ThreeD/useWebGL.ts
Normal file
28
src/components/ThreeD/useWebGL.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export function createMetrics(): Metrics {
|
||||
queueLength: 0,
|
||||
queueDelayMs: 0,
|
||||
conveyorSpeedMps: 0,
|
||||
pidTargetSpeedMps: 0.42,
|
||||
pidTargetSpeedMps: 1,
|
||||
pidActualSpeedMps: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
102
src/styles.css
102
src/styles.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user