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

@@ -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.

View File

@@ -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`.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

28546
input_info/doc-1783011400.pdf Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

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]}

View File

@@ -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',
},
{

View File

@@ -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.',

View File

@@ -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 {

View File

@@ -6,6 +6,7 @@ const expectedIds: ScenarioId[] = [
'normal_flow',
'oversized_item',
'round_object',
'c_priority',
'boundary_dimensions',
'close_items',
'low_confidence',

View File

@@ -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',

View File

@@ -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);
}
});
});

View File

@@ -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);

View File

@@ -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 {

View File

@@ -20,6 +20,7 @@ export type ScenarioId =
| 'normal_flow'
| 'oversized_item'
| 'round_object'
| 'c_priority'
| 'boundary_dimensions'
| 'close_items'
| 'low_confidence'

View File

@@ -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);