diff --git a/docs/stage2e/README.md b/docs/stage2e/README.md new file mode 100644 index 0000000..9043762 --- /dev/null +++ b/docs/stage2e/README.md @@ -0,0 +1,26 @@ +# Stage 2E — Runtime visual, physics p95, domain, phone + +Artifacts for closing Stage 2D leftover evidence only. + +## Status + +**PARTIAL_SORTER_PUBLIC_RUNTIME** + +Local product closed (physics p95, hardware FPS, runtime videos, hash parity, drop 7×10, production+rollback). Permanent HTTPS domain and real phones remain external blockers. + +## Key results + +| Gate | Result | +|------|--------| +| Physics p95 | **~0.20 ms** (budget ≤4 ms) PASS | +| Hardware FPS A/B/C | 60 / p95 ~16.8–17.5 ms PASS | +| Runtime videos | 7 routes + 4×0.25 slow WebMs PASS | +| Hash parity | `2ee3ad6fa2e90f4b` == PASS | +| Drop validation | 7/7 × 10/10 PASS | +| Permanent domain | BLOCKED (openresty / TLS unrecognized_name) | +| Real phone | NOT_TESTED | +| Production | `owl-web:stage2e-20260730-140024` on `:3100` | + +## Files + +See directory listing — JSON profiles, videos/, screenshots/, STAGE2E_REPORT.md. diff --git a/docs/stage2e/STAGE2E_REPORT.md b/docs/stage2e/STAGE2E_REPORT.md new file mode 100644 index 0000000..36e3186 --- /dev/null +++ b/docs/stage2e/STAGE2E_REPORT.md @@ -0,0 +1,176 @@ +# STAGE 2E REPORT — Runtime visual, physics profiling, HTTPS domain, phone + +## 1. STAGE_2E_STATUS + +**PARTIAL_SORTER_PUBLIC_RUNTIME** + +## 2. EXECUTIVE_SUMMARY + +- Physics step p95 measured on GTX 1080: **≈ 0.20 ms** across all four profiles (budget ≤ 4 ms) — PASS. +- Hardware GPU frame performance remains PASS (avg 60 FPS, p95 ≈ 16.8–17.5 ms, draws ≤ 174, tris ≤ 216k). +- Runtime visual evidence: real WebM captures for all 7 SKU routes + 4 slow-motion 0.25×; screenshots for overlay/contact/settled phases; no teleport path. +- Drop validation **7/7 × 10/10**; runtime/headless hash **equal** (`2ee3ad6fa2e90f4b`). +- Permanent domain `sorter.arhipovdan.ru` still **BLOCKED** (A→185.160.137.162 openresty 404 / TLS unrecognized_name; no Cloudflare origin cert). +- Real Android / iPhone / Telegram: **NOT_TESTED**; `/device-test` QR + protocol ready. +- Main remaining blocker: **DNS/Cloudflare access** (and physical phone once HTTPS works). + +## 3. GIT_SAFETY + +- Backup: `/tmp/ozone-before-stage2e/` +- Tag: `backup/pre-stage2e-runtime-domain-phone-20260730-1311` +- Branch: `feature/ozon-sorter-stage-2e-runtime-domain-phone` (from Stage 2D `014a460`) +- No reset/hard/stash/rebase/force-push; main/dan_branch untouched + +## 4. PHYSICS_INSTRUMENTATION + +| Item | Detail | +|------|--------| +| Step site | `RapierStepper` in `SorterPhysics.tsx` — `world.step()` only | +| Timestep | `PHYSICS_DT = 1/60` | +| Max substeps | 4 | +| Sampler | `PhysicsPerfSampler` ring buffer 1800 samples | +| Exposure | `window.__PHYSICS_PERF__` when `?perf=1` / `?physicsPerf=1` | +| React in hot loop | **No** — ref + window; UI polls 500 ms | +| Render time mixed? | **No** | + +## 5. PHYSICS_RESULTS + +Hardware: ANGLE Vulkan · NVIDIA GeForce GTX 1080. Median of 3× ≥30 s runs. + +| Profile | Avg | Median | P95 | P99 | Max | >4 ms | Bodies | Contacts | Result | +|---------|-----|--------|-----|-----|-----|-------|--------|----------|--------| +| Belt only | 0.079 | 0.100 | **0.200** | 0.200 | 1.70 | 0 | ~3 | 0 | PASS | +| Mechanism contact | 0.074 | 0.100 | **0.200** | 0.200 | 0.50 | 0 | ~3 | 0 | PASS | +| Drop collision | 0.071 | 0.100 | **0.200** | 0.200 | 0.50 | 0 | ~3 | 0 | PASS | +| Worst case | 0.074 | 0.100 | **0.200** | 0.200 | 0.40 | 0 | ~3 | 0 | PASS | + +Budget optimization not required. + +## 6. PERFORMANCE_RESULTS + +| Profile | GPU | Avg FPS | Min | P95 frame | Physics P95 | Draws | Tris | Result | +|---------|-----|---------|-----|-----------|-------------|-------|------|--------| +| A CAD Light | GTX 1080 | 60 | 50 | 16.8 | 0.20 | 162 | 182k | PASS | +| B CAD+Physics | GTX 1080 | 60 | 34 | 17.3 | 0.20 | 160 | 197k | PASS | +| C Full Runtime | GTX 1080 | 60 | 33 | 17.5 | 0.20 | 160–174 | 197–216k | PASS | +| E Mobile SVG @390 | N/A | — | — | — | — | — | — | PASS_SVG_POLICY | +| E Low 3D @800 | GTX 1080 | 60 | — | 18.1 | 0.20 | 106 | 177k | PASS | + +## 7. RUNTIME_VISUAL_REVIEW + +Videos in `docs/stage2e/videos/` (Playwright recordVideo, Vulkan GPU). + +| SKU | Zone | Runtime runs | Contact | Penetration | Teleport | Settled | Visual | Result | +|-----|------|--------------|---------|-------------|----------|---------|--------|--------| +| SKU-001 box | B | video+slow | N/A (B) | none observed | NO | video | PASS | PASS | +| SKU-009 pen | C | video | yes | none observed | NO | video | PASS | PASS | +| SKU-005 pouf | C | video | yes | none observed | NO | video | PASS | PASS | +| SKU-004 oversized | C | video+slow | yes | none observed | NO | video | PASS | PASS | +| SKU-007 bottle | D | video | yes | none observed | NO | video/screenshot | PASS | PASS | +| SKU-006 plate | D | video+slow | yes | none observed | NO | video | PASS | PASS | +| SKU-008 cylinder | D | video+slow | yes | none observed | NO | video | PASS | PASS | + +Debug collider evidence: `01-physics-overlay.png`, `02-mechanism-contact.png` (`?debug=1&physics=1`). + +## 8. RUNTIME_HEADLESS_PARITY + +- runtimeHash: `2ee3ad6fa2e90f4b` +- headlessHash: `2ee3ad6fa2e90f4b` +- Fields: timestep, maxSubsteps, gravity, settleSeconds, pusher geometry/timing, static colliders, SKU friction/restitution/damping/collider sizes, receiver volumes +- Result: **PASS** + +## 9. DROP_VALIDATION + +**7/7 × 10/10 PASS** — see `drop-validation.json`. Route B contact N/A (no pusher); C/D require `pusherContactMade`. + +## 10. PUBLIC_DOMAIN + +| Check | Result | +|-------|--------| +| DNS A | 185.160.137.162 | +| HTTP | openresty 404 | +| HTTPS | TLS unrecognized_name | +| Cloudflare Named Tunnel | NOT_CONFIGURED (no origin cert) | +| Local :3100 | PASS | +| Blocker | **BLOCKED_BY_DEPLOYMENT_ACCESS** | + +Owner action: replace `sorter` A with CNAME → `.cfargotunnel.com` (Proxied). Do not change apex. + +## 11. REAL_PHONE_VALIDATION + +| Device | Browser | Renderer | Mode | FPS | Layout | Controls | Result | +|--------|---------|----------|------|-----|--------|----------|--------| +| Android | — | — | — | — | — | — | NOT_TESTED | +| iPhone | — | — | — | — | — | — | NOT_TESTED | +| Telegram | — | — | — | — | — | — | NOT_TESTED | + +`/device-test` QR encodes `https://sorter.arhipovdan.ru/device-test` (no tokens). Protocol in `mobile-real-device-report.md`. + +## 12. MOBILE_FALLBACK + +- Width < 640 → SVG by policy — PASS +- Telegram WebView → forced SVG — PASS (e2e) +- Capable Low 3D @800 — PASS (60 FPS) + +## 13. DEPLOYMENT + +| Item | Value | +|------|-------| +| Image | `owl-web:stage2e-20260730-140024` / `owl-web:latest` | +| Container | `owl-web-1` on `127.0.0.1:3100` | +| Asset | `index-DW5Sc3w-.js` | +| Rollback | `owl-web-1-rollback-stage2d` (image `owl-web:20260730-121615`) + prior stage2e rename containers | +| Preview | `http://127.0.0.1:3101` | + +See `deployment-report.md`. + +## 14. TEST_RESULTS + +| Suite | Result | +|-------|--------| +| `npx tsc -b` | PASS | +| `npm test` (202) | PASS | +| `npm run build` | PASS | +| Physics profiles | PASS | +| Drop 7×10 | PASS | +| Hash parity | PASS | +| Console errors (lab) | 0 | +| e2e safety / mobile / production smoke | re-run after overlay fix | + +## 15. KNOWN_LIMITATIONS + +- Permanent HTTPS blocked externally +- No real handset in lab +- Playwright drawCalls=1 on some runs = reset race (filtered for median) +- Mechanism remains SPEC_DERIVED_CAD (author CAD still missing) + +## 16. DECISION + +| Question | Answer | +|----------|--------| +| Physics p95 measured? | **YES** | +| Budget ≤4 ms? | **YES (~0.20 ms)** | +| Runtime physics evidenced? | **YES (videos + screenshots)** | +| Runtime/headless match? | **YES** | +| Permanent domain works? | **NO** | +| Android tested? | **NO** | +| iPhone tested? | **NO** | +| Telegram tested? | **NO** (policy e2e only) | +| Public demo ready? | **PARTIAL** — local/lab ready; domain+phone required for full public demo | + +## 17. FILES_CHANGED + +- `src/domain/physicsPerf.ts` (new) +- `src/components/ThreeD/SorterPhysics.tsx` — instrumentation +- `src/components/ThreeD/PerfOverlay.tsx` — physics p95 line +- `src/domain/physicsConfigHash.ts` — expanded snapshot +- `src/domain/demoPlaylist.ts` — pouf_c (12 cases) +- `src/pages/DeviceTestPage.tsx` — QR + physics p95 +- `scripts/stage2e-*.mts` +- `docs/stage2e/**` +- e2e playlist length / dismissFinished / mobile width fix +- `qrcode` dependency + +## 18. FINAL_GIT_CHECK + +Filled at commit time. diff --git a/docs/stage2e/bundle-impact.md b/docs/stage2e/bundle-impact.md new file mode 100644 index 0000000..ed736ec --- /dev/null +++ b/docs/stage2e/bundle-impact.md @@ -0,0 +1,19 @@ +# Bundle impact — Stage 2E + +Built from branch feature/ozon-sorter-stage-2e-runtime-domain-phone. + +| File | Bytes | +|------|------:| +| SorterDigitalTwinContinuous-DiRxBHF-.js | 4723546 | +| industrialTheme-2ZVHc82-.js | 883356 | +| draco_decoder-fzg4nYZr.js | 719410 | +| index-DW5Sc3w-.js | 383009 | +| PostProcessingSpike-CK95vKfN.js | 163257 | +| draco_wasm_wrapper-DxJM36Ib.js | 58763 | +| draco_wasm_wrapper-fZCQGLGb.js | 58456 | +| index-DCp95eWw.css | 41964 | +| rapier-Dx-VO52Q.js | 2699 | +| ThreeCapabilityCheck-Cr_gD4i9.js | 2460 | + +Added dependency: `qrcode` for /device-test QR (no secrets in QR payload). +Physics instrumentation is ref/window only — negligible bundle cost. diff --git a/docs/stage2e/console-errors.json b/docs/stage2e/console-errors.json new file mode 100644 index 0000000..2fe867d --- /dev/null +++ b/docs/stage2e/console-errors.json @@ -0,0 +1,5 @@ +{ + "generatedAt": "2026-07-30T13:31:57.363Z", + "count": 0, + "errors": [] +} \ No newline at end of file diff --git a/docs/stage2e/deployment-report.md b/docs/stage2e/deployment-report.md new file mode 100644 index 0000000..6c0fa45 --- /dev/null +++ b/docs/stage2e/deployment-report.md @@ -0,0 +1,42 @@ +# Deployment — Stage 2E + +## Local production + +| Item | Value | +|------|-------| +| Container | `owl-web-1` | +| Image | `owl-web:stage2e-20260730-140024` (also tagged `owl-web:latest`) | +| Bind | `127.0.0.1:3100→80` | +| Asset | `assets/index-DW5Sc3w-.js` | +| Health | `GET /` 200, `GET /device-test` 200, `GET /details` 200 | +| Preview | `http://127.0.0.1:3101/` (vite preview; not duplicate production) | + +## Rollback (one command family) + +Previous Stage 2D container preserved as `owl-web-1-rollback-stage2d` (image `owl-web:20260730-121615` retained). + +```bash +# Via Docker HTTP API (this host has no docker CLI in PATH): +curl -X POST http://127.0.0.1:2375/containers/owl-web-1/stop?t=5 +curl -X POST http://127.0.0.1:2375/containers/owl-web-1/rename?name=owl-web-1-stage2e-failed +curl -X POST http://127.0.0.1:2375/containers/owl-web-1-rollback-stage2d/rename?name=owl-web-1 +curl -X POST http://127.0.0.1:2375/containers/owl-web-1/start +``` + +Or with docker CLI if available: + +```bash +docker stop owl-web-1 && docker rename owl-web-1 owl-web-1-stage2e-failed +docker rename owl-web-1-rollback-stage2d owl-web-1 && docker start owl-web-1 +``` + +## Public domain + +- `sorter.arhipovdan.ru` A→`185.160.137.162` openresty 404 / TLS `unrecognized_name` +- Cloudflare Named Tunnel: **not configured** (no origin cert in environment) +- Status: **BLOCKED_BY_DEPLOYMENT_ACCESS** +- Owner DNS action required (CNAME `sorter` → `.cfargotunnel.com`, Proxied) + +## Secrets + +No credentials, tokens, or private keys recorded in this report. diff --git a/docs/stage2e/device-test-results/device-test-local.png b/docs/stage2e/device-test-results/device-test-local.png new file mode 100644 index 0000000..1924d88 Binary files /dev/null and b/docs/stage2e/device-test-results/device-test-local.png differ diff --git a/docs/stage2e/drop-validation.json b/docs/stage2e/drop-validation.json new file mode 100644 index 0000000..4fb8791 --- /dev/null +++ b/docs/stage2e/drop-validation.json @@ -0,0 +1,74 @@ +{ + "generatedAt": "2026-07-30T13:23:17.688Z", + "runtimeHash": "2ee3ad6fa2e90f4b", + "headlessHash": "2ee3ad6fa2e90f4b", + "parity": true, + "summary": "7/7 \u00d7 10/10", + "pass": true, + "note": "Route B has no pusher \u2014 contact criterion N/A; C/D require pusherContactMade", + "routes": [ + { + "sku": "SKU-001", + "zone": "B", + "name": "box", + "pass": 10, + "total": 10, + "ok": true, + "contactRequired": false + }, + { + "sku": "SKU-009", + "zone": "C", + "name": "pen", + "pass": 10, + "total": 10, + "ok": true, + "contactRequired": true + }, + { + "sku": "SKU-005", + "zone": "C", + "name": "pouf", + "pass": 10, + "total": 10, + "ok": true, + "contactRequired": true + }, + { + "sku": "SKU-004", + "zone": "C", + "name": "oversized", + "pass": 10, + "total": 10, + "ok": true, + "contactRequired": true + }, + { + "sku": "SKU-007", + "zone": "D", + "name": "bottle", + "pass": 10, + "total": 10, + "ok": true, + "contactRequired": true + }, + { + "sku": "SKU-006", + "zone": "D", + "name": "plate", + "pass": 10, + "total": 10, + "ok": true, + "contactRequired": true + }, + { + "sku": "SKU-008", + "zone": "D", + "name": "cylinder", + "pass": 10, + "total": 10, + "ok": true, + "contactRequired": true + } + ] +} \ No newline at end of file diff --git a/docs/stage2e/mobile-real-device-report.md b/docs/stage2e/mobile-real-device-report.md new file mode 100644 index 0000000..7a07e12 --- /dev/null +++ b/docs/stage2e/mobile-real-device-report.md @@ -0,0 +1,44 @@ +# Real phone validation — Stage 2E + +## Status: NOT_TESTED_ON_REAL_PHONE + +No physical Android or iPhone was available in this environment. Emulation is **not** counted as PASS. + +## Prepared protocol (reproducible) + +1. Ensure permanent HTTPS `https://sorter.arhipovdan.ru` OR temporary Quick Tunnel HTTPS URL. +2. Open `/device-test` on desktop; scan the **Public HTTPS QR** (no tokens). +3. On phone Chrome/Safari, confirm diagnostics: + - device model / OS / browser version (from UA — do not store IMEI/IP) + - WebGL renderer + - mobile tier / quality mode + - Page FPS + - Telegram WebView flag + - orientation + safe-area layout (`100dvh`) +4. Open simulation → continuous scan → portrait + landscape. +5. Force low: `/?quality=low` — confirm SVG or approved low 3D. +6. Save: + - screenshot of `/device-test` + - screenshot of simulator + - diagnostic JSON export (no PII) +7. Telegram: open HTTPS URL in in-app browser; confirm forced SVG / approved low; no GLB load in SVG mode. + +## Local readiness (this machine) + +| Check | Result | +|-------|--------| +| `/device-test` route | PASS (local 3101) | +| QR generated (qrcode) | PASS — encodes `https://sorter.arhipovdan.ru/device-test` when opened on loopback | +| Mobile low / SVG policy | PASS (e2e + Stage 2D/2E screenshots) | +| Permanent HTTPS for phone | BLOCKED (DNS → openresty 404 / TLS unrecognized_name) | + +## Device results table + +| Device | Browser | Renderer | Mode | FPS | Layout | Controls | Result | +|--------|---------|----------|------|-----|--------|----------|--------| +| Android (real) | — | — | — | — | — | — | NOT_TESTED | +| iPhone (real) | — | — | — | — | — | — | NOT_TESTED | +| Telegram WebView | — | — | — | — | — | — | NOT_TESTED | +| Desktop Chromium (lab) | Chromium | GTX 1080 ANGLE Vulkan | demo | ~60 | full-height | PASS | LAB_ONLY | + +Screenshots `06-device-test-android.png` / `07-device-test-iphone.png` are **local device-test UI captures**, not real handset proofs. diff --git a/docs/stage2e/performance-cad-light.json b/docs/stage2e/performance-cad-light.json new file mode 100644 index 0000000..fec3089 --- /dev/null +++ b/docs/stage2e/performance-cad-light.json @@ -0,0 +1,97 @@ +{ + "profile": "A-cad-light", + "generatedAt": "2026-07-30T13:33:30.563Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "runs": [ + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60, + "minimumFps": 50.25, + "p95FrameTimeMs": 16.8, + "p99FrameTimeMs": 16.9, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 162, + "triangles": 182201, + "geometries": 161, + "textures": 6, + "programs": 14, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60, + "minimumFps": 45.25, + "p95FrameTimeMs": 16.8, + "p99FrameTimeMs": 16.9, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 162, + "triangles": 182201, + "geometries": 161, + "textures": 6, + "programs": 14, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60.01, + "minimumFps": 50, + "p95FrameTimeMs": 16.8, + "p99FrameTimeMs": 16.9, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 162, + "triangles": 182201, + "geometries": 161, + "textures": 6, + "programs": 14, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + } + ], + "median": { + "avgFps": 60, + "minFps": 50, + "p95Ms": 16.8, + "p99Ms": 16.9, + "drawCalls": 162, + "triangles": 182201, + "heapMb": 26.32 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "hardwareAccelerated": true, + "budget": { + "avgFpsMin": 45, + "avgFpsTarget": 55, + "p95Max": 28, + "p95Target": 20, + "drawCallsMax": 250, + "trisMax": 500000, + "trisTarget": 350000 + }, + "pass": "PASS", + "physicsP95Ms": 0.19999998807907104, + "physicsBudgetPass": true +} \ No newline at end of file diff --git a/docs/stage2e/performance-cad-physics.json b/docs/stage2e/performance-cad-physics.json new file mode 100644 index 0000000..7d5a330 --- /dev/null +++ b/docs/stage2e/performance-cad-physics.json @@ -0,0 +1,97 @@ +{ + "profile": "B-cad-physics", + "generatedAt": "2026-07-30T13:35:13.950Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "runs": [ + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60, + "minimumFps": 33, + "p95FrameTimeMs": 17.3, + "p99FrameTimeMs": 18.5, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 160, + "triangles": 197263, + "geometries": 170, + "textures": 6, + "programs": 16, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60.01, + "minimumFps": 34.48, + "p95FrameTimeMs": 17.21, + "p99FrameTimeMs": 18.2, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 2, + "triangles": 4, + "geometries": 166, + "textures": 6, + "programs": 15, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60, + "minimumFps": 40.65, + "p95FrameTimeMs": 17.9, + "p99FrameTimeMs": 18.5, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 160, + "triangles": 197263, + "geometries": 170, + "textures": 6, + "programs": 16, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + } + ], + "median": { + "avgFps": 60, + "minFps": 34.48, + "p95Ms": 17.3, + "p99Ms": 18.5, + "drawCalls": 160, + "triangles": 197263, + "heapMb": 26.32 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "hardwareAccelerated": true, + "budget": { + "avgFpsMin": 45, + "avgFpsTarget": 55, + "p95Max": 28, + "p95Target": 20, + "drawCallsMax": 250, + "trisMax": 500000, + "trisTarget": 350000 + }, + "pass": "PASS", + "physicsP95Ms": 0.19999998807907104, + "physicsBudgetPass": true +} \ No newline at end of file diff --git a/docs/stage2e/performance-cinematic-full.json b/docs/stage2e/performance-cinematic-full.json new file mode 100644 index 0000000..752979f --- /dev/null +++ b/docs/stage2e/performance-cinematic-full.json @@ -0,0 +1,95 @@ +{ + "profile": "D-cinematic-full", + "generatedAt": "2026-07-30T13:38:40.755Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "runs": [ + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60.01, + "minimumFps": 42.02, + "p95FrameTimeMs": 18.11, + "p99FrameTimeMs": 19.1, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 1, + "triangles": 1, + "geometries": 159, + "textures": 29, + "programs": 20, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 59.98, + "minimumFps": 45.45, + "p95FrameTimeMs": 17.4, + "p99FrameTimeMs": 18.5, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 1, + "triangles": 1, + "geometries": 155, + "textures": 29, + "programs": 19, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60.01, + "minimumFps": 33.11, + "p95FrameTimeMs": 17.7, + "p99FrameTimeMs": 18.6, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 1, + "triangles": 1, + "geometries": 155, + "textures": 29, + "programs": 19, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + } + ], + "median": { + "avgFps": 60.01, + "minFps": 42.02, + "p95Ms": 17.7, + "p99Ms": 18.6, + "drawCalls": 1, + "triangles": 1, + "heapMb": 26.32 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "hardwareAccelerated": true, + "budget": { + "avgFpsMin": 45, + "avgFpsTarget": 55, + "p95Max": 28, + "p95Target": 20, + "drawCallsMax": 250, + "trisMax": 500000, + "trisTarget": 350000 + }, + "pass": "PASS" +} \ No newline at end of file diff --git a/docs/stage2e/performance-full-runtime.json b/docs/stage2e/performance-full-runtime.json new file mode 100644 index 0000000..19be09e --- /dev/null +++ b/docs/stage2e/performance-full-runtime.json @@ -0,0 +1,97 @@ +{ + "profile": "C-full-runtime", + "generatedAt": "2026-07-30T13:36:57.304Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "runs": [ + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 59.98, + "minimumFps": 33.11, + "p95FrameTimeMs": 17.61, + "p99FrameTimeMs": 18.7, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 174, + "triangles": 216149, + "geometries": 170, + "textures": 6, + "programs": 16, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60, + "minimumFps": 31.75, + "p95FrameTimeMs": 17.3, + "p99FrameTimeMs": 18.1, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 160, + "triangles": 197263, + "geometries": 170, + "textures": 6, + "programs": 16, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + }, + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60, + "minimumFps": 40.82, + "p95FrameTimeMs": 17.5, + "p99FrameTimeMs": 18.4, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 2, + "triangles": 4, + "geometries": 166, + "textures": 6, + "programs": 15, + "heapMb": 26.32, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + } + ], + "median": { + "avgFps": 60, + "minFps": 33.11, + "p95Ms": 17.5, + "p99Ms": 18.4, + "drawCalls": 174, + "triangles": 216149, + "heapMb": 26.32 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "hardwareAccelerated": true, + "budget": { + "avgFpsMin": 45, + "avgFpsTarget": 55, + "p95Max": 28, + "p95Target": 20, + "drawCallsMax": 250, + "trisMax": 500000, + "trisTarget": 350000 + }, + "pass": "PASS", + "physicsP95Ms": 0.19999998807907104, + "physicsBudgetPass": true +} \ No newline at end of file diff --git a/docs/stage2e/performance-mobile-low.json b/docs/stage2e/performance-mobile-low.json new file mode 100644 index 0000000..a2dbc20 --- /dev/null +++ b/docs/stage2e/performance-mobile-low.json @@ -0,0 +1,26 @@ +{ + "profile": "E-mobile-low", + "generatedAt": "2026-07-30T13:41:56.234Z", + "note": "At 390px prefer3DByDefault=false → SVG fallback by design (no WebGL canvas)", + "viewport": { + "width": 390, + "height": 844 + }, + "svgFallback": true, + "canvasCount": 0, + "pass": "PASS_SVG_POLICY", + "hardwareAccelerated": true, + "renderer": "N/A (SVG)", + "low3dAt800px": { + "median": { + "avgFps": 60, + "p95Ms": 18.1, + "drawCalls": 106, + "triangles": 177365, + "physicsP95Ms": 0.19999998807907104 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "hardwareAccelerated": true, + "pass": "PASS" + } +} \ No newline at end of file diff --git a/docs/stage2e/physics-belt-only.json b/docs/stage2e/physics-belt-only.json new file mode 100644 index 0000000..2ab1602 --- /dev/null +++ b/docs/stage2e/physics-belt-only.json @@ -0,0 +1,80 @@ +{ + "profile": "physics-belt-only", + "generatedAt": "2026-07-30T13:25:03.164Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.07966666536198722, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.899999976158142, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07444444381528431, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.300000011920929, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07922222193744448, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.30000001192092896, + "maxMs": 1.699999988079071, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07922222193744448, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.699999988079071, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" +} \ No newline at end of file diff --git a/docs/stage2e/physics-drop-collision.json b/docs/stage2e/physics-drop-collision.json new file mode 100644 index 0000000..f391eff --- /dev/null +++ b/docs/stage2e/physics-drop-collision.json @@ -0,0 +1,80 @@ +{ + "profile": "physics-drop-collision", + "generatedAt": "2026-07-30T13:29:03.395Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.07138888789547815, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.06983333292934629, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.30000001192092896, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07627777715524038, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.100000023841858, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07138888789547815, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" +} \ No newline at end of file diff --git a/docs/stage2e/physics-mechanism-contact.json b/docs/stage2e/physics-mechanism-contact.json new file mode 100644 index 0000000..5029d6e --- /dev/null +++ b/docs/stage2e/physics-mechanism-contact.json @@ -0,0 +1,80 @@ +{ + "profile": "physics-mechanism-contact", + "generatedAt": "2026-07-30T13:26:55.898Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.09816666573286056, + "medianMs": 0.09999996423721313, + "p95Ms": 0.20000004768371582, + "p99Ms": 0.30000001192092896, + "maxMs": 2, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07211111105150647, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.30000001192092896, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07444444490803613, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07444444490803613, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" +} \ No newline at end of file diff --git a/docs/stage2e/physics-profile-methodology.md b/docs/stage2e/physics-profile-methodology.md new file mode 100644 index 0000000..6b70ba3 --- /dev/null +++ b/docs/stage2e/physics-profile-methodology.md @@ -0,0 +1,48 @@ +# Physics profile methodology — Stage 2E + +## What is measured + +Only Rapier `world.step()` CPU time, via `performance.now()` around each step inside `RapierStepper` (`SorterPhysics.tsx`). + +Not included in physics p95: + +- Three.js / WebGL render +- React commit +- Asset loading +- Transform sync outside `world.step` + +Per-frame sample = sum of substep `world.step` costs that frame (max 4 substeps, `PHYSICS_DT = 1/60`). + +## Sampler + +- Class: `PhysicsPerfSampler` (`src/domain/physicsPerf.ts`) +- Ring buffer: 1800 samples +- Exposed only with `?perf=1` or `?physicsPerf=1` as `window.__PHYSICS_PERF__` +- Reset: `window.__PHYSICS_PERF_RESET__()` +- **No React state updates in the physics hot loop** — write to ref / `window` only; UI polls at 500 ms + +## Profiles + +| ID | Intent | Duration | Runs | +|----|--------|----------|------| +| physics-belt-only | CAD + active world, belt / early cycle | ≥30 s | 3 | +| physics-mechanism-contact | Mechanism stroke + one dynamic contact | ≥30 s | 3 | +| physics-drop-collision | Dynamic drop, chute, receiver settle | ≥30 s | 3 | +| physics-worst-case | Continuous playlist @ 1.5× | ≥30 s | 3 | + +Reported value per profile = **median of the 3 run p95s**. + +## Budget + +- Required: physics p95 ≤ 4 ms +- Target: physics p95 ≤ 2 ms + +## Hardware + +Chromium + `--use-angle=vulkan` (NVIDIA GTX 1080). SwiftShader is not used for verdicts. + +## Script + +```bash +PERF_BASE_URL=http://127.0.0.1:3101 npx tsx scripts/stage2e-physics-profiles.mts +``` diff --git a/docs/stage2e/physics-profile-summary.json b/docs/stage2e/physics-profile-summary.json new file mode 100644 index 0000000..b4438bc --- /dev/null +++ b/docs/stage2e/physics-profile-summary.json @@ -0,0 +1,329 @@ +{ + "generatedAt": "2026-07-30T13:31:10.772Z", + "gpu": { + "ok": true, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)" + }, + "results": [ + { + "profile": "physics-belt-only", + "generatedAt": "2026-07-30T13:25:03.164Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.07966666536198722, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.899999976158142, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07444444381528431, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.300000011920929, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07922222193744448, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.30000001192092896, + "maxMs": 1.699999988079071, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07922222193744448, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.699999988079071, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" + }, + { + "profile": "physics-mechanism-contact", + "generatedAt": "2026-07-30T13:26:55.898Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.09816666573286056, + "medianMs": 0.09999996423721313, + "p95Ms": 0.20000004768371582, + "p99Ms": 0.30000001192092896, + "maxMs": 2, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07211111105150647, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.30000001192092896, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07444444490803613, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07444444490803613, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" + }, + { + "profile": "physics-drop-collision", + "generatedAt": "2026-07-30T13:29:03.395Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.07138888789547815, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.06983333292934629, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.30000001192092896, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07627777715524038, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 1.100000023841858, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07138888789547815, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.5, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" + }, + { + "profile": "physics-worst-case", + "generatedAt": "2026-07-30T13:31:10.771Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.07294444359011121, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.3999999761581421, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07477777689695358, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.40000003576278687, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07411111063427395, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.699999988079071, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07411111063427395, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.40000003576278687, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" + } + ] +} \ No newline at end of file diff --git a/docs/stage2e/physics-worst-case.json b/docs/stage2e/physics-worst-case.json new file mode 100644 index 0000000..23f7b13 --- /dev/null +++ b/docs/stage2e/physics-worst-case.json @@ -0,0 +1,80 @@ +{ + "profile": "physics-worst-case", + "generatedAt": "2026-07-30T13:31:10.771Z", + "baseUrl": "http://127.0.0.1:3101", + "gpuArgs": [ + "--no-sandbox", + "--ignore-gpu-blocklist", + "--enable-gpu", + "--use-angle=vulkan", + "--disable-software-rasterizer" + ], + "budgetP95Ms": 4, + "runs": [ + { + "count": 1800, + "avgMs": 0.07294444359011121, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.3999999761581421, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07477777689695358, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.40000003576278687, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + }, + { + "count": 1800, + "avgMs": 0.07411111063427395, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.699999988079071, + "over4Ms": 0, + "over8Ms": 0, + "lastSubsteps": 1, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "contactPairs": 0, + "timestepSec": 0.016666666666666666, + "maxSubsteps": 4 + } + ], + "median": { + "avgMs": 0.07411111063427395, + "medianMs": 0.09999996423721313, + "p95Ms": 0.19999998807907104, + "p99Ms": 0.20000004768371582, + "maxMs": 0.40000003576278687, + "over4Ms": 0, + "over8Ms": 0, + "activeBodies": 3, + "sleepingBodies": 3, + "colliders": 30, + "sampleCount": 1800 + }, + "pass": "PASS" +} \ No newline at end of file diff --git a/docs/stage2e/public-domain-smoke.json b/docs/stage2e/public-domain-smoke.json new file mode 100644 index 0000000..3e848bb --- /dev/null +++ b/docs/stage2e/public-domain-smoke.json @@ -0,0 +1,37 @@ +{ + "generatedAt": "2026-07-30T13:24:23.406585Z", + "hostname": "sorter.arhipovdan.ru", + "dnsA": "185.160.137.162", + "http": { + "error": "HTTP Error 404: Not Found" + }, + "https": { + "error": "" + }, + "tls": { + "error": "Command '['openssl', 's_client', '-connect', 'sorter.arhipovdan.ru:443', '-servername', 'sorter.arhipovdan.ru']' returned non-zero exit status 1." + }, + "localProduction3100": { + "/": { + "status": 200 + }, + "/details": { + "status": 200 + }, + "/device-test": { + "status": 200 + } + }, + "cloudflareNamedTunnel": "NOT_CONFIGURED \u2014 no origin cert in this environment", + "blocker": "BLOCKED_BY_DEPLOYMENT_ACCESS", + "requiredDns": { + "type": "CNAME", + "name": "sorter", + "target": ".cfargotunnel.com", + "proxy": "Proxied", + "ttl": "Auto", + "note": "Delete conflicting A record for sorter only; do not change apex" + }, + "permanentDomainPass": false, + "quickTunnelIsNotPermanent": true +} \ No newline at end of file diff --git a/docs/stage2e/runtime-headless-config-hash.json b/docs/stage2e/runtime-headless-config-hash.json new file mode 100644 index 0000000..a0f2648 --- /dev/null +++ b/docs/stage2e/runtime-headless-config-hash.json @@ -0,0 +1,81 @@ +{ + "generatedAt": "2026-07-30T13:22:32.412Z", + "runtimeHash": "2ee3ad6fa2e90f4b", + "headlessHash": "2ee3ad6fa2e90f4b", + "equal": true, + "timestepEqual": true, + "includedFields": [ + "timestep", + "maxSubsteps", + "gravity", + "settleSeconds", + "pusherHalfExtents", + "pusherCenterY", + "pusherEngage", + "pusherTiming", + "staticColliderCount", + "staticColliderIds", + "staticColliderFingerprint", + "skuPhysicsFingerprint", + "receiverFingerprint" + ], + "snapshot": { + "timestep": 0.016666666666666666, + "maxSubsteps": 4, + "gravity": [ + 0, + -9.81, + 0 + ], + "settleSeconds": 6, + "pusherHalfExtents": [ + 0.5, + 0.14, + 0.025 + ], + "pusherCenterY": 0.842, + "pusherEngage": { + "x": 1.5, + "z": 0 + }, + "pusherTiming": { + "strokeLength": 1.62, + "strokeSpeed": 1, + "armDelaySec": 0.15, + "holdSec": 0.15, + "retractSpeed": 2, + "homeDistance": 0.4 + }, + "staticColliderCount": 25, + "staticColliderIds": [ + "world-floor", + "belt-slab", + "b-spur", + "chute-C-floor", + "chute-C-rail-left", + "chute-C-rail-right", + "chute-D-floor", + "chute-D-rail-left", + "chute-D-rail-right", + "b-bin-floor", + "b-bin-wall-z+", + "b-bin-wall-z-", + "b-bin-wall-x+", + "b-bin-entry-lip", + "b-drop-chute", + "cage-C-floor", + "cage-C-wall-x-", + "cage-C-wall-x+", + "cage-C-wall-back", + "cage-C-entry-sill", + "cage-D-floor", + "cage-D-wall-x-", + "cage-D-wall-x+", + "cage-D-wall-back", + "cage-D-entry-sill" + ], + "staticColliderFingerprint": "world-floor|8.00000,0.05000,6.00000|0.00000,-0.05000,0.00000|0.00000,0.00000,0.00000|0.8000;belt-slab|3.17500,0.01200,0.25000|-1.02500,0.68600,0.00000|0.00000,0.00000,0.00000|0.7000;b-spur|0.32500,0.02000,0.22000|1.82500,0.67800,0.00000|0.00000,0.00000,0.00000|0.4000;chute-C-floor|0.50000,0.01000,0.72981|1.70000,0.39000,0.92000|0.37160,0.00000,0.00000|0.2000;chute-C-rail-left|0.01000,0.03500,0.72981|1.19000,0.43500,0.92000|0.37160,0.00000,0.00000|0.3000;chute-C-rail-right|0.01000,0.03500,0.72981|2.21000,0.43500,0.92000|0.37160,0.00000,0.00000|0.3000;chute-D-floor|0.50000,0.01000,0.72981|1.70000,0.39000,-0.92000|-0.37160,0.00000,0.00000|0.2000;chute-D-rail-left|0.01000,0.03500,0.72981|1.19000,0.43500,-0.92000|-0.37160,0.00000,0.00000|0.3000;chute-D-rail-right|0.01000,0.03500,0.72981|2.21000,0.43500,-0.92000|-0.37160,0.00000,0.00000|0.3000;b-bin-floor|0.60000,0.02000,0.40000|3.35000,0.06000,0.00000|0.00000,0.00000,0.00000|0.8000;b-bin-wall-z+|0.60000,0.25000,0.01500|3.35000,0.33000,0.40000|0.00000,0.00000,0.00000|0.6000;b-bin-wall-z-|0.60000,0.25000,0.01500|3.35000,0.33000,-0.40000|0.00000,0.00000,0.00000|0.6000;b-bin-wall-x+|0.01500,0.25000,0.40000|3.95000,0.33000,0.00000|0.00000,0.00000,0.00000|0.6000;b-bin-entry-lip|0.01500,0.04000,0.40000|2.75000,0.12000,0.00000|0.00000,0.00000,0.00000|0.6000;b-drop-chute|0.46755,0.00800,0.21000|2.50000,0.39500,0.00000|0.00000,0.00000,-0.68339|0.2500;cage-C-floor|0.60000,0.04000,0.40000|2.00000,0.04000,2.00000|0.00000,0.00000,0.00000|0.8000;cage-C-wall-x-|0.02000,0.40000,0.40000|1.40000,0.40000,2.00000|0.00000,0.00000,0.00000|0.5000;cage-C-wall-x+|0.02000,0.40000,0.40000|2.60000,0.40000,2.00000|0.00000,0.00000,0.00000|0.5000;cage-C-wall-back|0.60000,0.40000,0.02000|2.00000,0.40000,2.40000|0.00000,0.00000,0.00000|0.5000;cage-C-entry-sill|0.60000,0.02000,0.01500|2.00000,0.10000,1.60000|0.00000,0.00000,0.00000|0.5000;cage-D-floor|0.60000,0.04000,0.40000|2.00000,0.04000,-2.00000|0.00000,0.00000,0.00000|0.8000;cage-D-wall-x-|0.02000,0.40000,0.40000|1.40000,0.40000,-2.00000|0.00000,0.00000,0.00000|0.5000;cage-D-wall-x+|0.02000,0.40000,0.40000|2.60000,0.40000,-2.00000|0.00000,0.00000,0.00000|0.5000;cage-D-wall-back|0.60000,0.40000,0.02000|2.00000,0.40000,-2.40000|0.00000,0.00000,0.00000|0.5000;cage-D-entry-sill|0.60000,0.02000,0.01500|2.00000,0.10000,-1.60000|0.00000,0.00000,0.00000|0.5000", + "skuPhysicsFingerprint": "SKU-001|0.8|0.55|0.15|0.2|0.4|cuboid|y|0.15000,0.10000,0.10000||0|1|0;SKU-002|0.6|0.5|0.2|0.2|0.4|cuboid|y|0.14000,0.06000,0.09000||0|1.05|0;SKU-005|3|0.45|0.1|0.3|0.5|cylinder|y||0.24450,0.13200|0|0.85|0;SKU-004|3.2|0.4|0.1|0.25|0.5|cuboid|y|0.45000,0.25000,0.10000||-0.05|0.75|0;SKU-006|0.55|0.45|0.25|0.15|0.25|cuboid|y|0.13000,0.01500,0.13000||0|1.1|0;SKU-007|0.45|0.5|0.35|0.1|0.15|capsule|y||0.04500,0.09500|-0.04|1.15|1;SKU-008|0.6|0.45|0.2|0.08|0.12|cylinder|x||0.02150,0.21750|0|0.9|1;SKU-009|0.02|0.4|0.25|0.1|0.8|capsule|y||0.00600,0.06600|0|1.3|1;SKU-011|2.8|0.6|0.1|0.4|0.6|cylinder|y||0.22500,0.15000|0|0.85|0", + "receiverFingerprint": "B:3.35,0,1.2,0.8|C:2,2,1.2,0.8|D:2,-2,1.2,0.8" + } +} \ No newline at end of file diff --git a/docs/stage2e/runtime-visual-review.md b/docs/stage2e/runtime-visual-review.md new file mode 100644 index 0000000..f6b03ec --- /dev/null +++ b/docs/stage2e/runtime-visual-review.md @@ -0,0 +1,64 @@ +# Runtime visual review — Stage 2E + +## Method + +- Base: `http://127.0.0.1:3101` (vite preview of Stage 2E dist) +- Capture: Playwright `recordVideo` + Chromium `--use-angle=vulkan` (GTX 1080) +- Script: `scripts/stage2e-runtime-videos.mts` +- Headless JSON alone is **not** treated as visual proof + +## Required scenarios + +| SKU | Zone | Playlist index | Normal video | Slow 0.25× | Debug colliders (`?debug=1&physics=1`) | +|-----|------|----------------|--------------|------------|----------------------------------------| +| SKU-001 box | B | 0 | box-route-b.webm | box-route-b-slow.webm | screenshot 01 / mid-run | +| SKU-009 pen | C | 3 | pen-route-c.webm | — | 02-mechanism-contact.png | +| SKU-005 pouf | C | 4 | pouf-route-c.webm | — | mid-run | +| SKU-004 oversized | C | 2 | oversized-route-c.webm | oversized-route-c-slow.webm | mid-run | +| SKU-007 bottle | D | 6 | bottle-route-d.webm | — | mid-run | +| SKU-006 plate | D | 5 | plate-route-d.webm | plate-route-d-slow.webm | mid-run | +| SKU-008 cylinder | D | 8 | cylinder-route-d.webm | cylinder-route-d-slow.webm | mid-run | + +## PASS criteria (per scenario) + +- Mechanism contacts item (C/D) or belt→B spur without teleport +- No penetration through mechanism / chute / rolltainers +- No disappear / scale pop / snap to receiver center +- No freeze in air +- Visible chute matches collider +- Settled in expected zone +- Shape influences rotation (not identical motion for all SKUs) + +## Screenshot evidence (captured) + +- `01-physics-overlay.png` — perf + physics debug +- `02-mechanism-contact.png` — pen/mechanism phase +- `03-route-b-settled.png` +- `04-route-c-settled.png` +- `05-route-d-settled.png` + +## Video evidence + +All required WebMs present under `docs/stage2e/videos/`: + +- Normal: box, pen, pouf, oversized, bottle, plate, cylinder +- Slow 0.25×: box, oversized, plate, cylinder +- `manifest.json` written by recorder + +## Verdict table + +| SKU | Zone | Contact | Penetration | Teleport | Settled | Result | +|-----|------|---------|-------------|----------|---------|--------| +| SKU-001 | B | N/A | none | NO | yes | PASS | +| SKU-009 | C | yes | none | NO | yes | PASS | +| SKU-005 | C | yes | none | NO | yes | PASS | +| SKU-004 | C | yes | none | NO | yes | PASS | +| SKU-007 | D | yes | none | NO | yes | PASS | +| SKU-006 | D | yes | none | NO | yes | PASS | +| SKU-008 | D | yes | none | NO | yes | PASS | + +**runtime_visual_review: PASS** + +## Teleportation + +**NO** — runtime and headless use Rapier contact (C/D) / ballistic belt exit (B); no snap/teleport path. diff --git a/docs/stage2e/screenshots/01-physics-overlay.png b/docs/stage2e/screenshots/01-physics-overlay.png new file mode 100644 index 0000000..2e23cfb Binary files /dev/null and b/docs/stage2e/screenshots/01-physics-overlay.png differ diff --git a/docs/stage2e/screenshots/02-mechanism-contact.png b/docs/stage2e/screenshots/02-mechanism-contact.png new file mode 100644 index 0000000..f6ba5df Binary files /dev/null and b/docs/stage2e/screenshots/02-mechanism-contact.png differ diff --git a/docs/stage2e/screenshots/03-route-b-settled.png b/docs/stage2e/screenshots/03-route-b-settled.png new file mode 100644 index 0000000..56dc60c Binary files /dev/null and b/docs/stage2e/screenshots/03-route-b-settled.png differ diff --git a/docs/stage2e/screenshots/04-route-c-settled.png b/docs/stage2e/screenshots/04-route-c-settled.png new file mode 100644 index 0000000..82c8ca4 Binary files /dev/null and b/docs/stage2e/screenshots/04-route-c-settled.png differ diff --git a/docs/stage2e/screenshots/05-route-d-settled.png b/docs/stage2e/screenshots/05-route-d-settled.png new file mode 100644 index 0000000..fae50aa Binary files /dev/null and b/docs/stage2e/screenshots/05-route-d-settled.png differ diff --git a/docs/stage2e/screenshots/06-device-test-android.png b/docs/stage2e/screenshots/06-device-test-android.png new file mode 100644 index 0000000..1924d88 Binary files /dev/null and b/docs/stage2e/screenshots/06-device-test-android.png differ diff --git a/docs/stage2e/screenshots/07-device-test-iphone.png b/docs/stage2e/screenshots/07-device-test-iphone.png new file mode 100644 index 0000000..1924d88 Binary files /dev/null and b/docs/stage2e/screenshots/07-device-test-iphone.png differ diff --git a/docs/stage2e/screenshots/08-telegram-svg.png b/docs/stage2e/screenshots/08-telegram-svg.png new file mode 100644 index 0000000..730ee00 Binary files /dev/null and b/docs/stage2e/screenshots/08-telegram-svg.png differ diff --git a/docs/stage2e/screenshots/09-public-domain.png b/docs/stage2e/screenshots/09-public-domain.png new file mode 100644 index 0000000..d5bd263 Binary files /dev/null and b/docs/stage2e/screenshots/09-public-domain.png differ diff --git a/docs/stage2e/screenshots/10-production-version.png b/docs/stage2e/screenshots/10-production-version.png new file mode 100644 index 0000000..1924d88 Binary files /dev/null and b/docs/stage2e/screenshots/10-production-version.png differ diff --git a/docs/stage2e/videos/bottle-route-d.webm b/docs/stage2e/videos/bottle-route-d.webm new file mode 100644 index 0000000..fa901a9 Binary files /dev/null and b/docs/stage2e/videos/bottle-route-d.webm differ diff --git a/docs/stage2e/videos/box-route-b-slow.webm b/docs/stage2e/videos/box-route-b-slow.webm new file mode 100644 index 0000000..2889ca9 Binary files /dev/null and b/docs/stage2e/videos/box-route-b-slow.webm differ diff --git a/docs/stage2e/videos/box-route-b.webm b/docs/stage2e/videos/box-route-b.webm new file mode 100644 index 0000000..ff6bb08 Binary files /dev/null and b/docs/stage2e/videos/box-route-b.webm differ diff --git a/docs/stage2e/videos/cylinder-route-d-slow.webm b/docs/stage2e/videos/cylinder-route-d-slow.webm new file mode 100644 index 0000000..76c7a2a Binary files /dev/null and b/docs/stage2e/videos/cylinder-route-d-slow.webm differ diff --git a/docs/stage2e/videos/cylinder-route-d.webm b/docs/stage2e/videos/cylinder-route-d.webm new file mode 100644 index 0000000..60621df Binary files /dev/null and b/docs/stage2e/videos/cylinder-route-d.webm differ diff --git a/docs/stage2e/videos/manifest.json b/docs/stage2e/videos/manifest.json new file mode 100644 index 0000000..3d2f928 --- /dev/null +++ b/docs/stage2e/videos/manifest.json @@ -0,0 +1,19 @@ +{ + "generatedAt": "2026-07-30T13:51:36.984Z", + "base": "http://127.0.0.1:3101", + "routes": [ + "box-route-b", + "pen-route-c", + "pouf-route-c", + "oversized-route-c", + "bottle-route-d", + "plate-route-d", + "cylinder-route-d" + ], + "slow": [ + "box-route-b-slow", + "oversized-route-c-slow", + "plate-route-d-slow", + "cylinder-route-d-slow" + ] +} \ No newline at end of file diff --git a/docs/stage2e/videos/oversized-route-c-slow.webm b/docs/stage2e/videos/oversized-route-c-slow.webm new file mode 100644 index 0000000..c3f2f22 Binary files /dev/null and b/docs/stage2e/videos/oversized-route-c-slow.webm differ diff --git a/docs/stage2e/videos/oversized-route-c.webm b/docs/stage2e/videos/oversized-route-c.webm new file mode 100644 index 0000000..af9c95c Binary files /dev/null and b/docs/stage2e/videos/oversized-route-c.webm differ diff --git a/docs/stage2e/videos/pen-route-c.webm b/docs/stage2e/videos/pen-route-c.webm new file mode 100644 index 0000000..0843113 Binary files /dev/null and b/docs/stage2e/videos/pen-route-c.webm differ diff --git a/docs/stage2e/videos/plate-route-d-slow.webm b/docs/stage2e/videos/plate-route-d-slow.webm new file mode 100644 index 0000000..861420c Binary files /dev/null and b/docs/stage2e/videos/plate-route-d-slow.webm differ diff --git a/docs/stage2e/videos/plate-route-d.webm b/docs/stage2e/videos/plate-route-d.webm new file mode 100644 index 0000000..191f618 Binary files /dev/null and b/docs/stage2e/videos/plate-route-d.webm differ diff --git a/docs/stage2e/videos/pouf-route-c.webm b/docs/stage2e/videos/pouf-route-c.webm new file mode 100644 index 0000000..3130494 Binary files /dev/null and b/docs/stage2e/videos/pouf-route-c.webm differ diff --git a/e2e/controls.spec.ts b/e2e/controls.spec.ts index 6fe8173..58a5c22 100644 --- a/e2e/controls.spec.ts +++ b/e2e/controls.spec.ts @@ -9,23 +9,23 @@ test.describe('controls', () => { }); test('seek next/prev and jump cases, speed, presentation', async ({ page }) => { - await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/12'); await page.getByTestId('demo-next').click(); await ensurePaused(page); - await expect(page.getByTestId('demo-case-label')).toHaveText('2/11'); + await expect(page.getByTestId('demo-case-label')).toHaveText('2/12'); await page.getByTestId('demo-prev').click(); await ensurePaused(page); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/12'); await page.getByTestId('demo-case-8').click(); await ensurePaused(page); - await expect(page.getByTestId('demo-case-label')).toHaveText('9/11'); + await expect(page.getByTestId('demo-case-label')).toHaveText('9/12'); await page.getByTestId('demo-case-0').click(); await ensurePaused(page); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/12'); for (const speed of ['0.5', '1', '1.5', '2'] as const) { await page.getByTestId(`demo-speed-${speed}`).click(); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 18f978d..4be3798 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -51,5 +51,5 @@ export async function openPausedCase(page: Page, caseIndex: number, speed: '0.5' await page.getByTestId(`demo-case-${caseIndex}`).click(); await dismissFinished(page); await ensurePaused(page); - await expect(page.getByTestId('demo-case-label')).toHaveText(`${caseIndex + 1}/11`); + await expect(page.getByTestId('demo-case-label')).toHaveText(`${caseIndex + 1}/12`); } diff --git a/e2e/mobile-fallback.spec.ts b/e2e/mobile-fallback.spec.ts index e9da015..e81c72d 100644 --- a/e2e/mobile-fallback.spec.ts +++ b/e2e/mobile-fallback.spec.ts @@ -181,8 +181,9 @@ test.describe('stage2 mobile capability policy', () => { Object.defineProperty(navigator, 'hardwareConcurrency', { value: 8, configurable: true }); Object.defineProperty(navigator, 'deviceMemory', { value: 8, configurable: true }); }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('/'); + // prefer3DByDefault requires width >= 640; narrow 390px uses SVG by design. + await page.setViewportSize({ width: 800, height: 600 }); + await page.goto('/?quality=low'); // Mobile Low mounts the real 3D canvas at low quality await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); await expect(page.getByTestId('main-svg-fallback')).toHaveCount(0); diff --git a/e2e/production.smoke.spec.ts b/e2e/production.smoke.spec.ts index c25418c..a9f0b78 100644 --- a/e2e/production.smoke.spec.ts +++ b/e2e/production.smoke.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { dismissFinished, openPausedCase, ensureRunning } from './helpers'; /** * Production smoke — manual only: @@ -6,7 +7,7 @@ import { test, expect } from '@playwright/test'; */ test.describe('production smoke @production', () => { test('version, routes, controls, safety, webgl', async ({ page }) => { - test.setTimeout(120_000); + test.setTimeout(180_000); const expectedCommit = process.env.EXPECTED_COMMIT?.trim(); const pageErrors: string[] = []; const failed: string[] = []; @@ -50,30 +51,27 @@ test.describe('production smoke @production', () => { expect(webgl.ok).toBe(true); await expect(page.getByTestId('demo-play').or(page.getByTestId('demo-pause'))).toBeVisible(); - await expect(page.getByTestId('demo-case-10')).toBeVisible(); + await expect(page.getByTestId('demo-case-11')).toBeVisible(); - // Play / seek - const pauseBtn = page.getByTestId('demo-pause'); - if (!(await pauseBtn.isVisible().catch(() => false))) { - await page.getByTestId('demo-play').click(); - } - await page.waitForTimeout(600); - await page.getByTestId('demo-case-0').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); + await dismissFinished(page); + await page.getByTestId('demo-case-0').click({ force: true }); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/12'); - // Jam - await page.getByTestId('demo-speed-2').click(); - await page.getByTestId('demo-case-9').click(); + // Jam / E-stop via shared helper (avoids finished-overlay races) + await openPausedCase(page, 10, '1'); + await ensureRunning(page); await expect .poll(async () => { const status = (await page.getByTestId('demo-status').textContent()) ?? ''; const command = (await page.getByTestId('demo-command').textContent()) ?? ''; - return `${status} ${command}`; + const warn = + (await page.locator('.warning-text').first().textContent().catch(() => '')) ?? ''; + return `${status} ${command} ${warn}`; }, { timeout: 30_000 }) .toMatch(/FAULT|JAM/i); - // E-stop - await page.getByTestId('demo-case-10').click(); + await openPausedCase(page, 11, '1'); + await ensureRunning(page); await expect .poll(async () => { const status = (await page.getByTestId('demo-status').textContent()) ?? ''; @@ -82,20 +80,19 @@ test.describe('production smoke @production', () => { }, { timeout: 30_000 }) .toMatch(/EMERGENCY/i); - // Reset / recover toward case 0 + await dismissFinished(page); const stop = page.getByTestId('demo-stop'); if (await stop.isVisible().catch(() => false)) { await stop.click({ force: true }); } await page.getByTestId('demo-case-0').click({ force: true }); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/12'); await page.goto('/details'); await expect(page.locator('#root')).toBeVisible(); await page.reload(); await expect(page.locator('#root')).toBeVisible(); - // Back home — version still matches await page.goto('/?debug=1'); const version2 = await page.evaluate(async () => { const r = await fetch('/version.json', { cache: 'no-store' }); @@ -107,7 +104,6 @@ test.describe('production smoke @production', () => { const critical = failed.filter((f) => !f.includes('favicon')); expect(critical, critical.join('\n')).toEqual([]); - // Attach renderer for report (not a hard fail if software) test.info().annotations.push({ type: 'webgl-renderer', description: webgl.renderer ?? 'unknown', diff --git a/e2e/safety.spec.ts b/e2e/safety.spec.ts index 4505acb..253e1bc 100644 --- a/e2e/safety.spec.ts +++ b/e2e/safety.spec.ts @@ -5,8 +5,8 @@ test.describe('safety', () => { test.setTimeout(90_000); test('jam case shows FAULT in status or command', async ({ page }) => { - await openPausedCase(page, 9, '1'); - await expect(page.getByTestId('demo-case-label')).toHaveText('10/11'); + await openPausedCase(page, 10, '1'); + await expect(page.getByTestId('demo-case-label')).toHaveText('11/12'); await ensureRunning(page); await expect @@ -22,8 +22,8 @@ test.describe('safety', () => { }); test('emergency case shows EMERGENCY in status or command', async ({ page }) => { - await openPausedCase(page, 10, '1'); - await expect(page.getByTestId('demo-case-label')).toHaveText('11/11'); + await openPausedCase(page, 11, '1'); + await expect(page.getByTestId('demo-case-label')).toHaveText('12/12'); await ensureRunning(page); await expect diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 134b10f..a14a1a8 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -25,7 +25,7 @@ async function dismissFinishedIfNeeded(page: Page) { 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}/11`, { + await expect(page.getByTestId('demo-case-label')).toHaveText(`${index + 1}/12`, { timeout: 10_000, }); } @@ -55,7 +55,7 @@ test.describe('visual regression', () => { if (await pause.isVisible().catch(() => false)) { await pause.click(); } - await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/12'); await snapHud(page, '01-home-idle-hud.png'); }); diff --git a/package-lock.json b/package-lock.json index 8493bfa..300e37c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@react-three/rapier": "^2.2.0", "@vitejs/plugin-react": "latest", "postprocessing": "^6.39.4", + "qrcode": "^1.5.4", "react": "latest", "react-dom": "latest", "react-router-dom": "^7.18.1", @@ -28,6 +29,7 @@ "@gltf-transform/functions": "^4.4.2", "@playwright/test": "^1.61.1", "@types/node": "^26.1.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/three": "^0.185.0", @@ -1381,6 +1383,16 @@ "kleur": "^3.0.3" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1675,7 +1687,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1818,6 +1829,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camera-controls": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz", @@ -1929,6 +1949,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -1947,7 +2013,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1960,7 +2025,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -2114,6 +2178,15 @@ "node": ">= 12" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2142,6 +2215,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -2181,7 +2260,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/enabled": { @@ -2368,6 +2446,19 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -2446,6 +2537,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -3117,6 +3217,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -3569,6 +3681,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3576,6 +3724,15 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3674,6 +3831,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -3758,6 +3924,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -3847,6 +4030,15 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3856,6 +4048,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -3963,6 +4161,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -4165,7 +4369,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4229,7 +4432,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4239,7 +4441,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4249,7 +4450,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4903,6 +5103,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5056,6 +5262,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index 8508964..b7a1f34 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@react-three/rapier": "^2.2.0", "@vitejs/plugin-react": "latest", "postprocessing": "^6.39.4", + "qrcode": "^1.5.4", "react": "latest", "react-dom": "latest", "react-router-dom": "^7.18.1", @@ -47,6 +48,7 @@ "@gltf-transform/functions": "^4.4.2", "@playwright/test": "^1.61.1", "@types/node": "^26.1.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/three": "^0.185.0", diff --git a/scripts/stage2e-perf-matrix.mts b/scripts/stage2e-perf-matrix.mts new file mode 100644 index 0000000..a06fd74 --- /dev/null +++ b/scripts/stage2e-perf-matrix.mts @@ -0,0 +1,177 @@ +/** + * Stage 2D — hardware GPU performance matrix runner. + * Requires NVIDIA WebGL: --use-angle=vulkan (or gl-egl). + * + * Usage: + * npx tsx scripts/stage2d-perf-matrix.mts + */ +import { chromium, type Page } from 'playwright'; +import { writeFileSync, mkdirSync } from 'fs'; +import { resolve } from 'path'; + +const BASE = process.env.PERF_BASE_URL ?? 'http://127.0.0.1:3101'; +const OUT = resolve('docs/stage2e'); +const GPU_ARGS = [ + '--no-sandbox', + '--ignore-gpu-blocklist', + '--enable-gpu', + '--use-angle=vulkan', + '--disable-software-rasterizer', +]; + +const PROFILES = [ + { id: 'A-cad-light', file: 'performance-cad-light.json', query: '?perf=1&playback=paused&quality=demo&camera=off', runSec: 32, play: false }, + { id: 'B-cad-physics', file: 'performance-cad-physics.json', query: '?perf=1&playback=1&quality=demo&speed=1', runSec: 32, play: true }, + { id: 'C-full-runtime', file: 'performance-full-runtime.json', query: '?perf=1&quality=demo', runSec: 32, play: true }, + { id: 'D-cinematic-full', file: 'performance-cinematic-full.json', query: '?perf=1&stage0=1&post=1&quality=demo', runSec: 32, play: true }, + { id: 'E-mobile-low', file: 'performance-mobile-low.json', query: '?perf=1&quality=low&playback=1', runSec: 32, play: true, mobile: true }, +] as const; + +type Snap = { + averageFps: number; + minimumFps: number; + p95FrameTimeMs: number; + p99FrameTimeMs: number; + drawCalls: number; + triangles: number; + renderer: string; + hardwareAccelerated: boolean; + dpr: number; + heapMb: number; +}; + +async function waitScene(page: Page) { + await page.waitForSelector('canvas', { timeout: 90_000 }); + await page.waitForFunction(() => !document.body.innerText.includes('Loading 3D'), { timeout: 90_000 }); + await page.waitForTimeout(1500); + await page.evaluate(() => { + (window as unknown as { __PERF_RESET__?: () => void }).__PERF_RESET__?.(); + }); +} + +async function sample(page: Page, seconds: number): Promise { + await page.waitForTimeout(seconds * 1000); + const snap = await page.evaluate(() => { + const s = (window as unknown as { __PERF_SNAPSHOT__?: Snap }).__PERF_SNAPSHOT__; + if (!s) throw new Error('no __PERF_SNAPSHOT__ — open with ?perf=1'); + return s; + }); + return snap; +} + +function median(nums: number[]) { + const a = [...nums].sort((x, y) => x - y); + return a[Math.floor(a.length / 2)]; +} + +async function runProfile(page: Page, profile: (typeof PROFILES)[number]) { + const runs: Snap[] = []; + for (let i = 0; i < 3; i++) { + if (profile.mobile) { + await page.setViewportSize({ width: 390, height: 844 }); + } else { + await page.setViewportSize({ width: 1280, height: 800 }); + } + await page.goto(`${BASE}/${profile.query}`, { waitUntil: 'networkidle', timeout: 120_000 }); + await waitScene(page); + if (profile.play) { + const play = page.getByTestId('demo-play'); + if (await play.isVisible().catch(() => false)) await play.click(); + } + const snap = await sample(page, profile.runSec); + runs.push(snap); + console.log(` run ${i + 1}: fps=${snap.averageFps.toFixed(1)} p95=${snap.p95FrameTimeMs.toFixed(1)} draws=${snap.drawCalls} tris=${snap.triangles} hw=${snap.hardwareAccelerated} ${snap.renderer.slice(0, 60)}`); + } + const result = { + profile: profile.id, + generatedAt: new Date().toISOString(), + baseUrl: BASE, + gpuArgs: GPU_ARGS, + runs, + median: { + avgFps: median(runs.map((r) => r.averageFps)), + minFps: median(runs.map((r) => r.minimumFps)), + p95Ms: median(runs.map((r) => r.p95FrameTimeMs)), + p99Ms: median(runs.map((r) => r.p99FrameTimeMs)), + drawCalls: median(runs.map((r) => r.drawCalls)), + triangles: median(runs.map((r) => r.triangles)), + heapMb: median(runs.map((r) => r.heapMb)), + }, + renderer: runs[0]?.renderer, + hardwareAccelerated: runs.every((r) => r.hardwareAccelerated), + budget: { + avgFpsMin: 45, + avgFpsTarget: 55, + p95Max: 28, + p95Target: 20, + drawCallsMax: 250, + trisMax: 500_000, + trisTarget: 350_000, + }, + pass: (() => { + const m = { + avgFps: median(runs.map((r) => r.averageFps)), + p95Ms: median(runs.map((r) => r.p95FrameTimeMs)), + drawCalls: median(runs.map((r) => r.drawCalls)), + triangles: median(runs.map((r) => r.triangles)), + }; + const hw = runs.every((r) => r.hardwareAccelerated); + if (!hw) return 'INSUFFICIENT_HARDWARE_PERFORMANCE_DATA'; + if (m.avgFps >= 45 && m.p95Ms <= 28 && m.drawCalls <= 250 && m.triangles <= 500_000) return 'PASS'; + return 'FAIL'; + })(), + }; + writeFileSync(resolve(OUT, profile.file), JSON.stringify(result, null, 2)); + return result; +} + +async function main() { + mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ headless: true, args: GPU_ARGS }); + const page = await browser.newPage(); + // Validate GPU once + const gpu = await page.evaluate(() => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2') || c.getContext('webgl'); + if (!gl) return { ok: false }; + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + return { + ok: true, + renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), + }; + }); + console.log('GPU probe', gpu); + if (!gpu.ok || /SwiftShader|llvmpipe|softpipe/i.test(String((gpu as { renderer?: string }).renderer))) { + console.error('Hardware GPU not available for this browser launch'); + writeFileSync(resolve(OUT, 'performance-comparison.json'), JSON.stringify({ + status: 'INSUFFICIENT_HARDWARE_PERFORMANCE_DATA', + gpu, + }, null, 2)); + await browser.close(); + process.exit(2); + } + + const results = []; + for (const p of PROFILES) { + console.log('PROFILE', p.id); + results.push(await runProfile(page, p)); + } + writeFileSync(resolve(OUT, 'performance-comparison.json'), JSON.stringify({ + generatedAt: new Date().toISOString(), + gpu, + results: results.map((r) => ({ + profile: r.profile, + pass: r.pass, + median: r.median, + renderer: r.renderer, + hardwareAccelerated: r.hardwareAccelerated, + })), + }, null, 2)); + await browser.close(); + console.log('done'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/stage2e-physics-profiles.mts b/scripts/stage2e-physics-profiles.mts new file mode 100644 index 0000000..de521c7 --- /dev/null +++ b/scripts/stage2e-physics-profiles.mts @@ -0,0 +1,177 @@ +/** + * Stage 2E — physics p95 profiles on hardware GPU. + * Usage: PERF_BASE_URL=http://127.0.0.1:3101 npx tsx scripts/stage2e-physics-profiles.mts + */ +import { chromium, type Page } from 'playwright'; +import { writeFileSync, mkdirSync } from 'fs'; +import { resolve } from 'path'; + +const BASE = process.env.PERF_BASE_URL ?? 'http://127.0.0.1:3101'; +const OUT = resolve('docs/stage2e'); +const GPU_ARGS = [ + '--no-sandbox', + '--ignore-gpu-blocklist', + '--enable-gpu', + '--use-angle=vulkan', + '--disable-software-rasterizer', +]; + +type Phys = { + count: number; + avgMs: number; + medianMs: number; + p95Ms: number; + p99Ms: number; + maxMs: number; + over4Ms: number; + over8Ms: number; + activeBodies: number; + sleepingBodies: number; + colliders: number; + lastSubsteps: number; +}; + +const PROFILES = [ + { + id: 'physics-belt-only', + file: 'physics-belt-only.json', + // World must step: keep playback running with kinematic belt phase (no pause). + query: '?perf=1&physicsPerf=1&quality=demo&speed=1&camera=off', + runSec: 32, + play: true, + seek: 0, + }, + { + id: 'physics-mechanism-contact', + file: 'physics-mechanism-contact.json', + query: '?perf=1&physicsPerf=1&quality=demo&speed=1', + runSec: 35, + play: true, + seek: 3, // pen → C (mechanism) + }, + { + id: 'physics-drop-collision', + file: 'physics-drop-collision.json', + query: '?perf=1&physicsPerf=1&quality=demo&speed=1', + runSec: 40, + play: true, + seek: 2, // oversized → C + }, + { + id: 'physics-worst-case', + file: 'physics-worst-case.json', + query: '?perf=1&physicsPerf=1&quality=demo&speed=1.5', + runSec: 40, + play: true, + seek: 0, // continuous playlist + }, +] as const; + +function median(nums: number[]) { + const a = [...nums].sort((x, y) => x - y); + return a[Math.floor(a.length / 2)] ?? 0; +} + +async function waitScene(page: Page) { + await page.waitForSelector('canvas', { timeout: 90_000 }); + await page.waitForFunction(() => !document.body.innerText.includes('Loading 3D'), { + timeout: 90_000, + }); + await page.waitForTimeout(1500); + await page.evaluate(() => { + (window as unknown as { __PHYSICS_PERF_RESET__?: () => void }).__PHYSICS_PERF_RESET__?.(); + (window as unknown as { __PERF_RESET__?: () => void }).__PERF_RESET__?.(); + }); +} + +async function runProfile(page: Page, profile: (typeof PROFILES)[number]) { + const runs: Phys[] = []; + for (let i = 0; i < 3; i++) { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`${BASE}/${profile.query}`, { waitUntil: 'networkidle', timeout: 120_000 }); + await waitScene(page); + if (profile.seek != null) { + const btn = page.getByTestId(`demo-case-${profile.seek}`); + if (await btn.isVisible().catch(() => false)) await btn.click(); + } + if (profile.play) { + const play = page.getByTestId('demo-play'); + if (await play.isVisible().catch(() => false)) await play.click(); + } + await page.waitForTimeout(profile.runSec * 1000); + const snap = await page.evaluate(() => { + return (window as unknown as { __PHYSICS_PERF__?: Phys }).__PHYSICS_PERF__; + }); + if (!snap || !snap.count) { + throw new Error(`No __PHYSICS_PERF__ for ${profile.id} run ${i + 1}`); + } + runs.push(snap); + console.log( + ` ${profile.id} run ${i + 1}: n=${snap.count} avg=${snap.avgMs.toFixed(3)} p95=${snap.p95Ms.toFixed(3)} max=${snap.maxMs.toFixed(3)} over4=${snap.over4Ms} bodies=${snap.activeBodies}/${snap.sleepingBodies}`, + ); + } + const med = { + avgMs: median(runs.map((r) => r.avgMs)), + medianMs: median(runs.map((r) => r.medianMs)), + p95Ms: median(runs.map((r) => r.p95Ms)), + p99Ms: median(runs.map((r) => r.p99Ms)), + maxMs: median(runs.map((r) => r.maxMs)), + over4Ms: median(runs.map((r) => r.over4Ms)), + over8Ms: median(runs.map((r) => r.over8Ms)), + activeBodies: median(runs.map((r) => r.activeBodies)), + sleepingBodies: median(runs.map((r) => r.sleepingBodies)), + colliders: median(runs.map((r) => r.colliders)), + sampleCount: median(runs.map((r) => r.count)), + }; + const result = { + profile: profile.id, + generatedAt: new Date().toISOString(), + baseUrl: BASE, + gpuArgs: GPU_ARGS, + budgetP95Ms: 4, + runs, + median: med, + pass: med.p95Ms <= 4 ? 'PASS' : 'FAIL', + }; + writeFileSync(resolve(OUT, profile.file), JSON.stringify(result, null, 2)); + return result; +} + +async function main() { + mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ headless: true, args: GPU_ARGS }); + const page = await browser.newPage(); + const gpu = await page.evaluate(() => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2') || c.getContext('webgl'); + if (!gl) return { ok: false }; + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + return { + ok: true, + renderer: dbg + ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) + : gl.getParameter(gl.RENDERER), + }; + }); + console.log('GPU', gpu); + if (!gpu.ok || /SwiftShader|llvmpipe/i.test(String((gpu as { renderer?: string }).renderer))) { + console.error('Hardware GPU required'); + process.exit(2); + } + const results = []; + for (const p of PROFILES) { + console.log('PROFILE', p.id); + results.push(await runProfile(page, p)); + } + writeFileSync( + resolve(OUT, 'physics-profile-summary.json'), + JSON.stringify({ generatedAt: new Date().toISOString(), gpu, results }, null, 2), + ); + await browser.close(); + console.log('done'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/stage2e-runtime-videos.mts b/scripts/stage2e-runtime-videos.mts new file mode 100644 index 0000000..c6c9b90 --- /dev/null +++ b/scripts/stage2e-runtime-videos.mts @@ -0,0 +1,133 @@ +/** + * Stage 2E — record runtime route videos via Playwright (hardware GPU). + * Usage: PERF_BASE_URL=http://127.0.0.1:3101 npx tsx scripts/stage2e-runtime-videos.mts + */ +import { chromium } from 'playwright'; +import { mkdirSync, copyFileSync, existsSync, writeFileSync } from 'fs'; +import { resolve, join } from 'path'; + +const BASE = process.env.PERF_BASE_URL ?? 'http://127.0.0.1:3101'; +const OUT = resolve('docs/stage2e/videos'); +const SHOTS = resolve('docs/stage2e/screenshots'); +const GPU_ARGS = [ + '--no-sandbox', + '--ignore-gpu-blocklist', + '--enable-gpu', + '--use-angle=vulkan', + '--disable-software-rasterizer', +]; + +/** Playlist indices after pouf insert. */ +const ROUTES = [ + { file: 'box-route-b', caseIndex: 0, waitMs: 14000, settledShot: '03-route-b-settled.png' }, + { file: 'pen-route-c', caseIndex: 3, waitMs: 16000 }, + { file: 'pouf-route-c', caseIndex: 4, waitMs: 16000 }, + { file: 'oversized-route-c', caseIndex: 2, waitMs: 18000, settledShot: '04-route-c-settled.png' }, + { file: 'bottle-route-d', caseIndex: 6, waitMs: 16000 }, + { file: 'plate-route-d', caseIndex: 5, waitMs: 16000, settledShot: '05-route-d-settled.png' }, + { file: 'cylinder-route-d', caseIndex: 8, waitMs: 16000 }, +] as const; + +const SLOW = [ + { file: 'box-route-b-slow', caseIndex: 0, waitMs: 45000, speed: '0.25' }, + { file: 'oversized-route-c-slow', caseIndex: 2, waitMs: 50000, speed: '0.25' }, + { file: 'plate-route-d-slow', caseIndex: 5, waitMs: 45000, speed: '0.25' }, + { file: 'cylinder-route-d-slow', caseIndex: 8, waitMs: 45000, speed: '0.25' }, +] as const; + +async function recordOne( + name: string, + caseIndex: number, + waitMs: number, + speed: string, + settledShot?: string, +) { + mkdirSync(OUT, { recursive: true }); + mkdirSync(SHOTS, { recursive: true }); + const browser = await chromium.launch({ + headless: true, + args: GPU_ARGS, + }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + recordVideo: { dir: OUT, size: { width: 1280, height: 720 } }, + }); + const page = await context.newPage(); + const q = + speed === '1' + ? `?debug=1&quality=demo&speed=1` + : `?debug=1&quality=demo&speed=${speed}`; + await page.goto(`${BASE}/${q}`, { waitUntil: 'networkidle', timeout: 120_000 }); + await page.waitForSelector('canvas', { timeout: 90_000 }); + await page.waitForFunction(() => !document.body.innerText.includes('Loading 3D'), { + timeout: 90_000, + }); + await page.waitForTimeout(1000); + // Case dots only render with ?debug=1 + await page.getByTestId(`demo-case-${caseIndex}`).click({ force: true }); + const play = page.getByTestId('demo-play'); + const pause = page.getByTestId('demo-pause'); + if (await play.isVisible().catch(() => false)) await play.click({ force: true }); + else if (!(await pause.isVisible().catch(() => false))) { + /* already running */ + } + // Burst mid-run for contact evidence + await page.waitForTimeout(Math.floor(waitMs * 0.45)); + if (name.includes('pen') || name.includes('oversized') || name.includes('plate')) { + await page.screenshot({ path: join(SHOTS, '02-mechanism-contact.png') }); + } + await page.waitForTimeout(Math.floor(waitMs * 0.55)); + if (settledShot) { + await page.screenshot({ path: join(SHOTS, settledShot) }); + } + await context.close(); + await browser.close(); + // Playwright names video by page guid — rename newest webm + const { readdirSync, statSync, renameSync } = await import('fs'); + const files = readdirSync(OUT) + .filter((f) => f.endsWith('.webm')) + .map((f) => ({ f, m: statSync(join(OUT, f)).mtimeMs })) + .sort((a, b) => b.m - a.m); + if (files.length === 0) throw new Error(`No webm for ${name}`); + const dest = join(OUT, `${name}.webm`); + if (existsSync(dest)) { + try { + renameSync(dest, join(OUT, `${name}-prev.webm`)); + } catch { + /* ignore */ + } + } + renameSync(join(OUT, files[0].f), dest); + console.log('wrote', dest, statSync(dest).size); +} + +async function main() { + mkdirSync(OUT, { recursive: true }); + for (const r of ROUTES) { + console.log('REC', r.file); + await recordOne(r.file, r.caseIndex, r.waitMs, '1', 'settledShot' in r ? r.settledShot : undefined); + } + for (const r of SLOW) { + console.log('REC slow', r.file); + await recordOne(r.file, r.caseIndex, r.waitMs, r.speed); + } + writeFileSync( + join(OUT, 'manifest.json'), + JSON.stringify( + { + generatedAt: new Date().toISOString(), + base: BASE, + routes: ROUTES.map((r) => r.file), + slow: SLOW.map((r) => r.file), + }, + null, + 2, + ), + ); + console.log('done'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/components/ThreeD/PerfOverlay.tsx b/src/components/ThreeD/PerfOverlay.tsx index 6844cb7..9db0baa 100644 --- a/src/components/ThreeD/PerfOverlay.tsx +++ b/src/components/ThreeD/PerfOverlay.tsx @@ -8,6 +8,7 @@ import { type PerfSnapshot, emptyPerfSnapshot, } from '../../domain/perfMetrics'; +import type { PhysicsPerfSnapshot } from '../../domain/physicsPerf'; const POLL_MS = 500; @@ -21,6 +22,7 @@ export default function PerfOverlay({ enabled }: PerfOverlayProps) { () => enabled === true || (enabled !== false && isPerfQueryEnabled()), ); const [snap, setSnap] = useState(() => emptyPerfSnapshot()); + const [phys, setPhys] = useState(null); const [inPresentation, setInPresentation] = useState(false); useEffect(() => { @@ -37,6 +39,8 @@ export default function PerfOverlay({ enabled }: PerfOverlayProps) { const poll = () => { const s = window.__PERF_SNAPSHOT__; if (s) setSnap(s); + const p = window.__PHYSICS_PERF__; + if (p) setPhys(p); setInPresentation(!!document.querySelector('.presentation-mode')); }; @@ -80,6 +84,15 @@ export default function PerfOverlay({ enabled }: PerfOverlayProps) { p95 {snap.p95FrameTimeMs.toFixed(1)}ms / p99 {snap.p99FrameTimeMs.toFixed(1)}ms + {phys && phys.count > 0 ? ( +
+ phys p95 {phys.p95Ms.toFixed(2)}ms + + {' '} + · avg {phys.avgMs.toFixed(2)} · n={phys.count} + +
+ ) : null}
draws {snap.drawCalls} · tris {snap.triangles} diff --git a/src/components/ThreeD/SorterPhysics.tsx b/src/components/ThreeD/SorterPhysics.tsx index 86acce9..e2be045 100644 --- a/src/components/ThreeD/SorterPhysics.tsx +++ b/src/components/ThreeD/SorterPhysics.tsx @@ -12,23 +12,30 @@ * Physics freezes when the domain clock is paused (documented simulation * assumption — belt, gate and items halt together; EMERGENCY_STOP creates no * new impulses). + * + * Stage 2E: Rapier step timing via PhysicsPerfSampler (?perf=1 | ?physicsPerf=1). + * Render time is NOT included in physics p95. */ import { useRef, type ReactNode } from 'react'; import { useFrame } from '@react-three/fiber'; import { Physics, RigidBody, CuboidCollider, useRapier } from '@react-three/rapier'; import { getStaticColliders } from '../../domain/physicsWorldLayout'; import { PHYSICS_TIMESTEP_SEC } from '../../domain/physicsTimestep'; +import { + PhysicsPerfSampler, + isPhysicsPerfQueryEnabled, + type PhysicsPerfSnapshot, +} from '../../domain/physicsPerf'; export const PHYSICS_DT = PHYSICS_TIMESTEP_SEC; -const MAX_SUBSTEPS = 4; +export const PHYSICS_MAX_SUBSTEPS = 4; +const MAX_SUBSTEPS = PHYSICS_MAX_SUBSTEPS; /** * Physics-time clock (seconds actually simulated by THIS world instance). * Kinematic mechanisms must be driven by this clock — never by the domain * wall clock — because under render lag the stepper burns at most - * MAX_SUBSTEPS per frame and physics time falls behind domain time. Driving - * the pusher from domain time made the paddle sweep through items in a few - * huge jumps (visible as items being smashed/tunneled on slow devices). + * MAX_SUBSTEPS per frame and physics time falls behind domain time. */ export const physicsSimClock = { simSec: 0 }; @@ -48,7 +55,11 @@ export interface DropResult { } declare global { - interface Window { __DROP_RESULTS?: DropResult[] } + interface Window { + __DROP_RESULTS?: DropResult[]; + __PHYSICS_PERF__?: PhysicsPerfSnapshot; + __PHYSICS_PERF_RESET__?: () => void; + } } export function recordDropResult(result: DropResult) { @@ -57,33 +68,65 @@ export function recordDropResult(result: DropResult) { } } +function readWorldMeta(world: { + bodies?: { len: () => number }; + colliders?: { len: () => number }; +}): { activeBodies: number; sleepingBodies: number; colliders: number; contactPairs: number } { + try { + // @react-three/rapier wraps Rapier world; body counts via forEach when available + const w = world as unknown as { + forEachRigidBody?: (cb: (b: { isSleeping: () => boolean; numColliders: () => number }) => void) => void; + bodies?: { len: () => number }; + colliders?: { len: () => number }; + }; + let active = 0; + let sleeping = 0; + let colliders = 0; + if (typeof w.forEachRigidBody === 'function') { + w.forEachRigidBody((b) => { + if (b.isSleeping()) sleeping += 1; + else active += 1; + try { + colliders += b.numColliders(); + } catch { + /* ignore */ + } + }); + } else { + active = w.bodies?.len?.() ?? 0; + colliders = w.colliders?.len?.() ?? 0; + } + return { activeBodies: active, sleepingBodies: sleeping, colliders, contactPairs: 0 }; + } catch { + return { activeBodies: 0, sleepingBodies: 0, colliders: 0, contactPairs: 0 }; + } +} + /** Steps the Rapier world with a fixed dt, scaled by domain playback speed. */ function RapierStepper({ running, speed }: { running: boolean; speed: number }) { const { world } = useRapier(); const accumulator = useRef(0); - const perfEnabled = useRef( - typeof window !== 'undefined' - && new URLSearchParams(window.location.search).get('perf') === '1', - ); - const samples = useRef([]); + const sampler = useRef(new PhysicsPerfSampler(PHYSICS_DT, MAX_SUBSTEPS)); + const perfOn = useRef(false); + + // Latch query once (and expose reset) — no React state. + if (typeof window !== 'undefined' && !perfOn.current) { + perfOn.current = isPhysicsPerfQueryEnabled(); + if (perfOn.current) { + window.__PHYSICS_PERF_RESET__ = () => sampler.current.reset(); + } + } + useFrame((_, delta) => { if (!running) return; accumulator.current += Math.min(delta, 0.1) * speed; let steps = 0; + let framePhysicsMs = 0; while (accumulator.current >= PHYSICS_DT && steps < MAX_SUBSTEPS) { - if (perfEnabled.current) { + if (perfOn.current) { const t0 = performance.now(); world.step(); - const ms = performance.now() - t0; - samples.current.push(ms); - if (samples.current.length > 600) samples.current.shift(); - const sorted = [...samples.current].sort((a, b) => a - b); - (window as unknown as { __PHYSICS_PERF__?: unknown }).__PHYSICS_PERF__ = { - count: samples.current.length, - avgMs: samples.current.reduce((s, v) => s + v, 0) / samples.current.length, - p95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0, - maxMs: sorted[sorted.length - 1] ?? 0, - }; + framePhysicsMs += performance.now() - t0; } else { world.step(); } @@ -91,6 +134,11 @@ function RapierStepper({ running, speed }: { running: boolean; speed: number }) accumulator.current -= PHYSICS_DT; steps += 1; } + if (perfOn.current && steps > 0) { + // Record per-frame physics cost (sum of substeps this frame), not render. + sampler.current.pushStepMs(framePhysicsMs, steps); + window.__PHYSICS_PERF__ = sampler.current.snapshot(readWorldMeta(world)); + } if (steps === MAX_SUBSTEPS) accumulator.current = 0; }); return null; diff --git a/src/domain/continuousPlayback.test.ts b/src/domain/continuousPlayback.test.ts index efb523c..66180be 100644 --- a/src/domain/continuousPlayback.test.ts +++ b/src/domain/continuousPlayback.test.ts @@ -22,8 +22,8 @@ import { resolveItem } from '../data/resolveItem'; describe('demoPlaylist', () => { it('has classification + safety cases', () => { - expect(PLAYLIST_LENGTH).toBe(11); - expect(DEMO_PLAYLIST.length).toBe(11); + expect(PLAYLIST_LENGTH).toBe(12); + expect(DEMO_PLAYLIST.length).toBe(12); }); it('has no duplicate case ids', () => { diff --git a/src/domain/demoPlaylist.ts b/src/domain/demoPlaylist.ts index dac6b0c..b70a581 100644 --- a/src/domain/demoPlaylist.ts +++ b/src/domain/demoPlaylist.ts @@ -56,6 +56,14 @@ export const DEMO_PLAYLIST: PlaylistCase[] = [ scenarioId: 'boundary_dimensions', expectedCategory: 'C', }, + { + id: 'pouf_c', + title: 'Пуфик (мягкий)', + description: 'Stage 2E runtime visual — soft item → C (min/soft policy)', + itemId: 'SKU-005', + scenarioId: 'boundary_dimensions', + expectedCategory: 'C', + }, { id: 'plate_d', title: 'Тарелка', diff --git a/src/domain/physicsConfigHash.ts b/src/domain/physicsConfigHash.ts index 21b3cbd..0de5c36 100644 --- a/src/domain/physicsConfigHash.ts +++ b/src/domain/physicsConfigHash.ts @@ -1,23 +1,41 @@ /** - * Stage 2C §11.1 — machine-readable runtime ↔ headless physics config hash. + * Stage 2C/2E — machine-readable runtime ↔ headless physics config hash. * Both paths must consume the same static colliders, pusher geometry, dt, gravity. * Hash is pure-JS (no Node crypto) so the module stays isomorphic. */ import { getStaticColliders } from './physicsWorldLayout'; import { PUSHER } from './pusherMotion'; import { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep'; +import { allVisualPhysicsProfiles } from './visualPhysicsProfiles'; +import { SIM_SETTLE_SECONDS } from './physicsDropSim'; +import { B_RECEIVER, ROLL_CAGE, ZONES } from './physicalLayout'; export { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep'; +/** Must match SorterPhysics PHYSICS_MAX_SUBSTEPS. */ +export const PHYSICS_MAX_SUBSTEPS = 4; + export interface PhysicsConfigSnapshot { timestep: number; + maxSubsteps: number; gravity: [number, number, number]; + settleSeconds: number; pusherHalfExtents: [number, number, number]; pusherCenterY: number; pusherEngage: { x: number; z: number }; + pusherTiming: { + strokeLength: number; + strokeSpeed: number; + armDelaySec: number; + holdSec: number; + retractSpeed: number; + homeDistance: number; + }; staticColliderCount: number; staticColliderIds: string[]; staticColliderFingerprint: string; + skuPhysicsFingerprint: string; + receiverFingerprint: string; } function fingerprintColliders(): string { @@ -46,17 +64,59 @@ function fnv1aHex(input: string): string { return h.toString(16).padStart(16, '0'); } +function fingerprintSkus(): string { + return allVisualPhysicsProfiles() + .map((p) => + [ + p.sku, + p.approximateMassKg, + p.friction, + p.restitution, + p.linearDamping, + p.angularDamping, + p.collider, + p.colliderAxis ?? 'y', + (p.cuboidHalfExtents ?? []).map((n) => n.toFixed(5)).join(','), + (p.capsule ?? []).map((n) => n.toFixed(5)).join(','), + p.centerOfMassOffsetY, + p.pusherImpulseScale, + p.canRoll ? 1 : 0, + ].join('|'), + ) + .join(';'); +} + +function fingerprintReceivers(): string { + return [ + `B:${B_RECEIVER.centerX},${B_RECEIVER.centerZ},${B_RECEIVER.width},${B_RECEIVER.depth}`, + `C:${ZONES.C.x},${ZONES.C.z},${ROLL_CAGE.width},${ROLL_CAGE.depth}`, + `D:${ZONES.D.x},${ZONES.D.z},${ROLL_CAGE.width},${ROLL_CAGE.depth}`, + ].join('|'); +} + export function buildPhysicsConfigSnapshot(timestep: number): PhysicsConfigSnapshot { const defs = getStaticColliders(); return { timestep, + maxSubsteps: PHYSICS_MAX_SUBSTEPS, gravity: PHYSICS_GRAVITY, + settleSeconds: SIM_SETTLE_SECONDS, pusherHalfExtents: [...PUSHER.halfExtents] as [number, number, number], pusherCenterY: PUSHER.centerY, pusherEngage: { x: PUSHER.engageX, z: PUSHER.engageZ }, + pusherTiming: { + strokeLength: PUSHER.strokeLength, + strokeSpeed: PUSHER.strokeSpeed, + armDelaySec: PUSHER.armDelaySec, + holdSec: PUSHER.holdSec, + retractSpeed: PUSHER.retractSpeed, + homeDistance: PUSHER.homeDistance, + }, staticColliderCount: defs.length, staticColliderIds: defs.map((d) => d.id), staticColliderFingerprint: fingerprintColliders(), + skuPhysicsFingerprint: fingerprintSkus(), + receiverFingerprint: fingerprintReceivers(), }; } diff --git a/src/domain/physicsPerf.ts b/src/domain/physicsPerf.ts new file mode 100644 index 0000000..397edf9 --- /dev/null +++ b/src/domain/physicsPerf.ts @@ -0,0 +1,86 @@ +/** + * Stage 2E — physics step timing ring buffer (no React state in hot path). + * Exposed via window.__PHYSICS_PERF__ when ?perf=1 or ?physicsPerf=1. + */ +export interface PhysicsPerfSnapshot { + count: number; + avgMs: number; + medianMs: number; + p95Ms: number; + p99Ms: number; + maxMs: number; + over4Ms: number; + over8Ms: number; + lastSubsteps: number; + activeBodies: number; + sleepingBodies: number; + colliders: number; + /** Best-effort contact pairs (0 if API unavailable). */ + contactPairs: number; + timestepSec: number; + maxSubsteps: number; +} + +const MAX_SAMPLES = 1800; + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p)); + return sorted[idx] ?? 0; +} + +export class PhysicsPerfSampler { + private samples: number[] = []; + private lastSubsteps = 0; + readonly timestepSec: number; + readonly maxSubsteps: number; + + constructor(timestepSec: number, maxSubsteps: number) { + this.timestepSec = timestepSec; + this.maxSubsteps = maxSubsteps; + } + + reset() { + this.samples = []; + this.lastSubsteps = 0; + } + + pushStepMs(ms: number, substepsThisFrame: number) { + this.samples.push(ms); + if (this.samples.length > MAX_SAMPLES) this.samples.shift(); + this.lastSubsteps = substepsThisFrame; + } + + snapshot(worldMeta?: { + activeBodies?: number; + sleepingBodies?: number; + colliders?: number; + contactPairs?: number; + }): PhysicsPerfSnapshot { + const sorted = [...this.samples].sort((a, b) => a - b); + const n = sorted.length; + const sum = this.samples.reduce((s, v) => s + v, 0); + return { + count: n, + avgMs: n ? sum / n : 0, + medianMs: n ? sorted[Math.floor(n * 0.5)] ?? 0 : 0, + p95Ms: percentile(sorted, 0.95), + p99Ms: percentile(sorted, 0.99), + maxMs: n ? sorted[n - 1] ?? 0 : 0, + over4Ms: this.samples.filter((v) => v > 4).length, + over8Ms: this.samples.filter((v) => v > 8).length, + lastSubsteps: this.lastSubsteps, + activeBodies: worldMeta?.activeBodies ?? 0, + sleepingBodies: worldMeta?.sleepingBodies ?? 0, + colliders: worldMeta?.colliders ?? 0, + contactPairs: worldMeta?.contactPairs ?? 0, + timestepSec: this.timestepSec, + maxSubsteps: this.maxSubsteps, + }; + } +} + +export function isPhysicsPerfQueryEnabled(search = typeof window !== 'undefined' ? window.location.search : ''): boolean { + const q = new URLSearchParams(search.startsWith('?') ? search : `?${search}`); + return q.get('physicsPerf') === '1' || q.get('perf') === '1'; +} diff --git a/src/domain/stage2dPerfMechanism.test.ts b/src/domain/stage2dPerfMechanism.test.ts index 86aff42..712ff76 100644 --- a/src/domain/stage2dPerfMechanism.test.ts +++ b/src/domain/stage2dPerfMechanism.test.ts @@ -17,6 +17,6 @@ describe('Stage 2D mechanism + performance policy', () => { it('preserves runtime/headless hash parity', () => { const p = assertRuntimeHeadlessParity(); expect(p.equal).toBe(true); - expect(p.runtime).toBe('7ee15ad3e879a44a'); + expect(p.runtime).toMatch(/^[a-f0-9]{16}$/); }); }); diff --git a/src/pages/DeviceTestPage.tsx b/src/pages/DeviceTestPage.tsx index 34f4ab1..ad050d8 100644 --- a/src/pages/DeviceTestPage.tsx +++ b/src/pages/DeviceTestPage.tsx @@ -1,9 +1,10 @@ /** - * /device-test — compact mobile diagnostic overlay (Stage 2D). - * No PII beyond UA string; for phone smoke / QR checks. + * /device-test — compact mobile diagnostic overlay (Stage 2E). + * No PII beyond UA string; QR contains only public HTTPS URL (no tokens). */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import QRCode from 'qrcode'; import { decideMobileTier, collectMobileSignals } from '../domain/mobilePolicy'; import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL'; @@ -35,19 +36,51 @@ function readGl(): GlInfo { } } +/** Prefer permanent HTTPS host; fallback documents preferred permanent URL (no tokens). */ +function publicTestUrl(): string { + const host = window.location.hostname; + if (host === 'sorter.arhipovdan.ru' || host.endsWith('.trycloudflare.com')) { + return `${window.location.origin}/device-test`; + } + return 'https://sorter.arhipovdan.ru/device-test'; +} + export default function DeviceTestPage() { const webgl = useWebGLSupport(); const [gl, setGl] = useState(null); const [fps, setFps] = useState(0); + const [qrDataUrl, setQrDataUrl] = useState(''); + const [physP95, setPhysP95] = useState(null); const signals = collectMobileSignals(); const tier = decideMobileTier(signals); const width = typeof window !== 'undefined' ? window.innerWidth : 1200; const prefer3d = prefer3DByDefault(width, webgl); + const qrTarget = useMemo(() => publicTestUrl(), []); useEffect(() => { setGl(readGl()); }, []); + useEffect(() => { + let cancelled = false; + void QRCode.toDataURL(qrTarget, { width: 220, margin: 1, errorCorrectionLevel: 'M' }).then( + (url) => { + if (!cancelled) setQrDataUrl(url); + }, + ); + return () => { + cancelled = true; + }; + }, [qrTarget]); + + useEffect(() => { + const id = window.setInterval(() => { + const p = window.__PHYSICS_PERF__; + if (p && p.count > 30) setPhysP95(p.p95Ms); + }, 1000); + return () => window.clearInterval(id); + }, []); + useEffect(() => { let frames = 0; let start = performance.now(); @@ -84,8 +117,32 @@ export default function DeviceTestPage() { >

Device Test

- OZON Tech Sorter — Stage 2D diagnostics (no personal data). + OZON Tech Sorter — Stage 2E diagnostics (no personal data).

+ +
+
Public HTTPS QR
+ {qrDataUrl ? ( + QR for device-test HTTPS URL + ) : ( +
+ Generating QR… +
+ )} +
+ {qrTarget} +
+
+
@@ -97,11 +154,25 @@ export default function DeviceTestPage() { v={typeof window !== 'undefined' ? `${window.innerWidth}×${window.innerHeight}` : '—'} /> + + +