feat: 3D digital twin major rework - real models, clean labels, improved geometry
- Real STL Models: 6 models loaded (55%), fallback for 5 (45%) - Clean Labels: 12+ → 5 default, step-based, technical toggle - Geometry: Gate lift, pusher plates, conveyor rollers, roll-cage wireframes - Demo Director: 12 steps, camera presets, auto-play logic (not integrated) - Tests: +16 new (43/43 green), Build ✅, Docker ✅, Domains ✅ Remaining: Auto demo UI integration, camera transitions, docs updates, Visual QA
This commit is contained in:
1367
docs/THREE_D_REWORK_FINAL_REPORT.md
Normal file
1367
docs/THREE_D_REWORK_FINAL_REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
549
docs/THREE_D_REWORK_PLAN.md
Normal file
549
docs/THREE_D_REWORK_PLAN.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# 3D Digital Twin Rework Plan
|
||||||
|
|
||||||
|
## Текущие проблемы
|
||||||
|
|
||||||
|
### 1. Labels перекрывают объекты
|
||||||
|
- **Проблема**: `SceneLabels3D.tsx` показывает одновременно 12+ HTML labels:
|
||||||
|
- Зоны A/B/C/D (4)
|
||||||
|
- Накопитель (1)
|
||||||
|
- Sensors: Camera, Laser, Ultrasonic (3)
|
||||||
|
- Actuators: Stop-gate, Pusher C, Pusher D (3)
|
||||||
|
- Routes: Route B, Route C, Route D (3)
|
||||||
|
- ItemProofPanel с полной информацией (1)
|
||||||
|
- Дополнительные: C-priority badge, conveyor info (2+)
|
||||||
|
- **Результат**: labels перекрывают друг друга, закрывают 3D объекты, сцена нечитаема.
|
||||||
|
- **Корневая причина**: нет режима "Clean view" по умолчанию, все labels always visible.
|
||||||
|
|
||||||
|
### 2. Нет автоматической демонстрации
|
||||||
|
- **Проблема**: пользователь должен вручную жать "Next step" для каждого перехода состояния.
|
||||||
|
- **Результат**: демонстрация не идёт сама, нет ощущения непрерывного процесса.
|
||||||
|
- **Корневая причина**: `ProductDemoSection.tsx` управляется вручную через `onNext()`, нет auto-play loop.
|
||||||
|
|
||||||
|
### 3. Не используются реальные 3D модели товаров
|
||||||
|
- **Проблема**: `Item3D.tsx` использует только примитивы:
|
||||||
|
- `isRound ? <cylinderGeometry> : <boxGeometry>`
|
||||||
|
- Нет загрузки STL/GLB моделей из `input_info/extracted/Stl/`
|
||||||
|
- **Результат**: бутылка выглядит как цилиндр, тарелка как цилиндр, короб как куб — все одинаково.
|
||||||
|
- **Доступно**: 11 STL файлов реальных товаров в `input_info/extracted/Stl/`
|
||||||
|
|
||||||
|
### 4. Камера плохо поставлена
|
||||||
|
- **Проблема**: фиксированная камера с `OrbitControls`, пользователь может крутить сцену, но нет guided view.
|
||||||
|
- **Результат**: непонятно, на что смотреть в данный момент.
|
||||||
|
- **Нужно**: camera presets для разных этапов (detection, gate, routing, result).
|
||||||
|
|
||||||
|
### 5. Нет ощущения склада/ячейки
|
||||||
|
- **Проблема**: `Conveyor3D` — простой box, накопитель — box с walls, gate — rotating box, pusher — box.
|
||||||
|
- **Результат**: сцена выглядит как debug sketch, не как digital twin реальной системы.
|
||||||
|
- **Нужно**: realistic geometry — ролики конвейера, стойка с камерой, физическая gate/pusher, roll-cage каркасы.
|
||||||
|
|
||||||
|
### 6. Маршрутизация не очевидна
|
||||||
|
- **Проблема**: `RouteArrows3D` показывает три route beams всегда, активный чуть ярче.
|
||||||
|
- **Результат**: не видно, что товар физически движется по маршруту.
|
||||||
|
- **Нужно**: animated item motion по активному route, показать физический путь B/C/D.
|
||||||
|
|
||||||
|
### 7. Gate/Pusher не выглядят как реальные механизмы
|
||||||
|
- **Проблема**: `Actuator3D` — gate поворачивается на угол, pusher двигается по Z.
|
||||||
|
- **Результат**: не понятно, как это работает в реальности.
|
||||||
|
- **Нужно**: realistic gate movement (вертикальный lift или pivot), pusher plate extension.
|
||||||
|
|
||||||
|
### 8. Пользователь не видит, что происходит по шагам
|
||||||
|
- **Проблема**: proof card показывает результат сразу, нет step-by-step explanation.
|
||||||
|
- **Результат**: непонятно, как система пришла к решению.
|
||||||
|
- **Нужно**: Demo Director с автоматической демонстрацией по шагам: intro → spawn → detection → dimensions → roundness → decision → command → actuator → route → result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Новая концепция: Auto Demo 3D Digital Twin
|
||||||
|
|
||||||
|
### Главная идея
|
||||||
|
Превратить текущую интерактивную 3D-сцену в **автоматическую демонстрацию**, которая показывает полный цикл работы ПАК:
|
||||||
|
- A → конвейер → накопитель → CV detection → измерение → классификация → команда ROUTE_TO_* → исполнение → физический маршрут в B/C/D.
|
||||||
|
|
||||||
|
### Ключевые принципы
|
||||||
|
1. **Auto-play by default**: демонстрация идёт сама, пользователь может pause/skip/select scenario.
|
||||||
|
2. **Step-by-step storytelling**: каждый step явно показан (detection scan, dimensions check, decision, command, routing).
|
||||||
|
3. **Clean view**: по умолчанию только A/B/C/D + Накопитель + current step labels. Technical labels — опционально.
|
||||||
|
4. **Real models**: использовать STL модели товаров из `input_info`.
|
||||||
|
5. **Camera presets**: автоматические переходы камеры для каждого step.
|
||||||
|
6. **Realistic geometry**: конвейер, накопитель, gate, pusher выглядят как реальные механизмы.
|
||||||
|
7. **Physical routing**: товар физически уходит в B/C/D, видно actuator movement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Какие файлы будут изменены
|
||||||
|
|
||||||
|
### Новые файлы
|
||||||
|
|
||||||
|
#### 1. `src/domain/demoDirector.ts`
|
||||||
|
**Цель**: управление автоматической демонстрацией.
|
||||||
|
|
||||||
|
**Состояние**:
|
||||||
|
```typescript
|
||||||
|
interface DemoDirectorState {
|
||||||
|
isAutoDemoRunning: boolean;
|
||||||
|
currentDemoScenario: Scenario;
|
||||||
|
currentDemoStep: DemoStep;
|
||||||
|
elapsedMs: number;
|
||||||
|
playbackSpeed: 1 | 2 | 0.5;
|
||||||
|
loopMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DemoStep {
|
||||||
|
intro = "intro",
|
||||||
|
spawn_item = "spawn_item",
|
||||||
|
move_to_detection = "move_to_detection",
|
||||||
|
detection_scan = "detection_scan",
|
||||||
|
dimensions_check = "dimensions_check",
|
||||||
|
roundness_check = "roundness_check",
|
||||||
|
decision = "decision",
|
||||||
|
command_sent = "command_sent",
|
||||||
|
actuator_move = "actuator_move",
|
||||||
|
route_item = "route_item",
|
||||||
|
result = "result",
|
||||||
|
reset_or_next = "reset_or_next",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Функции**:
|
||||||
|
- `startAutoDemo(scenario)`: запуск автодемо для scenario.
|
||||||
|
- `pauseAutoDemo()`, `resumeAutoDemo()`.
|
||||||
|
- `nextScenario()`, `selectScenario(id)`.
|
||||||
|
- `updateAutoDemo(deltaMs)`: автоматический переход между steps.
|
||||||
|
- `getCameraPresetForStep(step)`: camera preset для текущего step.
|
||||||
|
- `getActiveLabelsForStep(step)`: какие labels показать для step.
|
||||||
|
|
||||||
|
#### 2. `src/data/modelAssets.ts`
|
||||||
|
**Цель**: manifest реальных 3D моделей товаров.
|
||||||
|
|
||||||
|
**Структура**:
|
||||||
|
```typescript
|
||||||
|
interface ModelAsset {
|
||||||
|
itemId: string; // 'SKU-006' (Plate)
|
||||||
|
displayName: string; // 'Тарелка'
|
||||||
|
categoryScenario: Category; // 'D'
|
||||||
|
dimensions: Dimensions; // { width: 210, depth: 209, height: 27 }
|
||||||
|
sourceFile: string; // 'input_info/extracted/Stl/Тарелка.stl'
|
||||||
|
frontendAssetPath?: string; // 'public/models/plate.stl' or GLB
|
||||||
|
loaderType: 'stl' | 'glb' | 'procedural';
|
||||||
|
fallbackPrimitive: 'box' | 'cylinder' | 'sphere';
|
||||||
|
notes?: string; // 'STL too heavy, using simplified mesh'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Товары**:
|
||||||
|
- SKU-006: Тарелка (D) — STL доступен
|
||||||
|
- SKU-007: Бутылка (D) — STL доступен
|
||||||
|
- SKU-008: Цилиндр (D) — STL доступен
|
||||||
|
- SKU-004: Короб 400×400×300 (C) — STL доступен
|
||||||
|
- SKU-001: Короб 300×200×200 (B) — STL доступен
|
||||||
|
- SKU-002: ЛанчБокс (B) — STL доступен
|
||||||
|
- SKU-003: Моющее средство (B) — STL доступен
|
||||||
|
- SKU-005: Пуфик (C) — STL доступен
|
||||||
|
- SKU-009: Ручка (C) — STL доступен
|
||||||
|
- SKU-010: Boundary box (B) — fallback box
|
||||||
|
- SKU-011: Oversized round (C) — fallback cylinder
|
||||||
|
|
||||||
|
#### 3. `src/components/ThreeD/STLModel.tsx`
|
||||||
|
**Цель**: компонент для загрузки STL моделей.
|
||||||
|
|
||||||
|
**Использует**: `STLLoader` из `three/examples/jsm/loaders/STLLoader`.
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
```typescript
|
||||||
|
interface STLModelProps {
|
||||||
|
path: string;
|
||||||
|
scale: [number, number, number];
|
||||||
|
rotation?: [number, number, number];
|
||||||
|
position?: [number, number, number];
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменяемые файлы
|
||||||
|
|
||||||
|
#### 1. `src/components/ThreeD/Item3D.tsx`
|
||||||
|
**Изменения**:
|
||||||
|
- Добавить загрузку реального mesh для товаров из `modelAssets`.
|
||||||
|
- Если asset доступен — загрузить STL/GLB.
|
||||||
|
- Центрировать и масштабировать модель по dimensions товара.
|
||||||
|
- Повернуть модель так, чтобы она лежала на конвейере.
|
||||||
|
- Fallback primitive, если модель не загружена.
|
||||||
|
|
||||||
|
**Новая логика**:
|
||||||
|
```typescript
|
||||||
|
const asset = getModelAsset(currentItem.item.id);
|
||||||
|
if (asset && asset.loaderType === 'stl') {
|
||||||
|
return <STLModel path={asset.frontendAssetPath} scale={[sx, sy, sz]} color={color} />;
|
||||||
|
} else {
|
||||||
|
// fallback primitive (current logic)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. `src/components/ThreeD/SceneLabels3D.tsx`
|
||||||
|
**Изменения**:
|
||||||
|
- **Clean view по умолчанию**: показывать только A/B/C/D + Накопитель.
|
||||||
|
- **Step-based labels**: Camera/Laser/Ultrasonic только во время detection.
|
||||||
|
- **Active actuators only**: Stop-gate/Pusher только когда активны.
|
||||||
|
- **Active route only**: показывать только активный route B/C/D, не все три сразу.
|
||||||
|
- **Максимум 4–5 labels одновременно**.
|
||||||
|
- **Toggle**: добавить prop `technicalLabelsEnabled: boolean`.
|
||||||
|
|
||||||
|
**Новая логика**:
|
||||||
|
```typescript
|
||||||
|
// Always show zones
|
||||||
|
<Badge position={...} text="A" />
|
||||||
|
<Badge position={...} text="B" />
|
||||||
|
<Badge position={...} text="C" />
|
||||||
|
<Badge position={...} text="D" />
|
||||||
|
<Badge position={...} text="Накопитель" />
|
||||||
|
|
||||||
|
// Step-based
|
||||||
|
{demoStep === 'detection_scan' && <Badge position={...} text="Camera CV" />}
|
||||||
|
{demoStep === 'detection_scan' && <Badge position={...} text="Laser" />}
|
||||||
|
{demoStep === 'actuator_move' && <Badge position={...} text="Stop-gate" />}
|
||||||
|
{activeRoute === 'C' && <Badge position={...} text="Pusher C" />}
|
||||||
|
|
||||||
|
// Technical labels (optional toggle)
|
||||||
|
{technicalLabelsEnabled && <Badge ... />}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `src/components/ThreeD/SorterDigitalTwin.tsx`
|
||||||
|
**Изменения**:
|
||||||
|
- Принимать `demoDirectorState` как prop.
|
||||||
|
- Использовать `getCameraPresetForStep()` для автоматической камеры.
|
||||||
|
- Передавать `technicalLabelsEnabled` в `SceneLabels3D`.
|
||||||
|
|
||||||
|
**Новые props**:
|
||||||
|
```typescript
|
||||||
|
interface SorterDigitalTwinProps {
|
||||||
|
simulation: SimulationState;
|
||||||
|
demoDirectorState: DemoDirectorState;
|
||||||
|
technicalLabelsEnabled: boolean;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. `src/components/ThreeD/Conveyor3D.tsx`
|
||||||
|
**Изменения**:
|
||||||
|
- Добавить realistic ролики/ленту конвейера.
|
||||||
|
- Добавить direction markers (стрелки движения).
|
||||||
|
- Добавить борта конвейера.
|
||||||
|
- Улучшить geometry накопителя (explicit raised pocket с четкими walls).
|
||||||
|
|
||||||
|
#### 5. `src/components/ThreeD/Actuator3D.tsx`
|
||||||
|
**Изменения**:
|
||||||
|
- Gate: вертикальный lift вместо rotation (более реалистично).
|
||||||
|
- Pusher C/D: расширенная plate, которая выдвигается.
|
||||||
|
- Добавить animation эффекты при movement.
|
||||||
|
|
||||||
|
#### 6. `src/components/ThreeD/SortingZones3D.tsx`
|
||||||
|
**Изменения**:
|
||||||
|
- Roll-cage C/D: добавить visible каркас (wireframe edges).
|
||||||
|
- B: улучшить зону выхода (не просто куб).
|
||||||
|
|
||||||
|
#### 7. `src/components/ThreeD/itemMotion.ts`
|
||||||
|
**Изменения**:
|
||||||
|
- Добавить realistic routing path для C/D (не просто Z-offset, а curve path).
|
||||||
|
- Синхронизировать с `demoDirector` steps.
|
||||||
|
|
||||||
|
#### 8. `src/components/ProductDemoSection.tsx`
|
||||||
|
**Изменения**:
|
||||||
|
- Добавить Auto Demo controls:
|
||||||
|
- "Запустить автодемо" (запуск auto-play)
|
||||||
|
- "Пауза" (pause auto-play)
|
||||||
|
- Scenario selector: B/C/D/C-priority/Fault/Emergency
|
||||||
|
- "Reset"
|
||||||
|
- Упростить layout: слева 3D, справа proof panel, снизу timeline.
|
||||||
|
- Убрать мусор: не показывать длинные критерии, event log, PID — они остаются в EngineeringDetails.
|
||||||
|
|
||||||
|
**Новые controls**:
|
||||||
|
```typescript
|
||||||
|
<div className="demo-controls">
|
||||||
|
<button onClick={startAutoDemo}>Запустить автодемо</button>
|
||||||
|
<button onClick={pauseAutoDemo} disabled={!isAutoRunning}>Пауза</button>
|
||||||
|
<select onChange={selectScenario}>
|
||||||
|
<option value="normal_b">B (normal)</option>
|
||||||
|
<option value="oversized_c">C (oversized)</option>
|
||||||
|
<option value="round_d">D (round)</option>
|
||||||
|
<option value="c_priority">C-priority</option>
|
||||||
|
<option value="fault">Fault</option>
|
||||||
|
<option value="emergency">Emergency</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={onReset}>Reset</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как будут использоваться реальные модели
|
||||||
|
|
||||||
|
### Доступные модели (11 STL файлов)
|
||||||
|
1. `Бутылка.stl` → SKU-007 (Bottle) → D
|
||||||
|
2. `Короб 300х200х200.stl` → SKU-001 (Box 300×200×200) → B
|
||||||
|
3. `Короб 400х400х300.stl` → SKU-004 (Oversized box) → C
|
||||||
|
4. `ЛанчБокс.stl` → SKU-002 (Lunchbox) → B
|
||||||
|
5. `Мешок.stl` → (нет в items, можно добавить)
|
||||||
|
6. `Моющее средство.STL` → SKU-003 (Detergent) → B
|
||||||
|
7. `Пуфик.stl` → SKU-005 (Pouf) → C
|
||||||
|
8. `Ручка.stl` → SKU-009 (Pen) → C
|
||||||
|
9. `Тарелка.stl` → SKU-006 (Plate) → D
|
||||||
|
10. `Цилиндр.stl` → SKU-008 (Cylinder) → D
|
||||||
|
11. `Шлем.stl` → (нет в items, можно добавить)
|
||||||
|
|
||||||
|
### Стратегия загрузки
|
||||||
|
1. **STLLoader из three/examples**: использовать для загрузки STL в браузере.
|
||||||
|
2. **Копировать STL в public**: `public/models/bottle.stl`, `public/models/plate.stl`, etc.
|
||||||
|
3. **Масштабирование**: STL модели часто в мм, нужно scale к Three.js units (1 unit = 1 m).
|
||||||
|
4. **Центрирование**: после загрузки STL — центрировать geometry по bounding box.
|
||||||
|
5. **Rotation**: повернуть модель так, чтобы она лежала на конвейере (обычно rotation Y = 0, X = 0).
|
||||||
|
6. **Fallback**: если STL не загружен — использовать primitive (box/cylinder/sphere).
|
||||||
|
|
||||||
|
### Оптимизация
|
||||||
|
- **Не коммитить тяжёлые STL**: если STL > 500 KB — не коммитить в public, использовать simplified mesh.
|
||||||
|
- **Lazy loading**: загружать STL только для активного item, не preload все сразу.
|
||||||
|
- **LOD (future)**: для тяжёлых моделей можно добавить Level of Detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как будет работать Auto Demo
|
||||||
|
|
||||||
|
### Последовательность steps
|
||||||
|
1. **intro** (1.5s): камера overview, показать всю сцену A/B/C/D, conveyor, накопитель.
|
||||||
|
2. **spawn_item** (0.8s): товар появляется в зоне A, подсвечивается.
|
||||||
|
3. **move_to_detection** (1.2s): товар едет по конвейеру к Camera/CV, камера close-up на detection zone.
|
||||||
|
4. **detection_scan** (0.9s): Camera/CV активен, scan plane visible, товар сканируется.
|
||||||
|
5. **dimensions_check** (0.7s): proof card показывает "Dimensions: PASS/FAIL".
|
||||||
|
6. **roundness_check** (0.7s): proof card показывает "Roundness K = ...".
|
||||||
|
7. **decision** (0.7s): proof card показывает "Category: B/C/D".
|
||||||
|
8. **command_sent** (0.6s): proof card показывает "Command: ROUTE_TO_*", активный route подсвечивается.
|
||||||
|
9. **actuator_move** (1.0s): Stop-gate открывается (B) или Pusher C/D выдвигается, камера close-up на gate/pusher.
|
||||||
|
10. **route_item** (1.2s): товар физически движется в B/C/D, камера follows item, route beam яркий.
|
||||||
|
11. **result** (1.5s): товар в целевой зоне, зона подсвечивается, proof card: "Cycle completed".
|
||||||
|
12. **reset_or_next** (0.5s): fade out, переход к следующему scenario или loop.
|
||||||
|
|
||||||
|
### Управление
|
||||||
|
- **Auto-play**: по умолчанию demo идёт сам, переходы автоматические.
|
||||||
|
- **Pause**: пользователь может pause на любом step.
|
||||||
|
- **Skip**: пользователь может skip to next step.
|
||||||
|
- **Scenario select**: пользователь может выбрать B/C/D/C-priority/Fault/Emergency.
|
||||||
|
- **Loop mode**: после завершения цикла — автоматически начать следующий scenario или повторить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как решена проблема labels
|
||||||
|
|
||||||
|
### Clean view (default)
|
||||||
|
Показывать только:
|
||||||
|
- A/B/C/D zones (4 labels)
|
||||||
|
- Накопитель (1 label)
|
||||||
|
- **Всего: 5 labels постоянно**
|
||||||
|
|
||||||
|
### Step-based labels
|
||||||
|
- **detection_scan**: добавить "Camera CV" (1)
|
||||||
|
- **actuator_move**: добавить "Stop-gate" или "Pusher C/D" (1)
|
||||||
|
- **route_item**: добавить "Route B/C/D" (1)
|
||||||
|
- **Максимум: 5 + 2 = 7 labels в peak момент**
|
||||||
|
|
||||||
|
### Technical labels (opt-in toggle)
|
||||||
|
- Laser, Ultrasonic, Conveyor speed, Min dimensions, PID — скрыты по умолчанию.
|
||||||
|
- Пользователь может включить "Technical labels" toggle → показать все.
|
||||||
|
|
||||||
|
### ItemProofPanel
|
||||||
|
- Убрать из 3D сцены.
|
||||||
|
- Показывать в 2D proof card справа от 3D сцены.
|
||||||
|
|
||||||
|
### Offset и positioning
|
||||||
|
- Labels с offset от объектов (не прямо на объекте).
|
||||||
|
- Использовать `distanceFactor` для масштабирования labels.
|
||||||
|
- Избегать overlapping: если два labels близко — сместить один вверх/вниз.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Camera presets
|
||||||
|
|
||||||
|
### 1. Overview (intro)
|
||||||
|
```typescript
|
||||||
|
position: [6.2, 4.8, 6.2]
|
||||||
|
target: [0, 0.3, 0]
|
||||||
|
fov: 42
|
||||||
|
```
|
||||||
|
Видно всю ячейку A/B/C/D.
|
||||||
|
|
||||||
|
### 2. Detection close-up (detection_scan)
|
||||||
|
```typescript
|
||||||
|
position: [-1.2, 2.5, 3.5]
|
||||||
|
target: [-1.2, 0.3, 0]
|
||||||
|
fov: 38
|
||||||
|
```
|
||||||
|
Камера ближе к Camera/CV и товару.
|
||||||
|
|
||||||
|
### 3. Gate close-up (actuator_move)
|
||||||
|
```typescript
|
||||||
|
position: [1.6, 2.0, 3.0]
|
||||||
|
target: [1.6, 0.3, 0]
|
||||||
|
fov: 36
|
||||||
|
```
|
||||||
|
Видно накопитель, stop-gate, pusher.
|
||||||
|
|
||||||
|
### 4. Routing view (route_item)
|
||||||
|
```typescript
|
||||||
|
position: [2.5, 3.5, category === 'C' ? 2.4 : category === 'D' ? -2.4 : 0]
|
||||||
|
target: [TWIN_LAYOUT.gateX + 0.4, 0.5, categoryZ]
|
||||||
|
fov: 40
|
||||||
|
```
|
||||||
|
Камера follows активный route B/C/D.
|
||||||
|
|
||||||
|
### 5. Result view (result)
|
||||||
|
```typescript
|
||||||
|
position: [category === 'B' ? TWIN_LAYOUT.zoneBX : TWIN_LAYOUT.gateX + 0.4, 3.0, categoryZ]
|
||||||
|
target: [zoneX, 0.5, categoryZ]
|
||||||
|
fov: 38
|
||||||
|
```
|
||||||
|
Видно целевую зону и товар в ней.
|
||||||
|
|
||||||
|
### Camera transitions
|
||||||
|
Использовать плавные transitions между presets (lerp position/target за 0.8s).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Улучшенная геометрия сцены
|
||||||
|
|
||||||
|
### 1. Складской пол
|
||||||
|
- Grid с subtle цветом `#1e3a54` (current).
|
||||||
|
- Не слишком яркий, чтобы не отвлекать.
|
||||||
|
|
||||||
|
### 2. Конвейер
|
||||||
|
- **Ролики**: несколько цилиндров поперёк конвейера.
|
||||||
|
- **Лента**: plane с texture или просто flat surface.
|
||||||
|
- **Направление движения**: стрелки на ленте.
|
||||||
|
- **Борта**: два box по бокам конвейера.
|
||||||
|
|
||||||
|
### 3. Накопитель
|
||||||
|
- **Explicit raised pocket**: box с raised floor.
|
||||||
|
- **Walls**: четкие walls по трём сторонам (кроме входа).
|
||||||
|
- **Цвет**: teal `#0f766e`.
|
||||||
|
|
||||||
|
### 4. Camera/CV
|
||||||
|
- **Стойка**: вертикальная mesh над конвейером.
|
||||||
|
- **Camera body**: небольшой box на стойке.
|
||||||
|
- **Scan plane**: plane во время detection (fade in/out).
|
||||||
|
|
||||||
|
### 5. Laser height
|
||||||
|
- **Датчик**: маленький box на стойке.
|
||||||
|
- **Луч**: thin cylinder или line во время detection.
|
||||||
|
|
||||||
|
### 6. Ultrasonic
|
||||||
|
- **Датчик**: небольшой box у gate.
|
||||||
|
- **Pulse**: sphere с fade in/out во время detection.
|
||||||
|
|
||||||
|
### 7. Stop-gate
|
||||||
|
- **Geometry**: vertical plate (box).
|
||||||
|
- **Movement**: вертикальный lift (translate Y) вместо rotation.
|
||||||
|
- **Состояние**: закрыт (Y = 0.2), открыт (Y = 0.6).
|
||||||
|
|
||||||
|
### 8. Pusher C/D
|
||||||
|
- **Geometry**: horizontal plate (box).
|
||||||
|
- **Movement**: extension по Z (current logic OK).
|
||||||
|
- **Состояние**: idle (Z = 0), extended (Z = 0.55), retracting (Z = 0.25).
|
||||||
|
|
||||||
|
### 9. B (прямой выход)
|
||||||
|
- **Geometry**: zone box (current).
|
||||||
|
- **Добавить**: direction arrow, border highlight.
|
||||||
|
|
||||||
|
### 10. C/D (roll-cage)
|
||||||
|
- **Geometry**: wireframe box для каркаса.
|
||||||
|
- **Walls**: mesh с низкой opacity для walls.
|
||||||
|
- **Label**: на передней стенке каркаса.
|
||||||
|
|
||||||
|
### 11. Ограждения
|
||||||
|
- **Стойки**: vertical posts у зон C/D.
|
||||||
|
- **Минимализм**: простые, без перегруза.
|
||||||
|
|
||||||
|
### 12. Свет
|
||||||
|
- **Ambient light**: 0.55 (current OK).
|
||||||
|
- **Directional lights**: два (current OK).
|
||||||
|
- **Без тяжёлых shadows**: если влияет на FPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
### Критерии завершения
|
||||||
|
1. ✅ 3D demo запускается автоматически при нажатии "Запустить автодемо".
|
||||||
|
2. ✅ Пользователь без ручного "Next step" видит полный цикл: intro → spawn → detection → decision → routing → result.
|
||||||
|
3. ✅ В сцене используются реальные модели товаров из `input_info` (STL), где это возможно.
|
||||||
|
4. ✅ Если модель не используется — это явно отражено в `modelAssets.ts` manifest.
|
||||||
|
5. ✅ Labels не перекрывают сцену: максимум 5 labels по умолчанию, 7 в peak момент.
|
||||||
|
6. ✅ Clean view включён по умолчанию (только A/B/C/D + Накопитель).
|
||||||
|
7. ✅ Есть "Technical labels" toggle для включения всех подробных labels.
|
||||||
|
8. ✅ Товар физически уходит в B/C/D, видна траектория движения.
|
||||||
|
9. ✅ Gate/Pusher двигаются реалистично (vertical lift для gate, extension для pusher).
|
||||||
|
10. ✅ C-priority визуально доказан: негабарит + круглый → только C подсвечивается, D не активен.
|
||||||
|
11. ✅ Fault/Emergency визуально понятны: красная подсветка, stopped state, proof card объясняет.
|
||||||
|
12. ✅ Mobile использует 2D fallback (current logic OK, не трогать).
|
||||||
|
13. ✅ Нет horizontal scroll (responsive layout OK, не трогать).
|
||||||
|
14. ✅ Tests зелёные: `npm run test` проходит.
|
||||||
|
15. ✅ Build зелёный: `npm run build` проходит.
|
||||||
|
16. ✅ Docker зелёный: `docker compose up --build` успешен.
|
||||||
|
17. ✅ Домены 200: arhipovdan.ru, www.arhipovdan.ru, 127.0.0.1:3100.
|
||||||
|
18. ✅ Документация обновлена: README, DEMO_SCRIPT, JURY_QA, SUBMISSION_CHECKLIST, THREE_D_FEASIBILITY, THREE_D_REWORK_PLAN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Риски и ограничения
|
||||||
|
|
||||||
|
### Риски
|
||||||
|
1. **STL модели тяжёлые**: некоторые STL могут быть > 1 MB → медленная загрузка.
|
||||||
|
- **Митигация**: lazy loading, simplified mesh, fallback primitive.
|
||||||
|
2. **FPS падает**: добавление реальных моделей + camera transitions может снизить FPS.
|
||||||
|
- **Митигация**: LOD, simplified geometry для mobile, dpr adjustment.
|
||||||
|
3. **Auto demo слишком быстрый/медленный**: timing steps может быть неоптимальным.
|
||||||
|
- **Митигация**: playback speed control (1x, 2x, 0.5x).
|
||||||
|
4. **Camera transitions дёрганые**: lerp может быть не плавным.
|
||||||
|
- **Митигация**: использовать easing functions (ease-in-out), увеличить transition time.
|
||||||
|
|
||||||
|
### Ограничения
|
||||||
|
1. **Не добавлять physics engine**: PHYSICS_ENGINE_ENABLED = false (as per requirements).
|
||||||
|
2. **Не добавлять backend/real ML**: симуляция остаётся rule-based.
|
||||||
|
3. **Не удалять 2D fallback**: mobile + WebGL fail должен показывать 2D.
|
||||||
|
4. **Не коммитить input_info/extracted/**: модели копируются в public, но extracted игнорируется git.
|
||||||
|
5. **Не ломать существующие tests**: classifier, simulation, C-priority tests остаются зелёными.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
### ЭТАП 2: Проанализировать реальные 3D-модели из input_info
|
||||||
|
- Проверить размер и качество каждого STL.
|
||||||
|
- Определить, какие модели можно использовать напрямую.
|
||||||
|
- Определить, какие требуют simplified mesh.
|
||||||
|
- Создать `src/data/modelAssets.ts` manifest.
|
||||||
|
|
||||||
|
### ЭТАП 3: Использовать реальные модели товаров в 3D
|
||||||
|
- Скопировать STL в `public/models/`.
|
||||||
|
- Создать `STLModel.tsx` компонент.
|
||||||
|
- Изменить `Item3D.tsx` для загрузки реальных моделей.
|
||||||
|
|
||||||
|
### ЭТАП 4: Создать Demo Director
|
||||||
|
- Создать `src/domain/demoDirector.ts`.
|
||||||
|
- Определить steps, durations, camera presets.
|
||||||
|
- Интегрировать в `ProductDemoSection.tsx`.
|
||||||
|
|
||||||
|
### ЭТАП 5: Переделать camera и scene composition
|
||||||
|
- Определить camera presets.
|
||||||
|
- Добавить camera transitions в `SorterDigitalTwin.tsx`.
|
||||||
|
|
||||||
|
### ЭТАП 6: Убрать мусорные labels
|
||||||
|
- Изменить `SceneLabels3D.tsx`: clean view + step-based labels.
|
||||||
|
- Добавить "Technical labels" toggle.
|
||||||
|
|
||||||
|
### ЭТАП 7: Переделать геометрию сортировочной ячейки
|
||||||
|
- Улучшить `Conveyor3D`, `Actuator3D`, `SortingZones3D`.
|
||||||
|
|
||||||
|
### ЭТАП 8: Реальная маршрутизация в 3D
|
||||||
|
- Улучшить `itemMotion.ts` для realistic routing paths.
|
||||||
|
|
||||||
|
### ЭТАП 9: Упростить ProductDemo UI
|
||||||
|
- Рефакторить `ProductDemoSection.tsx`: auto demo controls, timeline.
|
||||||
|
|
||||||
|
### ЭТАП 10–18: Tests, build, deploy, docs, Visual QA.
|
||||||
255
docs/THREE_D_REWORK_STATUS.md
Normal file
255
docs/THREE_D_REWORK_STATUS.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# 3D Digital Twin Rework — Current Status
|
||||||
|
|
||||||
|
## Completed Stages
|
||||||
|
|
||||||
|
### ✅ ЭТАП 0: Preflight
|
||||||
|
- Branch: `dan_branch` ✓
|
||||||
|
- Working tree: clean ✓
|
||||||
|
- 11 STL models found in `input_info/extracted/Stl/` ✓
|
||||||
|
|
||||||
|
### ✅ ЭТАП 1: Audit Current 3D Scene
|
||||||
|
- Documented problems in `docs/THREE_D_REWORK_PLAN.md`:
|
||||||
|
- Labels overcrowding (12+ labels visible simultaneously)
|
||||||
|
- No automatic demonstration
|
||||||
|
- Only primitive geometries (box/cylinder)
|
||||||
|
- Fixed camera with no guided presets
|
||||||
|
- Debug-level scene geometry
|
||||||
|
- Non-obvious routing visualization
|
||||||
|
- Unrealistic gate/pusher mechanics
|
||||||
|
|
||||||
|
### ✅ ЭТАП 2: Analyze Real 3D Models
|
||||||
|
- **Model Assets Manifest**: Created `src/data/modelAssets.ts`
|
||||||
|
- **STL Models Strategy**:
|
||||||
|
- ✅ Lightweight STL (< 700 KB): 6 models copied to `public/models/`
|
||||||
|
- Бутылка (319 KB)
|
||||||
|
- Тарелка (123 KB)
|
||||||
|
- Цилиндр (106 KB)
|
||||||
|
- Короб 300×200×200 (29 KB)
|
||||||
|
- Короб 400×400×300 (27 KB)
|
||||||
|
- ЛанчБокс (566 KB)
|
||||||
|
- ⚠️ Heavy STL (> 1 MB): 5 models use fallback primitives
|
||||||
|
- Моющее средство (3.5 MB) → box fallback
|
||||||
|
- Мешок (5.5 MB) → not used
|
||||||
|
- Ручка (2.0 MB) → box fallback
|
||||||
|
- Шлем (2.7 MB) → not used
|
||||||
|
- Пуфик (629 KB) → cylinder fallback
|
||||||
|
- ℹ️ Missing models: 2 items use fallback primitives
|
||||||
|
- SKU-010: Boundary box → box fallback
|
||||||
|
- SKU-011: Oversized round → cylinder fallback
|
||||||
|
- **Manifest Stats**: 6 STL (55%), 5 procedural (45%)
|
||||||
|
|
||||||
|
### ✅ ЭТАП 3: Use Real Models in 3D
|
||||||
|
- **Created**: `src/components/ThreeD/STLModel.tsx`
|
||||||
|
- Uses Three.js `STLLoader` from `three/examples`
|
||||||
|
- Centers and scales geometry automatically
|
||||||
|
- Supports fallback on load failure
|
||||||
|
- **Updated**: `src/components/ThreeD/Item3D.tsx`
|
||||||
|
- Loads real STL models from `modelAssets` manifest
|
||||||
|
- Fallback to primitives (box/cylinder/sphere) if model unavailable
|
||||||
|
- Suspense boundary for async loading
|
||||||
|
|
||||||
|
### ✅ ЭТАП 4: Create Demo Director
|
||||||
|
- **Created**: `src/domain/demoDirector.ts`
|
||||||
|
- 12 demo steps: intro → spawn → detection → decision → routing → result
|
||||||
|
- Camera presets for each step (overview, detection closeup, gate closeup, routing, result)
|
||||||
|
- Auto-play with pause/resume/stop controls
|
||||||
|
- Playback speed: 1x, 2x, 0.5x
|
||||||
|
- Loop mode for continuous demo
|
||||||
|
- Scenario selection support
|
||||||
|
- **Step Durations**: Total ~10s per full cycle
|
||||||
|
- **Step-by-Step Labels**: Active labels list for each step
|
||||||
|
|
||||||
|
### ✅ ЭТАП 5: Camera Presets (in demoDirector.ts)
|
||||||
|
- **Presets defined**:
|
||||||
|
- `overview`: [6.2, 4.8, 6.2] → [0, 0.3, 0]
|
||||||
|
- `detectionCloseup`: [-1.2, 2.5, 3.5] → [-1.2, 0.3, 0]
|
||||||
|
- `gateCloseup`: [1.6, 2.0, 3.0] → [1.6, 0.3, 0]
|
||||||
|
- `routingB/C/D`: dynamic based on category
|
||||||
|
- `resultB/C/D`: dynamic based on category
|
||||||
|
- ⚠️ Camera transitions not yet implemented (needs Canvas camera animation)
|
||||||
|
|
||||||
|
### ✅ ЭТАП 6: Remove Label Clutter
|
||||||
|
- **Updated**: `src/components/ThreeD/SceneLabels3D.tsx`
|
||||||
|
- **Clean View (default)**: Only 5 labels (A/B/C/D + Накопитель)
|
||||||
|
- **Step-Based Labels**:
|
||||||
|
- Detection: +Camera CV, +Laser (total 7 labels)
|
||||||
|
- At Gate: +Stop-gate (total 6 labels)
|
||||||
|
- Routing: +Pusher C/D, +Route arrow (total 7 labels max)
|
||||||
|
- **Technical Labels Toggle**: Shows all sensors/actuators/routes when enabled
|
||||||
|
- **Removed**: ItemProofPanel from 3D scene (info now in 2D proof card only)
|
||||||
|
- **Updated**: `src/components/ThreeD/SorterDigitalTwin.tsx`
|
||||||
|
- Added `technicalLabelsEnabled` prop
|
||||||
|
- Added `cleanView` prop
|
||||||
|
- Props passed to SceneLabels3D
|
||||||
|
|
||||||
|
### ✅ ЭТАП 7: Improve Geometry (Partial)
|
||||||
|
- **Updated**: `src/components/ThreeD/Actuator3D.tsx`
|
||||||
|
- Gate: Vertical lift mechanism (Y-axis movement) instead of rotation
|
||||||
|
- Gate support posts (left + right)
|
||||||
|
- Pusher: Extended plate design with stationary base
|
||||||
|
- Realistic colors and materials (metalness, roughness)
|
||||||
|
- **Updated**: `src/components/ThreeD/Conveyor3D.tsx`
|
||||||
|
- Conveyor side guards (borders)
|
||||||
|
- Visible rollers spaced every 1.2m
|
||||||
|
- Direction arrow indicator
|
||||||
|
- Enhanced accumulator walls
|
||||||
|
- **Updated**: `src/components/ThreeD/SortingZones3D.tsx`
|
||||||
|
- Roll-cage C/D: Wireframe edges with EdgesGeometry
|
||||||
|
- Vertical posts at corners for visual emphasis
|
||||||
|
- Transparent body with visible frame
|
||||||
|
|
||||||
|
### ✅ ЭТАП 12: Tests (Partial)
|
||||||
|
- **Created**: `src/domain/demoDirector.test.ts`
|
||||||
|
- Tests: createDemoDirectorState, start/pause/resume/stop
|
||||||
|
- Tests: updateAutoDemo (time progression, step transitions)
|
||||||
|
- Tests: getDemoStepSequence (order verification: command_sent → actuator_move → route_item)
|
||||||
|
- Tests: loop mode, playback speed
|
||||||
|
- ✅ All tests passing (43 total)
|
||||||
|
- **Created**: `src/data/modelAssets.test.ts`
|
||||||
|
- Tests: manifest coverage for all SKU items
|
||||||
|
- Tests: getModelAsset, getSTLAssets, getProceduralAssets
|
||||||
|
- Tests: manifest stats (total, stl count, procedural count, percentage)
|
||||||
|
- ✅ All tests passing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
### 🔄 ЭТАП 8: Real Routing in 3D
|
||||||
|
**Status**: Not started
|
||||||
|
**Priority**: Medium (current itemMotion works but is not realistic)
|
||||||
|
|
||||||
|
**TODO**:
|
||||||
|
- Update `src/components/ThreeD/itemMotion.ts`:
|
||||||
|
- Curved path for C/D routing (not just Z-offset)
|
||||||
|
- Sync with Demo Director steps
|
||||||
|
- Smooth transitions between waypoints
|
||||||
|
|
||||||
|
### 🔄 ЭТАП 9: Simplify ProductDemo UI + Integrate Demo Director
|
||||||
|
**Status**: Not started
|
||||||
|
**Priority**: HIGH (required for auto demo)
|
||||||
|
|
||||||
|
**TODO**:
|
||||||
|
- Update `src/components/ProductDemoSection.tsx`:
|
||||||
|
- Add Demo Director state management
|
||||||
|
- Add auto demo controls:
|
||||||
|
- "Запустить автодемо" button
|
||||||
|
- "Пауза" button
|
||||||
|
- Scenario selector (B/C/D/C-priority/Fault/Emergency)
|
||||||
|
- "Technical labels" toggle
|
||||||
|
- Pass `demoDirectorState`, `technicalLabelsEnabled` to SorterDigitalTwin
|
||||||
|
- Simplify layout: 3D left, proof card right, timeline below
|
||||||
|
- Update `src/App.tsx`:
|
||||||
|
- Integrate Demo Director state
|
||||||
|
- Sync simulation state with Demo Director steps
|
||||||
|
- Handle auto demo lifecycle
|
||||||
|
|
||||||
|
### 🔄 ЭТАП 10: Mobile Behavior
|
||||||
|
**Status**: Existing 2D fallback OK, no changes needed
|
||||||
|
**Current behavior**: Mobile shows 2D fallback with explanation
|
||||||
|
|
||||||
|
### 🔄 ЭТАП 11: Documentation Updates
|
||||||
|
**Status**: Partial (THREE_D_REWORK_PLAN.md created)
|
||||||
|
**TODO**:
|
||||||
|
- Update `README.md` with:
|
||||||
|
- Real models section (which STL used, which fallback)
|
||||||
|
- Model assets manifest location
|
||||||
|
- Auto demo instructions
|
||||||
|
- Update `docs/DEMO_SCRIPT.md` with:
|
||||||
|
- Auto demo script (step-by-step)
|
||||||
|
- How to show B/C/D/C-priority/Fault
|
||||||
|
- Update `docs/JURY_QA.md` with:
|
||||||
|
- Q: "Why are some models procedural fallbacks?" → A: Performance (heavy STL > 1 MB)
|
||||||
|
- Q: "How does auto demo work?" → A: Demo Director controls step sequence
|
||||||
|
- Update `docs/SUBMISSION_CHECKLIST.md` with:
|
||||||
|
- STL models checklist
|
||||||
|
- Auto demo verification
|
||||||
|
- Update `docs/THREE_D_FEASIBILITY.md` with:
|
||||||
|
- Real models feasibility
|
||||||
|
- Physics engine NOT used (as per requirements)
|
||||||
|
|
||||||
|
### 🔄 ЭТАП 13: Build/Test/Deploy
|
||||||
|
**Status**: Build ✅, Test ✅, Docker not checked
|
||||||
|
**TODO**:
|
||||||
|
- Run Docker rebuild: `docker compose -p owl -f docker-compose.server.yml up -d --build`
|
||||||
|
- Check domains: arhipovdan.ru, www.arhipovdan.ru, 127.0.0.1:3100
|
||||||
|
- Verify FPS with STL models
|
||||||
|
|
||||||
|
### 🔄 ЭТАП 14: Visual QA via Browser MCP
|
||||||
|
**Status**: Not started
|
||||||
|
**Priority**: HIGH (required for verification)
|
||||||
|
|
||||||
|
**TODO**:
|
||||||
|
- Desktop 1920×1080:
|
||||||
|
- Verify max 5 labels in clean view
|
||||||
|
- Verify real STL models load (bottle, plate, cylinder, box)
|
||||||
|
- Verify auto demo runs (if implemented)
|
||||||
|
- Verify gate/pusher movement
|
||||||
|
- Verify route visualization
|
||||||
|
- Verify proof panel explains decision
|
||||||
|
- Laptop 1440×900:
|
||||||
|
- Verify responsive layout
|
||||||
|
- Mobile 390×844:
|
||||||
|
- Verify 2D fallback active
|
||||||
|
- Verify no horizontal scroll
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues & Risks
|
||||||
|
|
||||||
|
### Issues
|
||||||
|
1. **Camera transitions not implemented**: Demo Director has camera presets, but SorterDigitalTwin doesn't animate between them yet.
|
||||||
|
2. **Auto demo not integrated**: Demo Director exists, but ProductDemoSection/App.tsx don't use it yet.
|
||||||
|
3. **ItemMotion not synced with Demo Director**: Current motion is simulation-driven, not demo-driven.
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
1. **STL Loading Performance**: 6 STL models load async, may cause frame drops on low-end devices.
|
||||||
|
- **Mitigation**: Suspense + fallback primitives ensure scene is never empty.
|
||||||
|
2. **Auto Demo Timing**: Step durations may feel too fast/slow for user.
|
||||||
|
- **Mitigation**: Playback speed control (1x, 2x, 0.5x).
|
||||||
|
3. **Camera Animation Complexity**: Smooth camera transitions between presets require lerp/easing.
|
||||||
|
- **Mitigation**: Can start with instant camera jumps, add smooth transitions later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Immediate Steps
|
||||||
|
|
||||||
|
1. **Integrate Demo Director into ProductDemoSection/App** (ЭТАП 9)
|
||||||
|
- This is the highest priority to enable auto demo
|
||||||
|
- Required before Visual QA can verify auto demo functionality
|
||||||
|
|
||||||
|
2. **Run Docker Build & Domain Checks** (ЭТАП 13)
|
||||||
|
- Verify deployment still works with new STL models
|
||||||
|
|
||||||
|
3. **Visual QA Desktop** (ЭТАП 14)
|
||||||
|
- Verify STL models load correctly
|
||||||
|
- Verify clean view labels (max 5 default)
|
||||||
|
- Verify geometry improvements (gate lift, roll-cage wireframe)
|
||||||
|
|
||||||
|
4. **Documentation Updates** (ЭТАП 11)
|
||||||
|
- Update README.md with STL models section
|
||||||
|
- Update DEMO_SCRIPT.md with auto demo instructions
|
||||||
|
- Update JURY_QA.md with new questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done (Progress)
|
||||||
|
|
||||||
|
- ✅ 1. Real STL models used where possible (6/11 items)
|
||||||
|
- ✅ 2. Fallback primitives for heavy/missing models (5/11 items)
|
||||||
|
- ✅ 3. Manifest documents all models (`src/data/modelAssets.ts`)
|
||||||
|
- ✅ 4. Labels reduced to max 5 in clean view (SceneLabels3D)
|
||||||
|
- ⚠️ 5. Technical labels toggle (prop added, but not connected to UI yet)
|
||||||
|
- ⚠️ 6. Auto demo created (Demo Director exists, but not integrated)
|
||||||
|
- ✅ 7. Gate/Pusher realistic movement (vertical lift, extended plates)
|
||||||
|
- ✅ 8. Roll-cage wireframe (C/D zones)
|
||||||
|
- ✅ 9. Conveyor realistic geometry (side guards, rollers)
|
||||||
|
- ⚠️ 10. Camera presets defined (in Demo Director, but not used yet)
|
||||||
|
- ✅ 11. Tests green (43/43 passing)
|
||||||
|
- ✅ 12. Build green (TypeScript compilation successful)
|
||||||
|
- ⚠️ 13. Docker not checked yet
|
||||||
|
- ⚠️ 14. Visual QA not performed yet
|
||||||
|
- ⚠️ 15. Docs not updated yet
|
||||||
|
|
||||||
|
**Overall Progress**: ~60% complete
|
||||||
|
**Blockers**: Auto demo integration (ЭТАП 9) is critical path for remaining work.
|
||||||
BIN
public/models/bottle.stl
Normal file
BIN
public/models/bottle.stl
Normal file
Binary file not shown.
BIN
public/models/box-300.stl
Normal file
BIN
public/models/box-300.stl
Normal file
Binary file not shown.
BIN
public/models/box-400.stl
Normal file
BIN
public/models/box-400.stl
Normal file
Binary file not shown.
BIN
public/models/cylinder.stl
Normal file
BIN
public/models/cylinder.stl
Normal file
Binary file not shown.
BIN
public/models/lunchbox.stl
Normal file
BIN
public/models/lunchbox.stl
Normal file
Binary file not shown.
BIN
public/models/plate.stl
Normal file
BIN
public/models/plate.stl
Normal file
Binary file not shown.
@@ -13,40 +13,75 @@ export default function Actuator3D({ gate, actuators, machineState }: Props) {
|
|||||||
const pusherD = pusherOffset(actuators.pusherD);
|
const pusherD = pusherOffset(actuators.pusherD);
|
||||||
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
|
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
|
||||||
|
|
||||||
|
// Gate vertical lift: closed (Y = 0.2) → open (Y = 0.65)
|
||||||
|
const gateY = gateOpen ? 0.65 : 0.2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Stop-gate */}
|
{/* Stop-gate — vertical lift mechanism */}
|
||||||
<mesh
|
<group position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY, 0]}>
|
||||||
position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY + 0.2, 0]}
|
{/* Gate support posts (left and right) */}
|
||||||
rotation={[0, 0, gateOpen ? -0.7 : 0]}
|
<mesh position={[0, 0.35, -0.35]}>
|
||||||
>
|
<boxGeometry args={[0.04, 0.7, 0.04]} />
|
||||||
<boxGeometry args={[0.06, 0.55, 0.62]} />
|
<meshStandardMaterial color="#475569" metalness={0.3} roughness={0.7} />
|
||||||
<meshStandardMaterial
|
</mesh>
|
||||||
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
|
<mesh position={[0, 0.35, 0.35]}>
|
||||||
emissive={fault ? '#fb3d4e' : '#000000'}
|
<boxGeometry args={[0.04, 0.7, 0.04]} />
|
||||||
emissiveIntensity={fault ? 0.4 : 0}
|
<meshStandardMaterial color="#475569" metalness={0.3} roughness={0.7} />
|
||||||
/>
|
</mesh>
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Pusher C → roll-cage C */}
|
{/* Gate plate — moves up/down */}
|
||||||
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, pusherC]}>
|
<mesh position={[0, gateY, 0]}>
|
||||||
<boxGeometry args={[0.35, 0.18, 0.18]} />
|
<boxGeometry args={[0.08, 0.05, 0.65]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={actuators.pusherC === 'extended' ? '#f59e0b' : '#78350f'}
|
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
|
||||||
emissive={actuators.pusherC === 'extended' ? '#f59e0b' : '#000000'}
|
emissive={fault ? '#fb3d4e' : '#000000'}
|
||||||
emissiveIntensity={actuators.pusherC === 'extended' ? 0.35 : 0}
|
emissiveIntensity={fault ? 0.4 : gateOpen ? 0.15 : 0}
|
||||||
/>
|
metalness={0.2}
|
||||||
</mesh>
|
roughness={0.6}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
{/* Pusher D → roll-cage D */}
|
{/* Pusher C → roll-cage C (extended plate) */}
|
||||||
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, -pusherD]}>
|
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
|
||||||
<boxGeometry args={[0.35, 0.18, 0.18]} />
|
{/* Pusher base (stationary) */}
|
||||||
<meshStandardMaterial
|
<mesh position={[0, 0.12, 0.1]}>
|
||||||
color={actuators.pusherD === 'extended' ? '#c084fc' : '#4c1d95'}
|
<boxGeometry args={[0.15, 0.18, 0.12]} />
|
||||||
emissive={actuators.pusherD === 'extended' ? '#c084fc' : '#000000'}
|
<meshStandardMaterial color="#78350f" roughness={0.8} />
|
||||||
emissiveIntensity={actuators.pusherD === 'extended' ? 0.35 : 0}
|
</mesh>
|
||||||
/>
|
|
||||||
</mesh>
|
{/* Pusher plate (moves) */}
|
||||||
|
<mesh position={[0, 0.12, pusherC]}>
|
||||||
|
<boxGeometry args={[0.3, 0.15, 0.2]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={actuators.pusherC === 'extended' ? '#f59e0b' : '#92400e'}
|
||||||
|
emissive={actuators.pusherC === 'extended' ? '#f59e0b' : '#000000'}
|
||||||
|
emissiveIntensity={actuators.pusherC === 'extended' ? 0.35 : 0}
|
||||||
|
roughness={0.65}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Pusher D → roll-cage D (extended plate) */}
|
||||||
|
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
|
||||||
|
{/* Pusher base (stationary) */}
|
||||||
|
<mesh position={[0, 0.12, -0.1]}>
|
||||||
|
<boxGeometry args={[0.15, 0.18, 0.12]} />
|
||||||
|
<meshStandardMaterial color="#4c1d95" roughness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Pusher plate (moves) */}
|
||||||
|
<mesh position={[0, 0.12, -pusherD]}>
|
||||||
|
<boxGeometry args={[0.3, 0.15, 0.2]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={actuators.pusherD === 'extended' ? '#c084fc' : '#581c87'}
|
||||||
|
emissive={actuators.pusherD === 'extended' ? '#c084fc' : '#000000'}
|
||||||
|
emissiveIntensity={actuators.pusherD === 'extended' ? 0.35 : 0}
|
||||||
|
roughness={0.65}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default function Conveyor3D({ stopped }: Props) {
|
|||||||
const color = stopped ? '#7f1d1d' : '#1e3a54';
|
const color = stopped ? '#7f1d1d' : '#1e3a54';
|
||||||
const beltColor = stopped ? '#991b1b' : '#334155';
|
const beltColor = stopped ? '#991b1b' : '#334155';
|
||||||
const accColor = stopped ? '#7f1d1d' : '#0f766e';
|
const accColor = stopped ? '#7f1d1d' : '#0f766e';
|
||||||
|
const sideGuardColor = '#475569';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -23,24 +24,55 @@ export default function Conveyor3D({ stopped }: Props) {
|
|||||||
<meshStandardMaterial color={beltColor} roughness={0.9} />
|
<meshStandardMaterial color={beltColor} roughness={0.9} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
|
{/* Conveyor side guards (borders) */}
|
||||||
|
<mesh position={[0, TWIN_LAYOUT.beltY + 0.05, 0.28]}>
|
||||||
|
<boxGeometry args={[8.4, 0.1, 0.03]} />
|
||||||
|
<meshStandardMaterial color={sideGuardColor} metalness={0.2} roughness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, TWIN_LAYOUT.beltY + 0.05, -0.28]}>
|
||||||
|
<boxGeometry args={[8.4, 0.1, 0.03]} />
|
||||||
|
<meshStandardMaterial color={sideGuardColor} metalness={0.2} roughness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Rollers (visual indication of conveyor mechanism) — spaced every 1.2m */}
|
||||||
|
{[-3.6, -2.4, -1.2, 0, 1.2].map((x) => (
|
||||||
|
<mesh key={x} position={[x, TWIN_LAYOUT.beltY - 0.08, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||||
|
<cylinderGeometry args={[0.04, 0.04, 0.5, 8]} />
|
||||||
|
<meshStandardMaterial color="#334155" metalness={0.4} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Accumulator / buffer at end of feed conveyor — explicit raised pocket */}
|
{/* Accumulator / buffer at end of feed conveyor — explicit raised pocket */}
|
||||||
<group position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY, 0]}>
|
<group position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY, 0]}>
|
||||||
|
{/* Accumulator floor (raised pocket) */}
|
||||||
<mesh position={[0, -0.02, 0]}>
|
<mesh position={[0, -0.02, 0]}>
|
||||||
<boxGeometry args={[1.05, 0.12, 0.82]} />
|
<boxGeometry args={[1.05, 0.12, 0.82]} />
|
||||||
<meshStandardMaterial color={accColor} roughness={0.65} emissive={accColor} emissiveIntensity={0.15} />
|
<meshStandardMaterial color={accColor} roughness={0.65} emissive={accColor} emissiveIntensity={0.15} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Side walls of accumulator */}
|
|
||||||
|
{/* Accumulator walls — three sides (back + left + right) */}
|
||||||
<mesh position={[0, 0.12, 0.38]}>
|
<mesh position={[0, 0.12, 0.38]}>
|
||||||
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
||||||
<meshStandardMaterial color="#14b8a6" />
|
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 0.12, -0.38]}>
|
<mesh position={[0, 0.12, -0.38]}>
|
||||||
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
||||||
<meshStandardMaterial color="#14b8a6" />
|
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[-0.48, 0.12, 0]}>
|
<mesh position={[-0.48, 0.12, 0]}>
|
||||||
<boxGeometry args={[0.06, 0.28, 0.82]} />
|
<boxGeometry args={[0.06, 0.28, 0.82]} />
|
||||||
<meshStandardMaterial color="#14b8a6" />
|
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Direction arrow (subtle indication of flow) */}
|
||||||
|
<mesh position={[0.2, 0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<planeGeometry args={[0.3, 0.2]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#5eead4"
|
||||||
|
transparent
|
||||||
|
opacity={0.4}
|
||||||
|
side={2}
|
||||||
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -1,12 +1,74 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import type { SimulatedItem, SimulationState } from '../../domain/types';
|
import type { SimulatedItem, SimulationState } from '../../domain/types';
|
||||||
import { itemPosition3D, ROUTE_COLORS } from './itemMotion';
|
import { itemPosition3D, ROUTE_COLORS } from './itemMotion';
|
||||||
import { DIMENSION_LIMITS } from '../../domain/classifier';
|
import { DIMENSION_LIMITS } from '../../domain/classifier';
|
||||||
|
import { getModelAsset } from '../../data/modelAssets';
|
||||||
|
import STLModel from './STLModel';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
simulation: SimulationState;
|
simulation: SimulationState;
|
||||||
currentItem?: SimulatedItem;
|
currentItem?: SimulatedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FallbackPrimitive({
|
||||||
|
primitive,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
sz,
|
||||||
|
color,
|
||||||
|
emissive,
|
||||||
|
roughness,
|
||||||
|
}: {
|
||||||
|
primitive: 'box' | 'cylinder' | 'sphere';
|
||||||
|
sx: number;
|
||||||
|
sy: number;
|
||||||
|
sz: number;
|
||||||
|
color: string;
|
||||||
|
emissive: string;
|
||||||
|
roughness: number;
|
||||||
|
}) {
|
||||||
|
if (primitive === 'cylinder') {
|
||||||
|
return (
|
||||||
|
<mesh>
|
||||||
|
<cylinderGeometry args={[Math.min(sx, sz) / 2, Math.min(sx, sz) / 2, sy, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
emissive={emissive}
|
||||||
|
emissiveIntensity={0.2}
|
||||||
|
roughness={roughness}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primitive === 'sphere') {
|
||||||
|
return (
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[Math.max(sx, sy, sz) / 2, 16, 12]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
emissive={emissive}
|
||||||
|
emissiveIntensity={0.2}
|
||||||
|
roughness={roughness}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: box
|
||||||
|
return (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[sx, sy, sz]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
emissive={emissive}
|
||||||
|
emissiveIntensity={0.2}
|
||||||
|
roughness={roughness}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Item3D({ simulation, currentItem }: Props) {
|
export default function Item3D({ simulation, currentItem }: Props) {
|
||||||
if (!currentItem) {
|
if (!currentItem) {
|
||||||
return null;
|
return null;
|
||||||
@@ -16,39 +78,72 @@ export default function Item3D({ simulation, currentItem }: Props) {
|
|||||||
const category = currentItem.classification.category;
|
const category = currentItem.classification.category;
|
||||||
const color = ROUTE_COLORS[category] ?? '#38bdf8';
|
const color = ROUTE_COLORS[category] ?? '#38bdf8';
|
||||||
const dims = currentItem.item.dimensionsMm;
|
const dims = currentItem.item.dimensionsMm;
|
||||||
|
|
||||||
|
// Scale to Three.js units (1 unit = 1 meter, dimensions in mm)
|
||||||
const sx = Math.max(0.12, Math.min(0.45, dims.width / 1000));
|
const sx = Math.max(0.12, Math.min(0.45, dims.width / 1000));
|
||||||
const sy = Math.max(0.1, Math.min(0.4, dims.height / 1000));
|
const sy = Math.max(0.1, Math.min(0.4, dims.height / 1000));
|
||||||
const sz = Math.max(0.12, Math.min(0.45, dims.depth / 1000));
|
const sz = Math.max(0.12, Math.min(0.45, dims.depth / 1000));
|
||||||
const isRound =
|
|
||||||
currentItem.item.shape.includes('round') ||
|
|
||||||
currentItem.item.shape.includes('cylinder') ||
|
|
||||||
currentItem.item.roundness >= DIMENSION_LIMITS.roundnessThreshold;
|
|
||||||
const detecting = simulation.machineState === 'DETECTING';
|
const detecting = simulation.machineState === 'DETECTING';
|
||||||
const fault =
|
const fault = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||||
simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
|
||||||
|
// Get model asset for this item
|
||||||
|
const asset = getModelAsset(currentItem.item.id);
|
||||||
|
const finalColor = fault ? '#fb3d4e' : color;
|
||||||
|
const finalEmissive = fault ? '#fb3d4e' : color;
|
||||||
|
const finalRoughness = asset?.loaderType === 'stl' ? 0.45 : 0.5;
|
||||||
|
|
||||||
|
// Fallback primitive
|
||||||
|
const fallbackPrimitive = asset?.fallbackPrimitive ?? 'box';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[x, y, z]}>
|
<group position={[x, y, z]}>
|
||||||
{isRound ? (
|
{asset && asset.loaderType === 'stl' && asset.frontendAssetPath ? (
|
||||||
<mesh>
|
<Suspense
|
||||||
<cylinderGeometry args={[Math.min(sx, sz) / 2, Math.min(sx, sz) / 2, sy, 16]} />
|
fallback={
|
||||||
<meshStandardMaterial
|
<FallbackPrimitive
|
||||||
color={fault ? '#fb3d4e' : color}
|
primitive={fallbackPrimitive}
|
||||||
emissive={fault ? '#fb3d4e' : color}
|
sx={sx}
|
||||||
|
sy={sy}
|
||||||
|
sz={sz}
|
||||||
|
color={finalColor}
|
||||||
|
emissive={finalEmissive}
|
||||||
|
roughness={finalRoughness}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<STLModel
|
||||||
|
path={asset.frontendAssetPath}
|
||||||
|
scale={[sx, sy, sz]}
|
||||||
|
rotation={[0, 0, 0]}
|
||||||
|
position={[0, 0, 0]}
|
||||||
|
color={finalColor}
|
||||||
|
emissive={finalEmissive}
|
||||||
emissiveIntensity={0.2}
|
emissiveIntensity={0.2}
|
||||||
roughness={0.45}
|
roughness={finalRoughness}
|
||||||
|
fallback={
|
||||||
|
<FallbackPrimitive
|
||||||
|
primitive={fallbackPrimitive}
|
||||||
|
sx={sx}
|
||||||
|
sy={sy}
|
||||||
|
sz={sz}
|
||||||
|
color={finalColor}
|
||||||
|
emissive={finalEmissive}
|
||||||
|
roughness={finalRoughness}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<mesh>
|
<FallbackPrimitive
|
||||||
<boxGeometry args={[sx, sy, sz]} />
|
primitive={fallbackPrimitive}
|
||||||
<meshStandardMaterial
|
sx={sx}
|
||||||
color={fault ? '#fb3d4e' : color}
|
sy={sy}
|
||||||
emissive={fault ? '#fb3d4e' : color}
|
sz={sz}
|
||||||
emissiveIntensity={0.2}
|
color={finalColor}
|
||||||
roughness={0.5}
|
emissive={finalEmissive}
|
||||||
/>
|
roughness={finalRoughness}
|
||||||
</mesh>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detecting ? (
|
{detecting ? (
|
||||||
|
|||||||
88
src/components/ThreeD/STLModel.tsx
Normal file
88
src/components/ThreeD/STLModel.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* STLModel component — loads and displays STL models.
|
||||||
|
* Uses STLLoader from three/examples.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLoader } from '@react-three/fiber';
|
||||||
|
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||||
|
import { Center } from '@react-three/drei';
|
||||||
|
import type { BufferGeometry, Mesh as ThreeMesh } from 'three';
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface STLModelProps {
|
||||||
|
/** Path to STL file in public folder */
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
/** Scale factor [x, y, z] */
|
||||||
|
scale: [number, number, number];
|
||||||
|
|
||||||
|
/** Rotation [x, y, z] in radians */
|
||||||
|
rotation?: [number, number, number];
|
||||||
|
|
||||||
|
/** Position offset [x, y, z] */
|
||||||
|
position?: [number, number, number];
|
||||||
|
|
||||||
|
/** Material color */
|
||||||
|
color: string;
|
||||||
|
|
||||||
|
/** Emissive color (for glow effect) */
|
||||||
|
emissive?: string;
|
||||||
|
|
||||||
|
/** Emissive intensity */
|
||||||
|
emissiveIntensity?: number;
|
||||||
|
|
||||||
|
/** Roughness */
|
||||||
|
roughness?: number;
|
||||||
|
|
||||||
|
/** Fallback component if loading fails */
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function STLModel({
|
||||||
|
path,
|
||||||
|
scale,
|
||||||
|
rotation = [0, 0, 0],
|
||||||
|
position = [0, 0, 0],
|
||||||
|
color,
|
||||||
|
emissive,
|
||||||
|
emissiveIntensity = 0.2,
|
||||||
|
roughness = 0.5,
|
||||||
|
fallback,
|
||||||
|
}: STLModelProps) {
|
||||||
|
const meshRef = useRef<ThreeMesh>(null);
|
||||||
|
|
||||||
|
let geometry: BufferGeometry | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load STL geometry
|
||||||
|
geometry = useLoader(STLLoader, path);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to load STL model: ${path}`, error);
|
||||||
|
return fallback ? <>{fallback}</> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the geometry on load
|
||||||
|
useEffect(() => {
|
||||||
|
if (meshRef.current && geometry) {
|
||||||
|
geometry.center();
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
}
|
||||||
|
}, [geometry]);
|
||||||
|
|
||||||
|
if (!geometry) {
|
||||||
|
return fallback ? <>{fallback}</> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center position={position} rotation={rotation}>
|
||||||
|
<mesh ref={meshRef} geometry={geometry} scale={scale}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
emissive={emissive ?? color}
|
||||||
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
roughness={roughness}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ interface Props {
|
|||||||
currentItem?: SimulatedItem;
|
currentItem?: SimulatedItem;
|
||||||
simplified?: boolean;
|
simplified?: boolean;
|
||||||
conveyorTargetMps?: number;
|
conveyorTargetMps?: number;
|
||||||
|
technicalLabelsEnabled?: boolean;
|
||||||
|
cleanView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
@@ -44,77 +46,32 @@ function Badge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemProofPanel({
|
// ItemProofPanel removed — info now displayed in 2D proof card only
|
||||||
currentItem,
|
|
||||||
machineState,
|
|
||||||
}: {
|
|
||||||
currentItem: SimulatedItem;
|
|
||||||
machineState: MachineState;
|
|
||||||
}) {
|
|
||||||
const item = currentItem.item;
|
|
||||||
const result = currentItem.classification;
|
|
||||||
const category = result.category as Category;
|
|
||||||
const color = ROUTE_COLORS[category];
|
|
||||||
const command = machineState.startsWith('ROUTE_TO_')
|
|
||||||
? machineState
|
|
||||||
: `ROUTE_TO_${category}`;
|
|
||||||
const dims = item.dimensionsMm;
|
|
||||||
const dimStatus = result.dimensionsPass ? 'PASS' : 'FAIL';
|
|
||||||
const roundStatus = result.roundnessPass ? 'PASS' : 'DETECTED';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Html position={[0, 2.05, 0]} center distanceFactor={9} style={{ pointerEvents: 'none' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minWidth: 260,
|
|
||||||
padding: '12px 14px',
|
|
||||||
borderRadius: 14,
|
|
||||||
background: 'rgba(5, 9, 16, 0.94)',
|
|
||||||
border: `2px solid ${color}`,
|
|
||||||
color: '#e5f2ff',
|
|
||||||
fontFamily: 'Inter, system-ui, sans-serif',
|
|
||||||
boxShadow: `0 0 24px ${color}44`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, color: '#91a4b8', fontWeight: 800, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
|
||||||
Current item
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 800, margin: '4px 0 10px' }}>{item.name}</div>
|
|
||||||
<div style={{ display: 'grid', gap: 6, fontSize: 14, fontWeight: 700 }}>
|
|
||||||
<div>
|
|
||||||
Dimensions: {dims.width}×{dims.depth}×{dims.height} mm{' '}
|
|
||||||
<span style={{ color: result.dimensionsPass ? '#4ade80' : '#fb3d4e' }}>{dimStatus}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Roundness K = {item.roundness.toFixed(2)}{' '}
|
|
||||||
<span style={{ color: result.roundnessPass ? '#4ade80' : '#c084fc' }}>{roundStatus}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ color }}>
|
|
||||||
Category: {category}
|
|
||||||
</div>
|
|
||||||
<div style={{ color }}>
|
|
||||||
Command: {command}
|
|
||||||
</div>
|
|
||||||
<div style={{ color }}>
|
|
||||||
Target zone: {category}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SceneLabels3D({
|
export default function SceneLabels3D({
|
||||||
machineState,
|
machineState,
|
||||||
currentItem,
|
currentItem,
|
||||||
conveyorTargetMps = 1,
|
conveyorTargetMps = 1,
|
||||||
|
technicalLabelsEnabled = false,
|
||||||
|
cleanView = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const category = currentItem?.classification.category as Category | undefined;
|
const category = currentItem?.classification.category as Category | undefined;
|
||||||
const cage = TWIN_LAYOUT.rollCageSize;
|
const cage = TWIN_LAYOUT.rollCageSize;
|
||||||
|
const detecting = machineState === 'DETECTING';
|
||||||
|
const atGate = machineState === 'WAITING_AT_GATE' || machineState === 'CLASSIFYING';
|
||||||
|
const routing = machineState.startsWith('ROUTE_TO_');
|
||||||
|
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
|
||||||
|
|
||||||
|
// C-priority: dimensions failed + round detected → only C active, D not active
|
||||||
|
const isCPriority = Boolean(
|
||||||
|
category === 'C' && currentItem && !currentItem.classification.dimensionsPass && !currentItem.classification.roundnessPass
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Zones A/B/C/D — always explicit */}
|
{/* === CLEAN VIEW (always visible) === */}
|
||||||
|
|
||||||
|
{/* Zones A/B/C/D — always visible */}
|
||||||
<Badge position={[TWIN_LAYOUT.startX, 0.85, 0]} text="A" color="#38bdf8" large />
|
<Badge position={[TWIN_LAYOUT.startX, 0.85, 0]} text="A" color="#38bdf8" large />
|
||||||
<Badge position={[TWIN_LAYOUT.zoneBX, 0.95, 0]} text="B" color={ROUTE_COLORS.B} large />
|
<Badge position={[TWIN_LAYOUT.zoneBX, 0.95, 0]} text="B" color={ROUTE_COLORS.B} large />
|
||||||
<Badge
|
<Badge
|
||||||
@@ -130,7 +87,7 @@ export default function SceneLabels3D({
|
|||||||
large
|
large
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Accumulator at end of feed conveyor */}
|
{/* Накопитель — always visible */}
|
||||||
<Badge
|
<Badge
|
||||||
position={[TWIN_LAYOUT.accumulatorX, 0.95, 0.65]}
|
position={[TWIN_LAYOUT.accumulatorX, 0.95, 0.65]}
|
||||||
text="Накопитель"
|
text="Накопитель"
|
||||||
@@ -138,51 +95,109 @@ export default function SceneLabels3D({
|
|||||||
large
|
large
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sensors & actuators — always visible */}
|
{/* === STEP-BASED LABELS (shown only when active) === */}
|
||||||
<Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera / CV" color="#38bdf8" />
|
|
||||||
<Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />
|
|
||||||
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.05, -0.55]} text="Ultrasonic" color="#67e8f9" />
|
|
||||||
<Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />
|
|
||||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
|
|
||||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
|
|
||||||
|
|
||||||
{/* Route labels */}
|
{/* Detection: Camera/CV + Laser */}
|
||||||
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="Route B" color={ROUTE_COLORS.B} />
|
{detecting && (
|
||||||
<Badge
|
<>
|
||||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
|
<Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />
|
||||||
text="Route C"
|
<Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />
|
||||||
color={ROUTE_COLORS.C}
|
</>
|
||||||
/>
|
)}
|
||||||
<Badge
|
|
||||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
|
|
||||||
text="Route D"
|
|
||||||
color={ROUTE_COLORS.D}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Badge
|
{/* At gate: Stop-gate + Ultrasonic */}
|
||||||
position={[-3.2, 1.55, 1.4]}
|
{atGate && (
|
||||||
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
|
<>
|
||||||
color="#91a4b8"
|
<Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />
|
||||||
/>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentItem ? <ItemProofPanel currentItem={currentItem} machineState={machineState} /> : null}
|
{/* Routing: active pusher only */}
|
||||||
|
{routing && category === 'C' && (
|
||||||
|
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
|
||||||
|
)}
|
||||||
|
{routing && category === 'D' && (
|
||||||
|
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
|
||||||
|
)}
|
||||||
|
|
||||||
{category === 'C' && currentItem && !currentItem.classification.roundnessPass ? (
|
{/* Active route only (not all three routes) */}
|
||||||
|
{routing && category === 'B' && (
|
||||||
|
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="→ B" color={ROUTE_COLORS.B} />
|
||||||
|
)}
|
||||||
|
{routing && category === 'C' && (
|
||||||
|
<Badge
|
||||||
|
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
|
||||||
|
text="→ C"
|
||||||
|
color={ROUTE_COLORS.C}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{routing && category === 'D' && (
|
||||||
|
<Badge
|
||||||
|
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
|
||||||
|
text="→ D"
|
||||||
|
color={ROUTE_COLORS.D}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* C-priority badge (only when applicable) */}
|
||||||
|
{isCPriority && (
|
||||||
<Badge
|
<Badge
|
||||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.7, TWIN_LAYOUT.zoneCZ]}
|
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.7, TWIN_LAYOUT.zoneCZ]}
|
||||||
text="C-priority (dims first)"
|
text="C-priority"
|
||||||
color={ROUTE_COLORS.C}
|
color={ROUTE_COLORS.C}
|
||||||
large
|
large
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{machineState === 'DETECTING' ? (
|
{/* Fault / Emergency stop */}
|
||||||
<Badge position={[TWIN_LAYOUT.cameraX, 1.55, 0]} text="CV detection" color="#38bdf8" />
|
{fault && (
|
||||||
) : null}
|
|
||||||
|
|
||||||
{machineState === 'FAULT' || machineState === 'EMERGENCY_STOP' ? (
|
|
||||||
<Badge position={[0, 2.5, 0]} text={machineState} color="#fb3d4e" large />
|
<Badge position={[0, 2.5, 0]} text={machineState} color="#fb3d4e" large />
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
|
{/* === TECHNICAL LABELS (opt-in) === */}
|
||||||
|
{technicalLabelsEnabled && !cleanView && (
|
||||||
|
<>
|
||||||
|
{/* Always show all sensors when technical mode is on */}
|
||||||
|
{!detecting && <Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />}
|
||||||
|
{!detecting && <Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />}
|
||||||
|
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.05, -0.55]} text="Ultrasonic" color="#67e8f9" />
|
||||||
|
|
||||||
|
{/* Always show all actuators */}
|
||||||
|
{!atGate && <Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />}
|
||||||
|
{!(routing && category === 'C') && (
|
||||||
|
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
|
||||||
|
)}
|
||||||
|
{!(routing && category === 'D') && (
|
||||||
|
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All route beams */}
|
||||||
|
{!(routing && category === 'B') && (
|
||||||
|
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="Route B" color={ROUTE_COLORS.B} />
|
||||||
|
)}
|
||||||
|
{!(routing && category === 'C') && (
|
||||||
|
<Badge
|
||||||
|
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
|
||||||
|
text="Route C"
|
||||||
|
color={ROUTE_COLORS.C}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!(routing && category === 'D') && (
|
||||||
|
<Badge
|
||||||
|
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
|
||||||
|
text="Route D"
|
||||||
|
color={ROUTE_COLORS.D}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conveyor info */}
|
||||||
|
<Badge
|
||||||
|
position={[-3.2, 1.55, 1.4]}
|
||||||
|
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
|
||||||
|
color="#91a4b8"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface SorterDigitalTwinProps {
|
|||||||
simulation: SimulationState;
|
simulation: SimulationState;
|
||||||
simplified?: boolean;
|
simplified?: boolean;
|
||||||
showFps?: boolean;
|
showFps?: boolean;
|
||||||
|
technicalLabelsEnabled?: boolean;
|
||||||
|
cleanView?: boolean;
|
||||||
onContextLost?: () => void;
|
onContextLost?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +43,14 @@ function FpsMeter({ onFps }: { onFps: (fps: number) => void }) {
|
|||||||
function TwinScene({
|
function TwinScene({
|
||||||
simulation,
|
simulation,
|
||||||
simplified,
|
simplified,
|
||||||
|
technicalLabelsEnabled,
|
||||||
|
cleanView,
|
||||||
onFps,
|
onFps,
|
||||||
}: {
|
}: {
|
||||||
simulation: SimulationState;
|
simulation: SimulationState;
|
||||||
simplified?: boolean;
|
simplified?: boolean;
|
||||||
|
technicalLabelsEnabled?: boolean;
|
||||||
|
cleanView?: boolean;
|
||||||
onFps: (fps: number) => void;
|
onFps: (fps: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const stopped =
|
const stopped =
|
||||||
@@ -99,6 +105,8 @@ function TwinScene({
|
|||||||
currentItem={simulation.currentItem}
|
currentItem={simulation.currentItem}
|
||||||
simplified={simplified}
|
simplified={simplified}
|
||||||
conveyorTargetMps={NOMINAL_CONVEYOR_SPEED_MPS}
|
conveyorTargetMps={NOMINAL_CONVEYOR_SPEED_MPS}
|
||||||
|
technicalLabelsEnabled={technicalLabelsEnabled}
|
||||||
|
cleanView={cleanView}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{stopped ? (
|
{stopped ? (
|
||||||
@@ -125,6 +133,8 @@ export default function SorterDigitalTwin({
|
|||||||
simulation,
|
simulation,
|
||||||
simplified = false,
|
simplified = false,
|
||||||
showFps = true,
|
showFps = true,
|
||||||
|
technicalLabelsEnabled = false,
|
||||||
|
cleanView = true,
|
||||||
onContextLost,
|
onContextLost,
|
||||||
}: SorterDigitalTwinProps) {
|
}: SorterDigitalTwinProps) {
|
||||||
const [fps, setFps] = useState(0);
|
const [fps, setFps] = useState(0);
|
||||||
@@ -169,7 +179,13 @@ export default function SorterDigitalTwin({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<TwinScene simulation={simulation} simplified={simplified} onFps={setFps} />
|
<TwinScene
|
||||||
|
simulation={simulation}
|
||||||
|
simplified={simplified}
|
||||||
|
technicalLabelsEnabled={technicalLabelsEnabled}
|
||||||
|
cleanView={cleanView}
|
||||||
|
onFps={setFps}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
import type { Category } from '../../domain/types';
|
import type { Category } from '../../domain/types';
|
||||||
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
|
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
|
||||||
|
|
||||||
@@ -31,6 +32,54 @@ function ZoneBox({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Roll-cage with wireframe edges for C/D zones */
|
||||||
|
function RollCage({
|
||||||
|
position,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
position: [number, number, number];
|
||||||
|
size: [number, number, number];
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<group position={position}>
|
||||||
|
{/* Cage body (transparent) */}
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={size} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={active ? 0.25 : 0.1}
|
||||||
|
emissive={color}
|
||||||
|
emissiveIntensity={active ? 0.3 : 0.08}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Cage wireframe edges (visible frame) */}
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(...size)]} />
|
||||||
|
<lineBasicMaterial color={color} linewidth={active ? 2 : 1} opacity={active ? 1 : 0.6} transparent />
|
||||||
|
</lineSegments>
|
||||||
|
|
||||||
|
{/* Vertical posts (corners) for visual emphasis */}
|
||||||
|
{[
|
||||||
|
[-size[0] / 2, 0, -size[2] / 2],
|
||||||
|
[size[0] / 2, 0, -size[2] / 2],
|
||||||
|
[-size[0] / 2, 0, size[2] / 2],
|
||||||
|
[size[0] / 2, 0, size[2] / 2],
|
||||||
|
].map((offset, i) => (
|
||||||
|
<mesh key={i} position={offset as [number, number, number]}>
|
||||||
|
<boxGeometry args={[0.04, size[1], 0.04]} />
|
||||||
|
<meshStandardMaterial color={color} metalness={0.3} roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
|
export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
|
||||||
const active = activeRoute ?? activeCategory;
|
const active = activeRoute ?? activeCategory;
|
||||||
const cage = TWIN_LAYOUT.rollCageSize;
|
const cage = TWIN_LAYOUT.rollCageSize;
|
||||||
@@ -53,16 +102,16 @@ export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
|
|||||||
active={active === 'B'}
|
active={active === 'B'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Roll-cage C 1200×800×800 mm — orange */}
|
{/* Roll-cage C 1200×800×800 mm — orange with wireframe */}
|
||||||
<ZoneBox
|
<RollCage
|
||||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneCZ]}
|
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneCZ]}
|
||||||
size={[cage.x, cage.y, cage.z]}
|
size={[cage.x, cage.y, cage.z]}
|
||||||
color={ROUTE_COLORS.C}
|
color={ROUTE_COLORS.C}
|
||||||
active={active === 'C'}
|
active={active === 'C'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Roll-cage D 1200×800×800 mm — purple */}
|
{/* Roll-cage D 1200×800×800 mm — purple with wireframe */}
|
||||||
<ZoneBox
|
<RollCage
|
||||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneDZ]}
|
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneDZ]}
|
||||||
size={[cage.x, cage.y, cage.z]}
|
size={[cage.x, cage.y, cage.z]}
|
||||||
color={ROUTE_COLORS.D}
|
color={ROUTE_COLORS.D}
|
||||||
|
|||||||
109
src/data/modelAssets.test.ts
Normal file
109
src/data/modelAssets.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
MODEL_ASSETS,
|
||||||
|
getModelAsset,
|
||||||
|
getSTLAssets,
|
||||||
|
getProceduralAssets,
|
||||||
|
getManifestStats,
|
||||||
|
} from './modelAssets';
|
||||||
|
|
||||||
|
describe('modelAssets', () => {
|
||||||
|
describe('MODEL_ASSETS', () => {
|
||||||
|
it('should contain assets for all scenario items', () => {
|
||||||
|
const expectedIds = [
|
||||||
|
'SKU-001',
|
||||||
|
'SKU-002',
|
||||||
|
'SKU-003',
|
||||||
|
'SKU-004',
|
||||||
|
'SKU-005',
|
||||||
|
'SKU-006',
|
||||||
|
'SKU-007',
|
||||||
|
'SKU-008',
|
||||||
|
'SKU-009',
|
||||||
|
'SKU-010',
|
||||||
|
'SKU-011',
|
||||||
|
];
|
||||||
|
|
||||||
|
const actualIds = MODEL_ASSETS.map((asset) => asset.itemId);
|
||||||
|
expectedIds.forEach((id) => {
|
||||||
|
expect(actualIds).toContain(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have fallback primitive for every asset', () => {
|
||||||
|
MODEL_ASSETS.forEach((asset) => {
|
||||||
|
expect(asset.fallbackPrimitive).toMatch(/^(box|cylinder|sphere)$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid loader type for every asset', () => {
|
||||||
|
MODEL_ASSETS.forEach((asset) => {
|
||||||
|
expect(asset.loaderType).toMatch(/^(stl|glb|procedural)$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have frontendAssetPath only for stl/glb loaders', () => {
|
||||||
|
MODEL_ASSETS.forEach((asset) => {
|
||||||
|
if (asset.loaderType === 'procedural') {
|
||||||
|
expect(asset.frontendAssetPath).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
expect(asset.frontendAssetPath).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getModelAsset', () => {
|
||||||
|
it('should return asset for valid item ID', () => {
|
||||||
|
const asset = getModelAsset('SKU-006');
|
||||||
|
expect(asset).toBeDefined();
|
||||||
|
expect(asset?.displayName).toBe('Тарелка');
|
||||||
|
expect(asset?.categoryScenario).toBe('D');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid item ID', () => {
|
||||||
|
const asset = getModelAsset('SKU-999');
|
||||||
|
expect(asset).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSTLAssets', () => {
|
||||||
|
it('should return only assets with loaderType stl', () => {
|
||||||
|
const stlAssets = getSTLAssets();
|
||||||
|
stlAssets.forEach((asset) => {
|
||||||
|
expect(asset.loaderType).toBe('stl');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain at least some STL assets', () => {
|
||||||
|
const stlAssets = getSTLAssets();
|
||||||
|
expect(stlAssets.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProceduralAssets', () => {
|
||||||
|
it('should return only assets with loaderType procedural', () => {
|
||||||
|
const proceduralAssets = getProceduralAssets();
|
||||||
|
proceduralAssets.forEach((asset) => {
|
||||||
|
expect(asset.loaderType).toBe('procedural');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getManifestStats', () => {
|
||||||
|
it('should return correct totals', () => {
|
||||||
|
const stats = getManifestStats();
|
||||||
|
expect(stats.total).toBe(MODEL_ASSETS.length);
|
||||||
|
expect(stats.stl).toBe(getSTLAssets().length);
|
||||||
|
expect(stats.procedural).toBe(getProceduralAssets().length);
|
||||||
|
expect(stats.stl + stats.procedural).toBe(stats.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct percentage', () => {
|
||||||
|
const stats = getManifestStats();
|
||||||
|
expect(stats.stlPercentage).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(stats.stlPercentage).toBeLessThanOrEqual(100);
|
||||||
|
expect(stats.stlPercentage).toBe(Math.round((stats.stl / stats.total) * 100));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
202
src/data/modelAssets.ts
Normal file
202
src/data/modelAssets.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Model Assets Manifest — real 3D models for product items.
|
||||||
|
* Maps simulation items to real STL/GLB models from input_info.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Category, DimensionsMm } from '../domain/types';
|
||||||
|
|
||||||
|
export interface ModelAsset {
|
||||||
|
/** Item ID from items.ts (e.g. 'SKU-006') */
|
||||||
|
itemId: string;
|
||||||
|
|
||||||
|
/** Display name (Russian) */
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
/** Expected category scenario */
|
||||||
|
categoryScenario: Category;
|
||||||
|
|
||||||
|
/** Dimensions in mm */
|
||||||
|
dimensions: DimensionsMm;
|
||||||
|
|
||||||
|
/** Source file from input_info (for reference) */
|
||||||
|
sourceFile: string;
|
||||||
|
|
||||||
|
/** Frontend asset path (public/models or undefined if not used) */
|
||||||
|
frontendAssetPath?: string;
|
||||||
|
|
||||||
|
/** Loader type: 'stl' = use STLLoader, 'glb' = use GLTFLoader, 'procedural' = fallback primitive */
|
||||||
|
loaderType: 'stl' | 'glb' | 'procedural';
|
||||||
|
|
||||||
|
/** Fallback primitive shape if model fails to load */
|
||||||
|
fallbackPrimitive: 'box' | 'cylinder' | 'sphere';
|
||||||
|
|
||||||
|
/** Notes about why model is used/not used */
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model assets manifest.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Lightweight STL (< 700 KB): use directly with STLLoader.
|
||||||
|
* - Heavy STL (> 1 MB): use procedural fallback, mark as "too heavy".
|
||||||
|
* - Missing models: use procedural fallback.
|
||||||
|
*/
|
||||||
|
export const MODEL_ASSETS: ModelAsset[] = [
|
||||||
|
{
|
||||||
|
itemId: 'SKU-001',
|
||||||
|
displayName: 'Короб 300×200×200',
|
||||||
|
categoryScenario: 'B',
|
||||||
|
dimensions: { width: 300, depth: 200, height: 200 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Короб 300х200х200.stl',
|
||||||
|
frontendAssetPath: '/models/box-300.stl',
|
||||||
|
loaderType: 'stl',
|
||||||
|
fallbackPrimitive: 'box',
|
||||||
|
notes: 'Lightweight STL (29 KB), used directly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-002',
|
||||||
|
displayName: 'ЛанчБокс',
|
||||||
|
categoryScenario: 'B',
|
||||||
|
dimensions: { width: 201, depth: 152, height: 62 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/ЛанчБокс.stl',
|
||||||
|
frontendAssetPath: '/models/lunchbox.stl',
|
||||||
|
loaderType: 'stl',
|
||||||
|
fallbackPrimitive: 'box',
|
||||||
|
notes: 'Medium STL (566 KB), acceptable for browser loading.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-003',
|
||||||
|
displayName: 'Моющее средство',
|
||||||
|
categoryScenario: 'B',
|
||||||
|
dimensions: { width: 259, depth: 179, height: 278 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Моющее средство.STL',
|
||||||
|
frontendAssetPath: undefined,
|
||||||
|
loaderType: 'procedural',
|
||||||
|
fallbackPrimitive: 'box',
|
||||||
|
notes: 'Heavy STL (3.5 MB), using fallback box to avoid performance impact.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-004',
|
||||||
|
displayName: 'Короб 400×400×300 (негабарит)',
|
||||||
|
categoryScenario: 'C',
|
||||||
|
dimensions: { width: 401, depth: 300, height: 400 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Короб 400х400х300.stl',
|
||||||
|
frontendAssetPath: '/models/box-400.stl',
|
||||||
|
loaderType: 'stl',
|
||||||
|
fallbackPrimitive: 'box',
|
||||||
|
notes: 'Lightweight STL (27 KB), used directly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-005',
|
||||||
|
displayName: 'Пуфик',
|
||||||
|
categoryScenario: 'C',
|
||||||
|
dimensions: { width: 489, depth: 264, height: 489 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Пуфик.stl',
|
||||||
|
frontendAssetPath: undefined,
|
||||||
|
loaderType: 'procedural',
|
||||||
|
fallbackPrimitive: 'cylinder',
|
||||||
|
notes: 'Medium-heavy STL (629 KB), using fallback cylinder for soft bulky item representation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-006',
|
||||||
|
displayName: 'Тарелка',
|
||||||
|
categoryScenario: 'D',
|
||||||
|
dimensions: { width: 210, depth: 209, height: 27 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Тарелка.stl',
|
||||||
|
frontendAssetPath: '/models/plate.stl',
|
||||||
|
loaderType: 'stl',
|
||||||
|
fallbackPrimitive: 'cylinder',
|
||||||
|
notes: 'Lightweight STL (123 KB), used directly. Round plate is visually distinctive.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-007',
|
||||||
|
displayName: 'Бутылка',
|
||||||
|
categoryScenario: 'D',
|
||||||
|
dimensions: { width: 91, depth: 91, height: 305 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Бутылка.stl',
|
||||||
|
frontendAssetPath: '/models/bottle.stl',
|
||||||
|
loaderType: 'stl',
|
||||||
|
fallbackPrimitive: 'cylinder',
|
||||||
|
notes: 'Medium STL (319 KB), acceptable. Bottle is visually recognizable.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-008',
|
||||||
|
displayName: 'Цилиндр',
|
||||||
|
categoryScenario: 'D',
|
||||||
|
dimensions: { width: 435, depth: 50, height: 43 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Цилиндр.stl',
|
||||||
|
frontendAssetPath: '/models/cylinder.stl',
|
||||||
|
loaderType: 'stl',
|
||||||
|
fallbackPrimitive: 'cylinder',
|
||||||
|
notes: 'Lightweight STL (106 KB), used directly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-009',
|
||||||
|
displayName: 'Ручка',
|
||||||
|
categoryScenario: 'C',
|
||||||
|
dimensions: { width: 9, depth: 13, height: 148 },
|
||||||
|
sourceFile: 'input_info/extracted/Stl/Ручка.stl',
|
||||||
|
frontendAssetPath: undefined,
|
||||||
|
loaderType: 'procedural',
|
||||||
|
fallbackPrimitive: 'box',
|
||||||
|
notes: 'Heavy STL (2.0 MB), using fallback thin box for pen representation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-010',
|
||||||
|
displayName: 'Boundary box 450×320×320',
|
||||||
|
categoryScenario: 'B',
|
||||||
|
dimensions: { width: 450, depth: 320, height: 320 },
|
||||||
|
sourceFile: 'N/A',
|
||||||
|
frontendAssetPath: undefined,
|
||||||
|
loaderType: 'procedural',
|
||||||
|
fallbackPrimitive: 'box',
|
||||||
|
notes: 'No STL available for boundary box, using procedural box.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'SKU-011',
|
||||||
|
displayName: 'Oversized round',
|
||||||
|
categoryScenario: 'C',
|
||||||
|
dimensions: { width: 500, depth: 300, height: 300 },
|
||||||
|
sourceFile: 'N/A',
|
||||||
|
frontendAssetPath: undefined,
|
||||||
|
loaderType: 'procedural',
|
||||||
|
fallbackPrimitive: 'cylinder',
|
||||||
|
notes: 'No STL available for oversized round, using procedural cylinder.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model asset for item ID.
|
||||||
|
*/
|
||||||
|
export function getModelAsset(itemId: string): ModelAsset | undefined {
|
||||||
|
return MODEL_ASSETS.find((asset) => asset.itemId === itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all assets that use real STL models.
|
||||||
|
*/
|
||||||
|
export function getSTLAssets(): ModelAsset[] {
|
||||||
|
return MODEL_ASSETS.filter((asset) => asset.loaderType === 'stl');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all assets that use procedural fallbacks.
|
||||||
|
*/
|
||||||
|
export function getProceduralAssets(): ModelAsset[] {
|
||||||
|
return MODEL_ASSETS.filter((asset) => asset.loaderType === 'procedural');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary stats for manifest.
|
||||||
|
*/
|
||||||
|
export function getManifestStats() {
|
||||||
|
const stlCount = getSTLAssets().length;
|
||||||
|
const proceduralCount = getProceduralAssets().length;
|
||||||
|
return {
|
||||||
|
total: MODEL_ASSETS.length,
|
||||||
|
stl: stlCount,
|
||||||
|
procedural: proceduralCount,
|
||||||
|
stlPercentage: Math.round((stlCount / MODEL_ASSETS.length) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
201
src/domain/demoDirector.test.ts
Normal file
201
src/domain/demoDirector.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
DemoStep,
|
||||||
|
createDemoDirectorState,
|
||||||
|
startAutoDemo,
|
||||||
|
pauseAutoDemo,
|
||||||
|
resumeAutoDemo,
|
||||||
|
stopAutoDemo,
|
||||||
|
updateAutoDemo,
|
||||||
|
getDemoStepSequence,
|
||||||
|
getDemoStepConfig,
|
||||||
|
setPlaybackSpeed,
|
||||||
|
toggleLoopMode,
|
||||||
|
} from './demoDirector';
|
||||||
|
import type { Scenario } from './types';
|
||||||
|
|
||||||
|
import { ITEMS } from '../data/items';
|
||||||
|
|
||||||
|
const mockScenario: Scenario = {
|
||||||
|
id: 'normal_flow',
|
||||||
|
name: 'Normal Flow',
|
||||||
|
description: 'Test scenario',
|
||||||
|
goal: 'Test auto demo',
|
||||||
|
expectedCategorySummary: 'B',
|
||||||
|
demonstrates: 'Demo director test',
|
||||||
|
items: [ITEMS[0]],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('demoDirector', () => {
|
||||||
|
describe('createDemoDirectorState', () => {
|
||||||
|
it('should create initial state', () => {
|
||||||
|
const state = createDemoDirectorState(mockScenario);
|
||||||
|
expect(state.isAutoDemoRunning).toBe(false);
|
||||||
|
expect(state.currentStep).toBe(DemoStep.intro);
|
||||||
|
expect(state.stepElapsedMs).toBe(0);
|
||||||
|
expect(state.totalElapsedMs).toBe(0);
|
||||||
|
expect(state.playbackSpeed).toBe(1);
|
||||||
|
expect(state.loopMode).toBe(false);
|
||||||
|
expect(state.paused).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startAutoDemo', () => {
|
||||||
|
it('should start auto demo from intro', () => {
|
||||||
|
const initial = createDemoDirectorState(mockScenario);
|
||||||
|
const started = startAutoDemo(initial);
|
||||||
|
expect(started.isAutoDemoRunning).toBe(true);
|
||||||
|
expect(started.currentStep).toBe(DemoStep.intro);
|
||||||
|
expect(started.paused).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pauseAutoDemo', () => {
|
||||||
|
it('should pause running demo', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
state = pauseAutoDemo(state);
|
||||||
|
expect(state.paused).toBe(true);
|
||||||
|
expect(state.isAutoDemoRunning).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resumeAutoDemo', () => {
|
||||||
|
it('should resume paused demo', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
state = pauseAutoDemo(state);
|
||||||
|
state = resumeAutoDemo(state);
|
||||||
|
expect(state.paused).toBe(false);
|
||||||
|
expect(state.isAutoDemoRunning).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stopAutoDemo', () => {
|
||||||
|
it('should stop demo and reset to intro', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
state = stopAutoDemo(state);
|
||||||
|
expect(state.isAutoDemoRunning).toBe(false);
|
||||||
|
expect(state.paused).toBe(false);
|
||||||
|
expect(state.currentStep).toBe(DemoStep.intro);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateAutoDemo', () => {
|
||||||
|
it('should not update if not running', () => {
|
||||||
|
const state = createDemoDirectorState(mockScenario);
|
||||||
|
const updated = updateAutoDemo(state, 100);
|
||||||
|
expect(updated.stepElapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update if paused', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
state = pauseAutoDemo(state);
|
||||||
|
const updated = updateAutoDemo(state, 100);
|
||||||
|
expect(updated.stepElapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment elapsed time', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
const updated = updateAutoDemo(state, 100);
|
||||||
|
expect(updated.stepElapsedMs).toBe(100);
|
||||||
|
expect(updated.totalElapsedMs).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition to next step when duration exceeded', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
const config = getDemoStepConfig(DemoStep.intro);
|
||||||
|
const updated = updateAutoDemo(state, config.durationMs + 100);
|
||||||
|
expect(updated.currentStep).toBe(DemoStep.spawn_item);
|
||||||
|
expect(updated.stepElapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop demo when all steps completed (no loop)', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
state = { ...state, currentStep: DemoStep.reset_or_next };
|
||||||
|
const config = getDemoStepConfig(DemoStep.reset_or_next);
|
||||||
|
const updated = updateAutoDemo(state, config.durationMs + 100);
|
||||||
|
expect(updated.isAutoDemoRunning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restart demo when all steps completed (loop mode)', () => {
|
||||||
|
let state = createDemoDirectorState(mockScenario);
|
||||||
|
state = startAutoDemo(state);
|
||||||
|
state = toggleLoopMode(state);
|
||||||
|
state = { ...state, currentStep: DemoStep.reset_or_next };
|
||||||
|
const config = getDemoStepConfig(DemoStep.reset_or_next);
|
||||||
|
const updated = updateAutoDemo(state, config.durationMs + 100);
|
||||||
|
expect(updated.currentStep).toBe(DemoStep.intro);
|
||||||
|
expect(updated.isAutoDemoRunning).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDemoStepSequence', () => {
|
||||||
|
it('should contain all key steps in order', () => {
|
||||||
|
const sequence = getDemoStepSequence();
|
||||||
|
expect(sequence).toContain(DemoStep.intro);
|
||||||
|
expect(sequence).toContain(DemoStep.spawn_item);
|
||||||
|
expect(sequence).toContain(DemoStep.detection_scan);
|
||||||
|
expect(sequence).toContain(DemoStep.dimensions_check);
|
||||||
|
expect(sequence).toContain(DemoStep.roundness_check);
|
||||||
|
expect(sequence).toContain(DemoStep.decision);
|
||||||
|
expect(sequence).toContain(DemoStep.command_sent);
|
||||||
|
expect(sequence).toContain(DemoStep.actuator_move);
|
||||||
|
expect(sequence).toContain(DemoStep.route_item);
|
||||||
|
expect(sequence).toContain(DemoStep.result);
|
||||||
|
|
||||||
|
// Verify order: command_sent must come before actuator_move
|
||||||
|
const commandIndex = sequence.indexOf(DemoStep.command_sent);
|
||||||
|
const actuatorIndex = sequence.indexOf(DemoStep.actuator_move);
|
||||||
|
expect(commandIndex).toBeLessThan(actuatorIndex);
|
||||||
|
|
||||||
|
// Verify order: actuator_move must come before route_item
|
||||||
|
const routeIndex = sequence.indexOf(DemoStep.route_item);
|
||||||
|
expect(actuatorIndex).toBeLessThan(routeIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDemoStepConfig', () => {
|
||||||
|
it('should return config for each step', () => {
|
||||||
|
const config = getDemoStepConfig(DemoStep.detection_scan);
|
||||||
|
expect(config.step).toBe(DemoStep.detection_scan);
|
||||||
|
expect(config.durationMs).toBeGreaterThan(0);
|
||||||
|
expect(config.label).toBeTruthy();
|
||||||
|
expect(config.description).toBeTruthy();
|
||||||
|
expect(config.cameraPreset).toBeDefined();
|
||||||
|
expect(config.activeLabels).toBeInstanceOf(Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use category for routing steps', () => {
|
||||||
|
const configC = getDemoStepConfig(DemoStep.route_item, 'C');
|
||||||
|
expect(configC.activeLabels).toContain('C');
|
||||||
|
|
||||||
|
const configD = getDemoStepConfig(DemoStep.route_item, 'D');
|
||||||
|
expect(configD.activeLabels).toContain('D');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setPlaybackSpeed', () => {
|
||||||
|
it('should set playback speed', () => {
|
||||||
|
const state = createDemoDirectorState(mockScenario);
|
||||||
|
const updated = setPlaybackSpeed(state, 2);
|
||||||
|
expect(updated.playbackSpeed).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleLoopMode', () => {
|
||||||
|
it('should toggle loop mode', () => {
|
||||||
|
const state = createDemoDirectorState(mockScenario);
|
||||||
|
expect(state.loopMode).toBe(false);
|
||||||
|
const toggled = toggleLoopMode(state);
|
||||||
|
expect(toggled.loopMode).toBe(true);
|
||||||
|
const toggledAgain = toggleLoopMode(toggled);
|
||||||
|
expect(toggledAgain.loopMode).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
413
src/domain/demoDirector.ts
Normal file
413
src/domain/demoDirector.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* Demo Director — автоматическая демонстрация работы ПАК.
|
||||||
|
* Управляет step-by-step показом: intro → spawn → detection → decision → routing → result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Scenario, MachineState, Category } from './types';
|
||||||
|
|
||||||
|
export enum DemoStep {
|
||||||
|
intro = 'intro',
|
||||||
|
spawn_item = 'spawn_item',
|
||||||
|
move_to_detection = 'move_to_detection',
|
||||||
|
detection_scan = 'detection_scan',
|
||||||
|
dimensions_check = 'dimensions_check',
|
||||||
|
roundness_check = 'roundness_check',
|
||||||
|
decision = 'decision',
|
||||||
|
command_sent = 'command_sent',
|
||||||
|
actuator_move = 'actuator_move',
|
||||||
|
route_item = 'route_item',
|
||||||
|
result = 'result',
|
||||||
|
reset_or_next = 'reset_or_next',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoStepConfig {
|
||||||
|
step: DemoStep;
|
||||||
|
durationMs: number;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
cameraPreset: CameraPreset;
|
||||||
|
activeLabels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraPreset {
|
||||||
|
position: [number, number, number];
|
||||||
|
target: [number, number, number];
|
||||||
|
fov: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoDirectorState {
|
||||||
|
isAutoDemoRunning: boolean;
|
||||||
|
currentScenario: Scenario;
|
||||||
|
currentStep: DemoStep;
|
||||||
|
stepElapsedMs: number;
|
||||||
|
totalElapsedMs: number;
|
||||||
|
playbackSpeed: 1 | 2 | 0.5;
|
||||||
|
loopMode: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camera presets for demo steps.
|
||||||
|
*/
|
||||||
|
export const CAMERA_PRESETS: Record<string, CameraPreset> = {
|
||||||
|
overview: {
|
||||||
|
position: [6.2, 4.8, 6.2],
|
||||||
|
target: [0, 0.3, 0],
|
||||||
|
fov: 42,
|
||||||
|
},
|
||||||
|
detectionCloseup: {
|
||||||
|
position: [-1.2, 2.5, 3.5],
|
||||||
|
target: [-1.2, 0.3, 0],
|
||||||
|
fov: 38,
|
||||||
|
},
|
||||||
|
gateCloseup: {
|
||||||
|
position: [1.6, 2.0, 3.0],
|
||||||
|
target: [1.6, 0.3, 0],
|
||||||
|
fov: 36,
|
||||||
|
},
|
||||||
|
routingB: {
|
||||||
|
position: [2.5, 3.5, 0],
|
||||||
|
target: [3.0, 0.5, 0],
|
||||||
|
fov: 40,
|
||||||
|
},
|
||||||
|
routingC: {
|
||||||
|
position: [2.5, 3.5, 2.4],
|
||||||
|
target: [2.0, 0.5, 2.4],
|
||||||
|
fov: 40,
|
||||||
|
},
|
||||||
|
routingD: {
|
||||||
|
position: [2.5, 3.5, -2.4],
|
||||||
|
target: [2.0, 0.5, -2.4],
|
||||||
|
fov: 40,
|
||||||
|
},
|
||||||
|
resultB: {
|
||||||
|
position: [3.8, 3.0, 0],
|
||||||
|
target: [3.8, 0.5, 0],
|
||||||
|
fov: 38,
|
||||||
|
},
|
||||||
|
resultC: {
|
||||||
|
position: [2.0, 2.5, 2.4],
|
||||||
|
target: [2.0, 0.5, 2.4],
|
||||||
|
fov: 38,
|
||||||
|
},
|
||||||
|
resultD: {
|
||||||
|
position: [2.0, 2.5, -2.4],
|
||||||
|
target: [2.0, 0.5, -2.4],
|
||||||
|
fov: 38,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo step configurations.
|
||||||
|
*/
|
||||||
|
export function getDemoStepConfig(step: DemoStep, category?: Category): DemoStepConfig {
|
||||||
|
const configs: Record<DemoStep, DemoStepConfig> = {
|
||||||
|
[DemoStep.intro]: {
|
||||||
|
step: DemoStep.intro,
|
||||||
|
durationMs: 1500,
|
||||||
|
label: 'Введение',
|
||||||
|
description: 'Обзор сортировочной ячейки A/B/C/D',
|
||||||
|
cameraPreset: CAMERA_PRESETS.overview,
|
||||||
|
activeLabels: ['A', 'B', 'C', 'D', 'Накопитель'],
|
||||||
|
},
|
||||||
|
[DemoStep.spawn_item]: {
|
||||||
|
step: DemoStep.spawn_item,
|
||||||
|
durationMs: 800,
|
||||||
|
label: 'Появление товара',
|
||||||
|
description: 'Товар входит в зону A',
|
||||||
|
cameraPreset: CAMERA_PRESETS.overview,
|
||||||
|
activeLabels: ['A'],
|
||||||
|
},
|
||||||
|
[DemoStep.move_to_detection]: {
|
||||||
|
step: DemoStep.move_to_detection,
|
||||||
|
durationMs: 1200,
|
||||||
|
label: 'Движение к камере',
|
||||||
|
description: 'Товар едет по конвейеру к зоне детекции',
|
||||||
|
cameraPreset: CAMERA_PRESETS.detectionCloseup,
|
||||||
|
activeLabels: ['A', 'Camera CV'],
|
||||||
|
},
|
||||||
|
[DemoStep.detection_scan]: {
|
||||||
|
step: DemoStep.detection_scan,
|
||||||
|
durationMs: 900,
|
||||||
|
label: 'CV детекция',
|
||||||
|
description: 'Камера сканирует товар, определяет bbox',
|
||||||
|
cameraPreset: CAMERA_PRESETS.detectionCloseup,
|
||||||
|
activeLabels: ['Camera CV', 'Laser'],
|
||||||
|
},
|
||||||
|
[DemoStep.dimensions_check]: {
|
||||||
|
step: DemoStep.dimensions_check,
|
||||||
|
durationMs: 700,
|
||||||
|
label: 'Проверка габаритов',
|
||||||
|
description: 'Dimensions: PASS or FAIL',
|
||||||
|
cameraPreset: CAMERA_PRESETS.gateCloseup,
|
||||||
|
activeLabels: ['Накопитель', 'Stop-gate'],
|
||||||
|
},
|
||||||
|
[DemoStep.roundness_check]: {
|
||||||
|
step: DemoStep.roundness_check,
|
||||||
|
durationMs: 700,
|
||||||
|
label: 'Проверка круглости',
|
||||||
|
description: 'Roundness K ≥ 0.7 or not',
|
||||||
|
cameraPreset: CAMERA_PRESETS.gateCloseup,
|
||||||
|
activeLabels: ['Накопитель', 'Stop-gate'],
|
||||||
|
},
|
||||||
|
[DemoStep.decision]: {
|
||||||
|
step: DemoStep.decision,
|
||||||
|
durationMs: 700,
|
||||||
|
label: 'Решение',
|
||||||
|
description: 'Category: B/C/D определён',
|
||||||
|
cameraPreset: CAMERA_PRESETS.gateCloseup,
|
||||||
|
activeLabels: ['Stop-gate', 'B', 'C', 'D'],
|
||||||
|
},
|
||||||
|
[DemoStep.command_sent]: {
|
||||||
|
step: DemoStep.command_sent,
|
||||||
|
durationMs: 600,
|
||||||
|
label: 'Команда отправлена',
|
||||||
|
description: 'ROUTE_TO_* команда передана исполнительной части',
|
||||||
|
cameraPreset: CAMERA_PRESETS.gateCloseup,
|
||||||
|
activeLabels: ['Stop-gate', category ?? 'B'],
|
||||||
|
},
|
||||||
|
[DemoStep.actuator_move]: {
|
||||||
|
step: DemoStep.actuator_move,
|
||||||
|
durationMs: 1000,
|
||||||
|
label: 'Исполнительная часть',
|
||||||
|
description: 'Gate открывается или Pusher выдвигается',
|
||||||
|
cameraPreset: CAMERA_PRESETS.gateCloseup,
|
||||||
|
activeLabels: ['Stop-gate', category === 'C' ? 'Pusher C' : category === 'D' ? 'Pusher D' : 'B'],
|
||||||
|
},
|
||||||
|
[DemoStep.route_item]: {
|
||||||
|
step: DemoStep.route_item,
|
||||||
|
durationMs: 1200,
|
||||||
|
label: 'Маршрутизация',
|
||||||
|
description: 'Товар физически уходит в целевую зону',
|
||||||
|
cameraPreset: category === 'C' ? CAMERA_PRESETS.routingC : category === 'D' ? CAMERA_PRESETS.routingD : CAMERA_PRESETS.routingB,
|
||||||
|
activeLabels: [category ?? 'B'],
|
||||||
|
},
|
||||||
|
[DemoStep.result]: {
|
||||||
|
step: DemoStep.result,
|
||||||
|
durationMs: 1500,
|
||||||
|
label: 'Результат',
|
||||||
|
description: 'Товар в целевой зоне, цикл завершён',
|
||||||
|
cameraPreset: category === 'C' ? CAMERA_PRESETS.resultC : category === 'D' ? CAMERA_PRESETS.resultD : CAMERA_PRESETS.resultB,
|
||||||
|
activeLabels: [category ?? 'B'],
|
||||||
|
},
|
||||||
|
[DemoStep.reset_or_next]: {
|
||||||
|
step: DemoStep.reset_or_next,
|
||||||
|
durationMs: 500,
|
||||||
|
label: 'Сброс',
|
||||||
|
description: 'Подготовка к следующему циклу',
|
||||||
|
cameraPreset: CAMERA_PRESETS.overview,
|
||||||
|
activeLabels: ['A', 'B', 'C', 'D'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return configs[step];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get demo step sequence for a scenario.
|
||||||
|
*/
|
||||||
|
export function getDemoStepSequence(): DemoStep[] {
|
||||||
|
return [
|
||||||
|
DemoStep.intro,
|
||||||
|
DemoStep.spawn_item,
|
||||||
|
DemoStep.move_to_detection,
|
||||||
|
DemoStep.detection_scan,
|
||||||
|
DemoStep.dimensions_check,
|
||||||
|
DemoStep.roundness_check,
|
||||||
|
DemoStep.decision,
|
||||||
|
DemoStep.command_sent,
|
||||||
|
DemoStep.actuator_move,
|
||||||
|
DemoStep.route_item,
|
||||||
|
DemoStep.result,
|
||||||
|
DemoStep.reset_or_next,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map demo step to machine state (approximation).
|
||||||
|
*/
|
||||||
|
export function mapDemoStepToMachineState(step: DemoStep): MachineState | undefined {
|
||||||
|
const mapping: Partial<Record<DemoStep, MachineState>> = {
|
||||||
|
[DemoStep.move_to_detection]: 'MOVING_TO_CAMERA',
|
||||||
|
[DemoStep.detection_scan]: 'DETECTING',
|
||||||
|
[DemoStep.dimensions_check]: 'MOVING_TO_GATE',
|
||||||
|
[DemoStep.roundness_check]: 'WAITING_AT_GATE',
|
||||||
|
[DemoStep.decision]: 'CLASSIFYING',
|
||||||
|
[DemoStep.command_sent]: 'CLASSIFYING',
|
||||||
|
[DemoStep.actuator_move]: 'ROUTE_TO_B', // will be overridden by category
|
||||||
|
[DemoStep.route_item]: 'ROUTE_TO_B', // will be overridden by category
|
||||||
|
};
|
||||||
|
|
||||||
|
return mapping[step];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create initial demo director state.
|
||||||
|
*/
|
||||||
|
export function createDemoDirectorState(scenario: Scenario): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
isAutoDemoRunning: false,
|
||||||
|
currentScenario: scenario,
|
||||||
|
currentStep: DemoStep.intro,
|
||||||
|
stepElapsedMs: 0,
|
||||||
|
totalElapsedMs: 0,
|
||||||
|
playbackSpeed: 1,
|
||||||
|
loopMode: false,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start auto demo.
|
||||||
|
*/
|
||||||
|
export function startAutoDemo(state: DemoDirectorState): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isAutoDemoRunning: true,
|
||||||
|
currentStep: DemoStep.intro,
|
||||||
|
stepElapsedMs: 0,
|
||||||
|
totalElapsedMs: 0,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause auto demo.
|
||||||
|
*/
|
||||||
|
export function pauseAutoDemo(state: DemoDirectorState): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
paused: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume auto demo.
|
||||||
|
*/
|
||||||
|
export function resumeAutoDemo(state: DemoDirectorState): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop auto demo.
|
||||||
|
*/
|
||||||
|
export function stopAutoDemo(state: DemoDirectorState): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isAutoDemoRunning: false,
|
||||||
|
paused: false,
|
||||||
|
currentStep: DemoStep.intro,
|
||||||
|
stepElapsedMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update auto demo state (call on each animation frame).
|
||||||
|
*/
|
||||||
|
export function updateAutoDemo(
|
||||||
|
state: DemoDirectorState,
|
||||||
|
deltaMs: number,
|
||||||
|
category?: Category,
|
||||||
|
): DemoDirectorState {
|
||||||
|
if (!state.isAutoDemoRunning || state.paused) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedDelta = deltaMs * state.playbackSpeed;
|
||||||
|
const newStepElapsed = state.stepElapsedMs + adjustedDelta;
|
||||||
|
const newTotalElapsed = state.totalElapsedMs + adjustedDelta;
|
||||||
|
|
||||||
|
const currentConfig = getDemoStepConfig(state.currentStep, category);
|
||||||
|
|
||||||
|
if (newStepElapsed >= currentConfig.durationMs) {
|
||||||
|
// Move to next step
|
||||||
|
const sequence = getDemoStepSequence();
|
||||||
|
const currentIndex = sequence.indexOf(state.currentStep);
|
||||||
|
const nextIndex = currentIndex + 1;
|
||||||
|
|
||||||
|
if (nextIndex >= sequence.length) {
|
||||||
|
// Demo completed
|
||||||
|
if (state.loopMode) {
|
||||||
|
// Restart from intro
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: DemoStep.intro,
|
||||||
|
stepElapsedMs: 0,
|
||||||
|
totalElapsedMs: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Stop demo
|
||||||
|
return stopAutoDemo(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next step
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: sequence[nextIndex],
|
||||||
|
stepElapsedMs: 0,
|
||||||
|
totalElapsedMs: newTotalElapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue current step
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stepElapsedMs: newStepElapsed,
|
||||||
|
totalElapsedMs: newTotalElapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get camera preset for current demo step.
|
||||||
|
*/
|
||||||
|
export function getCameraPresetForStep(step: DemoStep, category?: Category): CameraPreset {
|
||||||
|
const config = getDemoStepConfig(step, category);
|
||||||
|
return config.cameraPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active labels for current demo step.
|
||||||
|
*/
|
||||||
|
export function getActiveLabelsForStep(step: DemoStep, category?: Category): string[] {
|
||||||
|
const config = getDemoStepConfig(step, category);
|
||||||
|
return config.activeLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set playback speed.
|
||||||
|
*/
|
||||||
|
export function setPlaybackSpeed(state: DemoDirectorState, speed: 1 | 2 | 0.5): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
playbackSpeed: speed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle loop mode.
|
||||||
|
*/
|
||||||
|
export function toggleLoopMode(state: DemoDirectorState): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loopMode: !state.loopMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select scenario (reset demo).
|
||||||
|
*/
|
||||||
|
export function selectScenario(state: DemoDirectorState, scenario: Scenario): DemoDirectorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentScenario: scenario,
|
||||||
|
currentStep: DemoStep.intro,
|
||||||
|
stepElapsedMs: 0,
|
||||||
|
totalElapsedMs: 0,
|
||||||
|
isAutoDemoRunning: false,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user