feat: align 3D digital twin with OZON Track 3 requirements
This commit is contained in:
@@ -130,6 +130,7 @@ curl -I https://www.arhipovdan.ru/
|
||||
- `normal_flow` — обычный поток B/C/D.
|
||||
- `oversized_item` — max dimensions нарушены, маршрут C.
|
||||
- `round_object` — габариты проходят, roundness >= 0.8, маршрут D.
|
||||
- `c_priority` — негабарит + круглый → только C (приоритет габаритов).
|
||||
- `boundary_dimensions` — проверка min/max границ.
|
||||
- `close_items` — предупреждение spacing/queue, последовательная обработка.
|
||||
- `low_confidence` — низкий CV confidence, rule-based fallback.
|
||||
|
||||
@@ -26,7 +26,7 @@ Before final submission:
|
||||
- [ ] App is responsive on Desktop (1920×1080), Laptop (1440×900) and Mobile (390×844).
|
||||
- [ ] NO horizontal scroll on body / `#root` / product page.
|
||||
- [ ] Mobile: one column, buttons ≥ 44px, scene scales.
|
||||
- [ ] Scenarios checked: normal_flow, oversized_item, round_object, boundary_dimensions, close_items, low_confidence, jam, emergency_stop.
|
||||
- [ ] Scenarios checked: normal_flow, oversized_item, round_object, c_priority, boundary_dimensions, close_items, low_confidence, jam, emergency_stop.
|
||||
- [ ] Docs present: README, ARCHITECTURE, DEMO_SCRIPT, SCENARIOS, JURY_QA, UI_UX_REDESIGN_AUDIT, THREE_D_FEASIBILITY, SUBMISSION_CHECKLIST.
|
||||
- [ ] Cursor rules present in `.cursor/rules/` (including 3D / physics / WebGL rules).
|
||||
- [ ] Branch ready for manual push to `origin/dan_branch`.
|
||||
|
||||
BIN
input_info/doc-1782987706.zip
Normal file
BIN
input_info/doc-1782987706.zip
Normal file
Binary file not shown.
BIN
input_info/doc-1782987733.zip
Normal file
BIN
input_info/doc-1782987733.zip
Normal file
Binary file not shown.
BIN
input_info/doc-1783009063.pdf
Normal file
BIN
input_info/doc-1783009063.pdf
Normal file
Binary file not shown.
BIN
input_info/doc-1783009942.pdf
Normal file
BIN
input_info/doc-1783009942.pdf
Normal file
Binary file not shown.
28546
input_info/doc-1783011400.pdf
Normal file
28546
input_info/doc-1783011400.pdf
Normal file
File diff suppressed because one or more lines are too long
BIN
input_info/doc-1783011771.zip
Normal file
BIN
input_info/doc-1783011771.zip
Normal file
Binary file not shown.
BIN
input_info/ozone-tech_owl_prime_170-main.zip
Normal file
BIN
input_info/ozone-tech_owl_prime_170-main.zip
Normal file
Binary file not shown.
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 границ габаритов.',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const OZON_CRITERIA: OzonCriterion[] = [
|
||||
title: 'Classification rules: dimensions, roundness, priority',
|
||||
status: 'covered',
|
||||
evidence: 'ClassificationPanel shows thresholds, actual values, PASS/FAIL and C priority.',
|
||||
relatedScenarioIds: ['oversized_item', 'round_object', 'boundary_dimensions'],
|
||||
relatedScenarioIds: ['oversized_item', 'round_object', 'c_priority', 'boundary_dimensions'],
|
||||
relatedDoc: 'docs/ARCHITECTURE.md',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,10 +49,23 @@ export const DEMO_STEPS: DemoStep[] = [
|
||||
relatedCriteria: ['classification-rules', 'geometry-variety', 'physical-routing'],
|
||||
presenterPhrase: 'The dimensions fit, but roundness is high, so the system sends it to D for do-upakovka.',
|
||||
},
|
||||
{
|
||||
id: 'c-priority-oversized-round',
|
||||
title: 'C priority oversized + round',
|
||||
scenarioId: 'c_priority',
|
||||
preferredAction: 'step',
|
||||
focusArea: 'classification',
|
||||
explanation: 'Item is both oversized and round; dimensions are checked first so category is C, not D.',
|
||||
whatToWatch: 'DIM FAIL, K high, category C, ROUTE_TO_C active, route D inactive.',
|
||||
juryValue: 'Proves C-priority rule for Track 3.',
|
||||
relatedCriteria: ['classification-rules', 'boundary-cases', 'physical-routing'],
|
||||
presenterPhrase: 'Even though the item is round, dimensions fail first, so we route only to C.',
|
||||
},
|
||||
{
|
||||
id: 'boundary-dimensions',
|
||||
title: 'Boundary dimensions',
|
||||
scenarioId: 'boundary_dimensions',
|
||||
|
||||
preferredAction: 'step',
|
||||
focusArea: 'classification',
|
||||
explanation: 'Items at the edge of the allowed envelope prove the rule implementation is stable.',
|
||||
|
||||
@@ -91,6 +91,15 @@ export const ITEMS: Item[] = [
|
||||
shape: 'boundary box',
|
||||
expectedCategory: 'B',
|
||||
},
|
||||
{
|
||||
id: 'SKU-011',
|
||||
name: 'Oversized round 500x300x300',
|
||||
dimensionsMm: { width: 500, depth: 300, height: 300 },
|
||||
roundness: 0.93,
|
||||
confidence: 0.88,
|
||||
shape: 'oversized round cylinder',
|
||||
expectedCategory: 'C',
|
||||
},
|
||||
];
|
||||
|
||||
export function getItem(id: string): Item {
|
||||
|
||||
@@ -6,6 +6,7 @@ const expectedIds: ScenarioId[] = [
|
||||
'normal_flow',
|
||||
'oversized_item',
|
||||
'round_object',
|
||||
'c_priority',
|
||||
'boundary_dimensions',
|
||||
'close_items',
|
||||
'low_confidence',
|
||||
|
||||
@@ -29,6 +29,15 @@ export const SCENARIOS: Scenario[] = [
|
||||
demonstrates: 'Rule-based shape issue routing без реального ML на MVP-этапе.',
|
||||
items: ['SKU-006', 'SKU-007', 'SKU-008'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'c_priority',
|
||||
name: 'C priority (oversized + round)',
|
||||
description: 'Товар одновременно негабаритный и круглый — приоритет габаритов, маршрут только в C.',
|
||||
goal: 'Доказать, что dimensions check идёт раньше roundness: категория C, не D.',
|
||||
expectedCategorySummary: 'C only (D not activated)',
|
||||
demonstrates: 'C-priority: oversized + round → ROUTE_TO_C, route D остаётся неактивным.',
|
||||
items: ['SKU-011'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'boundary_dimensions',
|
||||
name: 'Boundary dimensions',
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyItem } from './classifier';
|
||||
import { classifyItem, DIMENSION_LIMITS } from './classifier';
|
||||
import { getItem } from '../data/items';
|
||||
import type { Item } from './types';
|
||||
import type { Category, Item } from './types';
|
||||
|
||||
const VALID_CATEGORIES: Category[] = ['B', 'C', 'D'];
|
||||
|
||||
describe('classifyItem', () => {
|
||||
it('uses min dimensions 10×10×10 mm', () => {
|
||||
expect(DIMENSION_LIMITS.min).toEqual({ width: 10, depth: 10, height: 10 });
|
||||
expect(DIMENSION_LIMITS.max).toEqual({ width: 450, depth: 320, height: 320 });
|
||||
expect(DIMENSION_LIMITS.roundnessThreshold).toBe(0.8);
|
||||
});
|
||||
|
||||
it('routes normal box to B', () => {
|
||||
expect(classifyItem(getItem('SKU-001')).category).toBe('B');
|
||||
});
|
||||
@@ -21,13 +29,7 @@ describe('classifyItem', () => {
|
||||
});
|
||||
|
||||
it('keeps C priority when item is oversized and round', () => {
|
||||
const item: Item = {
|
||||
...getItem('SKU-005'),
|
||||
id: 'TEST-OVERSIZED-ROUND',
|
||||
roundness: 0.95,
|
||||
};
|
||||
|
||||
const result = classifyItem(item);
|
||||
const result = classifyItem(getItem('SKU-011'));
|
||||
|
||||
expect(result.category).toBe('C');
|
||||
expect(result.dimensionsPass).toBe(false);
|
||||
@@ -37,4 +39,30 @@ describe('classifyItem', () => {
|
||||
it('accepts boundary item 450x320x320 as B', () => {
|
||||
expect(classifyItem(getItem('SKU-010')).category).toBe('B');
|
||||
});
|
||||
|
||||
it('rejects height below 10 mm as C', () => {
|
||||
const item: Item = {
|
||||
...getItem('SKU-001'),
|
||||
id: 'TEST-LOW-HEIGHT',
|
||||
dimensionsMm: { width: 100, depth: 100, height: 9 },
|
||||
};
|
||||
expect(classifyItem(item).category).toBe('C');
|
||||
expect(classifyItem(item).dimensionsPass).toBe(false);
|
||||
});
|
||||
|
||||
it('low confidence does not create a 4th class', () => {
|
||||
const lowConfidenceItems: Item[] = [
|
||||
{ ...getItem('SKU-001'), id: 'LC-B', confidence: 0.4 },
|
||||
{ ...getItem('SKU-004'), id: 'LC-C', confidence: 0.4 },
|
||||
{ ...getItem('SKU-006'), id: 'LC-D', confidence: 0.4 },
|
||||
{ ...getItem('SKU-011'), id: 'LC-C-PRIORITY', confidence: 0.4 },
|
||||
];
|
||||
|
||||
for (const item of lowConfidenceItems) {
|
||||
const result = classifyItem(item);
|
||||
expect(VALID_CATEGORIES).toContain(result.category);
|
||||
expect(result.category === 'B' || result.category === 'C' || result.category === 'D').toBe(true);
|
||||
expect(result.warnings.some((warning) => warning.toLowerCase().includes('confidence'))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPidState, updatePid } from './pid';
|
||||
import { NOMINAL_CONVEYOR_SPEED_MPS } from './simulation';
|
||||
|
||||
describe('PID conveyor speed imitation', () => {
|
||||
it('uses 1.00 m/s as default conveyor target', () => {
|
||||
expect(NOMINAL_CONVEYOR_SPEED_MPS).toBe(1);
|
||||
expect(NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)).toBe('1.00');
|
||||
const pid = createPidState();
|
||||
expect(pid.targetSpeedMps).toBe(NOMINAL_CONVEYOR_SPEED_MPS);
|
||||
});
|
||||
|
||||
it('moves actual speed toward target in normal mode', () => {
|
||||
let pid = createPidState(1);
|
||||
|
||||
@@ -13,6 +21,7 @@ describe('PID conveyor speed imitation', () => {
|
||||
expect(Math.abs(pid.pidError)).toBeLessThan(0.35);
|
||||
});
|
||||
|
||||
|
||||
it('decays actual speed toward zero when target is stopped', () => {
|
||||
let pid = createPidState(1);
|
||||
|
||||
|
||||
@@ -216,6 +216,9 @@ function completeCurrentItem(state: SimulationState): SimulationState {
|
||||
return transitionTo(next, 'RETURN_HOME');
|
||||
}
|
||||
|
||||
/** OZON Track 3 nominal conveyor speed (m/s). */
|
||||
export const NOMINAL_CONVEYOR_SPEED_MPS = 1;
|
||||
|
||||
function targetSpeedFor(state: SimulationState): number {
|
||||
if (state.machineState === 'FAULT' || state.machineState === 'EMERGENCY_STOP') {
|
||||
return 0;
|
||||
@@ -223,7 +226,7 @@ function targetSpeedFor(state: SimulationState): number {
|
||||
if (!state.running) {
|
||||
return 0;
|
||||
}
|
||||
return state.scenario.id === 'close_items' ? 0.75 : 1;
|
||||
return state.scenario.id === 'close_items' ? 0.75 : NOMINAL_CONVEYOR_SPEED_MPS;
|
||||
}
|
||||
|
||||
function deriveSensorsAndActuators(state: SimulationState): SimulationState {
|
||||
|
||||
@@ -20,6 +20,7 @@ export type ScenarioId =
|
||||
| 'normal_flow'
|
||||
| 'oversized_item'
|
||||
| 'round_object'
|
||||
| 'c_priority'
|
||||
| 'boundary_dimensions'
|
||||
| 'close_items'
|
||||
| 'low_confidence'
|
||||
|
||||
@@ -140,9 +140,24 @@ button:disabled {
|
||||
.category-C { color: var(--orange); }
|
||||
.category-D { color: var(--purple); }
|
||||
.fail-text { color: var(--red); }
|
||||
.pass-text { color: var(--green); }
|
||||
.warning-note { color: #facc15; font-weight: 700; }
|
||||
.error-note { color: var(--red); font-weight: 700; }
|
||||
|
||||
.proof-item em {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.proof-limits {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-eyebrow {
|
||||
color: var(--cyan);
|
||||
|
||||
Reference in New Issue
Block a user