chore: preserve pre-cleanup product baseline
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"status": "SPEC_DERIVED_CAD",
|
"status": "DERIVED_CAD_FINAL",
|
||||||
"notAuthorCad": true,
|
"notAuthorCad": true,
|
||||||
"sourceOfDimensions": [
|
"sourceOfDimensions": [
|
||||||
"src/domain/pusherMotion.ts PUSHER constants",
|
"src/domain/pusherMotion.ts PUSHER.halfExtents / stroke / yaw",
|
||||||
"docs/stage2_real_sorter/mechanism-map.md",
|
"src/domain/physicalLayout.ts CONVEYOR_WIDTH_M=0.5 BELT_TOP_Y=0.7",
|
||||||
"PusherMechanism.tsx visual housing sizes"
|
"conveyor-clean.glb belt half-width z±0.25 m",
|
||||||
|
"max item envelope 450×320×320 mm (demo oversize height 400 mm)"
|
||||||
],
|
],
|
||||||
"units": "mm",
|
"units": "mm",
|
||||||
"paddle": {
|
"paddle": {
|
||||||
@@ -27,54 +28,142 @@
|
|||||||
"strokeMm": 1620,
|
"strokeMm": 1620,
|
||||||
"yawDeg": 20.4,
|
"yawDeg": 20.4,
|
||||||
"homeDistanceMm": 400,
|
"homeDistanceMm": 400,
|
||||||
"glb": "public/models/sorter/mechanism-web.glb",
|
"localFrame": "X=width Y=height Z=thickness (−Z contact)",
|
||||||
|
"glb": "public/models/sorter/mechanism-final.glb",
|
||||||
|
"mountGlb": "public/models/sorter/mechanism-mount-final.glb",
|
||||||
"step": "3d_models/sorter_mechanism.step",
|
"step": "3d_models/sorter_mechanism.step",
|
||||||
"parts": [
|
"parts": [
|
||||||
{
|
{
|
||||||
"name": "paddle-plate",
|
"name": "paddle-plate",
|
||||||
"triangles": 12,
|
"triangles": 12
|
||||||
"stl": "paddle-plate.stl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rib-l",
|
|
||||||
"triangles": 12,
|
|
||||||
"stl": "rib-l.stl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rib-c",
|
|
||||||
"triangles": 12,
|
|
||||||
"stl": "rib-c.stl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rib-r",
|
|
||||||
"triangles": 12,
|
|
||||||
"stl": "rib-r.stl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "servo-body",
|
|
||||||
"triangles": 96,
|
|
||||||
"stl": "servo-body.stl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "servo-shaft",
|
|
||||||
"triangles": 64,
|
|
||||||
"stl": "servo-shaft.stl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bracket",
|
|
||||||
"triangles": 12,
|
|
||||||
"stl": "bracket.stl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "housing",
|
|
||||||
"triangles": 12,
|
|
||||||
"stl": "housing.stl"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "leading-edge",
|
"name": "leading-edge",
|
||||||
"triangles": 12,
|
"triangles": 12
|
||||||
"stl": "leading-edge.stl"
|
},
|
||||||
|
{
|
||||||
|
"name": "rib-l",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rib-c",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rib-r",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "carriage",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bearing-l",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bearing-r",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "actuator-rod",
|
||||||
|
"triangles": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "clevis",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "servo-body",
|
||||||
|
"triangles": 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "servo-shaft",
|
||||||
|
"triangles": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bracket-l",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bracket-l-foot",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bracket-r",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bracket-r-foot",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "housing",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cheek-l",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cheek-r",
|
||||||
|
"triangles": 12
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sha256_glb": "eb973217d4ec7288f784d93e6f539b4f3d29fb3163d6788b5467c97451588ed6"
|
"mountParts": [
|
||||||
|
{
|
||||||
|
"name": "post-n",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "post-n-base",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "post-n-clamp",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "post-s",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "post-s-base",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "post-s-clamp",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "crossbeam",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rail-c",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rail-c-cylinder",
|
||||||
|
"triangles": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rail-d",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rail-d-cylinder",
|
||||||
|
"triangles": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "drive-housing",
|
||||||
|
"triangles": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "drive-housing-yellow",
|
||||||
|
"triangles": 12
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sha256_glb": "77c96b92e9f615a9124d2255927d9d7dbaf28eee28e0f4d956755c2d37a1f4c0",
|
||||||
|
"bytes": 22512
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
ISO-10303-21;
|
ISO-10303-21;
|
||||||
HEADER;
|
HEADER;
|
||||||
FILE_DESCRIPTION(('SPEC_DERIVED_CAD angled paddle diverter — OZON Tech Sorter'),'2;1');
|
FILE_DESCRIPTION(('DERIVED_CAD_FINAL angled paddle diverter — OZON Tech Sorter'),'2;1');
|
||||||
FILE_NAME('sorter_mechanism.step','2026-07-30',('stage2d'),('ozone-tech_owl_prime_170'),
|
FILE_NAME('sorter_mechanism.step','2026-07-30',('stage2d-final'),('ozone-tech_owl_prime_170'),
|
||||||
'manual/trimesh-derived','FreeCAD-compatible exchange','');
|
'node-gltf-transform-derived','FreeCAD-compatible exchange','');
|
||||||
FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));
|
FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));
|
||||||
ENDSEC;
|
ENDSEC;
|
||||||
DATA;
|
DATA;
|
||||||
/* SPEC_DERIVED_CAD — NOT author FreeCAD. Dimensions from domain PUSHER + Stage2B mechanism-map. */
|
/* DERIVED_CAD_FINAL — engineered from domain PUSHER + conveyor-clean belt geometry. */
|
||||||
/* paddle plate L=1000 W=280 T=50 mm; servo D=90 L=160 mm; shaft D=20 L=60 mm; stroke=1620 mm; yaw=20.4 deg */
|
/* paddle L=1000 H=280 T=50 mm; leading 4 mm; servo D=90 L=160; stroke=1620; yaw=20.4 deg */
|
||||||
|
/* local: X width, Y height, Z thickness (−Z contact) — matches Rapier collider */
|
||||||
#1 = APPLICATION_CONTEXT('automotive design');
|
#1 = APPLICATION_CONTEXT('automotive design');
|
||||||
#2 = PRODUCT('sorter_mechanism','Angled paddle diverter SPEC_DERIVED_CAD','',(#1));
|
#2 = PRODUCT('sorter_mechanism','Angled paddle diverter DERIVED_CAD_FINAL','',(#1));
|
||||||
ENDSEC;
|
ENDSEC;
|
||||||
END-ISO-10303-21;
|
END-ISO-10303-21;
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -22,9 +22,8 @@ UI — **Product Demo Landing Page**:
|
|||||||
|
|
||||||
A → подающий конвейер → накопитель → CV/laser/ultrasonic → stop-gate → actuator → B/C/D.
|
A → подающий конвейер → накопитель → CV/laser/ultrasonic → stop-gate → actuator → B/C/D.
|
||||||
|
|
||||||
- Стек: `three` + `@react-three/fiber` + `@react-three/drei`.
|
- Стек: `three` + `@react-three/fiber` + `@react-three/drei` + `@react-three/rapier`.
|
||||||
- **Physics engine не используется** — motion по state machine / keyframe (предсказуемое демо).
|
- **Physics:** на участке drop используется Rapier; на ленте поза задаётся domain state machine (предсказуемое демо).
|
||||||
- Архитектура (`itemMotion.ts`) готова к подключению physics позже.
|
|
||||||
- Переключатель: **3D Digital Twin** / **2D fallback**.
|
- Переключатель: **3D Digital Twin** / **2D fallback**.
|
||||||
- На mobile (<640px) по умолчанию 2D; 3D можно включить вручную.
|
- На mobile (<640px) по умолчанию 2D; 3D можно включить вручную.
|
||||||
- Если WebGL недоступен — автоматический 2D fallback.
|
- Если WebGL недоступен — автоматический 2D fallback.
|
||||||
@@ -51,9 +50,9 @@ A → подающий конвейер → накопитель → CV/laser/ul
|
|||||||
- **E** — журнал событий
|
- **E** — журнал событий
|
||||||
- **0.5×–2×** — скорость
|
- **0.5×–2×** — скорость
|
||||||
3. HUD показывает live `classifyItem` (DIM / K / reason) — не заскриптованный override.
|
3. HUD показывает live `classifyItem` (DIM / K / reason) — не заскриптованный override.
|
||||||
4. Engineering / step demo: `/details`
|
4. Engineering / documentation: `/documentation`
|
||||||
|
|
||||||
Проверка: `npm test` (153+), `npm run build`, `./scripts/demo-health.sh`.
|
Проверка: `npm test` (200+), `npm run build`, `./scripts/demo-health.sh`.
|
||||||
|
|
||||||
**3D Verification Checklist** (для защиты):
|
**3D Verification Checklist** (для защиты):
|
||||||
1. Desktop Chrome/Edge → Play Demo
|
1. Desktop Chrome/Edge → Play Demo
|
||||||
@@ -88,14 +87,14 @@ A → подающий конвейер → накопитель → CV/laser/ul
|
|||||||
**Тестовый набор товаров:**
|
**Тестовый набор товаров:**
|
||||||
Цилиндр, Шлем, Бутылка, Мешок, Тарелка, Короб 400×400×300, ЛанчБокс, Короб 300×200×200, Пуфик, Ручка, Моющее средство.
|
Цилиндр, Шлем, Бутылка, Мешок, Тарелка, Короб 400×400×300, ЛанчБокс, Короб 300×200×200, Пуфик, Ручка, Моющее средство.
|
||||||
|
|
||||||
**Параметры классификации (согласно постановке):**
|
**Параметры классификации (официальная постановка `doc-1783095831`, стр. 5–8):**
|
||||||
- Min dimensions: **10×10×2 мм**
|
- Min dimensions: **строго больше 10×10×10 мм**
|
||||||
- Max dimensions: 450×320×320 мм
|
- Max dimensions: **строго меньше 450×320×320 мм**
|
||||||
- Roundness threshold: K ≥ **0.7**
|
- Roundness: **K > 0.8** (K = 0.8 не считается круглым)
|
||||||
- Conveyor speed: 1.00 м/с
|
- Conveyor speed: 1.00 м/с
|
||||||
- C-priority: габариты проверяются первыми
|
- C-priority: габариты проверяются первыми
|
||||||
|
|
||||||
Подробный анализ: `docs/INPUT_INFO_ANALYSIS.md`
|
> Исторический файл `docs/INPUT_INFO_ANALYSIS.md` содержит устаревшие значения 10×10×2 / K≥0.7 — помечен как SUPERSEDED.
|
||||||
|
|
||||||
## Как открыть демо
|
## Как открыть демо
|
||||||
|
|
||||||
@@ -182,11 +181,11 @@ curl -I https://www.arhipovdan.ru/
|
|||||||
|
|
||||||
- `normal_flow` — обычный поток B/C/D.
|
- `normal_flow` — обычный поток B/C/D.
|
||||||
- `oversized_item` — max dimensions нарушены, маршрут C.
|
- `oversized_item` — max dimensions нарушены, маршрут C.
|
||||||
- `round_object` — габариты проходят, roundness >= 0.7, маршрут D.
|
- `round_object` — габариты проходят, roundness K > 0.8, маршрут D.
|
||||||
- `c_priority` — негабарит + круглый → только C (приоритет габаритов).
|
- `c_priority` — негабарит + круглый → только C (приоритет габаритов).
|
||||||
- `boundary_dimensions` — проверка min/max границ.
|
- `boundary_dimensions` — проверка строгих min/max границ.
|
||||||
- `close_items` — предупреждение spacing/queue, последовательная обработка.
|
- `close_items` — предупреждение spacing/queue, последовательная обработка.
|
||||||
- `low_confidence` — низкий CV confidence, rule-based fallback.
|
- `low_confidence` — низкая уверенность измерения, rule-based fallback.
|
||||||
- `jam` — застревание у gate, FAULT, остановка конвейера.
|
- `jam` — застревание у gate, FAULT, остановка конвейера.
|
||||||
- `emergency_stop` — EMERGENCY_STOP, остановка всех движений.
|
- `emergency_stop` — EMERGENCY_STOP, остановка всех движений.
|
||||||
|
|
||||||
@@ -194,18 +193,18 @@ curl -I https://www.arhipovdan.ru/
|
|||||||
|
|
||||||
Классификация реализована чистой функцией `classifyItem`.
|
Классификация реализована чистой функцией `classifyItem`.
|
||||||
|
|
||||||
1. Проверяются габариты.
|
1. Проверяются габариты (строгие границы «больше» / «меньше»).
|
||||||
2. Если нарушены min/max размеры, категория C.
|
2. Если нарушены min/max размеры, категория C.
|
||||||
3. Если габариты подходят, проверяется `roundness`.
|
3. Если габариты подходят, проверяется `roundness` K = r_in / R_out.
|
||||||
4. Если `roundness >= 0.8`, категория D.
|
4. Если `K > 0.8`, категория D.
|
||||||
5. Иначе категория B.
|
5. Иначе категория B.
|
||||||
6. Если товар одновременно негабаритный и круглый, приоритет у C, потому что dimensions check идет первым.
|
6. Если товар одновременно негабаритный и круглый, приоритет у C.
|
||||||
|
|
||||||
Границы MVP:
|
Официальные границы (Track 3):
|
||||||
|
|
||||||
- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм;
|
- min: width > 10 мм, depth > 10 мм, height > 10 мм;
|
||||||
- max: width <= 450 мм, depth <= 320 мм, height <= 320 мм;
|
- max: width < 450 мм, depth < 320 мм, height < 320 мм;
|
||||||
- roundness threshold: 0.7 (K = r_in / r_out);
|
- roundness: круг при K > 0.8 (K = 0.8 не круглый);
|
||||||
- conveyor target speed: 1.00 м/с (close_items: 0.75 м/с).
|
- conveyor target speed: 1.00 м/с (close_items: 0.75 м/с).
|
||||||
|
|
||||||
## Исполнительная часть
|
## Исполнительная часть
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# 3D REBUILD SPEC — Главная страница как Full-Screen Demo
|
# 3D REBUILD SPEC — Главная страница как Full-Screen Demo
|
||||||
|
|
||||||
**Дата:** 2026-07-06
|
**Дата:** 2026-07-06
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# Auto Demo Integration — Final Report
|
# Auto Demo Integration — Final Report
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# Continuous Playback Report
|
# Continuous Playback Report
|
||||||
|
|
||||||
**Date:** 2026-07-09
|
**Date:** 2026-07-09
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Open https://arhipovdan.ru/ (или http://127.0.0.1:3100/).
|
|||||||
|
|
||||||
## 30-Second Pitch
|
## 30-Second Pitch
|
||||||
|
|
||||||
«Мы сделали инженерную симуляцию сортировочной ячейки OZON с 3D digital twin. Полный цикл: detection → classification → command → routing. Товар физически уходит в B/C/D. Physics engine не используем — motion по state machine, демо предсказуемо.»
|
«Мы сделали инженерную симуляцию сортировочной ячейки OZON с 3D digital twin. Полный цикл: тестовые измерения → classification → command → routing. Товар уходит в B/C/D в цифровом стенде. На ленте — state machine; на drop-участке — Rapier.»
|
||||||
|
|
||||||
## 7-Minute Structure
|
## 7-Minute Structure
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Open https://arhipovdan.ru/ (или http://127.0.0.1:3100/).
|
|||||||
- **0:20–0:45** Полный контур: Detection → Classification → Command → Actuator → Route.
|
- **0:20–0:45** Полный контур: Detection → Classification → Command → Actuator → Route.
|
||||||
- **0:45–1:30** Категория B: габариты проходят, нет круга, зелёный маршрут в основной сортировщик.
|
- **0:45–1:30** Категория B: габариты проходят, нет круга, зелёный маршрут в основной сортировщик.
|
||||||
- **1:30–2:15** Категория C: негабаритный товар, оранжевый маршрут в roll-cage C.
|
- **1:30–2:15** Категория C: негабаритный товар, оранжевый маршрут в roll-cage C.
|
||||||
- **2:15–3:00** Категория D: габариты проходят, но круг в сечении (K ≥ 0.7), фиолетовый маршрут в roll-cage D.
|
- **2:15–3:00** Категория D: габариты проходят, но круг в сечении (K > 0.8), фиолетовый маршрут в roll-cage D.
|
||||||
- **3:00–3:45** C-priority: негабаритный + круглый → всё равно C (приоритет габаритов).
|
- **3:00–3:45** C-priority: негабаритный + круглый → всё равно C (приоритет габаритов).
|
||||||
- **3:45–4:30** Low confidence и fault: rule-based fallback, FAULT состояние, EMERGENCY_STOP.
|
- **3:45–4:30** Low confidence и fault: rule-based fallback, FAULT состояние, EMERGENCY_STOP.
|
||||||
- **4:30–5:30** Engineering Details: timeline, PID, sensors, event log, 3D capability check.
|
- **4:30–5:30** Engineering Details: timeline, PID, sensors, event log, 3D capability check.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# INPUT_INFO_ANALYSIS
|
# INPUT_INFO_ANALYSIS
|
||||||
|
|
||||||
Анализ материалов из папки `input_info` для приведения проекта в соответствие с официальной постановкой задачи OZON Tech Track 3.
|
Анализ материалов из папки `input_info` для приведения проекта в соответствие с официальной постановкой задачи OZON Tech Track 3.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# CV Inspection Overlay Report
|
# CV Inspection Overlay Report
|
||||||
|
|
||||||
**Date:** 2026-07-09
|
**Date:** 2026-07-09
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ The proof card shows dimensions, roundness and reason. Tests cover key boundary
|
|||||||
|
|
||||||
## 11. How Are Dimensions And Circular Section Handled?
|
## 11. How Are Dimensions And Circular Section Handled?
|
||||||
|
|
||||||
Dimensions are checked first against min/max: 10×10×2 mm to 450×320×320 mm. If they pass, roundness K = r_in / r_out is checked against threshold 0.7. Conveyor target speed is 1.00 m/s.
|
Dimensions are checked first with exclusive official bounds: strictly greater than 10×10×10 mm and strictly less than 450×320×320 mm. If they pass, roundness K = r_in / R_out is checked: circular only when K > 0.8 (K = 0.8 is not round). Conveyor target speed is 1.00 m/s.
|
||||||
|
|
||||||
## 12. Why Does C Have Priority Over D?
|
## 12. Why Does C Have Priority Over D?
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# Measurement System Report
|
# Measurement System Report
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ Before final submission:
|
|||||||
- [ ] Routes B / C / D are visible in 3D.
|
- [ ] Routes B / C / D are visible in 3D.
|
||||||
- [ ] C-priority works for oversized + round.
|
- [ ] C-priority works for oversized + round.
|
||||||
- [ ] Jam / emergency stop show red fault state.
|
- [ ] Jam / emergency stop show red fault state.
|
||||||
- [ ] Physics engine is NOT required for the demo (state-machine motion).
|
- [ ] Twin demo uses domain motion + Rapier drop segment (no claim of full physics plant).
|
||||||
- [ ] Engineering Details includes 3D capability check (WebGL / FPS).
|
- [ ] Engineering Details includes 3D capability check (WebGL / FPS).
|
||||||
- [ ] Min dimensions are 10×10×2 mm; roundness threshold is 0.7; conveyor target is 1.00 m/s.
|
- [ ] Official rules: dims > 10×10×10 and < 450×320×320; roundness K > 0.8; conveyor 1.00 m/s.
|
||||||
- [ ] Storyline Stepper updates with machine state.
|
- [ ] Storyline Stepper updates with machine state.
|
||||||
- [ ] Scenario cards open cases with **Показать**.
|
- [ ] Scenario cards open cases with **Показать**.
|
||||||
- [ ] Criteria cards show OZON coverage and linked scenarios.
|
- [ ] Criteria cards show OZON coverage and linked scenarios.
|
||||||
|
|||||||
@@ -61,9 +61,12 @@ Props из `SimulationState`: item, machineState, scenario, classification, sens
|
|||||||
- [x] State machine двигает товар предсказуемо
|
- [x] State machine двигает товар предсказуемо
|
||||||
- [x] ROUTE_TO_* виден цветом и стрелкой
|
- [x] ROUTE_TO_* виден цветом и стрелкой
|
||||||
- [x] 2D fallback работает
|
- [x] 2D fallback работает
|
||||||
- [x] min dimensions 10×10×2, max 450×320×320, roundness K ≥ 0.7, conveyor 1.00 m/s
|
- [x] min dimensions > 10×10×10, max < 450×320×320, roundness K > 0.8, conveyor 1.00 m/s
|
||||||
- [x] Нет horizontal scroll
|
- [x] Нет horizontal scroll
|
||||||
|
|
||||||
## Decision log
|
## Decision log
|
||||||
|
|
||||||
Physics (`@react-three/rapier`) **не установлен**: для хакатона важнее предсказуемый route, чем dynamic collisions. Motion layer — `itemMotion.ts` (`PHYSICS_ENGINE_ENABLED = false`).
|
> **HISTORICAL / SUPERSEDED (stage note):** early feasibility assumed no physics engine.
|
||||||
|
> **Current:** `@react-three/rapier` is used on the drop segment; belt pose remains domain-driven.
|
||||||
|
|
||||||
|
Physics (`@react-three/rapier`) **установлен** для drop/handoff. Motion on belt — domain state machine.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# 3D Digital Twin Rework — Final Report
|
# 3D Digital Twin Rework — Final Report
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|||||||
BIN
docs/engineering/work-area-layout-source.png
Normal file
BIN
docs/engineering/work-area-layout-source.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
@@ -1,3 +1,5 @@
|
|||||||
|
> **HISTORICAL / SUPERSEDED:** This document records an earlier project stage. Canonical Track 3 rules are exclusive bounds **> 10×10×10** and **< 450×320×320** mm, roundness **K > 0.8** (`doc-1783095831` pp.5–8; `src/domain/classifier.ts`). Values 10×10×2 / K≥0.7 below are obsolete.
|
||||||
|
|
||||||
# STAGE 0 REPORT — Premium 3D Feasibility
|
# STAGE 0 REPORT — Premium 3D Feasibility
|
||||||
|
|
||||||
Date: 2026-07-29 · Branch: `feature/premium-3d-stage-0` · Executor: Senior Technical Lead / WebGL Performance Engineer (AI agent)
|
Date: 2026-07-29 · Branch: `feature/premium-3d-stage-0` · Executor: Senior Technical Lead / WebGL Performance Engineer (AI agent)
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ test.describe('mobile fallback (390x844)', () => {
|
|||||||
await page.getByTestId('demo-pause').click();
|
await page.getByTestId('demo-pause').click();
|
||||||
await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Details link present and working
|
// Documentation via AppNav only (duplicate CTA removed)
|
||||||
await page.getByTestId('fallback-details-link').click();
|
await expect(page.getByTestId('fallback-docs-link')).toHaveCount(0);
|
||||||
await expect(page).toHaveURL(/\/details/);
|
await expect(page.getByTestId('main-docs-link')).toHaveCount(0);
|
||||||
|
await page.getByTestId('nav-documentation').click();
|
||||||
|
await expect(page).toHaveURL(/\/documentation/);
|
||||||
|
|
||||||
// Stage 0 artifact
|
// Stage 0 artifact
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
@@ -88,10 +88,14 @@ test.describe('production smoke @production', () => {
|
|||||||
await page.getByTestId('demo-case-0').click({ force: true });
|
await page.getByTestId('demo-case-0').click({ force: true });
|
||||||
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
||||||
|
|
||||||
await page.goto('/details');
|
await page.goto('/documentation');
|
||||||
await expect(page.locator('#root')).toBeVisible();
|
await expect(page).toHaveURL(/\/documentation\/?$/);
|
||||||
|
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator('#root')).toBeVisible();
|
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/details');
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
|
||||||
await page.goto('/?debug=1');
|
await page.goto('/?debug=1');
|
||||||
const version2 = await page.evaluate(async () => {
|
const version2 = await page.evaluate(async () => {
|
||||||
|
|||||||
@@ -60,30 +60,11 @@ test.describe('Stage 1 real models', () => {
|
|||||||
expect(errors.filter((e) => e.startsWith('pageerror'))).toEqual([]);
|
expect(errors.filter((e) => e.startsWith('pageerror'))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/details renders the item at true physical scale (no 2.5x multiplier)', async ({ page }) => {
|
test('removed /details route redirects to simulation (true scale covered on /)', async ({ page }) => {
|
||||||
const errors = watchConsole(page);
|
|
||||||
const modelRequests: string[] = [];
|
|
||||||
page.on('request', (req) => {
|
|
||||||
if (MODEL_RE.test(req.url())) modelRequests.push(req.url());
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto('/details', { waitUntil: 'domcontentloaded' });
|
await page.goto('/details', { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||||
// The details demo is user-started; the item exists only after start.
|
expect(await page.locator('canvas').count()).toBeGreaterThan(0);
|
||||||
await page.getByRole('button', { name: /start demo/i }).first().click();
|
|
||||||
await page.waitForTimeout(6000);
|
|
||||||
|
|
||||||
// Item on the belt: bottom must sit at belt height (0.7m), not float.
|
|
||||||
const itemBottom = await page.evaluate(() => {
|
|
||||||
// Deterministic check via the motion contract: itemPosition3D y == beltY.
|
|
||||||
// We assert the rendered group Y through the scene graph is inaccessible
|
|
||||||
// here, so we verify the domain contract source instead.
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
expect(itemBottom).toBe(true);
|
|
||||||
// Real model must load on /details too (SKU-001 is the default demo item).
|
|
||||||
expect(modelRequests.some((u) => MODEL_RE.test(u))).toBe(true);
|
|
||||||
expect(errors.filter((e) => !e.includes('favicon'))).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile SVG fallback does not download real-model assets', async ({ browser }) => {
|
test('mobile SVG fallback does not download real-model assets', async ({ browser }) => {
|
||||||
|
|||||||
159
e2e/responsive-layout.spec.ts
Normal file
159
e2e/responsive-layout.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { test, expect, type Page, type Locator } from '@playwright/test';
|
||||||
|
|
||||||
|
type Box = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
|
async function boxOf(locator: Locator): Promise<Box> {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
expect(box, `missing box for ${locator}`).toBeTruthy();
|
||||||
|
return box as Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a: Box, b: Box, pad = 0): boolean {
|
||||||
|
return !(
|
||||||
|
a.x + a.width + pad <= b.x ||
|
||||||
|
b.x + b.width + pad <= a.x ||
|
||||||
|
a.y + a.height + pad <= b.y ||
|
||||||
|
b.y + b.height + pad <= a.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noHorizontalOverflow(page: Page) {
|
||||||
|
const metrics = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
bodyScrollWidth: document.body.scrollWidth,
|
||||||
|
}));
|
||||||
|
expect(metrics.scrollWidth, 'documentElement horizontal overflow').toBeLessThanOrEqual(
|
||||||
|
metrics.clientWidth + 1,
|
||||||
|
);
|
||||||
|
expect(metrics.bodyScrollWidth, 'body horizontal overflow').toBeLessThanOrEqual(
|
||||||
|
metrics.clientWidth + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insideViewport(page: Page, locator: Locator) {
|
||||||
|
const vp = page.viewportSize();
|
||||||
|
expect(vp).toBeTruthy();
|
||||||
|
const box = await boxOf(locator);
|
||||||
|
expect(box.x).toBeGreaterThanOrEqual(-1);
|
||||||
|
expect(box.y).toBeGreaterThanOrEqual(-1);
|
||||||
|
expect(box.x + box.width).toBeLessThanOrEqual((vp as { width: number }).width + 1);
|
||||||
|
expect(box.y + box.height).toBeLessThanOrEqual((vp as { height: number }).height + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('responsive layout bbox', () => {
|
||||||
|
test('desktop home: no control overlaps, no docs CTA', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto('/?playback=paused');
|
||||||
|
await expect(page.getByTestId('app-nav')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId('main-docs-link')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('fallback-docs-link')).toHaveCount(0);
|
||||||
|
|
||||||
|
const play = page.getByTestId('demo-play').or(page.getByTestId('demo-pause'));
|
||||||
|
const present = page.getByTestId('demo-presentation');
|
||||||
|
await expect(play).toBeVisible();
|
||||||
|
await expect(present).toBeVisible();
|
||||||
|
|
||||||
|
const playBox = await boxOf(play);
|
||||||
|
const presentBox = await boxOf(present);
|
||||||
|
expect(overlaps(playBox, presentBox)).toBe(false);
|
||||||
|
|
||||||
|
const autoCam = page.getByTestId('auto-camera-toggle');
|
||||||
|
if ((await autoCam.count()) > 0 && (await autoCam.isVisible())) {
|
||||||
|
const autoBox = await boxOf(autoCam);
|
||||||
|
expect(overlaps(playBox, autoBox)).toBe(false);
|
||||||
|
expect(overlaps(presentBox, autoBox)).toBe(false);
|
||||||
|
await insideViewport(page, autoCam);
|
||||||
|
}
|
||||||
|
|
||||||
|
await insideViewport(page, play);
|
||||||
|
await insideViewport(page, present);
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile home: sequential flow without overlaps', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'hardwareConcurrency', { value: 4, configurable: true });
|
||||||
|
Object.defineProperty(navigator, 'deviceMemory', { value: 2, configurable: true });
|
||||||
|
});
|
||||||
|
await page.goto('/?playback=paused');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('main-mobile-stack')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId('main-docs-link')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('fallback-docs-link')).toHaveCount(0);
|
||||||
|
|
||||||
|
const nav = page.getByTestId('app-nav');
|
||||||
|
const hud = page.getByTestId('demo-hud');
|
||||||
|
const banner = page.getByTestId('fallback-reason');
|
||||||
|
const diagram = page.getByTestId('fallback-diagram');
|
||||||
|
const controls = page.getByTestId('main-controls');
|
||||||
|
const play = page.getByTestId('demo-play').or(page.getByTestId('demo-pause'));
|
||||||
|
const present = page.getByTestId('demo-presentation');
|
||||||
|
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
await expect(hud).toBeVisible();
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(diagram).toBeVisible();
|
||||||
|
await expect(controls).toBeVisible();
|
||||||
|
await expect(play).toBeVisible();
|
||||||
|
await expect(present).toBeVisible();
|
||||||
|
|
||||||
|
const navBox = await boxOf(nav);
|
||||||
|
const hudBox = await boxOf(hud);
|
||||||
|
const bannerBox = await boxOf(banner);
|
||||||
|
const diagramBox = await boxOf(diagram);
|
||||||
|
const controlsBox = await boxOf(controls);
|
||||||
|
const playBox = await boxOf(play);
|
||||||
|
const presentBox = await boxOf(present);
|
||||||
|
|
||||||
|
expect(overlaps(navBox, hudBox)).toBe(false);
|
||||||
|
expect(overlaps(hudBox, bannerBox)).toBe(false);
|
||||||
|
expect(overlaps(diagramBox, controlsBox)).toBe(false);
|
||||||
|
expect(overlaps(playBox, presentBox)).toBe(false);
|
||||||
|
|
||||||
|
expect(navBox.y + navBox.height).toBeLessThanOrEqual(hudBox.y + 1);
|
||||||
|
expect(hudBox.y + hudBox.height).toBeLessThanOrEqual(bannerBox.y + 1);
|
||||||
|
expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(diagramBox.y + 8);
|
||||||
|
expect(diagramBox.y + diagramBox.height).toBeLessThanOrEqual(controlsBox.y + 8);
|
||||||
|
|
||||||
|
for (const loc of [nav, hud, play, present]) {
|
||||||
|
const b = await boxOf(loc);
|
||||||
|
// Tap target: at least one axis ≥ 40 (full-width buttons may be short in one dimension)
|
||||||
|
expect(Math.max(b.width, b.height), 'min interactive size').toBeGreaterThanOrEqual(40);
|
||||||
|
expect(Math.min(b.width, b.height), 'min interactive thickness').toBeGreaterThanOrEqual(40);
|
||||||
|
expect(b.x).toBeGreaterThanOrEqual(-1);
|
||||||
|
expect(b.x + b.width).toBeLessThanOrEqual(391);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty gap check: banner→diagram gap should be modest (not the old ~210px pad)
|
||||||
|
expect(diagramBox.y - (bannerBox.y + bannerBox.height)).toBeLessThan(48);
|
||||||
|
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile documentation: single column + readable table wrapper', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/documentation');
|
||||||
|
await expect(page.getByTestId('documentation-page')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await expect(page.getByTestId('docs-toc-desktop')).toBeHidden();
|
||||||
|
await expect(page.getByTestId('docs-toc-mobile')).toBeVisible();
|
||||||
|
|
||||||
|
const fontSize = await page
|
||||||
|
.locator('.docs-section p')
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||||
|
expect(fontSize).toBeGreaterThanOrEqual(15);
|
||||||
|
|
||||||
|
const layoutDisplay = await page.locator('.docs-layout').evaluate((el) => getComputedStyle(el).display);
|
||||||
|
expect(layoutDisplay).toBe('flex');
|
||||||
|
|
||||||
|
const scroll = page.getByTestId('docs-table-scroll');
|
||||||
|
await expect(scroll).toBeVisible();
|
||||||
|
const overflowX = await scroll.evaluate((el) => getComputedStyle(el).overflowX);
|
||||||
|
expect(['auto', 'scroll']).toContain(overflowX);
|
||||||
|
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('routes', () => {
|
test.describe('routes', () => {
|
||||||
test('/details opens, refresh stays, back to home', async ({ page }) => {
|
test('two-page UI: simulation, documentation, unknown redirect', async ({ page }) => {
|
||||||
await page.goto('/details');
|
await page.goto('/');
|
||||||
await expect(page).toHaveURL(/\/details\/?$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
await expect(page.getByRole('link', { name: /back to full-screen demo/i })).toBeVisible({
|
await expect(page.getByTestId('app-nav')).toBeVisible({ timeout: 30_000 });
|
||||||
timeout: 30_000,
|
await expect(page.getByTestId('nav-simulation')).toHaveClass(/active/);
|
||||||
});
|
|
||||||
|
await page.getByTestId('nav-documentation').click();
|
||||||
|
await expect(page).toHaveURL(/\/documentation\/?$/);
|
||||||
|
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('docs-production-status')).toContainText('DATA_ACQUISITION_PACK_READY');
|
||||||
|
await expect(page.getByTestId('nav-documentation')).toHaveClass(/active/);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page).toHaveURL(/\/details\/?$/);
|
await expect(page).toHaveURL(/\/documentation\/?$/);
|
||||||
await expect(page.getByRole('link', { name: /back to full-screen demo/i })).toBeVisible();
|
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('link', { name: /back to full-screen demo/i }).click();
|
await page.getByTestId('nav-simulation').click();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
await expect(page.getByTestId('demo-hud')).toBeVisible({ timeout: 60_000 });
|
await expect(page.getByTestId('demo-hud')).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
await page.goto('/details');
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
|
||||||
|
await page.goto('/device-test');
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
|
||||||
|
await page.goto('/old-route');
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,11 +128,11 @@ test.describe('visual regression', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('08 details engineering page', async ({ page }) => {
|
test('08 documentation page', async ({ page }) => {
|
||||||
await page.goto('/details');
|
await page.goto('/documentation');
|
||||||
await expect(page.locator('#root')).toBeVisible();
|
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(800);
|
||||||
await expect(page).toHaveScreenshot('08-details-page.png', {
|
await expect(page).toHaveScreenshot('08-documentation-page.png', {
|
||||||
threshold: 0.4,
|
threshold: 0.4,
|
||||||
maxDiffPixelRatio: 0.12,
|
maxDiffPixelRatio: 0.12,
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
|
|||||||
145855
official_sources/doc-1783095831.pdf
Normal file
145855
official_sources/doc-1783095831.pdf
Normal file
File diff suppressed because one or more lines are too long
BIN
public/models/sorter/conveyor-clean.glb
Normal file
BIN
public/models/sorter/conveyor-clean.glb
Normal file
Binary file not shown.
Binary file not shown.
199
src/App.tsx
199
src/App.tsx
@@ -1,21 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import MainPage from './pages/MainPage';
|
import MainPage from './pages/MainPage';
|
||||||
import DetailsPage from './pages/DetailsPage';
|
import DocumentationPage from './pages/DocumentationPage';
|
||||||
import DeviceTestPage from './pages/DeviceTestPage';
|
|
||||||
import { DEMO_STEPS } from './data/demoSteps';
|
|
||||||
import { SCENARIOS } from './data/scenarios';
|
|
||||||
import { createSimulation, stepSimulationToNextState, setRunning } from './domain/simulation';
|
|
||||||
import type { ScenarioId, SimulationState } from './domain/types';
|
|
||||||
import {
|
|
||||||
createDemoDirectorState,
|
|
||||||
startAutoDemo,
|
|
||||||
pauseAutoDemo,
|
|
||||||
resumeAutoDemo,
|
|
||||||
stopAutoDemo,
|
|
||||||
updateAutoDemo,
|
|
||||||
type DemoDirectorState,
|
|
||||||
} from './domain/demoDirector';
|
|
||||||
import {
|
import {
|
||||||
createPlaybackState,
|
createPlaybackState,
|
||||||
startPlayback,
|
startPlayback,
|
||||||
@@ -31,97 +17,16 @@ import {
|
|||||||
type PlaybackSpeed,
|
type PlaybackSpeed,
|
||||||
} from './domain/continuousPlayback';
|
} from './domain/continuousPlayback';
|
||||||
|
|
||||||
function scrollToId(id: string) {
|
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
// Details page state (existing simulation system)
|
|
||||||
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
|
|
||||||
const [demoStepIndex, setDemoStepIndex] = useState(0);
|
|
||||||
|
|
||||||
const activeScenario = useMemo(
|
|
||||||
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
|
|
||||||
[activeScenarioId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
|
|
||||||
|
|
||||||
const [demoDirector, setDemoDirector] = useState<DemoDirectorState>(() =>
|
|
||||||
createDemoDirectorState(activeScenario),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Main page state (continuous playback)
|
|
||||||
const [playback, setPlayback] = useState<ContinuousPlaybackState>(() => createPlaybackState());
|
const [playback, setPlayback] = useState<ContinuousPlaybackState>(() => createPlaybackState());
|
||||||
|
|
||||||
const rafIdRef = useRef<number | null>(null);
|
|
||||||
const lastUpdateRef = useRef(performance.now());
|
|
||||||
const playbackRafRef = useRef<number | null>(null);
|
const playbackRafRef = useRef<number | null>(null);
|
||||||
const playbackLastRef = useRef(performance.now());
|
const playbackLastRef = useRef(performance.now());
|
||||||
const UPDATE_INTERVAL = 100;
|
const PLAYBACK_INTERVAL = 50;
|
||||||
const PLAYBACK_INTERVAL = 50; // More frequent updates for smooth animation
|
|
||||||
|
|
||||||
// Sync activeScenario changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSimulation(createSimulation(activeScenario));
|
|
||||||
setDemoDirector(createDemoDirectorState(activeScenario));
|
|
||||||
}, [activeScenario]);
|
|
||||||
|
|
||||||
// Details page auto demo loop
|
|
||||||
useEffect(() => {
|
|
||||||
if (!demoDirector.isAutoDemoRunning || demoDirector.paused) {
|
|
||||||
if (rafIdRef.current) {
|
|
||||||
cancelAnimationFrame(rafIdRef.current);
|
|
||||||
rafIdRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastTime = performance.now();
|
|
||||||
|
|
||||||
const tick = () => {
|
|
||||||
const now = performance.now();
|
|
||||||
const deltaMs = now - lastTime;
|
|
||||||
|
|
||||||
if (now - lastUpdateRef.current >= UPDATE_INTERVAL) {
|
|
||||||
lastTime = now;
|
|
||||||
lastUpdateRef.current = now;
|
|
||||||
|
|
||||||
setDemoDirector((prev) => {
|
|
||||||
const category = simulation.currentItem?.classification.category;
|
|
||||||
return updateAutoDemo(prev, deltaMs, category);
|
|
||||||
});
|
|
||||||
|
|
||||||
setSimulation((current) => {
|
|
||||||
if (current.machineState === 'IDLE' && !current.currentItem) {
|
|
||||||
return setRunning(stepSimulationToNextState(current), true);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
current.machineState === 'RETURN_HOME' ||
|
|
||||||
current.machineState === 'FAULT' ||
|
|
||||||
current.machineState === 'EMERGENCY_STOP'
|
|
||||||
) {
|
|
||||||
return createSimulation(activeScenario);
|
|
||||||
}
|
|
||||||
return setRunning(current, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
rafIdRef.current = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
rafIdRef.current = requestAnimationFrame(tick);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (rafIdRef.current) {
|
|
||||||
cancelAnimationFrame(rafIdRef.current);
|
|
||||||
rafIdRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [demoDirector.isAutoDemoRunning, demoDirector.paused, activeScenario, simulation.currentItem?.classification.category]);
|
|
||||||
|
|
||||||
// Public page autostarts the sorter loop; ?playback=paused keeps it idle
|
// Public page autostarts the sorter loop; ?playback=paused keeps it idle
|
||||||
// (used by screenshot/debug tooling).
|
// (used by screenshot/debug tooling). First item is gated in the 3D scene
|
||||||
|
// until PRODUCT_ASSETS_READY (see SorterDigitalTwinContinuous).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('playback') === 'paused') return;
|
if (params.get('playback') === 'paused') return;
|
||||||
@@ -129,7 +34,6 @@ function AppContent() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Main page continuous playback loop
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playback.status !== 'running') {
|
if (playback.status !== 'running') {
|
||||||
if (playbackRafRef.current) {
|
if (playbackRafRef.current) {
|
||||||
@@ -162,78 +66,6 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
}, [playback.status]);
|
}, [playback.status]);
|
||||||
|
|
||||||
// Details page handlers
|
|
||||||
const handleStartDemo = () => {
|
|
||||||
scrollToId('demo');
|
|
||||||
setSimulation((current) => {
|
|
||||||
if (current.machineState === 'IDLE' && !current.currentItem) {
|
|
||||||
return stepSimulationToNextState(current);
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
const finished =
|
|
||||||
simulation.machineState === 'RETURN_HOME' ||
|
|
||||||
simulation.machineState === 'FAULT' ||
|
|
||||||
simulation.machineState === 'EMERGENCY_STOP';
|
|
||||||
|
|
||||||
if (finished) {
|
|
||||||
const nextIndex = (demoStepIndex + 1) % DEMO_STEPS.length;
|
|
||||||
setDemoStepIndex(nextIndex);
|
|
||||||
setActiveScenarioId(DEMO_STEPS[nextIndex].scenarioId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSimulation((current) => stepSimulationToNextState(current));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setSimulation(createSimulation(activeScenario));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScenarioChange = (scenarioId: ScenarioId) => {
|
|
||||||
setActiveScenarioId(scenarioId);
|
|
||||||
const stepIdx = DEMO_STEPS.findIndex((step) => step.scenarioId === scenarioId);
|
|
||||||
if (stepIdx >= 0) {
|
|
||||||
setDemoStepIndex(stepIdx);
|
|
||||||
}
|
|
||||||
scrollToId('demo');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartAutoDemo = () => {
|
|
||||||
scrollToId('demo');
|
|
||||||
setDemoDirector((prev) => startAutoDemo(prev));
|
|
||||||
setSimulation(createSimulation(activeScenario));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePauseAutoDemo = () => {
|
|
||||||
setDemoDirector((prev) => pauseAutoDemo(prev));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResumeAutoDemo = () => {
|
|
||||||
setDemoDirector((prev) => resumeAutoDemo(prev));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStopAutoDemo = () => {
|
|
||||||
setDemoDirector((prev) => stopAutoDemo(prev));
|
|
||||||
setSimulation(createSimulation(activeScenario));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleAutoDemo = () => {
|
|
||||||
if (demoDirector.isAutoDemoRunning) {
|
|
||||||
if (demoDirector.paused) {
|
|
||||||
handleResumeAutoDemo();
|
|
||||||
} else {
|
|
||||||
handlePauseAutoDemo();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handleStartAutoDemo();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main page handlers - continuous playback
|
|
||||||
const handleMainPlay = useCallback(() => {
|
const handleMainPlay = useCallback(() => {
|
||||||
setPlayback((prev) => {
|
setPlayback((prev) => {
|
||||||
if (prev.status === 'paused') {
|
if (prev.status === 'paused') {
|
||||||
@@ -284,26 +116,7 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="/documentation" element={<DocumentationPage />} />
|
||||||
path="/details"
|
|
||||||
element={
|
|
||||||
<DetailsPage
|
|
||||||
playback={playback}
|
|
||||||
simulation={simulation}
|
|
||||||
demoStepIndex={demoStepIndex}
|
|
||||||
demoDirector={demoDirector}
|
|
||||||
activeScenarioId={activeScenarioId}
|
|
||||||
onStartDemo={handleStartDemo}
|
|
||||||
onNext={handleNext}
|
|
||||||
onReset={handleReset}
|
|
||||||
onScenarioChange={handleScenarioChange}
|
|
||||||
onStartAutoDemo={handleStartAutoDemo}
|
|
||||||
onToggleAutoDemo={handleToggleAutoDemo}
|
|
||||||
onStopAutoDemo={handleStopAutoDemo}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/device-test" element={<DeviceTestPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
35
src/components/AppNav.tsx
Normal file
35
src/components/AppNav.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface AppNavProps {
|
||||||
|
/** Overlay (desktop sim), bar (mobile full-width), solid (docs header). */
|
||||||
|
variant?: 'overlay' | 'bar' | 'solid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product navigation — only Simulation and Documentation.
|
||||||
|
*/
|
||||||
|
export default function AppNav({ variant = 'solid' }: AppNavProps) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={`app-nav app-nav-${variant}`}
|
||||||
|
aria-label="Основная навигация"
|
||||||
|
data-testid="app-nav"
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
end
|
||||||
|
className={({ isActive }) => `app-nav-link${isActive ? ' active' : ''}`}
|
||||||
|
data-testid="nav-simulation"
|
||||||
|
>
|
||||||
|
Симуляция
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/documentation"
|
||||||
|
className={({ isActive }) => `app-nav-link${isActive ? ' active' : ''}`}
|
||||||
|
data-testid="nav-documentation"
|
||||||
|
>
|
||||||
|
Документация
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import type { MeasurementData } from '../domain/measurementSystem';
|
import type { MeasurementData } from '../domain/measurementSystem';
|
||||||
import { getStageLabel } from '../domain/measurementSystem';
|
import { getStageLabel } from '../domain/measurementSystem';
|
||||||
|
import { DIMENSION_LIMITS, isCircularCrossSection } from '../domain/classifier';
|
||||||
|
|
||||||
interface CVInspectionOverlayProps {
|
interface CVInspectionOverlayProps {
|
||||||
data: MeasurementData;
|
data: MeasurementData;
|
||||||
@@ -136,9 +137,11 @@ export default function CVInspectionOverlay({ data, visible }: CVInspectionOverl
|
|||||||
</div>
|
</div>
|
||||||
<div className="cv-row">
|
<div className="cv-row">
|
||||||
<span className="cv-label">Roundness</span>
|
<span className="cv-label">Roundness</span>
|
||||||
<span className={`cv-value ${roundnessK >= 0.7 ? 'warning' : ''}`}>
|
<span className={`cv-value ${isCircularCrossSection(roundnessK) ? 'warning' : ''}`}>
|
||||||
K = {roundnessK.toFixed(2)} ({roundnessPercent}%)
|
K = {roundnessK.toFixed(2)} ({roundnessPercent}%)
|
||||||
{roundnessK >= 0.7 && <span className="cv-flag"> ≥0.7</span>}
|
{isCircularCrossSection(roundnessK) && (
|
||||||
|
<span className="cv-flag"> >{DIMENSION_LIMITS.roundnessThreshold}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DIMENSION_LIMITS } from '../domain/classifier';
|
import { DIMENSION_LIMITS, OFFICIAL_RULE_LABELS } from '../domain/classifier';
|
||||||
import type { SimulationState } from '../domain/types';
|
import type { SimulationState } from '../domain/types';
|
||||||
|
|
||||||
function Verdict({ pass, skipped = false }: { pass?: boolean; skipped?: boolean }) {
|
function Verdict({ pass, skipped = false }: { pass?: boolean; skipped?: boolean }) {
|
||||||
@@ -28,12 +28,12 @@ export default function ClassificationPanel({ simulation }: { simulation: Simula
|
|||||||
<div className="rule-stack">
|
<div className="rule-stack">
|
||||||
<div className="rule-card">
|
<div className="rule-card">
|
||||||
<div><strong>Step 1: Dimensions check</strong><Verdict pass={result?.dimensionsPass} /></div>
|
<div><strong>Step 1: Dimensions check</strong><Verdict pass={result?.dimensionsPass} /></div>
|
||||||
<p>Allowed: W {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} mm, D {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} mm, H {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} mm.</p>
|
<p>Allowed: {OFFICIAL_RULE_LABELS.boundsSummary} (exclusive official bounds).</p>
|
||||||
<p>Actual: {dimensions ? `${dimensions.width} x ${dimensions.depth} x ${dimensions.height} mm` : 'waiting for item'}</p>
|
<p>Actual: {dimensions ? `${dimensions.width} x ${dimensions.depth} x ${dimensions.height} mm` : 'waiting for item'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rule-card">
|
<div className="rule-card">
|
||||||
<div><strong>Step 2: Roundness check</strong><Verdict pass={result?.roundnessPass} skipped={result ? !result.dimensionsPass : false} /></div>
|
<div><strong>Step 2: Roundness check</strong><Verdict pass={result?.roundnessPass} skipped={result ? !result.dimensionsPass : false} /></div>
|
||||||
<p>Roundness threshold for D: value >= {DIMENSION_LIMITS.roundnessThreshold}.</p>
|
<p>Roundness for D: {OFFICIAL_RULE_LABELS.roundnessDisplay} (threshold {DIMENSION_LIMITS.roundnessThreshold}).</p>
|
||||||
<p>Actual roundness: {item ? item.roundness.toFixed(2) : 'waiting for item'}</p>
|
<p>Actual roundness: {item ? item.roundness.toFixed(2) : 'waiting for item'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rule-card">
|
<div className="rule-card">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ScenarioId } from '../domain/types';
|
import type { ScenarioId } from '../domain/types';
|
||||||
|
import { OFFICIAL_RULE_LABELS } from '../domain/classifier';
|
||||||
|
|
||||||
interface CriteriaCard {
|
interface CriteriaCard {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,7 +30,7 @@ const PRODUCT_CRITERIA: CriteriaCard[] = [
|
|||||||
{
|
{
|
||||||
id: 'round-section',
|
id: 'round-section',
|
||||||
title: 'Круг в сечении',
|
title: 'Круг в сечении',
|
||||||
evidence: 'Roundness ≥ 0.7 направляет товар в зону D.',
|
evidence: `${OFFICIAL_RULE_LABELS.roundnessDisplay} направляет товар в зону D.`,
|
||||||
status: 'covered',
|
status: 'covered',
|
||||||
linkedScenario: 'round_object',
|
linkedScenario: 'round_object',
|
||||||
linkedLabel: 'Круглый объект',
|
linkedLabel: 'Круглый объект',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DIMENSION_LIMITS } from '../domain/classifier';
|
import { OFFICIAL_RULE_LABELS } from '../domain/classifier';
|
||||||
import { NOMINAL_CONVEYOR_SPEED_MPS } from '../domain/simulation';
|
import { NOMINAL_CONVEYOR_SPEED_MPS } from '../domain/simulation';
|
||||||
import type { SimulationState } from '../domain/types';
|
import type { SimulationState } from '../domain/types';
|
||||||
|
|
||||||
@@ -24,8 +24,7 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
|
|||||||
<h3>Готово к демонстрации</h3>
|
<h3>Готово к демонстрации</h3>
|
||||||
<p>Нажмите Start demo — товар пройдёт Detection → Classification → Command → Routing.</p>
|
<p>Нажмите Start demo — товар пройдёт Detection → Classification → Command → Routing.</p>
|
||||||
<p className="proof-limits">
|
<p className="proof-limits">
|
||||||
Limits: min {DIMENSION_LIMITS.min.width}×{DIMENSION_LIMITS.min.depth}×{DIMENSION_LIMITS.min.height} mm ·
|
Limits: {OFFICIAL_RULE_LABELS.boundsSummary} · {OFFICIAL_RULE_LABELS.roundnessDisplay} ·
|
||||||
max {DIMENSION_LIMITS.max.width}×{DIMENSION_LIMITS.max.depth}×{DIMENSION_LIMITS.max.height} mm ·
|
|
||||||
conveyor {NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)} m/s
|
conveyor {NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)} m/s
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +60,7 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
|
|||||||
: result?.dimensionsPass === false
|
: result?.dimensionsPass === false
|
||||||
? 'dimensions failed (oversized or undersized)'
|
? 'dimensions failed (oversized or undersized)'
|
||||||
: result?.roundnessPass === false
|
: result?.roundnessPass === false
|
||||||
? `dimensions pass, but K = ${item?.roundness.toFixed(2)} ≥ 0.7`
|
? `dimensions pass, but K = ${item?.roundness.toFixed(2)} > 0.8`
|
||||||
: 'dimensions pass and round section is not detected'}
|
: 'dimensions pass and round section is not detected'}
|
||||||
</em>
|
</em>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +81,7 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
|
|||||||
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : 'pass-text'}>
|
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : 'pass-text'}>
|
||||||
{item?.roundness.toFixed(2)}
|
{item?.roundness.toFixed(2)}
|
||||||
</strong>
|
</strong>
|
||||||
<em>{result?.roundnessPass ? 'PASS' : 'DETECTED (≥0.7)'}</em>
|
<em>{result?.roundnessPass ? 'PASS' : 'DETECTED (>0.8)'}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="proof-item">
|
<div className="proof-item">
|
||||||
<span className="proof-label">Category</span>
|
<span className="proof-label">Category</span>
|
||||||
@@ -100,9 +99,9 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
|
|||||||
|
|
||||||
<div className="proof-details">
|
<div className="proof-details">
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
<span>Min limits</span>
|
<span>Official limits</span>
|
||||||
<strong>
|
<strong>
|
||||||
{DIMENSION_LIMITS.min.width}×{DIMENSION_LIMITS.min.depth}×{DIMENSION_LIMITS.min.height} мм
|
{OFFICIAL_RULE_LABELS.minDisplay} · {OFFICIAL_RULE_LABELS.maxDisplay}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
@@ -118,7 +117,7 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
|
|||||||
<p className="warning-note">C-priority: негабарит + круглый → только C (габариты важнее формы)</p>
|
<p className="warning-note">C-priority: негабарит + круглый → только C (габариты важнее формы)</p>
|
||||||
) : null}
|
) : null}
|
||||||
{lowConfidence ? (
|
{lowConfidence ? (
|
||||||
<p className="warning-note">Низкая уверенность CV — rule-based fallback, класс только B/C/D</p>
|
<p className="warning-note">Низкая уверенность измерения — rule-based fallback, класс только B/C/D</p>
|
||||||
) : null}
|
) : null}
|
||||||
{simulation.machineState === 'FAULT' ? <p className="error-note">FAULT: конвейер остановлен</p> : null}
|
{simulation.machineState === 'FAULT' ? <p className="error-note">FAULT: конвейер остановлен</p> : null}
|
||||||
{simulation.machineState === 'EMERGENCY_STOP' ? (
|
{simulation.machineState === 'EMERGENCY_STOP' ? (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { OFFICIAL_RULE_LABELS } from '../domain/classifier';
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
onStartDemo: () => void;
|
onStartDemo: () => void;
|
||||||
onShowScenarios: () => void;
|
onShowScenarios: () => void;
|
||||||
@@ -17,14 +19,14 @@ export default function HeroSection({
|
|||||||
Интеллектуальная система предварительной сортировки товаров
|
Интеллектуальная система предварительной сортировки товаров
|
||||||
</h1>
|
</h1>
|
||||||
<p className="hero-subtitle">
|
<p className="hero-subtitle">
|
||||||
3D-симуляция ПАК показывает полный цикл: товар поступает из A, определяется CV-модулем, классифицируется в B/C/D и физически маршрутизируется исполнительной частью.
|
Цифровой стенд показывает полный цикл: тестовые измерения → rule-based классификация B/C/D → команда ROUTE_TO_* → маршрутизация в digital twin.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="hero-badges" aria-label="Параметры системы">
|
<div className="hero-badges" aria-label="Параметры системы">
|
||||||
<span className="hero-badge">Конвейер 1 м/с</span>
|
<span className="hero-badge">Конвейер 1 м/с</span>
|
||||||
<span className="hero-badge">Min 10×10×2 мм</span>
|
<span className="hero-badge">{OFFICIAL_RULE_LABELS.minDisplay}</span>
|
||||||
<span className="hero-badge">Max 450×320×320 мм</span>
|
<span className="hero-badge">{OFFICIAL_RULE_LABELS.maxDisplay}</span>
|
||||||
<span className="hero-badge">K ≥ 0.7 → D</span>
|
<span className="hero-badge">{OFFICIAL_RULE_LABELS.roundnessDisplay} → D</span>
|
||||||
<span className="hero-badge">C priority</span>
|
<span className="hero-badge">C priority</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import type { PlaylistCase } from '../../domain/demoPlaylist';
|
|||||||
import { classifyItem } from '../../domain/classifier';
|
import { classifyItem } from '../../domain/classifier';
|
||||||
import { getRenderedItemDimensions } from '../../domain/physicalLayout';
|
import { getRenderedItemDimensions } from '../../domain/physicalLayout';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import RealItemModel from './RealItemModel';
|
import RealItemModel, { isProductAssetReady } from './RealItemModel';
|
||||||
import { ItemVerificationOverlay } from './RealModelVerification';
|
import { ItemVerificationOverlay } from './RealModelVerification';
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
@@ -66,6 +66,7 @@ export function ItemVisualContent({
|
|||||||
isSettled,
|
isSettled,
|
||||||
castShadow = false,
|
castShadow = false,
|
||||||
verifySku = null,
|
verifySku = null,
|
||||||
|
onVisualReady,
|
||||||
}: {
|
}: {
|
||||||
caseData: PlaylistCase;
|
caseData: PlaylistCase;
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -73,6 +74,8 @@ export function ItemVisualContent({
|
|||||||
isSettled: boolean;
|
isSettled: boolean;
|
||||||
castShadow?: boolean;
|
castShadow?: boolean;
|
||||||
verifySku?: string | null;
|
verifySku?: string | null;
|
||||||
|
/** Fires once the visible mesh (real or procedural) is ready to show. */
|
||||||
|
onVisualReady?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]);
|
const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]);
|
||||||
const classification = useMemo(() => classifyItem(itemData), [itemData]);
|
const classification = useMemo(() => classifyItem(itemData), [itemData]);
|
||||||
@@ -106,6 +109,13 @@ export function ItemVisualContent({
|
|||||||
: dims.height;
|
: dims.height;
|
||||||
const pivotOffsetY = -modelHeightM / 2;
|
const pivotOffsetY = -modelHeightM / 2;
|
||||||
|
|
||||||
|
// Procedural / already-cached assets are ready immediately.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useReal || !asset?.runtimePath || isProductAssetReady(asset.runtimePath)) {
|
||||||
|
onVisualReady?.();
|
||||||
|
}
|
||||||
|
}, [useReal, asset?.runtimePath, caseData.id, onVisualReady]);
|
||||||
|
|
||||||
const fallback = (
|
const fallback = (
|
||||||
<FallbackPrimitive
|
<FallbackPrimitive
|
||||||
type={fallbackType}
|
type={fallbackType}
|
||||||
@@ -138,6 +148,7 @@ export function ItemVisualContent({
|
|||||||
}}
|
}}
|
||||||
castShadow={castShadow}
|
castShadow={castShadow}
|
||||||
fallback={fallback}
|
fallback={fallback}
|
||||||
|
onReady={onVisualReady}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* against the DOMAIN-decided receiver volume and the body is frozen
|
* against the DOMAIN-decided receiver volume and the body is frozen
|
||||||
* (kinematic) — no drift, clean replay, no teleportation at any point.
|
* (kinematic) — no drift, clean replay, no teleportation at any point.
|
||||||
*/
|
*/
|
||||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useFrame } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,8 @@ import { receiverContains } from '../../domain/receiverVolumes';
|
|||||||
import type { PlaylistCase } from '../../domain/demoPlaylist';
|
import type { PlaylistCase } from '../../domain/demoPlaylist';
|
||||||
import { ItemVisualContent } from './PhysicalPlaybackItem';
|
import { ItemVisualContent } from './PhysicalPlaybackItem';
|
||||||
import { recordDropResult, physicsSimClock } from './SorterPhysics';
|
import { recordDropResult, physicsSimClock } from './SorterPhysics';
|
||||||
|
import { getModelAsset } from '../../data/modelAssets';
|
||||||
|
import { isProductAssetReady } from './RealItemModel';
|
||||||
|
|
||||||
type Authority = 'kinematic' | 'dynamic' | 'frozen';
|
type Authority = 'kinematic' | 'dynamic' | 'frozen';
|
||||||
|
|
||||||
@@ -80,6 +82,14 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null);
|
const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null);
|
||||||
const handedOffAtSimSec = useRef<number | null>(null);
|
const handedOffAtSimSec = useRef<number | null>(null);
|
||||||
const verified = useRef(false);
|
const verified = useRef(false);
|
||||||
|
const asset = getModelAsset(itemId);
|
||||||
|
const needsRealAsset = Boolean(asset?.defaultRealAsset && asset?.runtimePath);
|
||||||
|
const [spawned, setSpawned] = useState(
|
||||||
|
() => !needsRealAsset || isProductAssetReady(asset?.runtimePath),
|
||||||
|
);
|
||||||
|
const onVisualReady = useCallback(() => {
|
||||||
|
setSpawned(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const pose = getPhysicalItemPose({
|
const pose = getPhysicalItemPose({
|
||||||
caseId: caseData.id,
|
caseId: caseData.id,
|
||||||
@@ -115,18 +125,38 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
frozenPose.current = null;
|
frozenPose.current = null;
|
||||||
handedOffAtSimSec.current = null;
|
handedOffAtSimSec.current = null;
|
||||||
verified.current = false;
|
verified.current = false;
|
||||||
|
const ready = !needsRealAsset || isProductAssetReady(asset?.runtimePath);
|
||||||
|
setSpawned(ready);
|
||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
if (body) {
|
if (body) {
|
||||||
body.setBodyType(RigidBodyType.KinematicPositionBased, false);
|
body.setBodyType(RigidBodyType.KinematicPositionBased, false);
|
||||||
body.setLinvel({ x: 0, y: 0, z: 0 }, false);
|
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
body.setAngvel({ x: 0, y: 0, z: 0 }, false);
|
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
|
const p = pose.position;
|
||||||
|
body.setTranslation({ x: p[0], y: p[1], z: p[2] }, true);
|
||||||
|
const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
|
||||||
|
const q = new THREE.Quaternion().setFromEuler(e);
|
||||||
|
body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset on case id only
|
||||||
}, [caseData.id]);
|
}, [caseData.id]);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|
||||||
|
// PREPARING: hold at spawn pose, zero velocity, keep invisible until visual ready.
|
||||||
|
if (!spawned) {
|
||||||
|
const p = pose.position;
|
||||||
|
body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] });
|
||||||
|
const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
|
||||||
|
const q = new THREE.Quaternion().setFromEuler(e);
|
||||||
|
body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w });
|
||||||
|
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
|
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (authority.current === 'kinematic') {
|
if (authority.current === 'kinematic') {
|
||||||
// Physics handoff at pusher contact / belt edge — never for fault cases.
|
// Physics handoff at pusher contact / belt edge — never for fault cases.
|
||||||
// MUST be checked BEFORE the kinematic drive: under render lag a single
|
// MUST be checked BEFORE the kinematic drive: under render lag a single
|
||||||
@@ -232,31 +262,35 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
enabledRotations={[true, true, true]}
|
enabledRotations={[true, true, true]}
|
||||||
position={pose.position}
|
position={pose.position}
|
||||||
>
|
>
|
||||||
{profile.collider === 'cuboid' && profile.cuboidHalfExtents && (
|
{/* Colliders only after visual ready — avoids stale/orphan contact. */}
|
||||||
|
{spawned && profile.collider === 'cuboid' && profile.cuboidHalfExtents && (
|
||||||
<CuboidCollider args={profile.cuboidHalfExtents} density={density} />
|
<CuboidCollider args={profile.cuboidHalfExtents} density={density} />
|
||||||
)}
|
)}
|
||||||
{profile.collider === 'capsule' && profile.capsule && (
|
{spawned && profile.collider === 'capsule' && profile.capsule && (
|
||||||
<CapsuleCollider
|
<CapsuleCollider
|
||||||
args={[profile.capsule[1], profile.capsule[0]]}
|
args={[profile.capsule[1], profile.capsule[0]]}
|
||||||
density={density}
|
density={density}
|
||||||
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{profile.collider === 'cylinder' && profile.capsule && (
|
{spawned && profile.collider === 'cylinder' && profile.capsule && (
|
||||||
<CylinderCollider
|
<CylinderCollider
|
||||||
args={[profile.capsule[1], profile.capsule[0]]}
|
args={[profile.capsule[1], profile.capsule[0]]}
|
||||||
density={density}
|
density={density}
|
||||||
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ItemVisualContent
|
<group visible={spawned}>
|
||||||
caseData={caseData}
|
<ItemVisualContent
|
||||||
phase={pose.phase}
|
caseData={caseData}
|
||||||
surface={pose.surface}
|
phase={pose.phase}
|
||||||
isSettled={authority.current === 'frozen' ? true : pose.isSettled}
|
surface={pose.surface}
|
||||||
castShadow={castShadow}
|
isSettled={authority.current === 'frozen' ? true : pose.isSettled}
|
||||||
verifySku={verifySku}
|
castShadow={castShadow && spawned}
|
||||||
/>
|
verifySku={verifySku}
|
||||||
|
onVisualReady={onVisualReady}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
</RigidBody>
|
</RigidBody>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,126 +1,162 @@
|
|||||||
/**
|
/**
|
||||||
* Stage 2D — SPEC_DERIVED_CAD angled paddle diverter.
|
* Junction rotary guide gates — two light-gray vanes at JUNCTION.
|
||||||
*
|
*
|
||||||
* Visual: public/models/sorter/mechanism-web.glb built from
|
* Invented sorter drive assets (mechanism-final / mechanism-mount motors,
|
||||||
* 3d_models/sorter_mechanism.step + tools/stage2d-mechanism (documented dims).
|
* servos, paddle) are not loaded. Each vane shares one kinematic RigidBody
|
||||||
* NOT author FreeCAD — see docs/stage2d/mechanism-cad-report.md.
|
* for visible mesh + collider (same pivot, position, quaternion).
|
||||||
*
|
|
||||||
* Physics: same kinematic cuboid as domain PUSHER (parity with headless).
|
|
||||||
*/
|
*/
|
||||||
import { memo, Suspense, useMemo, useRef } from 'react';
|
import { memo, useRef, type MutableRefObject } from 'react';
|
||||||
import { useFrame, useLoader } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import { Html } from '@react-three/drei';
|
||||||
import { RigidBody, CuboidCollider, type RapierRigidBody } from '@react-three/rapier';
|
import { RigidBody, CuboidCollider, type RapierRigidBody } from '@react-three/rapier';
|
||||||
import * as THREE from 'three';
|
import {
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
GATE_VANE,
|
||||||
import { getPusherState, PUSHER } from '../../domain/pusherMotion';
|
getGateState,
|
||||||
|
gateOpenLeadSec,
|
||||||
|
} from '../../domain/pusherMotion';
|
||||||
|
// GATE_VANE used for gateShot hold snapshot timing
|
||||||
|
import { JUNCTION } from '../../domain/physicalLayout';
|
||||||
|
import { getRoutingStartMs } from '../../domain/physicalItemMotion';
|
||||||
import { physicsSimClock } from './SorterPhysics';
|
import { physicsSimClock } from './SorterPhysics';
|
||||||
import type { Category } from '../../domain/types';
|
import type { Category } from '../../domain/types';
|
||||||
|
|
||||||
export const MECHANISM_CAD_URL = '/models/sorter/mechanism-web.glb';
|
export const MECHANISM_SOURCE_STATUS = 'JUNCTION_ROTARY_GATES' as const;
|
||||||
export const MECHANISM_SOURCE_STATUS = 'SPEC_DERIVED_CAD' as const;
|
export const MECHANISM_CAD_URL = '/models/sorter/mechanism-final.glb';
|
||||||
|
export const MECHANISM_MOUNT_URL = '/models/sorter/mechanism-mount-final.glb';
|
||||||
|
|
||||||
function MechanismCadMesh({ castShadow }: { castShadow: boolean }) {
|
const VANE_COLOR = '#e6ebf0';
|
||||||
const gltf = useLoader(GLTFLoader, MECHANISM_CAD_URL);
|
|
||||||
const root = useMemo(() => {
|
|
||||||
const scene = gltf.scene.clone(true);
|
|
||||||
const steel = new THREE.MeshStandardMaterial({
|
|
||||||
color: '#8a949e', metalness: 0.85, roughness: 0.35,
|
|
||||||
transparent: false, opacity: 1, depthWrite: true,
|
|
||||||
});
|
|
||||||
const dark = new THREE.MeshStandardMaterial({
|
|
||||||
color: '#3a4046', metalness: 0.9, roughness: 0.3,
|
|
||||||
transparent: false, opacity: 1, depthWrite: true,
|
|
||||||
});
|
|
||||||
const yellow = new THREE.MeshStandardMaterial({
|
|
||||||
color: '#f5c518', metalness: 0.25, roughness: 0.45,
|
|
||||||
transparent: false, opacity: 1, depthWrite: true,
|
|
||||||
});
|
|
||||||
scene.traverse((obj) => {
|
|
||||||
const mesh = obj as THREE.Mesh;
|
|
||||||
if (!mesh.isMesh) return;
|
|
||||||
const n = mesh.name || '';
|
|
||||||
if (n.includes('leading')) mesh.material = yellow;
|
|
||||||
else if (n.includes('servo') || n.includes('housing') || n.includes('rib')) mesh.material = dark;
|
|
||||||
else mesh.material = steel;
|
|
||||||
// Only the plate casts — keep shadow budget low
|
|
||||||
mesh.castShadow = castShadow && (n.includes('paddle') || n.includes('leading'));
|
|
||||||
mesh.receiveShadow = castShadow;
|
|
||||||
});
|
|
||||||
return scene;
|
|
||||||
}, [gltf, castShadow]);
|
|
||||||
return <primitive object={root} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** JSX fallback only if GLB fails to load — not the default. */
|
type YawRefs = { left: number; right: number };
|
||||||
function MechanismJsxFallback({ castShadow }: { castShadow: boolean }) {
|
|
||||||
const [hx, hy, hz] = PUSHER.halfExtents;
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh castShadow={castShadow} position={[0, 0, -hz - 0.002]}>
|
|
||||||
<boxGeometry args={[hx * 2, hy * 2, 0.004]} />
|
|
||||||
<meshStandardMaterial color="#f5c518" metalness={0.25} roughness={0.45} />
|
|
||||||
</mesh>
|
|
||||||
<mesh castShadow={castShadow}>
|
|
||||||
<boxGeometry args={[hx * 2, hy * 2, hz * 2]} />
|
|
||||||
<meshStandardMaterial color="#8a949e" metalness={0.85} roughness={0.35} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PusherMechanism = memo(function PusherMechanism({
|
function GateVaneBody({
|
||||||
category,
|
side,
|
||||||
routingElapsedMs,
|
yawRefs,
|
||||||
castShadow = false,
|
castShadow,
|
||||||
}: {
|
}: {
|
||||||
category: Category | null;
|
side: 'left' | 'right';
|
||||||
routingElapsedMs: number;
|
yawRefs: MutableRefObject<YawRefs>;
|
||||||
castShadow?: boolean;
|
castShadow: boolean;
|
||||||
}) {
|
}) {
|
||||||
const bodyRef = useRef<RapierRigidBody>(null);
|
const bodyRef = useRef<RapierRigidBody>(null);
|
||||||
const traceEnabled = useRef(
|
const pivot = side === 'left' ? JUNCTION.leftPivot : JUNCTION.rightPivot;
|
||||||
typeof window !== 'undefined'
|
const [hx, hy, hz] = GATE_VANE.halfExtents;
|
||||||
&& new URLSearchParams(window.location.search).get('trace') === '1',
|
|
||||||
);
|
|
||||||
const startSimSecRef = useRef<number | null>(null);
|
|
||||||
const route: 'B' | 'C' | 'D' | null =
|
|
||||||
category === 'C' || category === 'D' ? category : null;
|
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
if (route && routingElapsedMs > 0) {
|
const yawRad = side === 'left' ? yawRefs.current.left : yawRefs.current.right;
|
||||||
if (startSimSecRef.current == null) startSimSecRef.current = physicsSimClock.simSec;
|
const x = pivot.x + Math.cos(yawRad) * hx;
|
||||||
} else {
|
const y = GATE_VANE.centerY;
|
||||||
startSimSecRef.current = null;
|
const z = pivot.z + Math.sin(yawRad) * hx;
|
||||||
}
|
body.setNextKinematicTranslation({ x, y, z });
|
||||||
const elapsedSec = startSimSecRef.current != null
|
const half = yawRad / 2;
|
||||||
? physicsSimClock.simSec - startSimSecRef.current
|
|
||||||
: 0;
|
|
||||||
const state = getPusherState(route, elapsedSec);
|
|
||||||
body.setNextKinematicTranslation({ x: state.x, y: PUSHER.centerY, z: state.z });
|
|
||||||
if (traceEnabled.current) {
|
|
||||||
(window as unknown as { __PUSHER_STATE?: unknown }).__PUSHER_STATE = {
|
|
||||||
t: elapsedSec, phase: state.phase, x: state.x, z: state.z, simSec: physicsSimClock.simSec,
|
|
||||||
source: MECHANISM_SOURCE_STATUS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const half = state.yaw / 2;
|
|
||||||
body.setNextKinematicRotation({ x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) });
|
body.setNextKinematicRotation({ x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) });
|
||||||
});
|
});
|
||||||
|
|
||||||
const [hx, hy, hz] = PUSHER.halfExtents;
|
|
||||||
return (
|
return (
|
||||||
<RigidBody
|
<RigidBody
|
||||||
ref={bodyRef}
|
ref={bodyRef}
|
||||||
type="kinematicPosition"
|
type="kinematicPosition"
|
||||||
colliders={false}
|
colliders={false}
|
||||||
friction={0.15}
|
friction={0.35}
|
||||||
|
position={[pivot.x + hx, GATE_VANE.centerY, pivot.z]}
|
||||||
>
|
>
|
||||||
<CuboidCollider args={[hx, hy, hz]} />
|
<CuboidCollider args={[hx, hy, hz]} />
|
||||||
<Suspense fallback={<MechanismJsxFallback castShadow={castShadow} />}>
|
<mesh castShadow={castShadow} receiveShadow={castShadow}>
|
||||||
<MechanismCadMesh castShadow={castShadow} />
|
<boxGeometry args={[hx * 2, hy * 2, hz * 2]} />
|
||||||
</Suspense>
|
<meshStandardMaterial
|
||||||
|
color={VANE_COLOR}
|
||||||
|
metalness={0.35}
|
||||||
|
roughness={0.45}
|
||||||
|
transparent={false}
|
||||||
|
opacity={1}
|
||||||
|
depthWrite
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
</RigidBody>
|
</RigidBody>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invented sorter mount / motors — not rendered (GLBs not loaded). */
|
||||||
|
export const MechanismMount = memo(function MechanismMount(_props: {
|
||||||
|
castShadow?: boolean;
|
||||||
|
}) {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PusherMechanism = memo(function PusherMechanism({
|
||||||
|
category,
|
||||||
|
routingElapsedMs,
|
||||||
|
caseElapsedMs,
|
||||||
|
castShadow = false,
|
||||||
|
}: {
|
||||||
|
category: Category | null;
|
||||||
|
routingElapsedMs: number;
|
||||||
|
caseElapsedMs?: number;
|
||||||
|
castShadow?: boolean;
|
||||||
|
}) {
|
||||||
|
const yawRefs = useRef<YawRefs>({ left: 0, right: 0 });
|
||||||
|
const hudRef = useRef<HTMLDivElement>(null);
|
||||||
|
const routeCat: 'B' | 'C' | 'D' | null =
|
||||||
|
category === 'B' || category === 'C' || category === 'D' ? category : null;
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const routingStartMs = getRoutingStartMs();
|
||||||
|
const leadMs = gateOpenLeadSec() * 1000;
|
||||||
|
const openAtMs = routingStartMs - leadMs;
|
||||||
|
const elapsedCase = caseElapsedMs ?? routingElapsedMs + routingStartMs;
|
||||||
|
const gateTimelineSec = (elapsedCase - openAtMs) / 1000;
|
||||||
|
// ?gateShot=1 freezes the active divert pose at 45° for visual verification.
|
||||||
|
const gateShot = typeof window !== 'undefined'
|
||||||
|
&& new URLSearchParams(window.location.search).get('gateShot') === '1';
|
||||||
|
const state = gateShot && routeCat && routeCat !== 'B'
|
||||||
|
? getGateState(routeCat, GATE_VANE.swingSec + 0.05)
|
||||||
|
: getGateState(routeCat, gateTimelineSec);
|
||||||
|
yawRefs.current.left = state.leftYawRad;
|
||||||
|
yawRefs.current.right = state.rightYawRad;
|
||||||
|
|
||||||
|
if (hudRef.current) {
|
||||||
|
hudRef.current.textContent =
|
||||||
|
`L ${Math.round(state.leftDeg)}° / R ${Math.round(state.rightDeg)}°`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined'
|
||||||
|
&& new URLSearchParams(window.location.search).get('trace') === '1') {
|
||||||
|
(window as unknown as { __GATE_STATE?: unknown }).__GATE_STATE = {
|
||||||
|
mode: state.mode,
|
||||||
|
phase: state.phase,
|
||||||
|
leftDeg: state.leftDeg,
|
||||||
|
rightDeg: state.rightDeg,
|
||||||
|
junction: { x: JUNCTION.x, y: JUNCTION.y, z: JUNCTION.z },
|
||||||
|
simSec: physicsSimClock.simSec,
|
||||||
|
source: MECHANISM_SOURCE_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(window as unknown as { __GATE_ANGLES?: { left: number; right: number } }).__GATE_ANGLES = {
|
||||||
|
left: Math.round(state.leftDeg),
|
||||||
|
right: Math.round(state.rightDeg),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group name="junction-guide-gates">
|
||||||
|
<GateVaneBody side="left" yawRefs={yawRefs} castShadow={castShadow} />
|
||||||
|
<GateVaneBody side="right" yawRefs={yawRefs} castShadow={castShadow} />
|
||||||
|
<Html position={[JUNCTION.x, JUNCTION.y + 0.45, 0]} center style={{ pointerEvents: 'none' }}>
|
||||||
|
<div
|
||||||
|
ref={hudRef}
|
||||||
|
style={{
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 700,
|
||||||
|
textShadow: '0 1px 3px #000',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
L 0° / R 0°
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,56 @@ interface InnerProps {
|
|||||||
material: RealItemMaterial;
|
material: RealItemMaterial;
|
||||||
castShadow?: boolean;
|
castShadow?: boolean;
|
||||||
receiveShadow?: boolean;
|
receiveShadow?: boolean;
|
||||||
|
onReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyUrls = new Set<string>();
|
||||||
|
const inflight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
export function isProductAssetReady(runtimePath: string | null | undefined): boolean {
|
||||||
|
if (!runtimePath) return true;
|
||||||
|
return readyUrls.has(runtimePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markProductAssetReady(runtimePath: string): void {
|
||||||
|
readyUrls.add(runtimePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload a runtime STL into the shared loader cache and resolve when ready.
|
||||||
|
* Deduped by URL — concurrent callers share one Promise.
|
||||||
|
*/
|
||||||
|
export function preloadRealItemModelAsync(runtimePath: string): Promise<void> {
|
||||||
|
if (readyUrls.has(runtimePath)) return Promise.resolve();
|
||||||
|
const existing = inflight.get(runtimePath);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Warm R3F useLoader cache (deduped).
|
||||||
|
useLoader.preload(STLLoader, runtimePath);
|
||||||
|
|
||||||
|
const promise = new Promise<void>((resolve, reject) => {
|
||||||
|
const loader = new STLLoader();
|
||||||
|
loader.load(
|
||||||
|
runtimePath,
|
||||||
|
() => {
|
||||||
|
readyUrls.add(runtimePath);
|
||||||
|
inflight.delete(runtimePath);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(err) => {
|
||||||
|
inflight.delete(runtimePath);
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
inflight.set(runtimePath, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preload a runtime asset into the shared loader cache (deduped by URL). */
|
||||||
|
export function preloadRealItemModel(runtimePath: string): void {
|
||||||
|
void preloadRealItemModelAsync(runtimePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,10 +106,14 @@ export function normalizeGeometryClone(source: THREE.BufferGeometry, asset: Mode
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RealItemModelInner({ asset, material, castShadow, receiveShadow }: InnerProps) {
|
function RealItemModelInner({ asset, material, castShadow, receiveShadow, onReady }: InnerProps) {
|
||||||
const shared = useLoader(STLLoader, asset.runtimePath!) as THREE.BufferGeometry;
|
const shared = useLoader(STLLoader, asset.runtimePath!) as THREE.BufferGeometry;
|
||||||
const geometry = useMemo(() => normalizeGeometryClone(shared, asset), [shared, asset]);
|
const geometry = useMemo(() => normalizeGeometryClone(shared, asset), [shared, asset]);
|
||||||
useEffect(() => () => geometry.dispose(), [geometry]);
|
useEffect(() => () => geometry.dispose(), [geometry]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (asset.runtimePath) markProductAssetReady(asset.runtimePath);
|
||||||
|
onReady?.();
|
||||||
|
}, [asset.runtimePath, geometry, onReady]);
|
||||||
return (
|
return (
|
||||||
<mesh geometry={geometry} castShadow={castShadow} receiveShadow={receiveShadow}>
|
<mesh geometry={geometry} castShadow={castShadow} receiveShadow={receiveShadow}>
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
@@ -103,25 +157,37 @@ class ItemModelErrorBoundary extends Component<BoundaryProps, BoundaryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RealItemModelProps extends InnerProps {
|
export interface RealItemModelProps extends InnerProps {
|
||||||
/** Procedural fallback node (load failure AND suspense placeholder). */
|
/**
|
||||||
|
* Procedural fallback for load failure only.
|
||||||
|
* Suspense placeholder stays invisible so spawn is atomic (no flash-then-swap).
|
||||||
|
*/
|
||||||
fallback: ReactNode;
|
fallback: ReactNode;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
|
/** When true, Suspense shows fallback (legacy). Default: invisible placeholder. */
|
||||||
|
showSuspenseFallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RealItemModel({ fallback, ...inner }: RealItemModelProps) {
|
export default function RealItemModel({
|
||||||
|
fallback,
|
||||||
|
showSuspenseFallback = false,
|
||||||
|
onReady,
|
||||||
|
onError,
|
||||||
|
...inner
|
||||||
|
}: RealItemModelProps) {
|
||||||
if (!inner.asset.runtimePath) {
|
if (!inner.asset.runtimePath) {
|
||||||
return <>{fallback}</>;
|
return <>{fallback}</>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ItemModelErrorBoundary fallback={fallback} onError={inner.onError}>
|
<ItemModelErrorBoundary
|
||||||
<Suspense fallback={fallback}>
|
fallback={fallback}
|
||||||
<RealItemModelInner {...inner} />
|
onError={(err) => {
|
||||||
|
onReady?.();
|
||||||
|
onError?.(err);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Suspense fallback={showSuspenseFallback ? fallback : null}>
|
||||||
|
<RealItemModelInner {...inner} onReady={onReady} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ItemModelErrorBoundary>
|
</ItemModelErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preload a runtime asset into the shared loader cache (deduped by URL). */
|
|
||||||
export function preloadRealItemModel(runtimePath: string): void {
|
|
||||||
useLoader.preload(STLLoader, runtimePath);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Html } from '@react-three/drei';
|
import { Html } from '@react-three/drei';
|
||||||
import type { Category, MachineState, SimulatedItem } from '../../domain/types';
|
import type { Category, MachineState, SimulatedItem } from '../../domain/types';
|
||||||
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
|
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
|
||||||
import { DIMENSION_LIMITS } from '../../domain/classifier';
|
import { OFFICIAL_RULE_LABELS } from '../../domain/classifier';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
machineState: MachineState;
|
machineState: MachineState;
|
||||||
@@ -195,7 +195,7 @@ export default function SceneLabels3D({
|
|||||||
{/* Conveyor info */}
|
{/* Conveyor info */}
|
||||||
<Badge
|
<Badge
|
||||||
position={[-3.2, beltY + 0.85, 1.4]}
|
position={[-3.2, beltY + 0.85, 1.4]}
|
||||||
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
|
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · ${OFFICIAL_RULE_LABELS.minDisplay}`}
|
||||||
color="#91a4b8"
|
color="#91a4b8"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ export const DEMO_STEPS: DemoStep[] = [
|
|||||||
scenarioId: 'round_object',
|
scenarioId: 'round_object',
|
||||||
preferredAction: 'step',
|
preferredAction: 'step',
|
||||||
focusArea: 'scene',
|
focusArea: 'scene',
|
||||||
explanation: 'Roundness >= 0.7 after valid dimensions sends the item to D for repack/shape issue handling.',
|
explanation: 'Roundness K > 0.8 after valid dimensions sends the item to D for repack/shape issue handling.',
|
||||||
whatToWatch: 'Roundness FAIL for B, category D and the upper route/pusher D command.',
|
whatToWatch: 'Roundness FAIL for B, category D and the upper route/pusher D command.',
|
||||||
juryValue: 'Shows how the circle-section rule becomes a physical route.',
|
juryValue: 'Shows how the circle-section rule becomes a physical route.',
|
||||||
relatedCriteria: ['classification-rules', 'geometry-variety', 'physical-routing'],
|
relatedCriteria: ['classification-rules', 'geometry-variety', 'physical-routing'],
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export const ITEMS: Item[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'SKU-010',
|
id: 'SKU-010',
|
||||||
name: 'Boundary box 450x320x320',
|
name: 'Near-max box 449x319x319',
|
||||||
dimensionsMm: { width: 450, depth: 320, height: 320 },
|
dimensionsMm: { width: 449, depth: 319, height: 319 },
|
||||||
roundness: 0.2,
|
roundness: 0.2,
|
||||||
confidence: 0.9,
|
confidence: 0.9,
|
||||||
shape: 'boundary box',
|
shape: 'boundary box',
|
||||||
|
|||||||
70
src/data/productionStatusSummary.ts
Normal file
70
src/data/productionStatusSummary.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Compact tracked production-status summary for the Documentation page.
|
||||||
|
* Sources: Gate 2C / 2D confirmed decisions. Unverified values are NOT stated as facts.
|
||||||
|
* Does not import ignored tools/author-exact-v4/out/ at runtime.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PRODUCTION_STATUS = {
|
||||||
|
acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY',
|
||||||
|
electricalGateStatus: 'BLOCKED_BY_MISSING_PRODUCTION_INPUTS',
|
||||||
|
productionDesignReady: false,
|
||||||
|
motorSelected: false,
|
||||||
|
encoderSelected: false,
|
||||||
|
diverterApproved: false,
|
||||||
|
cadReady: false,
|
||||||
|
gate3Started: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Confirmed layout facts only (drawing / shared provenance). */
|
||||||
|
export const CONFIRMED_LAYOUT = {
|
||||||
|
workspaceMm: { length: 10000, width: 6000 },
|
||||||
|
conveyorWidthMm: 500,
|
||||||
|
conveyorHeightMm: 700,
|
||||||
|
pointACenterlineFromWorkspaceBottomMm: 3000,
|
||||||
|
authorDiverter: 'ABSENT' as const,
|
||||||
|
link027029Role: 'STOP_GATE' as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CONFIRMED_COMPONENT_POLICY = {
|
||||||
|
mg996r: 'PROTOTYPE_ONLY' as const,
|
||||||
|
vl53l0x: 'NOT_PRODUCTION_OR_SAFETY' as const,
|
||||||
|
productionMotor: 'NOT_DEFINED' as const,
|
||||||
|
productionGearbox: 'NOT_DEFINED' as const,
|
||||||
|
productionDriver: 'NOT_DEFINED' as const,
|
||||||
|
productionEncoder: 'NOT_SELECTED' as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ROLLER_DIAMETER_CONFLICT = {
|
||||||
|
authorCadIdlerMm: 50,
|
||||||
|
runtimeIdlerMm: 80,
|
||||||
|
runtimeDriveMm: 120,
|
||||||
|
status: 'CONFLICTING' as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CURRENT_BLOCKERS = [
|
||||||
|
'Отсутствуют производственные параметры двигателя',
|
||||||
|
'Энкодер не выбран',
|
||||||
|
'Конфликт диаметров роликов (CAD idler 50 / runtime idler 80 / runtime drive 120)',
|
||||||
|
'ROOT_BIND не подтверждён',
|
||||||
|
'Не зафиксирована геометрия junction',
|
||||||
|
'Отсутствуют товарные нагрузки (масса, COM, трение)',
|
||||||
|
'Не завершена силовая архитектура',
|
||||||
|
'Не завершена safety-архитектура',
|
||||||
|
'Производственный diverter ещё не спроектирован',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const GATE_STATUS_BOARD = [
|
||||||
|
{ gate: '0 BASELINE', status: 'PASS' },
|
||||||
|
{ gate: '1 AUTHOR STRUCTURE', status: 'PASS' },
|
||||||
|
{ gate: '2 KINEMATICS', status: 'BLOCKED' },
|
||||||
|
{ gate: '2A source discovery', status: 'NO_AUTHOR_DIVERTER_SOURCE' },
|
||||||
|
{ gate: '2B engineering spec', status: 'ARCHITECTURE_RESELECTION_REQUIRED' },
|
||||||
|
{ gate: '2C production electrical', status: 'BLOCKED_BY_MISSING_PRODUCTION_INPUTS' },
|
||||||
|
{ gate: '2D data acquisition pack', status: 'DATA_ACQUISITION_PACK_READY' },
|
||||||
|
{ gate: '3 AUTHOR_EXACT / production CAD', status: 'NOT_STARTED' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DATA_ACQUISITION_SEQUENCE = ['MEASURE', 'VERIFY', 'FREEZE', 'DESIGN'] as const;
|
||||||
|
|
||||||
|
export const OWNER_INPUT_FORM_NOTE =
|
||||||
|
'Главный документ входных данных владельца: OWNER_PRODUCTION_INPUT_FORM.md (пакет Gate 2D, вне публичного runtime).';
|
||||||
@@ -23,7 +23,7 @@ export const SCENARIOS: Scenario[] = [
|
|||||||
{
|
{
|
||||||
id: 'round_object',
|
id: 'round_object',
|
||||||
name: 'Round object',
|
name: 'Round object',
|
||||||
description: 'Габариты проходят, но roundness >= 0.7, маршрут в D.',
|
description: 'Габариты проходят, но roundness K > 0.8, маршрут в D.',
|
||||||
goal: 'Показать проверку круглого сечения после габаритов.',
|
goal: 'Показать проверку круглого сечения после габаритов.',
|
||||||
expectedCategorySummary: 'D for every item',
|
expectedCategorySummary: 'D for every item',
|
||||||
demonstrates: 'Rule-based shape issue routing без реального ML на MVP-этапе.',
|
demonstrates: 'Rule-based shape issue routing без реального ML на MVP-этапе.',
|
||||||
@@ -43,8 +43,8 @@ export const SCENARIOS: Scenario[] = [
|
|||||||
name: 'Boundary dimensions',
|
name: 'Boundary dimensions',
|
||||||
description: 'Товары около min/max границ показывают устойчивость правил.',
|
description: 'Товары около min/max границ показывают устойчивость правил.',
|
||||||
goal: 'Проверить строгие границы min/max.',
|
goal: 'Проверить строгие границы min/max.',
|
||||||
expectedCategorySummary: 'Boundary box -> B, Pen -> C',
|
expectedCategorySummary: 'Near-max 449×319×319 -> B, Pen -> C',
|
||||||
demonstrates: '450 x 320 x 320 проходит, width 9 мм не проходит min width.',
|
demonstrates: 'Строгие границы: 449×319×319 проходит, 450×320×320 и width 9 мм — нет.',
|
||||||
items: ['SKU-010', 'SKU-009', 'SKU-002'].map(getItem),
|
items: ['SKU-010', 'SKU-009', 'SKU-002'].map(getItem),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ const VIEWPORT_ADJUSTMENTS: Record<ViewportType, { heightMult: number; distMult:
|
|||||||
* Positions are relative to the scene center or specific zones.
|
* Positions are relative to the scene center or specific zones.
|
||||||
*/
|
*/
|
||||||
const BASE_CAMERA_CONFIGS: Record<CameraMode, Omit<CameraConfig, 'mode'>> = {
|
const BASE_CAMERA_CONFIGS: Record<CameraMode, Omit<CameraConfig, 'mode'>> = {
|
||||||
|
// Full product line: 3 CAD modules + close-in B/C/D baskets.
|
||||||
overview: {
|
overview: {
|
||||||
position: [4.3, 2.9, 4.6],
|
position: [0.35, 2.35, 5.1],
|
||||||
target: [0.1, BELT_TOP_Y, 0],
|
target: [-0.55, 0.55, 0.05],
|
||||||
fov: 46,
|
fov: 36,
|
||||||
},
|
},
|
||||||
feedCloseup: {
|
feedCloseup: {
|
||||||
position: [ZONES.A.x + 1.5, 2.0, 2.0],
|
position: [ZONES.A.x + 1.5, 2.0, 2.0],
|
||||||
@@ -79,9 +80,9 @@ const BASE_CAMERA_CONFIGS: Record<CameraMode, Omit<CameraConfig, 'mode'>> = {
|
|||||||
fov: 45,
|
fov: 45,
|
||||||
},
|
},
|
||||||
routingWide: {
|
routingWide: {
|
||||||
position: [2.5, 3.0, 4.5],
|
position: [2.2, 2.4, 4.0],
|
||||||
target: [ZONES.GATE.x, BELT_TOP_Y, 0],
|
target: [ZONES.GATE.x, BELT_TOP_Y, 0.15],
|
||||||
fov: 55,
|
fov: 48,
|
||||||
},
|
},
|
||||||
chuteCloseup: {
|
chuteCloseup: {
|
||||||
position: [ZONES.GATE.x + 0.5, 1.8, 2.8],
|
position: [ZONES.GATE.x + 0.5, 1.8, 2.8],
|
||||||
@@ -94,9 +95,9 @@ const BASE_CAMERA_CONFIGS: Record<CameraMode, Omit<CameraConfig, 'mode'>> = {
|
|||||||
fov: 52,
|
fov: 52,
|
||||||
},
|
},
|
||||||
nextItemReset: {
|
nextItemReset: {
|
||||||
position: [4.0, 3.0, 4.5],
|
position: [0.35, 2.35, 5.1],
|
||||||
target: [0, BELT_TOP_Y, 0],
|
target: [-0.55, 0.55, 0.05],
|
||||||
fov: 50,
|
fov: 36,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,10 +267,13 @@ export function smoothCameraTransition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get initial camera config (overview).
|
* Get initial camera config (product overview framing).
|
||||||
*/
|
*/
|
||||||
export function getInitialCameraConfig(viewport: ViewportType = 'desktop'): CameraConfig {
|
export function getInitialCameraConfig(viewport: ViewportType = 'desktop'): CameraConfig {
|
||||||
return getCameraConfig('spawn', null, null, viewport);
|
return adjustForViewport(
|
||||||
|
{ ...BASE_CAMERA_CONFIGS.overview, mode: 'overview' },
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { classifyItem, DIMENSION_LIMITS } from './classifier';
|
import {
|
||||||
import { getItem } from '../data/items';
|
classifyItem,
|
||||||
|
DIMENSION_LIMITS,
|
||||||
|
OFFICIAL_RULE_LABELS,
|
||||||
|
dimensionsPassOfficial,
|
||||||
|
isCircularCrossSection,
|
||||||
|
} from './classifier';
|
||||||
|
import { getItem, ITEMS } from '../data/items';
|
||||||
|
import { DEMO_PLAYLIST } from './demoPlaylist';
|
||||||
|
import { resolveItem } from '../data/resolveItem';
|
||||||
import type { Category, Item } from './types';
|
import type { Category, Item } from './types';
|
||||||
|
|
||||||
const VALID_CATEGORIES: Category[] = ['B', 'C', 'D'];
|
const VALID_CATEGORIES: Category[] = ['B', 'D', 'C'];
|
||||||
|
|
||||||
|
function itemWith(
|
||||||
|
dimensionsMm: Item['dimensionsMm'],
|
||||||
|
roundness: number,
|
||||||
|
confidence = 0.9,
|
||||||
|
): Item {
|
||||||
|
return {
|
||||||
|
...getItem('SKU-001'),
|
||||||
|
id: 'TEST-BOUNDARY',
|
||||||
|
dimensionsMm,
|
||||||
|
roundness,
|
||||||
|
confidence,
|
||||||
|
expectedCategory: 'B',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('classifyItem', () => {
|
describe('classifyItem', () => {
|
||||||
it('uses min dimensions 10×10×2 mm', () => {
|
it('uses official Track 3 constants (exclusive bounds, K > 0.8)', () => {
|
||||||
expect(DIMENSION_LIMITS.min).toEqual({ width: 10, depth: 10, height: 2 });
|
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.max).toEqual({ width: 450, depth: 320, height: 320 });
|
||||||
expect(DIMENSION_LIMITS.roundnessThreshold).toBe(0.7);
|
expect(DIMENSION_LIMITS.roundnessThreshold).toBe(0.8);
|
||||||
|
expect(OFFICIAL_RULE_LABELS.minDisplay).toContain('10×10×10');
|
||||||
|
expect(OFFICIAL_RULE_LABELS.roundnessDisplay).toBe('K > 0.8');
|
||||||
|
expect(JSON.stringify(DIMENSION_LIMITS)).not.toContain('"height":2');
|
||||||
|
expect(JSON.stringify(DIMENSION_LIMITS)).not.toMatch(/0\.7/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes normal box to B', () => {
|
it('routes normal box to B', () => {
|
||||||
@@ -24,7 +51,7 @@ describe('classifyItem', () => {
|
|||||||
expect(classifyItem(getItem('SKU-006')).category).toBe('D');
|
expect(classifyItem(getItem('SKU-006')).category).toBe('D');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes pen with width below 10 mm to C', () => {
|
it('routes pen with width below min to C', () => {
|
||||||
expect(classifyItem(getItem('SKU-009')).category).toBe('C');
|
expect(classifyItem(getItem('SKU-009')).category).toBe('C');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,18 +61,21 @@ describe('classifyItem', () => {
|
|||||||
expect(result.category).toBe('C');
|
expect(result.category).toBe('C');
|
||||||
expect(result.dimensionsPass).toBe(false);
|
expect(result.dimensionsPass).toBe(false);
|
||||||
expect(result.roundnessPass).toBe(false);
|
expect(result.roundnessPass).toBe(false);
|
||||||
|
expect(isCircularCrossSection(getItem('SKU-011').roundness)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts boundary item 450x320x320 as B', () => {
|
it('rejects exclusive-max boundary 450×320×320 as C', () => {
|
||||||
|
const item = itemWith({ width: 450, depth: 320, height: 320 }, 0.2);
|
||||||
|
expect(classifyItem(item).category).toBe('C');
|
||||||
|
expect(dimensionsPassOfficial(item.dimensionsMm)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts near-max 449×319×319 as B when not round', () => {
|
||||||
expect(classifyItem(getItem('SKU-010')).category).toBe('B');
|
expect(classifyItem(getItem('SKU-010')).category).toBe('B');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects height below 2 mm as C', () => {
|
it('rejects height at min boundary 10 mm as C', () => {
|
||||||
const item: Item = {
|
const item = itemWith({ width: 100, depth: 100, height: 10 }, 0.5);
|
||||||
...getItem('SKU-001'),
|
|
||||||
id: 'TEST-LOW-HEIGHT',
|
|
||||||
dimensionsMm: { width: 100, depth: 100, height: 1 },
|
|
||||||
};
|
|
||||||
expect(classifyItem(item).category).toBe('C');
|
expect(classifyItem(item).category).toBe('C');
|
||||||
expect(classifyItem(item).dimensionsPass).toBe(false);
|
expect(classifyItem(item).dimensionsPass).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -61,8 +91,127 @@ describe('classifyItem', () => {
|
|||||||
for (const item of lowConfidenceItems) {
|
for (const item of lowConfidenceItems) {
|
||||||
const result = classifyItem(item);
|
const result = classifyItem(item);
|
||||||
expect(VALID_CATEGORIES).toContain(result.category);
|
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);
|
expect(result.warnings.some((warning) => warning.toLowerCase().includes('confidence'))).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('official Track 3 classification boundaries', () => {
|
||||||
|
it('1. 11×11×11, K=0.50 → B', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 11, depth: 11, height: 11 }, 0.5)).category).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2. 9×20×20, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 9, depth: 20, height: 20 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3. 20×9×20, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 20, depth: 9, height: 20 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4. 20×20×9, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 20, depth: 20, height: 9 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5. 10×20×20, K=0.50 → C (min exclusive)', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 10, depth: 20, height: 20 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('6. 20×10×20, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 20, depth: 10, height: 20 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('7. 20×20×10, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 20, depth: 20, height: 10 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('8. 451×100×100, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 451, depth: 100, height: 100 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('9. 100×321×100, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 321, height: 100 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10. 100×100×321, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 321 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('11. 450×100×100, K=0.50 → C (max exclusive)', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 450, depth: 100, height: 100 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('12. 100×320×100, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 320, height: 100 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('13. 100×100×320, K=0.50 → C', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 320 }, 0.5)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('14. 449×319×319, K=0.50 → B', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 449, depth: 319, height: 319 }, 0.5)).category).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('15. admissible dims, K=0.79 → B', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.79)).category).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('16. admissible dims, K=0.80 → B (not round)', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.8)).category).toBe('B');
|
||||||
|
expect(isCircularCrossSection(0.8)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('17. admissible dims, K=0.8001 → D', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.8001)).category).toBe('D');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('18. admissible dims, K=0.95 → D', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.95)).category).toBe('D');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19. oversized + K=0.95 → C (priority)', () => {
|
||||||
|
expect(classifyItem(itemWith({ width: 500, depth: 100, height: 100 }, 0.95)).category).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20. low confidence does not cancel C-priority', () => {
|
||||||
|
const result = classifyItem({
|
||||||
|
...itemWith({ width: 500, depth: 300, height: 300 }, 0.95, 0.4),
|
||||||
|
id: 'LC-C-PRIO',
|
||||||
|
});
|
||||||
|
expect(result.category).toBe('C');
|
||||||
|
expect(result.warnings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('21–22. every demo SKU matches live classifier and ROUTE_TO_*', () => {
|
||||||
|
for (const playlistCase of DEMO_PLAYLIST.filter((c) => !c.faultType)) {
|
||||||
|
const item = resolveItem(playlistCase.itemId);
|
||||||
|
const result = classifyItem(item);
|
||||||
|
expect(result.category).toBe(playlistCase.expectedCategory);
|
||||||
|
expect(`ROUTE_TO_${result.category}`).toBe(`ROUTE_TO_${playlistCase.expectedCategory}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('23. UI rule labels expose official exclusive values', () => {
|
||||||
|
expect(OFFICIAL_RULE_LABELS.minDisplay).toBe('> 10×10×10 мм');
|
||||||
|
expect(OFFICIAL_RULE_LABELS.maxDisplay).toBe('< 450×320×320 мм');
|
||||||
|
expect(OFFICIAL_RULE_LABELS.roundnessDisplay).toBe('K > 0.8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('24. serialized settings do not contain 2 mm min height or 0.7 threshold', () => {
|
||||||
|
const serialized = JSON.stringify({
|
||||||
|
limits: DIMENSION_LIMITS,
|
||||||
|
labels: OFFICIAL_RULE_LABELS,
|
||||||
|
});
|
||||||
|
expect(serialized).not.toMatch(/10×10×2|10 x 10 x 2|height":2[^0-9]/);
|
||||||
|
expect(serialized).not.toMatch(/roundnessThreshold":0\.7|"0\.7"/);
|
||||||
|
expect(serialized).toContain('0.8');
|
||||||
|
expect(serialized).toContain('"height":10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catalog expectedCategory matches classifier for every SKU', () => {
|
||||||
|
for (const item of ITEMS) {
|
||||||
|
expect(classifyItem(item).category).toBe(item.expectedCategory);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,30 +15,59 @@ export const CATEGORY_DEFINITIONS: Record<Category, CategoryDefinition> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official Track 3 sorter limits (doc-1783095831 pp.5–7).
|
||||||
|
* Bounds are exclusive: dimensions must be strictly greater than min
|
||||||
|
* and strictly less than max («больше» / «меньше»).
|
||||||
|
* Round cross-section when K > roundnessThreshold (K = 0.8 is NOT round).
|
||||||
|
*/
|
||||||
export const DIMENSION_LIMITS = {
|
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 },
|
max: { width: 450, depth: 320, height: 320 },
|
||||||
roundnessThreshold: 0.7,
|
roundnessThreshold: 0.8,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
/** Public-facing labels for UI / demo / docs. */
|
||||||
|
export const OFFICIAL_RULE_LABELS = {
|
||||||
|
minDisplay: '> 10×10×10 мм',
|
||||||
|
maxDisplay: '< 450×320×320 мм',
|
||||||
|
roundnessDisplay: 'K > 0.8',
|
||||||
|
boundsSummary: '> 10×10×10 и < 450×320×320 мм',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function dimensionsPassOfficial(dimensionsMm: {
|
||||||
|
width: number;
|
||||||
|
depth: number;
|
||||||
|
height: number;
|
||||||
|
}): boolean {
|
||||||
|
const { width, depth, height } = dimensionsMm;
|
||||||
|
return (
|
||||||
|
width > DIMENSION_LIMITS.min.width &&
|
||||||
|
depth > DIMENSION_LIMITS.min.depth &&
|
||||||
|
height > DIMENSION_LIMITS.min.height &&
|
||||||
|
width < DIMENSION_LIMITS.max.width &&
|
||||||
|
depth < DIMENSION_LIMITS.max.depth &&
|
||||||
|
height < DIMENSION_LIMITS.max.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Official figure: circular cross-section iff K > 0.8. */
|
||||||
|
export function isCircularCrossSection(roundnessK: number): boolean {
|
||||||
|
return roundnessK > DIMENSION_LIMITS.roundnessThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
export function classifyItem(item: Item): ClassificationResult {
|
export function classifyItem(item: Item): ClassificationResult {
|
||||||
const { width, depth, height } = item.dimensionsMm;
|
const dimensionsPass = dimensionsPassOfficial(item.dimensionsMm);
|
||||||
const dimensionsPass =
|
// roundnessPass = shape OK for category B (not circular)
|
||||||
width >= DIMENSION_LIMITS.min.width &&
|
const roundnessPass = !isCircularCrossSection(item.roundness);
|
||||||
depth >= DIMENSION_LIMITS.min.depth &&
|
|
||||||
height >= DIMENSION_LIMITS.min.height &&
|
|
||||||
width <= DIMENSION_LIMITS.max.width &&
|
|
||||||
depth <= DIMENSION_LIMITS.max.depth &&
|
|
||||||
height <= DIMENSION_LIMITS.max.height;
|
|
||||||
|
|
||||||
const roundnessPass = item.roundness < DIMENSION_LIMITS.roundnessThreshold;
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
if (item.confidence < 0.65) {
|
if (item.confidence < 0.65) {
|
||||||
warnings.push('CV confidence ниже 0.65, решение принято rule-based способом');
|
warnings.push('Measurement confidence ниже 0.65, решение принято rule-based способом');
|
||||||
}
|
}
|
||||||
|
|
||||||
const category: Category = !dimensionsPass ? 'C' : roundnessPass ? 'B' : 'D';
|
// Order: dimensions → C; else circular → D; else B. C priority over D.
|
||||||
|
const category: Category = !dimensionsPass ? 'C' : isCircularCrossSection(item.roundness) ? 'D' : 'B';
|
||||||
const definition = CATEGORY_DEFINITIONS[category];
|
const definition = CATEGORY_DEFINITIONS[category];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
SCAN_START_X, SCAN_END_X, CLASSIFICATION_DEADLINE_X, MECHANISM_CONTACT_X,
|
SCAN_START_X, SCAN_END_X, CLASSIFICATION_DEADLINE_X, MECHANISM_CONTACT_X,
|
||||||
getScanProgress, getScanWindow,
|
getScanProgress, getScanWindow,
|
||||||
} from './measurementZone';
|
} from './measurementZone';
|
||||||
import { ZONES, CONVEYOR_SPEED_MPS, BELT_TOP_Y } from './physicalLayout';
|
import { ZONES, CONVEYOR_SPEED_MPS, BELT_TOP_Y, CAD_GATE_ENGAGE_X } from './physicalLayout';
|
||||||
import { resolveItem } from '../data/resolveItem';
|
import { resolveItem } from '../data/resolveItem';
|
||||||
import { initRapier, simulateDrop } from './physicsDropSim';
|
import { initRapier, simulateDrop } from './physicsDropSim';
|
||||||
|
|
||||||
@@ -111,31 +111,36 @@ describe('Stage 2B §11 — classification completes before mechanism contact',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Stage 2B §13 — handoff not before mechanism contact', () => {
|
describe('Stage 2B §13 — handoff at CAD diverter engage (not chute teleport)', () => {
|
||||||
beforeAll(async () => { await initRapier(); });
|
beforeAll(async () => { await initRapier(); });
|
||||||
|
|
||||||
it('C/D handoff happens at the junction (belt center), never pre-positioned on the chute', () => {
|
it('C/D handoff is on the belt center at CAD_GATE_ENGAGE_X, before domain GATE spur', () => {
|
||||||
const routingStart = getRoutingStartMs();
|
const routingStart = getRoutingStartMs();
|
||||||
for (const category of ['C', 'D'] as const) {
|
for (const category of ['C', 'D'] as const) {
|
||||||
expect(getDropHandoffTimeMs(category, undefined)).toBe(routingStart);
|
const handoff = getDropHandoffTimeMs(category, undefined);
|
||||||
const pose = poseAt('SKU-001', category, getDropHandoffTimeMs(category, undefined)!);
|
expect(handoff).not.toBeNull();
|
||||||
|
// CAD vane engage is upstream of the B-spur / domain routing start.
|
||||||
|
expect(handoff!).toBeLessThan(routingStart);
|
||||||
|
const pose = poseAt('SKU-001', category, handoff!);
|
||||||
expect(Math.abs(pose.position[2])).toBeLessThan(0.01); // belt center z=0
|
expect(Math.abs(pose.position[2])).toBeLessThan(0.01); // belt center z=0
|
||||||
expect(pose.position[0]).toBeCloseTo(ZONES.GATE.x, 5);
|
expect(pose.position[0]).toBeCloseTo(CAD_GATE_ENGAGE_X, 3);
|
||||||
expect(pose.position[1]).toBeCloseTo(BELT_TOP_Y + 0.1, 5); // box h/2 = 0.1
|
expect(pose.position[1]).toBeCloseTo(BELT_TOP_Y + 0.1, 5); // box h/2 = 0.1
|
||||||
|
expect(pose.position[0]).toBeLessThan(ZONES.GATE.x);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('paddle collider actually contacts the item collider on every C/D route', () => {
|
it('C/D headless drop stays deterministic without floor tunneling (CAD rotary gate)', () => {
|
||||||
for (const [sku, zone] of [['SKU-004', 'C'], ['SKU-009', 'C'], ['SKU-011', 'C'], ['SKU-006', 'D'], ['SKU-007', 'D'], ['SKU-008', 'D']] as const) {
|
for (const [sku, zone] of [['SKU-004', 'C'], ['SKU-009', 'C'], ['SKU-011', 'C'], ['SKU-006', 'D'], ['SKU-007', 'D'], ['SKU-008', 'D']] as const) {
|
||||||
const r = simulateDrop(sku, zone);
|
const r = simulateDrop(sku, zone);
|
||||||
expect(r.pusherContactMade).toBe(true);
|
expect(r.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
|
expect(r.stepsSimulated).toBeGreaterThan(10);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no positional teleport: handoff pose is continuous with the pre-handoff kinematic pose', () => {
|
it('no positional teleport: handoff pose is continuous with the pre-handoff kinematic pose', () => {
|
||||||
const routingStart = getRoutingStartMs();
|
const handoff = getDropHandoffTimeMs('C', undefined)!;
|
||||||
const before = poseAt('SKU-001', 'C', routingStart - 1);
|
const before = poseAt('SKU-001', 'C', handoff - 1);
|
||||||
const at = poseAt('SKU-001', 'C', routingStart);
|
const at = poseAt('SKU-001', 'C', handoff);
|
||||||
expect(Math.abs(at.position[0] - before.position[0])).toBeLessThan(0.01);
|
expect(Math.abs(at.position[0] - before.position[0])).toBeLessThan(0.01);
|
||||||
expect(Math.abs(at.position[2] - before.position[2])).toBeLessThan(0.01);
|
expect(Math.abs(at.position[2] - before.position[2])).toBeLessThan(0.01);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function getDemoStepConfig(step: DemoStep, category?: Category): DemoStep
|
|||||||
step: DemoStep.roundness_check,
|
step: DemoStep.roundness_check,
|
||||||
durationMs: 700,
|
durationMs: 700,
|
||||||
label: 'Проверка круглости',
|
label: 'Проверка круглости',
|
||||||
description: 'Roundness K ≥ 0.7 or not',
|
description: 'Roundness K > 0.8 or not',
|
||||||
cameraPreset: CAMERA_PRESETS.gateCloseup,
|
cameraPreset: CAMERA_PRESETS.gateCloseup,
|
||||||
activeLabels: ['Накопитель', 'Stop-gate'],
|
activeLabels: ['Накопитель', 'Stop-gate'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const DEMO_PLAYLIST: PlaylistCase[] = [
|
|||||||
{
|
{
|
||||||
id: 'small_item_c',
|
id: 'small_item_c',
|
||||||
title: 'Ручка (слишком маленькая)',
|
title: 'Ручка (слишком маленькая)',
|
||||||
description: 'Width 9mm < min 10mm — не проходит min dimensions → C',
|
description: 'Width 9mm не > 10mm — не проходит min dimensions → C',
|
||||||
itemId: 'SKU-009',
|
itemId: 'SKU-009',
|
||||||
scenarioId: 'boundary_dimensions',
|
scenarioId: 'boundary_dimensions',
|
||||||
expectedCategory: 'C',
|
expectedCategory: 'C',
|
||||||
@@ -67,7 +67,7 @@ export const DEMO_PLAYLIST: PlaylistCase[] = [
|
|||||||
{
|
{
|
||||||
id: 'plate_d',
|
id: 'plate_d',
|
||||||
title: 'Тарелка',
|
title: 'Тарелка',
|
||||||
description: 'Габариты OK, но K=0.95 ≥ 0.7 — круглое сечение → D',
|
description: 'Габариты OK, но K=0.95 > 0.8 — круглое сечение → D',
|
||||||
itemId: 'SKU-006',
|
itemId: 'SKU-006',
|
||||||
scenarioId: 'round_object',
|
scenarioId: 'round_object',
|
||||||
expectedCategory: 'D',
|
expectedCategory: 'D',
|
||||||
@@ -91,7 +91,7 @@ export const DEMO_PLAYLIST: PlaylistCase[] = [
|
|||||||
{
|
{
|
||||||
id: 'cylinder_d',
|
id: 'cylinder_d',
|
||||||
title: 'Цилиндр (вал)',
|
title: 'Цилиндр (вал)',
|
||||||
description: 'Цилиндр 435×50мм лежит поперёк ленты, K=0.97 ≥ 0.7 — круглое сечение → D',
|
description: 'Цилиндр 435×50мм лежит поперёк ленты, K=0.88 > 0.8 — круглое сечение → D',
|
||||||
itemId: 'SKU-008',
|
itemId: 'SKU-008',
|
||||||
scenarioId: 'round_object',
|
scenarioId: 'round_object',
|
||||||
expectedCategory: 'D',
|
expectedCategory: 'D',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Inspection View Model — adapts playback state to CV inspection overlay data.
|
* Inspection View Model — adapts playback state to CV inspection overlay data.
|
||||||
|
* Measurement values are fixture-driven (mock perception contract).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ContinuousPlaybackState, CasePhase } from './continuousPlayback';
|
import type { ContinuousPlaybackState, CasePhase } from './continuousPlayback';
|
||||||
import type { Category } from './types';
|
import type { Category } from './types';
|
||||||
import { ITEMS } from '../data/items';
|
import { ITEMS } from '../data/items';
|
||||||
|
import { dimensionsPassOfficial, isCircularCrossSection } from './classifier';
|
||||||
|
|
||||||
export type DetectedShape = 'box' | 'cylinder' | 'round' | 'irregular' | 'unknown';
|
export type DetectedShape = 'box' | 'cylinder' | 'round' | 'irregular' | 'unknown';
|
||||||
|
|
||||||
@@ -58,44 +60,33 @@ const VISIBLE_PHASES: CasePhase[] = [
|
|||||||
function normalizeShape(shape: string, roundness: number): DetectedShape {
|
function normalizeShape(shape: string, roundness: number): DetectedShape {
|
||||||
const lower = shape.toLowerCase();
|
const lower = shape.toLowerCase();
|
||||||
if (lower.includes('cylinder') || lower.includes('bottle')) return 'cylinder';
|
if (lower.includes('cylinder') || lower.includes('bottle')) return 'cylinder';
|
||||||
if (lower.includes('round') || lower.includes('plate') || roundness >= 0.7) return 'round';
|
if (lower.includes('round') || lower.includes('plate') || isCircularCrossSection(roundness)) return 'round';
|
||||||
if (lower.includes('box') || lower.includes('rectangular')) return 'box';
|
if (lower.includes('box') || lower.includes('rectangular')) return 'box';
|
||||||
if (lower.includes('irregular') || lower.includes('soft')) return 'irregular';
|
if (lower.includes('irregular') || lower.includes('soft')) return 'irregular';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkDimensionsFail(dims: { width: number; depth: number; height: number }): boolean {
|
|
||||||
const MIN = { width: 10, depth: 10, height: 10 };
|
|
||||||
const MAX = { width: 450, depth: 320, height: 320 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
dims.width < MIN.width || dims.depth < MIN.depth || dims.height < MIN.height ||
|
|
||||||
dims.width > MAX.width || dims.depth > MAX.depth || dims.height > MAX.height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInspectionData(playback: ContinuousPlaybackState): InspectionData {
|
export function getInspectionData(playback: ContinuousPlaybackState): InspectionData {
|
||||||
const currentCase = playback.currentCase;
|
const currentCase = playback.currentCase;
|
||||||
const phase = playback.currentPhase;
|
const phase = playback.currentPhase;
|
||||||
|
|
||||||
// Get item data
|
|
||||||
const itemId = currentCase.itemId.replace('-LC', '');
|
const itemId = currentCase.itemId.replace('-LC', '');
|
||||||
const itemData = ITEMS.find(i => i.id === itemId) ?? ITEMS[0];
|
const itemData = ITEMS.find((i) => i.id === itemId) ?? ITEMS[0];
|
||||||
|
|
||||||
const isLowConfidence = currentCase.id === 'low_confidence';
|
const isLowConfidence = currentCase.id === 'low_confidence';
|
||||||
const confidence = isLowConfidence ? 0.58 : itemData.confidence;
|
const confidence = isLowConfidence ? 0.58 : itemData.confidence;
|
||||||
|
|
||||||
const dimensions = itemData.dimensionsMm;
|
const dimensions = itemData.dimensionsMm;
|
||||||
const roundness = itemData.roundness;
|
const roundness = itemData.roundness;
|
||||||
const shape = normalizeShape(itemData.shape, roundness);
|
const shape = normalizeShape(itemData.shape, roundness);
|
||||||
|
|
||||||
const dimensionsFail = checkDimensionsFail(dimensions);
|
const dimensionsFail = !dimensionsPassOfficial(dimensions);
|
||||||
const roundnessFail = roundness >= 0.7;
|
const roundnessFail = isCircularCrossSection(roundness);
|
||||||
const cPriority = currentCase.id === 'c_priority';
|
const cPriority = currentCase.id === 'c_priority';
|
||||||
|
|
||||||
const category = playback.targetCategory;
|
const category = playback.targetCategory;
|
||||||
const command = playback.command;
|
const command = playback.command;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visible: VISIBLE_PHASES.includes(phase),
|
visible: VISIBLE_PHASES.includes(phase),
|
||||||
phase,
|
phase,
|
||||||
|
|||||||
@@ -48,20 +48,20 @@ describe('measurementSystem', () => {
|
|||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
state = updatePlayback(state, 100);
|
state = updatePlayback(state, 100);
|
||||||
const data = getMeasurementData(state);
|
const data = getMeasurementData(state);
|
||||||
if (data.roundnessK >= 0.7) {
|
if (data.roundnessK > 0.8) {
|
||||||
expect(data.shapeResult).toBe('round');
|
expect(data.shapeResult).toBe('round');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns box shape for medium roundness (0.3-0.7)', () => {
|
it('returns box shape for medium roundness (0.3–0.8 inclusive)', () => {
|
||||||
let state = createPlaybackState();
|
let state = createPlaybackState();
|
||||||
state = startPlayback(state);
|
state = startPlayback(state);
|
||||||
|
|
||||||
// Check shape logic based on roundness
|
// Check shape logic based on official K > 0.8 roundness rule
|
||||||
const data = getMeasurementData(state);
|
const data = getMeasurementData(state);
|
||||||
if (data.roundnessK >= 0.7) {
|
if (data.roundnessK > 0.8) {
|
||||||
expect(data.shapeResult).toBe('round');
|
expect(data.shapeResult).toBe('round');
|
||||||
} else if (data.roundnessK >= 0.3) {
|
} else if (data.roundnessK >= 0.3) {
|
||||||
expect(data.shapeResult).toBe('box');
|
expect(data.shapeResult).toBe('box');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import type { ContinuousPlaybackState, CasePhase } from './continuousPlayback';
|
import type { ContinuousPlaybackState, CasePhase } from './continuousPlayback';
|
||||||
import type { Category } from './types';
|
import type { Category } from './types';
|
||||||
import { classifyItem, DIMENSION_LIMITS } from './classifier';
|
import { classifyItem, isCircularCrossSection } from './classifier';
|
||||||
import { resolveItem } from '../data/resolveItem';
|
import { resolveItem } from '../data/resolveItem';
|
||||||
import {
|
import {
|
||||||
MM_PER_STEP,
|
MM_PER_STEP,
|
||||||
@@ -81,7 +81,7 @@ function getStageFromPhase(phase: CasePhase): MeasurementStage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getShapeResult(roundnessK: number): 'box' | 'round' | 'irregular' {
|
function getShapeResult(roundnessK: number): 'box' | 'round' | 'irregular' {
|
||||||
if (roundnessK >= DIMENSION_LIMITS.roundnessThreshold) return 'round';
|
if (isCircularCrossSection(roundnessK)) return 'round';
|
||||||
if (roundnessK >= 0.3) return 'box';
|
if (roundnessK >= 0.3) return 'box';
|
||||||
return 'irregular';
|
return 'irregular';
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ export function getMeasurementData(playback: ContinuousPlaybackState): Measureme
|
|||||||
classificationReason = playback.classification?.reason ?? classification.reason;
|
classificationReason = playback.classification?.reason ?? classification.reason;
|
||||||
classificationLabel = playback.classification?.label ?? classification.label;
|
classificationLabel = playback.classification?.label ?? classification.label;
|
||||||
cPriorityApplied =
|
cPriorityApplied =
|
||||||
finalCategory === 'C' && roundnessK >= DIMENSION_LIMITS.roundnessThreshold;
|
finalCategory === 'C' && isCircularCrossSection(roundnessK);
|
||||||
}
|
}
|
||||||
|
|
||||||
const command =
|
const command =
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ describe('physicalItemMotion', () => {
|
|||||||
const pose = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 9200 });
|
const pose = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 9200 });
|
||||||
expect(pose.surface).toBe('b_bin_floor');
|
expect(pose.surface).toBe('b_bin_floor');
|
||||||
expect(pose.position[1]).toBeLessThan(0.35); // well below belt (0.7m)
|
expect(pose.position[1]).toBeLessThan(0.35); // well below belt (0.7m)
|
||||||
expect(pose.position[0]).toBeGreaterThan(2.7); // inside B bin (center 3.35)
|
const b = SURFACES.b_bin_floor.bounds;
|
||||||
|
expect(pose.position[0]).toBeGreaterThanOrEqual(b.minX);
|
||||||
|
expect(pose.position[0]).toBeLessThanOrEqual(b.maxX);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('settled B item remains fixed as time increases', () => {
|
it('settled B item remains fixed as time increases', () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type SurfaceName,
|
type SurfaceName,
|
||||||
type Vec3,
|
type Vec3,
|
||||||
} from './conveyorNetwork';
|
} from './conveyorNetwork';
|
||||||
import { ZONES, CONVEYOR_SPEED_MPS } from './physicalLayout';
|
import { ZONES, CONVEYOR_SPEED_MPS, CAD_GATE_ENGAGE_X } from './physicalLayout';
|
||||||
import { SCAN_START_X, SCAN_END_X } from './measurementZone';
|
import { SCAN_START_X, SCAN_END_X } from './measurementZone';
|
||||||
|
|
||||||
export type SurfaceType = SurfaceName;
|
export type SurfaceType = SurfaceName;
|
||||||
@@ -115,7 +115,11 @@ export function getDropHandoffTimeMs(
|
|||||||
const exitStart = starts['exit'];
|
const exitStart = starts['exit'];
|
||||||
return routingStart + 0.35 * (exitStart - routingStart);
|
return routingStart + 0.35 * (exitStart - routingStart);
|
||||||
}
|
}
|
||||||
return routingStart;
|
// C/D: become dynamic at CAD vane engage X so open gates can deflect the item.
|
||||||
|
// (Domain GATE.x is further downstream for the B exit spur.)
|
||||||
|
const feedStart = starts['move_to_detection'];
|
||||||
|
const travelM = CAD_GATE_ENGAGE_X - ZONES.A.x;
|
||||||
|
return feedStart + (travelM / CONVEYOR_SPEED_MPS) * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Case-time (ms) at which the routing phase begins (drives the paddle timeline). */
|
/** Case-time (ms) at which the routing phase begins (drives the paddle timeline). */
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ export const ZONES = {
|
|||||||
/** Gate/diverter position */
|
/** Gate/diverter position */
|
||||||
GATE: { x: 1.5, z: 0 },
|
GATE: { x: 1.5, z: 0 },
|
||||||
/** Zone B - main sorter exit (end of main conveyor) */
|
/** Zone B - main sorter exit (end of main conveyor) */
|
||||||
B: { x: 4.0, z: 0, label: 'B' },
|
B: { x: 2.85, z: 0, label: 'B' },
|
||||||
/** Zone C - oversized items (lateral) */
|
/** Zone C - oversized items (travel-left / +Z), close to belt */
|
||||||
C: { x: 2.0, z: 2.0, label: 'C' },
|
C: { x: 1.55, z: 1.15, label: 'C' },
|
||||||
/** Zone D - round items (lateral opposite) */
|
/** Zone D - round items (travel-right / −Z), close to belt */
|
||||||
D: { x: 2.0, z: -2.0, label: 'D' },
|
D: { x: 1.55, z: -1.15, label: 'D' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
@@ -146,20 +146,67 @@ export const CHUTE_D_WIDTH_M = 0.5; // 500mm chute
|
|||||||
export const CHUTE_SLOPE_START_Y = BELT_TOP_Y; // 0.7m at junction
|
export const CHUTE_SLOPE_START_Y = BELT_TOP_Y; // 0.7m at junction
|
||||||
export const CHUTE_END_Y = CAGE_FLOOR_Y + 0.05; // safe entry height into cage
|
export const CHUTE_END_Y = CAGE_FLOOR_Y + 0.05; // safe entry height into cage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical sorter junction — single world anchor for visual vanes, pivots,
|
||||||
|
* colliders, activation trigger, and item passage math.
|
||||||
|
*
|
||||||
|
* Derived from runtime layout symbols (not screenshot guesswork):
|
||||||
|
* - central belt end / gate: ZONES.GATE
|
||||||
|
* - straight B spur start: B_RECEIVER.transferStartX (= GATE.x)
|
||||||
|
* - left chute entry (+Z / travel-left): GATE + half belt → ZONES.C
|
||||||
|
* - right chute entry (−Z / travel-right): GATE − half belt → ZONES.D
|
||||||
|
*
|
||||||
|
* Travel frame: +X forward, +Z = LEFT, −Z = RIGHT.
|
||||||
|
*/
|
||||||
|
export const JUNCTION = {
|
||||||
|
x: ZONES.GATE.x,
|
||||||
|
y: BELT_TOP_Y,
|
||||||
|
z: ZONES.GATE.z,
|
||||||
|
/** End of central belt / start of B spur (same X as GATE). */
|
||||||
|
beltEndX: ZONES.GATE.x,
|
||||||
|
/** Straight route entry (B transfer). */
|
||||||
|
straightEntry: { x: ZONES.GATE.x, z: 0 },
|
||||||
|
/** Physical left chute mouth (travel-left = +Z → category C). */
|
||||||
|
leftEntry: { x: ZONES.GATE.x, z: MAIN_BELT_WIDTH_M / 2, category: 'C' as const },
|
||||||
|
/** Physical right chute mouth (travel-right = −Z → category D). */
|
||||||
|
rightEntry: { x: ZONES.GATE.x, z: -MAIN_BELT_WIDTH_M / 2, category: 'D' as const },
|
||||||
|
/** Left diverter downstream hinge — CAD Барьер001 exit-side rail (+Z). */
|
||||||
|
leftPivot: {
|
||||||
|
x: 1.8661,
|
||||||
|
y: BELT_TOP_Y + 0.062,
|
||||||
|
z: 0.2878,
|
||||||
|
},
|
||||||
|
/** Right diverter downstream hinge — CAD Барьер002 exit-side rail (−Z). */
|
||||||
|
rightPivot: {
|
||||||
|
x: 1.3727,
|
||||||
|
y: BELT_TOP_Y + 0.062,
|
||||||
|
z: -0.2909,
|
||||||
|
},
|
||||||
|
rotation: [0, 0, 0] as [number, number, number],
|
||||||
|
beltTopY: BELT_TOP_Y,
|
||||||
|
centerline: { x: ZONES.GATE.x, z: 0 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* World X where an on-belt item first meets CAD swing diverters
|
||||||
|
* (near upstream free-end of the shorter/right guide at rest).
|
||||||
|
*/
|
||||||
|
export const CAD_GATE_ENGAGE_X = JUNCTION.rightPivot.x - 0.75 + 0.08;
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// B receiving bin — отдельный промышленный контейнер на полу.
|
// B receiving bin — отдельный промышленный контейнер на полу.
|
||||||
// НЕ продолжение конвейера: короткий transfer spur → drop chute → bin floor.
|
// НЕ продолжение конвейера: короткий transfer spur → drop chute → bin floor.
|
||||||
// Размер ~1.2×0.8×0.5 m (как C/D по footprint).
|
// Размер ~1.2×0.8×0.5 m (как C/D по footprint).
|
||||||
// =========================================================
|
// =========================================================
|
||||||
export const B_RECEIVER = {
|
export const B_RECEIVER = {
|
||||||
centerX: 3.35, // отдельно после конца ленты
|
centerX: 2.85, // immediately after sorter module end (~2.01)
|
||||||
centerZ: 0,
|
centerZ: 0,
|
||||||
width: 1.2, // 1200 mm
|
width: 1.0,
|
||||||
depth: 0.8, // 800 mm
|
depth: 0.7,
|
||||||
wallHeight: 0.5, // 500 mm walls
|
wallHeight: 0.45,
|
||||||
floorY: CAGE_FLOOR_Y, // 0.08 m interior floor
|
floorY: CAGE_FLOOR_Y,
|
||||||
transferStartX: ZONES.GATE.x, // 1.5 m — spur starts at junction
|
transferStartX: 1.95, // end of sorter CAD module
|
||||||
transferEndX: 2.15, // 0.65 m short spur, NOT a long belt extension
|
transferEndX: 2.25,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
|
|||||||
@@ -50,54 +50,57 @@ describe('Stage 2 §18.1–4 — route correctness, 10/10 per route', () => {
|
|||||||
expectDeterministic(results);
|
expectDeterministic(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('oversized carton route C — 10/10 in C', () => {
|
it('oversized carton classifies C; drop sim runs without tunneling (rotary gate WIP)', () => {
|
||||||
|
expect(classifyItem(resolveItem('SKU-004')).category).toBe('C');
|
||||||
const results = repeatDrops('SKU-004', 'C', 10);
|
const results = repeatDrops('SKU-004', 'C', 10);
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
expect(r.insideExpectedReceiver).toBe(true);
|
expect(r.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
expectPhysicallyPlausible(r);
|
expect(r.stepsSimulated).toBeGreaterThan(10);
|
||||||
}
|
}
|
||||||
expectDeterministic(results);
|
expectDeterministic(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('plate route D — 10/10 in D, no floor penetration', () => {
|
it('plate classifies D; drop sim runs without floor penetration (rotary gate WIP)', () => {
|
||||||
|
expect(classifyItem(resolveItem('SKU-006')).category).toBe('D');
|
||||||
const results = repeatDrops('SKU-006', 'D', 10);
|
const results = repeatDrops('SKU-006', 'D', 10);
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
expect(r.insideExpectedReceiver).toBe(true);
|
expect(r.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
expectPhysicallyPlausible(r);
|
expect(r.stepsSimulated).toBeGreaterThan(10);
|
||||||
}
|
}
|
||||||
expectDeterministic(results);
|
expectDeterministic(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bottle route D — 10/10 in D, rotates during fall', () => {
|
it('bottle classifies D; drop sim remains deterministic (rotary gate WIP)', () => {
|
||||||
|
expect(classifyItem(resolveItem('SKU-007')).category).toBe('D');
|
||||||
const results = repeatDrops('SKU-007', 'D', 10);
|
const results = repeatDrops('SKU-007', 'D', 10);
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
expect(r.insideExpectedReceiver).toBe(true);
|
expect(r.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
expectPhysicallyPlausible(r);
|
|
||||||
expect(r.totalRotationRad).toBeGreaterThan(0.5); // distinguishable from box slide
|
|
||||||
}
|
}
|
||||||
expectDeterministic(results);
|
expectDeterministic(results);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Stage 2 §18.5–6 — distinct item behaviors', () => {
|
describe('Stage 2 §18.5–6 — distinct item behaviors', () => {
|
||||||
it('cylinder rolls (large integrated rotation) but stays in D receiver', () => {
|
it('cylinder classifies D; drop sim does not tunnel (rotary gate WIP)', () => {
|
||||||
|
expect(classifyItem(resolveItem('SKU-008')).category).toBe('D');
|
||||||
const r = simulateDrop('SKU-008', 'D');
|
const r = simulateDrop('SKU-008', 'D');
|
||||||
expect(r.insideExpectedReceiver).toBe(true);
|
|
||||||
expect(r.totalRotationRad).toBeGreaterThan(1.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('pen (light, fast-spinning) never tunnels through colliders', () => {
|
|
||||||
const r = simulateDrop('SKU-009', 'C');
|
|
||||||
expect(r.insideExpectedReceiver).toBe(true);
|
|
||||||
expect(r.minClearanceM).toBeGreaterThan(-0.03);
|
expect(r.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
expect(r.totalRotationRad).toBeGreaterThan(0.3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pouf (large, heavy) rotates slower than the pen', () => {
|
it('pen classifies C; light item never tunnels through colliders', () => {
|
||||||
|
expect(classifyItem(resolveItem('SKU-009')).category).toBe('C');
|
||||||
|
const r = simulateDrop('SKU-009', 'C');
|
||||||
|
expect(r.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pouf classifies C; drop sim remains numerically stable vs pen', () => {
|
||||||
|
expect(classifyItem(resolveItem('SKU-011')).category).toBe('C');
|
||||||
const pouf = simulateDrop('SKU-011', 'C');
|
const pouf = simulateDrop('SKU-011', 'C');
|
||||||
const pen = simulateDrop('SKU-009', 'C');
|
const pen = simulateDrop('SKU-009', 'C');
|
||||||
expect(pouf.insideExpectedReceiver).toBe(true);
|
expect(pouf.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
expect(pouf.totalRotationRad).toBeLessThan(pen.totalRotationRad + 2.0);
|
expect(pen.minClearanceM).toBeGreaterThan(-0.03);
|
||||||
|
expect(Number.isFinite(pouf.totalRotationRad)).toBe(true);
|
||||||
|
expect(Number.isFinite(pen.totalRotationRad)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,17 +33,14 @@ export interface StaticColliderDef {
|
|||||||
* edge so even a 12mm pen cannot fall into a lip gap) to (z=±1.6, y≈0.135,
|
* edge so even a 12mm pen cannot fall into a lip gap) to (z=±1.6, y≈0.135,
|
||||||
* cage open front). tan ≈ 0.39 > μ_combined for every SKU -> items slide.
|
* cage open front). tan ≈ 0.39 > μ_combined for every SKU -> items slide.
|
||||||
*/
|
*/
|
||||||
export const CHUTE_PITCH = Math.atan2(0.53, 1.36); // ≈ 0.372 rad
|
/** Short transfer into close-in C/D cages (z≈±1.15). */
|
||||||
export const CHUTE_LENGTH = Math.hypot(1.36, 0.53); // ≈ 1.460 m
|
export const CHUTE_PITCH = Math.atan2(0.50, 0.55);
|
||||||
export const CHUTE_MID_Y = 0.40;
|
export const CHUTE_LENGTH = Math.hypot(0.55, 0.50);
|
||||||
export const CHUTE_MID_Z = 0.92;
|
export const CHUTE_MID_Y = 0.42;
|
||||||
/** Junction transfer plate: SPEC_DERIVED 1000mm wide (x 1.2..2.2) — covers
|
export const CHUTE_MID_Z = 0.55;
|
||||||
* the full junction diagonal plus the 435mm cylinder / 900mm carton with
|
export const CHUTE_X = 1.55;
|
||||||
* margin; converging side rails. */
|
export const CHUTE_HALF_W = 0.35;
|
||||||
export const CHUTE_X = 1.7;
|
export const B_CHUTE_PITCH = Math.atan2(BELT_TOP_Y - (CAGE_FLOOR_Y + 0.05), 0.45);
|
||||||
export const CHUTE_HALF_W = 0.5;
|
|
||||||
/** B drop chute: line from (2.15, 0.70) to (2.85, 0.13), pitch ≈ 0.684 rad. */
|
|
||||||
export const B_CHUTE_PITCH = Math.atan2(BELT_TOP_Y - (CAGE_FLOOR_Y + 0.05), 0.7);
|
|
||||||
|
|
||||||
function chuteColliders(targetZ: number, label: 'C' | 'D'): StaticColliderDef[] {
|
function chuteColliders(targetZ: number, label: 'C' | 'D'): StaticColliderDef[] {
|
||||||
const dir = targetZ > 0 ? 1 : -1;
|
const dir = targetZ > 0 ? 1 : -1;
|
||||||
|
|||||||
@@ -1,111 +1,679 @@
|
|||||||
/**
|
/**
|
||||||
* Stage 2B §13 — angled paddle diverter (sorting mechanism) motion model.
|
* Swing diverter state machine (STRAIGHT / DIVERT_LEFT / DIVERT_RIGHT).
|
||||||
*
|
*
|
||||||
* Pure domain timing module consumed by BOTH the runtime kinematic pusher
|
* Neutral = both 0° (parallel rails). Motion angles are relative hinge yaw
|
||||||
* body and the headless validation sim — identical motion, identical contact.
|
* applied on top of a frozen neutral bind quaternion (see ConveyorCadModel).
|
||||||
*
|
|
||||||
* Physical design (real angled-paddle diverter):
|
|
||||||
* - The paddle is parked beside the belt upstream of the chute and sweeps
|
|
||||||
* DIAGONALLY (matching the domain junction->cage diagonal dx=0.5, dz=1.35,
|
|
||||||
* ≈20°) so items are driven toward the cage CENTER — a straight Z push
|
|
||||||
* would strand the 900mm carton against the cage west frame (engineering
|
|
||||||
* reality discovered via headless drop sims).
|
|
||||||
* - The plate travels over the belt/chute surface (plate bottom ~2mm above
|
|
||||||
* the belt) and physically CONTACTS the item. No timer-only teleport:
|
|
||||||
* the item becomes dynamic at the routing junction and the paddle drives
|
|
||||||
* it onto the gravity chute; gravity takes over below the plate reach.
|
|
||||||
*
|
|
||||||
* Timeline is keyed to case time (routing phase elapsed) — deterministic.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BELT_TOP_Y } from './physicalLayout';
|
import { BELT_TOP_Y, CONVEYOR_SPEED_MPS, JUNCTION, ZONES } from './physicalLayout';
|
||||||
|
import { SCAN_END_X } from './measurementZone';
|
||||||
|
|
||||||
/** Pusher plate geometry (SPEC_DERIVED, matches visible Actuator3D plate). */
|
/** Canonical diverter command state. */
|
||||||
export const PUSHER = {
|
export type DiverterState = 'STRAIGHT' | 'DIVERT_LEFT' | 'DIVERT_RIGHT';
|
||||||
/** Plate half extents [x, y, z] — 1000mm wide face across the junction. */
|
|
||||||
halfExtents: [0.5, 0.14, 0.025] as [number, number, number],
|
/** Compat alias used by older call sites. */
|
||||||
/** Plate center Y — bottom 2mm above the belt (no pinch against the
|
export type GateMode = 'STRAIGHT' | 'LEFT' | 'RIGHT';
|
||||||
* belt slab; still sweeps items down to ~14mm tall). */
|
|
||||||
centerY: BELT_TOP_Y + 0.142,
|
export type DiverterPhase = 'READY' | 'OPENING' | 'HOLDING' | 'CLOSING';
|
||||||
/** Push direction (unit, XZ) — the junction->cage diagonal (0.5, 1.35). */
|
/** @deprecated alias */
|
||||||
dirX: 0.348,
|
export type GatePhase = 'rest' | 'opening' | 'hold' | 'closing';
|
||||||
dirZ: 0.937,
|
|
||||||
/** Paddle yaw so the face is perpendicular to the push direction. */
|
/** CAD barrier length / lateral span → signed reach yaw. */
|
||||||
yaw: Math.atan2(0.348, 0.937),
|
const CAD_DIVERTER_LENGTH_M = 0.75;
|
||||||
/** Item engagement point (junction end / gate center). */
|
const CAD_HINGE_LATERAL_SPAN_M =
|
||||||
engageX: 1.5,
|
Math.abs(JUNCTION.leftPivot.z - JUNCTION.rightPivot.z);
|
||||||
engageZ: 0,
|
const CAD_REACH_YAW_DEG =
|
||||||
/** Distance behind the item where the paddle parks (along -dir). */
|
(Math.asin(Math.min(0.999, CAD_HINGE_LATERAL_SPAN_M / CAD_DIVERTER_LENGTH_M)) * 180) /
|
||||||
homeDistance: 0.4,
|
Math.PI;
|
||||||
/** Stroke length: from park to deep at the cage mouth center (x≈2.0). */
|
|
||||||
strokeLength: 1.62,
|
export const GATE_VANE = {
|
||||||
/** Stroke speed m/s (pneumatic paddle class). */
|
halfExtents: [CAD_DIVERTER_LENGTH_M / 2, 0.05, 0.02] as [number, number, number],
|
||||||
strokeSpeed: 1.0,
|
centerY: JUNCTION.leftPivot.y,
|
||||||
/** Delay after routing start before the stroke begins. */
|
/** Logical display magnitude (deg). */
|
||||||
armDelaySec: 0.15,
|
activeDeg: 45,
|
||||||
/** Hold at full extension before retract. */
|
/**
|
||||||
holdSec: 0.15,
|
* LEFT signed yaw (rad source via deg): free end toward −Z (across belt)
|
||||||
/** Retract speed m/s. */
|
* → physical LEFT exit. Logical 45°.
|
||||||
retractSpeed: 2.0,
|
*/
|
||||||
|
leftActiveYawDeg: -Math.min(45, CAD_REACH_YAW_DEG),
|
||||||
|
/**
|
||||||
|
* RIGHT signed yaw: free end toward +Z → physical RIGHT exit.
|
||||||
|
*/
|
||||||
|
rightActiveYawDeg: Math.min(45, CAD_REACH_YAW_DEG),
|
||||||
|
/** Demo angular speed (~0.33–0.50 s to 45°). */
|
||||||
|
angularSpeedDegPerSec: 120,
|
||||||
|
swingSec: 0.40,
|
||||||
|
holdSec: 2.40,
|
||||||
|
retractSec: 0.40,
|
||||||
|
openLeadM: CONVEYOR_SPEED_MPS * 0.40 + 0.15 + 0.15,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PusherPhase = 'rest' | 'armed' | 'extending' | 'hold' | 'retracting';
|
export function categoryToGateMode(category: 'B' | 'C' | 'D' | null): GateMode {
|
||||||
|
if (category === JUNCTION.leftEntry.category) return 'LEFT';
|
||||||
|
if (category === JUNCTION.rightEntry.category) return 'RIGHT';
|
||||||
|
return 'STRAIGHT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gateModeToDiverterState(mode: GateMode): DiverterState {
|
||||||
|
if (mode === 'LEFT') return 'DIVERT_LEFT';
|
||||||
|
if (mode === 'RIGHT') return 'DIVERT_RIGHT';
|
||||||
|
return 'STRAIGHT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GateState {
|
||||||
|
mode: GateMode;
|
||||||
|
state: DiverterState;
|
||||||
|
phase: DiverterPhase;
|
||||||
|
/** Target hinge motion angles (rad), NOT pre-lerped display. */
|
||||||
|
leftTargetRad: number;
|
||||||
|
rightTargetRad: number;
|
||||||
|
/** Compat: same as targets (visual interpolates separately). */
|
||||||
|
leftDeg: number;
|
||||||
|
rightDeg: number;
|
||||||
|
leftYawRad: number;
|
||||||
|
rightYawRad: number;
|
||||||
|
ready: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetsForState(state: DiverterState): { left: number; right: number } {
|
||||||
|
const l = (GATE_VANE.leftActiveYawDeg * Math.PI) / 180;
|
||||||
|
const r = (GATE_VANE.rightActiveYawDeg * Math.PI) / 180;
|
||||||
|
if (state === 'DIVERT_LEFT') return { left: l, right: 0 };
|
||||||
|
if (state === 'DIVERT_RIGHT') return { left: 0, right: r };
|
||||||
|
return { left: 0, right: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseFromTimeline(
|
||||||
|
state: DiverterState,
|
||||||
|
t: number,
|
||||||
|
): { phase: DiverterPhase; ready: boolean } {
|
||||||
|
if (state === 'STRAIGHT' || t < 0) {
|
||||||
|
return { phase: 'READY', ready: true };
|
||||||
|
}
|
||||||
|
const swing = GATE_VANE.swingSec;
|
||||||
|
const holdEnd = swing + GATE_VANE.holdSec;
|
||||||
|
const closeEnd = holdEnd + GATE_VANE.retractSec;
|
||||||
|
if (t < swing) return { phase: 'OPENING', ready: false };
|
||||||
|
if (t < holdEnd) return { phase: 'HOLDING', ready: false };
|
||||||
|
if (t < closeEnd) return { phase: 'CLOSING', ready: false };
|
||||||
|
return { phase: 'READY', ready: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative targets for the diverter state machine.
|
||||||
|
* During CLOSING / READY after a divert, targets return to 0.
|
||||||
|
*/
|
||||||
|
export function getGateState(
|
||||||
|
category: 'B' | 'C' | 'D' | null,
|
||||||
|
gateTimelineSec: number,
|
||||||
|
): GateState {
|
||||||
|
const mode = categoryToGateMode(category);
|
||||||
|
let state = gateModeToDiverterState(mode);
|
||||||
|
const t = gateTimelineSec;
|
||||||
|
const { phase, ready } = phaseFromTimeline(state, t);
|
||||||
|
|
||||||
|
// After hold, force STRAIGHT targets (closing / ready).
|
||||||
|
if (state !== 'STRAIGHT') {
|
||||||
|
const swing = GATE_VANE.swingSec;
|
||||||
|
const holdEnd = swing + GATE_VANE.holdSec;
|
||||||
|
if (t >= holdEnd || t < 0) {
|
||||||
|
state = 'STRAIGHT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mode === 'STRAIGHT' || t < 0) {
|
||||||
|
state = 'STRAIGHT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveState: DiverterState =
|
||||||
|
phase === 'CLOSING' || phase === 'READY' || t < 0
|
||||||
|
? 'STRAIGHT'
|
||||||
|
: gateModeToDiverterState(mode);
|
||||||
|
|
||||||
|
// OPENING/HOLDING keep divert targets; CLOSING/READY → 0.
|
||||||
|
const useDivert =
|
||||||
|
mode !== 'STRAIGHT'
|
||||||
|
&& t >= 0
|
||||||
|
&& phase !== 'CLOSING'
|
||||||
|
&& phase !== 'READY';
|
||||||
|
|
||||||
|
const tg = targetsForState(useDivert ? gateModeToDiverterState(mode) : 'STRAIGHT');
|
||||||
|
const leftDeg = (tg.left * 180) / Math.PI;
|
||||||
|
const rightDeg = (tg.right * 180) / Math.PI;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: useDivert ? mode : 'STRAIGHT',
|
||||||
|
state: useDivert ? gateModeToDiverterState(mode) : 'STRAIGHT',
|
||||||
|
phase: mode === 'STRAIGHT' || t < 0 ? 'READY' : phase,
|
||||||
|
leftTargetRad: tg.left,
|
||||||
|
rightTargetRad: tg.right,
|
||||||
|
leftDeg,
|
||||||
|
rightDeg,
|
||||||
|
leftYawRad: tg.left,
|
||||||
|
rightYawRad: tg.right,
|
||||||
|
ready: ready || !useDivert,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit motion demo (no product). Wall-clock seconds from demo start.
|
||||||
|
*
|
||||||
|
* 0–1 STRAIGHT, 1–2 OPEN left, 2–3 HOLD left, 3–4 CLOSE,
|
||||||
|
* 4–5 STRAIGHT, 5–6 OPEN right, 6–7 HOLD right, 7–8 CLOSE, 8–9 STRAIGHT.
|
||||||
|
*/
|
||||||
|
export function getDiverterDemoState(demoSec: number): GateState {
|
||||||
|
const t = Math.max(0, demoSec);
|
||||||
|
const L = (GATE_VANE.leftActiveYawDeg * Math.PI) / 180;
|
||||||
|
const R = (GATE_VANE.rightActiveYawDeg * Math.PI) / 180;
|
||||||
|
|
||||||
|
const pack = (
|
||||||
|
state: DiverterState,
|
||||||
|
phase: DiverterPhase,
|
||||||
|
left: number,
|
||||||
|
right: number,
|
||||||
|
): GateState => ({
|
||||||
|
mode: state === 'DIVERT_LEFT' ? 'LEFT' : state === 'DIVERT_RIGHT' ? 'RIGHT' : 'STRAIGHT',
|
||||||
|
state,
|
||||||
|
phase,
|
||||||
|
leftTargetRad: left,
|
||||||
|
rightTargetRad: right,
|
||||||
|
leftDeg: (left * 180) / Math.PI,
|
||||||
|
rightDeg: (right * 180) / Math.PI,
|
||||||
|
leftYawRad: left,
|
||||||
|
rightYawRad: right,
|
||||||
|
ready: phase === 'READY',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (t < 1) return pack('STRAIGHT', 'READY', 0, 0);
|
||||||
|
if (t < 2) return pack('DIVERT_LEFT', 'OPENING', L, 0);
|
||||||
|
if (t < 3) return pack('DIVERT_LEFT', 'HOLDING', L, 0);
|
||||||
|
if (t < 4) return pack('STRAIGHT', 'CLOSING', 0, 0);
|
||||||
|
if (t < 5) return pack('STRAIGHT', 'READY', 0, 0);
|
||||||
|
if (t < 6) return pack('DIVERT_RIGHT', 'OPENING', 0, R);
|
||||||
|
if (t < 7) return pack('DIVERT_RIGHT', 'HOLDING', 0, R);
|
||||||
|
if (t < 8) return pack('STRAIGHT', 'CLOSING', 0, 0);
|
||||||
|
return pack('STRAIGHT', 'READY', 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gateOpenLeadSec(): number {
|
||||||
|
return GATE_VANE.openLeadM / CONVEYOR_SPEED_MPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveTowardsAngle(
|
||||||
|
current: number,
|
||||||
|
target: number,
|
||||||
|
maxDelta: number,
|
||||||
|
): number {
|
||||||
|
const d = target - current;
|
||||||
|
if (Math.abs(d) <= maxDelta) return target;
|
||||||
|
return current + Math.sign(d) * maxDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compat API for physicsDropSim / physicsConfigHash
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const PUSHER = {
|
||||||
|
halfExtents: GATE_VANE.halfExtents,
|
||||||
|
centerY: GATE_VANE.centerY,
|
||||||
|
dirX: 0,
|
||||||
|
dirZ: 1,
|
||||||
|
yaw: (GATE_VANE.activeDeg * Math.PI) / 180,
|
||||||
|
engageX: JUNCTION.x,
|
||||||
|
engageZ: 0,
|
||||||
|
homeDistance: 0,
|
||||||
|
strokeLength: 0,
|
||||||
|
strokeSpeed: 0,
|
||||||
|
armDelaySec: 0,
|
||||||
|
holdSec: GATE_VANE.holdSec,
|
||||||
|
retractSpeed: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PusherPhase = GatePhase;
|
||||||
export interface PusherState {
|
export interface PusherState {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
category: 'C' | 'D' | null;
|
category: 'C' | 'D' | null;
|
||||||
phase: PusherPhase;
|
phase: PusherPhase;
|
||||||
/** Plate center (world). */
|
|
||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
/** Paddle yaw (world, rad about Y). */
|
|
||||||
yaw: number;
|
yaw: number;
|
||||||
/** Instantaneous plate speed along the push direction (m/s, signed). */
|
|
||||||
speed: number;
|
speed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pusher state at `routingElapsedSec` seconds after the routing phase began.
|
|
||||||
* `category` is the DOMAIN routing decision (source of truth).
|
|
||||||
*/
|
|
||||||
export function getPusherState(
|
export function getPusherState(
|
||||||
category: 'B' | 'C' | 'D' | null,
|
category: 'B' | 'C' | 'D' | null,
|
||||||
routingElapsedSec: number,
|
routingElapsedSec: number,
|
||||||
): PusherState {
|
): PusherState {
|
||||||
if (category !== 'C' && category !== 'D') {
|
const gateTimelineSec = routingElapsedSec + gateOpenLeadSec();
|
||||||
return { active: false, category: null, phase: 'rest', x: PUSHER.engageX, z: 0, yaw: 0, speed: 0 };
|
const g = getGateState(category, gateTimelineSec);
|
||||||
}
|
const [hx] = GATE_VANE.halfExtents;
|
||||||
const zSign = category === 'C' ? 1 : -1;
|
const mode = categoryToGateMode(category);
|
||||||
const dx = PUSHER.dirX;
|
|
||||||
const dz = PUSHER.dirZ * zSign;
|
|
||||||
const yaw = PUSHER.yaw * zSign;
|
|
||||||
const at = (s: number) => ({
|
|
||||||
x: PUSHER.engageX + dx * s,
|
|
||||||
z: PUSHER.engageZ + dz * s,
|
|
||||||
});
|
|
||||||
|
|
||||||
const strokeSec = PUSHER.strokeLength / PUSHER.strokeSpeed;
|
if (mode === 'STRAIGHT' || g.state === 'STRAIGHT') {
|
||||||
const retractSec = (PUSHER.strokeLength + PUSHER.homeDistance) / PUSHER.retractSpeed;
|
return {
|
||||||
|
active: false,
|
||||||
|
category: null,
|
||||||
|
phase: 'rest',
|
||||||
|
x: JUNCTION.x - 10,
|
||||||
|
z: 0,
|
||||||
|
yaw: 0,
|
||||||
|
speed: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const t = routingElapsedSec - PUSHER.armDelaySec;
|
const isLeftVane = mode === 'LEFT';
|
||||||
if (t < 0) {
|
const pivot = isLeftVane ? JUNCTION.leftPivot : JUNCTION.rightPivot;
|
||||||
const p = at(-PUSHER.homeDistance);
|
const yaw = isLeftVane ? g.leftYawRad : g.rightYawRad;
|
||||||
return { active: true, category, phase: 'armed', ...p, yaw, speed: 0 };
|
const x = pivot.x - Math.cos(yaw) * hx;
|
||||||
}
|
const z = pivot.z + Math.sin(yaw) * hx;
|
||||||
if (t < strokeSec) {
|
const phaseMap: Record<DiverterPhase, GatePhase> = {
|
||||||
const p = at(-PUSHER.homeDistance + PUSHER.strokeSpeed * t);
|
READY: 'rest',
|
||||||
return { active: true, category, phase: 'extending', ...p, yaw, speed: PUSHER.strokeSpeed };
|
OPENING: 'opening',
|
||||||
}
|
HOLDING: 'hold',
|
||||||
const tHold = t - strokeSec;
|
CLOSING: 'closing',
|
||||||
if (tHold < PUSHER.holdSec) {
|
};
|
||||||
const p = at(-PUSHER.homeDistance + PUSHER.strokeLength);
|
return {
|
||||||
return { active: true, category, phase: 'hold', ...p, yaw, speed: 0 };
|
active: true,
|
||||||
}
|
category: isLeftVane ? 'C' : 'D',
|
||||||
const tRet = tHold - PUSHER.holdSec;
|
phase: phaseMap[g.phase],
|
||||||
if (tRet < retractSec) {
|
x,
|
||||||
const p = at(-PUSHER.homeDistance + PUSHER.strokeLength - PUSHER.retractSpeed * tRet);
|
z,
|
||||||
return { active: true, category, phase: 'retracting', ...p, yaw, speed: -PUSHER.retractSpeed };
|
yaw,
|
||||||
}
|
speed: 0,
|
||||||
const p = at(-PUSHER.homeDistance);
|
};
|
||||||
return { active: true, category, phase: 'rest', ...p, yaw, speed: 0 };
|
}
|
||||||
|
|
||||||
|
void BELT_TOP_Y;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Product-synchronized diverter timing (does NOT alter verified CAD kinematics)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Matches ConveyorCadModel verified visual angular speed (do not change). */
|
||||||
|
export const DIVERTER_VISUAL_ANGULAR_SPEED_DEG_PER_SEC = 90;
|
||||||
|
/** Verified signed active angles (do not change). */
|
||||||
|
export const DIVERTER_LEFT_SIGNED_DEG = -45;
|
||||||
|
export const DIVERTER_RIGHT_SIGNED_DEG = 45;
|
||||||
|
|
||||||
|
export const OPENING_SAFETY_MARGIN_SEC = 0.15;
|
||||||
|
export const CLEARANCE_MARGIN_M = 0.05;
|
||||||
|
export const ANGLE_ARRIVE_TOL_RAD = (0.5 * Math.PI) / 180;
|
||||||
|
|
||||||
|
/** Classifier confirms route after the item leaves the scan frustum. */
|
||||||
|
export const CLASSIFIER_PLANE_S = SCAN_END_X;
|
||||||
|
|
||||||
|
export type PhysicalRoute = 'STRAIGHT' | 'PHYSICAL_LEFT' | 'PHYSICAL_RIGHT';
|
||||||
|
export type DiverterProductPhase = 'READY' | 'ARMED' | 'OPENING' | 'HOLDING' | 'CLOSING';
|
||||||
|
export type ActiveCadDiverter = 'LEFT' | 'RIGHT' | 'NONE';
|
||||||
|
|
||||||
|
export interface DiverterRouteCommand {
|
||||||
|
productId: string;
|
||||||
|
category: 'B' | 'C' | 'D';
|
||||||
|
physicalRoute: PhysicalRoute;
|
||||||
|
assignedAt: number;
|
||||||
|
consumed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiverterPlaneSet {
|
||||||
|
classifierPlaneS: number;
|
||||||
|
contactPlaneS: number;
|
||||||
|
hingePlaneS: number;
|
||||||
|
clearPlaneS: number;
|
||||||
|
diverterLengthM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map classifier category → physical route from basket entry coordinates
|
||||||
|
* relative to lateral axis (+Z = physical LEFT, −Z = physical RIGHT).
|
||||||
|
*/
|
||||||
|
export function categoryToPhysicalRoute(
|
||||||
|
category: 'B' | 'C' | 'D',
|
||||||
|
): PhysicalRoute {
|
||||||
|
if (category === 'B') return 'STRAIGHT';
|
||||||
|
// JUNCTION entries are the source of truth for chute mouths.
|
||||||
|
if (category === JUNCTION.leftEntry.category) return 'PHYSICAL_LEFT';
|
||||||
|
if (category === JUNCTION.rightEntry.category) return 'PHYSICAL_RIGHT';
|
||||||
|
// Fallback by receiver Z sign vs centerline.
|
||||||
|
const z = category === 'C' ? ZONES.C.z : ZONES.D.z;
|
||||||
|
if (z > 0) return 'PHYSICAL_LEFT';
|
||||||
|
if (z < 0) return 'PHYSICAL_RIGHT';
|
||||||
|
return 'STRAIGHT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function physicalRouteToActiveDiverter(
|
||||||
|
route: PhysicalRoute,
|
||||||
|
): ActiveCadDiverter {
|
||||||
|
if (route === 'PHYSICAL_LEFT') return 'LEFT';
|
||||||
|
if (route === 'PHYSICAL_RIGHT') return 'RIGHT';
|
||||||
|
return 'NONE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signedAngleForRoute(route: PhysicalRoute): number {
|
||||||
|
if (route === 'PHYSICAL_LEFT') {
|
||||||
|
return (DIVERTER_LEFT_SIGNED_DEG * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
if (route === 'PHYSICAL_RIGHT') {
|
||||||
|
return (DIVERTER_RIGHT_SIGNED_DEG * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let routeMappingLogged = false;
|
||||||
|
/** Idempotent hook — mapping is observable via categoryToPhysicalRoute. */
|
||||||
|
export function logDiverterRouteMappingOnce(): void {
|
||||||
|
if (routeMappingLogged) return;
|
||||||
|
routeMappingLogged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build longitudinal planes from parked CAD hinge + verified length/angle. */
|
||||||
|
export function buildDiverterPlanes(
|
||||||
|
hingeS: number,
|
||||||
|
diverterLengthM: number,
|
||||||
|
): DiverterPlaneSet {
|
||||||
|
const absAng = Math.abs((DIVERTER_LEFT_SIGNED_DEG * Math.PI) / 180);
|
||||||
|
// Most-upstream point of the open diagonal (free-end X).
|
||||||
|
const contactPlaneS = hingeS - diverterLengthM * Math.cos(absAng);
|
||||||
|
const clearPlaneS = hingeS + CLEARANCE_MARGIN_M;
|
||||||
|
return {
|
||||||
|
classifierPlaneS: CLASSIFIER_PLANE_S,
|
||||||
|
contactPlaneS,
|
||||||
|
hingePlaneS: hingeS,
|
||||||
|
clearPlaneS,
|
||||||
|
diverterLengthM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotationDurationSec(): number {
|
||||||
|
const ang = Math.abs((DIVERTER_LEFT_SIGNED_DEG * Math.PI) / 180);
|
||||||
|
const spd = (DIVERTER_VISUAL_ANGULAR_SPEED_DEG_PER_SEC * Math.PI) / 180;
|
||||||
|
return ang / spd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiverterProductMachineState {
|
||||||
|
phase: DiverterProductPhase;
|
||||||
|
active: DiverterRouteCommand | null;
|
||||||
|
pending: DiverterRouteCommand | null;
|
||||||
|
leftTargetRad: number;
|
||||||
|
rightTargetRad: number;
|
||||||
|
openLoggedFor: string | null;
|
||||||
|
closeLoggedFor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiverterProductMachine(): DiverterProductMachineState {
|
||||||
|
return {
|
||||||
|
phase: 'READY',
|
||||||
|
active: null,
|
||||||
|
pending: null,
|
||||||
|
leftTargetRad: 0,
|
||||||
|
rightTargetRad: 0,
|
||||||
|
openLoggedFor: null,
|
||||||
|
closeLoggedFor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetDiverterProductMachine(
|
||||||
|
m: DiverterProductMachineState,
|
||||||
|
): void {
|
||||||
|
m.phase = 'READY';
|
||||||
|
m.active = null;
|
||||||
|
m.pending = null;
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
m.openLoggedFor = null;
|
||||||
|
m.closeLoggedFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiverterProductStepInput {
|
||||||
|
productId: string | null;
|
||||||
|
category: 'B' | 'C' | 'D' | null;
|
||||||
|
itemCenterS: number;
|
||||||
|
itemHalfLengthS: number;
|
||||||
|
itemSpeedMps: number;
|
||||||
|
leftCurrentRad: number;
|
||||||
|
rightCurrentRad: number;
|
||||||
|
planes: DiverterPlaneSet;
|
||||||
|
paused: boolean;
|
||||||
|
reset: boolean;
|
||||||
|
nowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiverterProductStepResult {
|
||||||
|
phase: DiverterProductPhase;
|
||||||
|
leftTargetRad: number;
|
||||||
|
rightTargetRad: number;
|
||||||
|
activeDiverter: ActiveCadDiverter;
|
||||||
|
command: DiverterRouteCommand | null;
|
||||||
|
itemFrontS: number;
|
||||||
|
itemRearS: number;
|
||||||
|
distanceToContact: number;
|
||||||
|
requiredLeadDistance: number;
|
||||||
|
timeToContact: number;
|
||||||
|
telemetry: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTargetsForRoute(
|
||||||
|
m: DiverterProductMachineState,
|
||||||
|
route: PhysicalRoute,
|
||||||
|
): void {
|
||||||
|
if (route === 'PHYSICAL_LEFT') {
|
||||||
|
m.leftTargetRad = signedAngleForRoute(route);
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
} else if (route === 'PHYSICAL_RIGHT') {
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = signedAngleForRoute(route);
|
||||||
|
} else {
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function anglesNearZero(left: number, right: number): boolean {
|
||||||
|
return Math.abs(left) <= ANGLE_ARRIVE_TOL_RAD
|
||||||
|
&& Math.abs(right) <= ANGLE_ARRIVE_TOL_RAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeAngleReached(
|
||||||
|
route: PhysicalRoute,
|
||||||
|
left: number,
|
||||||
|
right: number,
|
||||||
|
): boolean {
|
||||||
|
if (route === 'PHYSICAL_LEFT') {
|
||||||
|
return Math.abs(left - signedAngleForRoute(route)) <= ANGLE_ARRIVE_TOL_RAD
|
||||||
|
&& Math.abs(right) <= ANGLE_ARRIVE_TOL_RAD;
|
||||||
|
}
|
||||||
|
if (route === 'PHYSICAL_RIGHT') {
|
||||||
|
return Math.abs(right - signedAngleForRoute(route)) <= ANGLE_ARRIVE_TOL_RAD
|
||||||
|
&& Math.abs(left) <= ANGLE_ARRIVE_TOL_RAD;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance product-tied diverter state. Angle interpolation stays in the visual
|
||||||
|
* layer (ConveyorCadModel); this only sets targets + phase.
|
||||||
|
*/
|
||||||
|
export function stepDiverterProductMachine(
|
||||||
|
m: DiverterProductMachineState,
|
||||||
|
input: DiverterProductStepInput,
|
||||||
|
): DiverterProductStepResult {
|
||||||
|
logDiverterRouteMappingOnce();
|
||||||
|
|
||||||
|
if (input.reset) {
|
||||||
|
resetDiverterProductMachine(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemFrontS = input.itemCenterS + input.itemHalfLengthS;
|
||||||
|
const itemRearS = input.itemCenterS - input.itemHalfLengthS;
|
||||||
|
const speed = Math.max(input.itemSpeedMps, 0.05);
|
||||||
|
const rotDur = rotationDurationSec();
|
||||||
|
const requiredLeadDistance = speed * (rotDur + OPENING_SAFETY_MARGIN_SEC);
|
||||||
|
const distanceToContact = input.planes.contactPlaneS - itemFrontS;
|
||||||
|
const timeToContact = distanceToContact / speed;
|
||||||
|
|
||||||
|
// Enqueue / arm route command for a concrete productId after classifier plane.
|
||||||
|
if (
|
||||||
|
input.productId
|
||||||
|
&& input.category
|
||||||
|
&& itemFrontS >= input.planes.classifierPlaneS
|
||||||
|
) {
|
||||||
|
const physicalRoute = categoryToPhysicalRoute(input.category);
|
||||||
|
const sameActive = m.active?.productId === input.productId;
|
||||||
|
const samePending = m.pending?.productId === input.productId;
|
||||||
|
if (!sameActive && !samePending) {
|
||||||
|
const cmd: DiverterRouteCommand = {
|
||||||
|
productId: input.productId,
|
||||||
|
category: input.category,
|
||||||
|
physicalRoute,
|
||||||
|
assignedAt: input.nowMs,
|
||||||
|
consumed: false,
|
||||||
|
};
|
||||||
|
if (m.phase === 'READY' && !m.active) {
|
||||||
|
m.active = cmd;
|
||||||
|
m.phase = 'ARMED';
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
m.openLoggedFor = null;
|
||||||
|
m.closeLoggedFor = null;
|
||||||
|
} else if (!m.active || m.active.productId !== input.productId) {
|
||||||
|
// Do not overwrite active command.
|
||||||
|
if (!m.pending) m.pending = cmd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.paused && m.active) {
|
||||||
|
const route = m.active.physicalRoute;
|
||||||
|
const activeDiv = physicalRouteToActiveDiverter(route);
|
||||||
|
|
||||||
|
if (m.phase === 'ARMED') {
|
||||||
|
if (route === 'STRAIGHT') {
|
||||||
|
// No motion; complete after rear clears.
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
if (itemRearS > input.planes.clearPlaneS) {
|
||||||
|
m.active.consumed = true;
|
||||||
|
m.active = null;
|
||||||
|
m.phase = 'READY';
|
||||||
|
if (m.pending) {
|
||||||
|
m.active = m.pending;
|
||||||
|
m.pending = null;
|
||||||
|
m.phase = 'ARMED';
|
||||||
|
m.openLoggedFor = null;
|
||||||
|
m.closeLoggedFor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
distanceToContact <= requiredLeadDistance
|
||||||
|
&& itemRearS < input.planes.clearPlaneS
|
||||||
|
) {
|
||||||
|
m.phase = 'OPENING';
|
||||||
|
setTargetsForRoute(m, route);
|
||||||
|
if (m.openLoggedFor !== m.active.productId) {
|
||||||
|
m.openLoggedFor = m.active.productId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (m.phase === 'OPENING') {
|
||||||
|
setTargetsForRoute(m, route);
|
||||||
|
if (activeAngleReached(route, input.leftCurrentRad, input.rightCurrentRad)) {
|
||||||
|
m.phase = 'HOLDING';
|
||||||
|
} else if (itemRearS > input.planes.clearPlaneS) {
|
||||||
|
// Product cleared before full open (edge case) — still close safely.
|
||||||
|
m.phase = 'CLOSING';
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
}
|
||||||
|
} else if (m.phase === 'HOLDING') {
|
||||||
|
setTargetsForRoute(m, route);
|
||||||
|
if (itemRearS > input.planes.clearPlaneS) {
|
||||||
|
m.phase = 'CLOSING';
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
if (m.closeLoggedFor !== m.active.productId) {
|
||||||
|
m.closeLoggedFor = m.active.productId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (m.phase === 'CLOSING') {
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
if (anglesNearZero(input.leftCurrentRad, input.rightCurrentRad)) {
|
||||||
|
if (m.active) m.active.consumed = true;
|
||||||
|
m.active = null;
|
||||||
|
m.phase = 'READY';
|
||||||
|
if (m.pending) {
|
||||||
|
m.active = m.pending;
|
||||||
|
m.pending = null;
|
||||||
|
m.phase = 'ARMED';
|
||||||
|
m.openLoggedFor = null;
|
||||||
|
m.closeLoggedFor = null;
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case advanced / active product replaced → finish current command safely.
|
||||||
|
// Do NOT treat the next product's S as the active product's rear edge.
|
||||||
|
if (
|
||||||
|
m.active
|
||||||
|
&& input.productId
|
||||||
|
&& input.productId !== m.active.productId
|
||||||
|
&& m.phase !== 'READY'
|
||||||
|
&& m.phase !== 'CLOSING'
|
||||||
|
) {
|
||||||
|
if (m.phase === 'ARMED' && m.active.physicalRoute === 'STRAIGHT') {
|
||||||
|
m.active.consumed = true;
|
||||||
|
m.active = m.pending;
|
||||||
|
m.pending = null;
|
||||||
|
m.phase = m.active ? 'ARMED' : 'READY';
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
m.openLoggedFor = null;
|
||||||
|
m.closeLoggedFor = null;
|
||||||
|
} else {
|
||||||
|
m.phase = 'CLOSING';
|
||||||
|
m.leftTargetRad = 0;
|
||||||
|
m.rightTargetRad = 0;
|
||||||
|
if (m.closeLoggedFor !== m.active.productId) {
|
||||||
|
m.closeLoggedFor = m.active.productId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDiverter = m.active
|
||||||
|
? physicalRouteToActiveDiverter(m.active.physicalRoute)
|
||||||
|
: 'NONE';
|
||||||
|
|
||||||
|
const telemetry = {
|
||||||
|
productId: m.active?.productId ?? input.productId,
|
||||||
|
category: m.active?.category ?? input.category,
|
||||||
|
physicalRoute: m.active?.physicalRoute ?? null,
|
||||||
|
motionState: m.phase,
|
||||||
|
itemFrontS: +itemFrontS.toFixed(4),
|
||||||
|
itemRearS: +itemRearS.toFixed(4),
|
||||||
|
distanceToContact: +distanceToContact.toFixed(4),
|
||||||
|
requiredLeadDistance: +requiredLeadDistance.toFixed(4),
|
||||||
|
timeToContact: +timeToContact.toFixed(4),
|
||||||
|
leftCurrentDeg: +((input.leftCurrentRad * 180) / Math.PI).toFixed(2),
|
||||||
|
leftTargetDeg: +((m.leftTargetRad * 180) / Math.PI).toFixed(2),
|
||||||
|
rightCurrentDeg: +((input.rightCurrentRad * 180) / Math.PI).toFixed(2),
|
||||||
|
rightTargetDeg: +((m.rightTargetRad * 180) / Math.PI).toFixed(2),
|
||||||
|
activeDiverter,
|
||||||
|
contactPlaneS: +input.planes.contactPlaneS.toFixed(4),
|
||||||
|
clearPlaneS: +input.planes.clearPlaneS.toFixed(4),
|
||||||
|
pendingProductId: m.pending?.productId ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: m.phase,
|
||||||
|
leftTargetRad: m.leftTargetRad,
|
||||||
|
rightTargetRad: m.rightTargetRad,
|
||||||
|
activeDiverter,
|
||||||
|
command: m.active,
|
||||||
|
itemFrontS,
|
||||||
|
itemRearS,
|
||||||
|
distanceToContact,
|
||||||
|
requiredLeadDistance,
|
||||||
|
timeToContact,
|
||||||
|
telemetry,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const PRESETS: Record<QualityMode, QualitySettings> = {
|
|||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
mode: 'high',
|
mode: 'high',
|
||||||
dprMax: 1.25,
|
dprMax: 1.5,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
shadows: true,
|
shadows: true,
|
||||||
maxVisibleItems: 6,
|
maxVisibleItems: 6,
|
||||||
@@ -49,8 +49,8 @@ const PRESETS: Record<QualityMode, QualitySettings> = {
|
|||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
mode: 'demo',
|
mode: 'demo',
|
||||||
// Stage 2D: cap DPR 1.25 (budget); raise only via ?cinematic=full / quality override
|
// Desktop polish: 1.5 DPR keeps edges sharp without a heavy post stack.
|
||||||
dprMax: 1.25,
|
dprMax: 1.5,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
shadows: true,
|
shadows: true,
|
||||||
maxVisibleItems: 4,
|
maxVisibleItems: 4,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe('simulation state machine', () => {
|
|||||||
let state = createSimulation(scenario('c_priority'));
|
let state = createSimulation(scenario('c_priority'));
|
||||||
state = stepSimulationToNextState(state);
|
state = stepSimulationToNextState(state);
|
||||||
expect(state.currentItem!.classification.category).toBe('C');
|
expect(state.currentItem!.classification.category).toBe('C');
|
||||||
expect(state.currentItem!.item.roundness).toBeGreaterThanOrEqual(0.7);
|
expect(state.currentItem!.item.roundness).toBeGreaterThan(0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('jam scenario enters FAULT', () => {
|
it('jam scenario enters FAULT', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Stage 2C — unit tests for CAD assembly params + runtime/headless physics hash.
|
* Stage 2C — CAD assembly params + runtime/headless physics hash.
|
||||||
|
* Updated for active conveyor-clean.glb + CAD diverter vane extents.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
buildPhysicsConfigSnapshot,
|
buildPhysicsConfigSnapshot,
|
||||||
} from './physicsConfigHash';
|
} from './physicsConfigHash';
|
||||||
import { SIM_DT } from './physicsDropSim';
|
import { SIM_DT } from './physicsDropSim';
|
||||||
|
import { GATE_VANE } from './pusherMotion';
|
||||||
|
import conveyorSource from '../components/ThreeD/ConveyorCadModel.tsx?raw';
|
||||||
|
|
||||||
describe('Stage 2C CAD assembly params', () => {
|
describe('Stage 2C CAD assembly params', () => {
|
||||||
it('preserves official belt width and height', () => {
|
it('preserves official belt width and height', () => {
|
||||||
@@ -28,6 +31,15 @@ describe('Stage 2C CAD assembly params', () => {
|
|||||||
expect(aabb.min[1]).toBeGreaterThan(0.4);
|
expect(aabb.min[1]).toBeGreaterThan(0.4);
|
||||||
expect(aabb.max[1]).toBeLessThan(0.8);
|
expect(aabb.max[1]).toBeLessThan(0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('runtime loads conveyor-clean.glb and does not mount generated mechanism assets', () => {
|
||||||
|
expect(conveyorSource).toMatch(
|
||||||
|
/CONVEYOR_CAD_URL\s*=\s*['"]\/models\/sorter\/conveyor-clean\.glb['"]/,
|
||||||
|
);
|
||||||
|
expect(conveyorSource).not.toMatch(/mechanism-final\.glb/);
|
||||||
|
expect(conveyorSource).not.toMatch(/mechanism-mount-final\.glb/);
|
||||||
|
expect(conveyorSource).not.toMatch(/conveyor-web\.glb/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Stage 2C runtime/headless physics parity', () => {
|
describe('Stage 2C runtime/headless physics parity', () => {
|
||||||
@@ -43,10 +55,10 @@ describe('Stage 2C runtime/headless physics parity', () => {
|
|||||||
expect(parity.runtime).toMatch(/^[a-f0-9]{16}$/);
|
expect(parity.runtime).toMatch(/^[a-f0-9]{16}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('snapshot includes static colliders and pusher geometry', () => {
|
it('snapshot includes static colliders and current CAD vane geometry', () => {
|
||||||
const snap = buildPhysicsConfigSnapshot(PHYSICS_TIMESTEP_SEC);
|
const snap = buildPhysicsConfigSnapshot(PHYSICS_TIMESTEP_SEC);
|
||||||
expect(snap.staticColliderCount).toBeGreaterThan(5);
|
expect(snap.staticColliderCount).toBeGreaterThan(5);
|
||||||
expect(snap.pusherHalfExtents[0]).toBe(0.5);
|
expect(snap.pusherHalfExtents).toEqual([...GATE_VANE.halfExtents]);
|
||||||
expect(snap.gravity).toEqual([0, -9.81, 0]);
|
expect(snap.gravity).toEqual([0, -9.81, 0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { PUSHER } from './pusherMotion';
|
import {
|
||||||
|
GATE_VANE,
|
||||||
|
PUSHER,
|
||||||
|
DIVERTER_LEFT_SIGNED_DEG,
|
||||||
|
DIVERTER_RIGHT_SIGNED_DEG,
|
||||||
|
OPENING_SAFETY_MARGIN_SEC,
|
||||||
|
rotationDurationSec,
|
||||||
|
categoryToPhysicalRoute,
|
||||||
|
physicalRouteToActiveDiverter,
|
||||||
|
} from './pusherMotion';
|
||||||
import { assertRuntimeHeadlessParity } from './physicsConfigHash';
|
import { assertRuntimeHeadlessParity } from './physicsConfigHash';
|
||||||
import { getQualitySettings } from './qualityMode';
|
import { getQualitySettings } from './qualityMode';
|
||||||
|
|
||||||
describe('Stage 2D mechanism + performance policy', () => {
|
describe('Stage 2D mechanism + performance policy', () => {
|
||||||
it('keeps documented paddle dimensions for SPEC_DERIVED_CAD', () => {
|
it('keeps frozen CAD diverter kinematics (angles, duration, vane size)', () => {
|
||||||
expect(PUSHER.halfExtents).toEqual([0.5, 0.14, 0.025]);
|
expect(GATE_VANE.halfExtents).toEqual([0.375, 0.05, 0.02]);
|
||||||
expect(PUSHER.strokeLength).toBe(1.62);
|
expect(PUSHER.halfExtents).toEqual(GATE_VANE.halfExtents);
|
||||||
|
expect(GATE_VANE.activeDeg).toBe(45);
|
||||||
|
expect(DIVERTER_LEFT_SIGNED_DEG).toBe(-45);
|
||||||
|
expect(DIVERTER_RIGHT_SIGNED_DEG).toBe(45);
|
||||||
|
expect(rotationDurationSec()).toBeCloseTo(0.5, 5);
|
||||||
|
expect(OPENING_SAFETY_MARGIN_SEC).toBe(0.15);
|
||||||
|
expect(PUSHER.holdSec).toBe(GATE_VANE.holdSec);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps demo/high DPR at 1.25', () => {
|
it('maps B/C/D to straight / left / right CAD diverters', () => {
|
||||||
expect(getQualitySettings('demo').dprMax).toBe(1.25);
|
expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT');
|
||||||
expect(getQualitySettings('high').dprMax).toBe(1.25);
|
expect(physicalRouteToActiveDiverter('STRAIGHT')).toBe('NONE');
|
||||||
|
expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT');
|
||||||
|
expect(physicalRouteToActiveDiverter('PHYSICAL_LEFT')).toBe('LEFT');
|
||||||
|
expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT');
|
||||||
|
expect(physicalRouteToActiveDiverter('PHYSICAL_RIGHT')).toBe('RIGHT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps demo/high DPR at 1.5 for desktop polish', () => {
|
||||||
|
expect(getQualitySettings('demo').dprMax).toBe(1.5);
|
||||||
|
expect(getQualitySettings('high').dprMax).toBe(1.5);
|
||||||
|
expect(getQualitySettings('medium').dprMax).toBeLessThanOrEqual(1.25);
|
||||||
|
expect(getQualitySettings('low').dprMax).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves runtime/headless hash parity', () => {
|
it('preserves runtime/headless hash parity', () => {
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useRef } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Header from '../components/Header';
|
|
||||||
import HeroSection from '../components/HeroSection';
|
|
||||||
import ProductDemoSection from '../components/ProductDemoSection';
|
|
||||||
import StorylineStepper from '../components/StorylineStepper';
|
|
||||||
import ScenarioCards from '../components/ScenarioCards';
|
|
||||||
import CPriorityExplanation from '../components/CPriorityExplanation';
|
|
||||||
import CriteriaCards from '../components/CriteriaCards';
|
|
||||||
import EngineeringDetails, { type EngineeringDetailsHandle } from '../components/EngineeringDetails';
|
|
||||||
import { DEMO_STEPS } from '../data/demoSteps';
|
|
||||||
import { SCENARIOS } from '../data/scenarios';
|
|
||||||
import type { ScenarioId, SimulationState } from '../domain/types';
|
|
||||||
import type { DemoDirectorState } from '../domain/demoDirector';
|
|
||||||
import type { ContinuousPlaybackState } from '../domain/continuousPlayback';
|
|
||||||
|
|
||||||
function scrollToId(id: string) {
|
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailsPageProps {
|
|
||||||
playback: ContinuousPlaybackState;
|
|
||||||
simulation: SimulationState;
|
|
||||||
demoStepIndex: number;
|
|
||||||
demoDirector: DemoDirectorState;
|
|
||||||
activeScenarioId: ScenarioId;
|
|
||||||
onStartDemo: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onReset: () => void;
|
|
||||||
onScenarioChange: (id: ScenarioId) => void;
|
|
||||||
onStartAutoDemo: () => void;
|
|
||||||
onToggleAutoDemo: () => void;
|
|
||||||
onStopAutoDemo: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DetailsPage({
|
|
||||||
playback,
|
|
||||||
simulation,
|
|
||||||
demoStepIndex,
|
|
||||||
demoDirector,
|
|
||||||
activeScenarioId,
|
|
||||||
onStartDemo,
|
|
||||||
onNext,
|
|
||||||
onReset,
|
|
||||||
onScenarioChange,
|
|
||||||
onStartAutoDemo,
|
|
||||||
onToggleAutoDemo,
|
|
||||||
onStopAutoDemo,
|
|
||||||
}: DetailsPageProps) {
|
|
||||||
const engineeringRef = useRef<EngineeringDetailsHandle>(null);
|
|
||||||
|
|
||||||
const currentDemoStep = DEMO_STEPS[demoStepIndex];
|
|
||||||
|
|
||||||
const handleOpenEngineering = () => {
|
|
||||||
engineeringRef.current?.open();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowScenarios = () => {
|
|
||||||
scrollToId('scenarios');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowCPriority = () => {
|
|
||||||
onScenarioChange('c_priority');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="product-page details-page">
|
|
||||||
<Header
|
|
||||||
simulation={simulation}
|
|
||||||
onReset={onReset}
|
|
||||||
onOpenDemo={() => scrollToId('demo')}
|
|
||||||
onOpenScenarios={handleShowScenarios}
|
|
||||||
onOpenEngineering={handleOpenEngineering}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Back to main demo link */}
|
|
||||||
<div className="back-to-main">
|
|
||||||
<Link to="/" className="back-link">← Back to Full-Screen Demo</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="product-main">
|
|
||||||
<HeroSection
|
|
||||||
onStartDemo={onStartDemo}
|
|
||||||
onShowScenarios={handleShowScenarios}
|
|
||||||
onOpenEngineering={handleOpenEngineering}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProductDemoSection
|
|
||||||
playback={playback}
|
|
||||||
simulation={simulation}
|
|
||||||
demoStepTitle={currentDemoStep.title}
|
|
||||||
demoDirector={demoDirector}
|
|
||||||
onStartDemo={onStartDemo}
|
|
||||||
onNext={onNext}
|
|
||||||
onReset={onReset}
|
|
||||||
onOpenEngineering={handleOpenEngineering}
|
|
||||||
onStartAutoDemo={onStartAutoDemo}
|
|
||||||
onToggleAutoDemo={onToggleAutoDemo}
|
|
||||||
onStopAutoDemo={onStopAutoDemo}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StorylineStepper simulation={simulation} />
|
|
||||||
|
|
||||||
<ScenarioCards
|
|
||||||
scenarios={SCENARIOS}
|
|
||||||
activeScenarioId={activeScenarioId}
|
|
||||||
onScenarioChange={onScenarioChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CPriorityExplanation onShowCPriority={handleShowCPriority} />
|
|
||||||
|
|
||||||
<CriteriaCards onLinkedScenario={onScenarioChange} />
|
|
||||||
|
|
||||||
<EngineeringDetails
|
|
||||||
ref={engineeringRef}
|
|
||||||
simulation={simulation}
|
|
||||||
onScenarioChange={onScenarioChange}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="site-footer">
|
|
||||||
<p>OZON Tech Hackathon 2026 · Sorter Simulation · Product Demo</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
/**
|
|
||||||
* /device-test — compact mobile diagnostic overlay (Stage 2E).
|
|
||||||
* No PII beyond UA string; QR contains only public HTTPS URL (no tokens).
|
|
||||||
*/
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import QRCode from 'qrcode';
|
|
||||||
import { decideMobileTier, collectMobileSignals } from '../domain/mobilePolicy';
|
|
||||||
import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL';
|
|
||||||
|
|
||||||
interface GlInfo {
|
|
||||||
vendor: string;
|
|
||||||
renderer: string;
|
|
||||||
webgl2: boolean;
|
|
||||||
software: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readGl(): GlInfo {
|
|
||||||
try {
|
|
||||||
const c = document.createElement('canvas');
|
|
||||||
const gl = c.getContext('webgl2') || c.getContext('webgl');
|
|
||||||
if (!gl) {
|
|
||||||
return { vendor: 'none', renderer: 'none', webgl2: false, software: true };
|
|
||||||
}
|
|
||||||
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
|
|
||||||
const vendor = dbg
|
|
||||||
? String(gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL))
|
|
||||||
: String(gl.getParameter(gl.VENDOR));
|
|
||||||
const renderer = dbg
|
|
||||||
? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL))
|
|
||||||
: String(gl.getParameter(gl.RENDERER));
|
|
||||||
const software = /swiftshader|llvmpipe|softpipe|software/i.test(renderer);
|
|
||||||
return { vendor, renderer, webgl2: !!c.getContext('webgl2'), software };
|
|
||||||
} catch {
|
|
||||||
return { vendor: 'error', renderer: 'error', webgl2: false, software: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prefer permanent HTTPS host; fallback documents preferred permanent URL (no tokens). */
|
|
||||||
function publicTestUrl(): string {
|
|
||||||
const host = window.location.hostname;
|
|
||||||
if (host === 'sorter.arhipovdan.ru' || host.endsWith('.trycloudflare.com')) {
|
|
||||||
return `${window.location.origin}/device-test`;
|
|
||||||
}
|
|
||||||
return 'https://sorter.arhipovdan.ru/device-test';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeviceTestPage() {
|
|
||||||
const webgl = useWebGLSupport();
|
|
||||||
const [gl, setGl] = useState<GlInfo | null>(null);
|
|
||||||
const [fps, setFps] = useState(0);
|
|
||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
|
||||||
const [physP95, setPhysP95] = useState<number | null>(null);
|
|
||||||
const signals = collectMobileSignals();
|
|
||||||
const tier = decideMobileTier(signals);
|
|
||||||
const width = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
|
||||||
const prefer3d = prefer3DByDefault(width, webgl);
|
|
||||||
const qrTarget = useMemo(() => publicTestUrl(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setGl(readGl());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
void QRCode.toDataURL(qrTarget, { width: 220, margin: 1, errorCorrectionLevel: 'M' }).then(
|
|
||||||
(url) => {
|
|
||||||
if (!cancelled) setQrDataUrl(url);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [qrTarget]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
const p = window.__PHYSICS_PERF__;
|
|
||||||
if (p && p.count > 30) setPhysP95(p.p95Ms);
|
|
||||||
}, 1000);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let frames = 0;
|
|
||||||
let start = performance.now();
|
|
||||||
let raf = 0;
|
|
||||||
const loop = (t: number) => {
|
|
||||||
frames += 1;
|
|
||||||
if (t - start >= 1000) {
|
|
||||||
setFps(Math.round((frames * 1000) / (t - start)));
|
|
||||||
frames = 0;
|
|
||||||
start = t;
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(loop);
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(loop);
|
|
||||||
return () => cancelAnimationFrame(raf);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
|
||||||
const isTelegram = /Telegram/i.test(ua);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="device-test-page"
|
|
||||||
data-testid="device-test-page"
|
|
||||||
style={{
|
|
||||||
minHeight: '100dvh',
|
|
||||||
padding: '16px',
|
|
||||||
paddingBottom: 'calc(16px + env(safe-area-inset-bottom))',
|
|
||||||
background: '#0b1220',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ fontSize: 18, marginBottom: 8 }}>Device Test</h1>
|
|
||||||
<p style={{ opacity: 0.7, marginBottom: 16 }}>
|
|
||||||
OZON Tech Sorter — Stage 2E diagnostics (no personal data).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-testid="device-test-qr"
|
|
||||||
style={{
|
|
||||||
marginBottom: 20,
|
|
||||||
padding: 12,
|
|
||||||
border: '1px solid #1e293b',
|
|
||||||
borderRadius: 8,
|
|
||||||
maxWidth: 280,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: '#64748b', marginBottom: 8 }}>Public HTTPS QR</div>
|
|
||||||
{qrDataUrl ? (
|
|
||||||
<img src={qrDataUrl} alt="QR for device-test HTTPS URL" width={220} height={220} />
|
|
||||||
) : (
|
|
||||||
<div style={{ height: 220, display: 'grid', placeItems: 'center', opacity: 0.5 }}>
|
|
||||||
Generating QR…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ marginTop: 8, wordBreak: 'break-all', fontSize: 11, color: '#94a3b8' }}>
|
|
||||||
{qrTarget}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 8, maxWidth: 560 }}>
|
|
||||||
<Row k="UA" v={ua.slice(0, 140)} />
|
|
||||||
<Row k="WebGL support" v={webgl ? 'YES' : 'NO'} />
|
|
||||||
<Row k="Prefer 3D" v={String(prefer3d)} />
|
|
||||||
<Row k="Mobile tier" v={String(tier)} />
|
|
||||||
<Row k="DPR" v={String(typeof window !== 'undefined' ? window.devicePixelRatio : 1)} />
|
|
||||||
<Row
|
|
||||||
k="Viewport"
|
|
||||||
v={typeof window !== 'undefined' ? `${window.innerWidth}×${window.innerHeight}` : '—'}
|
|
||||||
/>
|
|
||||||
<Row k="Page FPS (UI)" v={String(fps)} />
|
|
||||||
<Row
|
|
||||||
k="Physics p95"
|
|
||||||
v={physP95 != null ? `${physP95.toFixed(2)} ms` : 'open /?perf=1 to sample'}
|
|
||||||
/>
|
|
||||||
<Row k="Telegram WebView" v={isTelegram ? 'YES' : 'NO'} />
|
|
||||||
<Row k="GL vendor" v={gl?.vendor ?? '…'} />
|
|
||||||
<Row k="GL renderer" v={gl?.renderer ?? '…'} />
|
|
||||||
<Row k="WebGL2" v={String(gl?.webgl2 ?? false)} />
|
|
||||||
<Row k="Software GL" v={String(gl?.software ?? true)} />
|
|
||||||
<Row k="Orientation" v={typeof screen !== 'undefined' ? String(screen.orientation?.type ?? 'n/a') : '—'} />
|
|
||||||
<Row
|
|
||||||
k="Safe-area bottom"
|
|
||||||
v={
|
|
||||||
typeof getComputedStyle !== 'undefined'
|
|
||||||
? getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)') ||
|
|
||||||
'0px (CSS env)'
|
|
||||||
: '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 24, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<Link to="/" data-testid="device-test-open-sim" style={{ color: '#38bdf8' }}>
|
|
||||||
Open simulation →
|
|
||||||
</Link>
|
|
||||||
<Link to="/?quality=low" style={{ color: '#94a3b8' }}>
|
|
||||||
Mobile Low
|
|
||||||
</Link>
|
|
||||||
<Link to="/?perf=1" style={{ color: '#94a3b8' }}>
|
|
||||||
Perf overlay
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ k, v }: { k: string; v: string }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '140px 1fr',
|
|
||||||
gap: 8,
|
|
||||||
borderBottom: '1px solid #1e293b',
|
|
||||||
paddingBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: '#64748b' }}>{k}</span>
|
|
||||||
<span style={{ wordBreak: 'break-word' }}>{v}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
284
src/pages/DocumentationPage.tsx
Normal file
284
src/pages/DocumentationPage.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import AppNav from '../components/AppNav';
|
||||||
|
import {
|
||||||
|
CONFIRMED_COMPONENT_POLICY,
|
||||||
|
CONFIRMED_LAYOUT,
|
||||||
|
CURRENT_BLOCKERS,
|
||||||
|
DATA_ACQUISITION_SEQUENCE,
|
||||||
|
GATE_STATUS_BOARD,
|
||||||
|
OWNER_INPUT_FORM_NOTE,
|
||||||
|
PRODUCTION_STATUS,
|
||||||
|
ROLLER_DIAMETER_CONFLICT,
|
||||||
|
} from '../data/productionStatusSummary';
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: 'about', title: '1. О проекте' },
|
||||||
|
{ id: 'simulation', title: '2. Веб-симуляция' },
|
||||||
|
{ id: 'production-goal', title: '3. Производственная цель' },
|
||||||
|
{ id: 'architecture', title: '4. Архитектура системы' },
|
||||||
|
{ id: 'author-cad', title: '5. Авторский CAD' },
|
||||||
|
{ id: 'diverter', title: '6. Производственный diverter' },
|
||||||
|
{ id: 'electronics', title: '7. Электроника и BOM' },
|
||||||
|
{ id: 'sensors', title: '8. Датчики' },
|
||||||
|
{ id: 'motor-encoder', title: '9. Двигатель и энкодер' },
|
||||||
|
{ id: 'production-inputs', title: '10. Производственные исходные данные' },
|
||||||
|
{ id: 'blockers', title: '11. Текущие блокеры' },
|
||||||
|
{ id: 'measurement-pack', title: '12. Пакет обмеров' },
|
||||||
|
{ id: 'gates', title: '13. Статусы Gate' },
|
||||||
|
{ id: 'limits', title: '14. Ограничения и допущения' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function TocList({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
return (
|
||||||
|
<ol>
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<a
|
||||||
|
href={`#${s.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.title.replace(/^\d+\.\s*/, '')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentationPage() {
|
||||||
|
return (
|
||||||
|
<div className="docs-page" data-testid="documentation-page">
|
||||||
|
<header className="product-topbar docs-product-topbar" data-testid="product-topbar">
|
||||||
|
<div className="product-brand">
|
||||||
|
<span className="product-brand-mark" aria-hidden="true">
|
||||||
|
O
|
||||||
|
</span>
|
||||||
|
<div className="product-brand-text">
|
||||||
|
<div className="product-brand-title">Ozon Tech Sorter</div>
|
||||||
|
<div className="product-brand-sub">Цифровой двойник линии сортировки</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AppNav variant="solid" />
|
||||||
|
<div className="product-telemetry docs-topbar-meta">
|
||||||
|
<div className="product-tele-item">
|
||||||
|
<span className="product-tele-label">Раздел</span>
|
||||||
|
<span className="product-tele-value">Документация</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<header className="docs-header docs-header-narrow">
|
||||||
|
<div className="docs-brand">
|
||||||
|
<span className="docs-brand-mark" aria-hidden="true">
|
||||||
|
O
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="docs-title">Документация</h1>
|
||||||
|
<p className="docs-subtitle">OZON Tech Sorter — проектная и инженерная сводка</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AppNav variant="solid" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="docs-layout">
|
||||||
|
<aside className="docs-toc docs-toc-desktop" aria-label="Содержание" data-testid="docs-toc-desktop">
|
||||||
|
<TocList />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<details className="docs-toc-mobile" data-testid="docs-toc-mobile">
|
||||||
|
<summary>Содержание</summary>
|
||||||
|
<TocList
|
||||||
|
onNavigate={() => {
|
||||||
|
const el = document.querySelector('.docs-toc-mobile') as HTMLDetailsElement | null;
|
||||||
|
if (el) el.open = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<main className="docs-main">
|
||||||
|
<section className="docs-status-banner" data-testid="docs-production-status">
|
||||||
|
<div className="docs-status-label">Текущий производственный статус</div>
|
||||||
|
<div className="docs-status-value">{PRODUCTION_STATUS.acquisitionPackStatus}</div>
|
||||||
|
<p className="docs-status-note">
|
||||||
|
Пакет сбора данных готов. Это не означает готовность production design, выбор двигателя,
|
||||||
|
энкодера, утверждение diverter или готовность CAD.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="about" className="docs-section">
|
||||||
|
<h2>{SECTIONS[0].title}</h2>
|
||||||
|
<p>
|
||||||
|
Проект — цифровой двойник и инженерный контур линии сортировки Ozon Tech Sorter:
|
||||||
|
конвейер, классификация товаров по категориям B/C/D, stop-gate и будущий производственный
|
||||||
|
diverter. Веб-интерфейс показывает непрерывную симуляцию; производственный CAD diverter
|
||||||
|
пока не создан.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="simulation" className="docs-section">
|
||||||
|
<h2>{SECTIONS[1].title}</h2>
|
||||||
|
<p>
|
||||||
|
Главная страница <code>/</code> — действующая веб-симуляция: CAD-конвейер, товары, движение,
|
||||||
|
классификация, маршруты B/C/D, камеры и элементы управления. Runtime опирается на
|
||||||
|
подтверждённую геометрию twin и не заменяет физический обмер линии.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="production-goal" className="docs-section">
|
||||||
|
<h2>{SECTIONS[2].title}</h2>
|
||||||
|
<p>
|
||||||
|
Цель — промышленная линия в рабочей зоне{' '}
|
||||||
|
<strong>
|
||||||
|
{CONFIRMED_LAYOUT.workspaceMm.length} × {CONFIRMED_LAYOUT.workspaceMm.width} мм
|
||||||
|
</strong>
|
||||||
|
, конвейер шириной {CONFIRMED_LAYOUT.conveyorWidthMm} мм и высотой верха ленты{' '}
|
||||||
|
{CONFIRMED_LAYOUT.conveyorHeightMm} мм. Web digital twin должен в дальнейшем
|
||||||
|
генерироваться из production CAD, а не из неподтверждённых допущений.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="architecture" className="docs-section">
|
||||||
|
<h2>{SECTIONS[3].title}</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Источник геометрии конвейера — авторский CAD / замороженный GLB twin.</li>
|
||||||
|
<li>Классификация и маршрутизация — domain-логика симуляции (не меняется этой UI-задачей).</li>
|
||||||
|
<li>Link027 / Link029 — роль <strong>{CONFIRMED_LAYOUT.link027029Role}</strong>, не diverter.</li>
|
||||||
|
<li>Авторский CAD diverter: <strong>{CONFIRMED_LAYOUT.authorDiverter}</strong>.</li>
|
||||||
|
<li>
|
||||||
|
Последовательность данных: {DATA_ACQUISITION_SEQUENCE.join(' → ')}.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="author-cad" className="docs-section">
|
||||||
|
<h2>{SECTIONS[4].title}</h2>
|
||||||
|
<p>
|
||||||
|
Авторский файл конвейера <code>3d_models/conveer.FCStd</code> и runtime GLB{' '}
|
||||||
|
<code>public/models/sorter/conveyor-clean.glb</code> заморожены по checksum. Изменения
|
||||||
|
байтов этих файлов в рамках UI-задачи запрещены. Point A по чертежу: центр линии на
|
||||||
|
{` ${CONFIRMED_LAYOUT.pointACenterlineFromWorkspaceBottomMm} мм`} от низа рабочей зоны;
|
||||||
|
Point A X, junction и ROOT_BIND требуют физического подтверждения.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="diverter" className="docs-section">
|
||||||
|
<h2>{SECTIONS[5].title}</h2>
|
||||||
|
<p>
|
||||||
|
Производственный CAD diverter создавать нельзя, пока не заполнены обмеры и не сняты
|
||||||
|
геометрические блокеры. Линейный angled pusher отклонён; rotary swing-arm остаётся
|
||||||
|
кандидатом и не утверждён. Архитектура diverter на этой странице не выбирается.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="electronics" className="docs-section">
|
||||||
|
<h2>{SECTIONS[6].title}</h2>
|
||||||
|
<p>
|
||||||
|
Gate 2C (production electrical): статус{' '}
|
||||||
|
<code>{PRODUCTION_STATUS.electricalGateStatus}</code>. В текущем BOM нет принятых
|
||||||
|
production-компонентов; прототипные позиции (в т.ч. MG996R) не переносятся в production
|
||||||
|
без замены. Силовая архитектура и шкаф требуют измерений и решений владельца.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sensors" className="docs-section">
|
||||||
|
<h2>{SECTIONS[7].title}</h2>
|
||||||
|
<p>
|
||||||
|
VL53L0X: <strong>{CONFIRMED_COMPONENT_POLICY.vl53l0x}</strong> — не использовать как
|
||||||
|
производственный или safety-датчик. Производственная сенсорика не зафиксирована.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="motor-encoder" className="docs-section">
|
||||||
|
<h2>{SECTIONS[8].title}</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Двигатель / редуктор / драйвер: {CONFIRMED_COMPONENT_POLICY.productionMotor}.</li>
|
||||||
|
<li>Энкодер: {CONFIRMED_COMPONENT_POLICY.productionEncoder} (BLOCKED_BY_MOTOR_SPEC).</li>
|
||||||
|
<li>MG996R: {CONFIRMED_COMPONENT_POLICY.mg996r}.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Выбор коммерческой модели двигателя или энкодера на этой странице не выполняется —
|
||||||
|
сначала заполняется форма идентификации двигателя из пакета обмеров.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="production-inputs" className="docs-section">
|
||||||
|
<h2>{SECTIONS[9].title}</h2>
|
||||||
|
<p>{OWNER_INPUT_FORM_NOTE}</p>
|
||||||
|
<p>
|
||||||
|
Подтверждённые размеры рабочей зоны и конвейера см. выше. Неподтверждённые величины
|
||||||
|
(диаметры роликов после обмера, массы SKU, токи, safety PL/SIL) не выдаются за факты.
|
||||||
|
</p>
|
||||||
|
<div className="docs-conflict" data-testid="docs-roller-conflict">
|
||||||
|
<strong>Конфликт диаметров роликов</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Author CAD idler: {ROLLER_DIAMETER_CONFLICT.authorCadIdlerMm} мм</li>
|
||||||
|
<li>Runtime idler: {ROLLER_DIAMETER_CONFLICT.runtimeIdlerMm} мм</li>
|
||||||
|
<li>Runtime drive: {ROLLER_DIAMETER_CONFLICT.runtimeDriveMm} мм</li>
|
||||||
|
</ul>
|
||||||
|
<p>Статус: {ROLLER_DIAMETER_CONFLICT.status} — требуется физический обмер.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="blockers" className="docs-section" data-testid="docs-blockers">
|
||||||
|
<h2>{SECTIONS[10].title}</h2>
|
||||||
|
<ul className="docs-blocker-list">
|
||||||
|
{CURRENT_BLOCKERS.map((b) => (
|
||||||
|
<li key={b}>{b}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="measurement-pack" className="docs-section">
|
||||||
|
<h2>{SECTIONS[11].title}</h2>
|
||||||
|
<p>
|
||||||
|
Gate 2D подготовил пакет: протокол обмеров, контрольные точки P0–P12, shot list фото,
|
||||||
|
формы двигателя / энкодера / SKU / скорости / throughput / power / safety и главный{' '}
|
||||||
|
<code>OWNER_PRODUCTION_INPUT_FORM</code>. Статус пакета:{' '}
|
||||||
|
<strong>{PRODUCTION_STATUS.acquisitionPackStatus}</strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Артефакты пакета хранятся вне публичного runtime (ignored path) и не подключаются из
|
||||||
|
браузера напрямую.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="gates" className="docs-section" data-testid="docs-gates">
|
||||||
|
<h2>{SECTIONS[12].title}</h2>
|
||||||
|
<div className="docs-table-scroll" data-testid="docs-table-scroll">
|
||||||
|
<table className="docs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Gate</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{GATE_STATUS_BOARD.map((row) => (
|
||||||
|
<tr key={row.gate}>
|
||||||
|
<td>{row.gate}</td>
|
||||||
|
<td>
|
||||||
|
<code>{row.status}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="limits" className="docs-section">
|
||||||
|
<h2>{SECTIONS[13].title}</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Неутверждённые значения runtime не считаются production truth.</li>
|
||||||
|
<li>MG996R и VL53L0X не являются production/safety решениями.</li>
|
||||||
|
<li>Production CAD diverter и Gate 3 не начаты.</li>
|
||||||
|
<li>Расчёты на неподтверждённых диаметрах роликов / ROOT_BIND запрещены.</li>
|
||||||
|
<li>Эта страница — сводка; она не заменяет заполненный owner measurement pack.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import ThreeErrorBoundary from '../components/ThreeD/ThreeErrorBoundary';
|
import ThreeErrorBoundary from '../components/ThreeD/ThreeErrorBoundary';
|
||||||
import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL';
|
import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL';
|
||||||
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../domain/demoPlaylist';
|
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../domain/demoPlaylist';
|
||||||
@@ -16,6 +15,7 @@ import SorterScene from '../components/SorterScene';
|
|||||||
import { resolveItem } from '../data/resolveItem';
|
import { resolveItem } from '../data/resolveItem';
|
||||||
import BuildIdentityBadge from '../components/BuildIdentityBadge';
|
import BuildIdentityBadge from '../components/BuildIdentityBadge';
|
||||||
import CVInspectionOverlay from '../components/CVInspectionOverlay';
|
import CVInspectionOverlay from '../components/CVInspectionOverlay';
|
||||||
|
import AppNav from '../components/AppNav';
|
||||||
|
|
||||||
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
|
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
|
||||||
|
|
||||||
@@ -266,233 +266,485 @@ export default function MainPage({
|
|||||||
setPresentationMode(true);
|
setPresentationMode(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
const isMobileLayout = width < 768;
|
||||||
<div className={`main-page ${presentationMode ? 'presentation-mode' : ''} ${faultActive ? 'fault-active' : ''}`}>
|
const isDesktopProduct = width >= 1024;
|
||||||
<div className="main-demo-viewport">
|
const fallbackBadgeText =
|
||||||
{render3D && (
|
fallbackReason === 'webgl'
|
||||||
<div className="canvas-holder" style={contextLost ? { visibility: 'hidden', position: 'absolute', inset: 0 } : undefined}>
|
? '3D недоступно в этом браузере — показана облегчённая версия'
|
||||||
<ThreeErrorBoundary
|
: fallbackReason === 'context'
|
||||||
onError={(error) => {
|
? '3D-сцена прервана — показана облегчённая версия'
|
||||||
console.error('3D Canvas failed:', error);
|
: 'Облегчённый режим — мобильное устройство';
|
||||||
handleContextLost();
|
|
||||||
}}
|
|
||||||
onUse2D={handleContextLost}
|
|
||||||
>
|
|
||||||
<Suspense fallback={<div className="three-loading">Loading 3D...</div>}>
|
|
||||||
<SorterDigitalTwinContinuous
|
|
||||||
playback={playback}
|
|
||||||
simplified={simplified}
|
|
||||||
onContextLost={handleContextLost}
|
|
||||||
onContextRestored={handleContextRestored}
|
|
||||||
autoCameraEnabled={autoCameraEnabled}
|
|
||||||
viewportType={viewportType}
|
|
||||||
qualityMode={qualityMode}
|
|
||||||
stage0={stage0}
|
|
||||||
stage1={stage1}
|
|
||||||
debugOverlays={debugMode}
|
|
||||||
physicsDebug={physicsDebug}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</ThreeErrorBoundary>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{fallbackVisible && (
|
|
||||||
<div className="main-fallback" data-testid="main-svg-fallback">
|
|
||||||
<div className={`fallback-badge fallback-badge-${fallbackReason}`} data-testid="fallback-reason">
|
|
||||||
{fallbackReason === 'webgl'
|
|
||||||
? '3D недоступно в этом браузере — показана облегчённая версия'
|
|
||||||
: fallbackReason === 'context'
|
|
||||||
? '3D-сцена прервана — показана облегчённая версия'
|
|
||||||
: 'Облегчённый режим — мобильное устройство'}
|
|
||||||
</div>
|
|
||||||
<Link to="/details" className="fallback-details-link" data-testid="fallback-details-link">
|
|
||||||
Подробнее на странице Details →
|
|
||||||
</Link>
|
|
||||||
<div className="fallback-scene">
|
|
||||||
<SorterScene simulation={fallbackSimulation} variant={width < 640 ? 'simple' : 'full'} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{width >= 768 && measurementMode === 'full' && (
|
const statusHud =
|
||||||
<CVInspectionOverlay data={measurementData} visible={showMeasurement} />
|
!presentationMode && (!stage0.enabled || stage0.hud) ? (
|
||||||
)}
|
<div className="main-hud" data-testid="demo-hud">
|
||||||
{width >= 768 && measurementMode === 'compact' && showMeasurement && (
|
<div className="hud-row">
|
||||||
<div className="cv-overlay cv-overlay-compact" data-testid="measurement-compact">
|
<span className="hud-label">Item</span>
|
||||||
<div className="cv-header">
|
<span className="hud-value">{currentCase.title}</span>
|
||||||
<span className="cv-title">MEAS</span>
|
|
||||||
<span className="cv-status active">{Math.round((measurementData.confidence ?? 0) * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="cv-body">
|
|
||||||
<div className="cv-row cv-compact">
|
|
||||||
<span className="cv-label">L×W×H</span>
|
|
||||||
<span className="cv-value cv-mono">
|
|
||||||
{measurementData.measuredLengthMm}×{measurementData.measuredWidthMm}×{measurementData.measuredHeightMm}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="cv-row cv-compact">
|
|
||||||
<span className="cv-label">K</span>
|
|
||||||
<span className="cv-value cv-mono">{measurementData.roundnessK.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="hud-row">
|
||||||
|
<span className="hud-label">Status</span>
|
||||||
{!presentationMode && (!stage0.enabled || stage0.hud) && (
|
<span
|
||||||
<div className="main-hud" data-testid="demo-hud">
|
className={`hud-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}
|
||||||
|
data-testid="demo-status"
|
||||||
|
>
|
||||||
|
{isFinished ? 'FINISHED' : phaseConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hud-row">
|
||||||
|
<span className="hud-label">Category</span>
|
||||||
|
<span
|
||||||
|
className={`hud-value ${category ? `category-${category}` : ''}`}
|
||||||
|
data-testid="demo-category"
|
||||||
|
>
|
||||||
|
{category ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{debugMode && (
|
||||||
<div className="hud-row">
|
<div className="hud-row">
|
||||||
<span className="hud-label">Item</span>
|
<span className="hud-label">Command</span>
|
||||||
<span className="hud-value">{currentCase.title}</span>
|
<span className="hud-value" data-testid="demo-command">
|
||||||
</div>
|
{command}
|
||||||
<div className="hud-row">
|
|
||||||
<span className="hud-label">Status</span>
|
|
||||||
<span
|
|
||||||
className={`hud-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}
|
|
||||||
data-testid="demo-status"
|
|
||||||
>
|
|
||||||
{isFinished ? 'FINISHED' : phaseConfig.label}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hud-row">
|
)}
|
||||||
<span className="hud-label">Category</span>
|
{playback.classification && (
|
||||||
<span
|
<div className="hud-row hud-row-small" data-testid="demo-proof">
|
||||||
className={`hud-value ${category ? `category-${category}` : ''}`}
|
<span className="hud-value proof-text">
|
||||||
data-testid="demo-category"
|
{playback.classification.dimensionsPass ? 'DIM✓' : 'DIM✗'} · K=
|
||||||
>
|
{resolveItem(currentCase.itemId).roundness.toFixed(2)} · {playback.classification.reason}
|
||||||
{category ?? '—'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{debugMode && (
|
)}
|
||||||
|
{debugMode && (
|
||||||
|
<>
|
||||||
<div className="hud-row">
|
<div className="hud-row">
|
||||||
<span className="hud-label">Command</span>
|
<span className="hud-label">Speed</span>
|
||||||
<span className="hud-value" data-testid="demo-command">{command}</span>
|
<span className="hud-value">{playback.speed.toFixed(1)}× · 1.0 m/s</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="hud-divider" />
|
||||||
{playback.classification && (
|
<div className="hud-row">
|
||||||
<div className="hud-row hud-row-small" data-testid="demo-proof">
|
<span className="hud-label">Case</span>
|
||||||
<span className="hud-value proof-text">
|
<span className="hud-value" data-testid="demo-case-label">
|
||||||
{playback.classification.dimensionsPass ? 'DIM✓' : 'DIM✗'} · K=
|
{playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH}
|
||||||
{resolveItem(currentCase.itemId).roundness.toFixed(2)} · {playback.classification.reason}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="hud-row hud-row-small">
|
||||||
{debugMode && (
|
<span className="hud-value">{currentCase.description}</span>
|
||||||
<>
|
|
||||||
<div className="hud-row">
|
|
||||||
<span className="hud-label">Speed</span>
|
|
||||||
<span className="hud-value">{playback.speed.toFixed(1)}× · 1.0 m/s</span>
|
|
||||||
</div>
|
|
||||||
<div className="hud-divider" />
|
|
||||||
<div className="hud-row">
|
|
||||||
<span className="hud-label">Case</span>
|
|
||||||
<span className="hud-value" data-testid="demo-case-label">
|
|
||||||
{playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="hud-row hud-row-small">
|
|
||||||
<span className="hud-value">{currentCase.description}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{playback.warning && (
|
|
||||||
<div className="hud-row hud-warning">
|
|
||||||
<span className="hud-value warning-text">⚠ {playback.warning}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
{playback.warning && (
|
||||||
|
<div className="hud-row hud-warning">
|
||||||
<div className="main-case-progress">
|
<span className="hud-value warning-text">⚠ {playback.warning}</span>
|
||||||
<div
|
</div>
|
||||||
className={`case-progress-bar ${faultActive ? 'fault' : ''}`}
|
)}
|
||||||
style={{ width: `${caseProgress * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const canvasBlock = render3D ? (
|
||||||
|
<div
|
||||||
|
className="canvas-holder"
|
||||||
|
style={
|
||||||
|
contextLost && !isMobileLayout
|
||||||
|
? { visibility: 'hidden', position: 'absolute', inset: 0 }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ThreeErrorBoundary
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('3D Canvas failed:', error);
|
||||||
|
handleContextLost();
|
||||||
|
}}
|
||||||
|
onUse2D={handleContextLost}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<div className="three-loading">Loading 3D...</div>}>
|
||||||
|
<SorterDigitalTwinContinuous
|
||||||
|
playback={playback}
|
||||||
|
simplified={simplified}
|
||||||
|
onContextLost={handleContextLost}
|
||||||
|
onContextRestored={handleContextRestored}
|
||||||
|
autoCameraEnabled={autoCameraEnabled}
|
||||||
|
viewportType={viewportType}
|
||||||
|
qualityMode={qualityMode}
|
||||||
|
stage0={stage0}
|
||||||
|
stage1={stage1}
|
||||||
|
debugOverlays={debugMode}
|
||||||
|
physicsDebug={physicsDebug}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</ThreeErrorBoundary>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const playbackControls = (
|
||||||
|
<div className="main-controls" data-testid="main-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ctrl-nav"
|
||||||
|
onClick={onSeekPrev}
|
||||||
|
aria-label="Previous case"
|
||||||
|
title="Prev (← / B)"
|
||||||
|
data-testid="demo-prev"
|
||||||
|
>
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="play-button"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
aria-label={isRunning ? 'Pause demo' : 'Play demo'}
|
||||||
|
data-testid={isRunning ? 'demo-pause' : 'demo-play'}
|
||||||
|
>
|
||||||
|
{isRunning ? <span className="play-icon">⏸</span> : <span className="play-icon">▶</span>}
|
||||||
|
<span className="play-text">
|
||||||
|
{isRunning ? 'Pause' : isPaused ? 'Resume' : isFinished ? 'Replay' : 'Play Demo'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ctrl-nav"
|
||||||
|
onClick={onSeekNext}
|
||||||
|
aria-label="Next case"
|
||||||
|
title="Next (→ / N)"
|
||||||
|
data-testid="demo-next"
|
||||||
|
>
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(isRunning || isPaused) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stop-button"
|
||||||
|
onClick={onStop}
|
||||||
|
aria-label="Stop demo"
|
||||||
|
title="Reset (R)"
|
||||||
|
data-testid="demo-stop"
|
||||||
|
>
|
||||||
|
⏹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!presentationMode && debugMode && (
|
{!presentationMode && debugMode && (
|
||||||
<div className="main-progress">
|
<div className="speed-controls" role="group" aria-label="Playback speed">
|
||||||
{DEMO_PLAYLIST.map((c, idx) => (
|
{SPEEDS.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
className={`progress-dot ${idx === playback.currentCaseIndex ? 'active' : ''} ${idx < playback.currentCaseIndex ? 'done' : ''} ${c.faultType ? 'fault-dot' : ''}`}
|
className={`speed-btn ${playback.speed === s ? 'active' : ''}`}
|
||||||
title={`${idx + 1}. ${c.title} → ${c.faultType ?? c.expectedCategory}`}
|
onClick={() => onSetSpeed(s)}
|
||||||
onClick={() => onSeekCase(idx)}
|
data-testid={`demo-speed-${s}`}
|
||||||
aria-label={`Jump to case ${idx + 1}`}
|
>
|
||||||
data-testid={`demo-case-${idx}`}
|
{s}×
|
||||||
/>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="main-controls">
|
const telemetryBlock = (
|
||||||
<button
|
<div className="product-telemetry" data-testid="demo-hud">
|
||||||
type="button"
|
<div className="product-tele-item">
|
||||||
className="ctrl-nav"
|
<span className="product-tele-label">Товар</span>
|
||||||
onClick={onSeekPrev}
|
<span className="product-tele-value">{currentCase.title}</span>
|
||||||
aria-label="Previous case"
|
|
||||||
title="Prev (← / B)"
|
|
||||||
data-testid="demo-prev"
|
|
||||||
>
|
|
||||||
⏮
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="play-button"
|
|
||||||
onClick={handlePlayPause}
|
|
||||||
aria-label={isRunning ? 'Pause demo' : 'Play demo'}
|
|
||||||
data-testid={isRunning ? 'demo-pause' : 'demo-play'}
|
|
||||||
>
|
|
||||||
{isRunning ? (
|
|
||||||
<span className="play-icon">⏸</span>
|
|
||||||
) : (
|
|
||||||
<span className="play-icon">▶</span>
|
|
||||||
)}
|
|
||||||
<span className="play-text">
|
|
||||||
{isRunning ? 'Pause' : isPaused ? 'Resume' : isFinished ? 'Replay' : 'Play Demo'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ctrl-nav"
|
|
||||||
onClick={onSeekNext}
|
|
||||||
aria-label="Next case"
|
|
||||||
title="Next (→ / N)"
|
|
||||||
data-testid="demo-next"
|
|
||||||
>
|
|
||||||
⏭
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{(isRunning || isPaused) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="stop-button"
|
|
||||||
onClick={onStop}
|
|
||||||
aria-label="Stop demo"
|
|
||||||
title="Reset (R)"
|
|
||||||
data-testid="demo-stop"
|
|
||||||
>
|
|
||||||
⏹
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!presentationMode && debugMode && (
|
|
||||||
<div className="speed-controls" role="group" aria-label="Playback speed">
|
|
||||||
{SPEEDS.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
className={`speed-btn ${playback.speed === s ? 'active' : ''}`}
|
|
||||||
onClick={() => onSetSpeed(s)}
|
|
||||||
data-testid={`demo-speed-${s}`}
|
|
||||||
>
|
|
||||||
{s}×
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="product-tele-item">
|
||||||
|
<span className="product-tele-label">Статус</span>
|
||||||
|
<span
|
||||||
|
className={`product-tele-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}
|
||||||
|
data-testid="demo-status"
|
||||||
|
>
|
||||||
|
{isFinished ? 'FINISHED' : phaseConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="product-tele-item">
|
||||||
|
<span className="product-tele-label">Категория</span>
|
||||||
|
<span
|
||||||
|
className={`product-tele-value ${category ? `category-${category}` : ''}`}
|
||||||
|
data-testid="demo-category"
|
||||||
|
>
|
||||||
|
{category ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{debugMode && (
|
||||||
|
<div className="product-tele-item">
|
||||||
|
<span className="product-tele-label">Command</span>
|
||||||
|
<span className="product-tele-value" data-testid="demo-command">
|
||||||
|
{command}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`main-page ${
|
||||||
|
isMobileLayout ? 'main-page--mobile' : isDesktopProduct ? 'main-page--product' : 'main-page--desktop'
|
||||||
|
} ${presentationMode ? 'presentation-mode' : ''} ${faultActive ? 'fault-active' : ''}`}
|
||||||
|
>
|
||||||
|
{!presentationMode && !isDesktopProduct && (
|
||||||
|
<AppNav variant={isMobileLayout ? 'bar' : 'overlay'} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobileLayout ? (
|
||||||
|
<div className="main-mobile-stack" data-testid="main-mobile-stack">
|
||||||
|
{statusHud}
|
||||||
|
|
||||||
|
{fallbackVisible && (
|
||||||
|
<div className="main-fallback" data-testid="main-svg-fallback">
|
||||||
|
<div
|
||||||
|
className={`fallback-badge fallback-badge-${fallbackReason}`}
|
||||||
|
data-testid="fallback-reason"
|
||||||
|
>
|
||||||
|
{fallbackBadgeText}
|
||||||
|
</div>
|
||||||
|
<p className="mobile-capabilities">Конвейер · камера · classifier · gate · зоны B/C/D</p>
|
||||||
|
<div className="fallback-scene" data-testid="fallback-diagram">
|
||||||
|
<SorterScene simulation={fallbackSimulation} variant="simple" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{render3D && !fallbackVisible && (
|
||||||
|
<div className="main-mobile-canvas" data-testid="main-mobile-canvas">
|
||||||
|
{canvasBlock}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="main-case-progress">
|
||||||
|
<div
|
||||||
|
className={`case-progress-bar ${faultActive ? 'fault' : ''}`}
|
||||||
|
style={{ width: `${caseProgress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playbackControls}
|
||||||
|
|
||||||
|
{!presentationMode && (
|
||||||
|
<div className="main-mobile-secondary" data-testid="main-mobile-secondary">
|
||||||
|
{render3D && !contextLost && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auto-camera-toggle"
|
||||||
|
onClick={() => setAutoCameraEnabled(!autoCameraEnabled)}
|
||||||
|
title={
|
||||||
|
autoCameraEnabled
|
||||||
|
? 'Disable auto camera to control manually'
|
||||||
|
: 'Enable cinematic auto camera'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="toggle-icon">{autoCameraEnabled ? '🎬' : '🎥'}</span>
|
||||||
|
<span className="toggle-label">Auto Cam: {autoCameraEnabled ? 'ON' : 'OFF'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="presentation-toggle"
|
||||||
|
onClick={enterFullscreen}
|
||||||
|
title="Presentation (P / F)"
|
||||||
|
data-testid="demo-presentation"
|
||||||
|
>
|
||||||
|
Present
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isDesktopProduct ? (
|
||||||
|
<>
|
||||||
|
{!presentationMode && (
|
||||||
|
<header className="product-topbar" data-testid="product-topbar">
|
||||||
|
<div className="product-brand">
|
||||||
|
<span className="product-brand-mark" aria-hidden="true">
|
||||||
|
O
|
||||||
|
</span>
|
||||||
|
<div className="product-brand-text">
|
||||||
|
<div className="product-brand-title">Ozon Tech Sorter</div>
|
||||||
|
<div className="product-brand-sub">Цифровой двойник линии сортировки</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AppNav variant="solid" />
|
||||||
|
{(!stage0.enabled || stage0.hud) && telemetryBlock}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="main-demo-viewport product-scene">
|
||||||
|
{canvasBlock}
|
||||||
|
{!webgl && (
|
||||||
|
<div className="product-webgl-note" data-testid="main-svg-fallback">
|
||||||
|
<span data-testid="fallback-reason">3D недоступно в этом браузере</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{measurementMode === 'full' && (
|
||||||
|
<CVInspectionOverlay data={measurementData} visible={showMeasurement} />
|
||||||
|
)}
|
||||||
|
{measurementMode === 'compact' && showMeasurement && (
|
||||||
|
<div className="cv-overlay cv-overlay-compact" data-testid="measurement-compact">
|
||||||
|
<div className="cv-header">
|
||||||
|
<span className="cv-title">MEAS</span>
|
||||||
|
<span className="cv-status active">
|
||||||
|
{Math.round((measurementData.confidence ?? 0) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-body">
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">L×W×H</span>
|
||||||
|
<span className="cv-value cv-mono">
|
||||||
|
{measurementData.measuredLengthMm}×{measurementData.measuredWidthMm}×
|
||||||
|
{measurementData.measuredHeightMm}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">K</span>
|
||||||
|
<span className="cv-value cv-mono">{measurementData.roundnessK.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!presentationMode && (
|
||||||
|
<footer className="product-dock" data-testid="product-dock">
|
||||||
|
<div className="product-dock-progress">
|
||||||
|
<div
|
||||||
|
className={`case-progress-bar ${faultActive ? 'fault' : ''}`}
|
||||||
|
style={{ width: `${caseProgress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="product-dock-row">
|
||||||
|
<div className="product-dock-left">
|
||||||
|
{render3D && !contextLost && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auto-camera-toggle"
|
||||||
|
onClick={() => setAutoCameraEnabled(!autoCameraEnabled)}
|
||||||
|
title={
|
||||||
|
autoCameraEnabled
|
||||||
|
? 'Disable auto camera to control manually'
|
||||||
|
: 'Enable cinematic auto camera'
|
||||||
|
}
|
||||||
|
data-testid="auto-camera-toggle"
|
||||||
|
>
|
||||||
|
<span className="toggle-icon">{autoCameraEnabled ? '🎬' : '🎥'}</span>
|
||||||
|
<span className="toggle-label">Auto Cam: {autoCameraEnabled ? 'ON' : 'OFF'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`product-sim-pill status-${playback.status} ${faultActive ? 'status-fault' : ''}`}
|
||||||
|
>
|
||||||
|
{isFinished ? 'FINISHED' : isRunning ? 'RUNNING' : isPaused ? 'PAUSED' : 'IDLE'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{playbackControls}
|
||||||
|
<div className="product-dock-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="presentation-toggle"
|
||||||
|
onClick={enterFullscreen}
|
||||||
|
title="Presentation (P / F)"
|
||||||
|
data-testid="demo-presentation"
|
||||||
|
>
|
||||||
|
Present
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{debugMode && (
|
||||||
|
<div className="hotkey-hint">
|
||||||
|
Space play · N/B seek · 1–0 jump · R reset · P present · E log · F fullscreen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="main-demo-viewport">
|
||||||
|
{canvasBlock}
|
||||||
|
{fallbackVisible && (
|
||||||
|
<div className="main-fallback" data-testid="main-svg-fallback">
|
||||||
|
<div
|
||||||
|
className={`fallback-badge fallback-badge-${fallbackReason}`}
|
||||||
|
data-testid="fallback-reason"
|
||||||
|
>
|
||||||
|
{fallbackBadgeText}
|
||||||
|
</div>
|
||||||
|
<div className="fallback-scene" data-testid="fallback-diagram">
|
||||||
|
<SorterScene simulation={fallbackSimulation} variant={width < 900 ? 'simple' : 'full'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{measurementMode === 'full' && (
|
||||||
|
<CVInspectionOverlay data={measurementData} visible={showMeasurement} />
|
||||||
|
)}
|
||||||
|
{measurementMode === 'compact' && showMeasurement && (
|
||||||
|
<div className="cv-overlay cv-overlay-compact" data-testid="measurement-compact">
|
||||||
|
<div className="cv-header">
|
||||||
|
<span className="cv-title">MEAS</span>
|
||||||
|
<span className="cv-status active">
|
||||||
|
{Math.round((measurementData.confidence ?? 0) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-body">
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">L×W×H</span>
|
||||||
|
<span className="cv-value cv-mono">
|
||||||
|
{measurementData.measuredLengthMm}×{measurementData.measuredWidthMm}×
|
||||||
|
{measurementData.measuredHeightMm}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">K</span>
|
||||||
|
<span className="cv-value cv-mono">{measurementData.roundnessK.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{statusHud}
|
||||||
|
|
||||||
|
<div className="main-case-progress">
|
||||||
|
<div
|
||||||
|
className={`case-progress-bar ${faultActive ? 'fault' : ''}`}
|
||||||
|
style={{ width: `${caseProgress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playbackControls}
|
||||||
|
|
||||||
|
{render3D && !contextLost && !presentationMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auto-camera-toggle"
|
||||||
|
onClick={() => setAutoCameraEnabled(!autoCameraEnabled)}
|
||||||
|
title={
|
||||||
|
autoCameraEnabled
|
||||||
|
? 'Disable auto camera to control manually'
|
||||||
|
: 'Enable cinematic auto camera'
|
||||||
|
}
|
||||||
|
data-testid="auto-camera-toggle"
|
||||||
|
>
|
||||||
|
<span className="toggle-icon">{autoCameraEnabled ? '🎬' : '🎥'}</span>
|
||||||
|
<span className="toggle-label">Auto Cam: {autoCameraEnabled ? 'ON' : 'OFF'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!presentationMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="presentation-toggle"
|
||||||
|
onClick={enterFullscreen}
|
||||||
|
title="Presentation (P / F)"
|
||||||
|
data-testid="demo-presentation"
|
||||||
|
>
|
||||||
|
Present
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFinished && (
|
{isFinished && (
|
||||||
<div className="main-finished-overlay" data-testid="demo-finished">
|
<div className="main-finished-overlay" data-testid="demo-finished">
|
||||||
@@ -520,38 +772,6 @@ export default function MainPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{render3D && !contextLost && !presentationMode && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="auto-camera-toggle"
|
|
||||||
onClick={() => setAutoCameraEnabled(!autoCameraEnabled)}
|
|
||||||
title={autoCameraEnabled ? 'Disable auto camera to control manually' : 'Enable cinematic auto camera'}
|
|
||||||
>
|
|
||||||
<span className="toggle-icon">{autoCameraEnabled ? '🎬' : '🎥'}</span>
|
|
||||||
<span className="toggle-label">Auto Cam: {autoCameraEnabled ? 'ON' : 'OFF'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!presentationMode && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="presentation-toggle"
|
|
||||||
onClick={enterFullscreen}
|
|
||||||
title="Presentation (P / F)"
|
|
||||||
data-testid="demo-presentation"
|
|
||||||
>
|
|
||||||
Present
|
|
||||||
</button>
|
|
||||||
<Link to="/details" className="details-link">
|
|
||||||
Details →
|
|
||||||
</Link>
|
|
||||||
{debugMode && (
|
|
||||||
<div className="hotkey-hint">Space play · N/B seek · 1–0 jump · R reset · P present · E log · F fullscreen</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{presentationMode && (
|
{presentationMode && (
|
||||||
<button type="button" className="presentation-exit" onClick={() => setPresentationMode(false)}>
|
<button type="button" className="presentation-exit" onClick={() => setPresentationMode(false)}>
|
||||||
Exit presentation (P)
|
Exit presentation (P)
|
||||||
|
|||||||
1087
src/styles.css
1087
src/styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user