fix: stabilize public demo and align sorter twins

Document arhipovdan.ru DNS/TLS blocker, add production smoke and GPU
perf tooling, unify twin layout, and expand visual regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-07-15 18:00:25 +00:00
parent b5985f33bb
commit 16e7930304
43 changed files with 1696 additions and 227 deletions

View File

@@ -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-сцена» — пока нет.
---

View File

@@ -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 — отдельный шаг после доменного фикса или явного запроса.

View File

@@ -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`

View File

@@ -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 3060 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.

View File

@@ -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
`}

View File

@@ -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:<release>`), порт `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:<release>`), порт `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 110, Previous/Next, Present, Play Demo.
Подтверждено: CASE 1/10, Jump to case 110, Previous/Next, Present, Play Demo, `/details`.

View File

@@ -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://<IP_этого_хоста_в_LAN>: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
Новое значение: <IP, где реально слушает ваш reverse proxy / CF>
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
```

View File

@@ -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. Детали по критическим рискам

View File

@@ -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.

View File

@@ -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([]);
});
});

View File

@@ -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,
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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",

View File

@@ -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',

View File

@@ -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);
});

View File

@@ -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 (
<group>
@@ -25,8 +25,8 @@ export default function Conveyor3D({ stopped }: Props) {
<planeGeometry args={[8.4, 0.5]} />
<meshStandardMaterial color={beltColor} roughness={0.75} />
</mesh>
{/* Conveyor side guards (borders) - brighter for visibility */}
{/* Conveyor side guards (borders) */}
<mesh position={[0, TWIN_LAYOUT.beltY + 0.06, 0.3]}>
<boxGeometry args={[8.4, 0.12, 0.04]} />
<meshStandardMaterial color={sideGuardColor} metalness={0.3} roughness={0.7} />
@@ -36,7 +36,7 @@ export default function Conveyor3D({ stopped }: Props) {
<meshStandardMaterial color={sideGuardColor} metalness={0.3} roughness={0.7} />
</mesh>
{/* 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) => (
<mesh key={x} position={[x, TWIN_LAYOUT.beltY - 0.08, 0]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.05, 0.05, 0.52, 12]} />
@@ -44,36 +44,33 @@ export default function Conveyor3D({ stopped }: Props) {
</mesh>
))}
{/* Accumulator / buffer at end of feed conveyor — explicit raised pocket */}
{/* Accumulator / buffer at end of feed conveyor */}
<group position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY, 0]}>
{/* Accumulator floor (raised pocket) */}
<mesh position={[0, -0.02, 0]}>
<boxGeometry args={[1.05, 0.12, 0.82]} />
<meshStandardMaterial color={accColor} roughness={0.65} emissive={accColor} emissiveIntensity={0.15} />
</mesh>
{/* Accumulator walls — three sides (back + left + right) */}
<mesh position={[0, 0.12, 0.38]}>
<boxGeometry args={[1.05, 0.28, 0.06]} />
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
<meshStandardMaterial color={INDUSTRIAL_PALETTE.sensorAccent} roughness={0.7} />
</mesh>
<mesh position={[0, 0.12, -0.38]}>
<boxGeometry args={[1.05, 0.28, 0.06]} />
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
<meshStandardMaterial color={INDUSTRIAL_PALETTE.sensorAccent} roughness={0.7} />
</mesh>
<mesh position={[-0.48, 0.12, 0]}>
<boxGeometry args={[0.06, 0.28, 0.82]} />
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
<meshStandardMaterial color={INDUSTRIAL_PALETTE.sensorAccent} roughness={0.7} />
</mesh>
{/* Direction arrow (subtle indication of flow) */}
<mesh position={[0.2, 0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.3, 0.2]} />
<meshStandardMaterial
color="#5eead4"
transparent
opacity={0.4}
side={2}
<meshStandardMaterial
color={INDUSTRIAL_PALETTE.lightFill}
transparent
opacity={0.4}
side={2}
/>
</mesh>
</group>

View File

@@ -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<PerfCollectorProps, 'enabled'>) {
const { gl } = useThree();
const frameTimes = useRef<number[]>([]);
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 <PerfCollectorInner mode={mode} shadows={shadows} antialias={antialias} />;
}

View File

@@ -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<PerfSnapshot>(() => 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 (
<div
className="perf-overlay"
data-testid="perf-overlay"
aria-label="Performance metrics"
>
<div className="perf-overlay-title">PERF</div>
<div>
FPS {snap.averageFps.toFixed(0)}
<span className="perf-muted"> (min {snap.minimumFps.toFixed(0)})</span>
</div>
<div>
p95 {snap.p95FrameTimeMs.toFixed(1)}ms
<span className="perf-muted"> / p99 {snap.p99FrameTimeMs.toFixed(1)}ms</span>
</div>
<div>
draws {snap.drawCalls}
<span className="perf-muted"> · tris {snap.triangles}</span>
</div>
<div>
geo {snap.geometries}
<span className="perf-muted">
{' '}
· tex {snap.textures} · prog {snap.programs}
</span>
</div>
<div>
heap {snap.heapMb.toFixed(1)}MB
<span className="perf-muted">
{' '}
· dpr {snap.dpr} · {snap.mode}
</span>
</div>
<div className={soft ? 'perf-soft' : 'perf-hw'} title={snap.renderer}>
{soft ? 'SW' : 'GPU'} {snap.renderer.slice(0, 42)}
{snap.renderer.length > 42 ? '…' : ''}
</div>
</div>
);
}

View File

@@ -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 */}
<Badge position={[TWIN_LAYOUT.startX, 0.85, 0]} text="A" color="#38bdf8" large />
<Badge position={[TWIN_LAYOUT.zoneBX, 0.95, 0]} text="B" color={ROUTE_COLORS.B} large />
<Badge position={[TWIN_LAYOUT.startX, beltY + 0.15, 0]} text="A" color="#38bdf8" large />
<Badge position={[TWIN_LAYOUT.zoneBX, beltY + 0.25, 0]} text="B" color={ROUTE_COLORS.B} large />
<Badge
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.35, TWIN_LAYOUT.zoneCZ]}
text="C"
@@ -90,7 +91,7 @@ export default function SceneLabels3D({
{/* Накопитель — only in technical mode (убран из clean view) */}
{technicalLabelsEnabled && (
<Badge
position={[TWIN_LAYOUT.accumulatorX, 0.95, 0.65]}
position={[TWIN_LAYOUT.accumulatorX, beltY + 0.25, 0.65]}
text="Накопитель"
color="#5eead4"
/>
@@ -101,40 +102,40 @@ export default function SceneLabels3D({
{/* Detection: Camera/CV + Laser */}
{detecting && (
<>
<Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />
<Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />
<Badge position={[TWIN_LAYOUT.cameraX, beltY + 0.5, -0.55]} text="Camera CV" color="#38bdf8" />
<Badge position={[TWIN_LAYOUT.laserX, beltY + 0.5, -0.55]} text="Laser" color="#22d3ee" />
</>
)}
{/* At gate: Stop-gate + Ultrasonic */}
{atGate && (
<>
<Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />
<Badge position={[TWIN_LAYOUT.gateX, beltY + 0.45, 0]} text="Stop-gate" color="#fda4af" />
</>
)}
{/* Routing: active pusher only */}
{routing && category === 'C' && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
<Badge position={[TWIN_LAYOUT.gateX - 0.1, beltY + 0.05, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
)}
{routing && category === 'D' && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
<Badge position={[TWIN_LAYOUT.gateX - 0.1, beltY + 0.05, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
)}
{/* Active route only (not all three routes) */}
{routing && category === 'B' && (
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="→ B" color={ROUTE_COLORS.B} />
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, beltY + 0.05, 0]} text="→ B" color={ROUTE_COLORS.B} />
)}
{routing && category === 'C' && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
position={[TWIN_LAYOUT.gateX + 0.2, beltY + 0.05, TWIN_LAYOUT.zoneCZ / 2]}
text="→ C"
color={ROUTE_COLORS.C}
/>
)}
{routing && category === 'D' && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
position={[TWIN_LAYOUT.gateX + 0.2, beltY + 0.05, TWIN_LAYOUT.zoneDZ / 2]}
text="→ D"
color={ROUTE_COLORS.D}
/>
@@ -152,40 +153,40 @@ export default function SceneLabels3D({
{/* Fault / Emergency stop */}
{fault && (
<Badge position={[0, 2.5, 0]} text={machineState} color="#fb3d4e" large />
<Badge position={[0, beltY + 1.8, 0]} text={machineState} color="#fb3d4e" large />
)}
{/* === TECHNICAL LABELS (opt-in) === */}
{technicalLabelsEnabled && !cleanView && (
<>
{/* Always show all sensors when technical mode is on */}
{!detecting && <Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />}
{!detecting && <Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />}
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.05, -0.55]} text="Ultrasonic" color="#67e8f9" />
{!detecting && <Badge position={[TWIN_LAYOUT.cameraX, beltY + 0.5, -0.55]} text="Camera CV" color="#38bdf8" />}
{!detecting && <Badge position={[TWIN_LAYOUT.laserX, beltY + 0.5, -0.55]} text="Laser" color="#22d3ee" />}
<Badge position={[TWIN_LAYOUT.ultrasonicX, beltY + 0.35, -0.55]} text="Ultrasonic" color="#67e8f9" />
{/* Always show all actuators */}
{!atGate && <Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />}
{!atGate && <Badge position={[TWIN_LAYOUT.gateX, beltY + 0.45, 0]} text="Stop-gate" color="#fda4af" />}
{!(routing && category === 'C') && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
<Badge position={[TWIN_LAYOUT.gateX - 0.1, beltY + 0.05, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
)}
{!(routing && category === 'D') && (
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
<Badge position={[TWIN_LAYOUT.gateX - 0.1, beltY + 0.05, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
)}
{/* All route beams */}
{!(routing && category === 'B') && (
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="Route B" color={ROUTE_COLORS.B} />
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, beltY + 0.05, 0]} text="Route B" color={ROUTE_COLORS.B} />
)}
{!(routing && category === 'C') && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
position={[TWIN_LAYOUT.gateX + 0.2, beltY + 0.05, TWIN_LAYOUT.zoneCZ / 2]}
text="Route C"
color={ROUTE_COLORS.C}
/>
)}
{!(routing && category === 'D') && (
<Badge
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
position={[TWIN_LAYOUT.gateX + 0.2, beltY + 0.05, TWIN_LAYOUT.zoneDZ / 2]}
text="Route D"
color={ROUTE_COLORS.D}
/>
@@ -193,7 +194,7 @@ export default function SceneLabels3D({
{/* Conveyor info */}
<Badge
position={[-3.2, 1.55, 1.4]}
position={[-3.2, beltY + 0.85, 1.4]}
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
color="#91a4b8"
/>

View File

@@ -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 (
<group>
<SensorPole x={TWIN_LAYOUT.cameraX} active={sensors.camera.active || detecting} labelColor="#38bdf8" />
<SensorPole x={TWIN_LAYOUT.laserX} active={sensors.laser.active} labelColor="#22d3ee" />
<SensorPole x={TWIN_LAYOUT.ultrasonicX} active={sensors.ultrasound.active} labelColor="#67e8f9" height={0.75} />
<SensorPole x={TWIN_LAYOUT.cameraX} active={sensors.camera.active || detecting} labelColor="#38bdf8" height={poleH} />
<SensorPole x={TWIN_LAYOUT.laserX} active={sensors.laser.active} labelColor="#22d3ee" height={poleH} />
<SensorPole x={TWIN_LAYOUT.ultrasonicX} active={sensors.ultrasound.active} labelColor="#67e8f9" height={ultrasonicH} />
{/* Detection beam - more visible */}
{detecting ? (

View File

@@ -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) */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[14, 10]} />
<meshStandardMaterial color="#0f1a2a" />
<meshStandardMaterial color={INDUSTRIAL_PALETTE.backgroundDark} />
</mesh>
<Conveyor3D stopped={stopped} />
@@ -118,7 +120,7 @@ function TwinScene({
{stopped ? (
<mesh position={[0, 1.2, 0]}>
<planeGeometry args={[6, 1.2]} />
<meshStandardMaterial color="#7f1d1d" transparent opacity={0.35} />
<meshStandardMaterial color={INDUSTRIAL_PALETTE.fault} transparent opacity={0.35} />
</mesh>
) : 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]}
/>
<FpsMeter onFps={onFps} />
</>
@@ -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 ? (
<PerfCollector
enabled
mode={simplified ? 'low' : 'demo'}
shadows={false}
antialias={!simplified}
/>
) : null}
</Suspense>
</Canvas>
</div>
{perfEnabled ? <PerfOverlay enabled /> : null}
</div>
);
}

View File

@@ -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 (
<div className="digital-twin-wrap continuous-twin">
<div className="digital-twin-canvas continuous-canvas">
@@ -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 ? (
<PerfCollector
enabled
mode={mode}
shadows={quality.shadows}
antialias={antialias}
/>
) : null}
</Suspense>
</Canvas>
</div>
{perfEnabled ? <PerfOverlay enabled /> : null}
</div>
);
}

View File

@@ -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 (
<group>
{/* Zone A feed — always visible cyan */}
{/* Zone A feed — pad under belt (aligned to shared belt height) */}
<ZoneBox
position={[TWIN_LAYOUT.startX, 0.22, 0]}
size={[1.0, 0.44, 1.0]}
position={[TWIN_LAYOUT.startX, beltY / 2, 0]}
size={[1.0, beltY, 1.0]}
color="#38bdf8"
active={active === undefined}
/>
{/* Zone B main sorter — green, straight */}
<ZoneBox
position={[TWIN_LAYOUT.zoneBX, 0.28, 0]}
size={[1.2, 0.56, 1.2]}
position={[TWIN_LAYOUT.zoneBX, beltY / 2, 0]}
size={[1.2, beltY, 1.2]}
color={ROUTE_COLORS.B}
active={active === 'B'}
/>

View File

@@ -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<Category, string> = {
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 {

View File

@@ -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';

View File

@@ -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;

View File

@@ -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,
});
});
});
});

130
src/domain/perfMetrics.ts Normal file
View File

@@ -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>): 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),
};
}

View File

@@ -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,

View File

@@ -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);
}
});
});

View File

@@ -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);

View File

@@ -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,

13
src/vite-env.d.ts vendored
View File

@@ -1 +1,14 @@
/// <reference types="vite/client" />
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 {};