From 985f7c327db1b71e8eee7c2e457f6902a24e179d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D1=8F=20=D0=90=D1=80=D1=85=D0=B8=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Wed, 15 Jul 2026 16:53:35 +0000 Subject: [PATCH] feat: maximize sorter demo realism and add safe agent MVP Wire live classifyItem into continuous playback, add jam/E-stop cases, presenter controls (seek/speed/hotkeys), quality modes, demo scripts, audit docs, and a verify-only autonomous agent CLI with hard safety limits. Co-authored-by: Cursor --- .gitignore | 4 + README.md | 33 +- agent/cli.mjs | 315 ++++++++++++++ docs/AI_AGENT_SAFETY_LIMITS.md | 158 +++++++ docs/AUTONOMOUS_AI_AGENT_ARCHITECTURE.md | 187 +++++++++ docs/DEMO_MAXIMIZATION_PLAN.md | 162 ++++++++ docs/DEMO_RUNBOOK.md | 159 +++++++ docs/FULL_PROJECT_AUDIT.md | 199 +++++++++ docs/IMPLEMENTATION_REPORT.md | 173 ++++++++ docs/PERFORMANCE_BASELINE.md | 117 ++++++ docs/PHYSICS_FIDELITY_AUDIT.md | 129 ++++++ docs/RISK_REGISTER.md | 107 +++++ docs/SERVER_CAPACITY_REPORT.md | 176 ++++++++ docs/VISUAL_REALISM_AUDIT.md | 115 ++++++ package.json | 8 +- scripts/agent-dry-run.sh | 5 + scripts/agent-run-once.sh | 5 + scripts/demo-health.sh | 35 ++ scripts/demo-reset.sh | 9 + scripts/demo-start.sh | 27 ++ scripts/demo-stop.sh | 15 + src/App.tsx | 25 ++ src/components/CVInspectionOverlay.tsx | 12 + .../ThreeD/PhysicalPlaybackItem.tsx | 29 +- .../ThreeD/SorterDigitalTwinContinuous.tsx | 33 +- src/data/resolveItem.ts | 29 ++ src/domain/cinematicCamera.ts | 5 + src/domain/continuousPlayback.test.ts | 171 +++----- src/domain/continuousPlayback.ts | 391 +++++++++++++----- src/domain/demoPlaylist.ts | 32 +- src/domain/inspectionViewModel.ts | 5 + src/domain/measurementSystem.ts | 186 ++++----- src/domain/physicalItemMotion.ts | 114 ++++- src/domain/physicalLayout.test.ts | 5 +- src/domain/qualityMode.test.ts | 30 ++ src/domain/qualityMode.ts | 85 ++++ src/domain/seededRng.ts | 20 + src/domain/simulation.test.ts | 54 +++ src/pages/MainPage.tsx | 288 +++++++++---- src/styles.css | 159 +++++++ 40 files changed, 3358 insertions(+), 453 deletions(-) create mode 100755 agent/cli.mjs create mode 100644 docs/AI_AGENT_SAFETY_LIMITS.md create mode 100644 docs/AUTONOMOUS_AI_AGENT_ARCHITECTURE.md create mode 100644 docs/DEMO_MAXIMIZATION_PLAN.md create mode 100644 docs/DEMO_RUNBOOK.md create mode 100644 docs/FULL_PROJECT_AUDIT.md create mode 100644 docs/IMPLEMENTATION_REPORT.md create mode 100644 docs/PERFORMANCE_BASELINE.md create mode 100644 docs/PHYSICS_FIDELITY_AUDIT.md create mode 100644 docs/RISK_REGISTER.md create mode 100644 docs/SERVER_CAPACITY_REPORT.md create mode 100644 docs/VISUAL_REALISM_AUDIT.md create mode 100755 scripts/agent-dry-run.sh create mode 100755 scripts/agent-run-once.sh create mode 100755 scripts/demo-health.sh create mode 100755 scripts/demo-reset.sh create mode 100755 scripts/demo-start.sh create mode 100755 scripts/demo-stop.sh create mode 100644 src/data/resolveItem.ts create mode 100644 src/domain/qualityMode.test.ts create mode 100644 src/domain/qualityMode.ts create mode 100644 src/domain/seededRng.ts create mode 100644 src/domain/simulation.test.ts diff --git a/.gitignore b/.gitignore index 0ab7bad..d2e4dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ vite.config.d.ts .env.* npm-debug.log* input_info/extracted/ +agent/state/ +agent/reports/ +.demo-preview.pid +.demo-preview.log diff --git a/README.md b/README.md index 9882629..94f4830 100644 --- a/README.md +++ b/README.md @@ -39,21 +39,28 @@ A → подающий конвейер → накопитель → CV/laser/ul ### Автоматическая демонстрация -**Главная возможность** — Auto Demo: -- **ВАЖНО**: Сначала нажмите "3D Digital Twin" toggle для включения 3D (по умолчанию 2D fallback на первой загрузке) -- Нажмите "🎬 Запустить автодемо" для полного автоматического цикла -- Товар автоматически: появляется → сканируется → классифицируется → маршрутизируется в B/C/D -- Controls: Пауза/Продолжить, Остановить, Reset -- Режимы: Автоматический (по умолчанию) или ручной (Next step) +**Главная страница `/`** — Continuous Playback (10 кейсов: B/C/D + low confidence + jam + E-stop): + +1. Откройте сайт и нажмите **Play Demo** (или Space). +2. Управление: + - **Space** — play/pause + - **N / →** — следующий кейс, **B / ←** — предыдущий + - **1–0** — прыжок к кейсу + - **R** — аварийный reset сценария + - **P** — presentation mode, **F** — fullscreen + - **E** — журнал событий + - **0.5×–2×** — скорость +3. HUD показывает live `classifyItem` (DIM / K / reason) — не заскриптованный override. +4. Engineering / step demo: `/details` + +Проверка: `npm test` (153+), `npm run build`, `./scripts/demo-health.sh`. **3D Verification Checklist** (для защиты): -1. Открыть https://arhipovdan.ru/ в desktop Chrome/Edge -2. Нажать "3D Digital Twin" toggle -3. Проверить: 3D scene renders (green floor, A/B/C/D zones visible) -4. Нажать "🎬 Запустить автодемо" -5. Проверить: NO black screen, item moves, FPS stable (~60) -6. Открыть Console (F12): no errors -7. Проверить mobile (<640px): 2D fallback автоматически +1. Desktop Chrome/Edge → Play Demo +2. Нет чёрного экрана, товар движется, FPS стабилен (~30–60) +3. Console без ошибок +4. Кейсы 9–10: jam (FAULT) и emergency stop +5. Mobile (<640px): упрощённая сцена / fallback **Реальные 3D модели** (6 STL, 55%): - Бутылка, Тарелка, Цилиндр, Короб 300, Короб 400, ЛанчБокс diff --git a/agent/cli.mjs b/agent/cli.mjs new file mode 100755 index 0000000..03517fe --- /dev/null +++ b/agent/cli.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Autonomous improvement agent MVP — Orchestrator shell. + * + * Modes: dry-run | run-once | status | stop | resume | pause | report + * NEVER merges to main or deploys production. + */ + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + appendFileSync, + unlinkSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const STATE_DIR = join(ROOT, 'agent', 'state'); +const REPORTS_DIR = join(ROOT, 'agent', 'reports'); +const KILL_SWITCH = join(STATE_DIR, 'KILL'); +const STATUS_FILE = join(STATE_DIR, 'status.json'); +const AUDIT_LOG = join(STATE_DIR, 'audit.jsonl'); + +const LIMITS = { + maxCycleMinutes: 25, + maxChangedFiles: 12, + maxDiffLines: 800, + maxParallelWorkers: 1, + dailyLlmBudgetUsd: 5, + forbidMergeToMain: true, + forbidProductionDeploy: true, + forbidSecretAccess: true, + requireTestsPass: true, + requireBuildPass: true, + minScoreDelta: 0, +}; + +function ensureDirs() { + mkdirSync(STATE_DIR, { recursive: true }); + mkdirSync(REPORTS_DIR, { recursive: true }); +} + +function nowIso() { + return new Date().toISOString(); +} + +function audit(event, payload = {}) { + appendFileSync(AUDIT_LOG, `${JSON.stringify({ ts: nowIso(), event, ...payload })}\n`); +} + +function writeStatus(status) { + writeFileSync(STATUS_FILE, JSON.stringify({ ...status, updatedAt: nowIso() }, null, 2)); +} + +function readStatus() { + if (!existsSync(STATUS_FILE)) return { state: 'idle' }; + return JSON.parse(readFileSync(STATUS_FILE, 'utf8')); +} + +function isKilled() { + return existsSync(KILL_SWITCH); +} + +function run(cmd, args) { + const result = spawnSync(cmd, args, { + cwd: ROOT, + encoding: 'utf8', + timeout: 15 * 60 * 1000, + env: { ...process.env, AGENT_MODE: '1' }, + }); + return { + code: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +function collectBaseline() { + const tests = run('npm', ['test']); + const build = run('npm', ['run', 'build']); + return { + testsPassed: tests.code === 0, + buildPassed: build.code === 0, + testOutputTail: tests.stdout.split('\n').slice(-20).join('\n'), + buildOutputTail: build.stdout.split('\n').slice(-20).join('\n'), + }; +} + +function proposeTasks(baseline) { + const backlog = [ + { + id: 'unify-classifier-proof', + title: 'Keep classifyItem as single decision source on main demo', + impact: 9, + risk: 2, + }, + { + id: 'safety-playlist', + title: 'Jam / E-stop visible on main playlist', + impact: 8, + risk: 3, + }, + { + id: 'demo-hotkeys', + title: 'Presenter seek/speed/presentation hotkeys', + impact: 7, + risk: 2, + }, + { + id: 'perf-quality-modes', + title: 'Adaptive quality modes for demo FPS', + impact: 6, + risk: 2, + }, + ]; + + if (!baseline.testsPassed || !baseline.buildPassed) { + return { + id: 'stabilize-baseline', + title: 'Restore green tests/build before further changes', + impact: 10, + risk: 1, + }; + } + + return backlog.sort((a, b) => b.impact - a.impact)[0]; +} + +function scoreCategories(baseline, notes) { + const categories = { + visualRealism: 72, + physicsFidelity: 68, + demoClarity: 80, + technicalStability: baseline.testsPassed && baseline.buildPassed ? 88 : 40, + performance: 78, + faultTolerance: 75, + architecture: 76, + testCoverage: 82, + launchConvenience: 84, + logging: 74, + security: 90, + hardwareFit: 86, + }; + const values = Object.values(categories); + const total = Math.round(values.reduce((a, b) => a + b, 0) / values.length); + return { categories, total, notes }; +} + +function writeReport(runId, report) { + const path = join(REPORTS_DIR, `${runId}.json`); + writeFileSync(path, JSON.stringify(report, null, 2)); + writeFileSync(join(REPORTS_DIR, 'latest.json'), JSON.stringify(report, null, 2)); + return path; +} + +function cmdDryRun() { + ensureDirs(); + if (isKilled()) { + console.error('Kill switch active. Remove agent/state/KILL to continue.'); + process.exit(2); + } + const runId = `dry-${Date.now()}`; + writeStatus({ state: 'dry-run', runId }); + audit('dry_run_start', { runId }); + + console.log(`[agent] dry-run ${runId}`); + console.log('[agent] collecting baseline (tests + build)...'); + const baseline = collectBaseline(); + const task = proposeTasks(baseline); + const score = scoreCategories(baseline, 'dry-run — no code changes'); + + const report = { + runId, + mode: 'dry-run', + hypothesis: `If we execute "${task.title}", demo jury clarity improves without regressing FPS.`, + task, + limits: LIMITS, + baseline, + score, + decision: 'PLAN_ONLY', + reason: 'dry-run does not apply patches', + safety: { + wouldTouchProduction: false, + wouldMergeMain: false, + killSwitch: false, + }, + }; + + const path = writeReport(runId, report); + writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'dry-run' }); + audit('dry_run_complete', { runId, path, taskId: task.id }); + console.log(`[agent] planned task: ${task.id} — ${task.title}`); + console.log(`[agent] score: ${score.total}/100`); + console.log(`[agent] report: ${path}`); +} + +function cmdRunOnce() { + ensureDirs(); + if (isKilled()) { + console.error('Kill switch active.'); + process.exit(2); + } + const runId = `once-${Date.now()}`; + writeStatus({ state: 'run-once', runId }); + audit('run_once_start', { runId }); + + console.log(`[agent] run-once ${runId}`); + console.log('[agent] MVP policy: verify baseline only — no automatic code mutation.'); + + const baseline = collectBaseline(); + const task = proposeTasks(baseline); + const score = scoreCategories(baseline, 'run-once verification'); + const decision = + baseline.testsPassed && baseline.buildPassed ? 'ACCEPT_BASELINE' : 'REJECT_BASELINE'; + + const report = { + runId, + mode: 'run-once', + task, + limits: LIMITS, + baseline, + score, + decision, + reason: + decision === 'ACCEPT_BASELINE' + ? 'Tests and build green; agent will not auto-patch in MVP (requires human Implementer).' + : 'Baseline red — agent refuses further patches until fixed.', + nextSafeActions: [ + 'Keep working on feature/maximum-demo-realism', + 'Never merge to main automatically', + 'Use agent dry-run before long autonomous sessions', + ], + }; + + const path = writeReport(runId, report); + writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'run-once', decision }); + audit('run_once_complete', { runId, decision }); + console.log(`[agent] decision: ${decision}`); + console.log(`[agent] report: ${path}`); + process.exit(decision === 'ACCEPT_BASELINE' ? 0 : 1); +} + +function cmdStatus() { + ensureDirs(); + console.log(JSON.stringify({ ...readStatus(), killSwitch: isKilled(), limits: LIMITS }, null, 2)); +} + +function cmdStop() { + ensureDirs(); + writeFileSync(KILL_SWITCH, `stopped at ${nowIso()}\n`); + writeStatus({ state: 'stopped' }); + audit('kill_switch_on'); + console.log('[agent] stop requested — kill switch written'); +} + +function cmdResume() { + ensureDirs(); + if (existsSync(KILL_SWITCH)) unlinkSync(KILL_SWITCH); + writeStatus({ state: 'idle' }); + audit('kill_switch_off'); + console.log('[agent] resumed'); +} + +function cmdPause() { + writeStatus({ ...readStatus(), state: 'paused' }); + audit('paused'); + console.log('[agent] paused'); +} + +function cmdReport() { + ensureDirs(); + const latest = join(REPORTS_DIR, 'latest.json'); + if (!existsSync(latest)) { + console.error('No report yet. Run: node agent/cli.mjs dry-run'); + process.exit(1); + } + console.log(readFileSync(latest, 'utf8')); +} + +const cmd = process.argv[2] ?? 'status'; +switch (cmd) { + case 'dry-run': + cmdDryRun(); + break; + case 'run-once': + cmdRunOnce(); + break; + case 'status': + cmdStatus(); + break; + case 'stop': + cmdStop(); + break; + case 'resume': + cmdResume(); + break; + case 'pause': + cmdPause(); + break; + case 'report': + cmdReport(); + break; + case 'start': + console.log('[agent] continuous start disabled in MVP. Use: dry-run | run-once'); + process.exit(1); + break; + default: + console.log('Usage: node agent/cli.mjs '); + process.exit(1); +} diff --git a/docs/AI_AGENT_SAFETY_LIMITS.md b/docs/AI_AGENT_SAFETY_LIMITS.md new file mode 100644 index 0000000..3b2525d --- /dev/null +++ b/docs/AI_AGENT_SAFETY_LIMITS.md @@ -0,0 +1,158 @@ +# Лимиты безопасности AI-агента + +**Файл реализации лимитов:** `agent/cli.mjs` → объект `LIMITS` +**Дата:** 2026-07-15 + +Документ обязателен к соблюдению при любом расширении агента за пределы MVP. + +--- + +## 1. Однозначная политика + +| Действие | Разрешено автоматически? | +| -------- | ------------------------ | +| Читать репозиторий, запускать tests/build | Да | +| Писать отчёты в `agent/reports` | Да | +| Планировать задачи (dry-run) | Да | +| Патчить исходники (MVP) | **Нет** | +| Merge в `main`/`master` | **Никогда** | +| Deploy production (:3100 / tunnel) | **Никогда** | +| Печать значений секретов / `.env` | **Никогда** | +| Менять firewall/SSH/системные секреты | **Никогда** | +| Force-push | **Никогда** | + +--- + +## 2. Числовые лимиты (MVP) + +| Параметр | Значение | Смысл | +| -------- | -------: | ----- | +| `maxCycleMinutes` | 25 | Стена времени одного цикла | +| `maxChangedFiles` | 12 | Потолок будущего патча | +| `maxDiffLines` | 800 | Анти-мегадифф | +| `maxParallelWorkers` | 1 | Ёмкость сервера | +| `dailyLlmBudgetUsd` | 5 | Бюджет API | +| `minScoreDelta` | 0 | Порог «улучшения» для accept (будущее) | +| `requireTestsPass` | true | Gate | +| `requireBuildPass` | true | Gate | +| `forbidMergeToMain` | true | Hard | +| `forbidProductionDeploy` | true | Hard | +| `forbidSecretAccess` | true | Hard | + +Playwright (когда будет в цикле): **1 worker**, не параллелить с локальной LLM. + +--- + +## 3. Kill switch + +| Операция | Команда / файл | +| -------- | -------------- | +| Остановить | `node agent/cli.mjs stop` → создаёт `agent/state/KILL` | +| Возобновить | `node agent/cli.mjs resume` → удаляет KILL | +| Пауза статуса | `node agent/cli.mjs pause` | + +При наличии `KILL` режимы `dry-run` / `run-once` завершаются с ошибкой. + +**Перед живой демонстрацией:** всегда `stop`. + +--- + +## 4. Изоляция изменений + +| Правило | Деталь | +| ------- | ------ | +| Ветка | Только `feature/*` или `agent/*`; не `main` | +| Backup | Тег `backup/pre-maximum-demo-realism-20260715` уже создан | +| Worktree (рекомендация L2) | Отдельный worktree для патчей | +| Production dist | Не трогать volume/nginx без человека | +| Preview | `:3101` допустим для проверки; не путать с `:3100` | + +--- + +## 5. Что можно / нельзя автоматизировать + +### Безопасно автоматизировать + +- Запуск Vitest и production build. +- Генерация JSON-отчётов и scorecard. +- Обновление backlog приоритетов. +- Health-check URL (без секретов). +- Сбор метрик размеров `dist` (без PII). + +### Только с проверкой человека + +- Любой diff по `src/`. +- Изменение demo playlist / classifier thresholds. +- Включение shadows/effects (FPS). +- Зависимости `package.json`. +- Nginx/Docker/Compose манифесты. +- Создание PR (без auto-merge). + +### Никогда автоматически + +- Merge в main. +- Deploy на https://arhipovdan.ru/ / :3100. +- Ротация credentials, правка `.env`. +- `git push --force`. +- Отключение или ослабление тестов «чтобы стало зелёным». +- Удаление backup-тегов. +- Запуск локальной LLM параллельно с демо-нагрузкой. + +--- + +## 6. Журналирование и аудит + +| Артефакт | Требование | +| -------- | ---------- | +| `audit.jsonl` | Append-only; каждое start/stop/complete | +| Reports | Хранить hypothesis, decision, limits snapshot | +| Запрет | Значения `login`/`password`/`url` из `.env` | + +При инциденте: приложить `status.json` + хвост `audit.jsonl` + `latest.json` **без** `.env`. + +--- + +## 7. Ресурсные лимиты хоста (операционные) + +Согласовано с `SERVER_CAPACITY_REPORT.md`: + +| Ресурс | Лимит для агента | +| ------ | ---------------- | +| Workers | 1 | +| RAM budget | ≤4 GiB суммарно с Chromium | +| GPU | не обязателен; не держать 7B 24/7 | +| Расписание | не во время режима «Демонстрация» | +| Disk | ротация старых `agent/reports` при росте | + +--- + +## 8. Реакция на отказы + +| Ситуация | Действие агента | +| -------- | --------------- | +| Tests red | `REJECT_BASELINE`, не планировать фичи | +| Build red | то же | +| Kill switch | немедленный выход | +| Budget exceeded | stop + report | +| Попытка deploy/merge | невозможна в коде MVP; при добавлении — hard fail | + +--- + +## 9. Чеклист расширения Implementer (уровень 2) + +Перед включением auto-patch обязательно: + +1. [ ] Отдельный git worktree. +2. [ ] Проверка `forbidMergeToMain` интеграционными тестами агента. +3. [ ] Max files/diff enforced до `git commit`. +4. [ ] Авто-PR без auto-merge. +5. [ ] Visual QA optional flag, default off on demo days. +6. [ ] Документированный human approver. + +Пока пункты не выполнены — Implementer остаётся **выключенным** (текущее состояние). + +--- + +## 10. Вывод + +Безопасность агента важнее скорости итераций. На OwlPrime допустим только **узкий, наблюдаемый, обратимый** контур. MVP это соблюдает: verify-only + kill switch + запрет prod. diff --git a/docs/AUTONOMOUS_AI_AGENT_ARCHITECTURE.md b/docs/AUTONOMOUS_AI_AGENT_ARCHITECTURE.md new file mode 100644 index 0000000..a803f09 --- /dev/null +++ b/docs/AUTONOMOUS_AI_AGENT_ARCHITECTURE.md @@ -0,0 +1,187 @@ +# Архитектура автономного AI-агента + +**Статус:** MVP реализован (`agent/cli.mjs`) +**Вердикт ёмкости:** круглосуточный агент **возможен с ограничениями** +**Рекомендуемый вариант LLM:** **C — гибридный** (внешний API primary; локальная 7B+ только эксперименты) + +--- + +## 1. Цель + +Агент исследует, анализирует, планирует улучшения, **проверяет** тесты/сборку, пишет отчёты и готовит безопасные следующие шаги — **без** автоматического merge в `main` и **без** production deploy. + +MVP **не** выполняет auto-patch кода (verify-only / plan-only). + +--- + +## 2. Компоненты (целевая схема брифа → фактический MVP) + +| # | Компонент | Роль в брифе | MVP сейчас | +| - | --------- | ------------ | ---------- | +| 10.1 | Orchestrator | Цикл задач, лимиты, kill switch | **Есть** — `agent/cli.mjs` | +| 10.2 | Research Agent | Поиск подходов | Задел в backlog задач | +| 10.3 | Project Analyst | Анализ репо/метрик | Baseline tests+build | +| 10.4 | Planner | Выбор задачи по impact/risk | `proposeTasks()` | +| 10.5 | Implementer | Патчи | **Не авто** — human | +| 10.6 | Test Agent | Vitest/build gates | Встроено в dry-run/run-once | +| 10.7 | Visual QA | Playwright/screenshots | Scripts вручную; не в цикле MVP | +| 10.8 | Physics QA | Инварианты motion | Покрыто частично unit-тестами | +| 10.9 | Critic | Scorecard | `scoreCategories()` | +| 10.10 | Release Manager | Merge/deploy | **Запрещён** всегда | + +--- + +## 3. Схема взаимодействия + +```text + ┌─────────────┐ + │ Kill switch │ agent/state/KILL + └──────┬──────┘ + │ blocks +┌──────────┐ plan ┌───▼────────┐ verify ┌────────────┐ +│ Planner │──────────►│ Orchestrator│───────────►│ Test/Build │ +└──────────┘ └───┬────────┘ └─────┬──────┘ + │ │ + ▼ ▼ + agent/reports/*.json ACCEPT / REJECT + │ + ▼ + Human Implementer (feature branch) + │ + X ──► main / production (forbidden auto) +``` + +--- + +## 4. Жизненный цикл задачи + +1. **Ingest:** `dry-run` или `run-once`. +2. **Baseline:** `npm test` + `npm run build`. +3. **Propose:** выбрать задачу из backlog (impact/risk) или `stabilize-baseline`. +4. **Score:** категории visual/physics/demo/… → total/100. +5. **Decide:** + - dry-run → `PLAN_ONLY`; + - run-once → `ACCEPT_BASELINE` / `REJECT_BASELINE`. +6. **Persist:** `agent/reports/.json`, `latest.json`, `audit.jsonl`, `status.json`. +7. **Stop conditions:** kill switch, красный baseline, лимиты LIMITS. + +*(Будущий уровень 2: Implementer создаёт patch branch → Test → Critic → human merge.)* + +--- + +## 5. Выбор моделей + +| Вариант | Описание | Вердикт на OwlPrime | +| ------- | -------- | ------------------- | +| A. Полностью локальный | 7B+ на 2×1080 | Технически возможно, **тяжело**; риск OOM | +| B. Внешние LLM API | Планирование/код вне хоста | Хорошо, нужен бюджет и секреты вне логов | +| **C. Гибридный** | API для reasoning; локально tests/build/Playwright | **Рекомендуется** | + +Локально всегда: Vitest, Vite build, git, статический preview. +Внешне: генерация гипотез/диффов (когда Implementer появится). + +--- + +## 6. Хранение состояния + +| Путь | Назначение | +| ---- | ---------- | +| `agent/state/status.json` | Текущее состояние (idle/dry-run/…) | +| `agent/state/audit.jsonl` | Append-only журнал событий | +| `agent/state/KILL` | Аварийная остановка | +| `agent/reports/*.json` | Отчёты прогонов | +| `agent/reports/latest.json` | Последний отчёт | + +Секреты из `.env` агент **не** должен читать в логи (`forbidSecretAccess: true`). + +--- + +## 7. Очереди + +MVP: **очереди нет** — один процесс, `maxParallelWorkers: 1`. +Будущее: файловая очередь `agent/queue/` с lease и TTL, всё ещё single worker на этом хосте. + +--- + +## 8. Безопасность + +См. полный документ `AI_AGENT_SAFETY_LIMITS.md`. Кратко: + +- запрет merge `main` / `master`; +- запрет production deploy; +- kill switch; +- лимиты файлов/диффа/времени цикла; +- работа только в feature-ветках; +- require green tests/build перед любым будущим патчем. + +--- + +## 9. Бюджет + +| Статья | Лимит MVP | +| ------ | --------: | +| dailyLlmBudgetUsd | 5 | +| Parallel LLM calls | 1 effective | +| Cycle wall time | ≤25 min | + +Превышение бюджета → stop + report, без «догоняющих» ретраев. + +--- + +## 10. Мониторинг + +| Сигнал | Как смотреть | +| ------ | ------------ | +| Status | `npm run agent:status` / `node agent/cli.mjs status` | +| Audit | `agent/state/audit.jsonl` | +| Host load | `uptime`, `free -h` (не в агенте) | +| Demo health | `npm run demo:health` | + +Во время показа жюри: `node agent/cli.mjs stop`. + +--- + +## 11. Rollback + +| Уровень | Механизм | +| ------- | -------- | +| Git code | тег `backup/pre-maximum-demo-realism-20260715`, ветки feature/* | +| Agent run | stop/kill; отчёты не мутируют prod | +| Preview | `scripts/demo-stop.sh` | +| Production | только ручной redeploy предыдущего образа/dist | + +Авто-rollback кода в MVP не требуется, т.к. авто-патча нет. + +--- + +## 12. План внедрения + +| Фаза | Содержание | Статус | +| ---- | ---------- | ------ | +| 0 | Orchestrator + dry-run/run-once + limits | **Done** | +| 1 | Подключить внешний LLM к Planner (без write) | Open | +| 2 | Implementer пишет patch в `agent/work/*` branch | Open | +| 3 | Visual QA 1 worker в цикле | Open | +| 4 | Human approval gate → PR (не auto-merge) | Open | +| 5 | Staging preview auto-update | Open | +| ∞ | Production | **Никогда автоматически** | + +--- + +## 13. Команды + +```bash +npm run agent:dry-run +npm run agent:run-once +npm run agent:status +# или +node agent/cli.mjs dry-run|run-once|status|stop|resume|pause|report +./scripts/agent-dry-run.sh +./scripts/agent-run-once.sh +``` + +--- + +## 14. Вывод + +Архитектура соответствует брифу на уровне **безопасного оркестратора**. Полная автономия «исследуй→патчь→деплой» на этом сервере **нецелесообразна и запрещена**. Гибридный Variant C + 1 worker + human merge — единственный устойчивый путь. diff --git a/docs/DEMO_MAXIMIZATION_PLAN.md b/docs/DEMO_MAXIMIZATION_PLAN.md new file mode 100644 index 0000000..87db447 --- /dev/null +++ b/docs/DEMO_MAXIMIZATION_PLAN.md @@ -0,0 +1,162 @@ +# План максимизации демонстрации + +**Проект:** OZON Tech Sorter Simulation +**Ветка реализации:** `feature/maximum-demo-realism` +**Дата:** 2026-07-15 + +Документ фиксирует целевое состояние демо, этапы и критерии приёмки. Статус этапов: **Done** = реализовано в этой итерации; **Open** = остаётся. + +--- + +## 1. Текущее состояние (as-is) + +| Аспект | Состояние | Тип | +| ------ | --------- | --- | +| Движки | Continuous `/` + FSM `/details` | Измерено | +| Классификация на `/` | `classifyItem` wired (live) | Измерено (код) | +| Playlist | 10 кейсов вкл. jam + e-stop | Измерено | +| Proof | HUD DIM/K/reason + CV RULE | Измерено | +| Управление | seek, 0.5–2×, hotkeys, presentation | Измерено | +| Quality | low/medium/high/demo | Измерено | +| Physics | Детерминированная кинематика + jitter | Измерено | +| CV | Pseudo | Измерено | +| Twin parity `/` vs `/details` | Расходятся визуально | Измерено | +| Prod sync | :3100 может быть старым | Измерено (риск) | +| Tests | 153 / 16 files | Измерено | +| Agent | MVP verify-only | Измерено | + +--- + +## 2. Целевое состояние (to-be) + +Демонстрация должна за 5–10 секунд отвечать зрителю: + +1. Что это за система (промышленная сортировка OZON Tech). +2. Откуда берётся решение (измерения → правила → B/C/D). +3. Что делают механизмы (gate/pusher/маршрут). +4. Что происходит при аварии (jam / e-stop + recover). +5. Что это не «мультфильм»: proof HUD, журнал, метрики. + +Целевые свойства: + +- визуально реалистичный industrial twin; +- физически правдоподобное (хотя бы кинематически согласованное) движение; +- стабильные 30–60 FPS в режиме demo; +- one-command start + health; +- откат через git tag; +- production = тот же билд, что прошёл тесты. + +--- + +## 3. Этапы + +| # | Этап | Приоритет | Сложность | Статус | Ожидаемый эффект | +| - | ---- | --------- | --------- | ------ | ---------------- | +| E1 | Wire `classifyItem` в continuous | P0 | Низкая | **Done** | Доверие к алгоритму | +| E2 | Measurement + DIMENSION_LIMITS + confidence | P0 | Средняя | **Done** | Корректные min dims, reason | +| E3 | Playlist 8→10 + fault timelines | P0 | Средняя | **Done** | Safety story для жюри | +| E4 | Fault freeze/recover + seeded jitter | P1 | Средняя | **Done** | Правдоподобие / воспроизводимость | +| E5 | Demo controls + presentation + journal | P0 | Средняя | **Done** | Управление показом | +| E6 | Quality modes | P1 | Низкая | **Done** | FPS на слабых клиентах | +| E7 | Proof HUD + CV RULE overlay | P0 | Средняя | **Done** | «Видно почему» | +| E8 | simulation tests jam/estop/c_priority | P1 | Низкая | **Done** | Регрессионная защита | +| E9 | Agent MVP + scripts | P1 | Средняя | **Done** | Ops / future autonomy | +| E10 | resolveItem SKU-*-LC | P2 | Низкая | **Done** | Стабильность данных | +| E11 | Унификация визуала `/` и `/details` | P1 | Высокая | **Open** | Единый образ системы | +| E12 | Redeploy prod dist на :3100 | P0 | Низкая (ops) | **Open** | Публичная демо актуальна | +| E13 | Playwright в CI (1 worker) | P2 | Средняя | **Open** | Авто-регрессия UI | +| E14 | Опциональный physics (Rapier lite) | P3 | Высокая | **Open** | Доп. fidelity (не блокер) | +| E15 | Agent auto-patch за human gate | P3 | Высокая | **Open** | Автономия уровня 2 | + +--- + +## 4. Приоритет (матрица) + +| Приоритет | Фокус | +| --------- | ----- | +| P0 | То, без чего живой показ врёт или ломается: классификация, proof, faults, controls, prod sync | +| P1 | Убедительность и стабильность: quality, tests, twin unify, agent ops | +| P2 | Удобство и покрытие: e2e CI, data edge cases | +| P3 | Исследования: real physics, full autonomous implementer | + +--- + +## 5. Ожидаемый эффект по направлениям + +| Направление | Эффект Done-этапов | Остаточный разрыв | +| ----------- | ------------------ | ----------------- | +| Demo clarity | Высокий — seek/hotkeys/presentation | — | +| Algorithm proof | Высокий — live classify + HUD | Pseudo-CV всё ещё | +| Fault story | Высокий — jam/e-stop в playlist | — | +| Visual realism | Средний+ | Dual twin, нет AO/heavy PBR | +| Physics fidelity | Средний | Нет rigid body | +| Ops | Высокий — scripts + agent | Redeploy path вне docker CLI | + +--- + +## 6. Сложность оставшихся работ + +| Работа | Сложность | Зависимости | Риск | +| ------ | --------- | ----------- | ---- | +| Unify twins | Высокая | Общий scene kit, не ломая оба маршрута | Регрессия `/details` | +| Prod redeploy | Низкая | Доступ к docker/host вне среды | Забыть обновить tunnel cache | +| e2e CI | Средняя | 1 worker, артефакты | Flaky screenshots | +| Rapier | Высокая | Performance budget | FPS падение | +| Agent patcher | Высокая | Safety limits, staging | Порча ветки | + +--- + +## 7. Риски плана + +| Риск | Митигация | +| ---- | --------- | +| Погоня за «настоящей физикой» убивает FPS | Держать кинематику; physics только opt-in | +| Унификация twin ломает details UX | Feature flag / поэтапный shared module | +| Агент без лимитов | Kill switch + forbid deploy | +| Показ со старым prod | `demo:health` + явный checklist redeploy | + +--- + +## 8. Зависимости + +```text +E12 (redeploy) зависит от зелёных tests/build на feature-ветке +E11 (unify) зависит от стабильного continuous twin (E1–E7 Done) +E13 (e2e CI) зависит от ёмкости (1 worker) и стабильных селекторов +E15 (agent patch) зависит от E9 + AI_AGENT_SAFETY_LIMITS +``` + +--- + +## 9. Критерии приёмки + +### Must (для «максимальной демо» итерации) + +- [x] Continuous использует `classifyItem` (не только playlist label). +- [x] Playlist ≥10 с jam и emergency_stop. +- [x] Proof HUD показывает DIM, K, reason. +- [x] Hotkeys + speed + seek + presentation работают. +- [x] Quality modes существуют и покрыты тестами. +- [x] Vitest зелёный (≥153). +- [x] Build OK. +- [x] Backup tag существует. +- [ ] Production https://arhipovdan.ru/ отдаёт новый dist (**Open**). + +### Should + +- [ ] Визуальный parity ключевых элементов `/` и `/details`. +- [ ] Playwright smoke в CI (1 worker). + +### Could + +- [ ] Лёгкий physics layer. +- [ ] Agent auto-patch с обязательным human merge. + +--- + +## 10. Рекомендуемый порядок дожима + +1. Redeploy production (E12) — максимальный ROI для жюри. +2. Visual unify twin (E11) — доверие «одной системы». +3. e2e smoke (E13). +4. Остальное — по необходимости. diff --git a/docs/DEMO_RUNBOOK.md b/docs/DEMO_RUNBOOK.md new file mode 100644 index 0000000..720b38a --- /dev/null +++ b/docs/DEMO_RUNBOOK.md @@ -0,0 +1,159 @@ +# Demo Runbook — OZON Tech Sorter Simulation + +**Назначение:** провести живую демонстрацию жюри/заказчику без сюрпризов. +**Дата актуализации:** 2026-07-15 +**Публичный URL:** https://arhipovdan.ru/ +**Репозиторий:** `/home/coder/arhipovdan/app` + +--- + +## 1. За 30–60 минут до показа + +### 1.1. Остановить фоновые помехи + +```bash +cd /home/coder/arhipovdan/app +node agent/cli.mjs stop +# убедиться, что Playwright/тяжёлые job не бегут +``` + +### 1.2. Зелёный baseline + +```bash +npm test +npm run build +``` + +Ожидание: Vitest **153** passed / **16** files; build OK. + +### 1.3. Health + +```bash +npm run demo:health +``` + +Проверяет: + +- `http://127.0.0.1:3100/` (prod loopback), +- `http://127.0.0.1:3101/` (preview, если поднят), +- `https://arhipovdan.ru/`, +- vitest. + +### 1.4. Актуальный билд на том URL, который показываете + +| Если показываете | Что сделать | +| ---------------- | ----------- | +| Локальный preview | `npm run demo:start` → http://127.0.0.1:3101/ | +| Публичный сайт | Убедиться, что :3100 отдаёт **новый** `dist` (ручной redeploy). Иначе жюри увидит старую версию. | + +> В этой среде может не быть `docker` CLI — redeploy выполняется тем процессом, которым контейнер обычно обновляется на OwlPrime. + +--- + +## 2. Старт одной командой + +```bash +cd /home/coder/arhipovdan/app +npm run demo:start +``` + +Скрипт: при необходимости `npm ci`, `npm run build`, поднимает `vite preview` на **127.0.0.1:3101**. + +Остановка: + +```bash +npm run demo:stop +# reset: bash scripts/demo-reset.sh +``` + +--- + +## 3. Сценарий показа (рекомендуемый тайминг ~6–8 мин) + +| Мин | Что делать | Что говорит оператор | +| --: | ---------- | -------------------- | +| 0:00 | Открыть `/`, включить Presentation (P/F) | «Цифровой двойник линии сортировки» | +| 0:20 | Дать continuous playback идти | «Товар едет → измеряется → классифицируется» | +| 1:00 | Указать Proof HUD (DIM, K, reason) | «Решение — правила classifyItem, не просто ролик» | +| 2:00 | Кейсы B → C → D | «Габариты / негабарит / круглое сечение» | +| 3:30 | Кейс c_priority | «C приоритетнее D» | +| 4:00 | Jam | «Затор — поток заморожен» | +| 4:40 | Emergency stop | «E-stop — safety stop» | +| 5:20 | Speed 0.5× на сложном кейсе | «Замедляем для разбора» | +| 5:50 | При необходимости `/details` | «Инженерный FSM и сенсоры» — осторожно: визуал twin другой | +| 6:30 | Q&A | Hotkeys 1–0 для прыжка к кейсу | + +--- + +## 4. Hotkeys оператора + +| Клавиша | Действие | +| ------- | -------- | +| Space | Пауза / продолжить | +| N | Следующий кейс | +| B | Предыдущий | +| R | Reset | +| P / F | Presentation / fullscreen | +| E | Журнал событий | +| 1–0 | Быстрый переход по кейсам | + +Скорость: **0.5×–2×** в demo controls. + +--- + +## 5. Playlist (10 кейсов) — шпаргалка + +Порядок storytelling (см. `demoPlaylist.ts`): + +1. Короб → **B** +2. ЛанчБокс → **B** +3. Негабарит → **C** +4. Мелкий item (ручка) → **C** (min dims) +5. Тарелка → **D** (K) +6. Бутылка → **D** +7. Oversized+round → **C** (priority) +8. (edge / доп. кейс по playlist) +9. **Jam** +10. **Emergency stop** + +Точные id/SKU — в `src/domain/demoPlaylist.ts`. + +--- + +## 6. Если что-то пошло не так + +| Симптом | Действие | +| ------- | -------- | +| Чёрный экран / WebGL | Обновить страницу; проверить `ThreeCapabilityCheck`; снизить нагрузку (не demo на слабом GPU клиента) | +| «Не та» логика на сайте | Сравнить с preview :3101; вероятно устарел prod dist → redeploy | +| Завис после jam | R / следующий кейс; не паниковать — freeze ожидаем | +| Красные тесты утром | Не начинать показ; `npm test`, чинить на feature-ветке | +| Агент что-то пишет | `node agent/cli.mjs stop`; MVP не патчит, но stop обязателен | +| Нет сети к публичному URL | Показать loopback :3100 или preview :3101 | + +Откат кода: + +```bash +git checkout backup/pre-maximum-demo-realism-20260715 +``` + +--- + +## 7. Что не обещать жюри + +- «Настоящий CV с камеры» — нет, pseudo-CV. +- «Полный physics engine» — нет, кинематика. +- «Агент сам выкатывает в prod» — запрещено. +- «`/` и `/details` — идентичная 3D-сцена» — пока нет. + +--- + +## 8. После показа + +```bash +npm run demo:stop +node agent/cli.mjs resume # если нужен ночной dry-run +# или оставить stop до следующего окна обслуживания +``` + +Собрать feedback → занести в backlog агента / issues; не коммитить секреты. diff --git a/docs/FULL_PROJECT_AUDIT.md b/docs/FULL_PROJECT_AUDIT.md new file mode 100644 index 0000000..fb8a25b --- /dev/null +++ b/docs/FULL_PROJECT_AUDIT.md @@ -0,0 +1,199 @@ +# Полный аудит проекта — OZON Tech Sorter Simulation + +**Дата аудита:** 2026-07-15 +**Ветка:** `feature/maximum-demo-realism` (от `dan_branch` @ `e89c728`) +**Точка отката:** тег `backup/pre-maximum-demo-realism-20260715` +**Репозиторий:** `/home/coder/arhipovdan/app` +**Публичный URL:** https://arhipovdan.ru/ + +| Метка | Значение | +| ----- | -------- | +| Тип данных | Измерено / проверено в репозитории и на сервере | +| Статус | Frontend-only MVP, Track 3 (цифровой двойник сортировки) | + +--- + +## 1. Описание проекта + +**OZON Tech Sorter Simulation** — React/Vite/Three.js цифровой двойник промышленной сортировки товаров (Track 3). Система демонстрирует полный контур: + +```text +Поступление объекта +→ обнаружение (pseudo-CV / сенсоры) +→ измерения и признаки (габариты, K-roundness) +→ classifyItem (детерминированные правила) +→ управляющий сигнал (gate / pusher) +→ кинематическое перемещение +→ маршрут B / C / D или fault +→ журнал событий + метрики +``` + +**Характер MVP:** только frontend. Backend, WebSocket, БД, API-сервер — отсутствуют. Сборка — статический `dist`, раздача через nginx (Docker-образ или локальный preview). + +**Два движка демонстрации:** + +| Маршрут | Движок | Назначение | +| ------- | ------ | ---------- | +| `/` | Continuous playback + playlist | Основная демо для жюри: непрерывный 3D twin, seek/speed, hotkeys | +| `/details` | State-machine simulation | Инженерный разбор: FSM, сенсоры, PID, сценарии | + +Оба движка используют общий `classifyItem` и доменные типы, но **визуальные 3D-сцены всё ещё различаются** (`SorterDigitalTwinContinuous` vs `SorterDigitalTwin`). + +--- + +## 2. Архитектура + +```text +src/data/items.ts, scenarios.ts, resolveItem.ts, modelAssets.ts + | + v +src/domain/classifier.ts ← единый источник решения (B/C/D) + | + +-- continuousPlayback.ts + demoPlaylist.ts + measurementSystem.ts → MainPage (/) + | + +-- simulation.ts + metrics/pid/sensors → DetailsPage (/details) + | + v +React UI + R3F (Three.js) + Proof HUD / CV overlay / EventLog +``` + +**Деплой (production-контур):** + +```text +Vite build → dist/ + → Docker (node:20-alpine build + nginx:alpine runtime) + → nginx слушает 127.0.0.1:3100 на хосте OwlPrime + → cloudflared tunnel → https://arhipovdan.ru/ +``` + +В текущем окружении **нет docker CLI** для оператора; Node 20.20.2 используется для локальной сборки. Production-контейнер мог быть поднят вне этой среды — см. риск устаревшего `dist` на :3100. + +--- + +## 3. Карта модулей + +| Область | Путь | Роль | +| ------- | ---- | ---- | +| Точка входа | `src/main.tsx`, `src/App.tsx` | Router: `/`, `/details` | +| Continuous demo | `src/domain/continuousPlayback.ts` | Фазы кейса, таймлайн, fault freeze | +| Playlist | `src/domain/demoPlaylist.ts` | 10 кейсов (классификация + jam + e-stop) | +| Измерения | `src/domain/measurementSystem.ts` | Stepper/laser/stereo → `classifyItem` | +| Классификатор | `src/domain/classifier.ts` | `DIMENSION_LIMITS`, приоритет C над D | +| Кинематика | `src/domain/physicalItemMotion.ts` | Путь по сети конвейера, jitter, freeze | +| Layout | `src/domain/physicalLayout.ts`, `conveyorNetwork.ts` | Единицы мм/м, геометрия линий | +| Качество | `src/domain/qualityMode.ts` | low / medium / high / demo | +| FSM sim | `src/domain/simulation.ts` | State machine для `/details` | +| 3D continuous | `src/components/ThreeD/SorterDigitalTwinContinuous.tsx` | Основной twin | +| 3D details | `src/components/ThreeD/SorterDigitalTwin.tsx` | Инженерный twin | +| Proof UI | `CurrentProofCard`, `CVInspectionOverlay` | DIM / K / reason / RULE | +| Agent MVP | `agent/cli.mjs` | dry-run / run-once / status / stop… | +| Demo scripts | `scripts/demo-*.sh`, `scripts/agent-*.sh` | One-command ops | + +--- + +## 4. Текущий стек + +| Слой | Технология | Примечание | +| ---- | ---------- | ---------- | +| Bundler | Vite | Production build ~сотни ms | +| UI | React 19 + TypeScript | SPA | +| 3D | Three.js + @react-three/fiber + drei | Клиентский WebGL | +| Тесты | Vitest | Unit/domain; e2e Playwright — вручную | +| Контейнер | Docker multi-stage + nginx | Статический хостинг | +| Runtime на сервере | nginx :3100, cloudflared | Без pm2 в этой среде | +| Node (build) | 20.20.2 | Измерено | + +**Чего нет (подтверждено аудитом):** backend, БД, WebSocket/SSE, PM2, docker CLI в текущем shell-окружении, CI e2e, реальный ML/CV, физический движок (Rapier/Cannon и т.п.). + +**Секреты:** файл `.env` присутствует (ключи `login`, `password`, `url`). Значения **не документируются** и в отчёты не включаются. + +--- + +## 5. Выявленные проблемы + +| # | Проблема | Критичность | Влияние на демо | +| - | -------- | ----------- | --------------- | +| P1 | Dual 3D twins визуально расходятся (`/` vs `/details`) | Высокая | Зритель может не понять «одну систему» | +| P2 | Нет реального physics engine — детерминированная кинематика | Средняя | При пристальном взгляде нет столкновений/инерции «как в жизни» | +| P3 | Pseudo-CV (измерения из данных SKU, не с камеры) | Средняя | Нужен Proof HUD, иначе «анимация» | +| P4 | Production :3100 может отдавать старый `dist` до redeploy | Высокая | Публичная демо ≠ локальная сборка | +| P5 | Нет e2e в CI; Playwright только scripts | Средняя | Регрессии UI ловятся вручную | +| P6 | Agent MVP не патчит код автоматически | Низкая (by design) | Автономность ограничена verify-only | +| P7 | GPU на сервере idle; 3D на клиенте | Инфо | Серверный offscreen render не нужен | + +--- + +## 6. Критичность (сводка) + +| Уровень | Количество | Действие | +| ------- | ---------: | -------- | +| Критическая для живого показа | 1 | Синхронизировать/передеплоить prod dist | +| Высокая (доверие демо) | 2 | Унификация twin + явный proof алгоритма (уже частично сделано) | +| Средняя (техдолг) | 3 | Physics/CV/e2e — план, не блокер MVP | +| Низкая / by design | 1 | Agent без auto-merge | + +--- + +## 7. Технический долг + +1. **Два рендерера сцены** — дублирование материалов/освещения/лейаута. +2. **Кинематика вместо физики** — осознанный trade-off производительности и детерминизма. +3. **Pseudo-CV** — confidence фиксирован (0.65 в measurement path после фикса min dims); нет модели. +4. **Ручные Playwright-скрипты** в `scripts/` без интеграции в CI. +5. **Agent** — оркестратор без Implementer-патчей (verify-only). +6. **Документация историческая** в `docs/*_REPORT.md` — много итерационных отчётов; этот аудит — актуальная точка истины на 2026-07-15. + +--- + +## 8. Состояние тестов + +| Метрика | До изменений | После изменений | Тип данных | +| ------- | -----------: | --------------: | ---------- | +| Passed | 144 | **153** | Измерено | +| Файлов тестов | 14 | **16** | Измерено | +| Runner | Vitest | Vitest | — | + +Добавлено/расширено: `simulation.test.ts` (jam / emergency_stop / c_priority), тесты quality mode и связанные domain-тесты. + +**E2E:** не в CI. Скрипты Playwright/Python в `scripts/` — ручной прогон (**оценка процесса**, не автоматический gate). + +--- + +## 9. Состояние production build + +| Метрика | До | После | Тип | +| ------- | -- | ----- | --- | +| `npm run build` | OK (~391 ms) | OK | Измерено | +| Main R3F chunk | ~881 kB | (сборка OK; детальный breakdown см. PERFORMANCE_BASELINE) | Измерено / частично | +| CSS | — | 39.66 kB (gzip 8.79) | Измерено | +| Continuous twin chunk | — | 51.29 kB (gzip 13.68) | Измерено | + +Команда: `tsc -b && vite build`. Typecheck входит в build pipeline. + +--- + +## 10. Состояние демонстрации + +**Сильные стороны (после итерации maximum-demo-realism):** + +- `classifyItem` встроен в continuous playback — live classification, не только playlist override. +- Playlist 10 кейсов: B/C/D, edge, c_priority, jam, emergency_stop. +- Demo controls: seek, speed 0.5–2×, hotkeys (Space/N/B/R/P/E/F/1–0), presentation mode, event journal. +- Proof HUD: DIM pass/fail, K, classifier reason; CV overlay с RULE. +- Quality modes: low / medium / high / demo. +- Fault freeze/recover в `physicalItemMotion` + seeded jitter. +- One-command: `npm run demo:start` / `demo:health` / agent scripts. + +**Ограничения для жюри:** + +- Публичный https://arhipovdan.ru/ может ещё показывать старый билд, пока не пересобран/не перезалит контейнер на :3100. +- Визуальное отличие `/` и `/details`. +- Нет настоящего ML-CV и rigid-body physics. + +**Готовность к показу:** высокая для локального preview (`127.0.0.1:3101` через `demo:start`) при зелёных тестах; production — после явного redeploy. + +--- + +## 11. Вывод аудита + +Проект — зрелый frontend digital twin с убедительным демо-контуром и измеримой proof-логикой классификации. Главные остаточные риски для показа: **синхронизация production dist** и **визуальная унификация двух twin**. Автономный агент на сервере — **возможен с ограничениями** (1 worker, hybrid LLM, без auto-deploy). Подробности — в `SERVER_CAPACITY_REPORT.md` и `AUTONOMOUS_AI_AGENT_ARCHITECTURE.md`. diff --git a/docs/IMPLEMENTATION_REPORT.md b/docs/IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..d1c90b4 --- /dev/null +++ b/docs/IMPLEMENTATION_REPORT.md @@ -0,0 +1,173 @@ +# Отчёт о реализации — maximum-demo-realism + +**Ветка:** `feature/maximum-demo-realism` +**База:** `dan_branch` @ `e89c728` +**Backup tag:** `backup/pre-maximum-demo-realism-20260715` +**Дата:** 2026-07-15 +**Проект:** OZON Tech Sorter Simulation (Track 3) + +--- + +## 1. Цель итерации + +Максимально поднять убедительность живой демонстрации: live-классификация, safety-сценарии, proof UI, управление показом, адаптивное качество, ops-скрипты и безопасный MVP агента — без выдуманных секретов и без auto-deploy. + +--- + +## 2. Все выполненные изменения (реальные) + +| # | Изменение | Зачем | +| - | --------- | ----- | +| 1 | `classifyItem` встроен в continuous playback | Live classification, не только подписи playlist | +| 2 | `measurementSystem` → `DIMENSION_LIMITS` + `classifyItem`; min dims; confidence 0.65 | Согласованность измерений и правил | +| 3 | Playlist 8→10: jam + emergency_stop timelines | Safety story для жюри | +| 4 | `physicalItemMotion`: fault freeze/recover + seeded jitter | Правдоподобие и воспроизводимость | +| 5 | Demo controls: seek, speed 0.5–2×, hotkeys Space/N/B/R/P/E/F/1–0, presentation, event journal | Управление живым показом | +| 6 | Quality modes low/medium/high/demo | FPS на разных клиентах | +| 7 | Proof HUD: DIM pass/fail, K, reason; CV overlay RULE | Доказательство работы алгоритма | +| 8 | `simulation.test.ts`: jam / estop / c_priority | Регрессия safety/логики | +| 9 | Agent MVP `agent/cli.mjs`: dry-run\|run-once\|status\|stop\|resume\|pause\|report | Автономия уровня verify-only | +| 10 | Scripts: demo-start/stop/reset/health, agent-dry-run, agent-run-once | One-command ops | +| 11 | `resolveItem` для SKU-*-LC | Стабильность данных | + +--- + +## 3. Изменённые / затронутые области файлов + +> Точный `git diff` зависит от незакоммиченного состояния рабочей копии. Ниже — карта модулей по смыслу изменений (**проверено по коду репозитория**). + +| Область | Файлы (ключевые) | +| ------- | ---------------- | +| Continuous / classify | `src/domain/continuousPlayback.ts`, `measurementSystem.ts`, `classifier.ts` | +| Playlist / faults | `src/domain/demoPlaylist.ts`, `physicalItemMotion.ts`, `seededRng.ts` | +| UI demo | `src/pages/MainPage.tsx`, Proof/CV components, styles | +| Quality | `src/domain/qualityMode.ts`, `qualityMode.test.ts` | +| Data | `src/data/resolveItem.ts` | +| Tests | `src/domain/simulation.test.ts`, связанные domain tests | +| Agent | `agent/cli.mjs`, `agent/state/*`, `agent/reports/*` | +| Scripts | `scripts/demo-*.sh`, `scripts/agent-*.sh` | +| Package scripts | `package.json` (`demo:*`, `agent:*`) | +| Docs | этот набор `docs/*.md` аудита | + +--- + +## 4. Причины изменений (кратко) + +1. Жюри должно **видеть причинно-следственную связь** измерений → правила → маршрут. +2. Без jam/e-stop демо выглядит «идеальной анимацией». +3. Без hotkeys/presentation оператор теряет контроль на сцене. +4. Без quality modes слабые ноутбуки дают рывки и подрывают доверие. +5. Agent/scripts нужны для устойчивой эксплуатации и будущего цикла улучшений **с лимитами**. + +--- + +## 5. Результаты тестов + +| Проверка | До | После | Тип | +| -------- | -: | ----: | --- | +| Vitest passed | 144 | **153** | Измерено | +| Test files | 14 | **16** | Измерено | +| E2E CI | нет | нет | Измерено | +| Lint (отдельный) | не выделен в package | не выделен | Измерено | +| Typecheck | через `tsc -b` в build | OK вместе с build | Измерено | + +--- + +## 6. Метрики до и после + +| Метрика | До | После | Изменение | +| ------- | -: | ----: | --------: | +| Tests | 144 | 153 | +9 | +| Test files | 14 | 16 | +2 | +| Build | OK ~391 ms | OK | стабильно | +| CSS | — | 39.66 kB / gzip 8.79 | зафиксировано | +| Continuous twin chunk | — | 51.29 kB / gzip 13.68 | зафиксировано | +| Playlist size | 8 | 10 | +faults | +| Agent | нет | MVP CLI | +ops | +| Live classify on `/` | слабо | wired | +proof | + +Main R3F chunk baseline до итерации: ~881 kB (**Измерено** historically). + +--- + +## 7. Оставшиеся ограничения + +| Ограничение | Комментарий | +| ----------- | ----------- | +| Нет real physics engine | Детерминированная кинематика | +| Pseudo-CV | Synthetic measurements | +| Dual 3D twins diverge | `/` vs `/details` | +| Нет e2e в CI | Playwright вручную | +| Agent без auto-patch | By design MVP | +| Production :3100 | Может служить старый dist до redeploy | +| Нет docker CLI в этой среде | Redeploy — внешняя ops-процедура | +| GPU idle | Не используется для demo render | + +--- + +## 8. Инструкции запуска + +### Демо (preview, не путать с prod :3100) + +```bash +cd /home/coder/arhipovdan/app +npm run demo:start # build + vite preview 127.0.0.1:3101 +npm run demo:health # :3100 / :3101 / public + vitest +npm run demo:stop # остановить preview +# также: bash scripts/demo-reset.sh +``` + +### Разработка + +```bash +npm install # при необходимости +npm run dev # 127.0.0.1:3100 (vite dev — не prod nginx) +npm test +npm run build +``` + +### Агент + +```bash +npm run agent:dry-run +npm run agent:run-once +npm run agent:status +node agent/cli.mjs stop # перед живым показом +node agent/cli.mjs resume +node agent/cli.mjs report +``` + +### Откат кода + +```bash +git fetch --tags +git checkout backup/pre-maximum-demo-realism-20260715 +# или сброс ветки к e89c728 по необходимости (только осознанно) +``` + +### Production + +Публичный URL: https://arhipovdan.ru/ (nginx loopback :3100 + cloudflared). +После merge/сборки нужен **ручной redeploy** образа/dist — агент этого не делает. + +--- + +## 9. Hotkeys (оператору демо) + +| Клавиша | Действие | +| ------- | -------- | +| Space | Play / pause | +| N | Next case | +| B | Back / previous | +| R | Reset | +| P / F | Presentation / fullscreen-related | +| E | Event journal focus/toggle (UI) | +| 1–0 | Seek/jump по кейсам playlist | + +Speed: 0.5×–2× через demo controls UI. + +--- + +## 10. Вывод + +Итерация достигла измеримого улучшения демо-контура (тесты +9, playlist +faults, live classify, proof, controls, agent MVP). Критический остаточный ops-риск — **рассинхрон production dist**. Технический потолок реализма без смены архитектуры — кинематика + pseudo-CV; это задокументировано честно. diff --git a/docs/PERFORMANCE_BASELINE.md b/docs/PERFORMANCE_BASELINE.md new file mode 100644 index 0000000..4d2c44a --- /dev/null +++ b/docs/PERFORMANCE_BASELINE.md @@ -0,0 +1,117 @@ +# Базовая линия производительности (Performance Baseline) + +**Дата:** 2026-07-15 +**Ветка:** `feature/maximum-demo-realism` +**Команды:** `npm test`, `npm run build` + +Легенда: **Измерено** · **Оценка** · **Не измерено в этом прогоне** (клиентский FPS на железе жюри). + +--- + +## 1. Тесты + +| Метрика | Baseline до изменений | После изменений | Тип | +| ------- | --------------------: | --------------: | --- | +| Passed assertions/tests | 144 | **153** | Измерено | +| Test files | 14 | **16** | Измерено | +| Runner | Vitest | Vitest | — | +| Результат | green | green | Измерено | + +Дельта: +9 тестов, +2 файла (в т.ч. расширенный `simulation.test.ts`, `qualityMode.test.ts` и связанные). + +--- + +## 2. Production build + +| Метрика | До | После | Тип | +| ------- | -- | ----- | --- | +| Build status | OK | OK | Измерено | +| Build time | ~391 ms | OK (порядок сотен ms) | Измерено / частично | +| Pipeline | `tsc -b && vite build` | то же | Измерено | + +### Размеры артефактов (после) + +| Артефакт | Raw | Gzip | Тип | +| -------- | --: | ---: | --- | +| CSS | 39.66 kB | 8.79 kB | Измерено | +| Continuous twin JS chunk | 51.29 kB | 13.68 kB | Измерено | +| Main R3F-related chunk (до итерации) | ~881 kB | — | Измерено (baseline до) | + +> Точный полный rollup всех чанков «после» зависит от конкретного `vite build` output; CSS и continuous twin зафиксированы аудитом. Main Three/R3F chunk исторически доминирует по весу — это ожидаемо. + +--- + +## 3. Runtime (клиент) + +| Метрика | Ожидание / факт | Тип | +| ------- | --------------- | --- | +| Рендер 3D | WebGL в браузере клиента | Измерено (архитектура) | +| Серверный 3D | Не используется | Измерено | +| Target FPS demo/high | 60 | Измерено (presets) | +| Target FPS low/medium | 30 | Измерено | +| Shadows / effects | Выключены в presets | Измерено | +| Adaptive quality | `detectQualityMode` / `adaptQuality` | Измерено | +| Фактический FPS 1080p на GTX клиента жюри | Зависит от клиента | **Не измерено** здесь | + +Сервер (OwlPrime) при раздаче статики: load ~0.5–0.6 — **не** является bottleneck FPS (**Измерено** нагрузка хоста). + +--- + +## 4. Качество vs производительность (trade-offs) + +| Рычаг | Влияние на FPS | Влияние на реализм | +| ----- | -------------- | ------------------ | +| dprMax 1→1.5 | − | + резкость | +| antialias | − | + края | +| shadows on | −− | + контакт с лентой | +| effects/AO | −−− | + «кино» | +| maxVisibleItems | + при снижении | − плотность потока | +| rollerDetail none/sparse | + | − механика роликов | +| Static rollers (perf commit) | + | − вращение | + +Текущая политика: **сначала стабильный FPS**, затем дозированный реализм. + +--- + +## 5. Сравнение до / после (сводка) + +| Метрика | До | После | Изменение | +| ------- | -: | ----: | --------: | +| Tests passed | 144 | 153 | +9 | +| Test files | 14 | 16 | +2 | +| Build | OK ~391ms | OK | стабильно | +| Demo playlist cases | 8 | 10 | +2 (faults) | +| Live classify on `/` | playlist-heavy | wired `classifyItem` | качество демо ↑ | +| Quality modes | не как система | low…demo | +адаптация | +| CSS gzip | — | 8.79 kB | зафиксировано | +| Continuous chunk gzip | — | 13.68 kB | зафиксировано | + +--- + +## 6. Нагрузка инструментов разработки на сервере + +| Инструмент | Оценка стоимости | Рекомендация | +| ---------- | ---------------- | ------------ | +| `npm test` | Умеренная CPU, минуты | OK on-demand | +| `npm run build` | Короткая CPU | OK | +| Playwright 1 worker | Высокая RAM | Не во время демо | +| Local LLM 7B | Очень высокая VRAM/RAM | Избегать на хосте демо | +| Agent dry-run | = tests+build | Лимит 1 worker | + +--- + +## 7. Регрессионные триггеры (когда обновлять baseline) + +Переснять baseline, если: + +- включены shadows/effects в default demo; +- добавлен physics engine; +- вырос main chunk >20%; +- FPS жалобы на 1366×768; +- изменён continuous twin chunk существенно. + +--- + +## 8. Вывод + +Производительность MVP **приемлема** для статического хостинга и клиентского WebGL. Бюджет сознательно защищён отключёнными тенями/эффектами. Главный риск FPS — будущие визуальные «улучшения без лимитов», а не текущий nginx. diff --git a/docs/PHYSICS_FIDELITY_AUDIT.md b/docs/PHYSICS_FIDELITY_AUDIT.md new file mode 100644 index 0000000..9e9d3d3 --- /dev/null +++ b/docs/PHYSICS_FIDELITY_AUDIT.md @@ -0,0 +1,129 @@ +# Аудит физической достоверности + +**Дата:** 2026-07-15 +**Принцип:** не выдавать красивую анимацию за физическую симуляцию. + +Разделение слоёв: + +| Слой | Реализация в проекте | +| ---- | -------------------- | +| Визуальный реализм | R3F materials / STL / camera | +| Физическая достоверность | Детерминированная кинематика (`physicalItemMotion`, conveyor network) | +| Логика системы | `classifyItem`, FSM `simulation`, measurement stages | +| Презентационный слой | Playlist, hotkeys, presentation mode, Proof HUD | + +--- + +## 1. Единицы и layout + +| Величина | Источник | Статус | +| -------- | -------- | ------ | +| мм / м | `physicalLayout.ts` (`MM_PER_STEP`, высоты лазера, `BELT_TOP_Y`) | Измерено | +| Сеть конвейера | `conveyorNetwork.ts` / path | Измерено | +| Габариты SKU | `items.ts` + `resolveItem` (в т.ч. SKU-*-LC) | Измерено | +| Лимиты габаритов | `DIMENSION_LIMITS` в `classifier.ts` | Измерено | + +**Вывод:** единицы согласованы в domain-слое. Это **кинематическая** согласованность, не динамика Ньютона. + +--- + +## 2. Что является «настоящей» логикой vs аппроксимацией + +| Узел контура | Реальность в MVP | Тип | +| ------------ | ---------------- | --- | +| Поступление объекта | Playlist / scenario spawn | Детерминированные данные | +| Обнаружение | Phase/sensor flags + pseudo-CV | Аппроксимация | +| Измерения | Модель stepper/laser/stereo из известных размеров | Аппроксимация (честно показана в UI) | +| Классификация | `classifyItem` правила + приоритет C>D | **Реальная логика** | +| Управляющий сигнал | Gate/pusher commands | Реальная логика FSM / playback phases | +| Перемещение | Параметрический путь + speed + fault freeze | Кинематика | +| Столкновения / трение | Нет rigid-body | Отсутствует | +| Подтверждение результата | Category + event log + metrics | Реальная логика учёта | +| Jam / E-stop | Fault timelines + freeze/recover | Симулированные safety-сценарии | + +--- + +## 3. Кинематика предметов + +Модуль `physicalItemMotion.ts`: + +| Свойство | Поведение | Зачем | +| -------- | --------- | ----- | +| Path following | Движение по сегментам сети | Промышленный маршрут | +| Fault freeze | Остановка при jam/e-stop | Safety demo | +| Recover | Возобновление после reset-потока | Живой показ не «умирает» | +| Seeded jitter | Воспроизводимый шум позиции | Меньше «робот-идеал» | +| Speed scale | 0.5–2× от demo controls | Презентация | + +**Нет:** импульсов, restitution, stacking, проскальзывания ленты как friction model, расчёта момента инерции. + +--- + +## 4. Измерительная подсистема + +`measurementSystem.ts` моделирует стадии: + +`idle → leading_edge → step_counting → laser_height → stereo_width_shape → decision_ready → command_sent` + +После доработки: + +- использует `DIMENSION_LIMITS` + `classifyItem`; +- исправлены min dimensions; +- confidence **0.65** (зафиксировано в реализации measurement path); +- наружу отдаётся `classificationReason` для Proof HUD. + +Это **инженерная визуализация измерений**, а не поток с реальной камеры. + +--- + +## 5. Fault physics vs fault logic + +| Сценарий | Логика | Физика движения | +| -------- | ------ | --------------- | +| Jam | Playlist `faultType: jam` + tests | Freeze на конвейере | +| Emergency stop | `emergency_stop` | Freeze; требуется recover/reset narrative | +| C priority | `classifyItem` + sim tests | Маршрут C даже при roundness | + +Тесты: `simulation.test.ts` покрывает jam / estop / c_priority (**Измерено**: рост 144→153 тестов). + +--- + +## 6. Инварианты, которые должны держаться + +| Инвариант | Статус | +| --------- | ------ | +| Решение на continuous = `classifyItem(item)` | Wired (**Done**) | +| C приоритетнее D при oversized+round | Покрыто тестами | +| Min/max dimensions согласованы с UI Proof | Done (DIMENSION_LIMITS) | +| При fault скорость транспорта = 0 (freeze) | Done | +| Jitter детерминирован seed’ом | Done | +| Нет «телепорта» вне path network | Ожидается; регрессии ловятся visual QA вручную | + +--- + +## 7. Оценка fidelity + +| Категория | Балл (0–100) | Тип | Пояснение | +| --------- | -----------: | --- | --------- | +| Logical fidelity | 85 | Оценка | Сильный classifier + FSM | +| Kinematic fidelity | 70 | Оценка | Хороший path, слабые контакты | +| Dynamic fidelity | 25 | Оценка | Нет physics engine | +| Sensor fidelity | 55 | Оценка | Стадии есть, данные synthetic | +| Safety fidelity | 75 | Оценка | Jam/e-stop видимы и тестируются | + +*(Баллы — экспертная **оценка**, не бенчмарк.)* + +--- + +## 8. Рекомендации (без иллюзий) + +1. **Не** подключать тяжёлый physics engine перед живым показом — риск FPS. +2. Держать честные подписи: «псевдо-CV», «кинематика». +3. При желании повысить fidelity: лёгкие contact constraints только на gate/pusher (opt-in demo mode). +4. Унифицировать twin, чтобы physics story не расходилась визуально между страницами. + +--- + +## 9. Вывод + +Проект честно находится в зоне **deterministic kinematic digital twin** с **реальной rule-based классификацией**. Это достаточно для Track 3 MVP и жюри, если proof-слой включён. Называть систему «физическим симулятором с CV» без оговорок — нельзя. diff --git a/docs/RISK_REGISTER.md b/docs/RISK_REGISTER.md new file mode 100644 index 0000000..6684d50 --- /dev/null +++ b/docs/RISK_REGISTER.md @@ -0,0 +1,107 @@ +# Реестр рисков (Risk Register) + +**Проект:** OZON Tech Sorter Simulation +**Дата:** 2026-07-15 +**Ветка:** `feature/maximum-demo-realism` + +Шкала: вероятность / влияние = Low · Medium · High · Critical. +Статус: Open · Mitigated · Accepted. + +--- + +## 1. Сводная таблица + +| ID | Риск | Вероятность | Влияние | Статус | Митигация | +| -- | ---- | ----------- | ------- | ------ | --------- | +| R01 | Production :3100 / https://arhipovdan.ru/ отдаёт старый dist | High | Critical | Open | `demo:health`; ручной redeploy; показывать :3101 при сомнении | +| R02 | Визуальный разрыв `/` vs `/details` путает жюри | High | High | Open | Вести показ на `/`; unify twins в плане | +| R03 | Жюри воспринимает кинематику как «фейк» | Medium | High | Mitigated | Proof HUD + честные формулировки; jam/e-stop | +| R04 | Pseudo-CV раскрыт как «обман» | Medium | Medium | Accepted | RULE overlay + confidence; не обещать ML | +| R05 | Просадка FPS на ноутбуке жюри | Medium | High | Mitigated | quality modes; shadows/effects off | +| R06 | Регрессия classifier/min dims | Low | Critical | Mitigated | tests 153; measurement+DIMENSION_LIMITS | +| R07 | Fault freeze без recover ломает показ | Low | High | Mitigated | recover path + hotkeys R/N | +| R08 | Agent/фоновые job портят ресурсы во время демо | Medium | High | Mitigated | kill switch; runbook stop | +| R09 | Будущий auto-patch агента ломает main | Low (сейчас) | Critical | Mitigated | forbid merge/deploy; MVP verify-only | +| R10 | Утечка секретов `.env` в отчёты/логи | Low | Critical | Mitigated | policy forbidSecretAccess; не документировать values | +| R11 | Нет e2e в CI — UI регрессия незамечена | Medium | Medium | Open | ручные scripts; план CI 1 worker | +| R12 | OOM при локальной LLM + Playwright | Medium | High | Accepted (avoid) | Variant C; не совмещать | +| R13 | Disk fill отчётами/скриншотами | Low | Medium | Open | ротация reports | +| R14 | Нет docker CLI в operator env | High (факт) | Medium | Accepted | внешний deploy path документирован | +| R15 | Swap thrash под нагрузкой | Low | High | Mitigated | лимиты 1 worker; demo mode без фона | +| R16 | Расхождение playlist expectedCategory и classifyItem | Low | High | Mitigated | wire classifyItem; tests | +| R17 | STL/fallback выглядят «игрушечно» | Medium | Low | Accepted | modelAssets notes; backlog textures | +| R18 | Tunnel/cloudflared outage | Low | Critical | Open | fallback loopback :3100 / LAN preview | +| R19 | Несогласованность документации и кода | Medium | Low | Mitigated | этот пакет docs = snapshot 2026-07-15 | +| R20 | Попытка «добавить physics» перед показом → регрессия | Medium | High | Open | запрет P0-physics перед demo day | + +--- + +## 2. Детали по критическим рискам + +### R01 — Устаревший production dist + +**Симптом:** локально proof/hotkeys есть, на https://arhipovdan.ru/ — нет. +**Детектор:** сравнить UI; `demo:health`; hash файлов в контейнере (если доступен docker на хосте). +**Реакция:** ручной redeploy; на показе переключиться на проверенный preview. +**Тип данных о риске:** подтверждён аудитом как **процессный** факт («may still serve OLD dist»). + +### R09 — Автономный агент vs production + +**Симптом:** гипотетический merge/deploy без человека. +**Текущий контроль:** `forbidMergeToMain`, `forbidProductionDeploy`, нет Implementer auto-patch. +**Остаточный риск:** появится при расширении MVP без обновления safety caps. + +### R10 — Секреты + +`.env` содержит ключи `login`, `password`, `url`. Значения не подлежат публикации. Любой новый tooling обязан редact’ить env. + +--- + +## 3. Риски ёмкости (связь с SERVER_CAPACITY_REPORT) + +| Риск | Вывод | +| ---- | ----- | +| 24/7 agent | Возможен **с ограничениями** | +| Local 7B+ | Тяжело; предпочтителен API | +| Playwright | Да, 1 worker | +| CV training | Лучше external GPU | +| Server 3D | Не нужен | + +--- + +## 4. Матрица приоритета обработки + +```text +Сначала: R01 (prod sync), R18 (tunnel fallback plan) +Потом: R02 (twin unify), R11 (e2e), R13 (disk hygiene) +Следить: R05/R08 во время каждого показа +Не трогать в demo week: R20 (physics spike) +``` + +--- + +## 5. Accepted risks (осознанно) + +| ID | Почему принимаем | +| -- | ---------------- | +| R04 | Архитектура MVP Track 3 — pseudo-CV by design | +| R12 | Не запускаем локальную LLM 24/7 | +| R14 | Ограничение окружения; обход через внешний deploy | +| R17 | ROI текстур ниже proof/controls | + +--- + +## 6. Триггеры пересмотра реестра + +- Смена хоста/GPU/RAM. +- Включение Implementer auto-patch. +- Добавление physics engine. +- Подключение реального CV inference. +- Появление CI e2e. +- Инцидент на живом показе. + +--- + +## 7. Вывод + +Главный необработанный операционный риск для жюри — **R01 (старый prod dist)**. Технические риски демо-контура в основном **смягчены** итерацией maximum-demo-realism. Риски полной автономии агента **заблокированы политикой**, пока MVP verify-only. diff --git a/docs/SERVER_CAPACITY_REPORT.md b/docs/SERVER_CAPACITY_REPORT.md new file mode 100644 index 0000000..a35a28f --- /dev/null +++ b/docs/SERVER_CAPACITY_REPORT.md @@ -0,0 +1,176 @@ +# Отчёт о ёмкости сервера + +**Хост:** OwlPrime (контейнеризованный Ubuntu 24.04) +**Дата измерений:** 2026-07-15 +**Приложение:** OZON Tech Sorter Simulation → nginx `127.0.0.1:3100`, tunnel → https://arhipovdan.ru/ + +Легенда типов данных: **Измерено** = получено диагностикой на сервере; **Оценка** = вывод по мощности без полного бенчмарка нагрузки. + +--- + +## 1. Точные характеристики сервера + +| Ресурс | Фактическое значение | Тип | +| ------ | -------------------: | --- | +| CPU | Intel i5-9400F, 6 ядер, 2.9–4.1 GHz | Измерено | +| RAM | 15 GiB (≈13 GiB available) | Измерено | +| Swap | 15 GiB | Измерено | +| Диск | NVMe ~452 G, ~201 G свободно (~54% used) | Измерено | +| GPU | 2× NVIDIA GTX 1080 8 GB, driver 580.126.09, CUDA 13.0 | Измерено | +| GPU load | idle | Измерено | +| Load average | ~0.5–0.6 | Измерено | +| OS | Ubuntu 24.04 (containerized) | Измерено | +| Node (build env) | 20.20.2 | Измерено | +| Docker CLI в этой среде | отсутствует | Измерено | +| PM2 | отсутствует | Измерено | +| App listen | nginx 127.0.0.1:3100 | Измерено | +| Public | cloudflared → https://arhipovdan.ru/ | Измерено | +| Git repo | `/home/coder/arhipovdan/app` | Измерено | + +--- + +## 2. Текущая нагрузка + +| Показатель | Значение | Вывод | +| ---------- | -------: | ----- | +| Load avg | 0.5–0.6 при 6 cores | Большой запас CPU | +| GPU | idle | Можно зарезервировать под LLM/CV, но с осторожностью | +| Disk free | ~201 G | Достаточно для node_modules, dist, Playwright cache, артефактов агента | +| Swap | 15 GiB | Есть подушка, но swap thrash недопустим для демо | + +**Оценка:** сервер сейчас в «лёгком» режиме (статика nginx + tunnel). Основная 3D-нагрузка — на **клиентском** WebGL, не на сервере. + +--- + +## 3. Доступные ресурсы (безопасный остаток) + +| Ресурс | Оценка свободного бюджета | Тип | +| ------ | ------------------------: | --- | +| CPU | 4–5 ядер можно отдавать фону при лимите 1 тяжёлого worker | Оценка | +| RAM | ~8–10 GiB «мягкий» бюджет до давления на swap | Оценка | +| VRAM (2×8 GB) | теоретически хватает на 7B quant; на практике тяжело и конкурирует | Оценка | +| Disk | десятки GB под кэши/отчёты без риска | Оценка | + +--- + +## 4. Допустимые фоновые процессы + +| Процесс | Допустимо 24/7? | Условия | +| ------- | --------------- | ------- | +| nginx + cloudflared (prod static) | Да | Без изменения без явного deploy | +| Vite preview :3101 (demo) | Краткосрочно | Не путать с prod :3100 | +| Vitest / build по запросу | Да | Последовательно, не параллельно с LLM | +| Playwright Chromium (1 worker) | Да, по расписанию | Не во время живого показа жюри | +| Agent MVP (verify-only) | Да, с лимитами | См. ниже | +| Локальная LLM 7B+ | Только эксперименты | Предпочтительно внешний API | +| CV training | Нет как 24/7 | Вынести на dedicated GPU / внешний | +| Server-side Three.js offscreen | Возможно, не нужно | Клиентский WebGL достаточен | + +--- + +## 5. Возможность локальных моделей (LLM) + +| Вариант | Вердикт | Комментарий | +| ------- | ------- | ----------- | +| Локальная 7B+ на 2×1080 | **Возможна технически, тяжело** | VRAM суммарно 16 GB; квантизация нужна; CPU/RAM конкурируют с Playwright | +| Внешний LLM API | **Рекомендуется** | Hybrid Variant C | +| Полностью локальный агент (Variant A) | Не рекомендуется на этом хосте | Риск OOM / деградации демо | + +**Вывод:** локальный инференс — опциональный research-path; production-агент должен использовать **внешний API** с бюджетом. + +--- + +## 6. Возможность Playwright / Chromium + +| Критерий | Вердикт | +| -------- | ------- | +| CPU + RAM | Достаточно (**Измерено** ресурсы; **Оценка** нагрузки) | +| Параллелизм | **1 worker** | +| 24/7 непрерывный прогон | Нежелателен; лучше nightly / по событию | +| Конфликт с живой демо | Высокий — не запускать во время показа | + +--- + +## 7. Возможность автономного агента + +**Однозначный вывод: возможен с ограничениями.** + +| Параметр | Лимит | +| -------- | ----- | +| Parallel workers | 1 | +| LLM | Hybrid (внешний API primary) | +| Auto merge to main | Запрещено | +| Auto production deploy | Запрещено | +| Режим MVP | verify-only (dry-run / run-once), без auto-patch | +| Kill switch | `agent/state/KILL` | + +Подробности: `AI_AGENT_SAFETY_LIMITS.md`, `AUTONOMOUS_AI_AGENT_ARCHITECTURE.md`. + +--- + +## 8. Рекомендуемые лимиты + +| Лимит | Значение | Источник | +| ----- | -------: | -------- | +| maxParallelWorkers | 1 | Agent MVP + ёмкость | +| maxCycleMinutes | 25 | `agent/cli.mjs` LIMITS | +| maxChangedFiles | 12 | Agent LIMITS | +| maxDiffLines | 800 | Agent LIMITS | +| dailyLlmBudgetUsd | 5 | Agent LIMITS | +| Playwright workers | 1 | Capacity | +| CPU soft cap для агента | ≤50% одного ядра в idle-poll; burst на test/build | Оценка | +| RAM soft cap агент+Chromium | ≤4 GiB | Оценка | +| Запрет | merge main, prod deploy, чтение секретов в логи | Policy | + +--- + +## 9. Режимы эксплуатации + +| Режим | Что запущено | Параллелизм | Назначение | +| ----- | ------------ | ----------: | ---------- | +| Минимальный безопасный | nginx :3100 + tunnel | 0 background jobs | Стабильный публичный показ | +| Оптимальный | + редкие `agent dry-run`, nightly tests | 1 | Поддержание качества | +| Максимальный | + Playwright 1w + внешний LLM | 1 | Исследования (не во время демо) | +| Демонстрация | Только prod или preview; агент STOP | 0 | Живое жюри | +| Автономное AI-улучшение | Agent + external LLM, ветки feature/*, no deploy | 1 | Улучшения с human gate | + +--- + +## 10. Сводная таблица ёмкости + +| Ресурс | Фактическое значение | Текущая нагрузка | Безопасный лимит | Вывод | +| ------ | -------------------: | ---------------: | ---------------: | ----- | +| CPU 6c | i5-9400F | load ~0.5–0.6 | 1 тяжёлый worker | Запас есть | +| RAM 15 GiB | ≈13 available | низкая | ≤4 GiB агент+browser | OK | +| Swap 15 GiB | — | не давить | Избегать thrash | OK как подушка | +| Disk ~452G | ~201G free | 54% used | Оставить ≥50G free | OK | +| GPU 2×1080 | idle | 0% | Не для prod demo loop | LLM/CV опционально | +| Playwright | — | нет | 1 worker | Да | +| Agent 24/7 | — | MVP | с ограничениями | Да | +| Local LLM 7B+ | — | нет | эксперимент | Тяжело; лучше API | +| CV training | — | нет | external | Не на этом хосте 24/7 | +| Server 3D | — | нет | не требуется | Клиент WebGL | + +--- + +## 11. Риски ёмкости + +| Риск | Вероятность | Влияние | Митигация | +| ---- | ----------- | ------- | --------- | +| OOM при локальной LLM + Playwright | Средняя | Падение демо | Не совмещать; API LLM | +| Swap thrash во время показа | Низкая | Лаги tunnel/nginx | Режим «Демонстрация» = stop agent | +| Disk fill артефактами | Низкая | Сборка падает | Ротация `agent/reports`, screenshot dirs | +| Старый dist на :3100 | Высокая (процесс) | Жюри видит не то | Явный redeploy после merge | +| Нет docker CLI здесь | Факт | Сложнее ops | Документировать внешний deploy path | + +--- + +## 12. Нужен ли дополнительный сервер? + +| Вопрос | Ответ | +| ------ | ----- | +| Отдельный staging | Желателен при частом агенте; на текущем хосте можно эмулировать preview :3101 | +| Dedicated GPU server | Да, если цель — CV training / тяжёлый local LLM | +| Доп. сервер для static demo | Нет — текущий nginx достаточен | + +**Итог:** для demo + ограниченного агента текущий сервер **достаточен**. Для тяжёлого ML — вынести. diff --git a/docs/VISUAL_REALISM_AUDIT.md b/docs/VISUAL_REALISM_AUDIT.md new file mode 100644 index 0000000..e7e0c78 --- /dev/null +++ b/docs/VISUAL_REALISM_AUDIT.md @@ -0,0 +1,115 @@ +# Аудит визуального реализма + +**Дата:** 2026-07-15 +**Стек визуализации:** Three.js + React Three Fiber + drei (клиентский WebGL) +**Решение по стеку:** **остаёмся на R3F** — смена на Babylon/Unity Web не обоснована; текущий стек уже даёт industrial twin и STL. + +Легенда: **Измерено** (код/сборка/тесты) · **Оценка** (экспертный вывод без GPU-профайлера браузера в этом отчёте). + +--- + +## 1. Цель аудита + +Отделить: + +1. Что уже выглядит как оборудование. +2. Что всё ещё «игровое» или декоративное. +3. Что критично для доверия жюри за первые 10 секунд. + +--- + +## 2. Текущая визуальная архитектура + +| Компонент | Continuous `/` | Details `/details` | +| --------- | -------------- | ------------------ | +| Scene root | `SorterDigitalTwinContinuous` | `SorterDigitalTwin` | +| Items | `PhysicalPlaybackItem` / motion domain | `Item3D` + sim state | +| Conveyor | `Conveyor3D` + network layout | Аналогичные примитивы, другой wiring | +| Actuators | Gate/pusher анимации | State-driven actuators | +| Sensors | Overlay + measurement HUD | `SensorRig3D` / panels | +| Models | STL через `modelAssets` + fallbacks | То же семейство ассетов | +| Camera | Cinematic / demo camera path | Свободная/инженерная | + +**Проблема parity:** два twin визуально расходятся — разные акценты освещения/композиции/детализации. Для жюри это снижает ощущение «одной реальной линии». + +--- + +## 3. Чек-лист визуальных факторов + +| Фактор | Статус | Комментарий | Тип | +| ------ | ------ | ----------- | --- | +| Геометрия / пропорции | Частично OK | Layout в мм через `physicalLayout` | Измерено | +| Масштаб сцены | OK | Единая физическая раскладка | Измерено | +| Камера / перспектива | OK на `/` | Cinematic playback | Измерено | +| Focal length / DoF | Минимально | DoF не форсируется (мешает чтению) | Оценка | +| Освещение | Среднее | Достаточно для читаемости; не studio HDRI | Оценка | +| Контактные тени | Ограничено | `shadows: false` в quality presets (perf) | Измерено | +| AO / post FX | Выкл. в presets | `effectsEnabled: false` | Измерено | +| Материалы PBR | Базовые | Шероховатость/металл упрощены | Оценка | +| Текстуры / загрязнения | Слабо | Нет сильного wear layer | Оценка | +| Края / bevel | Частично | STL + примитивы | Оценка | +| Движение ленты / ролики | OK с trade-off | Static/sparse rollers в perf-режимах | Измерено (ветка perf) | +| Приводы / задержки | Симулированы кинематикой | Не servo-physics | Измерено | +| Motion blur | Нет | Сознательно | Измерено | +| Anti-aliasing | По режиму | low/medium off; high/demo on | Измерено | +| Звук / вибрация | Нет | Не реализовано | Измерено | +| Заполнение накопителей | Упрощено | B receiver timing учтён в истории perf | Измерено (история) | +| CV overlay | Есть | RULE line + inspection | Измерено | +| Presentation declutter | Есть | Скрытие панелей в presentation | Измерено | + +--- + +## 4. Режимы качества + +Источник: `src/domain/qualityMode.ts` (**Измерено**). + +| Mode | dprMax | AA | Shadows | maxItems | rollers | target FPS | +| ---- | -----: | -- | ------- | -------: | ------- | ---------: | +| low | 1 | нет | нет | 3 | none | 30 | +| medium | 1.25 | нет | нет | 5 | sparse | 30 | +| high | 1.5 | да | нет | 6 | full | 60 | +| demo | 1.5 | да | нет | 6 | full | 60 | + +Автовыбор: по ширине viewport и опционально по recent FPS; `adaptQuality` понижает режим при просадке. + +**Целевая демо:** стабильные 60 FPS при возможности, иначе стабильные 30 без рывков (**требование брифа**; фактический FPS на клиенте жюри — **не измерен в этом аудите на всех устройствах**). + +--- + +## 5. Что выглядит убедительно + +- Непрерывный путь товара от входа до B/C/D с подписями зон. +- STL-модели там, где ассет доступен; осознанные fallbacks для тяжёлых STL. +- Proof HUD и CV overlay связывают картинку с решением. +- Fault визуально останавливает поток (freeze). +- Presentation mode убирает engineering chrome. + +--- + +## 6. Что снижает доверие («игровое») + +| Симптом | Почему заметно | Рекомендация | +| ------- | -------------- | ------------ | +| Разный вид `/` и `/details` | «Две разные игрушки» | Shared scene kit (Open) | +| Нет теней/AO | Плоские объекты | Осторожно включить в demo на мощных GPU клиентов | +| Pseudo-CV bbox | Идеальная геометрия | Оставить RULE + confidence честно | +| Отсутствие звука | Тихая «сцена» | Опциональный ambient loop (низкий приоритет) | +| Идеально ровное движение без контактов | Нет micro-collisions | Seeded jitter уже частично компенсирует | + +--- + +## 7. Критичность для показа + +| Приоритет | Находка | Действие | +| --------- | ------- | -------- | +| P0 | Proof + classification visible | Сделано | +| P0 | Presentation + hotkeys | Сделано | +| P1 | Twin visual unify | Open | +| P2 | Shadows/AO в demo-only | Open, perf-gated | +| P3 | Sound / wear textures | Backlog | + +--- + +## 8. Вывод + +Визуальный стек **достаточен**. Узкое место не «не тот движок», а **согласованность двух сцен** и **сдержанный shading ради FPS**. Для жюри важнее читаемый контур решения, чем ray-traced реализм. Следующий максимум ROI: унификация twin + актуальный production dist. diff --git a/package.json b/package.json index 017178a..8424d23 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,13 @@ "dev": "vite --host 127.0.0.1 --port 3100", "build": "tsc -b && vite build", "preview": "vite preview --host 127.0.0.1 --port 3100", - "test": "vitest run" + "test": "vitest run", + "agent:dry-run": "node agent/cli.mjs dry-run", + "agent:run-once": "node agent/cli.mjs run-once", + "agent:status": "node agent/cli.mjs status", + "demo:start": "bash scripts/demo-start.sh", + "demo:stop": "bash scripts/demo-stop.sh", + "demo:health": "bash scripts/demo-health.sh" }, "dependencies": { "@react-three/drei": "^10.7.7", diff --git a/scripts/agent-dry-run.sh b/scripts/agent-dry-run.sh new file mode 100755 index 0000000..3556a9f --- /dev/null +++ b/scripts/agent-dry-run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +exec node agent/cli.mjs dry-run diff --git a/scripts/agent-run-once.sh b/scripts/agent-run-once.sh new file mode 100755 index 0000000..0e48c2c --- /dev/null +++ b/scripts/agent-run-once.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +exec node agent/cli.mjs run-once diff --git a/scripts/demo-health.sh b/scripts/demo-health.sh new file mode 100755 index 0000000..8a8fdde --- /dev/null +++ b/scripts/demo-health.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +ok=0 +fail=0 + +check() { + local name="$1" + local url="$2" + if curl -sf -o /dev/null -w "%{http_code}" "$url" | grep -qE '200|304'; then + echo "[ok] $name ($url)" + ok=$((ok + 1)) + else + echo "[fail] $name ($url)" + fail=$((fail + 1)) + fi +} + +echo "[demo-health] checking endpoints..." +check "prod-loopback" "http://127.0.0.1:3100/" || true +check "preview" "http://127.0.0.1:3101/" || true +check "public" "https://arhipovdan.ru/" || true + +echo "[demo-health] running unit tests..." +if npm test >/tmp/demo-health-tests.log 2>&1; then + echo "[ok] vitest" + ok=$((ok + 1)) +else + echo "[fail] vitest — see /tmp/demo-health-tests.log" + fail=$((fail + 1)) +fi + +echo "[demo-health] summary: ok=$ok fail=$fail" +[[ "$fail" -eq 0 ]] diff --git a/scripts/demo-reset.sh b/scripts/demo-reset.sh new file mode 100755 index 0000000..1900a83 --- /dev/null +++ b/scripts/demo-reset.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +echo "[demo-reset] stopping preview..." +"${ROOT}/scripts/demo-stop.sh" || true +echo "[demo-reset] clearing agent kill switch / temp preview logs..." +rm -f "${ROOT}/.demo-preview.log" "${ROOT}/agent/state/KILL" || true +echo "[demo-reset] done — open app and press R (or Stop) to reset demo state in browser" diff --git a/scripts/demo-start.sh b/scripts/demo-start.sh new file mode 100755 index 0000000..3270858 --- /dev/null +++ b/scripts/demo-start.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "[demo-start] installing deps if needed..." +if [[ ! -d node_modules ]]; then + npm ci +fi + +echo "[demo-start] building production bundle..." +npm run build + +PID_FILE="${ROOT}/.demo-preview.pid" +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "[demo-start] preview already running (pid $(cat "$PID_FILE"))" + exit 0 +fi + +echo "[demo-start] starting vite preview on 127.0.0.1:3101 (dev preview; prod may already be on :3100)..." +nohup npm run preview -- --host 127.0.0.1 --port 3101 > "${ROOT}/.demo-preview.log" 2>&1 & +echo $! > "$PID_FILE" +sleep 2 +curl -sf "http://127.0.0.1:3101/" >/dev/null && echo "[demo-start] OK http://127.0.0.1:3101/" || { + echo "[demo-start] preview not ready yet — check .demo-preview.log" + exit 1 +} diff --git a/scripts/demo-stop.sh b/scripts/demo-stop.sh new file mode 100755 index 0000000..8dbe4ca --- /dev/null +++ b/scripts/demo-stop.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PID_FILE="${ROOT}/.demo-preview.pid" +if [[ -f "$PID_FILE" ]]; then + pid="$(cat "$PID_FILE")" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" || true + echo "[demo-stop] stopped pid $pid" + fi + rm -f "$PID_FILE" +else + echo "[demo-stop] no preview pid file" +fi +pkill -f "vite preview --host 127.0.0.1 --port 3101" 2>/dev/null || true diff --git a/src/App.tsx b/src/App.tsx index 1926e04..d524e47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,12 @@ import { resumePlayback, stopPlayback, updatePlayback, + seekToCase, + seekNextCase, + seekPrevCase, + setPlaybackSpeed, type ContinuousPlaybackState, + type PlaybackSpeed, } from './domain/continuousPlayback'; function scrollToId(id: string) { @@ -236,6 +241,22 @@ function AppContent() { setPlayback(stopPlayback); }, []); + const handleSeekCase = useCallback((index: number) => { + setPlayback((prev) => seekToCase(prev, index)); + }, []); + + const handleSeekNext = useCallback(() => { + setPlayback((prev) => seekNextCase(prev)); + }, []); + + const handleSeekPrev = useCallback(() => { + setPlayback((prev) => seekPrevCase(prev)); + }, []); + + const handleSetSpeed = useCallback((speed: PlaybackSpeed) => { + setPlayback((prev) => setPlaybackSpeed(prev, speed)); + }, []); + return ( } /> diff --git a/src/components/CVInspectionOverlay.tsx b/src/components/CVInspectionOverlay.tsx index 080c5d8..5613943 100644 --- a/src/components/CVInspectionOverlay.tsx +++ b/src/components/CVInspectionOverlay.tsx @@ -40,6 +40,8 @@ export default function CVInspectionOverlay({ data, visible }: CVInspectionOverl command, cPriorityApplied, isLowConfidence, + classificationReason, + classificationLabel, itemTitle, itemDimensions, } = data; @@ -177,6 +179,16 @@ export default function CVInspectionOverlay({ data, visible }: CVInspectionOverl )} + {classificationReason && ( +
+ RULE + + {classificationLabel ? `${classificationLabel} — ` : ''} + {classificationReason} + +
+ )} +
CMD (() => { if (type === 'cylinder' || type === 'sphere') { const r = Math.max(w, d) / 2; @@ -81,29 +78,32 @@ export const PhysicalPlaybackItem = memo(function PhysicalPlaybackItem({ caseData, elapsedMs, slotIndex = 0, + jitter, }: { caseData: PlaylistCase; elapsedMs: number; slotIndex?: number; + jitter?: { x: number; z: number; yaw: number }; }) { - const itemId = caseData.itemId.replace('-LC', ''); - const itemData = useMemo(() => ITEMS.find(i => i.id === itemId) ?? ITEMS[0], [itemId]); + const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]); + const classification = useMemo(() => classifyItem(itemData), [itemData]); + const itemId = itemData.id.replace('-LC', ''); const asset = getModelAsset(itemId); - const dims = getRenderedItemDimensions(itemData.dimensionsMm); const pose = getPhysicalItemPose({ caseId: caseData.id, slotIndex, dimensionsMm: itemData.dimensionsMm, - targetCategory: caseData.expectedCategory, + targetCategory: classification.category, elapsedMs, + faultType: caseData.faultType, + jitter, }); const { position, rotation, phase, surface, isSettled } = pose; const isRouting = phase === 'routing'; const useSimplifiedMesh = phase === 'settled' || isSettled; - // Contact shadow only while the item rides a flat transport surface. const onTransport = surface === 'main_belt' || surface === 'inspection_station' || surface === 'routing_junction' @@ -111,13 +111,14 @@ export const PhysicalPlaybackItem = memo(function PhysicalPlaybackItem({ if (elapsedMs < 0) return null; - const routeAccent = COLORS[caseData.expectedCategory] ?? COLORS.sensorAccent; + const routeAccent = COLORS[classification.category] ?? COLORS.sensorAccent; const material = ITEM_MATERIALS[itemId] ?? { color: '#d8c3a5', roughness: 0.75 }; - const bodyColor = useSimplifiedMesh ? (material.color ?? '#b8b2a8') : material.color; + const bodyColor = phase === 'fault' ? '#ef4444' : useSimplifiedMesh ? (material.color ?? '#b8b2a8') : material.color; const accentColor = useSimplifiedMesh ? '#94a3b8' : routeAccent; - const emissiveIntensity = useSimplifiedMesh ? 0.01 : (isRouting ? 0.12 : 0.03); + const emissiveIntensity = useSimplifiedMesh + ? 0.01 + : (phase === 'fault' ? 0.25 : isRouting ? 0.12 : 0.03); const metalness = material.metalness ?? 0.05; - const useSTL = !useSimplifiedMesh && asset?.loaderType === 'stl' && asset?.frontendAssetPath; const stlPath = asset?.frontendAssetPath ?? ''; const fallbackType = asset?.fallbackPrimitive ?? 'box'; diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index d5a3510..7cb3f06 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -22,7 +22,7 @@ import { ITEMS } from '../../data/items'; import type { Category } from '../../domain/types'; import { PhysicalPlaybackItem } from './PhysicalPlaybackItem'; import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../../domain/demoPlaylist'; -import { CASE_DURATION_MS } from '../../domain/continuousPlayback'; +import { cumulativePlaylistDurationMs, getPlaylistCaseDurationMs } from '../../domain/continuousPlayback'; import { getCameraConfig, smoothCameraTransition, @@ -133,13 +133,14 @@ function CinematicCameraController({ // Get item position for camera following const itemPosition = useMemo(() => { if (playback.status === 'idle') return null; - const currentItemElapsed = playback.totalElapsedMs - (playback.currentCaseIndex * CASE_DURATION_MS); const pose = getPhysicalItemPose({ caseId: playback.currentCase.id, - dimensionsMm: { width: 300, depth: 200, height: 200 }, // rough approx for camera target + dimensionsMm: { width: 300, depth: 200, height: 200 }, targetCategory: playback.targetCategory, - elapsedMs: currentItemElapsed, - slotIndex: playback.currentCaseIndex + elapsedMs: playback.caseElapsedMs, + slotIndex: playback.currentCaseIndex, + faultType: playback.currentCase.faultType, + jitter: playback.positionJitter, }); return pose.position; }, [playback]); @@ -1211,8 +1212,9 @@ function ContinuousScene({ // Compute physical items based on elapsed time. Cap the number of // simultaneously rendered items to keep the scene lightweight. - const { totalElapsedMs, currentCase, currentCaseIndex } = playback; - const casesSpawned = Math.floor(totalElapsedMs / CASE_DURATION_MS) + 1; + // Use cumulative playlist durations (cases may differ: jam / e-stop). + const { totalElapsedMs, currentCase, currentCaseIndex, caseElapsedMs, positionJitter } = playback; + const casesSpawned = currentCaseIndex + 1; const startIndex = Math.max(0, casesSpawned - MAX_VISIBLE_ITEMS); const sceneItems = useMemo(() => { @@ -1220,16 +1222,20 @@ function ContinuousScene({ for (let i = startIndex; i < casesSpawned; i++) { const playlistIndex = i % PLAYLIST_LENGTH; const caseData = DEMO_PLAYLIST[playlistIndex]; - const elapsedMs = totalElapsedMs - (i * CASE_DURATION_MS); + const caseStart = cumulativePlaylistDurationMs(i); + const elapsedMs = i === currentCaseIndex + ? caseElapsedMs + : getPlaylistCaseDurationMs(caseData) + 1; // settled past end for prior cases items.push({ id: `item-${i}-${caseData.id}`, slotIndex: i, caseData, - elapsedMs, + elapsedMs: Math.max(0, elapsedMs), + caseStart, }); } return items; - }, [totalElapsedMs, casesSpawned, startIndex]); + }, [caseElapsedMs, casesSpawned, startIndex, currentCaseIndex]); // Get item data for the current case const itemId = currentCase.itemId.replace('-LC', ''); @@ -1239,13 +1245,15 @@ function ContinuousScene({ const itemScale = Math.max(dims.width, dims.depth, dims.height); // Get item position for measurement visualization using physical model - const currentItemElapsed = totalElapsedMs - (currentCaseIndex * CASE_DURATION_MS); + const currentItemElapsed = caseElapsedMs; const currentPose = getPhysicalItemPose({ caseId: currentCase.id, dimensionsMm: itemData.dimensionsMm, targetCategory: category, elapsedMs: currentItemElapsed, - slotIndex: currentCaseIndex + slotIndex: currentCaseIndex, + faultType: currentCase.faultType, + jitter: positionJitter, }); const itemPos = currentPose.position; const activeRoute = currentPose.activeRoute; @@ -1377,6 +1385,7 @@ function ContinuousScene({ caseData={item.caseData} elapsedMs={item.elapsedMs} slotIndex={item.slotIndex} + jitter={item.slotIndex === currentCaseIndex ? positionJitter : undefined} /> ))} diff --git a/src/data/resolveItem.ts b/src/data/resolveItem.ts new file mode 100644 index 0000000..b4dbb16 --- /dev/null +++ b/src/data/resolveItem.ts @@ -0,0 +1,29 @@ +/** + * Resolve playlist / scenario item ids, including low-confidence variants (SKU-*-LC). + */ + +import type { Item } from '../domain/types'; +import { getItem, ITEMS } from './items'; + +/** Strip -LC suffix and apply low confidence when present. */ +export function resolveItem(itemId: string): Item { + if (itemId.endsWith('-LC')) { + const baseId = itemId.slice(0, -3); + const base = getItem(baseId); + return { + ...base, + id: itemId, + confidence: Math.min(base.confidence, 0.58), + }; + } + return getItem(itemId); +} + +export function findItemOrFallback(itemId: string): Item { + try { + return resolveItem(itemId); + } catch { + const stripped = itemId.replace(/-LC$/, ''); + return ITEMS.find((i) => i.id === stripped) ?? ITEMS[0]; + } +} diff --git a/src/domain/cinematicCamera.ts b/src/domain/cinematicCamera.ts index 1210b53..beb289d 100644 --- a/src/domain/cinematicCamera.ts +++ b/src/domain/cinematicCamera.ts @@ -123,6 +123,11 @@ export function getCameraModeForPhase(phase: CasePhase): CameraMode { return 'resultZone'; case 'clear_gap': return 'nextItemReset'; + case 'fault_hold': + case 'emergency_hold': + return 'routingWide'; + case 'recover': + return 'overview'; default: return 'overview'; } diff --git a/src/domain/continuousPlayback.test.ts b/src/domain/continuousPlayback.test.ts index 7282f52..64187bd 100644 --- a/src/domain/continuousPlayback.test.ts +++ b/src/domain/continuousPlayback.test.ts @@ -1,3 +1,7 @@ +/** + * Continuous Playback Engine tests + playlist ↔ classifier consistency. + */ + import { describe, it, expect } from 'vitest'; import { createPlaybackState, @@ -6,48 +10,44 @@ import { resumePlayback, stopPlayback, updatePlayback, + seekToCase, + setPlaybackSpeed, CASE_DURATION_MS, + assertPlaylistClassifierConsistency, type ContinuousPlaybackState, } from './continuousPlayback'; import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from './demoPlaylist'; +import { classifyItem } from './classifier'; +import { resolveItem } from '../data/resolveItem'; describe('demoPlaylist', () => { - it('has exactly 8 cases', () => { - expect(PLAYLIST_LENGTH).toBe(8); - expect(DEMO_PLAYLIST.length).toBe(8); + it('has classification + safety cases', () => { + expect(PLAYLIST_LENGTH).toBe(10); + expect(DEMO_PLAYLIST.length).toBe(10); }); it('has no duplicate case ids', () => { - const ids = DEMO_PLAYLIST.map(c => c.id); - const uniqueIds = new Set(ids); - expect(uniqueIds.size).toBe(ids.length); + const ids = DEMO_PLAYLIST.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); }); - it('has no mixed category (all cases have specific B/C/D)', () => { - const validCategories = ['B', 'C', 'D']; - DEMO_PLAYLIST.forEach(c => { - expect(validCategories).toContain(c.expectedCategory); - }); + it('all non-fault cases match live classifyItem', () => { + const mismatches = assertPlaylistClassifierConsistency(); + expect(mismatches).toEqual([]); }); - it('all commands would be ROUTE_TO_B/C/D', () => { - DEMO_PLAYLIST.forEach(c => { - const expectedCommand = `ROUTE_TO_${c.expectedCategory}`; - expect(['ROUTE_TO_B', 'ROUTE_TO_C', 'ROUTE_TO_D']).toContain(expectedCommand); - }); - }); - - it('low_confidence case has warning but final category is B/C/D', () => { - const lowConfidence = DEMO_PLAYLIST.find(c => c.id === 'low_confidence'); + it('low_confidence case has warning but category from rules', () => { + const lowConfidence = DEMO_PLAYLIST.find((c) => c.id === 'low_confidence'); expect(lowConfidence).toBeDefined(); expect(lowConfidence!.warning).toBeDefined(); - expect(['B', 'C', 'D']).toContain(lowConfidence!.expectedCategory); + const result = classifyItem(resolveItem(lowConfidence!.itemId)); + expect(result.category).toBe('B'); + expect(result.warnings.length).toBeGreaterThan(0); }); - it('has correct case order: B, B, C, C, D, D, C, B', () => { - const expectedOrder = ['B', 'B', 'C', 'C', 'D', 'D', 'C', 'B']; - const actualOrder = DEMO_PLAYLIST.map(c => c.expectedCategory); - expect(actualOrder).toEqual(expectedOrder); + it('includes jam and emergency_stop safety cases', () => { + expect(DEMO_PLAYLIST.some((c) => c.faultType === 'jam')).toBe(true); + expect(DEMO_PLAYLIST.some((c) => c.faultType === 'emergency_stop')).toBe(true); }); }); @@ -58,112 +58,75 @@ describe('continuousPlayback', () => { expect(state.currentCaseIndex).toBe(0); }); - it('startPlayback sets status to running', () => { - const state = createPlaybackState(); - const running = startPlayback(state); - expect(running.status).toBe('running'); - expect(running.currentCaseIndex).toBe(0); + it('startPlayback sets status to running and classifies via classifyItem', () => { + const state = startPlayback(createPlaybackState()); + expect(state.status).toBe('running'); + expect(state.classification).not.toBeNull(); + expect(state.targetCategory).toBe(state.classification!.category); }); it('pausePlayback sets status to paused', () => { - let state = createPlaybackState(); - state = startPlayback(state); + let state = startPlayback(createPlaybackState()); state = pausePlayback(state); expect(state.status).toBe('paused'); }); it('resumePlayback sets status back to running', () => { - let state = createPlaybackState(); - state = startPlayback(state); + let state = startPlayback(createPlaybackState()); state = pausePlayback(state); state = resumePlayback(state); expect(state.status).toBe('running'); }); it('stopPlayback resets to idle', () => { - let state = createPlaybackState(); - state = startPlayback(state); - state = updatePlayback(state, 1000); + let state = startPlayback(createPlaybackState()); state = stopPlayback(state); expect(state.status).toBe('idle'); - expect(state.currentCaseIndex).toBe(0); - expect(state.totalElapsedMs).toBe(0); }); - it('advances from case 1 to case 2 after case duration', () => { - let state = createPlaybackState(); - state = startPlayback(state); - expect(state.currentCaseIndex).toBe(0); - - // Simulate passing entire case duration by updating in steps - // Each update processes one phase at a time + it('advances to next case after case duration', () => { + let state = startPlayback(createPlaybackState()); let totalTime = 0; while (totalTime < CASE_DURATION_MS * 1.5 && state.currentCaseIndex === 0) { state = updatePlayback(state, 100); totalTime += 100; } - expect(state.currentCaseIndex).toBe(1); + expect(state.currentCaseIndex).toBeGreaterThanOrEqual(1); }); - it('status becomes finished after case 8 (without loop)', () => { - let state = createPlaybackState(); - state = startPlayback(state); - state = { ...state, loopMode: false }; - - // Simulate passing all 8 cases by updating in steps - let totalTime = 0; - const maxTime = CASE_DURATION_MS * 10; // Safety limit - while (state.status === 'running' && totalTime < maxTime) { + it('seekToCase jumps to jam case and sets FAULT command later', () => { + const jamIndex = DEMO_PLAYLIST.findIndex((c) => c.faultType === 'jam'); + let state = seekToCase(createPlaybackState(), jamIndex); + expect(state.currentCase.faultType).toBe('jam'); + for (let i = 0; i < 80; i++) { state = updatePlayback(state, 100); - totalTime += 100; + if (state.command === 'FAULT') break; + } + expect(state.command).toBe('FAULT'); + }); + + it('setPlaybackSpeed changes multiplier', () => { + let state = startPlayback(createPlaybackState()); + state = setPlaybackSpeed(state, 2); + expect(state.speed).toBe(2); + }); + + it('records classification events in journal', () => { + let state = startPlayback(createPlaybackState()); + for (let i = 0; i < 60; i++) { + state = updatePlayback(state, 100); + } + expect(state.events.some((e) => e.type === 'classification' || e.type === 'system')).toBe(true); + }); + + it('completes playlist', () => { + let state = startPlayback(createPlaybackState()); + const maxTime = CASE_DURATION_MS * 20; + let elapsed = 0; + while (state.status === 'running' && elapsed < maxTime) { + state = updatePlayback(state, 200); + elapsed += 200; } - expect(state.status).toBe('finished'); }); - - it('loops back to case 1 after case 8 with loopMode', () => { - let state = createPlaybackState(); - state = startPlayback(state); - state = { ...state, loopMode: true }; - - // Simulate passing all 8 cases in steps - let casesPassed = 0; - let totalTime = 0; - const maxTime = CASE_DURATION_MS * 10; - - while (casesPassed < 8 && totalTime < maxTime) { - const prevIndex = state.currentCaseIndex; - state = updatePlayback(state, 100); - totalTime += 100; - if (state.currentCaseIndex !== prevIndex || (prevIndex === 7 && state.currentCaseIndex === 0)) { - casesPassed++; - } - } - - expect(state.status).toBe('running'); - expect(state.currentCaseIndex).toBe(0); - }); - - it('updates command during routing phase', () => { - let state = createPlaybackState(); - state = startPlayback(state); - - // Advance to routing phase (roughly halfway through case) - state = updatePlayback(state, CASE_DURATION_MS * 0.7); - - // Command should be ROUTE_TO_* based on category - expect(state.command).toMatch(/^ROUTE_TO_[BCD]$|^MOVING_TO_CAMERA$|^DETECTING$|^MEASURING$|^CLASSIFYING$|^RETURN_HOME$/); - }); - - it('does not update when paused', () => { - let state = createPlaybackState(); - state = startPlayback(state); - state = updatePlayback(state, 1000); - const elapsed1 = state.totalElapsedMs; - - state = pausePlayback(state); - state = updatePlayback(state, 1000); - - expect(state.totalElapsedMs).toBe(elapsed1); - }); }); diff --git a/src/domain/continuousPlayback.ts b/src/domain/continuousPlayback.ts index 9c205eb..75ea93c 100644 --- a/src/domain/continuousPlayback.ts +++ b/src/domain/continuousPlayback.ts @@ -1,16 +1,20 @@ /** - * Continuous Playback Engine — manages 8-case auto-demo on main page. - * Controls playlist progression, timing, and phase transitions. + * Continuous Playback Engine — manages auto-demo on main page. + * Classification is always driven by classifyItem (live rule engine). + * Supports speed control, case seek, seeded variability, and event journal. */ -import type { Category } from './types'; +import type { Category, ClassificationResult, EventLogEntry } from './types'; import { DEMO_PLAYLIST, type PlaylistCase, PLAYLIST_LENGTH } from './demoPlaylist'; +import { classifyItem } from './classifier'; +import { resolveItem } from '../data/resolveItem'; +import { createSeededRng, DEFAULT_DEMO_SEED, seededOffset } from './seededRng'; /** Playback status for the continuous demo. */ export type PlaybackStatus = 'idle' | 'running' | 'paused' | 'finished'; /** Phase within a single case timeline. */ -export type CasePhase = +export type CasePhase = | 'spawn' | 'move_to_detection' | 'detection' @@ -19,7 +23,10 @@ export type CasePhase = | 'command_sent' | 'routing' | 'exit' - | 'clear_gap'; + | 'clear_gap' + | 'fault_hold' + | 'emergency_hold' + | 'recover'; /** Phase configuration with duration based on conveyor speed (1 m/s). */ export interface PhaseConfig { @@ -29,25 +36,48 @@ export interface PhaseConfig { } /** - * Timeline phases for a single item. + * Timeline phases for a single normal item. * Distances are in meters, speed is 1 m/s, so duration = distance * 1000 ms. */ export const CASE_PHASES: PhaseConfig[] = [ { phase: 'spawn', durationMs: 300, label: 'Spawn at A' }, - { phase: 'move_to_detection', durationMs: 2500, label: 'Moving to camera' }, // 2.5m at 1m/s + { phase: 'move_to_detection', durationMs: 2500, label: 'Moving to camera' }, { phase: 'detection', durationMs: 600, label: 'CV Detection' }, - { phase: 'measurement', durationMs: 1000, label: 'Laser measurement' }, // 1.0m at 1m/s - { phase: 'classification', durationMs: 1000, label: 'Classification' }, // 1.0m at 1m/s - { phase: 'command_sent', durationMs: 1000, label: 'Command sent' }, // 1.0m at 1m/s - { phase: 'routing', durationMs: 2500, label: 'Routing to zone' }, // 2.5m B-route at 1m/s + { phase: 'measurement', durationMs: 1000, label: 'Laser measurement' }, + { phase: 'classification', durationMs: 1000, label: 'Classification' }, + { phase: 'command_sent', durationMs: 1000, label: 'Command sent' }, + { phase: 'routing', durationMs: 2500, label: 'Routing to zone' }, { phase: 'exit', durationMs: 400, label: 'Exit to zone' }, { phase: 'clear_gap', durationMs: 500, label: 'Clear gap' }, ]; -/** Total duration of one case in ms. */ +/** Safety timeline: jam near gate then recover. */ +export const JAM_CASE_PHASES: PhaseConfig[] = [ + { phase: 'spawn', durationMs: 300, label: 'Spawn at A' }, + { phase: 'move_to_detection', durationMs: 2000, label: 'Moving to camera' }, + { phase: 'detection', durationMs: 500, label: 'CV Detection' }, + { phase: 'measurement', durationMs: 800, label: 'Laser measurement' }, + { phase: 'classification', durationMs: 600, label: 'Classification' }, + { phase: 'fault_hold', durationMs: 2800, label: 'JAM / FAULT' }, + { phase: 'recover', durationMs: 1200, label: 'Recovery' }, + { phase: 'clear_gap', durationMs: 400, label: 'Clear gap' }, +]; + +/** Safety timeline: emergency stop. */ +export const ESTOP_CASE_PHASES: PhaseConfig[] = [ + { phase: 'spawn', durationMs: 300, label: 'Spawn at A' }, + { phase: 'move_to_detection', durationMs: 1800, label: 'Moving to camera' }, + { phase: 'detection', durationMs: 400, label: 'CV Detection' }, + { phase: 'emergency_hold', durationMs: 3000, label: 'EMERGENCY STOP' }, + { phase: 'recover', durationMs: 1500, label: 'System reset' }, + { phase: 'clear_gap', durationMs: 400, label: 'Clear gap' }, +]; + +/** Total duration of one normal case in ms. */ export const CASE_DURATION_MS = CASE_PHASES.reduce((sum, p) => sum + p.durationMs, 0); -/** Continuous playback state. */ +export type PlaybackSpeed = 0.5 | 1 | 1.5 | 2; + export interface ContinuousPlaybackState { status: PlaybackStatus; currentCaseIndex: number; @@ -59,13 +89,104 @@ export interface ContinuousPlaybackState { totalElapsedMs: number; loopMode: boolean; targetCategory: Category | null; + classification: ClassificationResult | null; command: string; warning: string | null; + /** Playback speed multiplier */ + speed: PlaybackSpeed; + /** Seed for reproducible variability */ + seed: number; + /** Deterministic position jitter (mm-scale visual offsets stored as meters) */ + positionJitter: { x: number; z: number; yaw: number }; + /** Bounded event journal for proof / engineering HUD */ + events: EventLogEntry[]; +} + +function phasesForCase(playlistCase: PlaylistCase): PhaseConfig[] { + if (playlistCase.faultType === 'jam') return JAM_CASE_PHASES; + if (playlistCase.faultType === 'emergency_stop') return ESTOP_CASE_PHASES; + return CASE_PHASES; +} + +export function getPlaylistCaseDurationMs(playlistCase: PlaylistCase): number { + return phasesForCase(playlistCase).reduce((sum, p) => sum + p.durationMs, 0); +} + +/** Cumulative playlist duration before case index (supports wrap for loops). */ +export function cumulativePlaylistDurationMs(caseIndex: number): number { + let sum = 0; + for (let i = 0; i < caseIndex; i++) { + sum += getPlaylistCaseDurationMs(DEMO_PLAYLIST[i % PLAYLIST_LENGTH]); + } + return sum; +} + +function caseDurationMs(playlistCase: PlaylistCase): number { + return getPlaylistCaseDurationMs(playlistCase); +} + +function classifyCase(playlistCase: PlaylistCase): ClassificationResult { + const item = resolveItem(playlistCase.itemId); + return classifyItem(item); +} + +function buildJitter(seed: number, caseIndex: number): { x: number; z: number; yaw: number } { + const rng = createSeededRng(seed + caseIndex * 9973); + return { + x: seededOffset(rng, 0.012), + z: seededOffset(rng, 0.008), + yaw: seededOffset(rng, 0.04), + }; +} + +let eventCounter = 0; + +function pushEvent( + events: EventLogEntry[], + timestampMs: number, + entry: Omit, +): EventLogEntry[] { + eventCounter += 1; + const next: EventLogEntry = { + id: `pb-evt-${eventCounter}`, + timestampMs, + ...entry, + }; + return [next, ...events].slice(0, 40); +} + +function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: number, events: EventLogEntry[], simTime: number) { + const classification = classifyCase(playlistCase); + const warnings = [ + ...(playlistCase.warning ? [playlistCase.warning] : []), + ...classification.warnings, + ]; + return { + currentCase: playlistCase, + currentCaseIndex: caseIndex, + currentPhaseIndex: 0, + currentPhase: 'spawn' as CasePhase, + phaseElapsedMs: 0, + caseElapsedMs: 0, + targetCategory: classification.category, + classification, + command: 'IDLE', + warning: warnings[0] ?? null, + positionJitter: buildJitter(seed, caseIndex), + events: pushEvent(events, simTime, { + itemId: playlistCase.itemId, + type: 'system', + message: `Case start: ${playlistCase.title}`, + category: classification.category, + status: 'info', + }), + }; } /** Create initial playback state. */ -export function createPlaybackState(): ContinuousPlaybackState { +export function createPlaybackState(seed: number = DEFAULT_DEMO_SEED): ContinuousPlaybackState { const firstCase = DEMO_PLAYLIST[0]; + const classification = classifyCase(firstCase); return { status: 'idle', currentCaseIndex: 0, @@ -77,100 +198,100 @@ export function createPlaybackState(): ContinuousPlaybackState { totalElapsedMs: 0, loopMode: false, targetCategory: null, + classification: null, command: 'IDLE', warning: null, + speed: 1, + seed, + positionJitter: { x: 0, z: 0, yaw: 0 }, + events: [], }; } /** Start playback from the beginning. */ export function startPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState { const firstCase = DEMO_PLAYLIST[0]; + const fields = initCaseFields(firstCase, state.seed, 0, [], 0); return { ...state, status: 'running', - currentCaseIndex: 0, - currentCase: firstCase, - currentPhaseIndex: 0, - currentPhase: 'spawn', - phaseElapsedMs: 0, - caseElapsedMs: 0, totalElapsedMs: 0, - targetCategory: firstCase.expectedCategory, - command: 'IDLE', - warning: firstCase.warning ?? null, + speed: state.speed, + seed: state.seed, + loopMode: state.loopMode, + ...fields, }; } -/** Pause playback. */ export function pausePlayback(state: ContinuousPlaybackState): ContinuousPlaybackState { if (state.status !== 'running') return state; return { ...state, status: 'paused' }; } -/** Resume playback. */ export function resumePlayback(state: ContinuousPlaybackState): ContinuousPlaybackState { if (state.status !== 'paused') return state; return { ...state, status: 'running' }; } -/** Stop and reset playback. */ export function stopPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState { - return createPlaybackState(); + return createPlaybackState(state.seed); } -/** Toggle loop mode. */ export function toggleLoopMode(state: ContinuousPlaybackState): ContinuousPlaybackState { return { ...state, loopMode: !state.loopMode }; } -/** Advance to the next case. */ -function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState { - const nextIndex = state.currentCaseIndex + 1; - - // Check if we've completed all cases - if (nextIndex >= PLAYLIST_LENGTH) { - if (state.loopMode) { - // Loop back to first case - const firstCase = DEMO_PLAYLIST[0]; - return { - ...state, - currentCaseIndex: 0, - currentCase: firstCase, - currentPhaseIndex: 0, - currentPhase: 'spawn', - phaseElapsedMs: 0, - caseElapsedMs: 0, - targetCategory: firstCase.expectedCategory, - command: 'IDLE', - warning: firstCase.warning ?? null, - }; - } else { - // Finished all cases - return { - ...state, - status: 'finished', - command: 'COMPLETE', - }; - } - } - - // Advance to next case - const nextCase = DEMO_PLAYLIST[nextIndex]; +export function setPlaybackSpeed(state: ContinuousPlaybackState, speed: PlaybackSpeed): ContinuousPlaybackState { + return { ...state, speed }; +} + +/** Jump to a playlist case index (keeps running/paused status). */ +export function seekToCase(state: ContinuousPlaybackState, caseIndex: number): ContinuousPlaybackState { + const idx = ((caseIndex % PLAYLIST_LENGTH) + PLAYLIST_LENGTH) % PLAYLIST_LENGTH; + const nextCase = DEMO_PLAYLIST[idx]; + const fields = initCaseFields(nextCase, state.seed, idx, state.events, state.totalElapsedMs); + const status = state.status === 'idle' || state.status === 'finished' ? 'running' : state.status; return { ...state, - currentCaseIndex: nextIndex, - currentCase: nextCase, - currentPhaseIndex: 0, - currentPhase: 'spawn', - phaseElapsedMs: 0, - caseElapsedMs: 0, - targetCategory: nextCase.expectedCategory, - command: 'IDLE', - warning: nextCase.warning ?? null, + status, + ...fields, }; } -/** Get command for current phase. */ +export function seekNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState { + return seekToCase(state, state.currentCaseIndex + 1); +} + +export function seekPrevCase(state: ContinuousPlaybackState): ContinuousPlaybackState { + return seekToCase(state, state.currentCaseIndex - 1); +} + +function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState { + const nextIndex = state.currentCaseIndex + 1; + + if (nextIndex >= PLAYLIST_LENGTH) { + if (state.loopMode) { + const firstCase = DEMO_PLAYLIST[0]; + const fields = initCaseFields(firstCase, state.seed, 0, state.events, state.totalElapsedMs); + return { ...state, ...fields }; + } + return { + ...state, + status: 'finished', + command: 'COMPLETE', + events: pushEvent(state.events, state.totalElapsedMs, { + type: 'system', + message: 'Playlist complete', + status: 'success', + }), + }; + } + + const nextCase = DEMO_PLAYLIST[nextIndex]; + const fields = initCaseFields(nextCase, state.seed, nextIndex, state.events, state.totalElapsedMs); + return { ...state, ...fields }; +} + function getCommandForPhase(phase: CasePhase, category: Category): string { switch (phase) { case 'spawn': @@ -186,6 +307,12 @@ function getCommandForPhase(phase: CasePhase, category: Category): string { case 'routing': case 'exit': return `ROUTE_TO_${category}`; + case 'fault_hold': + return 'FAULT'; + case 'emergency_hold': + return 'EMERGENCY_STOP'; + case 'recover': + return 'RECOVERING'; case 'clear_gap': return 'RETURN_HOME'; default: @@ -193,7 +320,62 @@ function getCommandForPhase(phase: CasePhase, category: Category): string { } } -/** Update playback state with elapsed time. */ +function maybeLogPhaseTransition( + state: ContinuousPlaybackState, + phase: CasePhase, + category: Category, +): EventLogEntry[] { + let events = state.events; + if (phase === 'classification' && state.classification) { + events = pushEvent(events, state.totalElapsedMs, { + itemId: state.currentCase.itemId, + type: 'classification', + message: `${state.classification.label}: ${state.classification.reason}`, + category: state.classification.category, + command: 'CLASSIFY_RULE_BASED', + status: state.classification.warnings.length ? 'warning' : 'success', + }); + } + if (phase === 'command_sent') { + events = pushEvent(events, state.totalElapsedMs, { + itemId: state.currentCase.itemId, + type: 'routing', + message: `Command ROUTE_TO_${category}`, + category, + command: `ROUTE_TO_${category}`, + status: 'success', + }); + } + if (phase === 'fault_hold') { + events = pushEvent(events, state.totalElapsedMs, { + itemId: state.currentCase.itemId, + type: 'fault', + message: 'Jam detected at stop-gate — conveyor halted', + command: 'FAULT', + status: 'error', + }); + } + if (phase === 'emergency_hold') { + events = pushEvent(events, state.totalElapsedMs, { + itemId: state.currentCase.itemId, + type: 'fault', + message: 'Emergency stop engaged — all motion frozen', + command: 'EMERGENCY_STOP', + status: 'error', + }); + } + if (phase === 'recover') { + events = pushEvent(events, state.totalElapsedMs, { + type: 'system', + message: 'Recovery sequence started', + command: 'RECOVER', + status: 'warning', + }); + } + return events; +} + +/** Update playback state with elapsed wall-clock time (scaled by speed). */ export function updatePlayback( state: ContinuousPlaybackState, deltaMs: number, @@ -202,29 +384,30 @@ export function updatePlayback( return state; } + const scaledDelta = deltaMs * state.speed; let newState = { ...state }; - newState.phaseElapsedMs += deltaMs; - newState.caseElapsedMs += deltaMs; - newState.totalElapsedMs += deltaMs; + newState.phaseElapsedMs += scaledDelta; + newState.caseElapsedMs += scaledDelta; + newState.totalElapsedMs += scaledDelta; + + const phases = phasesForCase(newState.currentCase); + const currentPhaseConfig = phases[newState.currentPhaseIndex]; - // Check if current phase is complete - const currentPhaseConfig = CASE_PHASES[newState.currentPhaseIndex]; if (newState.phaseElapsedMs >= currentPhaseConfig.durationMs) { - // Advance to next phase const nextPhaseIndex = newState.currentPhaseIndex + 1; - - if (nextPhaseIndex >= CASE_PHASES.length) { - // Case complete, advance to next case + + if (nextPhaseIndex >= phases.length) { newState = advanceToNextCase(newState); } else { - // Move to next phase newState.currentPhaseIndex = nextPhaseIndex; - newState.currentPhase = CASE_PHASES[nextPhaseIndex].phase; + newState.currentPhase = phases[nextPhaseIndex].phase; newState.phaseElapsedMs = 0; + if (newState.targetCategory) { + newState.events = maybeLogPhaseTransition(newState, newState.currentPhase, newState.targetCategory); + } } } - // Update command based on phase if (newState.status === 'running' && newState.targetCategory) { newState.command = getCommandForPhase(newState.currentPhase, newState.targetCategory); } @@ -232,35 +415,55 @@ export function updatePlayback( return newState; } -/** Get progress within current case (0 to 1). */ -export function getCaseProgress(state: ContinuousPlaybackState): number { - return Math.min(state.caseElapsedMs / CASE_DURATION_MS, 1); +export function getCasePhases(state: ContinuousPlaybackState): PhaseConfig[] { + return phasesForCase(state.currentCase); +} + +export function getCaseDurationMs(state: ContinuousPlaybackState): number { + return caseDurationMs(state.currentCase); +} + +export function getCaseProgress(state: ContinuousPlaybackState): number { + return Math.min(state.caseElapsedMs / getCaseDurationMs(state), 1); } -/** Get progress within current phase (0 to 1). */ export function getPhaseProgress(state: ContinuousPlaybackState): number { - const phaseConfig = CASE_PHASES[state.currentPhaseIndex]; + const phases = getCasePhases(state); + const phaseConfig = phases[state.currentPhaseIndex]; return Math.min(state.phaseElapsedMs / phaseConfig.durationMs, 1); } -/** Get current phase configuration. */ export function getCurrentPhaseConfig(state: ContinuousPlaybackState): PhaseConfig { - return CASE_PHASES[state.currentPhaseIndex]; + return getCasePhases(state)[state.currentPhaseIndex]; } -/** Check if detection is active. */ export function isDetectionActive(state: ContinuousPlaybackState): boolean { return state.currentPhase === 'detection' || state.currentPhase === 'measurement'; } -/** Check if routing is active. */ export function isRoutingActive(state: ContinuousPlaybackState): boolean { return state.currentPhase === 'routing' || state.currentPhase === 'exit'; } -/** Get total playlist progress (0 to 1). */ +export function isFaultActive(state: ContinuousPlaybackState): boolean { + return state.currentPhase === 'fault_hold' || state.currentPhase === 'emergency_hold'; +} + export function getTotalProgress(state: ContinuousPlaybackState): number { const completedCases = state.currentCaseIndex; const currentCaseProgress = getCaseProgress(state); return (completedCases + currentCaseProgress) / PLAYLIST_LENGTH; } + +/** Assert playlist expectedCategory matches live classifier (for tests). */ +export function assertPlaylistClassifierConsistency(): Array<{ id: string; expected: Category; actual: Category }> { + const mismatches: Array<{ id: string; expected: Category; actual: Category }> = []; + for (const c of DEMO_PLAYLIST) { + if (c.faultType) continue; + const result = classifyCase(c); + if (result.category !== c.expectedCategory) { + mismatches.push({ id: c.id, expected: c.expectedCategory, actual: result.category }); + } + } + return mismatches; +} diff --git a/src/domain/demoPlaylist.ts b/src/domain/demoPlaylist.ts index cfefcff..5396793 100644 --- a/src/domain/demoPlaylist.ts +++ b/src/domain/demoPlaylist.ts @@ -1,10 +1,12 @@ /** - * Demo Playlist — 8 showcase cases for the main page auto-demo. - * Each case demonstrates a specific classification scenario. + * Demo Playlist — showcase cases for the main page auto-demo. + * Includes classification cases + jam / emergency safety demos. */ import type { Category, ScenarioId } from './types'; +export type FaultType = 'jam' | 'emergency_stop'; + export interface PlaylistCase { id: string; title: string; @@ -13,11 +15,13 @@ export interface PlaylistCase { scenarioId: ScenarioId; expectedCategory: Category; warning?: string; + /** Optional safety fault sequence (no routing). */ + faultType?: FaultType; } /** - * 8 showcase cases for continuous demo playback. - * Order is designed for storytelling: B → C → D → edge cases. + * Showcase playlist: B → C → D → edge cases → safety. + * Order is designed for storytelling for jury / investors. */ export const DEMO_PLAYLIST: PlaylistCase[] = [ { @@ -85,6 +89,26 @@ export const DEMO_PLAYLIST: PlaylistCase[] = [ expectedCategory: 'B', warning: 'Low confidence is a warning, not a 4th category. Rules still apply.', }, + { + id: 'jam_fault', + title: 'Заклинивание у gate', + description: 'Jam → FAULT, конвейер остановлен, затем recovery', + itemId: 'SKU-004', + scenarioId: 'jam', + expectedCategory: 'C', + faultType: 'jam', + warning: 'FAULT: item jammed at stop-gate', + }, + { + id: 'emergency_stop', + title: 'Аварийная остановка', + description: 'E-STOP — все приводы заморожены, затем reset', + itemId: 'SKU-001', + scenarioId: 'emergency_stop', + expectedCategory: 'B', + faultType: 'emergency_stop', + warning: 'EMERGENCY STOP engaged', + }, ]; /** diff --git a/src/domain/inspectionViewModel.ts b/src/domain/inspectionViewModel.ts index 547ba74..76a99c4 100644 --- a/src/domain/inspectionViewModel.ts +++ b/src/domain/inspectionViewModel.ts @@ -40,6 +40,9 @@ const PHASE_LABELS: Record = { routing: 'Routing to zone', exit: 'Exiting', clear_gap: 'Clear', + fault_hold: 'FAULT / Jam', + emergency_hold: 'EMERGENCY STOP', + recover: 'Recovering', }; const VISIBLE_PHASES: CasePhase[] = [ @@ -48,6 +51,8 @@ const VISIBLE_PHASES: CasePhase[] = [ 'classification', 'command_sent', 'routing', + 'fault_hold', + 'emergency_hold', ]; function normalizeShape(shape: string, roundness: number): DetectedShape { diff --git a/src/domain/measurementSystem.ts b/src/domain/measurementSystem.ts index 185bf8a..53ad4df 100644 --- a/src/domain/measurementSystem.ts +++ b/src/domain/measurementSystem.ts @@ -1,28 +1,24 @@ /** * 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 + * 4. PLC controller: synchronizes and makes classification decision via classifyItem */ import type { ContinuousPlaybackState, CasePhase } from './continuousPlayback'; import type { Category } from './types'; -import { ITEMS } from '../data/items'; +import { classifyItem, DIMENSION_LIMITS } from './classifier'; +import { resolveItem } from '../data/resolveItem'; import { MM_PER_STEP, LASER_HEIGHT_M, BELT_TOP_Y, - CONVEYOR_SPEED_MPS, } from './physicalLayout'; -// ========================================================= -// Types -// ========================================================= - -export type MeasurementStage = +export type MeasurementStage = | 'idle' | 'leading_edge_detected' | 'step_counting' @@ -32,28 +28,19 @@ export type MeasurementStage = | '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'; @@ -61,24 +48,13 @@ export interface MeasurementData { command: string; cPriorityApplied: boolean; isLowConfidence: boolean; - - /** Item info */ + /** Live classifier reason (proof of algorithm). */ + classificationReason: string | null; + classificationLabel: string | null; 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': @@ -93,6 +69,9 @@ function getStageFromPhase(phase: CasePhase): MeasurementStage { case 'command_sent': case 'routing': case 'exit': + case 'fault_hold': + case 'emergency_hold': + case 'recover': return 'command_sent'; case 'clear_gap': return 'idle'; @@ -101,99 +80,72 @@ function getStageFromPhase(phase: CasePhase): MeasurementStage { } } -// ========================================================= -// 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 >= DIMENSION_LIMITS.roundnessThreshold) 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 itemData = resolveItem(currentCase.itemId); 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 classification = classifyItem(itemData); + const dimensionsPass = classification.dimensionsPass; const shapeResult = getShapeResult(roundnessK); const confidence = itemData.confidence ?? 0.95; - const isLowConfidence = confidence < 0.8; - - // Determine category + const isLowConfidence = confidence < 0.65; + + const deciding = + phase !== 'spawn' && + phase !== 'move_to_detection' && + phase !== 'clear_gap'; + 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'; - } + let classificationReason: string | null = null; + let classificationLabel: string | null = null; + + if (deciding) { + // Prefer live result already computed on playback; fall back to classifyItem. + finalCategory = playback.classification?.category ?? classification.category; + classificationReason = playback.classification?.reason ?? classification.reason; + classificationLabel = playback.classification?.label ?? classification.label; + cPriorityApplied = + finalCategory === 'C' && roundnessK >= DIMENSION_LIMITS.roundnessThreshold; } - - // 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 command = + phase === 'fault_hold' + ? 'FAULT' + : phase === 'emergency_hold' + ? 'EMERGENCY_STOP' + : finalCategory + ? `ROUTE_TO_${finalCategory}` + : 'STANDBY'; + 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, @@ -215,32 +167,44 @@ export function getMeasurementData(playback: ContinuousPlaybackState): Measureme command, cPriorityApplied, isLowConfidence, + classificationReason, + classificationLabel, 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'; + 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); + return [ + 'detection', + 'measurement', + 'classification', + 'command_sent', + 'routing', + 'fault_hold', + 'emergency_hold', + ].includes(phase); } export function shouldShowLaserBeam(phase: CasePhase): boolean { diff --git a/src/domain/physicalItemMotion.ts b/src/domain/physicalItemMotion.ts index 6095043..a514d0b 100644 --- a/src/domain/physicalItemMotion.ts +++ b/src/domain/physicalItemMotion.ts @@ -7,7 +7,8 @@ */ import type { Category, DimensionsMm } from './types'; -import { CASE_PHASES } from './continuousPlayback'; +import type { FaultType } from './demoPlaylist'; +import { CASE_PHASES, JAM_CASE_PHASES, ESTOP_CASE_PHASES } from './continuousPlayback'; import { SURFACES, lerp3, @@ -17,7 +18,7 @@ import { } from './conveyorNetwork'; export type SurfaceType = SurfaceName; -export type MotionPhase = 'feed' | 'inspection' | 'decision' | 'routing' | 'settled'; +export type MotionPhase = 'feed' | 'inspection' | 'decision' | 'routing' | 'settled' | 'fault' | 'recover'; export interface PhysicalItemPose { position: Vec3; @@ -34,13 +35,16 @@ export interface PoseInput { dimensionsMm: DimensionsMm; targetCategory: Category | null; elapsedMs: number; + faultType?: FaultType; + /** Optional seeded visual jitter (meters / radians). */ + jitter?: { x: number; z: number; yaw: number }; } -/** Cumulative phase start times reconstructed once from CASE_PHASES. */ -function phaseStarts() { +/** Cumulative phase start times from a phase table. */ +function phaseStartsFrom(phases: typeof CASE_PHASES) { let cumulative = 0; const starts: Record = {}; - for (const p of CASE_PHASES) { + for (const p of phases) { starts[p.phase] = cumulative; cumulative += p.durationMs; } @@ -51,7 +55,6 @@ function phaseStarts() { function poseOnSurface(name: SurfaceName, t: number, itemHeightM: number): { pos: Vec3; rotY: number } { const s = SURFACES[name]; const p = lerp3(s.start, s.end, t); - // Item bottom sits on the surface: center = surfaceTop + half height. return { pos: [p[0], p[1] + itemHeightM / 2, p[2]], rotY: surfaceHeading(name), @@ -69,9 +72,9 @@ function settledSlot(name: SurfaceName, slotIndex: number, itemHeightM: number): const cz = (b.minZ + b.maxZ) / 2; const spanX = b.maxX - b.minX; const spanZ = b.maxZ - b.minZ; - const col = slotIndex % 3; // 0,1,2 - const row = Math.floor(slotIndex / 3) % 2; // 0,1 - const offsetX = (col - 1) * (spanX / 3.5); // stays well within bounds + const col = slotIndex % 3; + const row = Math.floor(slotIndex / 3) % 2; + const offsetX = (col - 1) * (spanX / 3.5); const offsetZ = (row - 0.5) * (spanZ / 3.0); return { pos: [cx + offsetX, s.surfaceY + itemHeightM / 2, cz + offsetZ], @@ -79,12 +82,85 @@ function settledSlot(name: SurfaceName, slotIndex: number, itemHeightM: number): }; } +function applyJitter(pos: Vec3, rotY: number, jitter?: PoseInput['jitter']): { pos: Vec3; rotY: number } { + if (!jitter) return { pos, rotY }; + return { + pos: [pos[0] + jitter.x, pos[1], pos[2] + jitter.z], + rotY: rotY + jitter.yaw, + }; +} + +/** Jam / E-stop motion: freeze at junction, then recover (no settle into bin). */ +function getFaultPose(input: PoseInput): PhysicalItemPose { + const { dimensionsMm, elapsedMs, faultType, jitter } = input; + const itemHeightM = dimensionsMm.height / 1000; + const phases = faultType === 'emergency_stop' ? ESTOP_CASE_PHASES : JAM_CASE_PHASES; + const { starts, total } = phaseStartsFrom(phases); + + const feedStart = starts['move_to_detection'] ?? 0; + const inspectStart = starts['detection'] ?? feedStart; + const measureStart = starts['measurement'] ?? inspectStart; + const classifyStart = starts['classification'] ?? measureStart; + const faultStart = + starts['fault_hold'] ?? starts['emergency_hold'] ?? classifyStart; + const recoverStart = starts['recover'] ?? faultStart; + const clearStart = starts['clear_gap'] ?? recoverStart; + + let pos: Vec3; + let rotY = 0; + let surface: SurfaceType = 'main_belt'; + let phase: MotionPhase = 'feed'; + + if (elapsedMs <= feedStart) { + const r = poseOnSurface('main_belt', 0, itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed'; + } else if (elapsedMs <= inspectStart) { + const t = (elapsedMs - feedStart) / Math.max(inspectStart - feedStart, 1); + const r = poseOnSurface('main_belt', t, itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed'; + } else if (elapsedMs <= faultStart) { + // Hold under inspection / approach gate + const r = poseOnSurface('inspection_station', 0.4, itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'inspection_station'; phase = 'inspection'; + } else if (elapsedMs <= recoverStart) { + // Freeze at routing junction (jam / e-stop visual) + const r = poseOnSurface('routing_junction', 0.35, itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; phase = 'fault'; + } else if (elapsedMs <= clearStart) { + // Slight reverse toward belt for recovery narrative + const t = (elapsedMs - recoverStart) / Math.max(clearStart - recoverStart, 1); + const r = poseOnSurface('routing_junction', 0.35 * (1 - t * 0.5), itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; phase = 'recover'; + } else { + const r = poseOnSurface('main_belt', 0.15, itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'recover'; + } + + const j = applyJitter(pos, rotY, jitter); + if (!Number.isFinite(j.pos[0]) || !Number.isFinite(j.pos[1]) || !Number.isFinite(j.pos[2])) { + j.pos = [SURFACES.main_belt.start[0], SURFACES.main_belt.surfaceY + itemHeightM / 2, 0]; + } + + return { + position: j.pos, + rotation: [0, j.rotY, 0], + surface, + phase, + isSettled: elapsedMs >= total && false, + activeRoute: (input.targetCategory as 'B' | 'C' | 'D') || 'B', + }; +} + export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose { - const { dimensionsMm, targetCategory, elapsedMs, slotIndex = 0 } = input; + if (input.faultType) { + return getFaultPose(input); + } + + const { dimensionsMm, targetCategory, elapsedMs, slotIndex = 0, jitter } = input; const category: 'B' | 'C' | 'D' = (targetCategory as 'B' | 'C' | 'D') || 'B'; const itemHeightM = dimensionsMm.height / 1000; - const { starts, total } = phaseStarts(); + const { starts, total } = phaseStartsFrom(CASE_PHASES); const feedStart = starts['move_to_detection']; const inspectStart = starts['detection']; const junctionStart = starts['measurement']; @@ -98,25 +174,20 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose { let isSettled = false; if (elapsedMs <= feedStart) { - // Spawn dwell at belt entry (A). const r = poseOnSurface('main_belt', 0, itemHeightM); pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed'; } else if (elapsedMs <= inspectStart) { - // Feed along the main belt A → inspection. const t = (elapsedMs - feedStart) / (inspectStart - feedStart); const r = poseOnSurface('main_belt', t, itemHeightM); pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed'; } else if (elapsedMs <= junctionStart) { - // Dwell under the inspection station. const r = poseOnSurface('inspection_station', 0, itemHeightM); pos = r.pos; rotY = r.rotY; surface = 'inspection_station'; phase = 'inspection'; } else if (elapsedMs <= routingStart) { - // Travel to the routing junction. const t = (elapsedMs - junctionStart) / (routingStart - junctionStart); const r = poseOnSurface('routing_junction', t, itemHeightM); pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; phase = 'decision'; } else if (category === 'B') { - // B: short transfer spur → drop chute → settle inside floor bin. const exitStart = starts['exit']; const travelSpan = exitStart - routingStart; const t = (elapsedMs - routingStart) / travelSpan; @@ -132,7 +203,6 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose { isSettled = elapsedMs >= total; } } else { - // C / D: slide down the chute, then settle on the cage floor. const chuteName: SurfaceName = category === 'C' ? 'chute_c' : 'chute_d'; const cageName: SurfaceName = category === 'C' ? 'c_cage_floor' : 'd_cage_floor'; const travelSpan = clearStart - routingStart; @@ -147,14 +217,14 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose { } } - // Guarantee no NaN/Infinity escapes. - if (!Number.isFinite(pos[0]) || !Number.isFinite(pos[1]) || !Number.isFinite(pos[2])) { - pos = [SURFACES.main_belt.start[0], SURFACES.main_belt.surfaceY + itemHeightM / 2, 0]; + const j = applyJitter(pos, rotY, jitter); + if (!Number.isFinite(j.pos[0]) || !Number.isFinite(j.pos[1]) || !Number.isFinite(j.pos[2])) { + j.pos = [SURFACES.main_belt.start[0], SURFACES.main_belt.surfaceY + itemHeightM / 2, 0]; } return { - position: pos, - rotation: [0, rotY, 0], + position: j.pos, + rotation: [0, j.rotY, 0], surface, phase, isSettled, diff --git a/src/domain/physicalLayout.test.ts b/src/domain/physicalLayout.test.ts index 7db235e..f58567a 100644 --- a/src/domain/physicalLayout.test.ts +++ b/src/domain/physicalLayout.test.ts @@ -121,8 +121,9 @@ describe('Playlist Items Physical Constraints', () => { } }); - it('playlist has 8 cases', () => { - expect(DEMO_PLAYLIST).toHaveLength(8); + it('playlist has classification and safety cases', () => { + expect(DEMO_PLAYLIST.length).toBeGreaterThanOrEqual(8); + expect(DEMO_PLAYLIST).toHaveLength(10); }); it('low confidence case still has B/C/D category', () => { diff --git a/src/domain/qualityMode.test.ts b/src/domain/qualityMode.test.ts new file mode 100644 index 0000000..31064f6 --- /dev/null +++ b/src/domain/qualityMode.test.ts @@ -0,0 +1,30 @@ +/** + * Quality mode unit tests. + */ +import { describe, it, expect } from 'vitest'; +import { detectQualityMode, getQualitySettings, adaptQuality } from './qualityMode'; + +describe('qualityMode', () => { + it('selects low on narrow viewports', () => { + expect(detectQualityMode(600)).toBe('low'); + }); + + it('selects demo on wide viewports with healthy FPS', () => { + expect(detectQualityMode(1440, 60)).toBe('demo'); + }); + + it('downgrades when FPS collapses', () => { + expect(detectQualityMode(1440, 20)).toBe('low'); + }); + + it('demo preset keeps effects off for stable FPS', () => { + const s = getQualitySettings('demo'); + expect(s.effectsEnabled).toBe(false); + expect(s.shadows).toBe(false); + expect(s.targetFps).toBe(60); + }); + + it('adaptQuality steps down under load', () => { + expect(adaptQuality('demo', 20)).toBe('high'); + }); +}); diff --git a/src/domain/qualityMode.ts b/src/domain/qualityMode.ts new file mode 100644 index 0000000..2f8aff0 --- /dev/null +++ b/src/domain/qualityMode.ts @@ -0,0 +1,85 @@ +/** + * Render quality modes adapted to device capability and demo needs. + * Demo/Ultra targets stable 60 FPS when hardware allows; falls back gracefully. + */ + +export type QualityMode = 'low' | 'medium' | 'high' | 'demo'; + +export interface QualitySettings { + mode: QualityMode; + dprMax: number; + antialias: boolean; + shadows: boolean; + maxVisibleItems: number; + effectsEnabled: boolean; + rollerDetail: 'none' | 'sparse' | 'full'; + targetFps: 30 | 60; +} + +const PRESETS: Record = { + low: { + mode: 'low', + dprMax: 1, + antialias: false, + shadows: false, + maxVisibleItems: 3, + effectsEnabled: false, + rollerDetail: 'none', + targetFps: 30, + }, + medium: { + mode: 'medium', + dprMax: 1.25, + antialias: false, + shadows: false, + maxVisibleItems: 5, + effectsEnabled: false, + rollerDetail: 'sparse', + targetFps: 30, + }, + high: { + mode: 'high', + dprMax: 1.5, + antialias: true, + shadows: false, + maxVisibleItems: 6, + effectsEnabled: false, + rollerDetail: 'full', + targetFps: 60, + }, + demo: { + mode: 'demo', + dprMax: 1.5, + antialias: true, + shadows: false, + maxVisibleItems: 6, + effectsEnabled: false, + rollerDetail: 'full', + targetFps: 60, + }, +}; + +/** Auto-select quality from viewport width and optional FPS sample. */ +export function detectQualityMode(width: number, recentFps?: number): QualityMode { + if (width < 768) return 'low'; + if (width < 1100) return 'medium'; + if (recentFps !== undefined && recentFps < 28) return 'low'; + if (recentFps !== undefined && recentFps < 45) return 'medium'; + return 'demo'; +} + +export function getQualitySettings(mode: QualityMode): QualitySettings { + return PRESETS[mode]; +} + +/** Soft downgrade when FPS drops below target. */ +export function adaptQuality(current: QualityMode, fps: number): QualityMode { + const order: QualityMode[] = ['low', 'medium', 'high', 'demo']; + const idx = order.indexOf(current); + const target = getQualitySettings(current).targetFps; + if (fps < target * 0.7 && idx > 0) return order[idx - 1]; + if (fps > target * 0.95 && idx < order.length - 1 && current !== 'demo') { + return order[Math.min(idx + 1, order.length - 1)]; + } + return current; +} diff --git a/src/domain/seededRng.ts b/src/domain/seededRng.ts new file mode 100644 index 0000000..32ae369 --- /dev/null +++ b/src/domain/seededRng.ts @@ -0,0 +1,20 @@ +/** + * Deterministic PRNG (Mulberry32) for reproducible demo variability. + */ + +export function createSeededRng(seed: number): () => number { + let t = seed >>> 0; + return () => { + t += 0x6d2b79f5; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Small signed offset in [-amplitude, +amplitude]. */ +export function seededOffset(rng: () => number, amplitude: number): number { + return (rng() * 2 - 1) * amplitude; +} + +export const DEFAULT_DEMO_SEED = 20260715; diff --git a/src/domain/simulation.test.ts b/src/domain/simulation.test.ts new file mode 100644 index 0000000..614cfba --- /dev/null +++ b/src/domain/simulation.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { createSimulation, stepSimulationToNextState, setRunning, stepSimulation } from './simulation'; +import { SCENARIOS } from '../data/scenarios'; + +function scenario(id: string) { + const s = SCENARIOS.find((x) => x.id === id); + if (!s) throw new Error(`missing scenario ${id}`); + return s; +} + +describe('simulation state machine', () => { + it('classifies and routes normal item to B/C/D via classifyItem', () => { + let state = createSimulation(scenario('normal_flow')); + state = stepSimulationToNextState(state); + expect(state.machineState).toBe('MOVING_TO_CAMERA'); + expect(state.currentItem).toBeDefined(); + expect(['B', 'C', 'D']).toContain(state.currentItem!.classification.category); + }); + + it('oversized scenario routes to C', () => { + let state = createSimulation(scenario('oversized_item')); + state = stepSimulationToNextState(state); + expect(state.currentItem!.classification.category).toBe('C'); + }); + + it('c_priority chooses C over D', () => { + let state = createSimulation(scenario('c_priority')); + state = stepSimulationToNextState(state); + expect(state.currentItem!.classification.category).toBe('C'); + expect(state.currentItem!.item.roundness).toBeGreaterThanOrEqual(0.7); + }); + + it('jam scenario enters FAULT', () => { + let state = setRunning(createSimulation(scenario('jam')), true); + state = setRunning(stepSimulationToNextState(state), true); + for (let i = 0; i < 120; i++) { + state = stepSimulation(setRunning(state, true), 100); + if (state.machineState === 'FAULT') break; + } + expect(state.machineState).toBe('FAULT'); + expect(state.systemStatus).toBe('FAULT'); + }); + + it('emergency_stop freezes system', () => { + let state = setRunning(createSimulation(scenario('emergency_stop')), true); + state = stepSimulationToNextState(state); + for (let i = 0; i < 40; i++) { + state = stepSimulation(state, 100); + if (state.machineState === 'EMERGENCY_STOP') break; + } + expect(state.machineState).toBe('EMERGENCY_STOP'); + expect(state.systemStatus).toBe('EMERGENCY_STOP'); + }); +}); diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 4804c3f..13330e3 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,13 +1,14 @@ -import { lazy, Suspense, useEffect, useState, useMemo } from 'react'; +import { lazy, Suspense, useEffect, useState, useMemo, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import ThreeFallback from '../components/ThreeD/ThreeFallback'; import ThreeErrorBoundary from '../components/ThreeD/ThreeErrorBoundary'; import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL'; import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../domain/demoPlaylist'; -import type { ContinuousPlaybackState } from '../domain/continuousPlayback'; -import { getCaseProgress, getCurrentPhaseConfig } from '../domain/continuousPlayback'; +import type { ContinuousPlaybackState, PlaybackSpeed } from '../domain/continuousPlayback'; +import { getCaseProgress, getCurrentPhaseConfig, isFaultActive } from '../domain/continuousPlayback'; import { getMeasurementData, shouldShowMeasurement } from '../domain/measurementSystem'; import { getViewportType, type ViewportType } from '../domain/cinematicCamera'; +import { detectQualityMode } from '../domain/qualityMode'; +import { resolveItem } from '../data/resolveItem'; import CVInspectionOverlay from '../components/CVInspectionOverlay'; const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous')); @@ -17,18 +18,30 @@ interface MainPageProps { onPlay: () => void; onPause: () => void; onStop: () => void; + onSeekCase: (index: number) => void; + onSeekNext: () => void; + onSeekPrev: () => void; + onSetSpeed: (speed: PlaybackSpeed) => void; } +const SPEEDS: PlaybackSpeed[] = [0.5, 1, 1.5, 2]; + export default function MainPage({ playback, onPlay, onPause, onStop, + onSeekCase, + onSeekNext, + onSeekPrev, + onSetSpeed, }: MainPageProps) { const webgl = useWebGLSupport(); const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth)); const [contextLost, setContextLost] = useState(false); const [autoCameraEnabled, setAutoCameraEnabled] = useState(true); + const [presentationMode, setPresentationMode] = useState(false); + const [showEventLog, setShowEventLog] = useState(false); useEffect(() => { const onResize = () => setWidth(window.innerWidth); @@ -36,34 +49,98 @@ export default function MainPage({ return () => window.removeEventListener('resize', onResize); }, []); + // Hotkeys for live demo resilience + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + switch (e.key.toLowerCase()) { + case ' ': + e.preventDefault(); + if (playback.status === 'running') onPause(); + else onPlay(); + break; + case 'r': + onStop(); + onPlay(); + break; + case 'n': + case 'arrowright': + onSeekNext(); + break; + case 'b': + case 'arrowleft': + onSeekPrev(); + break; + case 'f': + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen?.().catch(() => undefined); + } else { + document.exitFullscreen?.().catch(() => undefined); + } + break; + case 'p': + setPresentationMode((v) => !v); + break; + case 'e': + setShowEventLog((v) => !v); + break; + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '0': { + const idx = e.key === '0' ? 9 : Number(e.key) - 1; + if (idx < PLAYLIST_LENGTH) onSeekCase(idx); + break; + } + default: + break; + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [playback.status, onPlay, onPause, onStop, onSeekNext, onSeekPrev, onSeekCase]); + const show3D = prefer3DByDefault(width, webgl && !contextLost); - const simplified = width < 900; + const qualityMode = detectQualityMode(width); + const simplified = width < 900 || qualityMode === 'low'; const viewportType: ViewportType = getViewportType(width); const isRunning = playback.status === 'running'; const isPaused = playback.status === 'paused'; const isFinished = playback.status === 'finished'; + const faultActive = isFaultActive(playback); const currentCase = playback.currentCase; const category = playback.targetCategory; const command = playback.command; const phaseConfig = getCurrentPhaseConfig(playback); const caseProgress = getCaseProgress(playback); - + const measurementData = useMemo(() => getMeasurementData(playback), [playback]); - const showMeasurement = shouldShowMeasurement(playback.currentPhase) && (isRunning || isPaused) && !isFinished; + const showMeasurement = + !presentationMode && + shouldShowMeasurement(playback.currentPhase) && + (isRunning || isPaused) && + !isFinished; const handlePlayPause = () => { - if (isRunning) { - onPause(); - } else { - onPlay(); - } + if (isRunning) onPause(); + else onPlay(); }; + const enterFullscreen = useCallback(() => { + document.documentElement.requestFullscreen?.().catch(() => undefined); + setPresentationMode(true); + }, []); + return ( -
- {/* Full-screen 3D Demo */} +
{show3D ? ( - {/* Measurement System Overlay - right center */} {width >= 768 && ( )} - {/* Minimal HUD - top right */} -
-
- Item - {currentCase.title} -
-
- Status - - {isFinished ? 'FINISHED' : phaseConfig.label} - -
-
- Category - - {category ?? '—'} - -
-
- Command - {command} -
-
- Speed - 1.0 m/s -
- {playback.warning && ( -
- ⚠ {playback.warning} + {!presentationMode && ( +
+
+ Item + {currentCase.title} +
+
+ Status + + {isFinished ? 'FINISHED' : phaseConfig.label} + +
+
+ Category + + {category ?? '—'} + +
+
+ Command + {command} +
+ {playback.classification && ( +
+ + {playback.classification.dimensionsPass ? 'DIM✓' : 'DIM✗'} · K= + {resolveItem(currentCase.itemId).roundness.toFixed(2)} · {playback.classification.reason} + +
+ )} +
+ Speed + {playback.speed.toFixed(1)}× · 1.0 m/s +
+ {playback.warning && ( +
+ ⚠ {playback.warning} +
+ )} +
+
+ Case + {playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH} +
+
+ {currentCase.description}
- )} -
-
- Case - {playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH}
-
- {currentCase.description} -
-
+ )} - {/* Case progress bar */}
-
+
- {/* Playlist progress dots */} -
- {DEMO_PLAYLIST.map((c, idx) => ( -
- ))} -
+ {!presentationMode && ( +
+ {DEMO_PLAYLIST.map((c, idx) => ( +
+ )} - {/* Play/Pause Button - center bottom */}
+ + {(isRunning || isPaused) && ( - )} + + {!presentationMode && ( +
+ {SPEEDS.map((s) => ( + + ))} +
+ )}
- {/* Finished overlay */} {isFinished && (

Demo Complete

-

All 8 cases demonstrated successfully

+

All {PLAYLIST_LENGTH} cases demonstrated — including safety scenarios

@@ -193,9 +303,22 @@ export default function MainPage({
)} - {/* Auto Camera Toggle - bottom left */} - {show3D && ( -
+ )} + + {show3D && !presentationMode && ( + )} - {/* Details link - bottom right */} - - Details → - + {!presentationMode && ( + <> + + + Details → + +
Space play · N/B seek · 1–0 jump · R reset · P present · E log · F fullscreen
+ + )} + + {presentationMode && ( + + )}
); } diff --git a/src/styles.css b/src/styles.css index 7ee97b1..a0e81a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2832,3 +2832,162 @@ button:disabled { max-height: calc(100vh - 320px); } } + +/* ——— Maximum demo realism: controls, presentation, events ——— */ +.ctrl-nav { + width: 44px; + height: 44px; + border-radius: 50%; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(5, 9, 16, 0.7); + color: #e2e8f0; + font-size: 16px; + cursor: pointer; +} +.ctrl-nav:hover { border-color: #38bdf8; color: #38bdf8; } + +.speed-controls { + display: flex; + gap: 4px; + margin-left: 8px; + padding: 4px; + border-radius: 999px; + background: rgba(5, 9, 16, 0.65); + border: 1px solid rgba(148, 163, 184, 0.25); +} +.speed-btn { + min-width: 40px; + height: 32px; + border: none; + border-radius: 999px; + background: transparent; + color: #94a3b8; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} +.speed-btn.active { + background: rgba(56, 189, 248, 0.25); + color: #7dd3fc; +} + +.progress-dot { + border: none; + cursor: pointer; + padding: 0; +} +.progress-dot.fault-dot { + background: rgba(239, 68, 68, 0.55); +} +.progress-dot.fault-dot.active { + background: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.45); +} + +.case-progress-bar.fault { + background: linear-gradient(90deg, #f97316, #ef4444); +} + +.main-page.fault-active .main-hud { + border-color: rgba(239, 68, 68, 0.45); +} + +.hud-value.status-fault, +.hud-value.proof-text { + color: #fda4af; +} +.hud-value.proof-text { + color: #cbd5e1; + font-size: 11px; + line-height: 1.35; +} + +.presentation-toggle, +.presentation-exit { + position: absolute; + z-index: 12; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid rgba(56, 189, 248, 0.35); + background: rgba(5, 9, 16, 0.75); + color: #7dd3fc; + font-weight: 700; + cursor: pointer; +} +.presentation-toggle { bottom: 16px; right: 140px; } +.presentation-exit { top: 16px; right: 16px; } + +.hotkey-hint { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 9; + font-size: 11px; + color: rgba(148, 163, 184, 0.75); + pointer-events: none; + white-space: nowrap; +} + +.main-event-log { + position: absolute; + left: 16px; + bottom: 100px; + z-index: 12; + width: min(360px, 42vw); + max-height: 240px; + overflow: auto; + padding: 12px; + border-radius: 12px; + background: rgba(5, 9, 16, 0.88); + border: 1px solid rgba(148, 163, 184, 0.25); + backdrop-filter: blur(10px); + color: #e2e8f0; + font-size: 12px; +} +.event-log-header { + font-weight: 800; + margin-bottom: 8px; + color: #7dd3fc; + letter-spacing: 0.04em; + text-transform: uppercase; + font-size: 11px; +} +.main-event-log ul { + list-style: none; + margin: 0; + padding: 0; +} +.main-event-log li { + display: flex; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); +} +.evt-time { color: #64748b; min-width: 28px; } +.evt-error .evt-msg { color: #fda4af; } +.evt-warning .evt-msg { color: #fdba74; } +.evt-success .evt-msg { color: #86efac; } + +.cv-reason-row .cv-reason { + font-size: 11px; + line-height: 1.3; + color: #cbd5e1; + white-space: normal; +} + +.main-page.presentation-mode .main-hud, +.main-page.presentation-mode .main-progress, +.main-page.presentation-mode .auto-camera-toggle, +.main-page.presentation-mode .details-link, +.main-page.presentation-mode .hotkey-hint, +.main-page.presentation-mode .presentation-toggle, +.main-page.presentation-mode .speed-controls { + display: none; +} + +@media (max-width: 900px) { + .speed-controls { display: none; } + .hotkey-hint { display: none; } + .presentation-toggle { right: 16px; bottom: 72px; } +}