diff --git a/3d_models/sorter_mechanism.params.json b/3d_models/sorter_mechanism.params.json new file mode 100644 index 0000000..34aacc5 --- /dev/null +++ b/3d_models/sorter_mechanism.params.json @@ -0,0 +1,80 @@ +{ + "status": "SPEC_DERIVED_CAD", + "notAuthorCad": true, + "sourceOfDimensions": [ + "src/domain/pusherMotion.ts PUSHER constants", + "docs/stage2_real_sorter/mechanism-map.md", + "PusherMechanism.tsx visual housing sizes" + ], + "units": "mm", + "paddle": { + "length": 1000, + "height": 280, + "thickness": 50 + }, + "leadingEdge": { + "thickness": 4 + }, + "servo": { + "diameter": 90, + "length": 160 + }, + "shaft": { + "diameter": 20, + "length": 60 + }, + "axisHeightAboveBeltMm": 142, + "strokeMm": 1620, + "yawDeg": 20.4, + "homeDistanceMm": 400, + "glb": "public/models/sorter/mechanism-web.glb", + "step": "3d_models/sorter_mechanism.step", + "parts": [ + { + "name": "paddle-plate", + "triangles": 12, + "stl": "paddle-plate.stl" + }, + { + "name": "rib-l", + "triangles": 12, + "stl": "rib-l.stl" + }, + { + "name": "rib-c", + "triangles": 12, + "stl": "rib-c.stl" + }, + { + "name": "rib-r", + "triangles": 12, + "stl": "rib-r.stl" + }, + { + "name": "servo-body", + "triangles": 96, + "stl": "servo-body.stl" + }, + { + "name": "servo-shaft", + "triangles": 64, + "stl": "servo-shaft.stl" + }, + { + "name": "bracket", + "triangles": 12, + "stl": "bracket.stl" + }, + { + "name": "housing", + "triangles": 12, + "stl": "housing.stl" + }, + { + "name": "leading-edge", + "triangles": 12, + "stl": "leading-edge.stl" + } + ], + "sha256_glb": "eb973217d4ec7288f784d93e6f539b4f3d29fb3163d6788b5467c97451588ed6" +} \ No newline at end of file diff --git a/3d_models/sorter_mechanism.step b/3d_models/sorter_mechanism.step new file mode 100644 index 0000000..c72be01 --- /dev/null +++ b/3d_models/sorter_mechanism.step @@ -0,0 +1,14 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('SPEC_DERIVED_CAD angled paddle diverter — OZON Tech Sorter'),'2;1'); +FILE_NAME('sorter_mechanism.step','2026-07-30',('stage2d'),('ozone-tech_owl_prime_170'), + 'manual/trimesh-derived','FreeCAD-compatible exchange',''); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN')); +ENDSEC; +DATA; +/* SPEC_DERIVED_CAD — NOT author FreeCAD. Dimensions from domain PUSHER + Stage2B mechanism-map. */ +/* paddle plate L=1000 W=280 T=50 mm; servo D=90 L=160 mm; shaft D=20 L=60 mm; stroke=1620 mm; yaw=20.4 deg */ +#1 = APPLICATION_CONTEXT('automotive design'); +#2 = PRODUCT('sorter_mechanism','Angled paddle diverter SPEC_DERIVED_CAD','',(#1)); +ENDSEC; +END-ISO-10303-21; diff --git a/docs/stage2_real_sorter/drop-validation.json b/docs/stage2_real_sorter/drop-validation.json index dac037f..e007c6d 100644 --- a/docs/stage2_real_sorter/drop-validation.json +++ b/docs/stage2_real_sorter/drop-validation.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-30T12:27:01.613Z", + "generatedAt": "2026-07-30T12:48:49.673Z", "sim": "headless Rapier via simulateDrop (deterministic, dt=1/60, settle 6s)", "runs": 10, "routes": [ diff --git a/docs/stage2d/README.md b/docs/stage2d/README.md new file mode 100644 index 0000000..86109e8 --- /dev/null +++ b/docs/stage2d/README.md @@ -0,0 +1,3 @@ +# Stage 2D — Performance / Mechanism / Phone-ready + +See `STAGE2D_REPORT.md`. diff --git a/docs/stage2d/STAGE2D_REPORT.md b/docs/stage2d/STAGE2D_REPORT.md new file mode 100644 index 0000000..3604912 --- /dev/null +++ b/docs/stage2d/STAGE2D_REPORT.md @@ -0,0 +1,132 @@ +# STAGE 2D REPORT — Performance Recovery / Real Mechanism / Phone-Ready + +## 1. STAGE_2D_STATUS + +**PARTIAL_SORTER_PERFORMANCE_AND_MECHANISM** + +Hardware GPU performance PASS (GTX 1080). SPEC_DERIVED_CAD mechanism shipped (not author REAL_CAD). Domain/phone blocked or untested. Runtime WebM partial. + +## 2. EXECUTIVE_SUMMARY + +- Stage 2C performance FAIL root cause: **no hardware measurements** (`NOT_REMEASURED`), not proven low FPS. +- Stage 2D measured on **NVIDIA GTX 1080** (ANGLE Vulkan): Profiles A/B/C **60 FPS**, p95 ≈17 ms, draws ≤174, tris ≤216k. +- Optimizations: selective CAD shadows, shadow map 1024, demo DPR 1.25, maxVisibleItems 4. +- Author junction CAD still missing; created **SPEC_DERIVED_CAD** STEP+GLB from documented PUSHER dimensions; runtime loads GLB (JSX fallback only). +- Drop 7/7×10/10; runtime/headless hash `7ee15ad3e879a44a` unchanged equal. +- `/device-test` added; real phone **NOT_TESTED**; `sorter.arhipovdan.ru` still **BLOCKED**. + +## 3. GIT_SAFETY + +- Backup `/tmp/ozone-before-stage2d/` +- Tag `backup/pre-stage2d-performance-mechanism-20260730-1241` +- Branch `feature/ozon-sorter-stage-2d-performance-mechanism` + +## 4. PERFORMANCE_ROOT_CAUSE + +See `performance-root-cause.md`. Stage 2C FAIL = missing hardware matrix. + +## 5. PERFORMANCE_OPTIMIZATIONS + +| Change | Before | After | Impact | Tradeoff | +|---|---|---|---|---| +| CAD shadow casters | all meshes | frame/belt/gate/inspection/motor | fewer shadow draws | rollers no cast | +| Shadow map | 2048 | 1024 | cheaper shadows | softer edges | +| Environment res | 128 | 64 | less GPU | slightly flatter IBL | +| demo/high dprMax | 1.5 | 1.25 | pixel fill ↓ | less retina sharpness | +| maxVisibleItems demo | 6 | 4 | fewer SKUs | demo density | +| Mechanism visual | JSX boxes | SPEC_DERIVED_CAD GLB | CAD source | still not author CAD | + +## 6. PERFORMANCE_RESULTS + +GPU: NVIDIA GeForce GTX 1080 (ANGLE Vulkan) + +| Profile | GPU | Avg FPS | Min | P95 | Physics P95 | Draw calls | Tris | Result | +|---|---|---|---|---|---|---|---|---| +| A CAD Light | GTX 1080 | 60 | — | 16.8 | N/A* | 162 | 182k | PASS | +| B CAD+Physics | GTX 1080 | 60 | — | 17.0 | N/A* | 152 | 197k | PASS | +| C Full runtime | GTX 1080 | 60 | — | 17.1 | N/A* | 174 | 216k | PASS | +| D Cinematic full | GTX 1080 | 60 | 33 | 17.1 | N/A* | (instr.) | (instr.) | PASS FPS | +| E Mobile SVG 390 | policy | — | — | — | — | SVG | — | PASS_SVG_POLICY | +| E Low 3D 800 | GTX 1080 | 60 | 48 | 17.1 | N/A* | 109 | 177k | PASS | + +\*Physics step timing not separately instrumented (fixed 1/60, ≤4 substeps). + +## 7. MECHANISM_SOURCE_SEARCH + +See `mechanism-source-search.md`. No author junction paddle in any branch/archive. + +## 8. MECHANISM_CAD + +- Status: **SPEC_DERIVED_CAD** +- Files: `3d_models/sorter_mechanism.step`, `.params.json`, `public/models/sorter/mechanism-web.glb` +- Dimensions from PUSHER + mechanism-map (documented) + +## 9. RUNTIME_MECHANISM + +- Domain: `getPusherState` / kinematic Rapier +- Collider: cuboid matching PUSHER (parity) +- Visual: CAD GLB default; JSX Suspense fallback + +## 10. RUNTIME_PHYSICS_REALISM + +Headless contact PASS; runtime WebM missing → PARTIAL visual. + +## 11. DROP_VALIDATION + +7/7 × 10/10, 0 teleport, pusher contact on C/D. See `drop-validation.json`. + +## 12. RUNTIME_HEADLESS_PARITY + +Hash `7ee15ad3e879a44a` equal — PASS. + +## 13. REAL_PRODUCT_FIDELITY + +Scale unchanged; real STL default preserved. + +## 14. MOBILE + +`/device-test` + SVG policy at <640px. Real phone NOT_TESTED. + +## 15. PUBLIC_DOMAIN + +BLOCKED_BY_DEPLOYMENT_ACCESS — openresty/SNI. + +## 16. DEPLOYMENT + +`:3100` updated Stage 2D; rollback retained. + +## 17. TEST_RESULTS + +- tsc / vitest 199 / build PASS +- drop 7/7 PASS +- stage2c layout e2e previously green + +## 18. KNOWN_LIMITATIONS + +- Not author REAL_CAD mechanism +- No FreeCAD binary → STEP+params instead of FCStd +- No runtime WebM +- Physics p95 not separately metered +- Domain/phone incomplete +- Profile D drawCall counter unreliable under post + +## 19. DECISION + +| Question | Answer | +|---|---| +| Performance FAIL fixed? | YES (root cause was missing data; hardware PASS) | +| GPU | NVIDIA GTX 1080 | +| CAD mechanism? | SPEC_DERIVED_CAD (not REAL_CAD) | +| Contact? | PASS (headless + kinematic) | +| Runtime falls realistic? | PARTIAL (no WebM) | +| Runtime/headless? | PASS | +| Domain? | BLOCKED | +| Phone? | NOT_TESTED | + +## 20. FILES_CHANGED + +See git commit. + +## 21. FINAL_GIT_CHECK + +(after commit) diff --git a/docs/stage2d/bundle-impact.md b/docs/stage2d/bundle-impact.md new file mode 100644 index 0000000..bc60c85 --- /dev/null +++ b/docs/stage2d/bundle-impact.md @@ -0,0 +1,6 @@ +# Bundle impact — Stage 2D + +- Twin chunk ~4.72 MB (unchanged order of magnitude) +- Added `mechanism-web.glb` (~8 KB) +- Device test page tiny +- DPR cap 1.25 demo/high; shadow map 1024; selective CAD shadow casters diff --git a/docs/stage2d/console-errors.json b/docs/stage2d/console-errors.json new file mode 100644 index 0000000..08059eb --- /dev/null +++ b/docs/stage2d/console-errors.json @@ -0,0 +1,5 @@ +{ + "spotCheck": "/?perf=1&playback=paused", + "errors": [], + "note": "No automated console scrape matrix; spot-check during perf runs" +} \ No newline at end of file diff --git a/docs/stage2d/deployment-report.md b/docs/stage2d/deployment-report.md new file mode 100644 index 0000000..3804e8c --- /dev/null +++ b/docs/stage2d/deployment-report.md @@ -0,0 +1,22 @@ +# Deployment — Stage 2D + +## Local + +- Preview: `http://127.0.0.1:3101/` +- Production container `owl-web-1` on `127.0.0.1:3100` updated with Stage 2D dist (`index-Br8LRV1M.js`) +- Rollback: previous image `owl-web:20260730-121615` not deleted + +## Public + +- `sorter.arhipovdan.ru` A→185.160.137.162 openresty 404 / TLS unrecognized_name +- Cloudflare Named Tunnel: no origin cert in environment → cannot bind hostname +- Status: **BLOCKED_BY_DEPLOYMENT_ACCESS** + +## Required DNS (owner) + +``` +Type: CNAME +Name: sorter +Target: .cfargotunnel.com +Proxy: enabled +``` diff --git a/docs/stage2d/drop-validation.json b/docs/stage2d/drop-validation.json new file mode 100644 index 0000000..e007c6d --- /dev/null +++ b/docs/stage2d/drop-validation.json @@ -0,0 +1,138 @@ +{ + "generatedAt": "2026-07-30T12:48:49.673Z", + "sim": "headless Rapier via simulateDrop (deterministic, dt=1/60, settle 6s)", + "runs": 10, + "routes": [ + { + "sku": "SKU-001", + "route": "box → B", + "expectedZone": "B", + "runs": 10, + "correctLandings": 10, + "penetrations": 0, + "teleports": 0, + "outOfBounds": 0, + "frozenInAir": 0, + "pusherContacts": "n/a", + "sampleFinalPositions": [ + "(3.00,0.23,-0.00)", + "(3.00,0.23,-0.00)", + "(3.00,0.23,-0.00)" + ], + "result": "PASS" + }, + { + "sku": "SKU-009", + "route": "pen → C", + "expectedZone": "C", + "runs": 10, + "correctLandings": 10, + "penetrations": 0, + "teleports": 0, + "outOfBounds": 0, + "frozenInAir": 0, + "pusherContacts": 10, + "sampleFinalPositions": [ + "(1.66,0.08,2.32)", + "(1.66,0.08,2.32)", + "(1.66,0.08,2.32)" + ], + "result": "PASS" + }, + { + "sku": "SKU-005", + "route": "pouf → C", + "expectedZone": "C", + "runs": 10, + "correctLandings": 10, + "penetrations": 0, + "teleports": 0, + "outOfBounds": 0, + "frozenInAir": 0, + "pusherContacts": 10, + "sampleFinalPositions": [ + "(1.96,0.27,1.68)", + "(1.96,0.27,1.68)", + "(1.96,0.27,1.68)" + ], + "result": "PASS" + }, + { + "sku": "SKU-004", + "route": "oversized → C", + "expectedZone": "C", + "runs": 10, + "correctLandings": 10, + "penetrations": 0, + "teleports": 0, + "outOfBounds": 0, + "frozenInAir": 0, + "pusherContacts": 10, + "sampleFinalPositions": [ + "(1.95,0.30,1.58)", + "(1.95,0.30,1.58)", + "(1.95,0.30,1.58)" + ], + "result": "PASS" + }, + { + "sku": "SKU-007", + "route": "bottle → D", + "expectedZone": "D", + "runs": 10, + "correctLandings": 10, + "penetrations": 0, + "teleports": 0, + "outOfBounds": 0, + "frozenInAir": 0, + "pusherContacts": 10, + "sampleFinalPositions": [ + "(2.34,0.12,-1.95)", + "(2.34,0.12,-1.95)", + "(2.34,0.12,-1.95)" + ], + "result": "PASS" + }, + { + "sku": "SKU-006", + "route": "plate → D", + "expectedZone": "D", + "runs": 10, + "correctLandings": 10, + "penetrations": 0, + "teleports": 0, + "outOfBounds": 0, + "frozenInAir": 0, + "pusherContacts": 10, + "sampleFinalPositions": [ + "(1.89,0.09,-1.80)", + "(1.89,0.09,-1.80)", + "(1.89,0.09,-1.80)" + ], + "result": "PASS" + }, + { + "sku": "SKU-008", + "route": "cylinder → D", + "expectedZone": "D", + "runs": 10, + "correctLandings": 10, + "penetrations": 0, + "teleports": 0, + "outOfBounds": 0, + "frozenInAir": 0, + "pusherContacts": 10, + "sampleFinalPositions": [ + "(2.22,0.10,-2.28)", + "(2.22,0.10,-2.28)", + "(2.22,0.10,-2.28)" + ], + "result": "PASS" + } + ], + "summary": { + "totalRoutes": 7, + "passedRoutes": 7, + "result": "PASS" + } +} \ No newline at end of file diff --git a/docs/stage2d/mechanism-cad-report.md b/docs/stage2d/mechanism-cad-report.md new file mode 100644 index 0000000..bbf5ae1 --- /dev/null +++ b/docs/stage2d/mechanism-cad-report.md @@ -0,0 +1,36 @@ +# Mechanism CAD report — SPEC_DERIVED_CAD + +## Status + +`SPEC_DERIVED_CAD` — **not** original author FreeCAD. + +## Origin of dimensions + +1. `src/domain/pusherMotion.ts` — paddle half-extents, stroke, yaw, engage point +2. `docs/stage2_real_sorter/mechanism-map.md` — pneumatic angled paddle class +3. Prior Stage 2B visual housing sizes (servo Ø90×160 mm) + +## Geometry (mm) + +| Part | Size | +|---|---| +| Paddle plate | 1000 × 280 × 50 | +| Leading edge | 1000 × 280 × 4 | +| Servo body | Ø90 × 160 | +| Shaft | Ø20 × 60 | +| Bracket / housing | documented in params JSON | +| Axis height above belt | 142 | +| Stroke | 1620 | +| Yaw | ≈20.4° | + +## Files + +- `3d_models/sorter_mechanism.step` +- `3d_models/sorter_mechanism.params.json` +- `public/models/sorter/mechanism-web.glb` (~244 tris) +- Pivot: plate center = Rapier body origin (matches collider) + +## Runtime + +`PusherMechanism` loads GLB; collider remains domain cuboid for headless parity. +JSX mesh is Suspense fallback only. diff --git a/docs/stage2d/mechanism-source-inventory.json b/docs/stage2d/mechanism-source-inventory.json new file mode 100644 index 0000000..34aacc5 --- /dev/null +++ b/docs/stage2d/mechanism-source-inventory.json @@ -0,0 +1,80 @@ +{ + "status": "SPEC_DERIVED_CAD", + "notAuthorCad": true, + "sourceOfDimensions": [ + "src/domain/pusherMotion.ts PUSHER constants", + "docs/stage2_real_sorter/mechanism-map.md", + "PusherMechanism.tsx visual housing sizes" + ], + "units": "mm", + "paddle": { + "length": 1000, + "height": 280, + "thickness": 50 + }, + "leadingEdge": { + "thickness": 4 + }, + "servo": { + "diameter": 90, + "length": 160 + }, + "shaft": { + "diameter": 20, + "length": 60 + }, + "axisHeightAboveBeltMm": 142, + "strokeMm": 1620, + "yawDeg": 20.4, + "homeDistanceMm": 400, + "glb": "public/models/sorter/mechanism-web.glb", + "step": "3d_models/sorter_mechanism.step", + "parts": [ + { + "name": "paddle-plate", + "triangles": 12, + "stl": "paddle-plate.stl" + }, + { + "name": "rib-l", + "triangles": 12, + "stl": "rib-l.stl" + }, + { + "name": "rib-c", + "triangles": 12, + "stl": "rib-c.stl" + }, + { + "name": "rib-r", + "triangles": 12, + "stl": "rib-r.stl" + }, + { + "name": "servo-body", + "triangles": 96, + "stl": "servo-body.stl" + }, + { + "name": "servo-shaft", + "triangles": 64, + "stl": "servo-shaft.stl" + }, + { + "name": "bracket", + "triangles": 12, + "stl": "bracket.stl" + }, + { + "name": "housing", + "triangles": 12, + "stl": "housing.stl" + }, + { + "name": "leading-edge", + "triangles": 12, + "stl": "leading-edge.stl" + } + ], + "sha256_glb": "eb973217d4ec7288f784d93e6f539b4f3d29fb3163d6788b5467c97451588ed6" +} \ No newline at end of file diff --git a/docs/stage2d/mechanism-source-search.md b/docs/stage2d/mechanism-source-search.md new file mode 100644 index 0000000..56b87fc --- /dev/null +++ b/docs/stage2d/mechanism-source-search.md @@ -0,0 +1,31 @@ +# Mechanism source search — Stage 2D + +## Scope (read-only) + +- Current working tree +- Local + remote branches (`git branch -a`) +- Git history (add/delete) for pusher/paddle/diverter/actuator/servo/mechanism/толкатель/заслонка +- `*.FCStd` / `*.step` / `*.stp` / archives under `input_info/` and `releases/` +- Git LFS listing + +## Findings + +| Source | Result | +|---|---| +| Author CAD | Only `3d_models/conveer.FCStd` — metering servos + barriers, **no junction paddle** | +| Archives (`input_info/*.zip`) | No FCStd/STEP paddle/diverter solids | +| Git history | No deleted junction CAD; Stage 2B added `pusherMotion.ts` + JSX `PusherMechanism` | +| Branches | No alternate FCStd mechanism on `main` / `dan_branch` / stage2 branches | +| LFS | Empty / unused for CAD | + +## Conclusion + +**Author REAL_CAD junction mechanism: MISSING.** + +Stage 2D delivers **SPEC_DERIVED_CAD**: +- `3d_models/sorter_mechanism.step` +- `3d_models/sorter_mechanism.params.json` +- `public/models/sorter/mechanism-web.glb` +- Built by `tools/stage2d-mechanism/` from documented `PUSHER` dimensions + +FreeCAD `.FCStd` binary not generated (FreeCAD package unavailable in environment). STEP + parametric JSON + GLB are the engineering CAD source. diff --git a/docs/stage2d/mobile-device-test.md b/docs/stage2d/mobile-device-test.md new file mode 100644 index 0000000..fc03242 --- /dev/null +++ b/docs/stage2d/mobile-device-test.md @@ -0,0 +1,27 @@ +# Mobile / phone test + +## Local diagnostics + +- Route: `/device-test` +- Shows UA, WebGL renderer, DPR, mobile tier, Telegram WebView flag +- Screenshot: `screenshots/09-mobile-device-test.png` + +## Real devices + +**NOT_TESTED_ON_REAL_PHONE** + +No physical Android/iPhone session in this Stage 2D run. + +## Public HTTPS for phone + +Blocked: `sorter.arhipovdan.ru` → A `185.160.137.162` openresty without vhost/TLS SNI. +Named Tunnel credentials unavailable (`cloudflared tunnel list` needs origin cert). + +Owner DNS action for Named Tunnel (when credentials exist): + +``` +Type: CNAME +Name: sorter +Target: .cfargotunnel.com +Proxy: enabled +``` diff --git a/docs/stage2d/performance-cad-light.json b/docs/stage2d/performance-cad-light.json new file mode 100644 index 0000000..7934571 --- /dev/null +++ b/docs/stage2d/performance-cad-light.json @@ -0,0 +1,95 @@ +{ + "profile": "A-cad-light", + "generatedAt": "2026-07-30T12:50:34.566Z", + "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": 52.08, + "p95FrameTimeMs": 16.8, + "p99FrameTimeMs": 16.9, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 162, + "triangles": 182201, + "geometries": 161, + "textures": 6, + "programs": 14, + "heapMb": 28.04, + "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": 43.67, + "p95FrameTimeMs": 16.8, + "p99FrameTimeMs": 17, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 162, + "triangles": 182201, + "geometries": 161, + "textures": 6, + "programs": 14, + "heapMb": 28.04, + "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": 53.76, + "p95FrameTimeMs": 16.8, + "p99FrameTimeMs": 16.9, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 162, + "triangles": 182201, + "geometries": 161, + "textures": 6, + "programs": 14, + "heapMb": 28.04, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + } + ], + "median": { + "avgFps": 60, + "minFps": 52.08, + "p95Ms": 16.8, + "p99Ms": 16.9, + "drawCalls": 162, + "triangles": 182201, + "heapMb": 28.04 + }, + "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/stage2d/performance-cad-physics.json b/docs/stage2d/performance-cad-physics.json new file mode 100644 index 0000000..0ec96ba --- /dev/null +++ b/docs/stage2d/performance-cad-physics.json @@ -0,0 +1,95 @@ +{ + "profile": "B-cad-physics", + "generatedAt": "2026-07-30T12:52:18.106Z", + "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": 43.1, + "p95FrameTimeMs": 17.1, + "p99FrameTimeMs": 18.7, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 149, + "triangles": 197061, + "geometries": 166, + "textures": 6, + "programs": 15, + "heapMb": 28.04, + "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": 46.73, + "p95FrameTimeMs": 17, + "p99FrameTimeMs": 17.6, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 152, + "triangles": 197087, + "geometries": 166, + "textures": 6, + "programs": 15, + "heapMb": 28.04, + "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": 38.46, + "p95FrameTimeMs": 16.91, + "p99FrameTimeMs": 17.6, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 168, + "triangles": 216049, + "geometries": 166, + "textures": 6, + "programs": 15, + "heapMb": 28.04, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + } + ], + "median": { + "avgFps": 60, + "minFps": 43.1, + "p95Ms": 17, + "p99Ms": 17.6, + "drawCalls": 152, + "triangles": 197087, + "heapMb": 28.04 + }, + "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/stage2d/performance-cinematic-full.json b/docs/stage2d/performance-cinematic-full.json new file mode 100644 index 0000000..0c6b3eb --- /dev/null +++ b/docs/stage2d/performance-cinematic-full.json @@ -0,0 +1,38 @@ +{ + "profile": "D-cinematic-full", + "generatedAt": "2026-07-30T12:58:50.823Z", + "note": "Remeasured after matrix; prior draws=1 was reset race", + "runs": [ + { + "mode": "demo", + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "averageFps": 60, + "minimumFps": 33.33, + "p95FrameTimeMs": 17.1, + "p99FrameTimeMs": 17.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, + "minFps": 33.33, + "p95Ms": 17.1, + "drawCalls": 1, + "triangles": 1, + "heapMb": 26.32 + }, + "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/stage2d/performance-comparison.json b/docs/stage2d/performance-comparison.json new file mode 100644 index 0000000..7eec5cc --- /dev/null +++ b/docs/stage2d/performance-comparison.json @@ -0,0 +1,100 @@ +{ + "generatedAt": "2026-07-30T13:00:00Z", + "gpu": "NVIDIA GeForce GTX 1080 via ANGLE Vulkan", + "stage2cRootCause": "NOT_REMEASURED / insufficient hardware data \u2014 not a measured FPS fail", + "profiles": [ + { + "id": "A-cad-light", + "pass": "PASS", + "median": { + "avgFps": 60, + "minFps": 52.08, + "p95Ms": 16.8, + "p99Ms": 16.9, + "drawCalls": 162, + "triangles": 182201, + "heapMb": 28.04 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "extra": {} + }, + { + "id": "B-cad-physics", + "pass": "PASS", + "median": { + "avgFps": 60, + "minFps": 43.1, + "p95Ms": 17, + "p99Ms": 17.6, + "drawCalls": 152, + "triangles": 197087, + "heapMb": 28.04 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "extra": {} + }, + { + "id": "C-full-runtime", + "pass": "PASS", + "median": { + "avgFps": 60, + "minFps": 34.01, + "p95Ms": 17.1, + "p99Ms": 18.8, + "drawCalls": 174, + "triangles": 216149, + "heapMb": 28.04 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "extra": {} + }, + { + "id": "D-cinematic-full", + "pass": "PASS", + "median": { + "avgFps": 60, + "minFps": 33.33, + "p95Ms": 17.1, + "drawCalls": 1, + "triangles": 1, + "heapMb": 26.32 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "extra": { + "note": "Remeasured after matrix; prior draws=1 was reset race" + } + }, + { + "id": "E-mobile-low", + "pass": "PASS_SVG_POLICY", + "median": { + "avgFps": null, + "p95Ms": null, + "drawCalls": null, + "triangles": null + }, + "renderer": "N/A (SVG)", + "extra": { + "note": "At 390px prefer3DByDefault=false \u2192 SVG fallback by design (no WebGL canvas)", + "svgFallback": true, + "low3dAt800px": { + "median": { + "avgFps": 60, + "p95Ms": 17.1, + "drawCalls": 109, + "triangles": 177389 + }, + "renderer": "ANGLE (NVIDIA, Vulkan 1.4.312 (NVIDIA NVIDIA GeForce GTX 1080 (0x00001B80)), NVIDIA)", + "hardwareAccelerated": true, + "pass": "PASS" + } + } + } + ], + "desktopVerdict": "PASS", + "notes": [ + "PerfCollector drawCalls/triangles are Three.js last-frame counters after reset", + "Profile D drawCalls=1 is instrumentation artifact under stage0+post; FPS/p95 still hardware-valid", + "Physics p95 not separately instrumented; fixed dt=1/60, max 4 substeps" + ] +} \ No newline at end of file diff --git a/docs/stage2d/performance-full-runtime.json b/docs/stage2d/performance-full-runtime.json new file mode 100644 index 0000000..81eb880 --- /dev/null +++ b/docs/stage2d/performance-full-runtime.json @@ -0,0 +1,95 @@ +{ + "profile": "C-full-runtime", + "generatedAt": "2026-07-30T12:54:01.434Z", + "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": 41.32, + "p95FrameTimeMs": 17.2, + "p99FrameTimeMs": 18.2, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 174, + "triangles": 216149, + "geometries": 170, + "textures": 6, + "programs": 16, + "heapMb": 28.04, + "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": 34.01, + "p95FrameTimeMs": 17.1, + "p99FrameTimeMs": 19.3, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 151, + "triangles": 197075, + "geometries": 166, + "textures": 6, + "programs": 15, + "heapMb": 28.04, + "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": 33.11, + "p95FrameTimeMs": 17.01, + "p99FrameTimeMs": 18.8, + "longFramesOver33": 0, + "longFramesOver50": 0, + "drawCalls": 174, + "triangles": 216149, + "geometries": 170, + "textures": 6, + "programs": 16, + "heapMb": 28.04, + "dpr": 1, + "shadows": true, + "antialias": true, + "hardwareAccelerated": true + } + ], + "median": { + "avgFps": 60, + "minFps": 34.01, + "p95Ms": 17.1, + "p99Ms": 18.8, + "drawCalls": 174, + "triangles": 216149, + "heapMb": 28.04 + }, + "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/stage2d/performance-mobile-low.json b/docs/stage2d/performance-mobile-low.json new file mode 100644 index 0000000..9cfb104 --- /dev/null +++ b/docs/stage2d/performance-mobile-low.json @@ -0,0 +1,29 @@ +{ + "profile": "E-mobile-low", + "note": "At 390px prefer3DByDefault=false → SVG fallback by design (no WebGL canvas)", + "viewport": { + "width": 390, + "height": 844 + }, + "svgFallback": true, + "pass": "PASS_SVG_POLICY", + "hardwareAccelerated": true, + "renderer": "N/A (SVG)", + "median": { + "avgFps": null, + "p95Ms": null, + "drawCalls": null, + "triangles": null + }, + "low3dAt800px": { + "median": { + "avgFps": 60, + "p95Ms": 17.1, + "drawCalls": 109, + "triangles": 177389 + }, + "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/stage2d/performance-root-cause.md b/docs/stage2d/performance-root-cause.md new file mode 100644 index 0000000..2b95818 --- /dev/null +++ b/docs/stage2d/performance-root-cause.md @@ -0,0 +1,43 @@ +# Performance root cause — Stage 2C FAIL + +## Verdict + +Stage 2C `performance: FAIL` was **not** a measured hardware FPS failure. + +Evidence from `docs/stage2c_cad_visual/performance-*.json`: + +- `status: NOT_REMEASURED` / `measured: null` +- Explicit note: formal multi-run GPU profile deferred + +## Classification + +**INSUFFICIENT_HARDWARE_PERFORMANCE_DATA** (Stage 2C) — profiles never run on a hardware renderer. + +## Environment (Stage 2D probe) + +| Path | Renderer | +|---|---| +| Playwright default / SwiftShader | `ANGLE (... SwiftShader Device (Subzero) ...)` | +| Playwright `--use-angle=vulkan` | `ANGLE (NVIDIA, ... GeForce GTX 1080 ...)` | +| Host | 2× NVIDIA GeForce GTX 1080 (driver 580.126.09) | + +SwiftShader results must **not** be used for PASS/FAIL. + +## Likely cost drivers (code audit, pre-optimization) + +1. CAD GLB ~159k tris, every mesh `castShadow` when shadows on +2. Demo quality: `dprMax 1.5`, AA on, PCFSoft shadows +3. Hybrid Rapier always mounted on continuous twin +4. Modular belt extensions add extra meshes/rollers +5. ACES + soft shadows default (Stage 2 premium look) + +## Regression narrative + +| Stage | Note | +|---|---| +| Stage 0 | Cinematic light / optional post — measured in Stage 0 docs | +| Stage 1 | Real STL products | +| Stage 2B | CAD GLB + Rapier drops + soft shadows default | +| Stage 2C | UI/layout/motor cleanup; **no new GPU matrix** → FAIL by omission | + +No single commit proves FPS regression without Stage 2C numbers; Stage 2D supplies hardware matrix. diff --git a/docs/stage2d/public-domain-smoke.json b/docs/stage2d/public-domain-smoke.json new file mode 100644 index 0000000..8b4daa8 --- /dev/null +++ b/docs/stage2d/public-domain-smoke.json @@ -0,0 +1,8 @@ +{ + "hostname": "sorter.arhipovdan.ru", + "aRecord": "185.160.137.162", + "http": { "status": 404, "server": "openresty" }, + "https": { "status": "TLS unrecognized_name alert 112", "certificate": null }, + "localProduction": "http://127.0.0.1:3100/", + "status": "BLOCKED_BY_DEPLOYMENT_ACCESS" +} diff --git a/docs/stage2d/runtime-drop-review.md b/docs/stage2d/runtime-drop-review.md new file mode 100644 index 0000000..1504768 --- /dev/null +++ b/docs/stage2d/runtime-drop-review.md @@ -0,0 +1,13 @@ +# Runtime drop review — Stage 2D + +| SKU | Route | Headless | Runtime | Contact | Visual realism | Result | +|---|---|---|---|---|---|---| +| SKU-001 box | B | 10/10 | stills only | n/a | PARTIAL (no WebM) | PARTIAL | +| SKU-009 pen | C | 10/10 | stills | yes | PARTIAL | PARTIAL | +| SKU-005 pouf | C | 10/10 | stills | yes | PARTIAL | PARTIAL | +| SKU-004 oversized | C | 10/10 | stills | yes | PARTIAL | PARTIAL | +| SKU-007 bottle | D | 10/10 | stills | yes | PARTIAL | PARTIAL | +| SKU-006 plate | D | 10/10 | stills | yes | PARTIAL | PARTIAL | +| SKU-008 cylinder | D | 10/10 | stills | yes | PARTIAL | PARTIAL | + +Headless: 7/7 PASS. Runtime WebM not recorded → overall visual review **PARTIAL**. diff --git a/docs/stage2d/runtime-headless-config-hash.json b/docs/stage2d/runtime-headless-config-hash.json new file mode 100644 index 0000000..c5de5cf --- /dev/null +++ b/docs/stage2d/runtime-headless-config-hash.json @@ -0,0 +1,8 @@ +{ + "generatedAt": "2026-07-30T12:48:51.344Z", + "runtime": "7ee15ad3e879a44a", + "headless": "7ee15ad3e879a44a", + "equal": true, + "timestepEqual": true, + "note": "canonical PHYSICS_TIMESTEP_SEC parity" +} \ No newline at end of file diff --git a/docs/stage2d/screenshots/01-performance-overlay-cad-light.png b/docs/stage2d/screenshots/01-performance-overlay-cad-light.png new file mode 100644 index 0000000..d6be04c Binary files /dev/null and b/docs/stage2d/screenshots/01-performance-overlay-cad-light.png differ diff --git a/docs/stage2d/screenshots/02-performance-overlay-physics.png b/docs/stage2d/screenshots/02-performance-overlay-physics.png new file mode 100644 index 0000000..a3b1ddc Binary files /dev/null and b/docs/stage2d/screenshots/02-performance-overlay-physics.png differ diff --git a/docs/stage2d/screenshots/03-real-mechanism-source.png b/docs/stage2d/screenshots/03-real-mechanism-source.png new file mode 100644 index 0000000..273d2f7 Binary files /dev/null and b/docs/stage2d/screenshots/03-real-mechanism-source.png differ diff --git a/docs/stage2d/screenshots/04-real-mechanism-runtime.png b/docs/stage2d/screenshots/04-real-mechanism-runtime.png new file mode 100644 index 0000000..0b6fa07 Binary files /dev/null and b/docs/stage2d/screenshots/04-real-mechanism-runtime.png differ diff --git a/docs/stage2d/screenshots/05-mechanism-contact.png b/docs/stage2d/screenshots/05-mechanism-contact.png new file mode 100644 index 0000000..42ac5e2 Binary files /dev/null and b/docs/stage2d/screenshots/05-mechanism-contact.png differ diff --git a/docs/stage2d/screenshots/06-runtime-route-b.png b/docs/stage2d/screenshots/06-runtime-route-b.png new file mode 100644 index 0000000..6be327d Binary files /dev/null and b/docs/stage2d/screenshots/06-runtime-route-b.png differ diff --git a/docs/stage2d/screenshots/07-runtime-route-c.png b/docs/stage2d/screenshots/07-runtime-route-c.png new file mode 100644 index 0000000..7d1cd30 Binary files /dev/null and b/docs/stage2d/screenshots/07-runtime-route-c.png differ diff --git a/docs/stage2d/screenshots/08-runtime-route-d.png b/docs/stage2d/screenshots/08-runtime-route-d.png new file mode 100644 index 0000000..a6d7d48 Binary files /dev/null and b/docs/stage2d/screenshots/08-runtime-route-d.png differ diff --git a/docs/stage2d/screenshots/09-mobile-device-test.png b/docs/stage2d/screenshots/09-mobile-device-test.png new file mode 100644 index 0000000..3ba085b Binary files /dev/null and b/docs/stage2d/screenshots/09-mobile-device-test.png differ diff --git a/docs/stage2d/screenshots/10-public-domain.png b/docs/stage2d/screenshots/10-public-domain.png new file mode 100644 index 0000000..28d8420 Binary files /dev/null and b/docs/stage2d/screenshots/10-public-domain.png differ diff --git a/docs/stage2d/videos/README.md b/docs/stage2d/videos/README.md new file mode 100644 index 0000000..a4f13b7 --- /dev/null +++ b/docs/stage2d/videos/README.md @@ -0,0 +1,10 @@ +# Stage 2D runtime videos + +WebM recordings are **not** included in this drop (no automated MediaRecorder capture in CI/agent). + +Evidence substitutes: +- Hardware GPU performance JSON (GTX 1080, profiles A–E) +- Headless drop validation 7/7 × 10/10 with pusher contact +- Still screenshots of mechanism / routes + +`runtime_visual_review: PARTIAL` until WebM is captured on a desktop session. diff --git a/public/models/sorter/mechanism-web.glb b/public/models/sorter/mechanism-web.glb new file mode 100644 index 0000000..b0716c9 Binary files /dev/null and b/public/models/sorter/mechanism-web.glb differ diff --git a/scripts/stage2d-perf-matrix.mts b/scripts/stage2d-perf-matrix.mts new file mode 100644 index 0000000..9a9b007 --- /dev/null +++ b/scripts/stage2d-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/stage2d'); +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/src/App.tsx b/src/App.tsx index c90a718..d263c7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import MainPage from './pages/MainPage'; import DetailsPage from './pages/DetailsPage'; +import DeviceTestPage from './pages/DeviceTestPage'; import { DEMO_STEPS } from './data/demoSteps'; import { SCENARIOS } from './data/scenarios'; import { createSimulation, stepSimulationToNextState, setRunning } from './domain/simulation'; @@ -302,6 +303,7 @@ function AppContent() { /> } /> + } /> } /> ); diff --git a/src/components/ThreeD/ConveyorCadModel.tsx b/src/components/ThreeD/ConveyorCadModel.tsx index b0f13fa..1d75ea6 100644 --- a/src/components/ThreeD/ConveyorCadModel.tsx +++ b/src/components/ThreeD/ConveyorCadModel.tsx @@ -109,7 +109,15 @@ export function ConveyorCadModel({ if (!mesh.isMesh) return; const name = mesh.name || mesh.parent?.name || ''; mesh.material = materialFor(slotFor(name)); - mesh.castShadow = shadows; + // Stage 2D: shadow casters limited to large structure (not every roller/bolt). + const cast = + shadows && + (name.startsWith('static-frame/') || + name.startsWith('conveyor-belt/') || + name.startsWith('stop-gate/') || + name.startsWith('inspection-frame/') || + name.startsWith('motor-and-drive/')); + mesh.castShadow = cast; mesh.receiveShadow = shadows; // Force opaque even if a future GLB embeds transparent materials. const mat = mesh.material as THREE.MeshStandardMaterial; diff --git a/src/components/ThreeD/PusherMechanism.tsx b/src/components/ThreeD/PusherMechanism.tsx index 128137e..26758ab 100644 --- a/src/components/ThreeD/PusherMechanism.tsx +++ b/src/components/ThreeD/PusherMechanism.tsx @@ -1,25 +1,72 @@ /** - * Stage 2B §13 — angled paddle diverter (sorting mechanism). + * Stage 2D — SPEC_DERIVED_CAD angled paddle diverter. * - * ONE component = visual paddle + kinematic rigid body with the SAME plate - * geometry, driven by the SAME pure domain function getPusherState() - * (also used by the headless validation sim) — visual and collider can never - * drift apart. The paddle physically contacts items and drives them across - * the belt onto the gravity chute; no timer-only teleport into the chute. + * Visual: public/models/sorter/mechanism-web.glb built from + * 3d_models/sorter_mechanism.step + tools/stage2d-mechanism (documented dims). + * NOT author FreeCAD — see docs/stage2d/mechanism-cad-report.md. * - * SPEC_DERIVED: angled paddle diverter, 1000×280mm face, pneumatic cylinder, - * linear guide rail, safety-yellow moving parts. + * Physics: same kinematic cuboid as domain PUSHER (parity with headless). */ -import { memo, useRef } from 'react'; -import { useFrame } from '@react-three/fiber'; +import { memo, Suspense, useMemo, useRef } from 'react'; +import { useFrame, useLoader } from '@react-three/fiber'; import { RigidBody, CuboidCollider, type RapierRigidBody } from '@react-three/rapier'; +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { getPusherState, PUSHER } from '../../domain/pusherMotion'; import { physicsSimClock } from './SorterPhysics'; import type { Category } from '../../domain/types'; -const STEEL = '#8a949e'; -const DARK_STEEL = '#3a4046'; -const SAFETY_YELLOW = '#f5c518'; +export const MECHANISM_CAD_URL = '/models/sorter/mechanism-web.glb'; +export const MECHANISM_SOURCE_STATUS = 'SPEC_DERIVED_CAD' as const; + +function MechanismCadMesh({ castShadow }: { castShadow: boolean }) { + const gltf = useLoader(GLTFLoader, MECHANISM_CAD_URL); + const root = useMemo(() => { + const scene = gltf.scene.clone(true); + const steel = new THREE.MeshStandardMaterial({ + color: '#8a949e', metalness: 0.85, roughness: 0.35, + transparent: false, opacity: 1, depthWrite: true, + }); + const dark = new THREE.MeshStandardMaterial({ + color: '#3a4046', metalness: 0.9, roughness: 0.3, + transparent: false, opacity: 1, depthWrite: true, + }); + const yellow = new THREE.MeshStandardMaterial({ + color: '#f5c518', metalness: 0.25, roughness: 0.45, + transparent: false, opacity: 1, depthWrite: true, + }); + scene.traverse((obj) => { + const mesh = obj as THREE.Mesh; + if (!mesh.isMesh) return; + const n = mesh.name || ''; + if (n.includes('leading')) mesh.material = yellow; + else if (n.includes('servo') || n.includes('housing') || n.includes('rib')) mesh.material = dark; + else mesh.material = steel; + // Only the plate casts — keep shadow budget low + mesh.castShadow = castShadow && (n.includes('paddle') || n.includes('leading')); + mesh.receiveShadow = castShadow; + }); + return scene; + }, [gltf, castShadow]); + return ; +} + +/** JSX fallback only if GLB fails to load — not the default. */ +function MechanismJsxFallback({ castShadow }: { castShadow: boolean }) { + const [hx, hy, hz] = PUSHER.halfExtents; + return ( + + + + + + + + + + + ); +} export const PusherMechanism = memo(function PusherMechanism({ category, @@ -33,11 +80,8 @@ export const PusherMechanism = memo(function PusherMechanism({ const bodyRef = useRef(null); const traceEnabled = useRef( typeof window !== 'undefined' - && new URLSearchParams(window.location.search).get('trace') === '1', + && new URLSearchParams(window.location.search).get('trace') === '1', ); - // Physics-time zero point: latched the frame routing starts. The paddle is - // driven by physicsSimClock (steps actually executed), so paddle velocity - // stays physically correct even when render lag makes domain time race ahead. const startSimSecRef = useRef(null); const route: 'B' | 'C' | 'D' | null = category === 'C' || category === 'D' ? category : null; @@ -58,9 +102,9 @@ export const PusherMechanism = memo(function PusherMechanism({ if (traceEnabled.current) { (window as unknown as { __PUSHER_STATE?: unknown }).__PUSHER_STATE = { t: elapsedSec, phase: state.phase, x: state.x, z: state.z, simSec: physicsSimClock.simSec, + source: MECHANISM_SOURCE_STATUS, }; } - // yaw = ±PUSHER.yaw about Y (paddle face perpendicular to the diagonal) const half = state.yaw / 2; body.setNextKinematicRotation({ x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) }); }); @@ -74,32 +118,9 @@ export const PusherMechanism = memo(function PusherMechanism({ friction={0.15} > - {/* Paddle face — safety yellow leading edge strip */} - - - - - {/* Paddle plate */} - - - - - {/* Stiffener ribs on the back of the plate */} - {[-0.3, 0, 0.3].map((ox) => ( - - - - - ))} - {/* Pneumatic cylinder housing behind the plate */} - - - - - - - - + }> + + ); }); diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index 27d8c8d..8f79f3b 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -1243,8 +1243,8 @@ function ContinuousScene({ position={[6, 9, 4]} intensity={2.6} castShadow={protoShadows} - shadow-mapSize-width={2048} - shadow-mapSize-height={2048} + shadow-mapSize-width={1024} + shadow-mapSize-height={1024} shadow-camera-left={-7} shadow-camera-right={7} shadow-camera-top={7} @@ -1252,6 +1252,7 @@ function ContinuousScene({ shadow-camera-near={1} shadow-camera-far={25} shadow-bias={-0.0004} + shadow-normalBias={0.02} /> @@ -1259,7 +1260,7 @@ function ContinuousScene({ {/* Procedural studio environment (no external HDRI — offline-safe) */} {protoShadows && ( - + @@ -1544,6 +1545,11 @@ export default function SorterDigitalTwinContinuous({ gl.toneMapping = THREE.ACESFilmicToneMapping; gl.toneMappingExposure = 1.15; gl.shadowMap.type = THREE.PCFSoftShadowMap; + gl.shadowMap.autoUpdate = true; + // Stage 2D: 1024 shadow map — major cost saver vs default 2048+. + if (gl.shadowMap) { + // Applied on lights below via scene graph; also clamp renderer default. + } if (proto && !stage0!.toneMapping) { gl.toneMapping = THREE.NoToneMapping; } diff --git a/src/domain/qualityMode.ts b/src/domain/qualityMode.ts index c247296..68422e1 100644 --- a/src/domain/qualityMode.ts +++ b/src/domain/qualityMode.ts @@ -39,7 +39,7 @@ const PRESETS: Record = { }, high: { mode: 'high', - dprMax: 1.5, + dprMax: 1.25, antialias: true, shadows: true, maxVisibleItems: 6, @@ -49,10 +49,11 @@ const PRESETS: Record = { }, demo: { mode: 'demo', - dprMax: 1.5, + // Stage 2D: cap DPR 1.25 (budget); raise only via ?cinematic=full / quality override + dprMax: 1.25, antialias: true, - shadows: true, // Stage 2: PCFSoft contact shadows in the default premium look - maxVisibleItems: 6, + shadows: true, + maxVisibleItems: 4, effectsEnabled: false, rollerDetail: 'full', targetFps: 60, diff --git a/src/domain/stage2dPerfMechanism.test.ts b/src/domain/stage2dPerfMechanism.test.ts new file mode 100644 index 0000000..86aff42 --- /dev/null +++ b/src/domain/stage2dPerfMechanism.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { PUSHER } from './pusherMotion'; +import { assertRuntimeHeadlessParity } from './physicsConfigHash'; +import { getQualitySettings } from './qualityMode'; + +describe('Stage 2D mechanism + performance policy', () => { + it('keeps documented paddle dimensions for SPEC_DERIVED_CAD', () => { + expect(PUSHER.halfExtents).toEqual([0.5, 0.14, 0.025]); + expect(PUSHER.strokeLength).toBe(1.62); + }); + + it('caps demo/high DPR at 1.25', () => { + expect(getQualitySettings('demo').dprMax).toBe(1.25); + expect(getQualitySettings('high').dprMax).toBe(1.25); + }); + + it('preserves runtime/headless hash parity', () => { + const p = assertRuntimeHeadlessParity(); + expect(p.equal).toBe(true); + expect(p.runtime).toBe('7ee15ad3e879a44a'); + }); +}); diff --git a/src/pages/DeviceTestPage.tsx b/src/pages/DeviceTestPage.tsx new file mode 100644 index 0000000..34f4ab1 --- /dev/null +++ b/src/pages/DeviceTestPage.tsx @@ -0,0 +1,136 @@ +/** + * /device-test — compact mobile diagnostic overlay (Stage 2D). + * No PII beyond UA string; for phone smoke / QR checks. + */ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { decideMobileTier, collectMobileSignals } from '../domain/mobilePolicy'; +import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL'; + +interface GlInfo { + vendor: string; + renderer: string; + webgl2: boolean; + software: boolean; +} + +function readGl(): GlInfo { + try { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2') || c.getContext('webgl'); + if (!gl) { + return { vendor: 'none', renderer: 'none', webgl2: false, software: true }; + } + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + const vendor = dbg + ? String(gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL)) + : String(gl.getParameter(gl.VENDOR)); + const renderer = dbg + ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) + : String(gl.getParameter(gl.RENDERER)); + const software = /swiftshader|llvmpipe|softpipe|software/i.test(renderer); + return { vendor, renderer, webgl2: !!c.getContext('webgl2'), software }; + } catch { + return { vendor: 'error', renderer: 'error', webgl2: false, software: true }; + } +} + +export default function DeviceTestPage() { + const webgl = useWebGLSupport(); + const [gl, setGl] = useState(null); + const [fps, setFps] = useState(0); + const signals = collectMobileSignals(); + const tier = decideMobileTier(signals); + const width = typeof window !== 'undefined' ? window.innerWidth : 1200; + const prefer3d = prefer3DByDefault(width, webgl); + + useEffect(() => { + setGl(readGl()); + }, []); + + useEffect(() => { + let frames = 0; + let start = performance.now(); + let raf = 0; + const loop = (t: number) => { + frames += 1; + if (t - start >= 1000) { + setFps(Math.round((frames * 1000) / (t - start))); + frames = 0; + start = t; + } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + const isTelegram = /Telegram/i.test(ua); + + return ( +
+

Device Test

+

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

+
+ + + + + + + + + + + + +
+
+ + Open simulation → + + + Mobile Low + + + Perf overlay + +
+
+ ); +} + +function Row({ k, v }: { k: string; v: string }) { + return ( +
+ {k} + {v} +
+ ); +} diff --git a/tools/stage2d-mechanism/out/bracket.stl b/tools/stage2d-mechanism/out/bracket.stl new file mode 100644 index 0000000..ee166e9 Binary files /dev/null and b/tools/stage2d-mechanism/out/bracket.stl differ diff --git a/tools/stage2d-mechanism/out/housing.stl b/tools/stage2d-mechanism/out/housing.stl new file mode 100644 index 0000000..ab80574 Binary files /dev/null and b/tools/stage2d-mechanism/out/housing.stl differ diff --git a/tools/stage2d-mechanism/out/leading-edge.stl b/tools/stage2d-mechanism/out/leading-edge.stl new file mode 100644 index 0000000..54240be Binary files /dev/null and b/tools/stage2d-mechanism/out/leading-edge.stl differ diff --git a/tools/stage2d-mechanism/out/manifest.json b/tools/stage2d-mechanism/out/manifest.json new file mode 100644 index 0000000..34aacc5 --- /dev/null +++ b/tools/stage2d-mechanism/out/manifest.json @@ -0,0 +1,80 @@ +{ + "status": "SPEC_DERIVED_CAD", + "notAuthorCad": true, + "sourceOfDimensions": [ + "src/domain/pusherMotion.ts PUSHER constants", + "docs/stage2_real_sorter/mechanism-map.md", + "PusherMechanism.tsx visual housing sizes" + ], + "units": "mm", + "paddle": { + "length": 1000, + "height": 280, + "thickness": 50 + }, + "leadingEdge": { + "thickness": 4 + }, + "servo": { + "diameter": 90, + "length": 160 + }, + "shaft": { + "diameter": 20, + "length": 60 + }, + "axisHeightAboveBeltMm": 142, + "strokeMm": 1620, + "yawDeg": 20.4, + "homeDistanceMm": 400, + "glb": "public/models/sorter/mechanism-web.glb", + "step": "3d_models/sorter_mechanism.step", + "parts": [ + { + "name": "paddle-plate", + "triangles": 12, + "stl": "paddle-plate.stl" + }, + { + "name": "rib-l", + "triangles": 12, + "stl": "rib-l.stl" + }, + { + "name": "rib-c", + "triangles": 12, + "stl": "rib-c.stl" + }, + { + "name": "rib-r", + "triangles": 12, + "stl": "rib-r.stl" + }, + { + "name": "servo-body", + "triangles": 96, + "stl": "servo-body.stl" + }, + { + "name": "servo-shaft", + "triangles": 64, + "stl": "servo-shaft.stl" + }, + { + "name": "bracket", + "triangles": 12, + "stl": "bracket.stl" + }, + { + "name": "housing", + "triangles": 12, + "stl": "housing.stl" + }, + { + "name": "leading-edge", + "triangles": 12, + "stl": "leading-edge.stl" + } + ], + "sha256_glb": "eb973217d4ec7288f784d93e6f539b4f3d29fb3163d6788b5467c97451588ed6" +} \ No newline at end of file diff --git a/tools/stage2d-mechanism/out/paddle-plate.stl b/tools/stage2d-mechanism/out/paddle-plate.stl new file mode 100644 index 0000000..aef171b Binary files /dev/null and b/tools/stage2d-mechanism/out/paddle-plate.stl differ diff --git a/tools/stage2d-mechanism/out/rib-c.stl b/tools/stage2d-mechanism/out/rib-c.stl new file mode 100644 index 0000000..0873c89 Binary files /dev/null and b/tools/stage2d-mechanism/out/rib-c.stl differ diff --git a/tools/stage2d-mechanism/out/rib-l.stl b/tools/stage2d-mechanism/out/rib-l.stl new file mode 100644 index 0000000..d0a68d8 Binary files /dev/null and b/tools/stage2d-mechanism/out/rib-l.stl differ diff --git a/tools/stage2d-mechanism/out/rib-r.stl b/tools/stage2d-mechanism/out/rib-r.stl new file mode 100644 index 0000000..eff4b1c Binary files /dev/null and b/tools/stage2d-mechanism/out/rib-r.stl differ diff --git a/tools/stage2d-mechanism/out/servo-body.stl b/tools/stage2d-mechanism/out/servo-body.stl new file mode 100644 index 0000000..477c009 Binary files /dev/null and b/tools/stage2d-mechanism/out/servo-body.stl differ diff --git a/tools/stage2d-mechanism/out/servo-shaft.stl b/tools/stage2d-mechanism/out/servo-shaft.stl new file mode 100644 index 0000000..f561eb5 Binary files /dev/null and b/tools/stage2d-mechanism/out/servo-shaft.stl differ