feat: align 3D digital twin with OZON Track 3 requirements

This commit is contained in:
root
2026-07-04 12:39:57 +02:00
parent b749cef14e
commit e0a655db4f
28 changed files with 28886 additions and 92 deletions

View File

@@ -37,10 +37,10 @@ const PRODUCT_CRITERIA: CriteriaCard[] = [
{
id: 'rule-priority',
title: 'Приоритет правил',
evidence: 'Сначала габариты (C), затем форма (D), иначе основной поток B.',
evidence: 'Сначала габариты (C), затем форма (D). Негабарит + круглый → только C.',
status: 'covered',
linkedScenario: 'boundary_dimensions',
linkedLabel: 'Пограничные размеры',
linkedScenario: 'c_priority',
linkedLabel: 'C-priority',
},
{
id: 'routing',

View File

@@ -1,3 +1,5 @@
import { DIMENSION_LIMITS } from '../domain/classifier';
import { NOMINAL_CONVEYOR_SPEED_MPS } from '../domain/simulation';
import type { SimulationState } from '../domain/types';
export default function CurrentProofCard({ simulation }: { simulation: SimulationState }) {
@@ -12,6 +14,7 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
: result
? `ROUTE_TO_${result.category}`
: 'WAITING';
const isCPriority = Boolean(result && !result.dimensionsPass && !result.roundnessPass);
if (!current) {
return (
@@ -20,6 +23,11 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
<p className="eyebrow">Ожидание цикла</p>
<h3>Готово к демонстрации</h3>
<p>Нажмите Start demo — товар пройдёт Detection → Classification → Command → Routing.</p>
<p className="proof-limits">
Limits: min {DIMENSION_LIMITS.min.width}×{DIMENSION_LIMITS.min.depth}×{DIMENSION_LIMITS.min.height} mm ·
max {DIMENSION_LIMITS.max.width}×{DIMENSION_LIMITS.max.depth}×{DIMENSION_LIMITS.max.height} mm ·
conveyor {NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)} m/s
</p>
</div>
</div>
);
@@ -34,38 +42,57 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
<div className="proof-grid">
<div className="proof-item">
<span className="proof-label">Категория</span>
<span className="proof-label">Dimensions</span>
<strong className={result?.dimensionsPass === false ? 'fail-text' : 'pass-text'}>
{result?.dimensionsPass ? 'PASS' : 'FAIL'}
</strong>
<em>
{dimensions?.width}×{dimensions?.depth}×{dimensions?.height} мм
</em>
</div>
<div className="proof-item">
<span className="proof-label">Roundness K</span>
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : 'pass-text'}>
{item?.roundness.toFixed(2)}
</strong>
<em>{result?.roundnessPass ? 'PASS' : 'DETECTED (≥0.8)'}</em>
</div>
<div className="proof-item">
<span className="proof-label">Category</span>
<strong className={`category-${result?.category ?? 'default'}`}>{result?.category ?? '—'}</strong>
</div>
<div className="proof-item">
<span className="proof-label">Зона</span>
<strong>{result?.category ? `Зона ${result.category}` : '—'}</strong>
<span className="proof-label">Target zone</span>
<strong>{result?.category ? `Zone ${result.category}` : '—'}</strong>
</div>
<div className="proof-item proof-item-wide">
<span className="proof-label">Команда</span>
<span className="proof-label">Command</span>
<strong>{command}</strong>
</div>
</div>
<div className="proof-details">
<div className="detail-row">
<span>Габариты</span>
<strong className={result?.dimensionsPass === false ? 'fail-text' : ''}>
{dimensions?.width} × {dimensions?.depth} × {dimensions?.height} мм
<span>Min limits</span>
<strong>
{DIMENSION_LIMITS.min.width}×{DIMENSION_LIMITS.min.depth}×{DIMENSION_LIMITS.min.height} мм
</strong>
</div>
<div className="detail-row">
<span>Roundness</span>
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : ''}>
{item?.roundness.toFixed(2)}
</strong>
<span>Conveyor target</span>
<strong>{NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)} m/s</strong>
</div>
</div>
<div className="proof-reason">
<strong>Причина решения</strong>
<p>{result?.reason}</p>
{lowConfidence ? <p className="warning-note">Низкая уверенность CV — rule-based fallback</p> : null}
{isCPriority ? (
<p className="warning-note">C-priority: негабарит + круглый → только C (габариты важнее формы)</p>
) : null}
{lowConfidence ? (
<p className="warning-note">Низкая уверенность CV — rule-based fallback, класс только B/C/D</p>
) : null}
{simulation.machineState === 'FAULT' ? <p className="error-note">FAULT: конвейер остановлен</p> : null}
{simulation.machineState === 'EMERGENCY_STOP' ? (
<p className="error-note">EMERGENCY_STOP: аварийная остановка</p>

View File

@@ -129,16 +129,26 @@ export default function ProductDemoSection({
<strong>{item?.item.name ?? 'Ожидание'}</strong>
</div>
<div className="summary-item">
<span>Категория</span>
<span>Dimensions</span>
<strong className={item?.classification.dimensionsPass === false ? 'fail-text' : 'pass-text'}>
{item ? (item.classification.dimensionsPass ? 'PASS' : 'FAIL') : '—'}
</strong>
</div>
<div className="summary-item">
<span>Roundness K</span>
<strong>{item ? item.item.roundness.toFixed(2) : '—'}</strong>
</div>
<div className="summary-item">
<span>Category</span>
<strong className={category ? `category-${category}` : ''}>{category ?? '—'}</strong>
</div>
<div className="summary-item">
<span>Команда</span>
<span>Command</span>
<strong>{command}</strong>
</div>
<div className="summary-item">
<span>Зона</span>
<strong>{category ? `Зона ${category}` : '—'}</strong>
<span>Target zone</span>
<strong>{category ? `Zone ${category}` : '—'}</strong>
</div>
</div>

View File

@@ -28,6 +28,12 @@ const SCENARIO_UI: Record<
badge: 'Зона D',
badgeTone: 'd',
},
c_priority: {
title: 'C-priority',
summary: 'Негабарит + круглый: габариты важнее формы, маршрут только в C.',
badge: 'C priority',
badgeTone: 'c',
},
boundary_dimensions: {
title: 'Пограничные размеры',
summary: 'Проверка строгих min/max границ габаритов.',

View File

@@ -7,6 +7,7 @@ interface Props {
export default function Conveyor3D({ stopped }: Props) {
const color = stopped ? '#7f1d1d' : '#1e3a54';
const beltColor = stopped ? '#991b1b' : '#334155';
const accColor = stopped ? '#7f1d1d' : '#0f766e';
return (
<group>
@@ -22,11 +23,26 @@ export default function Conveyor3D({ stopped }: Props) {
<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>
{/* Accumulator / buffer at end of feed conveyor — explicit raised pocket */}
<group position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY, 0]}>
<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 */}
<mesh position={[0, 0.12, 0.38]}>
<boxGeometry args={[1.05, 0.28, 0.06]} />
<meshStandardMaterial color="#14b8a6" />
</mesh>
<mesh position={[0, 0.12, -0.38]}>
<boxGeometry args={[1.05, 0.28, 0.06]} />
<meshStandardMaterial color="#14b8a6" />
</mesh>
<mesh position={[-0.48, 0.12, 0]}>
<boxGeometry args={[0.06, 0.28, 0.82]} />
<meshStandardMaterial color="#14b8a6" />
</mesh>
</group>
</group>
);
}

View File

@@ -29,13 +29,13 @@ function RouteBeam({
return (
<mesh position={mid} rotation={[0, -angle, 0]}>
<boxGeometry args={[length, 0.04, active ? 0.12 : 0.06]} />
<boxGeometry args={[length, active ? 0.06 : 0.04, active ? 0.14 : 0.08]} />
<meshStandardMaterial
color={color}
transparent
opacity={active ? 0.95 : 0.25}
opacity={active ? 1 : 0.45}
emissive={color}
emissiveIntensity={active ? 0.5 : 0.05}
emissiveIntensity={active ? 0.55 : 0.18}
/>
</mesh>
);
@@ -44,12 +44,13 @@ function RouteBeam({
export default function RouteArrows3D({ category, machineState }: Props) {
const routing = machineState.startsWith('ROUTE_TO_');
const classifying = machineState === 'CLASSIFYING';
const preview = classifying || routing;
const preview = classifying || routing || Boolean(category);
const y = TWIN_LAYOUT.beltY + 0.05;
const from: [number, number, number] = [TWIN_LAYOUT.gateX, y, 0];
return (
<group>
{/* Always draw B/C/D routes in distinct colors; active route is brighter */}
<RouteBeam
start={from}
end={[TWIN_LAYOUT.zoneBX, y, 0]}

View File

@@ -1,35 +1,40 @@
import { Html } from '@react-three/drei';
import type { Category, MachineState, SimulatedItem } from '../../domain/types';
import { TWIN_LAYOUT } from './itemMotion';
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
interface Props {
machineState: MachineState;
currentItem?: SimulatedItem;
simplified?: boolean;
conveyorTargetMps?: number;
}
function Badge({
position,
text,
color = '#e5f2ff',
large = false,
}: {
position: [number, number, number];
text: string;
color?: string;
large?: boolean;
}) {
return (
<Html position={position} center distanceFactor={10} style={{ pointerEvents: 'none' }}>
<Html position={position} center distanceFactor={large ? 8 : 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)',
padding: large ? '8px 12px' : '4px 8px',
borderRadius: large ? 12 : 8,
background: 'rgba(5, 9, 16, 0.9)',
border: `1px solid ${color}`,
color,
fontSize: 11,
fontWeight: 700,
fontSize: large ? 15 : 12,
fontWeight: 800,
whiteSpace: 'nowrap',
fontFamily: 'Inter, system-ui, sans-serif',
letterSpacing: large ? '0.04em' : undefined,
boxShadow: '0 8px 20px rgba(0,0,0,0.35)',
}}
>
{text}
@@ -38,54 +43,144 @@ function Badge({
);
}
export default function SceneLabels3D({ machineState, currentItem, simplified }: Props) {
const category = currentItem?.classification.category as Category | undefined;
const item = currentItem?.item;
const result = currentItem?.classification;
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
: category
? `ROUTE_TO_${category}`
: null;
: `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>
);
}
export default function SceneLabels3D({
machineState,
currentItem,
conveyorTargetMps = 1,
}: Props) {
const category = currentItem?.classification.category as Category | undefined;
const cage = TWIN_LAYOUT.rollCageSize;
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" />
{/* Zones A/B/C/D — always explicit */}
<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
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.35, TWIN_LAYOUT.zoneCZ]}
text="C"
color={ROUTE_COLORS.C}
large
/>
<Badge
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.35, TWIN_LAYOUT.zoneDZ]}
text="D"
color={ROUTE_COLORS.D}
large
/>
{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}
{/* Accumulator at end of feed conveyor */}
<Badge
position={[TWIN_LAYOUT.accumulatorX, 0.95, 0.65]}
text="Накопитель"
color="#5eead4"
large
/>
{command ? (
{/* 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} />
{/* 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}
/>
<Badge
position={[-3.2, 1.55, 1.4]}
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min 10×10×10 mm`}
color="#91a4b8"
/>
{currentItem ? <ItemProofPanel currentItem={currentItem} machineState={machineState} /> : null}
{category === 'C' && currentItem && !currentItem.classification.roundnessPass ? (
<Badge
position={[TWIN_LAYOUT.gateX + 1.2, 1.35, 0]}
text={command}
color={category === 'B' ? '#4ade80' : category === 'C' ? '#f59e0b' : '#c084fc'}
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.7, TWIN_LAYOUT.zoneCZ]}
text="C-priority (dims first)"
color={ROUTE_COLORS.C}
large
/>
) : null}
{machineState === 'DETECTING' ? (
<Badge position={[TWIN_LAYOUT.cameraX, 1.45, 0]} text="CV detection" color="#38bdf8" />
<Badge position={[TWIN_LAYOUT.cameraX, 1.55, 0]} text="CV detection" color="#38bdf8" />
) : null}
{machineState === 'FAULT' || machineState === 'EMERGENCY_STOP' ? (
<Badge position={[0, 1.8, 0]} text={machineState} color="#fb3d4e" />
<Badge position={[0, 2.5, 0]} text={machineState} color="#fb3d4e" large />
) : null}
</group>
);

View File

@@ -10,6 +10,7 @@ import Item3D from './Item3D';
import RouteArrows3D from './RouteArrows3D';
import SceneLabels3D from './SceneLabels3D';
import { PHYSICS_ENGINE_ENABLED } from './itemMotion';
import { NOMINAL_CONVEYOR_SPEED_MPS } from '../../domain/simulation';
export interface SorterDigitalTwinProps {
simulation: SimulationState;
@@ -96,6 +97,7 @@ function TwinScene({
machineState={simulation.machineState}
currentItem={simulation.currentItem}
simplified={simplified}
conveyorTargetMps={NOMINAL_CONVEYOR_SPEED_MPS}
/>
{stopped ? (
@@ -139,6 +141,8 @@ export default function SorterDigitalTwin({
<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>
<span className="twin-chip">Conveyor {NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)} m/s</span>
<span className="twin-chip">Min 10×10×10 mm</span>
{showFps ? <span className="twin-chip">FPS ~{fps}</span> : null}
<span className="twin-chip muted-chip">
Motion: {PHYSICS_ENGINE_ENABLED ? 'physics' : 'state-machine'}

View File

@@ -23,9 +23,9 @@ function ZoneBox({
<meshStandardMaterial
color={color}
transparent
opacity={active ? 0.45 : 0.18}
opacity={active ? 0.5 : 0.22}
emissive={color}
emissiveIntensity={active ? 0.35 : 0.05}
emissiveIntensity={active ? 0.4 : 0.12}
/>
</mesh>
);
@@ -37,23 +37,23 @@ export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
return (
<group>
{/* Zone A feed */}
{/* Zone A feed — always visible cyan */}
<ZoneBox
position={[TWIN_LAYOUT.startX, 0.2, 0]}
size={[0.9, 0.4, 0.9]}
position={[TWIN_LAYOUT.startX, 0.22, 0]}
size={[1.0, 0.44, 1.0]}
color="#38bdf8"
active={!active}
active={active === undefined}
/>
{/* Zone B main sorter — straight */}
{/* Zone B main sorter — green, straight */}
<ZoneBox
position={[TWIN_LAYOUT.zoneBX, 0.25, 0]}
size={[1.1, 0.5, 1.1]}
position={[TWIN_LAYOUT.zoneBX, 0.28, 0]}
size={[1.2, 0.56, 1.2]}
color={ROUTE_COLORS.B}
active={active === 'B'}
/>
{/* Roll-cage C 1200×800×800 mm */}
{/* Roll-cage C 1200×800×800 mm — orange */}
<ZoneBox
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneCZ]}
size={[cage.x, cage.y, cage.z]}
@@ -61,7 +61,7 @@ export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
active={active === 'C'}
/>
{/* Roll-cage D 1200×800×800 mm */}
{/* Roll-cage D 1200×800×800 mm — purple */}
<ZoneBox
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneDZ]}
size={[cage.x, cage.y, cage.z]}