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>
@@ -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-сцена» — пока нет.
|
||||
|
||||
---
|
||||
|
||||
40
docs/FINAL_DEMO_READINESS.md
Normal 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 — отдельный шаг после доменного фикса или явного запроса.
|
||||
53
docs/HEADED_GPU_PERFORMANCE_REPORT.md
Normal 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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
`}
|
||||
@@ -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 1–10, Previous/Next, Present, Play Demo.
|
||||
Подтверждено: CASE 1/10, Jump to case 1–10, Previous/Next, Present, Play Demo, `/details`.
|
||||
|
||||
122
docs/PUBLIC_DOMAIN_DIAGNOSTIC.md
Normal 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
|
||||
```
|
||||
@@ -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. Детали по критическим рискам
|
||||
|
||||
63
docs/TWIN_GEOMETRY_ALIGNMENT.md
Normal 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.
|
||||
44
e2e/production.smoke.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
BIN
e2e/visual.spec.ts-snapshots/01-home-idle-hud-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
e2e/visual.spec.ts-snapshots/02-class-b-hud-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
e2e/visual.spec.ts-snapshots/03-class-c-hud-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
e2e/visual.spec.ts-snapshots/04-class-d-hud-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
e2e/visual.spec.ts-snapshots/05-jam-fault-hud-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
e2e/visual.spec.ts-snapshots/06-emergency-hud-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 120 KiB |
BIN
e2e/visual.spec.ts-snapshots/08-details-page-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
e2e/visual.spec.ts-snapshots/09-recovery-hud-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
e2e/visual.spec.ts-snapshots/10-perf-overlay-chromium-linux.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
10
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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
302
scripts/headed-gpu-baseline.mjs
Normal 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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
143
src/components/ThreeD/PerfCollector.tsx
Normal 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} />;
|
||||
}
|
||||
91
src/components/ThreeD/PerfOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
src/domain/layout/index.ts
Normal 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';
|
||||
99
src/domain/layout/sharedLayout.ts
Normal 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;
|
||||
87
src/domain/perfMetrics.test.ts
Normal 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
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
62
src/domain/routeConsistency.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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 {};
|
||||
|
||||