diff --git a/docs/DEMO_RUNBOOK.md b/docs/DEMO_RUNBOOK.md index 720b38a..fd84dc2 100644 --- a/docs/DEMO_RUNBOOK.md +++ b/docs/DEMO_RUNBOOK.md @@ -1,8 +1,10 @@ # Demo Runbook — OZON Tech Sorter Simulation -**Назначение:** провести живую демонстрацию жюри/заказчику без сюрпризов. -**Дата актуализации:** 2026-07-15 -**Публичный URL:** https://arhipovdan.ru/ +**Назначение:** провести живую демонстрацию жюри/заказчику без сюрпризов. +**Дата актуализации:** 2026-07-15 +**Рабочий публичный URL (временный):** Quick Tunnel — см. `PRODUCTION_DEPLOYMENT_REPORT.md` +**Ожидаемый постоянный домен:** https://arhipovdan.ru/ — **BLOCKED_EXTERNAL** (`PUBLIC_DOMAIN_DIAGNOSTIC.md`) +**Локальный production:** http://127.0.0.1:3100/ **Репозиторий:** `/home/coder/arhipovdan/app` --- @@ -24,7 +26,7 @@ npm test npm run build ``` -Ожидание: Vitest **153** passed / **16** files; build OK. +Ожидание: Vitest **166** passed; build OK. ### 1.3. Health @@ -105,16 +107,16 @@ npm run demo:stop Порядок 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** +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`. @@ -141,9 +143,9 @@ git checkout backup/pre-maximum-demo-realism-20260715 ## 7. Что не обещать жюри -- «Настоящий CV с камеры» — нет, pseudo-CV. -- «Полный physics engine» — нет, кинематика. -- «Агент сам выкатывает в prod» — запрещено. +- «Настоящий CV с камеры» — нет, pseudo-CV. +- «Полный physics engine» — нет, кинематика. +- «Агент сам выкатывает в prod» — запрещено. - «`/` и `/details` — идентичная 3D-сцена» — пока нет. --- diff --git a/docs/FINAL_DEMO_READINESS.md b/docs/FINAL_DEMO_READINESS.md new file mode 100644 index 0000000..4a336f3 --- /dev/null +++ b/docs/FINAL_DEMO_READINESS.md @@ -0,0 +1,40 @@ +# FINAL_DEMO_READINESS + +Дата: 2026-07-15 +Ветка: `feature/maximum-demo-realism` +Production release (не тронут этим коммитом): `20260715-1712` / `index-ncgt6PBL.js` + +## Status board + +| Область | Статус | Комментарий | +| ------- | ------ | ----------- | +| Git | PASS | Feature branch clean after commit; no push | +| Production container | PASS | `owl-web-1` на `:3100`, bundle `index-ncgt6PBL.js` | +| Permanent domain | BLOCKED_EXTERNAL | `arhipovdan.ru` → openresty 404 / TLS SNI fail | +| TLS | BLOCKED_EXTERNAL | Нет SNI-сертификата на destination IP | +| Main route | PASS | Local + Quick Tunnel | +| Details route | PASS | SPA fallback 200 | +| Unit tests | PASS | 166 | +| E2E | PASS | 15 (excl. production) | +| Visual regression | PASS | 10 snapshots | +| GPU performance | PARTIAL | SwiftShader/xvfb only; hardware baseline blocked in coder | +| Safety scenarios | PASS | Jam + E-stop e2e | +| Rollback | PASS | Tag `backup/pre-maximum-demo-realism-20260715` + docker rename | +| Agent safety | PASS | Worktree / dry-run / no auto DNS | + +## Verdict + +```text +READY WITH EXTERNAL DOMAIN BLOCKER +``` + +Live demo **можно** вести с: + +- `http://127.0.0.1:3100/` (локально), или +- актуального Quick Tunnel `https://invitations-based-characters-accent.trycloudflare.com/` + +Постоянный домен `https://arhipovdan.ru/` **не готов** до DNS/proxy фикса пользователем (см. `PUBLIC_DOMAIN_DIAGNOSTIC.md`). + +## Next deploy note + +Новый код (perf overlay, twin layout, visual snapshots) собран как `index-DEbogfOZ.js` в `dist/`, но **не** задеплоен в `owl-web-1`, чтобы не нарушить работающий production. Redeploy — отдельный шаг после доменного фикса или явного запроса. diff --git a/docs/HEADED_GPU_PERFORMANCE_REPORT.md b/docs/HEADED_GPU_PERFORMANCE_REPORT.md new file mode 100644 index 0000000..7fea684 --- /dev/null +++ b/docs/HEADED_GPU_PERFORMANCE_REPORT.md @@ -0,0 +1,53 @@ +# HEADED_GPU_PERFORMANCE_REPORT + +Measured: 2026-07-15T17:48:54.064Z +Commit: `b5985f3` +Base URL: http://127.0.0.1:3101/ +Display: headed (:99) +Software renderer detected: **YES** +Verdict: **NOT A GPU BASELINE — software renderer (SwiftShader/llvmpipe/etc.)** + +## Notes + +- Headed Chromium with GPU-preferring flags. +- PerfCollector only with `?perf=1`; quality forced via `?quality=`. +- Prior headless ~9.6 FPS is labeled software/headless WebGL diagnostic baseline. +- **Host has NVIDIA GTX 1080 ×2**, but Playwright Chromium in this coder/Xvfb session resolves WebGL to **SwiftShader** — numbers below are **not** a hardware GPU baseline. +- For true GPU baseline: run `PLAYWRIGHT_BASE_URL=… npm run perf:gpu` on the presentation machine with a real `DISPLAY` and confirm renderer does **not** contain SwiftShader/llvmpipe. + +## Mode table (after Play) + +| Mode | Renderer | Avg FPS | Min FPS | p95 | Calls | Triangles | Heap | +| ---- | -------- | ------: | ------: | --: | ----: | --------: | ---: | +| low | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) | 10.58 | 4.44 | 151.9 | 167 | 3546 | 16.26 | +| medium | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) | 9.91 | 4.68 | 158.28 | 167 | 3546 | 16.38 | +| high | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) | 8.69 | 4.03 | 182.2 | 167 | 3546 | 17.45 | +| demo | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) | 8.88 | 4.17 | 181.81 | 167 | 3546 | 21.1 | + +## Memory after 3 seek+replay (demo mode) + +``` +{ + "heapDeltaMb": 2.19, + "geometriesDelta": 12, + "texturesDelta": 0 +} +``` + +## Prior software/headless WebGL diagnostic baseline (reference only) + +``` +Average FPS: 9.6 +Minimum FPS: 3.3 +p95 frame time: 200 ms +``` + +Do **not** treat the above as live-demo GPU characteristics. + +## Chromium launch args + +``` +--use-gl=angle --use-angle=gl-egl --enable-webgl --enable-webgl2 --ignore-gpu-blocklist --enable-gpu-rasterization --enable-precise-memory-info --enable-unsafe-swiftshader +``` + +Raw JSON: `agent/reports/gpu-baseline.json` diff --git a/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md b/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md index c31009a..e64e380 100644 --- a/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md +++ b/docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md @@ -1,35 +1,38 @@ # PERFORMANCE_AFTER_MAXIMUM_DEMO -Measured: 2026-07-15T17:11:06.031Z -Commit: `985f7c3` +Measured: 2026-07-15 +Commits referenced: `985f7c3` / tooling on current branch -## Bundle (measured) +## Bundle (production container, unchanged) | Метрика | Значение | | ------- | -------: | -| Total JS | 1300.9 KB | -| Total JS gzip | 358.1 KB | -| Main chunk | index-ncgt6PBL.js (96.4 KB gzip) | -| R3F chunk gzip | 226.1 KB | +| Main chunk | `index-ncgt6PBL.js` | +| Release | `20260715-1712` | -## Runtime (Playwright Chromium headless) +## software/headless WebGL diagnostic baseline -| Метрика | До | После | Цель | Статус | -| ------- | -: | ----: | ---: | ------ | -| Average FPS | n/a (не измерялось) | 9.6 | ≥30 | CHECK | -| Minimum FPS | n/a | 3.3 | ≥20 | CHECK | -| p95 frame time | n/a | 200.1 ms | ≤33 ms | CHECK | -| Load time | n/a | 1008 ms | ≤5000 | OK | -| JS heap start | n/a | 13400000 | — | measured | -| JS heap after interaction | n/a | 13400000 | no runaway | measured | -| Canvas present | — | 1 | ≥1 | OK | -| JS gzip (main+vendor) | ~335 KB main previously | 358.1 KB | — | measured | +**Не является характеристикой живой демонстрации.** + +```text +Average FPS: 9.6 +Minimum FPS: 3.3 +p95 frame time: 200 ms +``` + +Повторено под xvfb/SwiftShader (~8.9 FPS demo) — см. `HEADED_GPU_PERFORMANCE_REPORT.md`. + +## Headed / GPU tooling + +| Item | Status | +| ---- | ------ | +| `?perf=1` overlay | Implemented | +| `?quality=low\|medium\|high\|demo` | Implemented | +| `npm run perf:gpu` | Writes report + JSON | +| Hardware NVIDIA WebGL in coder | **Not available** (Chromium → SwiftShader) | +| True GPU baseline | Run on presentation laptop with DISPLAY + real GPU | ## Notes -- **Headless Chromium WebGL is not a GPU FPS measurement.** Values ~9 FPS / p95 ~200 ms reflect software/SwiftShader-like rendering in CI/headless, **not** desktop Chrome with NVIDIA. Do not claim 30–60 FPS from this pass. -- For jury/demo FPS, measure in headed Chromium on the presentation machine (`npm run test:e2e:headed` + DevTools Performance) or Engineering Details FPS meter. -- Draw calls / triangles require WebGL inspector in headed mode; not available in this headless pass. -- Demo quality mode keeps `shadows=false` and `effectsEnabled=false` for stable real-device FPS (see `qualityMode.ts`). High mode may enable shadows. -- Raw JSON: `agent/reports/perf-metrics.json` -- Runtime error (if any): none +- Demo quality keeps `shadows=false` for stable FPS. +- Do not quote SwiftShader FPS to jury as demo capability. diff --git a/docs/PLAYWRIGHT_E2E_REPORT.md b/docs/PLAYWRIGHT_E2E_REPORT.md index e418402..ea536a1 100644 --- a/docs/PLAYWRIGHT_E2E_REPORT.md +++ b/docs/PLAYWRIGHT_E2E_REPORT.md @@ -1,95 +1,62 @@ # Playwright E2E + Visual Regression -Lightweight end-to-end and visual regression coverage for the continuous demo on `/` and the SPA route `/details`. - -## Prerequisites - -- Node.js with project deps installed (`npm install`) -- Chromium for Playwright: `npx playwright install chromium` -- App reachable at the base URL (default preview: `http://127.0.0.1:3101`) - -By default tests **do not** start a server. Start preview yourself, for example: - -```bash -npm run build && npx vite preview --host 127.0.0.1 --port 3101 -``` - -Or point at an already-running preview/prod instance via `PLAYWRIGHT_BASE_URL`. - ## Scripts -| Script | Command | Purpose | -|--------|---------|---------| -| `test:e2e` | `playwright test` | Run all e2e + visual tests | -| `test:e2e:headed` | `playwright test --headed` | Same, headed browser | -| `test:e2e:update` | `playwright test --update-snapshots` | Refresh visual baselines | +| Script | Purpose | +|--------|---------| +| `npm run test:e2e` | Local e2e + visual (`--grep-invert @production`) | +| `npm run test:e2e:headed` | Same, headed | +| `npm run test:e2e:update` | Refresh visual baselines (manual review) | +| `npm run test:e2e:production` | Manual production smoke via `PLAYWRIGHT_BASE_URL` | ## Environment | Variable | Default | Meaning | |----------|---------|---------| -| `PLAYWRIGHT_BASE_URL` | `http://127.0.0.1:3101` | Target app origin | -| `PLAYWRIGHT_START_SERVER` | unset | Set to `1` to let Playwright run `vite preview` on port 3101 | +| `PLAYWRIGHT_BASE_URL` | `http://127.0.0.1:3101` | Target origin | +| `PLAYWRIGHT_START_SERVER` | unset | `1` → auto `vite preview` :3101 | -Examples: +## Config -```bash -# Against local preview (already running) -PLAYWRIGHT_BASE_URL=http://127.0.0.1:3101 npm run test:e2e - -# Against prod-style port -PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e - -# Auto-start preview if nothing is listening -PLAYWRIGHT_START_SERVER=1 npm run test:e2e -``` - -## Config highlights - -- **workers: 1** — keep e2e light on shared servers -- **retries: 1** -- **chromium only** -- Screenshot on failure; trace on first retry -- Soft visual thresholds (`threshold: 0.3`, `maxDiffPixelRatio: 0.05`) +- workers: **1** +- retries: **1** (trace on first retry) +- screenshot on failure +- visual: `threshold: 0.35`, `maxDiffPixelRatio: 0.08` +- CI: `npm ci` → `npm test` → `npm run build` → `npm run test:e2e` (no production smoke) ## Test map | Spec | Coverage | |------|----------| -| `e2e/smoke.spec.ts` | `/` loads, canvas (or loading→canvas), no `pageerror`, play works, replay if finished | -| `e2e/controls.spec.ts` | next/prev seek, jump case 0 & 8, speed buttons, presentation toggle | -| `e2e/safety.spec.ts` | jam (index 8) → FAULT; emergency (index 9) → EMERGENCY | -| `e2e/routes.spec.ts` | `/details`, refresh stays on details, navigate back to `/` | -| `e2e/visual.spec.ts` | Idle HUD panel snapshot only (not full WebGL canvas) | +| `smoke` | canvas, play | +| `controls` | seek, speed, presentation | +| `safety` | jam / emergency | +| `routes` | `/details` SPA | +| `visual` | 10 snapshots (HUD B/C/D/jam/E-stop/recovery, presentation, details, perf overlay) | +| `production.smoke` | `@production` — manual only | -Stable selectors live on `MainPage` as `data-testid` values (`demo-play`, `demo-hud`, `demo-case-{n}`, etc.). +## Snapshot list -## Updating visual snapshots - -1. Ensure the app at `PLAYWRIGHT_BASE_URL` matches the UI you want to lock. -2. Run: - -```bash -npm run test:e2e:update +```text +01-home-idle-hud +02-class-b-hud +03-class-c-hud +04-class-d-hud +05-jam-fault-hud +06-emergency-hud +07-presentation-mode +08-details-page +09-recovery-hud +10-perf-overlay ``` -3. Review diffs under `e2e/visual.spec.ts-snapshots/` (and commit those PNGs when intentional). -4. Re-run `npm run test:e2e` to confirm green. +HUD snapshots preferred over full WebGL pixel-perfect. Golden files are not auto-updated in CI. -Snapshots intentionally target **`[data-testid="demo-hud"]`** so WebGL canvas noise does not fail CI. +## Latest local run -## System deps - -On a fresh Linux host, Chromium may need OS libraries: - -```bash -npx playwright install chromium -npx playwright install-deps chromium +```text +15 passed (e2e, excl. production) +Production smoke @ :3100 — PASS (index-ncgt6PBL.js) +Production smoke @ Quick Tunnel — PASS +Production smoke @ arhipovdan.ru — FAIL (TLS/DNS blocker) ``` - -## Artifacts (gitignored) - -- `test-results/` — failure screenshots / traces -- `playwright-report/` — HTML report -- `blob-report/` — blob reporter output -`} \ No newline at end of file diff --git a/docs/PRODUCTION_DEPLOYMENT_REPORT.md b/docs/PRODUCTION_DEPLOYMENT_REPORT.md index 1e9f136..4eeb46e 100644 --- a/docs/PRODUCTION_DEPLOYMENT_REPORT.md +++ b/docs/PRODUCTION_DEPLOYMENT_REPORT.md @@ -1,15 +1,15 @@ # PRODUCTION_DEPLOYMENT_REPORT -Дата: 2026-07-15 -Ветка: `feature/maximum-demo-realism` -Базовый commit этапа 1: `985f7c3` +Дата: 2026-07-15 +Ветка: `feature/maximum-demo-realism` +Базовый commit этапа 1: `985f7c3` Production bundle после этапа 2: `index-ncgt6PBL.js` ## Способ деплоя -Docker-контейнер `owl-web-1` (image `owl-web:`), порт `127.0.0.1:3100→80`. -Docker API: `DOCKER_HOST=tcp://127.0.0.1:2375` (из coder-контейнера). -Compose-файл: `docker-compose.server.yml` (исторически `-p owl`). +Docker-контейнер `owl-web-1` (image `owl-web:`), порт `127.0.0.1:3100→80`. +Docker API: `DOCKER_HOST=tcp://127.0.0.1:2375` (из coder-контейнера). +Compose-файл: `docker-compose.server.yml` (исторически `-p owl`). Скрипт: `scripts/deploy-production.sh`. Публичный доступ в этой среде: @@ -47,6 +47,14 @@ curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' | `GET /details` (SPA) | 200 | | Bundle | `index-ncgt6PBL.js` | | Tunnel HTML | same bundle | +| `https://arhipovdan.ru/` | **BLOCKED_EXTERNAL** — `PUBLIC_DOMAIN_DIAGNOSTIC.md` | + +## Public smoke + +```bash +PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e:production +PLAYWRIGHT_BASE_URL=https://invitations-based-characters-accent.trycloudflare.com/ npm run test:e2e:production +``` ## Rollback @@ -61,4 +69,4 @@ docker start owl-web-1 ## Smoke (tunnel) -Подтверждено: CASE 1/10, Jump to case 1–10, Previous/Next, Present, Play Demo. +Подтверждено: CASE 1/10, Jump to case 1–10, Previous/Next, Present, Play Demo, `/details`. diff --git a/docs/PUBLIC_DOMAIN_DIAGNOSTIC.md b/docs/PUBLIC_DOMAIN_DIAGNOSTIC.md new file mode 100644 index 0000000..60f10b8 --- /dev/null +++ b/docs/PUBLIC_DOMAIN_DIAGNOSTIC.md @@ -0,0 +1,122 @@ +# PUBLIC_DOMAIN_DIAGNOSTIC + +Дата: 2026-07-15 +Окружение: coder container `coder-arhipov-owlprime` на хосте OwlPrime + +## Verdict + +```text +BLOCKED_EXTERNAL +``` + +Постоянный домен `https://arhipovdan.ru/` **не обслуживается** текущим production-контейнером `owl-web-1`. Приложение на `127.0.0.1:3100` здорово и отдаёт актуальный bundle; проблема на уровне DNS → чужой openresty на публичном IP. + +## DNS + +| Поле | Значение | +| ---- | -------- | +| Nameservers | `ns1.reg.ru.`, `ns2.reg.ru.` (REG.RU, **не** Cloudflare) | +| A | `185.160.137.162` (TTL ~86400 у registrar) | +| AAAA | **нет** | +| CNAME | нет на apex; `www` тоже → `185.160.137.162` | +| Cloudflare Proxy | **нет** (NS не Cloudflare) | + +Команды: + +```bash +dig A arhipovdan.ru +short # 185.160.137.162 +dig AAAA arhipovdan.ru +short # (empty) +dig NS arhipovdan.ru +short # ns1.reg.ru. ns2.reg.ru. +``` + +## Сетевая топология (факт) + +| Проверка | Результат | +| -------- | --------- | +| Egress IP хоста (ipify) | `185.160.137.162` (совпадает с A) | +| `127.0.0.1:80` / `:443` на хосте | **closed** | +| `185.160.137.162:80` / `:443` | **open**, отвечает **openresty** | +| `127.0.0.1:3100` | **open**, `owl-web-1` nginx → `index-ncgt6PBL.js` | + +Вывод: порт-форвард / другой хост за тем же публичным IP принимает 80/443. Это **не** namespace текущего Docker host listener set (на host net видны лишь `:3100` и `:8082`). + +## TLS / HTTP к домену + +| Проверка | Результат | +| -------- | --------- | +| `curl -4 http://arhipovdan.ru/` | **404** `Server: openresty` | +| `curl -4 https://arhipovdan.ru/` | **TLS fail**: `tlsv1 unrecognized name` (нет сертификата/vhost SNI для имени) | +| `curl -6` | нет AAAA → resolve fail | +| Certificate / SAN | получить нельзя (handshake abort) | +| HTML / bundle | недоступны через постоянный домен | + +## Cloudflare Tunnel + +| Поле | Значение | +| ---- | -------- | +| Type | **Quick Tunnel** only | +| Process | `/usr/local/bin/cloudflared tunnel --url http://127.0.0.1:3100` (pid в coder) | +| Named Tunnel | **не найден** (нет systemd unit, нет config.yml credentials) | +| Permanent ingress for arhipovdan.ru | **отсутствует** | +| Working public URL | `https://invitations-based-characters-accent.trycloudflare.com/` → bundle `index-ncgt6PBL.js` | + +Quick Tunnel **не** является завершённым production DNS-решением. + +## Точная причина 404 / TLS fail + +1. DNS A указывает на `185.160.137.162`. +2. На этом IP:80 отвечает **чужой openresty** без vhost `arhipovdan.ru` → HTTP 404. +3. На IP:443 нет SNI-сертификата для `arhipovdan.ru` → `unrecognized_name`. +4. Актуальный sorter (`owl-web-1`) слушает только `127.0.0.1:3100` и **не** связан с этим openresty. + +## Что было исправлено в коде/инфре приложения + +- Production container уже отдаёт `index-ncgt6PBL.js` на `:3100`. +- Добавлены диагностика, production smoke script, документация (этот файл). +- DNS/openresty **не** изменялись из этого окружения (нет доступа к REG.RU и к openresty vhost). + +## Действия пользователя (точные) + +### Вариант 1 — предпочтительно: Cloudflare Named Tunnel + смена NS/DNS + +1. Открыть Cloudflare → Add site `arhipovdan.ru` **или** создать Named Tunnel в существующем CF-аккаунте. +2. В REG.RU → DNS управления доменом: либо делегировать NS на Cloudflare, либо оставить REG.RU и создать запись, которую выдаст `cloudflared tunnel route dns`. +3. Ingress tunnel: + - hostname: `arhipovdan.ru` (+ `www`) + - service: `http://127.0.0.1:3100` (если cloudflared на том же host net, что и publish `3100`) +4. Выключить/не использовать Quick Tunnel как постоянный URL. +5. Проверить: + +```bash +dig A arhipovdan.ru +short +curl -I https://arhipovdan.ru/ +curl -s https://arhipovdan.ru/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' +# ожидается: index-ncgt6PBL.js +``` + +### Вариант 2 — починить openresty на том, кто слушает 185.160.137.162:80/443 + +1. На сервере с openresty добавить `server_name arhipovdan.ru www.arhipovdan.ru`. +2. `proxy_pass http://:3100;` (нужен LAN-доступ к OwlPrime:3100; сейчас publish только `127.0.0.1:3100` — потребуется сменить publish на `0.0.0.0:3100` или LAN IP **осознанно**). +3. Выпустить TLS (certbot/acme) для SNI `arhipovdan.ru`. +4. Проверить теми же curl-командами. + +### Вариант 3 — сменить A-запись в REG.RU + +```text +Тип: A +Имя: @ (и www) +Старое значение: 185.160.137.162 +Новое значение: +Proxy status: DNS only (если не Cloudflare) или Proxied (если Cloudflare) +``` + +## Проверочные команды после фикса + +```bash +curl -I https://arhipovdan.ru/ +curl -I https://arhipovdan.ru/details +curl -I https://arhipovdan.ru/assets/index-ncgt6PBL.js +curl -s https://arhipovdan.ru/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' +PLAYWRIGHT_BASE_URL=https://arhipovdan.ru/ npm run test:e2e:production +``` diff --git a/docs/RISK_REGISTER.md b/docs/RISK_REGISTER.md index 6684d50..46cddb3 100644 --- a/docs/RISK_REGISTER.md +++ b/docs/RISK_REGISTER.md @@ -13,27 +13,27 @@ | ID | Риск | Вероятность | Влияние | Статус | Митигация | | -- | ---- | ----------- | ------- | ------ | --------- | -| R01 | Production :3100 / https://arhipovdan.ru/ отдаёт старый dist | High | Critical | Open | `demo:health`; ручной redeploy; показывать :3101 при сомнении | -| R02 | Визуальный разрыв `/` vs `/details` путает жюри | High | High | Open | Вести показ на `/`; unify twins в плане | +| R01 | Production :3100 / https://arhipovdan.ru/ отдаёт старый dist / домен 404 | High | Critical | Open | Local+tunnel PASS; domain BLOCKED_EXTERNAL — `PUBLIC_DOMAIN_DIAGNOSTIC.md` | +| R02 | Визуальный разрыв `/` vs `/details` путает жюри | Medium | High | Mitigated | `sharedLayout` + routeConsistency tests | | 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 | +| R05 | Просадка FPS на ноутбуке жюри | Medium | High | Mitigated | quality modes; `?perf=1`; SwiftShader ≠ GPU | +| R06 | Регрессия classifier/min dims | Low | Critical | Mitigated | tests 166; 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 | +| R11 | Нет e2e в CI — UI регрессия незамечена | Low | Medium | Mitigated | CI + 10 visual snapshots; production smoke manual | | 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 документирован | +| R14 | Нет docker CLI в operator env | High (факт) | Medium | Accepted | `DOCKER_HOST=tcp://127.0.0.1:2375` | | 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 | +| R18 | Tunnel/cloudflared outage / permanent DNS | High | Critical | Open | Quick Tunnel temp; Named Tunnel + REG.RU DNS required | | R19 | Несогласованность документации и кода | Medium | Low | Mitigated | этот пакет docs = snapshot 2026-07-15 | | R20 | Попытка «добавить physics» перед показом → регрессия | Medium | High | Open | запрет P0-physics перед demo day | - +| R21 | Hardware GPU baseline недоступен в coder | High | Medium | Accepted | NVIDIA есть, Chromium→SwiftShader; измерять на demo laptop | --- ## 2. Детали по критическим рискам diff --git a/docs/TWIN_GEOMETRY_ALIGNMENT.md b/docs/TWIN_GEOMETRY_ALIGNMENT.md new file mode 100644 index 0000000..5498d76 --- /dev/null +++ b/docs/TWIN_GEOMETRY_ALIGNMENT.md @@ -0,0 +1,63 @@ +# Twin Geometry Alignment + +## Goal + +Keep `/` (continuous twin) and `/details` (state-machine twin) as projections of one sorter layout — same belt height, zone X/Z, sensors, gate, and category colors — without rewriting both scenes or enabling a physics engine. + +## What was unified + +| Concern | Source of truth | Consumers | +| --- | --- | --- | +| Conveyor dims, `BELT_TOP_Y` (0.7 m), zones A/B/C/D, gate, roll cages | `src/domain/physicalLayout.ts` | Continuous twin, `conveyorNetwork`, cameras | +| Shared bridge + details-derived layout | `src/domain/layout/sharedLayout.ts` | Details `TWIN_LAYOUT`, route/color tokens | +| Category / route colors | `src/domain/industrialTheme.ts` → `CATEGORY_COLORS` | Both twins, details `ROUTE_COLORS` | +| Class route directions (B→+X, C→+Z, D→−Z) | `CLASS_ROUTE_DIRECTIONS` in `sharedLayout` | Documentation / consistency | + +### Details twin derivation + +`itemMotion.ts` no longer hardcodes a compact layout (`beltY: 0.35`, divergent X). It exports: + +```ts +TWIN_LAYOUT = DETAILS_TWIN_LAYOUT // from sharedLayout +ROUTE_COLORS = CATEGORY_COLORS +PHYSICS_ENGINE_ENABLED = false +``` + +Aligned meters (vs previous details values): + +- `beltY` → `BELT_TOP_Y` (0.7) +- `startX` / `zoneBX` / `zoneCZ` / `zoneDZ` → `ZONES.A/B/C/D` +- `cameraX` / `laserX` / `gateX` → `ZONES.CAMERA/LASER/GATE` +- `rollCageSize` → `ROLL_CAGE` +- Progress keyframes (`progressForState`) unchanged — only absolute meters moved + +Details scene helpers (conveyor materials, zone pads, sensor poles, label Y) use `TWIN_LAYOUT.beltY` or `INDUSTRIAL_PALETTE` so the presentation layer tracks the shared height. + +### Consistency test + +`src/domain/routeConsistency.test.ts` asserts for each non-fault `DEMO_PLAYLIST` case: + +1. `classifyItem` category === `expectedCategory` +2. Continuous playback `targetCategory` matches after seek +3. `CATEGORY_COLORS` has an entry for that category +4. `getPhysicalItemPose` at mid-routing has `activeRoute` === category and `phase === 'routing'` + +## Intentional differences (keep) + +| Continuous (`/`) | Details (`/details`) | +| --- | --- | +| Engineering HUD: measurement, events, physical surfaces from `conveyorNetwork` | Presentation / teaching twin: state-machine progress keyframes | +| Deterministic `getPhysicalItemPose` along network surfaces | `itemPosition3D` progress along a simplified X (and Z for C/D) | +| Light industrial floor / grid (`INDUSTRIAL_PALETTE` light) | Dark engineering projection of the same palette | +| Full B receiver spur + chutes + cage floors | Simplified zone boxes + roll-cage visuals | +| Ultrasonic not a separate continuous station | Details-only `ultrasonicX` between laser and gate | +| Cinematic camera / playlist director | OrbitControls + machine-state HUD chips | + +Motion remains **state-machine / kinematic** only (`PHYSICS_ENGINE_ENABLED === false`). Do not enable a physics engine for alignment. + +## How to extend + +1. Change geometry in `physicalLayout.ts` (or zone map there). +2. Re-export / map in `layout/sharedLayout.ts` if details needs a new derived field. +3. Keep details keyframe ratios in `itemMotion.ts` unless timing intentionally changes. +4. Prefer `INDUSTRIAL_PALETTE` / `CATEGORY_COLORS` for new materials. diff --git a/e2e/production.smoke.spec.ts b/e2e/production.smoke.spec.ts new file mode 100644 index 0000000..5d9c9ef --- /dev/null +++ b/e2e/production.smoke.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +/** + * Production smoke — run manually: + * PLAYWRIGHT_BASE_URL=https://arhipovdan.ru/ npm run test:e2e:production + * or against the working tunnel / local prod: + * PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e:production + */ +test.describe('production smoke @production', () => { + test('serves current app shell and demo controls', async ({ page }) => { + const pageErrors: string[] = []; + const failed: string[] = []; + page.on('pageerror', (e) => pageErrors.push(String(e))); + page.on('response', (r) => { + if (r.status() >= 400 && r.url().includes(new URL(page.url()).host)) { + failed.push(`${r.status()} ${r.url()}`); + } + }); + + await page.goto('/'); + const html = await page.content(); + const bundle = html.match(/index-[A-Za-z0-9_-]+\.js/)?.[0] ?? ''; + expect(bundle).toMatch(/^index-/); + expect(bundle).not.toBe('index-CCdZzxPJ.js'); + + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('demo-play')).toBeVisible(); + await expect(page.getByTestId('demo-case-9')).toBeVisible(); + + await page.getByTestId('demo-play').click(); + await page.waitForTimeout(800); + await page.getByTestId('demo-case-8').click(); + await expect(page.getByTestId('demo-case-label')).toHaveText('9/10'); + + await page.goto('/details'); + await expect(page.locator('#root')).toBeVisible(); + await page.reload(); + await expect(page.locator('#root')).toBeVisible(); + + expect(pageErrors, pageErrors.join('\n')).toEqual([]); + const critical = failed.filter((f) => !f.includes('favicon')); + expect(critical, critical.join('\n')).toEqual([]); + }); +}); diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index f9561e6..19db7a6 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -1,20 +1,174 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; + +test.setTimeout(90_000); + +async function waitScene(page: Page) { + await page.goto('/'); + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); + await page.evaluate(() => document.fonts.ready); + await expect(page.getByTestId('demo-hud')).toBeVisible(); + // Demo auto-starts as running — pin speed early + await page.getByTestId('demo-speed-2').click(); +} + +async function dismissFinishedIfNeeded(page: Page) { + const finished = page.getByTestId('demo-finished'); + if (await finished.isVisible().catch(() => false)) { + await finished.getByRole('button', { name: /Replay/i }).click(); + await expect(finished).toHaveCount(0); + } +} + +async function jumpCase(page: Page, index: number) { + await dismissFinishedIfNeeded(page); + await page.getByTestId(`demo-case-${index}`).click(); + await expect(page.getByTestId('demo-case-label')).toHaveText(`${index + 1}/10`, { + timeout: 10_000, + }); +} + +async function ensureRunning(page: Page) { + await dismissFinishedIfNeeded(page); + const pause = page.getByTestId('demo-pause'); + if (await pause.isVisible().catch(() => false)) return; + await page.getByTestId('demo-play').click(); +} + +async function snapHud(page: Page, name: string) { + await dismissFinishedIfNeeded(page); + const hud = page.getByTestId('demo-hud'); + await expect(hud).toBeVisible(); + await expect(hud).toHaveScreenshot(name, { + threshold: 0.35, + maxDiffPixelRatio: 0.08, + }); +} test.describe('visual regression', () => { - test('home idle HUD panel', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); - - const hud = page.getByTestId('demo-hud'); - await expect(hud).toBeVisible(); - // Wait for idle classification proof row / stable HUD text - await expect(page.getByTestId('demo-command')).toHaveText('IDLE'); + test('01 home idle HUD', async ({ page }) => { + await waitScene(page); + // Pause for a stable idle-ish HUD + const pause = page.getByTestId('demo-pause'); + if (await pause.isVisible().catch(() => false)) { + await pause.click(); + } await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + await snapHud(page, '01-home-idle-hud.png'); + }); - // Snapshot HUD only — avoids flaky WebGL canvas pixels - await expect(hud).toHaveScreenshot('home-idle-hud.png', { - threshold: 0.3, - maxDiffPixelRatio: 0.05, + test('02 class B after seek case 0', async ({ page }) => { + await waitScene(page); + await jumpCase(page, 0); + await ensureRunning(page); + await expect(page.getByTestId('demo-category')).toHaveText('B', { timeout: 25_000 }); + await page.getByTestId('demo-pause').click().catch(() => undefined); + await snapHud(page, '02-class-b-hud.png'); + }); + + test('03 class C oversized case 2', async ({ page }) => { + await waitScene(page); + await jumpCase(page, 2); + await ensureRunning(page); + await expect(page.getByTestId('demo-category')).toHaveText('C', { timeout: 25_000 }); + await page.getByTestId('demo-pause').click().catch(() => undefined); + await snapHud(page, '03-class-c-hud.png'); + }); + + test('04 class D plate case 4', async ({ page }) => { + await waitScene(page); + await jumpCase(page, 4); + await ensureRunning(page); + await expect(page.getByTestId('demo-category')).toHaveText('D', { timeout: 25_000 }); + await page.getByTestId('demo-pause').click().catch(() => undefined); + await snapHud(page, '04-class-d-hud.png'); + }); + + test('05 jam fault HUD', async ({ page }) => { + await waitScene(page); + await jumpCase(page, 8); + await ensureRunning(page); + await expect + .poll(async () => { + const status = await page.getByTestId('demo-status').innerText(); + const cmd = await page.getByTestId('demo-command').innerText(); + return `${status} ${cmd}`; + }, { timeout: 30_000 }) + .toMatch(/FAULT|JAM/i); + await page.getByTestId('demo-pause').click().catch(() => undefined); + await snapHud(page, '05-jam-fault-hud.png'); + }); + + test('06 emergency stop HUD', async ({ page }) => { + await waitScene(page); + await jumpCase(page, 9); + await ensureRunning(page); + await expect + .poll(async () => { + const status = await page.getByTestId('demo-status').innerText(); + const cmd = await page.getByTestId('demo-command').innerText(); + return `${status} ${cmd}`; + }, { timeout: 30_000 }) + .toMatch(/EMERGENCY/i); + await page.getByTestId('demo-pause').click().catch(() => undefined); + await snapHud(page, '06-emergency-hud.png'); + }); + + test('07 presentation mode hides HUD', async ({ page }) => { + await waitScene(page); + await dismissFinishedIfNeeded(page); + await page.getByTestId('demo-presentation').click(); + await expect(page.getByTestId('demo-hud')).toHaveCount(0); + await expect(page.locator('.main-page')).toHaveScreenshot('07-presentation-mode.png', { + threshold: 0.4, + maxDiffPixelRatio: 0.12, + animations: 'disabled', + }); + }); + + test('08 details engineering page', async ({ page }) => { + await page.goto('/details'); + await expect(page.locator('#root')).toBeVisible(); + await page.waitForTimeout(1500); + await expect(page).toHaveScreenshot('08-details-page.png', { + threshold: 0.4, + maxDiffPixelRatio: 0.12, + fullPage: true, + animations: 'disabled', + }); + }); + + test('09 recovery after jam stop', async ({ page }) => { + await waitScene(page); + await jumpCase(page, 8); + await ensureRunning(page); + await expect + .poll(async () => { + const status = await page.getByTestId('demo-status').innerText(); + const cmd = await page.getByTestId('demo-command').innerText(); + return `${status} ${cmd}`; + }, { timeout: 30_000 }) + .toMatch(/FAULT|JAM/i); + // Stop resets toward recovery + const stop = page.getByTestId('demo-stop'); + if (await stop.isVisible().catch(() => false)) { + await stop.click({ force: true }); + } else { + await dismissFinishedIfNeeded(page); + await page.getByTestId('demo-case-0').click(); + } + await expect(page.getByTestId('demo-case-label')).toBeVisible(); + await snapHud(page, '09-recovery-hud.png'); + }); + + test('10 perf overlay engineering snapshot', async ({ page }) => { + await page.goto('/?perf=1&quality=demo'); + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); + await page.waitForTimeout(2000); + const overlay = page.locator('.perf-overlay'); + await expect(overlay).toBeVisible({ timeout: 15_000 }); + await expect(overlay).toHaveScreenshot('10-perf-overlay.png', { + threshold: 0.4, + maxDiffPixelRatio: 0.15, }); }); }); diff --git a/e2e/visual.spec.ts-snapshots/01-home-idle-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/01-home-idle-hud-chromium-linux.png new file mode 100644 index 0000000..62cf1ea Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/01-home-idle-hud-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/02-class-b-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/02-class-b-hud-chromium-linux.png new file mode 100644 index 0000000..a3f5f76 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/02-class-b-hud-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/03-class-c-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/03-class-c-hud-chromium-linux.png new file mode 100644 index 0000000..09cdbeb Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/03-class-c-hud-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/04-class-d-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/04-class-d-hud-chromium-linux.png new file mode 100644 index 0000000..ee2f6b5 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/04-class-d-hud-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/05-jam-fault-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/05-jam-fault-hud-chromium-linux.png new file mode 100644 index 0000000..4ea3651 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/05-jam-fault-hud-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/06-emergency-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/06-emergency-hud-chromium-linux.png new file mode 100644 index 0000000..1525d3d Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/06-emergency-hud-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/07-presentation-mode-chromium-linux.png b/e2e/visual.spec.ts-snapshots/07-presentation-mode-chromium-linux.png new file mode 100644 index 0000000..59b707b Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/07-presentation-mode-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/08-details-page-chromium-linux.png b/e2e/visual.spec.ts-snapshots/08-details-page-chromium-linux.png new file mode 100644 index 0000000..00e319d Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/08-details-page-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/09-recovery-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/09-recovery-hud-chromium-linux.png new file mode 100644 index 0000000..62cf1ea Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/09-recovery-hud-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png b/e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png new file mode 100644 index 0000000..e211f37 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/home-idle-hud-chromium-linux.png b/e2e/visual.spec.ts-snapshots/home-idle-hud-chromium-linux.png deleted file mode 100644 index a81f628..0000000 Binary files a/e2e/visual.spec.ts-snapshots/home-idle-hud-chromium-linux.png and /dev/null differ diff --git a/package.json b/package.json index 2904276..108ac7e 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "build": "tsc -b && vite build", "preview": "vite preview --host 127.0.0.1 --port 3100", "test": "vitest run --config vitest.config.ts", - "test:e2e": "playwright test", - "test:e2e:headed": "playwright test --headed", - "test:e2e:update": "playwright test --update-snapshots", + "test:e2e": "playwright test --grep-invert @production", + "test:e2e:headed": "playwright test --headed --grep-invert @production", + "test:e2e:update": "playwright test --update-snapshots --grep-invert @production", + "test:e2e:production": "playwright test e2e/production.smoke.spec.ts", "agent:dry-run": "node agent/cli.mjs dry-run", "agent:run-once": "node agent/cli.mjs run-once", "agent:implement": "node agent/cli.mjs implement", @@ -19,7 +20,8 @@ "agent:resume": "node agent/cli.mjs resume", "demo:start": "bash scripts/demo-start.sh", "demo:stop": "bash scripts/demo-stop.sh", - "demo:health": "bash scripts/demo-health.sh" + "demo:health": "bash scripts/demo-health.sh", + "perf:gpu": "node scripts/headed-gpu-baseline.mjs" }, "dependencies": { "@react-three/drei": "^10.7.7", diff --git a/playwright.config.ts b/playwright.config.ts index d5cb038..1cfce9c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,8 +19,8 @@ export default defineConfig({ expect: { toHaveScreenshot: { // Soft thresholds — WebGL/fonts can vary slightly across environments - threshold: 0.3, - maxDiffPixelRatio: 0.05, + threshold: 0.35, + maxDiffPixelRatio: 0.08, }, }, projects: [ @@ -29,6 +29,8 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], + // Keep visual/e2e deterministic on one worker (CI + local) + // Production smoke is excluded via package.json --grep-invert @production webServer: startServer ? { command: 'npm run preview -- --host 127.0.0.1 --port 3101', diff --git a/scripts/headed-gpu-baseline.mjs b/scripts/headed-gpu-baseline.mjs new file mode 100644 index 0000000..d959957 --- /dev/null +++ b/scripts/headed-gpu-baseline.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +/** + * Headed / hardware-preferring GPU baseline. + * Samples window.__PERF_SNAPSHOT__ for quality modes low|medium|high|demo. + * + * Usage: npm run perf:gpu + * Env: + * PLAYWRIGHT_BASE_URL default http://127.0.0.1:3100 + * PERF_USE_XVFB=0 skip auto xvfb when DISPLAY unset + */ +import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { chromium } from 'playwright'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const MODES = ['low', 'medium', 'high', 'demo']; + +const GPU_ARGS = [ + '--use-gl=angle', + '--use-angle=gl-egl', + '--enable-webgl', + '--enable-webgl2', + '--ignore-gpu-blocklist', + '--enable-gpu-rasterization', + '--enable-precise-memory-info', + '--enable-unsafe-swiftshader', // fallback if EGL/NVIDIA path unavailable in CI/coder +]; + +function resolveBaseUrl() { + const raw = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3100'; + return new URL(raw.includes('://') ? raw : `http://${raw}`); +} + +function urlForMode(mode) { + const u = resolveBaseUrl(); + u.searchParams.set('perf', '1'); + u.searchParams.set('quality', mode); + return u.toString(); +} + +function isSoftwareRenderer(renderer = '') { + return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|microsoft basic render/i.test( + renderer, + ); +} + +function emptySnap(mode = 'demo') { + return { + mode, + renderer: 'unknown', + averageFps: 0, + minimumFps: 0, + p95FrameTimeMs: 0, + p99FrameTimeMs: 0, + longFramesOver33: 0, + longFramesOver50: 0, + drawCalls: 0, + triangles: 0, + geometries: 0, + textures: 0, + programs: 0, + heapMb: 0, + dpr: 1, + shadows: false, + antialias: false, + hardwareAccelerated: true, + }; +} + +async function sampleSnapshot(page) { + const snap = await page.evaluate(() => { + const s = window.__PERF_SNAPSHOT__; + return s ? { ...s } : null; + }); + return snap ?? emptySnap(); +} + +async function measureMode(browser, mode, notes) { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + const url = urlForMode(mode); + try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.waitForSelector('canvas', { timeout: 45_000 }); + await page.waitForTimeout(2000); + await page.evaluate(() => window.__PERF_RESET__?.()); + await page.waitForTimeout(2500); + const idle = await sampleSnapshot(page); + + await page.evaluate(() => window.__PERF_RESET__?.()); + await page + .getByTestId('demo-play') + .click({ timeout: 8_000 }) + .catch(() => notes.push(`${mode}: play click skipped`)); + await page.waitForTimeout(4500); + const play = await sampleSnapshot(page); + + // memory stability probe: seek + replay light + const heapBefore = play.heapMb; + const geoBefore = play.geometries; + const texBefore = play.textures; + for (let i = 0; i < 3; i++) { + await page.getByTestId('demo-case-0').click().catch(() => undefined); + await page.waitForTimeout(400); + await page.getByTestId('demo-play').click().catch(() => undefined); + await page.waitForTimeout(1200); + } + const afterReplay = await sampleSnapshot(page); + + return { + mode, + url, + idle, + play, + afterReplay, + heapDeltaMb: Number((afterReplay.heapMb - heapBefore).toFixed(2)), + geometriesDelta: afterReplay.geometries - geoBefore, + texturesDelta: afterReplay.textures - texBefore, + software: isSoftwareRenderer(play.renderer || idle.renderer), + }; + } finally { + await page.close(); + } +} + +function renderMarkdown(report) { + const soft = report.softwareRendererDetected; + const rows = report.modes + .map((m) => { + const s = m.play; + return `| ${m.mode} | ${s.renderer} | ${s.averageFps} | ${s.minimumFps} | ${s.p95FrameTimeMs} | ${s.drawCalls} | ${s.triangles} | ${s.heapMb} |`; + }) + .join('\n'); + + const demo = report.modes.find((m) => m.mode === 'demo')?.play ?? emptySnap(); + let verdict = 'PARTIAL — software/headless diagnostic only'; + if (!soft && demo.averageFps >= 55 && demo.minimumFps >= 40 && demo.p95FrameTimeMs <= 20) { + verdict = 'PASS — Excellent Demo GPU'; + } else if (!soft && demo.averageFps >= 30 && demo.minimumFps >= 24 && demo.p95FrameTimeMs <= 33) { + verdict = 'PASS — Acceptable Demo GPU'; + } else if (!soft && (demo.averageFps < 30 || demo.p95FrameTimeMs > 40)) { + verdict = 'FAIL — Demo GPU below live demo bar'; + } else if (soft) { + verdict = 'NOT A GPU BASELINE — software renderer (SwiftShader/llvmpipe/etc.)'; + } + + return `# HEADED_GPU_PERFORMANCE_REPORT + +Measured: ${report.measuredAt} +Commit: \`${report.commit}\` +Base URL: ${report.baseUrl} +Display: ${report.displayNote} +Software renderer detected: **${soft ? 'YES' : 'no'}** +Verdict: **${verdict}** + +## Notes + +${report.notes.map((n) => `- ${n}`).join('\n')} + +## Mode table (after Play) + +| Mode | Renderer | Avg FPS | Min FPS | p95 | Calls | Triangles | Heap | +| ---- | -------- | ------: | ------: | --: | ----: | --------: | ---: | +${rows} + +## Memory after 3 seek+replay (demo mode) + +\`\`\` +${JSON.stringify( + report.modes.find((m) => m.mode === 'demo') + ? { + heapDeltaMb: report.modes.find((m) => m.mode === 'demo').heapDeltaMb, + geometriesDelta: report.modes.find((m) => m.mode === 'demo').geometriesDelta, + texturesDelta: report.modes.find((m) => m.mode === 'demo').texturesDelta, + } + : {}, + null, + 2, +)} +\`\`\` + +## Prior software/headless WebGL diagnostic baseline (reference only) + +\`\`\` +Average FPS: 9.6 +Minimum FPS: 3.3 +p95 frame time: 200 ms +\`\`\` + +Do **not** treat the above as live-demo GPU characteristics. + +## Chromium launch args + +\`\`\` +${report.launchArgs.join(' ')} +\`\`\` + +Raw JSON: \`agent/reports/gpu-baseline.json\` +`; +} + +async function runMeasurement() { + const notes = []; + const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + let displayNote = hasDisplay + ? `headed (${process.env.DISPLAY || process.env.WAYLAND_DISPLAY})` + : 'no DISPLAY — Chromium headless'; + + if (!hasDisplay) { + notes.push( + 'No DISPLAY/WAYLAND_DISPLAY. Prefer xvfb-run wrapper (auto) for a virtual headed session.', + ); + } else { + notes.push('Headed Chromium with GPU-preferring flags.'); + } + notes.push('PerfCollector only with ?perf=1; quality forced via ?quality=.'); + notes.push('Prior headless ~9.6 FPS is labeled software/headless WebGL diagnostic baseline.'); + + const launchArgs = [...GPU_ARGS]; + const browser = await chromium.launch({ + headless: !hasDisplay, + args: launchArgs, + }); + + const modes = []; + try { + for (const mode of MODES) { + modes.push(await measureMode(browser, mode, notes)); + } + } finally { + await browser.close(); + } + + const softwareRendererDetected = modes.some((m) => m.software); + const commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: ROOT, + encoding: 'utf8', + }).stdout.trim(); + + const report = { + measuredAt: new Date().toISOString(), + commit, + baseUrl: resolveBaseUrl().toString(), + headed: hasDisplay, + displayNote, + launchArgs, + softwareRendererDetected, + modes, + notes, + }; + + mkdirSync(join(ROOT, 'agent', 'reports'), { recursive: true }); + writeFileSync(join(ROOT, 'agent', 'reports', 'gpu-baseline.json'), JSON.stringify(report, null, 2)); + writeFileSync(join(ROOT, 'docs', 'HEADED_GPU_PERFORMANCE_REPORT.md'), renderMarkdown(report)); + + console.log( + JSON.stringify( + { + ok: true, + headed: hasDisplay, + softwareRendererDetected, + demoFps: modes.find((m) => m.mode === 'demo')?.play.averageFps, + renderer: modes.find((m) => m.mode === 'demo')?.play.renderer, + reports: ['docs/HEADED_GPU_PERFORMANCE_REPORT.md', 'agent/reports/gpu-baseline.json'], + }, + null, + 2, + ), + ); +} + +async function main() { + const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + const allowXvfb = process.env.PERF_USE_XVFB !== '0'; + const xvfb = existsSync('/usr/bin/xvfb-run'); + + // Re-exec under xvfb so Chromium gets a virtual DISPLAY (better chance at real GL). + if (!hasDisplay && allowXvfb && xvfb && !process.env.PERF_UNDER_XVFB) { + console.log('Re-launching under xvfb-run for virtual headed session…'); + const env = { ...process.env, PERF_UNDER_XVFB: '1' }; + delete env.DISPLAY; // let xvfb-run assign the real display + const r = spawnSync( + 'xvfb-run', + ['-a', '-s', '-screen 0 1440x900x24', 'node', fileURLToPath(import.meta.url)], + { + cwd: ROOT, + env, + stdio: 'inherit', + }, + ); + process.exit(r.status ?? 1); + } + + await runMeasurement(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/components/ThreeD/Conveyor3D.tsx b/src/components/ThreeD/Conveyor3D.tsx index 499b516..e981e77 100644 --- a/src/components/ThreeD/Conveyor3D.tsx +++ b/src/components/ThreeD/Conveyor3D.tsx @@ -1,16 +1,16 @@ import { TWIN_LAYOUT } from './itemMotion'; +import { INDUSTRIAL_PALETTE } from '../../domain/industrialTheme'; interface Props { stopped: boolean; } export default function Conveyor3D({ stopped }: Props) { - // Brighter colors for better visibility - const color = stopped ? '#7f1d1d' : '#3b5998'; - const beltColor = stopped ? '#991b1b' : '#5a6577'; - const accColor = stopped ? '#7f1d1d' : '#14b8a6'; - const sideGuardColor = '#6b7a8f'; - const rollerColor = '#8b9cb0'; + const color = stopped ? INDUSTRIAL_PALETTE.fault : INDUSTRIAL_PALETTE.frame; + const beltColor = stopped ? '#991b1b' : INDUSTRIAL_PALETTE.belt; + const accColor = stopped ? INDUSTRIAL_PALETTE.fault : INDUSTRIAL_PALETTE.sensorAccent; + const sideGuardColor = INDUSTRIAL_PALETTE.metal; + const rollerColor = INDUSTRIAL_PALETTE.plastic; return ( @@ -25,8 +25,8 @@ export default function Conveyor3D({ stopped }: Props) { - - {/* Conveyor side guards (borders) - brighter for visibility */} + + {/* Conveyor side guards (borders) */} @@ -36,7 +36,7 @@ export default function Conveyor3D({ stopped }: Props) { - {/* Rollers (visual indication of conveyor mechanism) — spaced every 1.2m */} + {/* Rollers — spaced every 1.2m */} {[-3.6, -2.4, -1.2, 0, 1.2].map((x) => ( @@ -44,36 +44,33 @@ export default function Conveyor3D({ stopped }: Props) { ))} - {/* Accumulator / buffer at end of feed conveyor — explicit raised pocket */} + {/* Accumulator / buffer at end of feed conveyor */} - {/* Accumulator floor (raised pocket) */} - - {/* Accumulator walls — three sides (back + left + right) */} + - + - + - + - - {/* Direction arrow (subtle indication of flow) */} + - diff --git a/src/components/ThreeD/PerfCollector.tsx b/src/components/ThreeD/PerfCollector.tsx new file mode 100644 index 0000000..085c177 --- /dev/null +++ b/src/components/ThreeD/PerfCollector.tsx @@ -0,0 +1,143 @@ +/** + * R3F performance sampler. Mount only when ?perf=1 or enabled prop. + * Exposes window.__PERF_SNAPSHOT__ (getter) and window.__PERF_RESET__. + */ + +import { useFrame, useThree } from '@react-three/fiber'; +import { useEffect, useRef } from 'react'; +import type { WebGLRenderer } from 'three'; +import { + computeFrameTimeStats, + emptyPerfSnapshot, + isHardwareAccelerated, + isPerfQueryEnabled, + roundPerfSnapshot, + type PerfSnapshot, +} from '../../domain/perfMetrics'; + +const MAX_SAMPLES = 600; + +export interface PerfCollectorProps { + /** Force-enable even without ?perf=1 */ + enabled?: boolean; + mode?: string; + shadows?: boolean; + antialias?: boolean; +} + +function readRendererString(gl: WebGLRenderer): string { + try { + const ctx = gl.getContext() as WebGLRenderingContext; + const dbg = ctx.getExtension('WEBGL_debug_renderer_info'); + if (dbg) { + return String(ctx.getParameter(dbg.UNMASKED_RENDERER_WEBGL) ?? 'unknown'); + } + return String(ctx.getParameter(ctx.RENDERER) ?? 'unknown'); + } catch { + return 'unknown'; + } +} + +function readHeapMb(): number { + const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; + if (!mem) return 0; + return mem.usedJSHeapSize / (1024 * 1024); +} + +function PerfCollectorInner({ + mode = 'demo', + shadows = false, + antialias = false, +}: Omit) { + const { gl } = useThree(); + const frameTimes = useRef([]); + const lastTs = useRef(0); + const meta = useRef({ mode, shadows, antialias, renderer: 'unknown' }); + meta.current = { ...meta.current, mode, shadows, antialias }; + + const buildSnapshot = (): PerfSnapshot => { + const stats = computeFrameTimeStats(frameTimes.current); + const info = gl.info; + const renderer = meta.current.renderer || readRendererString(gl); + return roundPerfSnapshot( + emptyPerfSnapshot({ + mode: meta.current.mode, + renderer, + ...stats, + drawCalls: info.render.calls, + triangles: info.render.triangles, + geometries: info.memory.geometries, + textures: info.memory.textures, + programs: info.programs?.length ?? 0, + heapMb: readHeapMb(), + dpr: gl.getPixelRatio(), + shadows: meta.current.shadows, + antialias: meta.current.antialias, + hardwareAccelerated: isHardwareAccelerated(renderer), + }), + ); + }; + + useEffect(() => { + meta.current.renderer = readRendererString(gl); + + const reset = () => { + frameTimes.current = []; + lastTs.current = 0; + gl.info.reset(); + }; + + Object.defineProperty(window, '__PERF_SNAPSHOT__', { + configurable: true, + enumerable: true, + get: () => buildSnapshot(), + }); + window.__PERF_RESET__ = reset; + + return () => { + reset(); + try { + delete window.__PERF_SNAPSHOT__; + } catch { + /* ignore */ + } + delete window.__PERF_RESET__; + }; + }, [gl]); + + useFrame((_state, delta) => { + // Prefer measured rAF delta; fall back to clock delta (seconds → ms) + const now = performance.now(); + let dtMs: number; + if (lastTs.current > 0) { + dtMs = now - lastTs.current; + } else { + dtMs = delta * 1000; + } + lastTs.current = now; + + // Ignore absurd spikes from tab backgrounding + if (dtMs <= 0 || dtMs > 500) return; + + const buf = frameTimes.current; + buf.push(dtMs); + if (buf.length > MAX_SAMPLES) buf.shift(); + }); + + return null; +} + +/** + * Safe wrapper: returns null unless enabled or ?perf=1. + * Keeps demos free of sampling overhead by default. + */ +export default function PerfCollector({ + enabled, + mode, + shadows, + antialias, +}: PerfCollectorProps) { + const active = enabled === true || (enabled !== false && isPerfQueryEnabled()); + if (!active) return null; + return ; +} diff --git a/src/components/ThreeD/PerfOverlay.tsx b/src/components/ThreeD/PerfOverlay.tsx new file mode 100644 index 0000000..cb95a25 --- /dev/null +++ b/src/components/ThreeD/PerfOverlay.tsx @@ -0,0 +1,91 @@ +/** + * Compact FPS / GPU overlay. Visible only with ?perf=1 and outside presentation mode. + */ + +import { useEffect, useState } from 'react'; +import { + isPerfQueryEnabled, + type PerfSnapshot, + emptyPerfSnapshot, +} from '../../domain/perfMetrics'; + +const POLL_MS = 500; + +export interface PerfOverlayProps { + /** Force show (still hidden in presentation mode via CSS / class check) */ + enabled?: boolean; +} + +export default function PerfOverlay({ enabled }: PerfOverlayProps) { + const [visible, setVisible] = useState( + () => enabled === true || (enabled !== false && isPerfQueryEnabled()), + ); + const [snap, setSnap] = useState(() => emptyPerfSnapshot()); + const [inPresentation, setInPresentation] = useState(false); + + useEffect(() => { + if (enabled === false) { + setVisible(false); + return; + } + setVisible(enabled === true || isPerfQueryEnabled()); + }, [enabled]); + + useEffect(() => { + if (!visible) return; + + const poll = () => { + const s = window.__PERF_SNAPSHOT__; + if (s) setSnap(s); + setInPresentation(!!document.querySelector('.presentation-mode')); + }; + + poll(); + const id = window.setInterval(poll, POLL_MS); + return () => window.clearInterval(id); + }, [visible]); + + if (!visible || inPresentation) return null; + + const soft = !snap.hardwareAccelerated; + + return ( +
+
PERF
+
+ FPS {snap.averageFps.toFixed(0)} + (min {snap.minimumFps.toFixed(0)}) +
+
+ p95 {snap.p95FrameTimeMs.toFixed(1)}ms + / p99 {snap.p99FrameTimeMs.toFixed(1)}ms +
+
+ draws {snap.drawCalls} + · tris {snap.triangles} +
+
+ geo {snap.geometries} + + {' '} + · tex {snap.textures} · prog {snap.programs} + +
+
+ heap {snap.heapMb.toFixed(1)}MB + + {' '} + · dpr {snap.dpr} · {snap.mode} + +
+
+ {soft ? 'SW' : 'GPU'} {snap.renderer.slice(0, 42)} + {snap.renderer.length > 42 ? '…' : ''} +
+
+ ); +} diff --git a/src/components/ThreeD/SceneLabels3D.tsx b/src/components/ThreeD/SceneLabels3D.tsx index e1f0e4b..af2fb75 100644 --- a/src/components/ThreeD/SceneLabels3D.tsx +++ b/src/components/ThreeD/SceneLabels3D.tsx @@ -57,6 +57,7 @@ export default function SceneLabels3D({ }: Props) { const category = currentItem?.classification.category as Category | undefined; const cage = TWIN_LAYOUT.rollCageSize; + const beltY = TWIN_LAYOUT.beltY; const detecting = machineState === 'DETECTING'; const atGate = machineState === 'WAITING_AT_GATE' || machineState === 'CLASSIFYING'; const routing = machineState.startsWith('ROUTE_TO_'); @@ -72,8 +73,8 @@ export default function SceneLabels3D({ {/* === CLEAN VIEW (always visible) === */} {/* Zones A/B/C/D — always visible */} - - + + @@ -101,40 +102,40 @@ export default function SceneLabels3D({ {/* Detection: Camera/CV + Laser */} {detecting && ( <> - - + + )} {/* At gate: Stop-gate + Ultrasonic */} {atGate && ( <> - + )} {/* Routing: active pusher only */} {routing && category === 'C' && ( - + )} {routing && category === 'D' && ( - + )} {/* Active route only (not all three routes) */} {routing && category === 'B' && ( - + )} {routing && category === 'C' && ( )} {routing && category === 'D' && ( @@ -152,40 +153,40 @@ export default function SceneLabels3D({ {/* Fault / Emergency stop */} {fault && ( - + )} {/* === TECHNICAL LABELS (opt-in) === */} {technicalLabelsEnabled && !cleanView && ( <> {/* Always show all sensors when technical mode is on */} - {!detecting && } - {!detecting && } - + {!detecting && } + {!detecting && } + {/* Always show all actuators */} - {!atGate && } + {!atGate && } {!(routing && category === 'C') && ( - + )} {!(routing && category === 'D') && ( - + )} {/* All route beams */} {!(routing && category === 'B') && ( - + )} {!(routing && category === 'C') && ( )} {!(routing && category === 'D') && ( @@ -193,7 +194,7 @@ export default function SceneLabels3D({ {/* Conveyor info */} diff --git a/src/components/ThreeD/SensorRig3D.tsx b/src/components/ThreeD/SensorRig3D.tsx index c4620af..83e776a 100644 --- a/src/components/ThreeD/SensorRig3D.tsx +++ b/src/components/ThreeD/SensorRig3D.tsx @@ -50,12 +50,14 @@ function SensorPole({ export default function SensorRig3D({ sensors, machineState }: Props) { const detecting = machineState === 'DETECTING'; + const poleH = TWIN_LAYOUT.beltY + 0.45; + const ultrasonicH = TWIN_LAYOUT.beltY + 0.3; return ( - - - + + + {/* Detection beam - more visible */} {detecting ? ( diff --git a/src/components/ThreeD/SorterDigitalTwin.tsx b/src/components/ThreeD/SorterDigitalTwin.tsx index e076d78..f21014a 100644 --- a/src/components/ThreeD/SorterDigitalTwin.tsx +++ b/src/components/ThreeD/SorterDigitalTwin.tsx @@ -1,6 +1,6 @@ import { Canvas, useFrame } from '@react-three/fiber'; import { Grid, OrbitControls } from '@react-three/drei'; -import { Suspense, useRef, useState } from 'react'; +import { Suspense, useMemo, useRef, useState } from 'react'; import type { SimulationState } from '../../domain/types'; import Conveyor3D from './Conveyor3D'; import SensorRig3D from './SensorRig3D'; @@ -13,6 +13,8 @@ import { PHYSICS_ENGINE_ENABLED } from './itemMotion'; import { NOMINAL_CONVEYOR_SPEED_MPS } from '../../domain/simulation'; import { DIMENSION_LIMITS } from '../../domain/classifier'; import { INDUSTRIAL_PALETTE } from '../../domain/industrialTheme'; +import PerfCollector from './PerfCollector'; +import PerfOverlay from './PerfOverlay'; export interface SorterDigitalTwinProps { simulation: SimulationState; @@ -81,19 +83,19 @@ function TwinScene({ args={[16, 16]} cellSize={0.5} cellThickness={0.7} - cellColor="#2a4a6a" + cellColor={INDUSTRIAL_PALETTE.metalDark} sectionSize={2} sectionThickness={1.2} - sectionColor="#3a6080" + sectionColor={INDUSTRIAL_PALETTE.frame} fadeDistance={16} infiniteGrid={false} position={[0, 0.001, 0]} /> - {/* Floor with better contrast */} + {/* Floor — shared industrial tokens (dark engineering projection) */} - + @@ -118,7 +120,7 @@ function TwinScene({ {stopped ? ( - + ) : null} @@ -128,7 +130,7 @@ function TwinScene({ maxPolarAngle={Math.PI / 2.1} minDistance={2.5} maxDistance={10} - target={[0.6, 0.4, 0]} + target={[0.6, 0.55, 0]} /> @@ -144,6 +146,12 @@ export default function SorterDigitalTwin({ onContextLost, }: SorterDigitalTwinProps) { const [fps, setFps] = useState(0); + const perfEnabled = useMemo( + () => + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('perf') === '1', + [], + ); const category = simulation.currentItem?.classification.category; const command = simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState @@ -192,9 +200,18 @@ export default function SorterDigitalTwin({ cleanView={cleanView} onFps={setFps} /> + {perfEnabled ? ( + + ) : null} + {perfEnabled ? : null} ); } diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index 5ccacb4..fdeea38 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -55,6 +55,8 @@ import { CAGE_FLOOR_Y, CONVEYOR_SPEED_MPS, } from '../../domain/physicalLayout'; +import PerfCollector from './PerfCollector'; +import PerfOverlay from './PerfOverlay'; export interface SorterDigitalTwinContinuousProps { playback: ContinuousPlaybackState; @@ -1452,6 +1454,10 @@ export default function SorterDigitalTwinContinuous({ const mode = qualityMode ?? detectQualityMode(typeof window !== 'undefined' ? window.innerWidth : 1200); const quality = getQualitySettings(mode); const useSimplified = simplified || mode === 'low'; + const antialias = quality.antialias && !useSimplified; + const perfEnabled = + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('perf') === '1'; return (
@@ -1459,7 +1465,7 @@ export default function SorterDigitalTwinContinuous({ camera={{ position: [4.5, 3.5, 5.0], fov: 45 }} dpr={[1, quality.dprMax]} shadows={quality.shadows} - gl={{ antialias: quality.antialias && !useSimplified, powerPreference: 'high-performance' }} + gl={{ antialias, powerPreference: 'high-performance' }} onCreated={({ gl }) => { const canvas = gl.domElement; const handleLost = (event: Event) => { @@ -1477,9 +1483,18 @@ export default function SorterDigitalTwinContinuous({ autoCameraEnabled={autoCameraEnabled} viewportType={viewportType} /> + {perfEnabled ? ( + + ) : null}
+ {perfEnabled ? : null}
); } diff --git a/src/components/ThreeD/SortingZones3D.tsx b/src/components/ThreeD/SortingZones3D.tsx index c661883..e59f6c1 100644 --- a/src/components/ThreeD/SortingZones3D.tsx +++ b/src/components/ThreeD/SortingZones3D.tsx @@ -119,21 +119,22 @@ function RollCage({ export default function SortingZones3D({ activeCategory, activeRoute }: Props) { const active = activeRoute ?? activeCategory; const cage = TWIN_LAYOUT.rollCageSize; + const beltY = TWIN_LAYOUT.beltY; return ( - {/* Zone A feed — always visible cyan */} + {/* Zone A feed — pad under belt (aligned to shared belt height) */} {/* Zone B main sorter — green, straight */} diff --git a/src/components/ThreeD/itemMotion.ts b/src/components/ThreeD/itemMotion.ts index 89e0a85..1271c6a 100644 --- a/src/components/ThreeD/itemMotion.ts +++ b/src/components/ThreeD/itemMotion.ts @@ -1,25 +1,19 @@ import type { Category, MachineState, SimulationState } from '../../domain/types'; +import { + CATEGORY_COLORS, + DETAILS_TWIN_LAYOUT, +} from '../../domain/layout/sharedLayout'; -/** Compact digital-twin coordinates (1 unit ≈ 1 m, scaled layout). */ -export const TWIN_LAYOUT = { - beltY: 0.35, - startX: -4.2, - endX: 4.2, - cameraX: -1.2, - laserX: -0.2, - ultrasonicX: 1.0, - gateX: 1.6, - accumulatorX: 1.4, - zoneBX: 3.8, - zoneCZ: 2.4, - zoneDZ: -2.4, - rollCageSize: { x: 1.2, y: 0.8, z: 0.8 }, -} as const; +/** + * Details-twin layout — derived from shared physical constants + * (belt height, zone X/Z, sensors, gate) so `/details` matches continuous scale. + */ +export const TWIN_LAYOUT = DETAILS_TWIN_LAYOUT; export const ROUTE_COLORS: Record = { - B: '#4ade80', - C: '#f59e0b', - D: '#c084fc', + B: CATEGORY_COLORS.B, + C: CATEGORY_COLORS.C, + D: CATEGORY_COLORS.D, }; export function progressForState(state: MachineState, elapsedMs: number): number { diff --git a/src/domain/layout/index.ts b/src/domain/layout/index.ts new file mode 100644 index 0000000..3b1c7ab --- /dev/null +++ b/src/domain/layout/index.ts @@ -0,0 +1,18 @@ +export { + SHARED_CONVEYOR, + SHARED_ZONES, + SHARED_SENSORS, + SHARED_GATE, + CLASS_ROUTE_DIRECTIONS, + DETAILS_TWIN_LAYOUT, + BELT_TOP_Y, + CONVEYOR_HEIGHT_M, + CONVEYOR_LENGTH_M, + CONVEYOR_SPEED_MPS, + CONVEYOR_WIDTH_M, + MAIN_BELT_WIDTH_M, + ROLL_CAGE, + ZONES, + CATEGORY_COLORS, + INDUSTRIAL_PALETTE, +} from './sharedLayout'; diff --git a/src/domain/layout/sharedLayout.ts b/src/domain/layout/sharedLayout.ts new file mode 100644 index 0000000..8e1670d --- /dev/null +++ b/src/domain/layout/sharedLayout.ts @@ -0,0 +1,99 @@ +/** + * Shared twin geometry — single source of truth for continuous (`/`) and details (`/details`). + * + * Continuous twin consumes `physicalLayout` / `conveyorNetwork` directly. + * Details twin derives `TWIN_LAYOUT` from these constants so belt height and + * zone/sensor X positions stay aligned without rewriting both scenes. + */ + +import { + BELT_TOP_Y, + CONVEYOR_HEIGHT_M, + CONVEYOR_LENGTH_M, + CONVEYOR_SPEED_MPS, + CONVEYOR_WIDTH_M, + MAIN_BELT_WIDTH_M, + ROLL_CAGE, + ZONES, +} from '../physicalLayout'; +import { CATEGORY_COLORS, INDUSTRIAL_PALETTE } from '../industrialTheme'; +import type { Category } from '../types'; + +// Re-export palette + physical primitives used by both twins +export { + BELT_TOP_Y, + CONVEYOR_HEIGHT_M, + CONVEYOR_LENGTH_M, + CONVEYOR_SPEED_MPS, + CONVEYOR_WIDTH_M, + MAIN_BELT_WIDTH_M, + ROLL_CAGE, + ZONES, +}; +export { CATEGORY_COLORS, INDUSTRIAL_PALETTE }; + +/** Conveyor dims shared across both twins. */ +export const SHARED_CONVEYOR = { + widthM: CONVEYOR_WIDTH_M, + heightM: CONVEYOR_HEIGHT_M, + lengthM: CONVEYOR_LENGTH_M, + speedMps: CONVEYOR_SPEED_MPS, + beltTopY: BELT_TOP_Y, + beltWidthM: MAIN_BELT_WIDTH_M, +} as const; + +/** Zone A/B/C/D positions (meters). */ +export const SHARED_ZONES = { + A: { x: ZONES.A.x, z: ZONES.A.z, label: ZONES.A.label }, + B: { x: ZONES.B.x, z: ZONES.B.z, label: ZONES.B.label }, + C: { x: ZONES.C.x, z: ZONES.C.z, label: ZONES.C.label }, + D: { x: ZONES.D.x, z: ZONES.D.z, label: ZONES.D.label }, +} as const; + +/** Sensor stations along the main belt. */ +export const SHARED_SENSORS = { + cameraX: ZONES.CAMERA.x, + laserX: ZONES.LASER.x, + /** Details twin only — mid-span between laser and gate. */ + ultrasonicX: (ZONES.LASER.x + ZONES.GATE.x) / 2, +} as const; + +/** Stop-gate / diverter junction. */ +export const SHARED_GATE = { + x: ZONES.GATE.x, + z: ZONES.GATE.z, +} as const; + +/** Route exit directions after the gate (engineering + presentation). */ +export const CLASS_ROUTE_DIRECTIONS: Record< + Category, + { axis: 'x' | 'z'; sign: 1 | -1; color: string } +> = { + B: { axis: 'x', sign: 1, color: CATEGORY_COLORS.B }, + C: { axis: 'z', sign: 1, color: CATEGORY_COLORS.C }, + D: { axis: 'z', sign: -1, color: CATEGORY_COLORS.D }, +}; + +/** + * Details-twin layout derived from physical constants. + * Keyframes still use progress ratios; only absolute meters change. + */ +export const DETAILS_TWIN_LAYOUT = { + beltY: BELT_TOP_Y, + startX: ZONES.A.x, + endX: ZONES.B.x + 0.2, + cameraX: SHARED_SENSORS.cameraX, + laserX: SHARED_SENSORS.laserX, + ultrasonicX: SHARED_SENSORS.ultrasonicX, + gateX: SHARED_GATE.x, + /** Buffer pocket just upstream of the gate. */ + accumulatorX: SHARED_GATE.x - 0.1, + zoneBX: ZONES.B.x, + zoneCZ: ZONES.C.z, + zoneDZ: ZONES.D.z, + rollCageSize: { + x: ROLL_CAGE.width, + y: ROLL_CAGE.height, + z: ROLL_CAGE.depth, + }, +} as const; diff --git a/src/domain/perfMetrics.test.ts b/src/domain/perfMetrics.test.ts new file mode 100644 index 0000000..12e5ec4 --- /dev/null +++ b/src/domain/perfMetrics.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { + percentile, + computeFrameTimeStats, + isSoftwareRenderer, + isHardwareAccelerated, + isPerfQueryEnabled, + emptyPerfSnapshot, +} from './perfMetrics'; + +describe('perfMetrics', () => { + describe('percentile', () => { + it('returns 0 for empty input', () => { + expect(percentile([], 0.95)).toBe(0); + }); + + it('returns the sole value', () => { + expect(percentile([16], 0.99)).toBe(16); + }); + + it('interpolates p95 on a sorted series', () => { + const values = [10, 20, 30, 40, 50]; + // idx = 0.95 * 4 = 3.8 → 40 * 0.2 + 50 * 0.8 = 48 + expect(percentile(values, 0.95)).toBeCloseTo(48, 5); + }); + + it('returns endpoints at 0 and 1', () => { + const values = [1, 2, 3, 4]; + expect(percentile(values, 0)).toBe(1); + expect(percentile(values, 1)).toBe(4); + }); + }); + + describe('computeFrameTimeStats', () => { + it('derives fps and long-frame counts', () => { + const frames = [16, 16, 16, 40, 16, 60]; + const s = computeFrameTimeStats(frames); + expect(s.longFramesOver33).toBe(2); + expect(s.longFramesOver50).toBe(1); + expect(s.averageFps).toBeGreaterThan(0); + expect(s.minimumFps).toBeCloseTo(1000 / 60, 5); + expect(s.p95FrameTimeMs).toBeGreaterThanOrEqual(s.p99FrameTimeMs > 0 ? 0 : 0); + }); + }); + + describe('renderer detection', () => { + it('flags SwiftShader and llvmpipe as software', () => { + expect(isSoftwareRenderer('Google SwiftShader')).toBe(true); + expect(isSoftwareRenderer('llvmpipe (LLVM 15.0.7)')).toBe(true); + expect(isHardwareAccelerated('ANGLE (NVIDIA GeForce RTX 3080)')).toBe(true); + }); + }); + + describe('isPerfQueryEnabled', () => { + it('reads perf=1 from a search string', () => { + expect(isPerfQueryEnabled('?perf=1')).toBe(true); + expect(isPerfQueryEnabled('?foo=1')).toBe(false); + expect(isPerfQueryEnabled('')).toBe(false); + }); + }); + + describe('emptyPerfSnapshot', () => { + it('matches the expected JSON shape defaults', () => { + const s = emptyPerfSnapshot(); + expect(s).toMatchObject({ + mode: 'demo', + renderer: 'unknown', + averageFps: 0, + minimumFps: 0, + p95FrameTimeMs: 0, + p99FrameTimeMs: 0, + longFramesOver33: 0, + longFramesOver50: 0, + drawCalls: 0, + triangles: 0, + geometries: 0, + textures: 0, + programs: 0, + heapMb: 0, + dpr: 1, + shadows: false, + antialias: false, + hardwareAccelerated: true, + }); + }); + }); +}); diff --git a/src/domain/perfMetrics.ts b/src/domain/perfMetrics.ts new file mode 100644 index 0000000..ed5083a --- /dev/null +++ b/src/domain/perfMetrics.ts @@ -0,0 +1,130 @@ +/** + * Pure helpers and types for Three.js performance snapshots. + * Used by PerfCollector (runtime) and unit tests — no R3F/DOM side effects here. + */ + +export interface PerfSnapshot { + mode: string; + renderer: string; + averageFps: number; + minimumFps: number; + p95FrameTimeMs: number; + p99FrameTimeMs: number; + longFramesOver33: number; + longFramesOver50: number; + drawCalls: number; + triangles: number; + geometries: number; + textures: number; + programs: number; + heapMb: number; + dpr: number; + shadows: boolean; + antialias: boolean; + hardwareAccelerated: boolean; +} + +export interface FrameTimeStats { + averageFps: number; + minimumFps: number; + p95FrameTimeMs: number; + p99FrameTimeMs: number; + longFramesOver33: number; + longFramesOver50: number; +} + +/** Linear-interpolation percentile on a sorted ascending array. `p` in [0, 1]. */ +export function percentile(sortedAsc: number[], p: number): number { + if (sortedAsc.length === 0) return 0; + if (sortedAsc.length === 1) return sortedAsc[0]; + const clamped = Math.min(1, Math.max(0, p)); + const idx = clamped * (sortedAsc.length - 1); + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sortedAsc[lo]; + const t = idx - lo; + return sortedAsc[lo] * (1 - t) + sortedAsc[hi] * t; +} + +export function computeFrameTimeStats(frameTimesMs: number[]): FrameTimeStats { + if (frameTimesMs.length === 0) { + return { + averageFps: 0, + minimumFps: 0, + p95FrameTimeMs: 0, + p99FrameTimeMs: 0, + longFramesOver33: 0, + longFramesOver50: 0, + }; + } + + const sorted = [...frameTimesMs].sort((a, b) => a - b); + const avgMs = frameTimesMs.reduce((a, b) => a + b, 0) / frameTimesMs.length; + const maxMs = sorted[sorted.length - 1]; + + return { + averageFps: avgMs > 0 ? 1000 / avgMs : 0, + minimumFps: maxMs > 0 ? 1000 / maxMs : 0, + p95FrameTimeMs: percentile(sorted, 0.95), + p99FrameTimeMs: percentile(sorted, 0.99), + longFramesOver33: frameTimesMs.filter((t) => t > 33).length, + longFramesOver50: frameTimesMs.filter((t) => t > 50).length, + }; +} + +/** Detect software / CPU renderers (SwiftShader, llvmpipe, etc.). */ +export function isSoftwareRenderer(renderer: string): boolean { + return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|microsoft basic render/i.test( + renderer, + ); +} + +export function isHardwareAccelerated(renderer: string): boolean { + if (!renderer || renderer === 'unknown') return false; + return !isSoftwareRenderer(renderer); +} + +export function emptyPerfSnapshot(partial?: Partial): PerfSnapshot { + return { + mode: 'demo', + renderer: 'unknown', + averageFps: 0, + minimumFps: 0, + p95FrameTimeMs: 0, + p99FrameTimeMs: 0, + longFramesOver33: 0, + longFramesOver50: 0, + drawCalls: 0, + triangles: 0, + geometries: 0, + textures: 0, + programs: 0, + heapMb: 0, + dpr: 1, + shadows: false, + antialias: false, + hardwareAccelerated: true, + ...partial, + }; +} + +/** True when `?perf=1` is present in the current URL. */ +export function isPerfQueryEnabled(search?: string): boolean { + const q = + search ?? + (typeof window !== 'undefined' ? window.location.search : ''); + return new URLSearchParams(q).get('perf') === '1'; +} + +export function roundPerfSnapshot(snap: PerfSnapshot, digits = 2): PerfSnapshot { + const r = (n: number) => Number(n.toFixed(digits)); + return { + ...snap, + averageFps: r(snap.averageFps), + minimumFps: r(snap.minimumFps), + p95FrameTimeMs: r(snap.p95FrameTimeMs), + p99FrameTimeMs: r(snap.p99FrameTimeMs), + heapMb: r(snap.heapMb), + dpr: r(snap.dpr), + }; +} diff --git a/src/domain/physicalLayout.ts b/src/domain/physicalLayout.ts index b33d046..d007c02 100644 --- a/src/domain/physicalLayout.ts +++ b/src/domain/physicalLayout.ts @@ -217,6 +217,7 @@ export const CAMERA_RIG = { // ========================================================= // Derived constants for legacy compatibility +// Details twin layout is derived in domain/layout/sharedLayout.ts // ========================================================= export const CONVEYOR_POSITIONS = { spawnX: ZONES.A.x, diff --git a/src/domain/routeConsistency.test.ts b/src/domain/routeConsistency.test.ts new file mode 100644 index 0000000..3e9809c --- /dev/null +++ b/src/domain/routeConsistency.test.ts @@ -0,0 +1,62 @@ +/** + * Route consistency — playlist expectedCategory ↔ classifier ↔ playback ↔ pose. + */ + +import { describe, it, expect } from 'vitest'; +import { DEMO_PLAYLIST } from './demoPlaylist'; +import { classifyItem } from './classifier'; +import { resolveItem } from '../data/resolveItem'; +import { + CASE_PHASES, + createPlaybackState, + seekToCase, + startPlayback, +} from './continuousPlayback'; +import { CATEGORY_COLORS } from './industrialTheme'; +import { getPhysicalItemPose } from './physicalItemMotion'; +import type { Category } from './types'; + +/** Mid-routing elapsed time for normal CASE_PHASES timeline. */ +function routingElapsedMs(): number { + let elapsed = 0; + for (const p of CASE_PHASES) { + if (p.phase === 'routing') { + return elapsed + p.durationMs / 2; + } + elapsed += p.durationMs; + } + throw new Error('CASE_PHASES missing routing phase'); +} + +describe('routeConsistency', () => { + const nonFaultCases = DEMO_PLAYLIST.filter((c) => !c.faultType); + + it('every non-fault playlist case stays consistent across classifier, playback, colors, and pose', () => { + const midRouting = routingElapsedMs(); + + for (const playlistCase of nonFaultCases) { + const item = resolveItem(playlistCase.itemId); + const classification = classifyItem(item); + + expect(classification.category).toBe(playlistCase.expectedCategory); + + const caseIndex = DEMO_PLAYLIST.findIndex((c) => c.id === playlistCase.id); + const playback = seekToCase(startPlayback(createPlaybackState()), caseIndex); + expect(playback.targetCategory).toBe(playlistCase.expectedCategory); + expect(playback.classification?.category).toBe(playlistCase.expectedCategory); + + const category = playlistCase.expectedCategory as Category; + expect(CATEGORY_COLORS[category]).toBeDefined(); + expect(typeof CATEGORY_COLORS[category]).toBe('string'); + + const pose = getPhysicalItemPose({ + caseId: playlistCase.id, + dimensionsMm: item.dimensionsMm, + targetCategory: classification.category, + elapsedMs: midRouting, + }); + expect(pose.phase).toBe('routing'); + expect(pose.activeRoute).toBe(category); + } + }); +}); diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index bd5ae1c..da0ed09 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -107,7 +107,13 @@ export default function MainPage({ }, [playback.status, onPlay, onPause, onStop, onSeekNext, onSeekPrev, onSeekCase]); const show3D = prefer3DByDefault(width, webgl && !contextLost); - const qualityMode = detectQualityMode(width); + const qualityOverride = useMemo(() => { + if (typeof window === 'undefined') return undefined; + const q = new URLSearchParams(window.location.search).get('quality'); + if (q === 'low' || q === 'medium' || q === 'high' || q === 'demo') return q; + return undefined; + }, []); + const qualityMode = qualityOverride ?? detectQualityMode(width); const simplified = width < 900 || qualityMode === 'low'; const viewportType: ViewportType = getViewportType(width); diff --git a/src/styles.css b/src/styles.css index a0e81a2..ddd90d7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -515,6 +515,7 @@ button:disabled { } .digital-twin-wrap { + position: relative; display: flex; flex-direction: column; gap: 10px; @@ -2976,6 +2977,35 @@ button:disabled { white-space: normal; } +/* Perf instrumentation overlay (?perf=1 only) */ +.perf-overlay { + position: absolute; + top: 10px; + left: 10px; + z-index: 40; + min-width: 180px; + max-width: 280px; + padding: 8px 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.35; + color: #e8f0f7; + background: rgba(8, 14, 22, 0.78); + border: 1px solid rgba(120, 160, 200, 0.35); + pointer-events: none; + user-select: none; +} +.perf-overlay-title { + font-weight: 700; + letter-spacing: 0.06em; + margin-bottom: 4px; + color: #9fd0ff; +} +.perf-overlay .perf-muted { color: rgba(200, 220, 240, 0.55); } +.perf-overlay .perf-soft { color: #f0b4a0; margin-top: 4px; } +.perf-overlay .perf-hw { color: #a8e0b8; margin-top: 4px; } +.main-page.presentation-mode .perf-overlay { display: none; } + .main-page.presentation-mode .main-hud, .main-page.presentation-mode .main-progress, .main-page.presentation-mode .auto-camera-toggle, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..781fd8a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +import type { PerfSnapshot } from './domain/perfMetrics'; + +declare global { + interface Window { + /** Live Three.js perf snapshot (getter) — only when ?perf=1 / PerfCollector mounted */ + __PERF_SNAPSHOT__?: PerfSnapshot; + /** Clear frame-time samples and gl.info counters */ + __PERF_RESET__?: () => void; + } +} + +export {};