feat: engineering measurement architecture in 3D demo
- Stepper motor length measurement (step counting) - Laser rangefinder height measurement - Stereo camera width/shape analysis - PLC decision logic with C-priority - Actuator pusher animation - Full measurement overlay with live data
This commit is contained in:
162
docs/MEASUREMENT_SYSTEM_REPORT.md
Normal file
162
docs/MEASUREMENT_SYSTEM_REPORT.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Measurement System Report
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Добавлена инженерная архитектура измерения в full-screen 3D demo:
|
||||||
|
- Шаговый двигатель измеряет длину через подсчёт шагов
|
||||||
|
- Лазерный дальномер измеряет высоту
|
||||||
|
- Стереокамера измеряет ширину и форму
|
||||||
|
- PLC контроллер принимает решение и управляет actuator
|
||||||
|
|
||||||
|
## Инженерная логика
|
||||||
|
|
||||||
|
### 1. Измерение длины (Stepper Motor)
|
||||||
|
|
||||||
|
```
|
||||||
|
Длина = stepCount × mmPerStep
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- Step angle: 1.8° (200 steps/rev)
|
||||||
|
- Microstep: 16x (3200 steps/rev)
|
||||||
|
- Drive roller: Ø120mm
|
||||||
|
- mmPerStep ≈ 0.118 mm
|
||||||
|
|
||||||
|
Пример: 300mm depth → stepCount ≈ 2540 pulses
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Измерение высоты (Laser Rangefinder)
|
||||||
|
|
||||||
|
```
|
||||||
|
Высота = laserMountHeight - laserDistance
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- Laser mount: 1150mm
|
||||||
|
- Belt surface: 700mm
|
||||||
|
- Max item height: 320mm
|
||||||
|
- Laser distance: mount - (belt + itemHeight)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Измерение ширины и формы (Stereo Camera)
|
||||||
|
|
||||||
|
```
|
||||||
|
Стереокамера:
|
||||||
|
- Baseline: 120mm между линзами
|
||||||
|
- FOV: 60°
|
||||||
|
- Mount: 1100mm
|
||||||
|
|
||||||
|
Roundness K:
|
||||||
|
- K ≥ 0.7 → round shape → D
|
||||||
|
- K < 0.7 → box/irregular → B or C
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. C-Priority Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
if (dimensionsFail) {
|
||||||
|
category = 'C'; // Overrides roundness
|
||||||
|
if (roundness >= 0.7) {
|
||||||
|
cPriorityApplied = true;
|
||||||
|
warning = "Dims fail overrides roundness → C";
|
||||||
|
}
|
||||||
|
} else if (roundness >= 0.7) {
|
||||||
|
category = 'D';
|
||||||
|
} else {
|
||||||
|
category = 'B';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Файлы созданы/изменены
|
||||||
|
|
||||||
|
| Файл | Действие |
|
||||||
|
|------|----------|
|
||||||
|
| `src/domain/physicalLayout.ts` | Добавлены stepper/laser/stereo параметры |
|
||||||
|
| `src/domain/measurementSystem.ts` | **Создан** — полная measurement логика |
|
||||||
|
| `src/domain/measurementSystem.test.ts` | **Создан** — unit tests |
|
||||||
|
| `src/components/CVInspectionOverlay.tsx` | Обновлён — MEASUREMENT overlay |
|
||||||
|
| `src/components/ThreeD/SorterDigitalTwinContinuous.tsx` | Добавлены LaserBeam, StereoCameraLenses, PointCloud, ActuatorPusher |
|
||||||
|
| `src/pages/MainPage.tsx` | Использует measurementSystem |
|
||||||
|
| `src/styles.css` | Новые стили для measurement overlay |
|
||||||
|
|
||||||
|
## Визуальные элементы 3D
|
||||||
|
|
||||||
|
### Stepper Motor
|
||||||
|
- Pulse indicator LED на моторе
|
||||||
|
- Вращающийся вал при активном измерении
|
||||||
|
|
||||||
|
### Laser Beam
|
||||||
|
- Вертикальный cyan луч от датчика к товару
|
||||||
|
- Laser dot на верхней поверхности товара
|
||||||
|
- Laser emitter подсвечивается при измерении
|
||||||
|
|
||||||
|
### Stereo Camera
|
||||||
|
- Два объектива на overhead rig (baseline 120mm)
|
||||||
|
- View cones к товару при активном измерении
|
||||||
|
|
||||||
|
### Point Cloud
|
||||||
|
- 12 точек вокруг контура товара
|
||||||
|
- Форма зависит от roundness (круглая/квадратная)
|
||||||
|
|
||||||
|
### Actuator Pusher
|
||||||
|
- Animated pusher при routing в C/D
|
||||||
|
- Цвет соответствует категории
|
||||||
|
|
||||||
|
## Measurement Overlay
|
||||||
|
|
||||||
|
Секции:
|
||||||
|
1. **STEPPER LENGTH** — Pulses, mm/step, Length
|
||||||
|
2. **LASER HEIGHT** — Mount, Distance, Height
|
||||||
|
3. **STEREO WIDTH/SHAPE** — Width, Shape, Roundness K
|
||||||
|
4. **PLC DECISION** — Dims pass/fail, Confidence, CLASS, CMD
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
- C-priority: "Dims fail overrides roundness → C"
|
||||||
|
- Low confidence: "Low confidence, rule-based fallback"
|
||||||
|
|
||||||
|
## Скриншоты
|
||||||
|
|
||||||
|
`docs/measurement_system_screenshots/`:
|
||||||
|
- `stepper_counting.png` — overlay с данными stepper
|
||||||
|
- `laser_height_measurement.png` — лазерный луч на товаре
|
||||||
|
- `stereo_shape_measurement.png` — анализ формы
|
||||||
|
- `c_priority_decision.png` — C-priority warning
|
||||||
|
- `route_command_sent.png` — команда маршрутизации
|
||||||
|
- `mobile_compact_overlay.png` — mobile view (overlay hidden)
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
| Проверка | Результат |
|
||||||
|
|----------|-----------|
|
||||||
|
| `npm run build` | ✅ Успешно |
|
||||||
|
| `npm run test` | ✅ 74 тестов |
|
||||||
|
| Docker rebuild | ✅ Успешно |
|
||||||
|
| Production QA | ✅ Работает |
|
||||||
|
| Console errors | ✅ Нет ошибок |
|
||||||
|
| /details | ✅ Не сломан |
|
||||||
|
|
||||||
|
## Что осталось
|
||||||
|
|
||||||
|
- Можно добавить более детальную анимацию point cloud
|
||||||
|
- Можно добавить визуальный encoder на drive roller
|
||||||
|
- Actuator pusher может быть более механистичным
|
||||||
|
|
||||||
|
## Команды commit/push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: engineering measurement architecture in 3D demo
|
||||||
|
|
||||||
|
- Stepper motor length measurement (step counting)
|
||||||
|
- Laser rangefinder height measurement
|
||||||
|
- Stereo camera width/shape analysis
|
||||||
|
- PLC decision logic with C-priority
|
||||||
|
- Actuator pusher animation
|
||||||
|
- Full measurement overlay with live data
|
||||||
|
|
||||||
|
Technical details:
|
||||||
|
- mmPerStep = 0.118mm (1.8° step, 16x microstep, Ø120mm roller)
|
||||||
|
- Laser mount at 1150mm
|
||||||
|
- Stereo baseline 120mm, FOV 60°
|
||||||
|
|
||||||
|
All measurements derived from item dimensions, no random values."
|
||||||
|
git push origin dan_branch
|
||||||
|
```
|
||||||
BIN
docs/measurement_system_screenshots/c_priority_decision.png
Normal file
BIN
docs/measurement_system_screenshots/c_priority_decision.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
BIN
docs/measurement_system_screenshots/laser_height_measurement.png
Normal file
BIN
docs/measurement_system_screenshots/laser_height_measurement.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
BIN
docs/measurement_system_screenshots/mobile_compact_overlay.png
Normal file
BIN
docs/measurement_system_screenshots/mobile_compact_overlay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/measurement_system_screenshots/route_command_sent.png
Normal file
BIN
docs/measurement_system_screenshots/route_command_sent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
BIN
docs/measurement_system_screenshots/stepper_counting.png
Normal file
BIN
docs/measurement_system_screenshots/stepper_counting.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 339 KiB |
BIN
docs/measurement_system_screenshots/stereo_shape_measurement.png
Normal file
BIN
docs/measurement_system_screenshots/stereo_shape_measurement.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
107
scripts/test_measurement_system.py
Normal file
107
scripts/test_measurement_system.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Browser QA for measurement system on production."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_URL = "https://arhipovdan.ru"
|
||||||
|
SCREENSHOT_DIR = "/opt/arhipovdan/app/docs/measurement_system_screenshots"
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=True)
|
||||||
|
context = await browser.new_context(
|
||||||
|
viewport={"width": 1920, "height": 1080},
|
||||||
|
device_scale_factor=1.5,
|
||||||
|
)
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
print(f"Opening {BASE_URL}...")
|
||||||
|
await page.goto(BASE_URL, wait_until="networkidle", timeout=30000)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Click Play
|
||||||
|
play_btn = page.locator("button.play-button")
|
||||||
|
if await play_btn.count() > 0:
|
||||||
|
print("Clicking Play...")
|
||||||
|
await play_btn.click()
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Screenshot 1: Stepper counting (detection phase)
|
||||||
|
print("Screenshot 1: Stepper counting...")
|
||||||
|
await page.screenshot(path=f"{SCREENSHOT_DIR}/stepper_counting.png", full_page=False)
|
||||||
|
|
||||||
|
# Wait for measurement phase
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Screenshot 2: Laser height measurement
|
||||||
|
print("Screenshot 2: Laser height measurement...")
|
||||||
|
await page.screenshot(path=f"{SCREENSHOT_DIR}/laser_height_measurement.png", full_page=False)
|
||||||
|
|
||||||
|
# Wait for classification
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Screenshot 3: Stereo shape measurement
|
||||||
|
print("Screenshot 3: Stereo shape measurement...")
|
||||||
|
await page.screenshot(path=f"{SCREENSHOT_DIR}/stereo_shape_measurement.png", full_page=False)
|
||||||
|
|
||||||
|
# Let demo run to routing phase
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
# Screenshot 4: Route command sent
|
||||||
|
print("Screenshot 4: Route command sent...")
|
||||||
|
await page.screenshot(path=f"{SCREENSHOT_DIR}/route_command_sent.png", full_page=False)
|
||||||
|
|
||||||
|
# Wait for C-priority case (case 7)
|
||||||
|
print("Waiting for C-priority case...")
|
||||||
|
for _ in range(30):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
content = await page.content()
|
||||||
|
if "C priority" in content or "c_priority" in content.lower():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Screenshot 5: C-priority decision
|
||||||
|
print("Screenshot 5: C-priority decision...")
|
||||||
|
await page.screenshot(path=f"{SCREENSHOT_DIR}/c_priority_decision.png", full_page=False)
|
||||||
|
|
||||||
|
# Test mobile view
|
||||||
|
print("Testing mobile view...")
|
||||||
|
await page.set_viewport_size({"width": 390, "height": 844})
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Screenshot 6: Mobile compact overlay
|
||||||
|
print("Screenshot 6: Mobile compact overlay...")
|
||||||
|
await page.screenshot(path=f"{SCREENSHOT_DIR}/mobile_compact_overlay.png", full_page=False)
|
||||||
|
|
||||||
|
# Reset to desktop and check /details
|
||||||
|
await page.set_viewport_size({"width": 1920, "height": 1080})
|
||||||
|
print(f"\nOpening {BASE_URL}/details...")
|
||||||
|
await page.goto(f"{BASE_URL}/details", wait_until="networkidle", timeout=30000)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
errors = []
|
||||||
|
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||||
|
await page.goto(BASE_URL, wait_until="networkidle", timeout=30000)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
print("\n=== QA Results ===")
|
||||||
|
print(f"Screenshots saved to: {SCREENSHOT_DIR}/")
|
||||||
|
for f in sorted(os.listdir(SCREENSHOT_DIR)):
|
||||||
|
print(f" - {f}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"\nConsole errors: {len(errors)}")
|
||||||
|
for e in errors[:5]:
|
||||||
|
print(f" - {e[:100]}")
|
||||||
|
else:
|
||||||
|
print("\nNo console errors detected.")
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
print("\nBrowser QA complete!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,43 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* CV Inspection Overlay — industrial camera monitor style panel.
|
* CV Inspection Overlay — industrial measurement system monitor.
|
||||||
* Shows detection, measurement, and classification data.
|
* Shows stepper, laser, stereo camera data and classification results.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { InspectionData } from '../domain/inspectionViewModel';
|
import type { MeasurementData } from '../domain/measurementSystem';
|
||||||
|
import { getStageLabel } from '../domain/measurementSystem';
|
||||||
|
|
||||||
interface CVInspectionOverlayProps {
|
interface CVInspectionOverlayProps {
|
||||||
data: InspectionData;
|
data: MeasurementData;
|
||||||
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHAPE_ICONS: Record<string, string> = {
|
const SHAPE_ICONS: Record<string, string> = {
|
||||||
box: '▭',
|
box: '▭',
|
||||||
cylinder: '⬤',
|
|
||||||
round: '◯',
|
round: '◯',
|
||||||
irregular: '◇',
|
irregular: '◇',
|
||||||
unknown: '?',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps) {
|
export default function CVInspectionOverlay({ data, visible }: CVInspectionOverlayProps) {
|
||||||
if (!data.visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
phaseLabel,
|
stage,
|
||||||
itemTitle,
|
stepCount,
|
||||||
shape,
|
mmPerStep,
|
||||||
dimensions,
|
measuredLengthMm,
|
||||||
roundness,
|
pulseActive,
|
||||||
|
laserDistanceMm,
|
||||||
|
laserMountHeightMm,
|
||||||
|
measuredHeightMm,
|
||||||
|
laserBeamActive,
|
||||||
|
measuredWidthMm,
|
||||||
|
roundnessK,
|
||||||
|
stereoActive,
|
||||||
confidence,
|
confidence,
|
||||||
isLowConfidence,
|
dimensionsPass,
|
||||||
category,
|
shapeResult,
|
||||||
|
finalCategory,
|
||||||
command,
|
command,
|
||||||
warning,
|
cPriorityApplied,
|
||||||
dimensionsFail,
|
isLowConfidence,
|
||||||
roundnessFail,
|
itemTitle,
|
||||||
cPriority,
|
itemDimensions,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const confidencePercent = Math.round(confidence * 100);
|
const confidencePercent = Math.round(confidence * 100);
|
||||||
const roundnessPercent = Math.round(roundness * 100);
|
const roundnessPercent = Math.round(roundnessK * 100);
|
||||||
|
const stageLabel = getStageLabel(stage);
|
||||||
|
|
||||||
const categoryColors: Record<string, string> = {
|
const categoryColors: Record<string, string> = {
|
||||||
B: '#22c55e',
|
B: '#22c55e',
|
||||||
@@ -45,15 +54,18 @@ export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps)
|
|||||||
D: '#8b5cf6',
|
D: '#8b5cf6',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isActive = stage !== 'idle';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cv-overlay">
|
<div className="cv-overlay">
|
||||||
<div className="cv-header">
|
<div className="cv-header">
|
||||||
<span className="cv-icon">📷</span>
|
<span className="cv-icon">⚙</span>
|
||||||
<span className="cv-title">CV INSPECTION</span>
|
<span className="cv-title">MEASUREMENT</span>
|
||||||
<span className={`cv-status ${data.phase}`}>{phaseLabel}</span>
|
<span className={`cv-status ${isActive ? 'active' : 'idle'}`}>{stageLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cv-body">
|
<div className="cv-body">
|
||||||
|
{/* Item info */}
|
||||||
<div className="cv-row cv-item-row">
|
<div className="cv-row cv-item-row">
|
||||||
<span className="cv-label">ITEM</span>
|
<span className="cv-label">ITEM</span>
|
||||||
<span className="cv-value">{itemTitle}</span>
|
<span className="cv-value">{itemTitle}</span>
|
||||||
@@ -61,48 +73,106 @@ export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps)
|
|||||||
|
|
||||||
<div className="cv-divider" />
|
<div className="cv-divider" />
|
||||||
|
|
||||||
|
{/* Stepper motor section */}
|
||||||
|
<div className="cv-section-header">
|
||||||
|
<span className={`cv-indicator ${pulseActive ? 'pulse' : ''}`}>●</span>
|
||||||
|
STEPPER LENGTH
|
||||||
|
</div>
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">Pulses</span>
|
||||||
|
<span className="cv-value cv-mono">
|
||||||
|
{stepCount.toLocaleString()}
|
||||||
|
{pulseActive && <span className="cv-blink"> ↑</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">mm/step</span>
|
||||||
|
<span className="cv-value cv-mono">{mmPerStep.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
<div className="cv-row">
|
<div className="cv-row">
|
||||||
<span className="cv-label">SHAPE</span>
|
<span className="cv-label">Length</span>
|
||||||
|
<span className="cv-value cv-result">{measuredLengthMm} mm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cv-divider" />
|
||||||
|
|
||||||
|
{/* Laser rangefinder section */}
|
||||||
|
<div className="cv-section-header">
|
||||||
|
<span className={`cv-indicator ${laserBeamActive ? 'active' : ''}`}>●</span>
|
||||||
|
LASER HEIGHT
|
||||||
|
</div>
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">Mount</span>
|
||||||
|
<span className="cv-value cv-mono">{laserMountHeightMm} mm</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-row cv-compact">
|
||||||
|
<span className="cv-label">Distance</span>
|
||||||
|
<span className="cv-value cv-mono">{laserDistanceMm} mm</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-row">
|
||||||
|
<span className="cv-label">Height</span>
|
||||||
|
<span className="cv-value cv-result">{measuredHeightMm} mm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cv-divider" />
|
||||||
|
|
||||||
|
{/* Stereo camera section */}
|
||||||
|
<div className="cv-section-header">
|
||||||
|
<span className={`cv-indicator ${stereoActive ? 'active' : ''}`}>●</span>
|
||||||
|
STEREO WIDTH/SHAPE
|
||||||
|
</div>
|
||||||
|
<div className="cv-row">
|
||||||
|
<span className="cv-label">Width</span>
|
||||||
|
<span className="cv-value cv-result">{measuredWidthMm} mm</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-row">
|
||||||
|
<span className="cv-label">Shape</span>
|
||||||
<span className="cv-value cv-shape">
|
<span className="cv-value cv-shape">
|
||||||
<span className="shape-icon">{SHAPE_ICONS[shape]}</span>
|
<span className="shape-icon">{SHAPE_ICONS[shapeResult]}</span>
|
||||||
{shape}
|
{shapeResult}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cv-row cv-dims-row">
|
|
||||||
<span className="cv-label">SIZE</span>
|
|
||||||
<span className={`cv-value cv-dims ${dimensionsFail ? 'fail' : 'pass'}`}>
|
|
||||||
{dimensions.width} × {dimensions.depth} × {dimensions.height} mm
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="cv-row">
|
<div className="cv-row">
|
||||||
<span className="cv-label">ROUNDNESS</span>
|
<span className="cv-label">Roundness</span>
|
||||||
<span className={`cv-value ${roundnessFail ? 'fail' : 'pass'}`}>
|
<span className={`cv-value ${roundnessK >= 0.7 ? 'warning' : ''}`}>
|
||||||
K = {(roundness).toFixed(2)} ({roundnessPercent}%)
|
K = {roundnessK.toFixed(2)} ({roundnessPercent}%)
|
||||||
{roundnessFail && <span className="cv-flag"> ⚠ ≥0.7</span>}
|
{roundnessK >= 0.7 && <span className="cv-flag"> ≥0.7</span>}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="cv-row">
|
|
||||||
<span className="cv-label">CONFIDENCE</span>
|
|
||||||
<span className={`cv-value ${isLowConfidence ? 'warning' : ''}`}>
|
|
||||||
{confidencePercent}%
|
|
||||||
{isLowConfidence && <span className="cv-flag"> ⚠ LOW</span>}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cv-divider" />
|
<div className="cv-divider" />
|
||||||
|
|
||||||
{category && (
|
{/* Decision section */}
|
||||||
|
<div className="cv-section-header">
|
||||||
|
<span className="cv-indicator">●</span>
|
||||||
|
PLC DECISION
|
||||||
|
</div>
|
||||||
|
<div className="cv-row">
|
||||||
|
<span className="cv-label">Dims</span>
|
||||||
|
<span className={`cv-value ${dimensionsPass ? 'pass' : 'fail'}`}>
|
||||||
|
{dimensionsPass ? 'PASS' : 'FAIL'}
|
||||||
|
<span className="cv-dims-detail">
|
||||||
|
{' '}({itemDimensions.width}×{itemDimensions.depth}×{itemDimensions.height})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cv-row">
|
||||||
|
<span className="cv-label">Confidence</span>
|
||||||
|
<span className={`cv-value ${isLowConfidence ? 'warning' : ''}`}>
|
||||||
|
{confidencePercent}%
|
||||||
|
{isLowConfidence && <span className="cv-flag"> LOW</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{finalCategory && (
|
||||||
<div className="cv-row cv-result-row">
|
<div className="cv-row cv-result-row">
|
||||||
<span className="cv-label">CLASS</span>
|
<span className="cv-label">CLASS</span>
|
||||||
<span
|
<span
|
||||||
className="cv-value cv-category"
|
className="cv-value cv-category"
|
||||||
style={{ color: categoryColors[category] }}
|
style={{ color: categoryColors[finalCategory] }}
|
||||||
>
|
>
|
||||||
{category}
|
{finalCategory}
|
||||||
{cPriority && <span className="cv-priority"> (C priority)</span>}
|
{cPriorityApplied && <span className="cv-priority"> (C priority)</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -111,23 +181,31 @@ export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps)
|
|||||||
<span className="cv-label">CMD</span>
|
<span className="cv-label">CMD</span>
|
||||||
<span
|
<span
|
||||||
className="cv-value cv-command"
|
className="cv-value cv-command"
|
||||||
style={{ color: category ? categoryColors[category] : '#64748b' }}
|
style={{ color: finalCategory ? categoryColors[finalCategory] : '#64748b' }}
|
||||||
>
|
>
|
||||||
{command}
|
{command}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warning && (
|
{/* Warnings */}
|
||||||
|
{cPriorityApplied && (
|
||||||
|
<div className="cv-warning cv-cpriority">
|
||||||
|
<span className="warning-icon">⚠</span>
|
||||||
|
<span className="warning-text">Dims fail overrides roundness → C</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLowConfidence && (
|
||||||
<div className="cv-warning">
|
<div className="cv-warning">
|
||||||
<span className="warning-icon">⚠</span>
|
<span className="warning-icon">⚠</span>
|
||||||
<span className="warning-text">{warning}</span>
|
<span className="warning-text">Low confidence, rule-based fallback</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cv-footer">
|
<div className="cv-footer">
|
||||||
<span className="cv-live">● LIVE</span>
|
<span className="cv-live">● LIVE</span>
|
||||||
<span className="cv-fps">60 FPS</span>
|
<span className="cv-fps">PLC</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { ContinuousPlaybackState, CasePhase } from '../../domain/continuous
|
|||||||
import { isDetectionActive, isRoutingActive, getPhaseProgress } from '../../domain/continuousPlayback';
|
import { isDetectionActive, isRoutingActive, getPhaseProgress } from '../../domain/continuousPlayback';
|
||||||
import { getItemPosition, isItemVisible, getActiveRoute, getConveyorSpeedFactor } from '../../domain/conveyorPath';
|
import { getItemPosition, isItemVisible, getActiveRoute, getConveyorSpeedFactor } from '../../domain/conveyorPath';
|
||||||
import { shouldShowBoundingBox, shouldShowScanEffect, shouldShowShapeOutline, shouldHighlightCamera } from '../../domain/inspectionViewModel';
|
import { shouldShowBoundingBox, shouldShowScanEffect, shouldShowShapeOutline, shouldHighlightCamera } from '../../domain/inspectionViewModel';
|
||||||
|
import { getMeasurementData, shouldShowLaserBeam, shouldShowStepperPulse, shouldShowPointCloud, shouldShowActuator } from '../../domain/measurementSystem';
|
||||||
import { getModelAsset } from '../../data/modelAssets';
|
import { getModelAsset } from '../../data/modelAssets';
|
||||||
import { ITEMS } from '../../data/items';
|
import { ITEMS } from '../../data/items';
|
||||||
import type { Category } from '../../domain/types';
|
import type { Category } from '../../domain/types';
|
||||||
@@ -34,6 +35,8 @@ import {
|
|||||||
DRIVE_ROLLER_RADIUS_M,
|
DRIVE_ROLLER_RADIUS_M,
|
||||||
ZONES,
|
ZONES,
|
||||||
CAMERA_RIG,
|
CAMERA_RIG,
|
||||||
|
LASER_HEIGHT_M,
|
||||||
|
STEREO_CAMERA,
|
||||||
} from '../../domain/physicalLayout';
|
} from '../../domain/physicalLayout';
|
||||||
|
|
||||||
export interface SorterDigitalTwinContinuousProps {
|
export interface SorterDigitalTwinContinuousProps {
|
||||||
@@ -111,8 +114,18 @@ function BeltStripe({ offset, speedFactor }: { offset: number; speedFactor: numb
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stepper motor drive unit */
|
/** Stepper motor drive unit with pulse indicator */
|
||||||
function StepperMotor({ position }: { position: [number, number, number] }) {
|
function StepperMotor({ position, pulseActive }: { position: [number, number, number]; pulseActive: boolean }) {
|
||||||
|
const pulseRef = useRef<Mesh>(null);
|
||||||
|
const rotationRef = useRef(0);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (pulseRef.current && pulseActive) {
|
||||||
|
rotationRef.current += delta * 8;
|
||||||
|
pulseRef.current.rotation.z = rotationRef.current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={position}>
|
<group position={position}>
|
||||||
{/* Motor body */}
|
{/* Motor body */}
|
||||||
@@ -120,8 +133,12 @@ function StepperMotor({ position }: { position: [number, number, number] }) {
|
|||||||
<boxGeometry args={[MOTOR_WIDTH_M, MOTOR_HEIGHT_M, MOTOR_DEPTH_M]} />
|
<boxGeometry args={[MOTOR_WIDTH_M, MOTOR_HEIGHT_M, MOTOR_DEPTH_M]} />
|
||||||
<meshStandardMaterial color="#475569" metalness={0.6} roughness={0.3} />
|
<meshStandardMaterial color="#475569" metalness={0.6} roughness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Motor shaft */}
|
{/* Motor shaft - rotates when active */}
|
||||||
<mesh position={[0, 0, CONVEYOR_WIDTH_M / 2 + 0.01]} rotation={[Math.PI / 2, 0, 0]}>
|
<mesh
|
||||||
|
ref={pulseRef}
|
||||||
|
position={[0, 0, CONVEYOR_WIDTH_M / 2 + 0.01]}
|
||||||
|
rotation={[Math.PI / 2, 0, 0]}
|
||||||
|
>
|
||||||
<cylinderGeometry args={[0.015, 0.015, 0.03, 8]} />
|
<cylinderGeometry args={[0.015, 0.015, 0.03, 8]} />
|
||||||
<meshStandardMaterial color="#94a3b8" metalness={0.7} roughness={0.2} />
|
<meshStandardMaterial color="#94a3b8" metalness={0.7} roughness={0.2} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -130,15 +147,209 @@ function StepperMotor({ position }: { position: [number, number, number] }) {
|
|||||||
<cylinderGeometry args={[0.035, 0.035, 0.025, 12]} />
|
<cylinderGeometry args={[0.035, 0.035, 0.025, 12]} />
|
||||||
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} />
|
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Belt to drive roller (simplified) */}
|
{/* Belt to drive roller */}
|
||||||
<mesh position={[0, DRIVE_ROLLER_RADIUS_M / 2, CONVEYOR_WIDTH_M / 2 - 0.02]}>
|
<mesh position={[0, DRIVE_ROLLER_RADIUS_M / 2, CONVEYOR_WIDTH_M / 2 - 0.02]}>
|
||||||
<boxGeometry args={[0.01, DRIVE_ROLLER_RADIUS_M + 0.02, 0.015]} />
|
<boxGeometry args={[0.01, DRIVE_ROLLER_RADIUS_M + 0.02, 0.015]} />
|
||||||
<meshStandardMaterial color="#1e293b" />
|
<meshStandardMaterial color="#1e293b" />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{/* Pulse indicator LED */}
|
||||||
|
<mesh position={[0, MOTOR_HEIGHT_M / 2 - 0.01, CONVEYOR_WIDTH_M / 2 + MOTOR_DEPTH_M + 0.025]}>
|
||||||
|
<sphereGeometry args={[0.008, 8, 8]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={pulseActive ? '#22d3ee' : '#475569'}
|
||||||
|
emissive={pulseActive ? '#22d3ee' : '#000'}
|
||||||
|
emissiveIntensity={pulseActive ? 0.8 : 0}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Laser beam from rangefinder to item */
|
||||||
|
function LaserBeam({ active, itemY }: { active: boolean; itemY: number }) {
|
||||||
|
const beamRef = useRef<Mesh>(null);
|
||||||
|
const opacityRef = useRef(0.6);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (beamRef.current && active) {
|
||||||
|
opacityRef.current = 0.4 + Math.sin(Date.now() / 100) * 0.3;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
const beamLength = LASER_HEIGHT_M - itemY;
|
||||||
|
const beamCenterY = itemY + beamLength / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[ZONES.CAMERA.x, 0, 0]}>
|
||||||
|
{/* Main laser beam */}
|
||||||
|
<mesh ref={beamRef} position={[0, beamCenterY, 0]}>
|
||||||
|
<cylinderGeometry args={[0.003, 0.003, beamLength, 8]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#22d3ee"
|
||||||
|
emissive="#22d3ee"
|
||||||
|
emissiveIntensity={0.6}
|
||||||
|
transparent
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Laser dot on item */}
|
||||||
|
<mesh position={[0, itemY + 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<circleGeometry args={[0.015, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#22d3ee"
|
||||||
|
emissive="#22d3ee"
|
||||||
|
emissiveIntensity={0.8}
|
||||||
|
transparent
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Laser emitter */}
|
||||||
|
<mesh position={[0, LASER_HEIGHT_M, 0]}>
|
||||||
|
<boxGeometry args={[0.04, 0.02, 0.04]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#0ea5e9"
|
||||||
|
emissive="#0ea5e9"
|
||||||
|
emissiveIntensity={active ? 0.4 : 0}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stereo camera lenses with view cones */
|
||||||
|
function StereoCameraLenses({ active, itemPosition }: { active: boolean; itemPosition: [number, number, number] }) {
|
||||||
|
const baseline = STEREO_CAMERA.baseline;
|
||||||
|
const mountY = STEREO_CAMERA.mountY;
|
||||||
|
const cameraX = ZONES.CAMERA.x;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[cameraX, mountY, 0]}>
|
||||||
|
{/* Left lens */}
|
||||||
|
<mesh position={[0, 0, baseline / 2]}>
|
||||||
|
<cylinderGeometry args={[0.02, 0.02, 0.015, 12]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={active ? '#0ea5e9' : '#1e293b'}
|
||||||
|
emissive={active ? '#0ea5e9' : '#000'}
|
||||||
|
emissiveIntensity={active ? 0.3 : 0}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Right lens */}
|
||||||
|
<mesh position={[0, 0, -baseline / 2]}>
|
||||||
|
<cylinderGeometry args={[0.02, 0.02, 0.015, 12]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={active ? '#0ea5e9' : '#1e293b'}
|
||||||
|
emissive={active ? '#0ea5e9' : '#000'}
|
||||||
|
emissiveIntensity={active ? 0.3 : 0}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* View cones when active */}
|
||||||
|
{active && (
|
||||||
|
<>
|
||||||
|
<Line
|
||||||
|
points={[
|
||||||
|
[0, 0, baseline / 2],
|
||||||
|
[0, itemPosition[1] - mountY, itemPosition[2]],
|
||||||
|
]}
|
||||||
|
color="#0ea5e9"
|
||||||
|
lineWidth={1}
|
||||||
|
transparent
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[
|
||||||
|
[0, 0, -baseline / 2],
|
||||||
|
[0, itemPosition[1] - mountY, itemPosition[2]],
|
||||||
|
]}
|
||||||
|
color="#0ea5e9"
|
||||||
|
lineWidth={1}
|
||||||
|
transparent
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point cloud dots around item during stereo analysis */
|
||||||
|
function PointCloud({ active, itemPosition, scale, isRound }: {
|
||||||
|
active: boolean;
|
||||||
|
itemPosition: [number, number, number];
|
||||||
|
scale: number;
|
||||||
|
isRound: boolean;
|
||||||
|
}) {
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
const points = useMemo(() => {
|
||||||
|
const pts: [number, number, number][] = [];
|
||||||
|
const count = 12;
|
||||||
|
const radius = scale * 0.4;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
const r = isRound ? radius : radius * (1 + Math.cos(angle * 2) * 0.3);
|
||||||
|
pts.push([
|
||||||
|
Math.cos(angle) * r,
|
||||||
|
0,
|
||||||
|
Math.sin(angle) * r,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}, [scale, isRound]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[itemPosition[0], itemPosition[1] + scale * 0.3, itemPosition[2]]}>
|
||||||
|
{points.map((pt, i) => (
|
||||||
|
<mesh key={i} position={pt}>
|
||||||
|
<sphereGeometry args={[0.008, 6, 6]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#38bdf8"
|
||||||
|
emissive="#38bdf8"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actuator/pusher animation during routing */
|
||||||
|
function ActuatorPusher({ active, category }: { active: boolean; category: Category | null }) {
|
||||||
|
const pusherRef = useRef<Mesh>(null);
|
||||||
|
const extendRef = useRef(0);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (pusherRef.current) {
|
||||||
|
const target = active && (category === 'C' || category === 'D') ? 0.15 : 0;
|
||||||
|
extendRef.current += (target - extendRef.current) * delta * 5;
|
||||||
|
|
||||||
|
const direction = category === 'C' ? 1 : -1;
|
||||||
|
pusherRef.current.position.z = direction * extendRef.current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const gateX = ZONES.GATE.x;
|
||||||
|
const color = category === 'C' ? COLORS.routeC : category === 'D' ? COLORS.routeD : COLORS.gateFrame;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
ref={pusherRef}
|
||||||
|
position={[gateX + 0.3, BELT_Y + 0.08, 0]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.15, 0.08, 0.06]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
emissive={active ? color : '#000'}
|
||||||
|
emissiveIntensity={active ? 0.3 : 0}
|
||||||
|
metalness={0.5}
|
||||||
|
roughness={0.4}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Support leg from floor to frame */
|
/** Support leg from floor to frame */
|
||||||
function SupportLeg({ x }: { x: number }) {
|
function SupportLeg({ x }: { x: number }) {
|
||||||
const legHeight = FRAME_TOP_Y;
|
const legHeight = FRAME_TOP_Y;
|
||||||
@@ -167,7 +378,7 @@ function SupportLeg({ x }: { x: number }) {
|
|||||||
* Conveyor belt - realistic roller conveyor
|
* Conveyor belt - realistic roller conveyor
|
||||||
* Belt top surface at 0.7m (BELT_TOP_Y)
|
* Belt top surface at 0.7m (BELT_TOP_Y)
|
||||||
*/
|
*/
|
||||||
function ConveyorBelt({ speedFactor }: { speedFactor: number }) {
|
function ConveyorBelt({ speedFactor, pulseActive }: { speedFactor: number; pulseActive: boolean }) {
|
||||||
const rollerCount = Math.floor(CONVEYOR_LENGTH / ROLLER_SPACING_M);
|
const rollerCount = Math.floor(CONVEYOR_LENGTH / ROLLER_SPACING_M);
|
||||||
|
|
||||||
const rollerPositions = useMemo(() => {
|
const rollerPositions = useMemo(() => {
|
||||||
@@ -242,7 +453,7 @@ function ConveyorBelt({ speedFactor }: { speedFactor: number }) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Stepper motor at drive end */}
|
{/* Stepper motor at drive end */}
|
||||||
<StepperMotor position={[CONVEYOR_END_X - 0.1, ROLLER_Y, 0]} />
|
<StepperMotor position={[CONVEYOR_END_X - 0.1, ROLLER_Y, 0]} pulseActive={pulseActive} />
|
||||||
|
|
||||||
{/* End caps / guards */}
|
{/* End caps / guards */}
|
||||||
<mesh position={[CONVEYOR_START_X, BELT_Y - 0.05, 0]}>
|
<mesh position={[CONVEYOR_START_X, BELT_Y - 0.05, 0]}>
|
||||||
@@ -714,6 +925,25 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
|
|||||||
|
|
||||||
const cameraHighlight = shouldHighlightCamera(phase);
|
const cameraHighlight = shouldHighlightCamera(phase);
|
||||||
const showScan = shouldShowScanEffect(phase);
|
const showScan = shouldShowScanEffect(phase);
|
||||||
|
|
||||||
|
// Measurement system states
|
||||||
|
const measurementData = getMeasurementData(playback);
|
||||||
|
const showLaser = shouldShowLaserBeam(phase);
|
||||||
|
const showPulse = shouldShowStepperPulse(phase);
|
||||||
|
const showCloud = shouldShowPointCloud(phase);
|
||||||
|
const showActuator = shouldShowActuator(phase);
|
||||||
|
|
||||||
|
// Get item position for measurement visualization
|
||||||
|
const itemPosition = getItemPosition(playback);
|
||||||
|
const itemPos: [number, number, number] = [itemPosition.x, itemPosition.y, itemPosition.z];
|
||||||
|
|
||||||
|
// Get item data for point cloud
|
||||||
|
const currentCase = playback.currentCase;
|
||||||
|
const itemId = currentCase.itemId.replace('-LC', '');
|
||||||
|
const itemData = ITEMS.find(i => i.id === itemId) ?? ITEMS[0];
|
||||||
|
const isRound = itemData.roundness >= 0.7;
|
||||||
|
const dims = itemData.dimensionsMm;
|
||||||
|
const itemScale = Math.max(dims.width, dims.depth, dims.height) / 1000 * 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -747,7 +977,7 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
|
|||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Conveyor - belt top at 0.7m */}
|
{/* Conveyor - belt top at 0.7m */}
|
||||||
<ConveyorBelt speedFactor={speedFactor} />
|
<ConveyorBelt speedFactor={speedFactor} pulseActive={showPulse} />
|
||||||
|
|
||||||
{/* Zone markers on floor */}
|
{/* Zone markers on floor */}
|
||||||
<ZoneMarker
|
<ZoneMarker
|
||||||
@@ -778,6 +1008,20 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
|
|||||||
{/* Camera rig - overhead above belt */}
|
{/* Camera rig - overhead above belt */}
|
||||||
<CameraRig active={cameraHighlight} />
|
<CameraRig active={cameraHighlight} />
|
||||||
|
|
||||||
|
{/* Stereo camera lenses */}
|
||||||
|
<StereoCameraLenses active={cameraHighlight} itemPosition={itemPos} />
|
||||||
|
|
||||||
|
{/* Laser beam for height measurement */}
|
||||||
|
<LaserBeam active={showLaser} itemY={itemPosition.y} />
|
||||||
|
|
||||||
|
{/* Point cloud for stereo analysis */}
|
||||||
|
<PointCloud
|
||||||
|
active={showCloud}
|
||||||
|
itemPosition={itemPos}
|
||||||
|
scale={itemScale}
|
||||||
|
isRound={isRound}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Inspection zone on belt surface */}
|
{/* Inspection zone on belt surface */}
|
||||||
<InspectionZone active={cameraHighlight} />
|
<InspectionZone active={cameraHighlight} />
|
||||||
|
|
||||||
@@ -786,6 +1030,9 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
|
|||||||
|
|
||||||
{/* Gate/diverter */}
|
{/* Gate/diverter */}
|
||||||
<GateZone category={category} />
|
<GateZone category={category} />
|
||||||
|
|
||||||
|
{/* Actuator pusher for routing */}
|
||||||
|
<ActuatorPusher active={showActuator} category={category} />
|
||||||
|
|
||||||
{/* Route arrows on belt surface */}
|
{/* Route arrows on belt surface */}
|
||||||
<RouteArrows activeRoute={activeRoute} />
|
<RouteArrows activeRoute={activeRoute} />
|
||||||
|
|||||||
190
src/domain/measurementSystem.test.ts
Normal file
190
src/domain/measurementSystem.test.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for measurementSystem
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getMeasurementData, getStageLabel, shouldShowMeasurement, shouldShowLaserBeam } from './measurementSystem';
|
||||||
|
import { createPlaybackState, startPlayback, updatePlayback } from './continuousPlayback';
|
||||||
|
import { MM_PER_STEP } from './physicalLayout';
|
||||||
|
|
||||||
|
describe('measurementSystem', () => {
|
||||||
|
describe('getMeasurementData', () => {
|
||||||
|
it('returns valid measurement data for idle state', () => {
|
||||||
|
const state = createPlaybackState();
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
|
||||||
|
expect(data.stage).toBe('idle');
|
||||||
|
expect(data.stepCount).toBeGreaterThan(0);
|
||||||
|
expect(data.mmPerStep).toBe(MM_PER_STEP);
|
||||||
|
expect(data.itemTitle).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates step count from item length', () => {
|
||||||
|
const state = createPlaybackState();
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
|
||||||
|
// stepCount should be positive
|
||||||
|
expect(data.stepCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// measuredLength should equal stepCount * mmPerStep
|
||||||
|
expect(data.measuredLengthMm).toBeCloseTo(data.stepCount * data.mmPerStep, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates height from laser distance', () => {
|
||||||
|
const state = createPlaybackState();
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
|
||||||
|
// height = mount height - distance
|
||||||
|
expect(data.measuredHeightMm).toBe(data.laserMountHeightMm - data.laserDistanceMm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns round shape for high roundness', () => {
|
||||||
|
// Start playback and advance to measurement phase
|
||||||
|
let state = createPlaybackState();
|
||||||
|
state = startPlayback(state);
|
||||||
|
|
||||||
|
// Find a case with high roundness (plate or bottle)
|
||||||
|
// Advance through cases until we find one
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
state = updatePlayback(state, 100);
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
if (data.roundnessK >= 0.7) {
|
||||||
|
expect(data.shapeResult).toBe('round');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns box shape for medium roundness (0.3-0.7)', () => {
|
||||||
|
let state = createPlaybackState();
|
||||||
|
state = startPlayback(state);
|
||||||
|
|
||||||
|
// Check shape logic based on roundness
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
if (data.roundnessK >= 0.7) {
|
||||||
|
expect(data.shapeResult).toBe('round');
|
||||||
|
} else if (data.roundnessK >= 0.3) {
|
||||||
|
expect(data.shapeResult).toBe('box');
|
||||||
|
} else {
|
||||||
|
expect(data.shapeResult).toBe('irregular');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies C-priority when dims fail but roundness is high', () => {
|
||||||
|
let state = createPlaybackState();
|
||||||
|
state = startPlayback(state);
|
||||||
|
|
||||||
|
// Advance to c_priority case (case 7)
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
// Advance through cases
|
||||||
|
for (let j = 0; j < 100; j++) {
|
||||||
|
state = updatePlayback(state, 100);
|
||||||
|
if (state.currentCaseIndex > i) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for classification phase
|
||||||
|
for (let j = 0; j < 50; j++) {
|
||||||
|
state = updatePlayback(state, 100);
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
if (data.cPriorityApplied) {
|
||||||
|
expect(data.finalCategory).toBe('C');
|
||||||
|
expect(data.command).toBe('ROUTE_TO_C');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ROUTE_TO_B/C/D command always', () => {
|
||||||
|
let state = createPlaybackState();
|
||||||
|
state = startPlayback(state);
|
||||||
|
|
||||||
|
// Advance to classification
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
state = updatePlayback(state, 100);
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
|
||||||
|
if (data.finalCategory) {
|
||||||
|
expect(data.command).toMatch(/^ROUTE_TO_[BCD]$/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps final category as B/C/D for low confidence', () => {
|
||||||
|
let state = createPlaybackState();
|
||||||
|
state = startPlayback(state);
|
||||||
|
|
||||||
|
// Advance to low_confidence case (case 8, index 7)
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
for (let j = 0; j < 100; j++) {
|
||||||
|
state = updatePlayback(state, 100);
|
||||||
|
if (state.currentCaseIndex > i) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that low confidence case has valid category
|
||||||
|
for (let j = 0; j < 50; j++) {
|
||||||
|
state = updatePlayback(state, 100);
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
|
||||||
|
if (data.isLowConfidence && data.finalCategory) {
|
||||||
|
expect(['B', 'C', 'D']).toContain(data.finalCategory);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has positive laser distance', () => {
|
||||||
|
const state = createPlaybackState();
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
|
||||||
|
expect(data.laserDistanceMm).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has valid confidence between 0 and 1', () => {
|
||||||
|
const state = createPlaybackState();
|
||||||
|
const data = getMeasurementData(state);
|
||||||
|
|
||||||
|
expect(data.confidence).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(data.confidence).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStageLabel', () => {
|
||||||
|
it('returns correct labels for all stages', () => {
|
||||||
|
expect(getStageLabel('idle')).toBe('Standby');
|
||||||
|
expect(getStageLabel('leading_edge_detected')).toBe('Edge Detected');
|
||||||
|
expect(getStageLabel('step_counting')).toBe('Counting Steps');
|
||||||
|
expect(getStageLabel('decision_ready')).toBe('Decision Ready');
|
||||||
|
expect(getStageLabel('command_sent')).toBe('Command Sent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldShowMeasurement', () => {
|
||||||
|
it('returns true for measurement phases', () => {
|
||||||
|
expect(shouldShowMeasurement('detection')).toBe(true);
|
||||||
|
expect(shouldShowMeasurement('measurement')).toBe(true);
|
||||||
|
expect(shouldShowMeasurement('classification')).toBe(true);
|
||||||
|
expect(shouldShowMeasurement('routing')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-measurement phases', () => {
|
||||||
|
expect(shouldShowMeasurement('spawn')).toBe(false);
|
||||||
|
expect(shouldShowMeasurement('move_to_detection')).toBe(false);
|
||||||
|
expect(shouldShowMeasurement('clear_gap')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldShowLaserBeam', () => {
|
||||||
|
it('returns true for detection and measurement', () => {
|
||||||
|
expect(shouldShowLaserBeam('detection')).toBe(true);
|
||||||
|
expect(shouldShowLaserBeam('measurement')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for other phases', () => {
|
||||||
|
expect(shouldShowLaserBeam('spawn')).toBe(false);
|
||||||
|
expect(shouldShowLaserBeam('classification')).toBe(false);
|
||||||
|
expect(shouldShowLaserBeam('routing')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
260
src/domain/measurementSystem.ts
Normal file
260
src/domain/measurementSystem.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Measurement System — engineering logic for item measurement.
|
||||||
|
*
|
||||||
|
* Measurement architecture:
|
||||||
|
* 1. Stepper motor: measures length along conveyor axis (step counting)
|
||||||
|
* 2. Laser rangefinder: measures height from above
|
||||||
|
* 3. Stereo camera: measures width and determines shape/roundness
|
||||||
|
* 4. PLC controller: synchronizes and makes classification decision
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ContinuousPlaybackState, CasePhase } from './continuousPlayback';
|
||||||
|
import type { Category } from './types';
|
||||||
|
import { ITEMS } from '../data/items';
|
||||||
|
import {
|
||||||
|
MM_PER_STEP,
|
||||||
|
LASER_HEIGHT_M,
|
||||||
|
BELT_TOP_Y,
|
||||||
|
CONVEYOR_SPEED_MPS,
|
||||||
|
} from './physicalLayout';
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Types
|
||||||
|
// =========================================================
|
||||||
|
|
||||||
|
export type MeasurementStage =
|
||||||
|
| 'idle'
|
||||||
|
| 'leading_edge_detected'
|
||||||
|
| 'step_counting'
|
||||||
|
| 'laser_height'
|
||||||
|
| 'stereo_width_shape'
|
||||||
|
| 'decision_ready'
|
||||||
|
| 'command_sent';
|
||||||
|
|
||||||
|
export interface MeasurementData {
|
||||||
|
/** Current measurement stage */
|
||||||
|
stage: MeasurementStage;
|
||||||
|
|
||||||
|
/** Stepper motor data */
|
||||||
|
stepCount: number;
|
||||||
|
mmPerStep: number;
|
||||||
|
measuredLengthMm: number;
|
||||||
|
pulseActive: boolean;
|
||||||
|
|
||||||
|
/** Laser rangefinder data */
|
||||||
|
laserDistanceMm: number;
|
||||||
|
laserMountHeightMm: number;
|
||||||
|
measuredHeightMm: number;
|
||||||
|
laserBeamActive: boolean;
|
||||||
|
|
||||||
|
/** Stereo camera data */
|
||||||
|
measuredWidthMm: number;
|
||||||
|
roundnessK: number;
|
||||||
|
stereoActive: boolean;
|
||||||
|
pointCloudActive: boolean;
|
||||||
|
|
||||||
|
/** Classification */
|
||||||
|
confidence: number;
|
||||||
|
dimensionsPass: boolean;
|
||||||
|
shapeResult: 'box' | 'round' | 'irregular';
|
||||||
|
finalCategory: Category | null;
|
||||||
|
command: string;
|
||||||
|
cPriorityApplied: boolean;
|
||||||
|
isLowConfidence: boolean;
|
||||||
|
|
||||||
|
/** Item info */
|
||||||
|
itemTitle: string;
|
||||||
|
itemDimensions: { width: number; depth: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Dimension limits (OZON Track 3 spec)
|
||||||
|
// =========================================================
|
||||||
|
const MAX_DIMENSION_MM = {
|
||||||
|
width: 450,
|
||||||
|
depth: 320,
|
||||||
|
height: 320,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Stage mapping from playback phase
|
||||||
|
// =========================================================
|
||||||
|
function getStageFromPhase(phase: CasePhase): MeasurementStage {
|
||||||
|
switch (phase) {
|
||||||
|
case 'spawn':
|
||||||
|
case 'move_to_detection':
|
||||||
|
return 'idle';
|
||||||
|
case 'detection':
|
||||||
|
return 'leading_edge_detected';
|
||||||
|
case 'measurement':
|
||||||
|
return 'step_counting';
|
||||||
|
case 'classification':
|
||||||
|
return 'decision_ready';
|
||||||
|
case 'command_sent':
|
||||||
|
case 'routing':
|
||||||
|
case 'exit':
|
||||||
|
return 'command_sent';
|
||||||
|
case 'clear_gap':
|
||||||
|
return 'idle';
|
||||||
|
default:
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Check dimension compliance
|
||||||
|
// =========================================================
|
||||||
|
function checkDimensionsPass(dims: { width: number; depth: number; height: number }): boolean {
|
||||||
|
return (
|
||||||
|
dims.width <= MAX_DIMENSION_MM.width &&
|
||||||
|
dims.depth <= MAX_DIMENSION_MM.depth &&
|
||||||
|
dims.height <= MAX_DIMENSION_MM.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Determine shape from roundness
|
||||||
|
// =========================================================
|
||||||
|
function getShapeResult(roundnessK: number): 'box' | 'round' | 'irregular' {
|
||||||
|
if (roundnessK >= 0.7) return 'round';
|
||||||
|
if (roundnessK >= 0.3) return 'box';
|
||||||
|
return 'irregular';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Main measurement data getter
|
||||||
|
// =========================================================
|
||||||
|
export function getMeasurementData(playback: ContinuousPlaybackState): MeasurementData {
|
||||||
|
const currentCase = playback.currentCase;
|
||||||
|
const phase = playback.currentPhase;
|
||||||
|
const stage = getStageFromPhase(phase);
|
||||||
|
|
||||||
|
// Get item data
|
||||||
|
const itemId = currentCase.itemId.replace('-LC', '');
|
||||||
|
const itemData = ITEMS.find(i => i.id === itemId) ?? ITEMS[0];
|
||||||
|
const dims = itemData.dimensionsMm;
|
||||||
|
|
||||||
|
// Calculate step count from item length (depth = length along conveyor)
|
||||||
|
const lengthMm = dims.depth;
|
||||||
|
const stepCount = Math.round(lengthMm / MM_PER_STEP);
|
||||||
|
|
||||||
|
// Laser measurement: distance from laser to top of item
|
||||||
|
const laserMountHeightMm = LASER_HEIGHT_M * 1000;
|
||||||
|
const itemTopHeightMm = BELT_TOP_Y * 1000 + dims.height;
|
||||||
|
const laserDistanceMm = laserMountHeightMm - itemTopHeightMm;
|
||||||
|
|
||||||
|
// Derived measurements
|
||||||
|
const measuredLengthMm = stepCount * MM_PER_STEP;
|
||||||
|
const measuredHeightMm = laserMountHeightMm - laserDistanceMm;
|
||||||
|
const measuredWidthMm = dims.width;
|
||||||
|
const roundnessK = itemData.roundness;
|
||||||
|
|
||||||
|
// Classification
|
||||||
|
const dimensionsPass = checkDimensionsPass(dims);
|
||||||
|
const shapeResult = getShapeResult(roundnessK);
|
||||||
|
const confidence = itemData.confidence ?? 0.95;
|
||||||
|
const isLowConfidence = confidence < 0.8;
|
||||||
|
|
||||||
|
// Determine category
|
||||||
|
let finalCategory: Category | null = null;
|
||||||
|
let cPriorityApplied = false;
|
||||||
|
|
||||||
|
if (phase !== 'spawn' && phase !== 'move_to_detection') {
|
||||||
|
if (!dimensionsPass) {
|
||||||
|
// Dimensions fail → C (overrides roundness)
|
||||||
|
finalCategory = 'C';
|
||||||
|
if (roundnessK >= 0.7) {
|
||||||
|
cPriorityApplied = true;
|
||||||
|
}
|
||||||
|
} else if (roundnessK >= 0.7) {
|
||||||
|
// Round shape → D
|
||||||
|
finalCategory = 'D';
|
||||||
|
} else {
|
||||||
|
// Normal → B
|
||||||
|
finalCategory = 'B';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with expected category if available (for consistent demo)
|
||||||
|
if (currentCase.expectedCategory && phase !== 'spawn' && phase !== 'move_to_detection') {
|
||||||
|
finalCategory = currentCase.expectedCategory;
|
||||||
|
// Check if C-priority case
|
||||||
|
if (finalCategory === 'C' && roundnessK >= 0.7) {
|
||||||
|
cPriorityApplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command
|
||||||
|
const command = finalCategory ? `ROUTE_TO_${finalCategory}` : 'STANDBY';
|
||||||
|
|
||||||
|
// Active states for visualization
|
||||||
|
const isActivePhase = ['detection', 'measurement', 'classification'].includes(phase);
|
||||||
|
const pulseActive = stage === 'step_counting' || stage === 'leading_edge_detected';
|
||||||
|
const laserBeamActive = isActivePhase;
|
||||||
|
const stereoActive = isActivePhase;
|
||||||
|
const pointCloudActive = phase === 'measurement' || phase === 'classification';
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage,
|
||||||
|
stepCount,
|
||||||
|
mmPerStep: MM_PER_STEP,
|
||||||
|
measuredLengthMm: Math.round(measuredLengthMm),
|
||||||
|
pulseActive,
|
||||||
|
laserDistanceMm: Math.round(laserDistanceMm),
|
||||||
|
laserMountHeightMm: Math.round(laserMountHeightMm),
|
||||||
|
measuredHeightMm: Math.round(measuredHeightMm),
|
||||||
|
laserBeamActive,
|
||||||
|
measuredWidthMm,
|
||||||
|
roundnessK,
|
||||||
|
stereoActive,
|
||||||
|
pointCloudActive,
|
||||||
|
confidence,
|
||||||
|
dimensionsPass,
|
||||||
|
shapeResult,
|
||||||
|
finalCategory,
|
||||||
|
command,
|
||||||
|
cPriorityApplied,
|
||||||
|
isLowConfidence,
|
||||||
|
itemTitle: currentCase.title,
|
||||||
|
itemDimensions: dims,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Stage labels for UI
|
||||||
|
// =========================================================
|
||||||
|
export function getStageLabel(stage: MeasurementStage): string {
|
||||||
|
switch (stage) {
|
||||||
|
case 'idle': return 'Standby';
|
||||||
|
case 'leading_edge_detected': return 'Edge Detected';
|
||||||
|
case 'step_counting': return 'Counting Steps';
|
||||||
|
case 'laser_height': return 'Laser Height';
|
||||||
|
case 'stereo_width_shape': return 'Stereo Analysis';
|
||||||
|
case 'decision_ready': return 'Decision Ready';
|
||||||
|
case 'command_sent': return 'Command Sent';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Check if measurement visualization should be shown
|
||||||
|
// =========================================================
|
||||||
|
export function shouldShowMeasurement(phase: CasePhase): boolean {
|
||||||
|
return ['detection', 'measurement', 'classification', 'command_sent', 'routing'].includes(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowLaserBeam(phase: CasePhase): boolean {
|
||||||
|
return ['detection', 'measurement'].includes(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowStepperPulse(phase: CasePhase): boolean {
|
||||||
|
return ['detection', 'measurement'].includes(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowPointCloud(phase: CasePhase): boolean {
|
||||||
|
return ['measurement', 'classification'].includes(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowActuator(phase: CasePhase): boolean {
|
||||||
|
return ['routing', 'exit'].includes(phase);
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ export const DRIVE_ROLLER_RADIUS_M = 0.06; // 60mm drive roller
|
|||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// Zone positions (X axis = along conveyor, Z axis = lateral)
|
// Zone positions (X axis = along conveyor, Z axis = lateral)
|
||||||
|
// Must be declared before other constants that reference it
|
||||||
// =========================================================
|
// =========================================================
|
||||||
export const ZONES = {
|
export const ZONES = {
|
||||||
/** Zone A - item spawn point (start of conveyor) */
|
/** Zone A - item spawn point (start of conveyor) */
|
||||||
@@ -71,6 +72,40 @@ export const ZONES = {
|
|||||||
D: { x: 2.0, z: -2.0, label: 'D' },
|
D: { x: 2.0, z: -2.0, label: 'D' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Stepper motor parameters (for length measurement)
|
||||||
|
// =========================================================
|
||||||
|
export const STEPPER_STEP_ANGLE_DEG = 1.8; // 1.8° per full step (200 steps/rev)
|
||||||
|
export const MICROSTEP_DIVISOR = 16; // 16x microstepping
|
||||||
|
export const STEPS_PER_REV = 360 / STEPPER_STEP_ANGLE_DEG * MICROSTEP_DIVISOR; // 3200 steps/rev
|
||||||
|
export const DRIVE_ROLLER_CIRCUMFERENCE_M = Math.PI * DRIVE_ROLLER_RADIUS_M * 2; // ~0.377m
|
||||||
|
export const MM_PER_STEP = (DRIVE_ROLLER_CIRCUMFERENCE_M * 1000) / STEPS_PER_REV; // ~0.118mm/step
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Laser rangefinder
|
||||||
|
// =========================================================
|
||||||
|
export const LASER_HEIGHT_M = 1.15; // Laser mounted at 1.15m
|
||||||
|
export const LASER_POSITION_X = ZONES.CAMERA.x; // Co-located with camera
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Stereo camera
|
||||||
|
// =========================================================
|
||||||
|
export const STEREO_CAMERA = {
|
||||||
|
baseline: 0.12, // 120mm between lenses
|
||||||
|
focalLength: 0.004, // 4mm focal length
|
||||||
|
fov: 60, // 60° field of view
|
||||||
|
mountY: 1.1, // Mounted at 1.1m
|
||||||
|
};
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Measurement zone
|
||||||
|
// =========================================================
|
||||||
|
export const MEASUREMENT_ZONE = {
|
||||||
|
startX: ZONES.CAMERA.x - 0.4,
|
||||||
|
endX: ZONES.CAMERA.x + 0.4,
|
||||||
|
length: 0.8, // 800mm measurement zone
|
||||||
|
};
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// Roll cages (C/D destination bins)
|
// Roll cages (C/D destination bins)
|
||||||
// =========================================================
|
// =========================================================
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebG
|
|||||||
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../domain/demoPlaylist';
|
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../domain/demoPlaylist';
|
||||||
import type { ContinuousPlaybackState } from '../domain/continuousPlayback';
|
import type { ContinuousPlaybackState } from '../domain/continuousPlayback';
|
||||||
import { getCaseProgress, getCurrentPhaseConfig } from '../domain/continuousPlayback';
|
import { getCaseProgress, getCurrentPhaseConfig } from '../domain/continuousPlayback';
|
||||||
import { getInspectionData } from '../domain/inspectionViewModel';
|
import { getMeasurementData, shouldShowMeasurement } from '../domain/measurementSystem';
|
||||||
import CVInspectionOverlay from '../components/CVInspectionOverlay';
|
import CVInspectionOverlay from '../components/CVInspectionOverlay';
|
||||||
|
|
||||||
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
|
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
|
||||||
@@ -47,8 +47,8 @@ export default function MainPage({
|
|||||||
const phaseConfig = getCurrentPhaseConfig(playback);
|
const phaseConfig = getCurrentPhaseConfig(playback);
|
||||||
const caseProgress = getCaseProgress(playback);
|
const caseProgress = getCaseProgress(playback);
|
||||||
|
|
||||||
const inspectionData = useMemo(() => getInspectionData(playback), [playback]);
|
const measurementData = useMemo(() => getMeasurementData(playback), [playback]);
|
||||||
const showInspection = inspectionData.visible && (isRunning || isPaused) && !isFinished;
|
const showMeasurement = shouldShowMeasurement(playback.currentPhase) && (isRunning || isPaused) && !isFinished;
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
@@ -88,9 +88,9 @@ export default function MainPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CV Inspection Overlay - right center */}
|
{/* Measurement System Overlay - right center */}
|
||||||
{showInspection && width >= 768 && (
|
{width >= 768 && (
|
||||||
<CVInspectionOverlay data={inspectionData} />
|
<CVInspectionOverlay data={measurementData} visible={showMeasurement} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Minimal HUD - top right */}
|
{/* Minimal HUD - top right */}
|
||||||
|
|||||||
@@ -2624,6 +2624,100 @@ button:disabled {
|
|||||||
font-family: 'SF Mono', monospace;
|
font-family: 'SF Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Measurement section headers */
|
||||||
|
.cv-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-indicator {
|
||||||
|
font-size: 6px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-indicator.active {
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-indicator.pulse {
|
||||||
|
animation: pulse-indicator 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-indicator {
|
||||||
|
0%, 100% { color: #22d3ee; opacity: 1; }
|
||||||
|
50% { color: #0ea5e9; opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact rows */
|
||||||
|
.cv-row.cv-compact {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-row.cv-compact .cv-label {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-row.cv-compact .cv-value {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mono values */
|
||||||
|
.cv-value.cv-mono {
|
||||||
|
font-family: 'SF Mono', Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result values */
|
||||||
|
.cv-value.cv-result {
|
||||||
|
color: #0284c7;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dimension details */
|
||||||
|
.cv-dims-detail {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blink animation */
|
||||||
|
.cv-blink {
|
||||||
|
animation: blink 0.3s ease-in-out infinite;
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status variants */
|
||||||
|
.cv-status.active { background: #22d3ee; color: #0c4a6e; }
|
||||||
|
.cv-status.idle { background: #94a3b8; color: #1e293b; }
|
||||||
|
|
||||||
|
/* C-priority warning */
|
||||||
|
.cv-warning.cv-cpriority {
|
||||||
|
background: rgba(249, 115, 22, 0.1);
|
||||||
|
border-color: rgba(249, 115, 22, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-warning.cv-cpriority .warning-icon {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-warning.cv-cpriority .warning-text {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile: hide CV overlay */
|
/* Mobile: hide CV overlay */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.cv-overlay {
|
.cv-overlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user