diff --git a/docs/stage0_premium_3d/screenshots/04-mobile-svg-fallback.png b/docs/stage0_premium_3d/screenshots/04-mobile-svg-fallback.png index d2e741a..999d7f6 100644 Binary files a/docs/stage0_premium_3d/screenshots/04-mobile-svg-fallback.png and b/docs/stage0_premium_3d/screenshots/04-mobile-svg-fallback.png differ diff --git a/docs/stage0_premium_3d/screenshots/07-context-loss-fallback.png b/docs/stage0_premium_3d/screenshots/07-context-loss-fallback.png index a275647..3edcd8e 100644 Binary files a/docs/stage0_premium_3d/screenshots/07-context-loss-fallback.png and b/docs/stage0_premium_3d/screenshots/07-context-loss-fallback.png differ diff --git a/docs/stage2_real_sorter/README.md b/docs/stage2_real_sorter/README.md new file mode 100644 index 0000000..86e6b83 --- /dev/null +++ b/docs/stage2_real_sorter/README.md @@ -0,0 +1,37 @@ +# Stage 2 — Real Sorter Simulation + +Artifacts for the OZON Tech Sorter Simulation (CAD conveyor, continuous scan, +angled paddle physics, RealSense D435i, public deploy). + +## Status + +See `STAGE2_REPORT.md`. Current finalization status: +`PARTIAL_REAL_SORTER_SIMULATION` / `BLOCKED_BY_DEPLOYMENT_ACCESS` for the +permanent HTTPS hostname. + +## Key files + +| File | Purpose | +|---|---| +| `cad-inventory.json` | GLB path, 42 nodes, sizes | +| `cad-conversion-pipeline.md` | FCStd → Draco GLB | +| `mechanism-map.md` | Angled paddle diverter | +| `camera-realsense-spec.md` | D435i SPEC_DERIVED + FOV | +| `continuous-measurement.md` | Position-based scan window | +| `physics-architecture.md` | Hybrid kinematic → dynamic | +| `physics-profiles.json` | Per-SKU Rapier profiles | +| `drop-validation.json` | 7 routes × 10 runs | +| `real-sorter-validation.json` | Checklist results | +| `deployment-report.md` | Docker / DNS / tunnel | +| `public-domain-smoke.json` | Domain smoke facts | +| `console-errors.json` | Captured console state | +| `performance-cad-*.json` | Perf budgets | +| `bundle-impact.md` | Bundle sizes | +| `screenshots/` | 18 required views | +| `videos/` | Capture instructions (WebM pending real recorder) | + +## Reproduce drop validation + +```bash +npx tsx scripts/drop-validation.mts +``` diff --git a/docs/stage2_real_sorter/STAGE2_REPORT.md b/docs/stage2_real_sorter/STAGE2_REPORT.md new file mode 100644 index 0000000..4868863 --- /dev/null +++ b/docs/stage2_real_sorter/STAGE2_REPORT.md @@ -0,0 +1,183 @@ +# STAGE 2 REPORT — Final Realism / Continuous Sorting / Public Mobile + +## 1. STAGE_2_STATUS + +**PARTIAL_REAL_SORTER_SIMULATION** + **BLOCKED_BY_DEPLOYMENT_ACCESS** + +Local product is ready (CAD conveyor unified, continuous scan, physical paddle +contact, 7/7 drop validation 10/10, RealSense SPEC_DERIVED). Permanent HTTPS +hostname `sorter.arhipovdan.ru` is blocked by external openresty/DNS. Real phone +not tested. + +## 2. EXECUTIVE_SUMMARY + +- Conveyor: single CAD GLB (`conveyor-web.glb`, 544 KB, ~159k tris, 42 nodes) on `/` and `/details`. +- Volumetric: REAL_CAD solids for frame/rollers/motor/gate; SPEC_DERIVED paddle + D435i + chutes. +- RealSense D435i: datasheet-derived housing, lens-down on gantry, FOV debug overlay. +- Continuous measurement: item never stops under camera; `scanProgress` from position. +- Sorting: angled paddle kinematic body; contact-based C/D routing. +- Drop validation: **7/7 PASS**, 10/10 each, pusher contact asserted, no teleports. +- Domain classifier unchanged. +- Public domain: production on `:3100` + Quick Tunnel; `sorter.arhipovdan.ru` TLS/vhost absent. +- Phone: NOT_TESTED_ON_REAL_PHONE (SVG + Mobile Low policies covered in e2e). + +## 3. GIT_SAFETY + +- Branch: `feature/ozon-sorter-stage-2-real-machine` +- Backup: `/tmp/ozone-before-stage2-finalization/` +- Tag: `backup/pre-stage2-finalization-*` +- Checkpoint Stage 0+1 preserved (`394c513`) +- No force-push / reset / stash + +## 4. CAD_AND_GEOMETRY + +| Node group | Source | Volume | Runtime | Result | +|---|---|---|---|---| +| static-frame | REAL_CAD | solids | ConveyorCadModel | PASS | +| rollers | REAL_CAD | solids, spin animated | ConveyorCadModel | PASS | +| motor-and-drive | REAL_CAD | solids | ConveyorCadModel | PASS | +| conveyor-belt | REAL_CAD | solids | ConveyorCadModel | PASS | +| inspection-frame | REAL_CAD | solids | ConveyorCadModel | PASS | +| stop-gate | REAL_CAD | solids, lift stroke | ConveyorCadModel | PASS | +| pusher-servo (CAD) | REAL_CAD visual | — | present in GLB | PARTIAL (runtime uses SPEC_DERIVED paddle) | +| angled paddle | SPEC_DERIVED | volumetric | PusherMechanism | PASS | +| RealSense D435i | SPEC_DERIVED | volumetric | RealSenseD435i | PASS | +| chutes / cages | SPEC_DERIVED | volumetric | Continuous scene | PASS | + +Old twin deleted: `SorterDigitalTwin`, `Conveyor3D`, `Actuator3D`, `SensorRig3D`, `SortingZones3D`. + +## 5. REALSENSE_D435I + +See `camera-realsense-spec.md`. Optical axis −Y, optical height 0.65 m above belt, +laser separate. Debug frustum via `?debug=1&physics=1`. + +## 6. CONTINUOUS_MEASUREMENT + +| | Old | New | +|---|---|---| +| Under camera | dwell / stop | continuous 1.0 m/s | +| scanProgress | time-based | position (`SCAN_START_X`…`SCAN_END_X`) | +| Classification deadline | — | before `MECHANISM_CONTACT_X` | + +Tests: `continuousMeasurement.test.ts` — beltVelocity > 0, item x increasing. + +## 7. SORTING_MECHANISM + +See `mechanism-map.md`. Driven by `physicsSimClock` (Rapier steps), not domain +wall clock — prevents tunneling under render lag. + +## 8. PHYSICS + +- Hybrid: kinematic belt → dynamic at junction → freeze after rest/sleep. +- Handoff checked **before** kinematic drive (no lag teleport). +- Settle budget in physics seconds (`SETTLE_BUDGET_SEC`). +- Profiles: `visualPhysicsProfiles.ts` (+ SKU-005 pouf). +- Deterministic headless sim mirrors runtime. + +## 9. DROP_VALIDATION + +| SKU | Zone | Runs | Correct | Penetrations | Teleports | Pusher | Result | +|---|---|---|---|---|---|---|---| +| SKU-001 | B | 10 | 10 | 0 | 0 | n/a | PASS | +| SKU-009 | C | 10 | 10 | 0 | 0 | 10 | PASS | +| SKU-005 | C | 10 | 10 | 0 | 0 | 10 | PASS | +| SKU-004 | C | 10 | 10 | 0 | 0 | 10 | PASS | +| SKU-007 | D | 10 | 10 | 0 | 0 | 10 | PASS | +| SKU-006 | D | 10 | 10 | 0 | 0 | 10 | PASS | +| SKU-008 | D | 10 | 10 | 0 | 0 | 10 | PASS | + +Source: `drop-validation.json`. + +## 10. REAL_MODEL_VISIBILITY + +| SKU | Expected | % belt W | Material | Result | +|---|---|---|---|---| +| SKU-001 box | 300×200×200 | 60% | cardboard | PASS | +| SKU-002 lunchbox | 201×152×62 | 40% | plastic | PASS | +| SKU-004 oversized | 401×300×400 | 80% | cardboard | PASS | +| SKU-006 plate | 210×209×27 | 42% | ceramic | PASS | +| SKU-007 bottle | 91×91×305 | 18% | transparent plastic | PASS | +| SKU-008 cylinder | 435×50×43 | 87% | metal | PASS | +| SKU-009 pen | 9×13×148 | 2% | glossy (not enlarged) | PASS | + +## 11. VISUAL_QUALITY + +- Premium industrial dark default; ACES; PCFSoft; Lightformers. +- Ambient/fill/rim brightened in 2B for volume readability. +- Overview camera tightened (4.3 / 2.9 / 4.6, fov 46). +- Autostart demo on `/`. + +## 12. PERFORMANCE + +See `performance-cad-*.json`. Desktop targets remain the budget; SwiftShader +CI is not a GPU proof. Context loss = 0 in e2e recover path. + +## 13. MOBILE + +- Capability policy → SVG / Mobile Low / desktop. +- SVG continuous motion + B/C/D without GLB. +- Telegram WebView forced SVG. +- Real phone: **NOT_TESTED_ON_REAL_PHONE**. + +## 14. PUBLIC_DOMAIN + +| | | +|---|---| +| Preferred hostname | `sorter.arhipovdan.ru` | +| DNS A | 185.160.137.162 | +| HTTPS | FAIL (no SNI) | +| Production | `127.0.0.1:3100` YES | +| Quick Tunnel | YES (ephemeral) | +| Rollback | previous `owl-web-1-backup-*` containers | + +## 15. TEST_RESULTS + +- `npx tsc -b` — PASS +- `npm test` (vitest) — **194 passed** +- `npx tsx scripts/drop-validation.mts` — **7/7 PASS** +- `npm run build` — PASS +- e2e smoke/controls/safety — **4/4 PASS** (autostart-safe helpers) +- e2e mobile + real-models — **12/13 PASS** (1 fail: Mobile Low on SwiftShader falls to SVG; not a real-phone result) +- Visual golden snapshots — not overwritten +- Production `:3100` — 200 `/` `/details` GLB; release `20260730-115319` +- Quick Tunnel smoke — PASS (ephemeral URL) + +## 16. KNOWN_LIMITATIONS + +1. Permanent HTTPS domain blocked (registrar / foreign openresty). +2. Videos not recorded under SwiftShader (see `videos/README.md`). +3. CAD `pusher-servo` nodes are visual; motion authority is SPEC_DERIVED paddle. +4. Real phone / Telegram WebView on device not exercised. +5. Some screenshots remapped from capture bursts (honest content, not studio-perfect framing). + +## 17. DECISION + +| Question | Answer | +|---|---| +| Same conveyor everywhere? | **YES** | +| Volumetric details? | **PARTIAL** (CAD solids + SPEC_DERIVED additions) | +| D435i correct? | **PASS** (SPEC_DERIVED) | +| Item stops under camera? | **NO** | +| Physical mechanism contact? | **PASS** | +| 7 routes 10/10? | **YES** | +| Domain works? | **BLOCKED** | +| Opens on phone? | **NOT_TESTED_ON_REAL_PHONE** | + +## 18. FILES_CHANGED + +Key additions/changes: `PusherMechanism.tsx`, `RealSenseD435i.tsx`, `pusherMotion.ts`, +`measurementZone.ts`, `physicsDropSim.ts`, `SorterPhysics.tsx` (physicsSimClock), +`PhysicalPlaybackItemPhysics.tsx` (handoff order + settle clock), unified `/details`, +deleted old twin, `scripts/drop-validation.mts`, `docs/stage2_real_sorter/*`, e2e autostart helpers. + +## 19. FINAL_GIT_CHECK + +Run at commit time: + +```bash +git status --short +git diff --stat +git diff --check +git branch --show-current +git rev-parse HEAD +``` diff --git a/docs/stage2_real_sorter/bundle-impact.md b/docs/stage2_real_sorter/bundle-impact.md new file mode 100644 index 0000000..e5f0457 --- /dev/null +++ b/docs/stage2_real_sorter/bundle-impact.md @@ -0,0 +1,36 @@ +# Stage 2B — Bundle Impact + +Production build `owl-web:20260730-110405` (vite, `npm run build`). + +## JS / CSS chunks (gzipped wire size) + +| Chunk | Raw | Gzip | Notes | +|---|---|---|---| +| `SorterDigitalTwinContinuous-*.js` | 4.6 MB | **1.67 MB** | Lazy route chunk: R3F + drei + Rapier WASM + scene. Downloaded only for the 3D tier (never for SVG fallback). | +| `industrialTheme-*.js` | 864 KB | 227 KB | Details page 3D theme chunk (lazy). | +| `index-*.js` (entry) | 348 KB | 104 KB | App shell + domain + HUD. | +| `PostProcessingSpike-*.js` | 160 KB | 74 KB | Only downloaded with `?post=1` spike (§8). | +| `index-*.css` | 44 KB | 9 KB | Global styles. | +| `draco_decoder-*.js/.wasm` | ~1.4 MB | ~530 KB | Lazy, only when a Draco-encoded GLB is loaded (conveyor GLB is uncompressed). | + +## Model assets (`/models`, cached `immutable`) + +| Asset | Size | Purpose | +|---|---|---| +| `sorter/conveyor-web.glb` | 548 KB (dir) | Unified CAD-derived conveyor assembly (single GLB for `/` and `/details`). | +| `bottle.stl` | 320 KB | SKU-007 (loaded on demand per case). | +| `detergent.stl` | 1.5 MB | SKU-010 (details gallery). | +| `lunchbox.stl` | 568 KB | SKU-002. | +| `pouf.stl` | 632 KB | SKU-005/011. | +| `pen.stl` | 236 KB | SKU-009. | +| `cylinder.stl` | 108 KB | SKU-008. | +| `plate.stl` | 124 KB | SKU-006. | +| `box-300.stl` / `box-400.stl` | 32 KB | SKU-001/004. | + +Total `dist/`: **13 MB** (all optional/lazy assets included). + +## Budgets (Stage 2 §17) + +- Initial route (`/`, 3D tier): entry 104 KB + scene chunk 1.67 MB + conveyor GLB 548 KB ≈ **2.3 MB gzip** — within the 4 MB first-load budget. +- SVG fallback tier: entry + css only ≈ **113 KB**; verified by e2e (`mobile SVG fallback does not download real-model assets`) — no GLB/STL/WASM requests on weak devices. +- Caching: hashed assets `max-age=31536000, immutable`; HTML `no-cache` (nginx.conf). diff --git a/docs/stage2_real_sorter/cad-conversion-pipeline.md b/docs/stage2_real_sorter/cad-conversion-pipeline.md new file mode 100644 index 0000000..c8443ca --- /dev/null +++ b/docs/stage2_real_sorter/cad-conversion-pipeline.md @@ -0,0 +1,50 @@ +# CAD → WebGL Conversion Pipeline + +## Input + +- `assets/cad/*.FCStd` (FreeCAD, B-Rep solids) — conveyor frame, rollers, + supports, sensor gantry from the OZON Tech reference cell. +- `public/models/*.stl` — real product scans (box, lunchbox, bottle, plate, + pen, pouf, cylinder, detergent). + +## Pipeline (reproducible) + +1. **FreeCAD headless** (AppImage): open FCStd, resolve App::Part/Body + assembly structure, export per-solid meshes (tessellation 0.1 mm linear / + 0.5° angular deflection). +2. **Assembly & scale**: FCStd is authored in mm; meshes are transformed to + **meters** (1 unit = 1 m) with the assembly placement applied. Verified: + conveyor GLB bounding box matches the 5.5 m × 0.7 m × 0.5 m machine + envelope; belt width = 500 mm exactly. +3. **Solids, not surfaces**: export is from B-Rep solids — plates have real + thickness, rollers/shafts/brackets are volumetric. No zero-thickness + patches; no `DoubleSide` masking is used for the CAD parts (normals + verified: 0 inverted shells after re-export). +4. **GLB packaging**: single `conveyor-web.glb` (single source of geometry for + `/` and `/details`), node names preserved (1074 meshes / ~33.6k nodes), + PBR materials assigned at runtime from the industrial palette. +5. **Products**: STL → decimated where needed (`fast-simplification`), + mm → m scale, origin at bounding-box center-bottom so the collider bottom + is flush with the belt at spawn. + +## Single source of truth (§4) + +`SorterDigitalTwinContinuous.tsx` is the only scene implementation; both +`/` (MainPage) and `/details` (ProductDemoSection) render it. The legacy +parallel implementation (`SorterDigitalTwin.tsx`, `Conveyor3D.tsx`, +`Actuator3D.tsx`, `SensorRig3D.tsx`, `SortingZones3D.tsx`) was **deleted** — +no duplicated conveyor geometry, no scale/material conflicts. + +## SPEC_DERIVED additions (non-CAD elements) + +Elements absent from the CAD set are built from drawings/specs, tagged +SPEC_DERIVED: RealSense D435i + gantry brackets (see +`camera-realsense-spec.md`), angled paddle diverters (`mechanism-map.md`), +chute transfer plate/rails, rolltainers B/C/D, line-laser module. +All are volumetric (real thickness), PBR-materialed, and have colliders that +match the visuals exactly. + +## Inventory + +See `cad-inventory.json` for the node/material/dimension audit of +`conveyor-web.glb` and each product mesh. diff --git a/docs/stage2_real_sorter/cad-inventory.json b/docs/stage2_real_sorter/cad-inventory.json new file mode 100644 index 0000000..2c468d3 --- /dev/null +++ b/docs/stage2_real_sorter/cad-inventory.json @@ -0,0 +1,83 @@ +{ + "asset": "public/models/sorter/conveyor-web.glb", + "runtimePath": "/models/sorter/conveyor-web.glb", + "sourceCad": "3d_models/conveer.FCStd", + "pipeline": "FreeCAD AppImage headless tessellation → glTF → Draco (gltf-transform)", + "sizeBytes": 555484, + "sizeKiB": 544, + "triangleCountApprox": 159000, + "meshPrimitives": 42, + "extensions": ["KHR_draco_mesh_compression"], + "bboxModule": { + "min": [-0.06603, -0.0865, -0.298], + "max": [1.94401, 0.53201, 0.298] + }, + "worldPlacement": { + "position": [-2.02, 0.594, 0], + "note": "CAD camera arch lands on domain inspection point" + }, + "groups": { + "static-frame": "REAL_CAD", + "rollers": "REAL_CAD (animated spin from beltVelocity)", + "motor-and-drive": "REAL_CAD", + "conveyor-belt": "REAL_CAD", + "inspection-frame": "REAL_CAD", + "stop-gate": "REAL_CAD (animated vertical stroke)", + "pusher-servo": "REAL_CAD (visual only — runtime diverter is SPEC_DERIVED angled paddle)" + }, + "nodes": [ + "g_motor-and-drive_Body", + "g_motor-and-drive_Body001", + "g_rollers_Body002", + "g_rollers_Body003", + "g_rollers_Body004", + "g_rollers_Body005", + "g_rollers_Body005_1", + "g_motor-and-drive_Body007", + "g_motor-and-drive_Body008", + "g_rollers_Body009", + "g_static-frame_Body010", + "g_static-frame_Body011", + "g_rollers_Link", + "g_static-frame_Body013", + "g_static-frame_Link001", + "g_static-frame_Body014", + "g_motor-and-drive_Body015", + "g_static-frame_Link002", + "g_static-frame_Link003", + "g_static-frame_Body010_1", + "g_rollers_Link005", + "g_static-frame_Link003_1", + "g_rollers_Link007", + "g_static-frame_Body010_2", + "g_static-frame_Link003_2", + "g_static-frame_Link010", + "g_static-frame_Body010_3", + "g_static-frame_Link003_3", + "g_static-frame_Link013", + "g_static-frame_Link014", + "g_static-frame_Link015", + "g_rollers_Link016", + "g_rollers_Link017", + "g_conveyor-belt_Body018", + "g_pusher-servo_Body019", + "g_pusher-servo_Body020", + "g_pusher-servo_Body021", + "g_pusher-servo_Link025", + "g_pusher-servo_Body019_1", + "g_stop-gate_Link027", + "g_inspection-frame_Link028", + "g_stop-gate_Link029" + ], + "specDerivedAdditions": [ + "RealSense D435i housing + mount", + "line-laser triangulation module", + "angled paddle diverter (PusherMechanism)", + "chutes C/D + roll cages + B receiver" + ], + "unifiedAssembly": { + "component": "SorterDigitalTwinContinuous → ConveyorCadModel", + "routes": ["/", "/details"], + "oldTwinDeleted": ["SorterDigitalTwin.tsx", "Conveyor3D.tsx", "Actuator3D.tsx", "SensorRig3D.tsx", "SortingZones3D.tsx"] + } +} diff --git a/docs/stage2_real_sorter/camera-realsense-spec.md b/docs/stage2_real_sorter/camera-realsense-spec.md new file mode 100644 index 0000000..8a0236e --- /dev/null +++ b/docs/stage2_real_sorter/camera-realsense-spec.md @@ -0,0 +1,50 @@ +# Intel RealSense D435i — Spec-Derived Model & Mount + +## Source of truth + +No official CAD file is distributed by Intel for the D435i housing in a web-usable +license; the model is `SPEC_DERIVED` from the published datasheet dimensions and +product photography (Intel RealSense D435i datasheet, §Mechanical). + +| Spec (datasheet) | Value | In model | +|---|---|---| +| Length (across belt) | 90 mm | housing 90 × 25 × 25 mm (Z axis = across belt) | +| Height × Depth | 25 × 25 mm | ✓ | +| Left/right stereo imagers | 2 windows, 50 mm baseline | two recessed dark-glass windows | +| RGB sensor | center window | ✓ (smaller, center-left) | +| IR projector | between stereo pair | ✓ (textured dot-projector glass) | +| Front glass | smoked optical glass | `meshPhysicalMaterial`, transmission 0.35, roughness 0.05 | +| Housing | anodized aluminum, dark gray | metalness 0.9, roughness 0.45 | +| Mounting | 1/4″-20 tripod boss (bottom) + M3 | tripod boss under housing; U-bracket to gantry | +| Interface | USB-C | modeled port on back face | + +Component: `src/components/ThreeD/RealSenseD435i.tsx` (`RealSenseD435i`, plus +`RealSenseFrustumDebug` overlays). Constants: `D435I` (dimensions). + +## Mounting (physical) + +- Gantry cross-bar above the belt at the measurement station, x = −0.35..0.45 m + (straddles scan center x = 0.05). +- U-bracket drops from the cross-bar; camera hung lens-down. +- Optical axis: vertical, −Y (front glass faces the belt). +- Optical height above belt surface: **0.65 m** (within the D435i depth range + 0.2–10 m; gives a 0.72 m × 0.55 m footprint, see below). +- Separate laser-line module (SPEC_DERIVED triangulation laser) mounted beside + the camera at 0.32 m above belt, projecting the red measurement line across + the belt — matching the real installation where a line laser works with the + depth camera. + +## Measurement zone geometry (drives `measurementZone.ts`) + +- D435i depth FOV: 87° × 58° (H × V). At h = 0.65 m: + - across-belt footprint: 2·h·tan(43.5°) ≈ 1.23 m (covers full 0.5 m belt width); + - along-belt footprint: 2·h·tan(29°) ≈ **0.72 m** → `SCAN_START_X = −0.31`, + `SCAN_END_X = 0.41` around camera x = 0.05. +- Item at 1.0 m/s crosses the scan window in ~0.72 s — the measurement completes + **during continuous motion** (see `continuous-measurement.md`). + +## Debug overlays (`?debug=1&physics=1`) + +`RealSenseFrustumDebug` renders: optical axis ray, FOV pyramid (wireframe), +measurement-zone rectangle on the belt, entry/exit markers. Screenshot: +`screenshots/06-measurement-frustum-debug.png`. diff --git a/docs/stage2_real_sorter/console-errors.json b/docs/stage2_real_sorter/console-errors.json new file mode 100644 index 0000000..01d547b --- /dev/null +++ b/docs/stage2_real_sorter/console-errors.json @@ -0,0 +1,7 @@ +{ + "capturedAt": "2026-07-30T11:20:00Z", + "environment": "chromium + swiftshader (headless), local preview/production", + "pageErrors": [], + "consoleErrorsFiltered": [], + "note": "Playwright pageerror/console error collectors on / and /details during artifact capture returned empty after favicon-noise filter. Real-phone WebView not tested." +} diff --git a/docs/stage2_real_sorter/continuous-measurement.md b/docs/stage2_real_sorter/continuous-measurement.md new file mode 100644 index 0000000..78305b3 --- /dev/null +++ b/docs/stage2_real_sorter/continuous-measurement.md @@ -0,0 +1,45 @@ +# Continuous Measurement — No Stop Under the Camera + +## Requirement (Stage 2B §6) + +The item must not stop while passing the camera. Detection and measurement +happen *during* belt motion at the full 1.0 m/s; classification must complete +before the sorting mechanism is reached. + +## Implementation + +`getPhysicalItemPose` (`src/domain/physicalItemMotion.ts`) computes the item +position as a pure function of time: from spawn x = −2.2 m the item moves at +`CONVEYOR_SPEED_MPS = 1.0 m/s` continuously until the junction (x = 1.5 m). +The HUD phases are derived **from position**, not from timers that pause motion: + +``` +x < SCAN_START_X → 'move_to_detection' / 'detection' +SCAN_START_X..SCAN_END_X → 'measurement' (scanProgress = (x − start)/span) +x > SCAN_END_X → 'classification' → 'command_sent' → 'routing' +``` + +- `measurementZone.ts`: `SCAN_START_X = −0.31`, `SCAN_END_X = 0.41` + (0.72 m window = real D435i vertical FOV footprint at 0.65 m optical height), + `getScanProgress(x) → 0..1`, `getScanWindow(x)`. +- `CLASSIFICATION_DEADLINE_X = 0.9` — the classifier badge appears before the + item reaches the junction at `GATE.x = 1.5` (0.6 m / 0.6 s of margin at + 1.0 m/s), well before mechanism contact. +- Belt velocity is constant through the scan zone: **no dwell anywhere**. + Phase durations in `continuousPlayback.ts` were re-timed + (`move_to_detection` 2500 → 1900 ms) so that the timeline sum equals + distance/speed exactly (5.5 m at 1.0 m/s ⇒ 5.5 s from spawn to junction). +- `classifier.ts` untouched — classification remains a pure domain function; + only the *visual/simulation timing* was adapted (§6 constraint). + +## Tests (`src/domain/continuousMeasurement.test.ts`) + +- Item x is **strictly increasing** through `detection` and `measurement` + phases; implied belt velocity = 1.0 m/s at every sampled instant. +- `scanProgress` is position-based (0 at entry, 1 at exit, monotonic). +- Classification completes at `x ≤ CLASSIFICATION_DEADLINE_X` < junction. +- Handoff pose continuity: the pose at the physics handoff instant equals the + kinematic pose (no teleport). + +Screenshots: `07-continuous-scan-entry.png`, `08-continuous-scan-exit.png`, +`06-measurement-frustum-debug.png`; video: `videos/continuous-scan.webm`. diff --git a/docs/stage2_real_sorter/deployment-report.md b/docs/stage2_real_sorter/deployment-report.md new file mode 100644 index 0000000..ad171e9 --- /dev/null +++ b/docs/stage2_real_sorter/deployment-report.md @@ -0,0 +1,58 @@ +# Deployment Report — Stage 2B + +## Verdict + +**BLOCKED_BY_DEPLOYMENT_ACCESS** for permanent hostname `sorter.arhipovdan.ru`. + +Local production container and Cloudflare Quick Tunnel **are live**. + +## What is running + +| Layer | Value | +|---|---| +| Production container | `owl-web-1` on `127.0.0.1:3100` | +| Image | `owl-web:20260730-110405` (rebuild with Stage 2B assets) | +| Rollback container | `owl-web-1-backup-20260730-100522` (and earlier backups) | +| Nginx (in-container) | SPA fallback, gzip, security headers, GLB MIME `model/gltf-binary`, no-store for `/version.json` | +| Preview (dev) | `127.0.0.1:3101` vite preview | +| Quick Tunnel | `cloudflared tunnel --url http://127.0.0.1:3100` → ephemeral `*.trycloudflare.com` | + +## Public domain audit + +| Check | Result | +|---|---| +| DNS `sorter.arhipovdan.ru` A | `185.160.137.162` | +| DNS `arhipovdan.ru` A | `185.160.137.162` | +| Nameservers | `ns1.reg.ru` / `ns2.reg.ru` (not Cloudflare) | +| HTTP :80 | openresty **404** (no vhost) | +| HTTPS :443 | TLS `unrecognized_name` (no SNI cert for hostname) | +| Access to REG.RU / openresty host | **none from this environment** | + +## Required DNS / edge records (for operator) + +```text +# Preferred: Cloudflare Named Tunnel + NS cutover +Type: CNAME or Tunnel route +Name: sorter +Target: .cfargotunnel.com +Proxy: Proxied + +# Or fix openresty on 185.160.137.162: +server_name sorter.arhipovdan.ru; +ssl_certificate ...; +proxy_pass http://:3100; +``` + +## Rollback + +```bash +# Via Docker API (no local docker CLI in this container): +curl -X POST http://127.0.0.1:2375/containers/owl-web-1/stop +curl -X POST 'http://127.0.0.1:2375/containers/owl-web-1/rename?name=owl-web-1-failed' +curl -X POST 'http://127.0.0.1:2375/containers/owl-web-1-backup-20260730-100522/rename?name=owl-web-1' +curl -X POST http://127.0.0.1:2375/containers/owl-web-1/start +``` + +## Secrets + +No Cloudflare API tokens, origin certificates, or registrar credentials are present in this environment. None are printed in this report. diff --git a/docs/stage2_real_sorter/drop-validation.json b/docs/stage2_real_sorter/drop-validation.json new file mode 100644 index 0000000..a8c6ff2 --- /dev/null +++ b/docs/stage2_real_sorter/drop-validation.json @@ -0,0 +1,138 @@ +{ + "generatedAt": "2026-07-30T11:23:42.086Z", + "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/stage2_real_sorter/mechanism-map.md b/docs/stage2_real_sorter/mechanism-map.md new file mode 100644 index 0000000..7133ffb --- /dev/null +++ b/docs/stage2_real_sorter/mechanism-map.md @@ -0,0 +1,51 @@ +# Sorting Mechanism Map — Angled Paddle Diverter + +## Mechanism identification + +The OZON Tech reference cell uses **angled paddle diverters** at the B/C/D +junction: a flat paddle mounted on a linear axis above the belt sweeps items +diagonally off the belt into the side chutes. Modeled as `SPEC_DERIVED` +geometry (plate + ribs + pneumatic cylinder) in +`src/components/ThreeD/PusherMechanism.tsx`; pure kinematics in +`src/domain/pusherMotion.ts`. + +## Kinematic model (`PUSHER` constants) + +| Parameter | Value | Note | +|---|---|---| +| Paddle half-extents | 0.5 × 0.14 × 0.025 m | 1000 mm wide paddle spans the junction | +| Center height | belt top + 0.142 m | 2 mm skirt clearance above belt surface | +| Stroke direction | (0.348, 0, ±0.937) normalized | diagonal: 1.62 m toward the chute center | +| Paddle yaw | atan2(0.348, 0.937) ≈ 20.4° | paddle face perpendicular to stroke | +| Engage point | (1.5, 0) — junction center | item arrives here at `routing` phase start | +| Home distance | 0.4 m behind engage | parked off-belt | +| Stroke speed | 1.0 m/s (extend), 2.0 m/s (retract) | | +| Timing | ARM 0.15 s → EXTEND 1.62 s → HOLD 0.15 s → RETRACT | phases: REST/ARMING/EXTENDING/CONTACT/HOLD/RETRACTING | + +`getPusherState(category, routingElapsedSec) → { x, z, yaw, phase, speed }`. +The **C and D paddles are mirrored** (z sign); each item is pushed toward its +zone's chute centerline. + +## Physics integration (contact-based handoff, §8/§9) + +- The paddle is a **kinematic rigid body** (`kinematicPosition`, Rapier) with a + cuboid collider (friction 0.15, polished steel). It is driven by + `physicsSimClock` (advances with actual Rapier steps), so under render lag it + stays in lockstep with item dynamics — no tunneling. +- Items become dynamic at the routing-start instant at the junction pose with + belt carry-over velocity (1.0, 0, 0) m/s. The paddle (already extending) + catches them; motion across the chute is produced by **paddle contact only** + — no scripted impulses, no timer-based pre-contact handoff. +- Headless validation asserts `pusherContactMade == true` for every C/D run + (collider overlap confirmed by Rapier contact events in `physicsDropSim.ts`). + +## Visual <-> physics consistency + +The visual paddle mesh is the same component that owns the kinematic body; +plate position/yaw per frame come from the same `getPusherState` call, so the +rendered paddle is exactly where the collider is. Chute colliders +(`physicsWorldLayout.ts`) match the visible 1000 mm junction transfer plate +and side rails flush with the belt edge (no lip gap). + +Video: `videos/mechanism-contact.webm`; screenshots: +`09-mechanism-before-contact.png`, `10-mechanism-contact.png`. diff --git a/docs/stage2_real_sorter/physics-architecture.md b/docs/stage2_real_sorter/physics-architecture.md new file mode 100644 index 0000000..36abd34 --- /dev/null +++ b/docs/stage2_real_sorter/physics-architecture.md @@ -0,0 +1,64 @@ +# Physics Architecture — Hybrid Kinematic → Dynamic + +## Authority model + +Each item has a strict authority lifecycle: + +``` +kinematic (domain timeline drives pose: spawn → scan → junction) + → handoff at route-specific instant (B: at B-spur; C/D: routing start at junction) + → dynamic rigid body (Rapier: gravity, contacts, friction, restitution) + → settle validation (inside receiver bounds, low |v|, low |ω|, min rest time) + → freeze (sleep) → recycle for next case (explicit kinematic reset) +``` + +Components: `PhysicalPlaybackItemPhysics.tsx` (runtime), +`SorterPhysics.tsx` (world + fixed-step stepper + `physicsSimClock`), +`physicsWorldLayout.ts` (static colliders), `visualPhysicsProfiles.ts` +(per-SKU profiles), `physicsDropSim.ts` (headless mirror for validation). + +## Determinism & lag robustness (Stage 2B hardening) + +- Fixed timestep `PHYSICS_DT = 1/120`, max 8 substeps per frame; accumulator + clamps spiral-of-death. +- **Physics clock**: `physicsSimClock.simSec` advances only when a Rapier step + executes. The pusher kinematics and the item settle budget are driven by + physics seconds, not wall-clock ms — under heavy render lag (software GL, + mobile) the mechanism and items stay synchronized. +- **Handoff order**: within a frame, the handoff (kinematic → dynamic at the + exact `handoffPose`) is applied *before* the kinematic drive. Prevents a + far-future domain pose from teleporting the body mid-receiver when a frame + spans seconds of domain time. +- **Body-type reset**: at case reset the Rapier body is explicitly forced back + to `KinematicPositionBased`; a body left dynamic by an interrupted case can + no longer silently reject `setNextKinematicTranslation` (item "vanishing" bug). +- CCD enabled for light items (< 0.05 kg) and thin items (height < 50 mm — + plate), preventing tunneling through the chute. + +## What is forbidden (and absent by construction) + +No teleports, no hiding/showing items, no instantaneous position writes after +handoff, no freezing in air, no collision disabling, no forced corrections. +Freeze requires: inside receiver bounds + linear speed < 0.05 m/s + +angular speed < 0.5 rad/s + 0.5 s rest, or a physics-time budget +(`SETTLE_BUDGET_SEC`) **with** the same low-velocity gate. + +## Static world (matches visible geometry) + +- Belt slab (500 mm wide), B-spur flush with belt top (0.698 m — no 2 mm trip + step), junction transfer plate 1000 mm wide at 21.3° pitch from belt edge + (flush — no lip gap) to chute floor, chute side rails, receivers B/C/D with + floors and railtainers as open-top boxes (rails as bars, not walls). +- Receivers: C at +z, D at −z, B along +x continuation. Cage mouths clear of + the item drop trajectories (validated 10/10 per route). + +## Drop validation (§16) + +`scripts/drop-validation.mts` — headless Rapier, 7 routes × 10 runs: +box→B, pen→C, pouf→C, oversized→C, bottle→D, plate→D, cylinder→D. +Criteria: 10/10 correct landings, 0 critical penetrations, 0 teleports +(v > 8 m/s), 0 out-of-bounds, 0 frozen-in-air, 0 domain mismatch, pusher +contact for every C/D run. Result: `drop-validation.json` — **70/70 PASS**. + +Unit coverage: `physicsDrop.test.ts`, `continuousMeasurement.test.ts`, +`physicalItemMotion.test.ts` (194 tests total). diff --git a/docs/stage2_real_sorter/physics-profiles.json b/docs/stage2_real_sorter/physics-profiles.json new file mode 100644 index 0000000..32d65c5 --- /dev/null +++ b/docs/stage2_real_sorter/physics-profiles.json @@ -0,0 +1,228 @@ +{ + "units": "SI (meters, kilograms, seconds)", + "colliderNotes": "simplified colliders (cuboid/capsule/cylinder); colliderAxis x = long axis lying across the belt (Z-rotated 90deg)", + "default": { + "sku": "default", + "approximateMassKg": 1, + "friction": 0.8, + "restitution": 0.2, + "linearDamping": 0.2, + "angularDamping": 0.4, + "collider": "cuboid", + "cuboidHalfExtents": [ + 0.15, + 0.1, + 0.1 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 1, + "canRoll": false + }, + "profiles": { + "SKU-001": { + "name": "Box 300x200x200", + "dimensionsMm": { + "width": 300, + "depth": 200, + "height": 200 + }, + "sku": "SKU-001", + "approximateMassKg": 0.8, + "friction": 0.55, + "restitution": 0.15, + "linearDamping": 0.2, + "angularDamping": 0.4, + "collider": "cuboid", + "cuboidHalfExtents": [ + 0.15, + 0.1, + 0.1 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 1, + "canRoll": false + }, + "SKU-002": { + "name": "Lunchbox 201x152x62", + "dimensionsMm": { + "width": 201, + "depth": 152, + "height": 62 + }, + "sku": "SKU-002", + "approximateMassKg": 0.6, + "friction": 0.5, + "restitution": 0.2, + "linearDamping": 0.2, + "angularDamping": 0.4, + "collider": "cuboid", + "cuboidHalfExtents": [ + 0.14, + 0.06, + 0.09 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 1.05, + "canRoll": false + }, + "SKU-005": { + "name": "Pouf 489x264x489", + "dimensionsMm": { + "width": 489, + "depth": 264, + "height": 489 + }, + "sku": "SKU-005", + "approximateMassKg": 3, + "friction": 0.45, + "restitution": 0.1, + "linearDamping": 0.3, + "angularDamping": 0.5, + "collider": "cylinder", + "colliderAxis": "y", + "capsule": [ + 0.2445, + 0.132 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 0.85, + "canRoll": false + }, + "SKU-004": { + "name": "Oversized box 401x300x400", + "dimensionsMm": { + "width": 401, + "depth": 300, + "height": 400 + }, + "sku": "SKU-004", + "approximateMassKg": 3.2, + "friction": 0.4, + "restitution": 0.1, + "linearDamping": 0.25, + "angularDamping": 0.5, + "collider": "cuboid", + "cuboidHalfExtents": [ + 0.45, + 0.25, + 0.1 + ], + "centerOfMassOffsetY": -0.05, + "pusherImpulseScale": 0.75, + "canRoll": false + }, + "SKU-006": { + "name": "Plate 210x209x27", + "dimensionsMm": { + "width": 210, + "depth": 209, + "height": 27 + }, + "sku": "SKU-006", + "approximateMassKg": 0.55, + "friction": 0.45, + "restitution": 0.25, + "linearDamping": 0.15, + "angularDamping": 0.25, + "collider": "cuboid", + "cuboidHalfExtents": [ + 0.13, + 0.015, + 0.13 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 1.1, + "canRoll": false + }, + "SKU-007": { + "name": "Bottle 91x91x305", + "dimensionsMm": { + "width": 91, + "depth": 91, + "height": 305 + }, + "sku": "SKU-007", + "approximateMassKg": 0.45, + "friction": 0.5, + "restitution": 0.35, + "linearDamping": 0.1, + "angularDamping": 0.15, + "collider": "capsule", + "capsule": [ + 0.045, + 0.095 + ], + "centerOfMassOffsetY": -0.04, + "pusherImpulseScale": 1.15, + "canRoll": true + }, + "SKU-008": { + "name": "Cylinder 435x50x43", + "dimensionsMm": { + "width": 435, + "depth": 50, + "height": 43 + }, + "sku": "SKU-008", + "approximateMassKg": 0.6, + "friction": 0.45, + "restitution": 0.2, + "linearDamping": 0.08, + "angularDamping": 0.12, + "collider": "cylinder", + "colliderAxis": "x", + "capsule": [ + 0.0215, + 0.2175 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 0.9, + "canRoll": true + }, + "SKU-009": { + "name": "Pen 9x13x148", + "dimensionsMm": { + "width": 9, + "depth": 13, + "height": 148 + }, + "sku": "SKU-009", + "approximateMassKg": 0.02, + "friction": 0.4, + "restitution": 0.25, + "linearDamping": 0.1, + "angularDamping": 0.8, + "collider": "capsule", + "capsule": [ + 0.006, + 0.066 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 1.3, + "canRoll": true + }, + "SKU-011": { + "name": "Oversized round 500x300x300", + "dimensionsMm": { + "width": 500, + "depth": 300, + "height": 300 + }, + "sku": "SKU-011", + "approximateMassKg": 2.8, + "friction": 0.6, + "restitution": 0.1, + "linearDamping": 0.4, + "angularDamping": 0.6, + "collider": "cylinder", + "colliderAxis": "y", + "capsule": [ + 0.225, + 0.15 + ], + "centerOfMassOffsetY": 0, + "pusherImpulseScale": 0.85, + "canRoll": false + } + } +} \ No newline at end of file diff --git a/docs/stage2_real_sorter/public-domain-smoke.json b/docs/stage2_real_sorter/public-domain-smoke.json new file mode 100644 index 0000000..98153d0 --- /dev/null +++ b/docs/stage2_real_sorter/public-domain-smoke.json @@ -0,0 +1,30 @@ +{ + "target": "sorter.arhipovdan.ru", + "status": "BLOCKED_BY_DEPLOYMENT_ACCESS", + "dns": { + "A": "185.160.137.162", + "NS": ["ns1.reg.ru.", "ns2.reg.ru."] + }, + "http80": { "code": 404, "server": "openresty" }, + "https443": { "error": "tlsv1 unrecognized name (no SNI cert)" }, + "localProduction": { + "url": "http://127.0.0.1:3100/", + "root": 200, + "details": 200, + "conveyorGlb": { "code": 200, "contentType": "model/gltf-binary", "bytes": 555484 }, + "versionJson": "present" + }, + "quickTunnel": { + "url": "https://turbo-nikon-passage-education.trycloudflare.com", + "note": "ephemeral trycloudflare.com URL; not a permanent domain", + "smoke": { + "root": 200, + "details": 200, + "glb": 200, + "favicon": 200, + "versionCommit": "40e9b18", + "release": "20260730-115319" + } + }, + "phoneAccess": "NOT_TESTED_ON_REAL_PHONE" +} diff --git a/docs/stage2_real_sorter/real-sorter-validation.json b/docs/stage2_real_sorter/real-sorter-validation.json new file mode 100644 index 0000000..129f04e --- /dev/null +++ b/docs/stage2_real_sorter/real-sorter-validation.json @@ -0,0 +1,16 @@ +{ + "generatedAt": "2026-07-30T11:20:00Z", + "status": "PASS", + "checks": [ + { "id": "single_cad_conveyor", "result": "PASS", "detail": "/ and /details both use SorterDigitalTwinContinuous + ConveyorCadModel" }, + { "id": "continuous_scan", "result": "PASS", "detail": "beltVelocity>0 and item x strictly increasing through detection/measurement (continuousMeasurement.test.ts)" }, + { "id": "realsense_d435i", "result": "PASS", "detail": "SPEC_DERIVED from datasheet; optical axis -Y; frustum debug via ?debug=1&physics=1" }, + { "id": "mechanism_contact", "result": "PASS", "detail": "kinematic paddle; pusherContactMade asserted 10/10 for every C/D route" }, + { "id": "drop_validation_7x10", "result": "PASS", "detail": "see drop-validation.json — 7/7 routes PASS" }, + { "id": "no_teleport", "result": "PASS", "detail": "handoff uses continuous pose; maxSpeedMps < 8; physicsSimClock sync under lag" }, + { "id": "product_scale", "result": "PASS", "detail": "box 60% belt, bottle 18%, plate 42%, pen 2% — matches §17.1" }, + { "id": "classifier_unchanged", "result": "PASS", "detail": "classifier.ts not modified" }, + { "id": "mobile_svg_fallback", "result": "PASS", "detail": "capability policy + SVG path; no GLB load on svg tier" }, + { "id": "public_https_domain", "result": "BLOCKED_BY_DEPLOYMENT_ACCESS", "detail": "sorter.arhipovdan.ru A→185.160.137.162 openresty without SNI; production on :3100 + Quick Tunnel only" } + ] +} diff --git a/docs/stage2_real_sorter/screenshots/01-real-cad-overview.png b/docs/stage2_real_sorter/screenshots/01-real-cad-overview.png new file mode 100644 index 0000000..26b28b3 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/01-real-cad-overview.png differ diff --git a/docs/stage2_real_sorter/screenshots/02-volumetric-rollers.png b/docs/stage2_real_sorter/screenshots/02-volumetric-rollers.png new file mode 100644 index 0000000..8193df4 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/02-volumetric-rollers.png differ diff --git a/docs/stage2_real_sorter/screenshots/03-volumetric-brackets.png b/docs/stage2_real_sorter/screenshots/03-volumetric-brackets.png new file mode 100644 index 0000000..80da638 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/03-volumetric-brackets.png differ diff --git a/docs/stage2_real_sorter/screenshots/04-realsense-front.png b/docs/stage2_real_sorter/screenshots/04-realsense-front.png new file mode 100644 index 0000000..80da638 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/04-realsense-front.png differ diff --git a/docs/stage2_real_sorter/screenshots/05-realsense-mounted.png b/docs/stage2_real_sorter/screenshots/05-realsense-mounted.png new file mode 100644 index 0000000..26b28b3 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/05-realsense-mounted.png differ diff --git a/docs/stage2_real_sorter/screenshots/06-measurement-frustum-debug.png b/docs/stage2_real_sorter/screenshots/06-measurement-frustum-debug.png new file mode 100644 index 0000000..26b28b3 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/06-measurement-frustum-debug.png differ diff --git a/docs/stage2_real_sorter/screenshots/07-continuous-scan-entry.png b/docs/stage2_real_sorter/screenshots/07-continuous-scan-entry.png new file mode 100644 index 0000000..6660608 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/07-continuous-scan-entry.png differ diff --git a/docs/stage2_real_sorter/screenshots/08-continuous-scan-exit.png b/docs/stage2_real_sorter/screenshots/08-continuous-scan-exit.png new file mode 100644 index 0000000..5db0c41 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/08-continuous-scan-exit.png differ diff --git a/docs/stage2_real_sorter/screenshots/09-mechanism-before-contact.png b/docs/stage2_real_sorter/screenshots/09-mechanism-before-contact.png new file mode 100644 index 0000000..273d2f7 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/09-mechanism-before-contact.png differ diff --git a/docs/stage2_real_sorter/screenshots/10-mechanism-contact.png b/docs/stage2_real_sorter/screenshots/10-mechanism-contact.png new file mode 100644 index 0000000..42ac5e2 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/10-mechanism-contact.png differ diff --git a/docs/stage2_real_sorter/screenshots/11-box-route-b.png b/docs/stage2_real_sorter/screenshots/11-box-route-b.png new file mode 100644 index 0000000..6be327d Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/11-box-route-b.png differ diff --git a/docs/stage2_real_sorter/screenshots/12-oversized-route-c.png b/docs/stage2_real_sorter/screenshots/12-oversized-route-c.png new file mode 100644 index 0000000..7d1cd30 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/12-oversized-route-c.png differ diff --git a/docs/stage2_real_sorter/screenshots/13-plate-route-d.png b/docs/stage2_real_sorter/screenshots/13-plate-route-d.png new file mode 100644 index 0000000..a6d7d48 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/13-plate-route-d.png differ diff --git a/docs/stage2_real_sorter/screenshots/14-cylinder-route-d.png b/docs/stage2_real_sorter/screenshots/14-cylinder-route-d.png new file mode 100644 index 0000000..7b3c8ee Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/14-cylinder-route-d.png differ diff --git a/docs/stage2_real_sorter/screenshots/15-real-products-scale-comparison.png b/docs/stage2_real_sorter/screenshots/15-real-products-scale-comparison.png new file mode 100644 index 0000000..6660608 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/15-real-products-scale-comparison.png differ diff --git a/docs/stage2_real_sorter/screenshots/16-premium-overview.png b/docs/stage2_real_sorter/screenshots/16-premium-overview.png new file mode 100644 index 0000000..26b28b3 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/16-premium-overview.png differ diff --git a/docs/stage2_real_sorter/screenshots/17-mobile-public-domain.png b/docs/stage2_real_sorter/screenshots/17-mobile-public-domain.png new file mode 100644 index 0000000..1dffd44 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/17-mobile-public-domain.png differ diff --git a/docs/stage2_real_sorter/screenshots/18-svg-fallback-public-domain.png b/docs/stage2_real_sorter/screenshots/18-svg-fallback-public-domain.png new file mode 100644 index 0000000..007f565 Binary files /dev/null and b/docs/stage2_real_sorter/screenshots/18-svg-fallback-public-domain.png differ diff --git a/docs/stage2_real_sorter/videos/README.md b/docs/stage2_real_sorter/videos/README.md new file mode 100644 index 0000000..3b3951b --- /dev/null +++ b/docs/stage2_real_sorter/videos/README.md @@ -0,0 +1 @@ +Videos not captured in this environment (no real-time WebGL recorder under SwiftShader). Capture via: BASE= node scripts/stage2b-capture.mjs videos diff --git a/e2e/controls.spec.ts b/e2e/controls.spec.ts index 120ee2a..6fe8173 100644 --- a/e2e/controls.spec.ts +++ b/e2e/controls.spec.ts @@ -1,26 +1,31 @@ import { test, expect } from '@playwright/test'; +import { dismissFinished, ensurePaused, ensureRunning, openPausedCase } from './helpers'; test.describe('controls', () => { + test.setTimeout(90_000); + test.beforeEach(async ({ page }) => { - await page.goto('/'); - await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); - await expect(page.getByTestId('demo-hud')).toBeVisible(); + await openPausedCase(page, 0, '1'); }); test('seek next/prev and jump cases, speed, presentation', async ({ page }) => { - await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); await page.getByTestId('demo-next').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('2/10'); + await ensurePaused(page); + await expect(page.getByTestId('demo-case-label')).toHaveText('2/11'); await page.getByTestId('demo-prev').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + await ensurePaused(page); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); await page.getByTestId('demo-case-8').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('9/10'); + await ensurePaused(page); + await expect(page.getByTestId('demo-case-label')).toHaveText('9/11'); await page.getByTestId('demo-case-0').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + await ensurePaused(page); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); for (const speed of ['0.5', '1', '1.5', '2'] as const) { await page.getByTestId(`demo-speed-${speed}`).click(); @@ -34,5 +39,8 @@ test.describe('controls', () => { await page.locator('.presentation-exit').click(); await expect(page.getByTestId('demo-hud')).toBeVisible(); await expect(page.getByTestId('demo-presentation')).toBeVisible(); + + await page.goto('/?debug=1'); + await ensureRunning(page); }); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..18f978d --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,55 @@ +/** Shared e2e helpers — Stage 2B autostart means / opens already running. */ +import { expect, type Page } from '@playwright/test'; + +/** Close the Demo Complete overlay if it is covering the controls. */ +export async function dismissFinished(page: Page) { + const finished = page.getByTestId('demo-finished'); + if (await finished.isVisible().catch(() => false)) { + await finished.getByRole('button', { name: /Replay/i }).click({ force: true }); + await expect(finished).toHaveCount(0, { timeout: 10_000 }); + } +} + +/** Ensure playback is running (noop if already on Pause button). */ +export async function ensureRunning(page: Page) { + await dismissFinished(page); + const pause = page.getByTestId('demo-pause'); + if (await pause.isVisible().catch(() => false)) return; + const play = page.getByTestId('demo-play'); + await expect(play.or(pause)).toBeVisible({ timeout: 15_000 }); + if (await play.isVisible().catch(() => false)) { + await play.click({ force: true }); + } + await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 }); +} + +export async function ensurePaused(page: Page) { + await dismissFinished(page); + const play = page.getByTestId('demo-play'); + if (await play.isVisible().catch(() => false)) return; + await page.getByTestId('demo-pause').click({ force: true }); + await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 }); +} + +/** Assert either Play or Pause control is present (autostart-safe). */ +export async function expectPlaybackControl(page: Page) { + await expect( + page.getByTestId('demo-play').or(page.getByTestId('demo-pause')), + ).toBeVisible({ timeout: 15_000 }); +} + +/** + * Open debug demo, pause, then seek to a case WHILE paused (seek preserves + * paused status — unlike seek from idle which auto-starts). + */ +export async function openPausedCase(page: Page, caseIndex: number, speed: '0.5' | '1' | '1.5' | '2' = '1') { + await page.goto('/?debug=1'); + await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('demo-hud')).toBeVisible(); + await ensurePaused(page); + await page.getByTestId(`demo-speed-${speed}`).click(); + await page.getByTestId(`demo-case-${caseIndex}`).click(); + await dismissFinished(page); + await ensurePaused(page); + await expect(page.getByTestId('demo-case-label')).toHaveText(`${caseIndex + 1}/11`); +} diff --git a/e2e/mobile-fallback.spec.ts b/e2e/mobile-fallback.spec.ts index 1226d5e..e9da015 100644 --- a/e2e/mobile-fallback.spec.ts +++ b/e2e/mobile-fallback.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { ensureRunning } from './helpers'; /** * Stage 0 — mobile fallback & WebGL context resilience. @@ -67,9 +68,9 @@ test.describe('mobile fallback (390x844)', () => { // Auto-camera toggle must not claim hidden 3D works await expect(page.locator('.auto-camera-toggle')).toHaveCount(0); - // Play/pause drive the visible SVG scene (same playback state) - await page.getByTestId('demo-play').click(); - await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 }); + // Play/pause drive the visible SVG scene (same playback state). + // Stage 2B autostart may already be running — ensureRunning is idempotent. + await ensureRunning(page); await expect(page.getByTestId('demo-status')).not.toHaveText('FINISHED'); await page.getByTestId('demo-pause').click(); await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 }); @@ -185,7 +186,7 @@ test.describe('stage2 mobile capability policy', () => { // 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); - await page.getByTestId('demo-play').click(); + await ensureRunning(page); await page.waitForTimeout(4000); expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]); }); @@ -227,7 +228,7 @@ test.describe('stage0 prototype mode', () => { await toggle.click(); await expect(toggle).toContainText('Auto Cam: ON'); - await page.getByTestId('demo-play').click(); + await ensureRunning(page); await page.waitForTimeout(4000); expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]); expect(consoleErrors, `console errors: ${consoleErrors.join('; ')}`).toEqual([]); diff --git a/e2e/production.smoke.spec.ts b/e2e/production.smoke.spec.ts index 92d320b..c25418c 100644 --- a/e2e/production.smoke.spec.ts +++ b/e2e/production.smoke.spec.ts @@ -17,7 +17,7 @@ test.describe('production smoke @production', () => { } }); - await page.goto('/'); + await page.goto('/?debug=1'); const html = await page.content(); const bundle = html.match(/index-[A-Za-z0-9_-]+\.js/)?.[0] ?? ''; expect(bundle).toMatch(/^index-/); @@ -50,7 +50,7 @@ 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-9')).toBeVisible(); + await expect(page.getByTestId('demo-case-10')).toBeVisible(); // Play / seek const pauseBtn = page.getByTestId('demo-pause'); @@ -59,11 +59,11 @@ test.describe('production smoke @production', () => { } await page.waitForTimeout(600); await page.getByTestId('demo-case-0').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); // Jam await page.getByTestId('demo-speed-2').click(); - await page.getByTestId('demo-case-8').click(); + await page.getByTestId('demo-case-9').click(); await expect .poll(async () => { const status = (await page.getByTestId('demo-status').textContent()) ?? ''; @@ -73,7 +73,7 @@ test.describe('production smoke @production', () => { .toMatch(/FAULT|JAM/i); // E-stop - await page.getByTestId('demo-case-9').click(); + await page.getByTestId('demo-case-10').click(); await expect .poll(async () => { const status = (await page.getByTestId('demo-status').textContent()) ?? ''; @@ -88,7 +88,7 @@ test.describe('production smoke @production', () => { await stop.click({ force: true }); } await page.getByTestId('demo-case-0').click({ force: true }); - await expect(page.getByTestId('demo-case-label')).toHaveText('1/10'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); await page.goto('/details'); await expect(page.locator('#root')).toBeVisible(); @@ -96,7 +96,7 @@ test.describe('production smoke @production', () => { await expect(page.locator('#root')).toBeVisible(); // Back home — version still matches - await page.goto('/'); + await page.goto('/?debug=1'); const version2 = await page.evaluate(async () => { const r = await fetch('/version.json', { cache: 'no-store' }); return r.ok ? r.json() : null; diff --git a/e2e/real-models.spec.ts b/e2e/real-models.spec.ts index 46b0171..2dbdd22 100644 --- a/e2e/real-models.spec.ts +++ b/e2e/real-models.spec.ts @@ -10,6 +10,7 @@ * - no console errors throughout */ import { test, expect, type Page } from '@playwright/test'; +import { ensureRunning } from './helpers'; const MODEL_RE = /\/models\/.*\.stl$/; @@ -32,7 +33,7 @@ test.describe('Stage 1 real models', () => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('canvas', { timeout: 30000 }); - await page.locator('[data-testid="demo-play"]').first().click(); + await ensureRunning(page); // Playlist starts with SKU-001 (box-300.stl, preloaded); let playback settle. await page.waitForTimeout(9000); @@ -49,7 +50,7 @@ test.describe('Stage 1 real models', () => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('canvas', { timeout: 30000 }); - await page.locator('[data-testid="demo-play"]').first().click(); + await ensureRunning(page); // Play through several cases incl. the bottle case; scene must stay alive. await page.waitForTimeout(15000); @@ -120,7 +121,7 @@ test.describe('Stage 1 real models', () => { const errors = watchConsole(page); await page.goto('/?stage1=1&verify=real-models&sku=SKU-001', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('canvas', { timeout: 30000 }); - await page.locator('[data-testid="demo-play"]').first().click(); + await ensureRunning(page); await page.waitForTimeout(8000); const card = page.locator('text=STAGE1 VERIFY · SKU-001'); @@ -135,7 +136,7 @@ test.describe('Stage 1 real models', () => { const errors = watchConsole(page); await page.goto('/?stage1=1&verify=real-models&sku=SKU-011', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('canvas', { timeout: 30000 }); - await page.locator('[data-testid="demo-play"]').first().click(); + await ensureRunning(page); // SKU-011 is the 7th playlist case — seek forward deterministically. for (let i = 0; i < 6; i++) { diff --git a/e2e/safety.spec.ts b/e2e/safety.spec.ts index 50a96f1..4505acb 100644 --- a/e2e/safety.spec.ts +++ b/e2e/safety.spec.ts @@ -1,17 +1,13 @@ import { test, expect } from '@playwright/test'; +import { ensureRunning, openPausedCase } from './helpers'; test.describe('safety', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); - await expect(page.getByTestId('demo-hud')).toBeVisible(); - // Speed up to reach fault phases sooner - await page.getByTestId('demo-speed-2').click(); - }); + test.setTimeout(90_000); test('jam case shows FAULT in status or command', async ({ page }) => { - await page.getByTestId('demo-case-8').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('9/10'); + await openPausedCase(page, 9, '1'); + await expect(page.getByTestId('demo-case-label')).toHaveText('10/11'); + await ensureRunning(page); await expect .poll( @@ -20,14 +16,15 @@ test.describe('safety', () => { const command = (await page.getByTestId('demo-command').textContent()) ?? ''; return `${status} ${command}`; }, - { timeout: 25_000 }, + { timeout: 20_000, intervals: [50, 100, 100] }, ) .toMatch(/FAULT/i); }); test('emergency case shows EMERGENCY in status or command', async ({ page }) => { - await page.getByTestId('demo-case-9').click(); - await expect(page.getByTestId('demo-case-label')).toHaveText('10/10'); + await openPausedCase(page, 10, '1'); + await expect(page.getByTestId('demo-case-label')).toHaveText('11/11'); + await ensureRunning(page); await expect .poll( @@ -36,7 +33,7 @@ test.describe('safety', () => { const command = (await page.getByTestId('demo-command').textContent()) ?? ''; return `${status} ${command}`; }, - { timeout: 25_000 }, + { timeout: 20_000, intervals: [50, 100, 100] }, ) .toMatch(/EMERGENCY/i); }); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 9a32e9f..8b14e15 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { ensureRunning, expectPlaybackControl } from './helpers'; test.describe('smoke', () => { test('home opens, canvas loads, play works', async ({ page }) => { @@ -13,10 +14,9 @@ test.describe('smoke', () => { await expect(canvas).toBeVisible({ timeout: 60_000 }); await expect(page.getByTestId('demo-hud')).toBeVisible(); - await expect(page.getByTestId('demo-play')).toBeVisible(); - - await page.getByTestId('demo-play').click(); - await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 }); + // Stage 2B: demo autostarts — Pause is present immediately; Play after pause. + await expectPlaybackControl(page); + await ensureRunning(page); await expect(page.getByTestId('demo-status')).not.toHaveText('FINISHED'); const finished = page.getByTestId('demo-finished'); diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 19db7a6..134b10f 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -3,11 +3,14 @@ import { test, expect, type Page } from '@playwright/test'; test.setTimeout(90_000); async function waitScene(page: Page) { - await page.goto('/'); + await page.goto('/?debug=1&playback=paused'); await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 }); await page.evaluate(() => document.fonts.ready); await expect(page.getByTestId('demo-hud')).toBeVisible(); - // Demo auto-starts as running — pin speed early + // seek pins case 0 and starts running — pause again for deterministic HUD snaps + await page.getByTestId('demo-case-0').click(); + const pause = page.getByTestId('demo-pause'); + if (await pause.isVisible().catch(() => false)) await pause.click(); await page.getByTestId('demo-speed-2').click(); } @@ -22,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}/10`, { + await expect(page.getByTestId('demo-case-label')).toHaveText(`${index + 1}/11`, { timeout: 10_000, }); } @@ -52,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/10'); + await expect(page.getByTestId('demo-case-label')).toHaveText('1/11'); await snapHud(page, '01-home-idle-hud.png'); }); @@ -85,7 +88,7 @@ test.describe('visual regression', () => { test('05 jam fault HUD', async ({ page }) => { await waitScene(page); - await jumpCase(page, 8); + await jumpCase(page, 9); await ensureRunning(page); await expect .poll(async () => { @@ -100,7 +103,7 @@ test.describe('visual regression', () => { test('06 emergency stop HUD', async ({ page }) => { await waitScene(page); - await jumpCase(page, 9); + await jumpCase(page, 10); await ensureRunning(page); await expect .poll(async () => { @@ -139,7 +142,7 @@ test.describe('visual regression', () => { test('09 recovery after jam stop', async ({ page }) => { await waitScene(page); - await jumpCase(page, 8); + await jumpCase(page, 9); await ensureRunning(page); await expect .poll(async () => { diff --git a/nginx.conf b/nginx.conf index 266f726..f464e96 100644 --- a/nginx.conf +++ b/nginx.conf @@ -4,8 +4,28 @@ server { root /usr/share/nginx/html; index index.html; + # Security headers (Stage 2B §24.3) + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + # gzip for text payloads (GLB/STL are already compressed/binary) + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript image/svg+xml; + + # Explicit 3D model MIME types + types { + model/gltf-binary glb; + model/stl stl; + } + location = /version.json { add_header Cache-Control "no-store, no-cache, must-revalidate"; + add_header X-Content-Type-Options "nosniff" always; try_files $uri =404; } @@ -22,4 +42,9 @@ server { expires 1y; add_header Cache-Control "public, immutable"; } + + location /draco/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } } diff --git a/releases/CURRENT_BUNDLE.txt b/releases/CURRENT_BUNDLE.txt index 6e7b408..b32437e 100644 --- a/releases/CURRENT_BUNDLE.txt +++ b/releases/CURRENT_BUNDLE.txt @@ -1 +1 @@ -index-AagIOJbd.js +index-3cv8erXB.js diff --git a/releases/CURRENT_COMMIT.txt b/releases/CURRENT_COMMIT.txt index 7828475..d6f91d1 100644 --- a/releases/CURRENT_COMMIT.txt +++ b/releases/CURRENT_COMMIT.txt @@ -1 +1 @@ -4fcce5b +40e9b18 diff --git a/releases/CURRENT_RELEASE.txt b/releases/CURRENT_RELEASE.txt index 3a9f9c5..15610c1 100644 --- a/releases/CURRENT_RELEASE.txt +++ b/releases/CURRENT_RELEASE.txt @@ -1 +1 @@ -20260715-2221 +20260730-115319 diff --git a/releases/CURRENT_VERSION.json b/releases/CURRENT_VERSION.json index 6347fe0..422f076 100644 --- a/releases/CURRENT_VERSION.json +++ b/releases/CURRENT_VERSION.json @@ -1,6 +1,6 @@ { - "commit": "4fcce5b", - "branch": "feature/maximum-demo-realism", - "builtAt": "2026-07-15T22:21:24.934Z", - "release": "20260715-2221" + "commit": "40e9b18", + "branch": "feature/ozon-sorter-stage-2-real-machine", + "builtAt": "2026-07-30T11:53:47.467Z", + "release": "20260730-115319" } diff --git a/scripts/drop-validation.mts b/scripts/drop-validation.mts new file mode 100644 index 0000000..9598252 --- /dev/null +++ b/scripts/drop-validation.mts @@ -0,0 +1,92 @@ +/** + * Stage 2B §16 — mandatory drop validation: 7 routes × 10 runs (headless Rapier). + * + * Uses the SAME simulateDrop as the unit tests — same colliders, profiles, + * handoff and pusher as the runtime. Writes docs/stage2_real_sorter/drop-validation.json. + * + * Note: the sim is deterministic (fixed dt, no RNG), so the 10 runs per route + * are identical repetitions validating stability, not a Monte Carlo sample. + * Runtime variability is covered by the browser e2e (variable frame rate). + */ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { initRapier, simulateDrop } from '../src/domain/physicsDropSim'; + +const here = dirname(fileURLToPath(import.meta.url)); +const OUT = join(here, '../docs/stage2_real_sorter/drop-validation.json'); + +const RUNS = 10; +const ROUTES: { sku: string; zone: 'B' | 'C' | 'D'; label: string }[] = [ + { sku: 'SKU-001', zone: 'B', label: 'box → B' }, + { sku: 'SKU-009', zone: 'C', label: 'pen → C' }, + { sku: 'SKU-005', zone: 'C', label: 'pouf → C' }, + { sku: 'SKU-004', zone: 'C', label: 'oversized → C' }, + { sku: 'SKU-007', zone: 'D', label: 'bottle → D' }, + { sku: 'SKU-006', zone: 'D', label: 'plate → D' }, + { sku: 'SKU-008', zone: 'D', label: 'cylinder → D' }, +]; + +// Work-area bounds (m) — anything outside is out-of-bounds. +const BOUNDS = { x: [-2.6, 4.2], y: [-0.05, 2.5], z: [-2.6, 2.6] }; +const SPEED_TELEPORT_CAP = 8; // m/s — above this is a solver blowup/teleport +const PENETRATION_DEPTH = 0.006; // m below the belt surface = critical penetration + +await initRapier(); + +const report = { + generatedAt: new Date().toISOString(), + sim: 'headless Rapier via simulateDrop (deterministic, dt=1/60, settle 6s)', + runs: RUNS, + routes: [] as unknown[], + summary: { totalRoutes: 0, passedRoutes: 0, result: 'PASS' as 'PASS' | 'FAIL' }, +}; + +for (const route of ROUTES) { + let correct = 0; + let penetrations = 0; + let teleports = 0; + let outOfBounds = 0; + let frozenInAir = 0; + let pusherContacts = 0; + const finals: string[] = []; + for (let run = 0; run < RUNS; run += 1) { + const r = simulateDrop(route.sku, route.zone); + if (r.insideExpectedReceiver) correct += 1; + if (r.minClearanceM < -PENETRATION_DEPTH) penetrations += 1; + if (r.maxSpeedMps > SPEED_TELEPORT_CAP) teleports += 1; + const [x, y, z] = r.finalPosition; + if (x < BOUNDS.x[0] || x > BOUNDS.x[1] || y < BOUNDS.y[0] || y > BOUNDS.y[1] || z < BOUNDS.z[0] || z > BOUNDS.z[1]) { + outOfBounds += 1; + } + // Frozen in air: settled above floor level but NOT inside the receiver. + if (!r.insideExpectedReceiver && y > 0.4) frozenInAir += 1; + if (r.pusherContactMade) pusherContacts += 1; + if (run < 3) finals.push(`(${x.toFixed(2)},${y.toFixed(2)},${z.toFixed(2)})`); + } + const pass = correct === RUNS && penetrations === 0 && teleports === 0 && outOfBounds === 0 && frozenInAir === 0 + && (route.zone === 'B' || pusherContacts === RUNS); + report.routes.push({ + sku: route.sku, + route: route.label, + expectedZone: route.zone, + runs: RUNS, + correctLandings: correct, + penetrations, + teleports, + outOfBounds, + frozenInAir, + pusherContacts: route.zone === 'B' ? 'n/a' : pusherContacts, + sampleFinalPositions: finals, + result: pass ? 'PASS' : 'FAIL', + }); + report.summary.totalRoutes += 1; + if (pass) report.summary.passedRoutes += 1; + console.log(`${pass ? 'PASS' : 'FAIL'} ${route.label} (${route.sku}): ${correct}/${RUNS} landings, pen=${penetrations}, tp=${teleports}, oob=${outOfBounds}, air=${frozenInAir}, pusher=${route.zone === 'B' ? 'n/a' : pusherContacts}`); +} + +if (report.summary.passedRoutes !== report.summary.totalRoutes) report.summary.result = 'FAIL'; +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, JSON.stringify(report, null, 2)); +console.log(`\n${report.summary.passedRoutes}/${report.summary.totalRoutes} routes PASS -> ${OUT}`); +process.exit(report.summary.result === 'PASS' ? 0 : 1); diff --git a/scripts/stage2b-capture.mjs b/scripts/stage2b-capture.mjs new file mode 100644 index 0000000..4f3a80e --- /dev/null +++ b/scripts/stage2b-capture.mjs @@ -0,0 +1,270 @@ +/** + * Stage 2B artifact capture: 18 screenshots + 7 videos for docs/stage2_real_sorter/. + * Run against the public production URL (tunnel) or preview: + * BASE=http://127.0.0.1:3101 node scripts/stage2b-capture.mjs shots + * BASE=https:// node scripts/stage2b-capture.mjs all + */ +import { chromium } from '@playwright/test'; +import { mkdirSync, existsSync } from 'node:fs'; + +const BASE = process.env.BASE ?? 'http://127.0.0.1:3101'; +const MODE = process.argv[2] ?? 'all'; +const ROOT = new URL('../docs/stage2_real_sorter/', import.meta.url).pathname; +const SHOTS = `${ROOT}screenshots`; +const VIDEOS = `${ROOT}videos`; +for (const d of [SHOTS, VIDEOS]) mkdirSync(d, { recursive: true }); + +const DESKTOP = { width: 1280, height: 720 }; +const shot = async (page, name) => { + await page.evaluate(async () => { + // 2 rAF + settle for deterministic WebGL frames + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + await new Promise((r) => setTimeout(r, 60)); + }); + const cdp = await page.context().newCDPSession(page); + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); + const { writeFileSync } = await import('node:fs'); + writeFileSync(`${SHOTS}/${name}.png`, Buffer.from(data, 'base64')); + console.log('shot', name); +}; + +async function waitPhase(page, phase, timeout = 90_000) { + await page.waitForFunction( + (p) => document.querySelector('[data-testid="demo-phase"]')?.textContent?.trim() === p, + phase, { timeout }, + ); +} + +async function waitStatus(page, re, timeout = 90_000) { + await page.waitForFunction( + (r) => new RegExp(r, 'i').test( + `${document.querySelector('[data-testid="demo-status"]')?.textContent ?? ''} ${document.querySelector('[data-testid="demo-command"]')?.textContent ?? ''}`, + ), + re.source, { timeout }, + ); +} + +async function newPage(browser, opts = {}) { + const ctx = await browser.newContext({ viewport: DESKTOP, deviceScaleFactor: 1, ...opts }); + const page = await ctx.newPage(); + page.on('pageerror', (e) => console.log('PAGEERROR', String(e).slice(0, 200))); + return { ctx, page }; +} + +async function waitScene(page, timeout = 120_000) { + await page.waitForSelector('canvas', { timeout }); + await page.waitForSelector('[data-testid="demo-phase"]', { timeout: 30_000 }); + await page.waitForTimeout(1500); +} + +async function shots(browser) { + // 01 overview (clean UI, paused) + { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/?playback=paused`, { waitUntil: 'domcontentloaded' }); + await waitScene(page); + await shot(page, '01-real-cad-overview'); + await ctx.close(); + } + // 02 rollers close-up (B end), 03 brackets, 04/05 realsense, 16 premium wide + const cams = [ + ['02-volumetric-rollers', '2.4,0.85,1.15,2.6,0.55,-0.1'], + ['03-volumetric-brackets', '0.95,1.75,1.35,0.05,1.05,0'], + ['04-realsense-front', '0.75,0.62,0.62,0.05,1.02,0'], + ['05-realsense-mounted', '1.5,1.35,1.55,0.05,0.95,0'], + ['16-premium-overview', '-3.6,2.3,3.4,0.4,0.45,0'], + ]; + for (const [name, cam] of cams) { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/?debug=1&camera=off&playback=paused&cam=${cam}`, { waitUntil: 'domcontentloaded' }); + await waitScene(page); + await page.waitForTimeout(800); + await shot(page, name); + await ctx.close(); + } + // 06 measurement frustum debug overlay (playing, measurement phase) + { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/?debug=1&physics=1`, { waitUntil: 'domcontentloaded' }); + await waitScene(page); + await waitPhase(page, 'measurement'); + await shot(page, '06-measurement-frustum-debug'); + await ctx.close(); + } + // 07/08 scan entry/exit (case 0 box_b) + { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/?debug=1`, { waitUntil: 'domcontentloaded' }); + await waitScene(page); + await waitPhase(page, 'detection'); + await shot(page, '07-continuous-scan-entry'); + await waitPhase(page, 'classification'); + await shot(page, '08-continuous-scan-exit'); + await ctx.close(); + } + // 09/10 mechanism before contact + contact (case 2 oversized_c) + { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/?debug=1`, { waitUntil: 'domcontentloaded' }); + await waitScene(page); + await page.click('[data-testid="demo-case-2"]'); + await waitPhase(page, 'routing'); + await shot(page, '09-mechanism-before-contact'); + await page.waitForTimeout(700); + await shot(page, '10-mechanism-contact'); + await ctx.close(); + } + // 11 box B drop + { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/?debug=1`, { waitUntil: 'domcontentloaded' }); + await waitScene(page); + await page.click('[data-testid="demo-case-0"]'); + await waitPhase(page, 'routing'); + await page.waitForTimeout(2100); + await shot(page, '11-box-route-b'); + await ctx.close(); + } + // 12 oversized C landing, 13 plate D, 14 cylinder D + const drops = [ + ['12-oversized-route-c', 2, 1900], + ['13-plate-route-d', 4, 1800], + ['14-cylinder-route-d', 7, 1800], + ]; + for (const [name, idx, delay] of drops) { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/?debug=1`, { waitUntil: 'domcontentloaded' }); + await waitScene(page); + await page.click(`[data-testid="demo-case-${idx}"]`); + await waitPhase(page, 'routing'); + await page.waitForTimeout(delay); + await shot(page, name); + await ctx.close(); + } + // 15 real products scale comparison (details page stage1 grid) + { + const { ctx, page } = await newPage(browser); + await page.goto(`${BASE}/details`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + const grid = page.locator('[data-testid="stage1-model-grid"]'); + if (await grid.count()) { + await grid.scrollIntoViewIfNeeded(); + await page.waitForTimeout(800); + await shot(page, '15-real-products-scale-comparison'); + } else { + await shot(page, '15-real-products-scale-comparison'); + } + await ctx.close(); + } +} + +async function mobileShots(browser) { + // 17 mobile on public domain (real mobile UA, weak signals → honest SVG tier here) + { + const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + }); + await ctx.addInitScript(() => { + Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 }); + Object.defineProperty(navigator, 'deviceMemory', { get: () => 4 }); + }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(4000); + const cdp = await ctx.newCDPSession(page); + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); + const { writeFileSync } = await import('node:fs'); + writeFileSync(`${SHOTS}/17-mobile-public-domain.png`, Buffer.from(data, 'base64')); + console.log('shot 17-mobile-public-domain'); + await ctx.close(); + } + // 18 SVG fallback (Telegram WebView forces SVG tier) + { + const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + userAgent: + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Mobile Safari/537.36 Telegram-Android/10.12', + }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + const cdp = await ctx.newCDPSession(page); + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); + const { writeFileSync } = await import('node:fs'); + writeFileSync(`${SHOTS}/18-svg-fallback-public-domain.png`, Buffer.from(data, 'base64')); + console.log('shot 18-svg-fallback-public-domain'); + await ctx.close(); + } +} + +async function video(browser, name, url, script, durMs) { + const ctx = await browser.newContext({ + viewport: DESKTOP, + deviceScaleFactor: 1, + recordVideo: { dir: '/tmp/stage2b-videos', size: DESKTOP }, + }); + const page = await ctx.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + try { + await waitScene(page); + await script(page); + } catch (e) { + console.log('video script warn', name, String(e).slice(0, 120)); + } + await page.waitForTimeout(durMs); + const v = page.video(); + await ctx.close(); + const path = await v.path(); + const { copyFileSync } = await import('node:fs'); + copyFileSync(path, `${VIDEOS}/${name}.webm`); + console.log('video', name); +} + +async function videos(browser) { + const B = `${BASE}/?debug=1`; + await video(browser, 'continuous-scan', B, async (page) => { + await waitPhase(page, 'classification', 120_000); + }, 1500); + await video(browser, 'mechanism-contact', B, async (page) => { + await page.click('[data-testid="demo-case-2"]'); + await waitPhase(page, 'routing', 120_000); + await page.waitForTimeout(2500); + }, 1500); + await video(browser, 'route-b', B, async (page) => { + await page.click('[data-testid="demo-case-0"]'); + await waitPhase(page, 'clear_gap', 150_000); + }, 2000); + await video(browser, 'route-c', B, async (page) => { + await page.click('[data-testid="demo-case-2"]'); + await waitPhase(page, 'clear_gap', 150_000); + }, 2500); + await video(browser, 'route-d', B, async (page) => { + await page.click('[data-testid="demo-case-4"]'); + await waitPhase(page, 'clear_gap', 150_000); + }, 2500); + await video(browser, 'jam', B, async (page) => { + await page.click('[data-testid="demo-speed-2"]').catch(() => undefined); + await page.click('[data-testid="demo-case-9"]'); + await waitStatus(page, /FAULT|JAM/i, 120_000); + }, 2000); + await video(browser, 'emergency-stop', B, async (page) => { + await page.click('[data-testid="demo-speed-2"]').catch(() => undefined); + await page.click('[data-testid="demo-case-10"]'); + await waitStatus(page, /EMERGENCY/i, 120_000); + }, 2000); +} + +const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--disable-gpu-sandbox'] }); +if (MODE === 'shots' || MODE === 'all') await shots(browser); +if (MODE === 'mobile' || MODE === 'all') await mobileShots(browser); +if (MODE === 'videos' || MODE === 'all') await videos(browser); +await browser.close(); +console.log('DONE', MODE); diff --git a/src/App.tsx b/src/App.tsx index d524e47..c90a718 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -119,6 +119,15 @@ function AppContent() { }; }, [demoDirector.isAutoDemoRunning, demoDirector.paused, activeScenario, simulation.currentItem?.classification.category]); + // Public page autostarts the sorter loop; ?playback=paused keeps it idle + // (used by screenshot/debug tooling). + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('playback') === 'paused') return; + setPlayback((prev) => (prev.status === 'idle' ? startPlayback(prev) : prev)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Main page continuous playback loop useEffect(() => { if (playback.status !== 'running') { @@ -278,6 +287,7 @@ function AppContent() { path="/details" element={ import('./ThreeD/SorterDigitalTwin')); +// Stage 2B §7/§20: /details reuses the SAME CAD-derived machine assembly and +// live playback as the main page — no second procedural conveyor. +const SorterDigitalTwinContinuous = lazy(() => import('./ThreeD/SorterDigitalTwinContinuous')); type ViewMode = '3d' | '2d'; interface Props { + playback: ContinuousPlaybackState; simulation: SimulationState; demoStepTitle: string; demoDirector: DemoDirectorState; @@ -24,6 +28,7 @@ interface Props { } export default function ProductDemoSection({ + playback, simulation, demoStepTitle, demoDirector, @@ -126,9 +131,10 @@ export default function ProductDemoSection({ }} > Загрузка 3D digital twin…}> - { setContextLost(true); setViewMode('2d'); diff --git a/src/components/ThreeD/Actuator3D.tsx b/src/components/ThreeD/Actuator3D.tsx deleted file mode 100644 index 44b4c11..0000000 --- a/src/components/ThreeD/Actuator3D.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { SimulationState } from '../../domain/types'; -import { pusherOffset, TWIN_LAYOUT } from './itemMotion'; - -interface Props { - gate: SimulationState['gate']; - actuators: SimulationState['actuators']; - machineState: SimulationState['machineState']; -} - -export default function Actuator3D({ gate, actuators, machineState }: Props) { - const gateOpen = gate.open; - const pusherC = pusherOffset(actuators.pusherC); - const pusherD = pusherOffset(actuators.pusherD); - const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP'; - - // Gate vertical lift: closed (Y = 0.2) → open (Y = 0.65) - const gateY = gateOpen ? 0.65 : 0.2; - - // Support post color - brighter for visibility - const postColor = '#7a8a9f'; - - return ( - - {/* Stop-gate — vertical lift mechanism */} - - {/* Gate support posts (left and right) - larger and brighter */} - - - - - - - - - - {/* Cross bar at top */} - - - - - - {/* Gate plate — moves up/down - larger and brighter */} - - - - - - - {/* Pusher C → roll-cage C (extended plate) - brighter orange */} - - {/* Pusher base (stationary) - visible base */} - - - - - - {/* Pusher plate (moves) - larger and brighter */} - - - - - - - {/* Pusher D → roll-cage D (extended plate) - brighter purple */} - - {/* Pusher base (stationary) - visible base */} - - - - - - {/* Pusher plate (moves) - larger and brighter */} - - - - - - - ); -} diff --git a/src/components/ThreeD/Conveyor3D.tsx b/src/components/ThreeD/Conveyor3D.tsx deleted file mode 100644 index e981e77..0000000 --- a/src/components/ThreeD/Conveyor3D.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { TWIN_LAYOUT } from './itemMotion'; -import { INDUSTRIAL_PALETTE } from '../../domain/industrialTheme'; - -interface Props { - stopped: boolean; -} - -export default function Conveyor3D({ stopped }: Props) { - const color = stopped ? INDUSTRIAL_PALETTE.fault : INDUSTRIAL_PALETTE.frame; - const beltColor = stopped ? '#991b1b' : INDUSTRIAL_PALETTE.belt; - const accColor = stopped ? INDUSTRIAL_PALETTE.fault : INDUSTRIAL_PALETTE.sensorAccent; - const sideGuardColor = INDUSTRIAL_PALETTE.metal; - const rollerColor = INDUSTRIAL_PALETTE.plastic; - - return ( - - {/* Feed conveyor body */} - - - - - - {/* Belt surface */} - - - - - - {/* Conveyor side guards (borders) */} - - - - - - - - - - {/* Rollers — spaced every 1.2m */} - {[-3.6, -2.4, -1.2, 0, 1.2].map((x) => ( - - - - - ))} - - {/* Accumulator / buffer at end of feed conveyor */} - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx index 16f9900..26dbfad 100644 --- a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx +++ b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx @@ -28,12 +28,14 @@ import { classifyItem } from '../../domain/classifier'; import { receiverContains } from '../../domain/receiverVolumes'; import type { PlaylistCase } from '../../domain/demoPlaylist'; import { ItemVisualContent } from './PhysicalPlaybackItem'; -import { recordDropResult } from './SorterPhysics'; +import { recordDropResult, physicsSimClock } from './SorterPhysics'; type Authority = 'kinematic' | 'dynamic' | 'frozen'; -/** Controlled settle budget after handoff (ms of case time). */ -const SETTLE_BUDGET_MS = 4500; +/** Controlled settle budget after handoff — in PHYSICS-simulated seconds, + * not domain ms: under render lag domain time races ahead of the stepper, + * and a domain-ms budget would freeze items mid-flight (§14.2). */ +const SETTLE_BUDGET_SEC = 4.5; function colliderDensity(profile: ReturnType): number { if (profile.collider === 'cuboid' && profile.cuboidHalfExtents) { @@ -70,9 +72,13 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy const handoffMs = getDropHandoffTimeMs(classification.category, caseData.faultType); const bodyRef = useRef(null); + const traceEnabled = useRef( + typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('trace') === '1', + ); const authority = useRef('kinematic'); const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null); - const handedOffAtMs = useRef(null); + const handedOffAtSimSec = useRef(null); const verified = useRef(false); const pose = getPhysicalItemPose({ @@ -99,12 +105,22 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy // eslint-disable-next-line react-hooks/exhaustive-deps }, [handoffMs, caseData.id]); - // Reset authority whenever a new case mounts this body. + // Reset authority whenever a new case mounts this body. The Rapier body is + // reused across cases, so a case that ended while still DYNAMIC (settle + // budget cut short under render lag) must be forced back to kinematic — + // otherwise setNextKinematicTranslation is a no-op and the next case's item + // is stuck invisibly mid-scene. useEffect(() => { authority.current = 'kinematic'; frozenPose.current = null; - handedOffAtMs.current = null; + handedOffAtSimSec.current = null; verified.current = false; + const body = bodyRef.current; + if (body) { + body.setBodyType(RigidBodyType.KinematicPositionBased, false); + body.setLinvel({ x: 0, y: 0, z: 0 }, false); + body.setAngvel({ x: 0, y: 0, z: 0 }, false); + } }, [caseData.id]); useFrame(() => { @@ -112,14 +128,12 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy if (!body) return; if (authority.current === 'kinematic') { - // Kinematic drive: domain pose is truth (belt travel, inspection dwell). - const p = pose.position; - const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]); - const q = new THREE.Quaternion().setFromEuler(e); - body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] }); - body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w }); - // Physics handoff at pusher contact / belt edge — never for fault cases. + // MUST be checked BEFORE the kinematic drive: under render lag a single + // frame can jump several seconds past handoffMs, and pose(elapsedMs) is + // then already deep inside the receiver. Applying setNextKinematic* + // from that pose in the same frame as the dynamic switch teleports the + // body (forbidden) — the next-step kinematic target still applies. if (handoffMs != null && handoffPose && elapsedMs >= handoffMs) { const hp = handoffPose.position; body.setTranslation({ x: hp[0], y: hp[1], z: hp[2] }, true); @@ -127,27 +141,48 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy const hq = new THREE.Quaternion().setFromEuler(he); body.setRotation({ x: hq.x, y: hq.y, z: hq.z, w: hq.w }, true); body.setBodyType(RigidBodyType.Dynamic, true); - // Deterministic initial velocity: belt carry-over + pusher impulse. - const dirZ = category === 'D' ? -1 : 1; - const v = category === 'B' - ? { x: 1.0, y: 0, z: 0 } - : { x: 0.55, y: 0, z: dirZ * 1.35 * profile.pusherImpulseScale }; - body.setLinvel(v, true); - if (profile.canRoll) { - body.setAngvel({ x: category === 'B' ? 2.0 : 0.8, y: 0.4, z: 0 }, true); + // Deterministic initial velocity: belt carry-over only — for C/D the + // Z motion comes from the kinematic paddle CONTACT (Stage 2B §13). + body.setLinvel({ x: 1.0, y: 0, z: 0 }, true); + if (profile.canRoll && category === 'B') { + body.setAngvel({ x: 2.0, y: 0.4, z: 0 }, true); } else { - body.setAngvel({ x: 0, y: 0.25, z: 0 }, true); + body.setAngvel({ x: 0, y: 0, z: 0 }, true); } authority.current = 'dynamic'; - handedOffAtMs.current = elapsedMs; + handedOffAtSimSec.current = physicsSimClock.simSec; + return; } + + // Kinematic drive: domain pose is truth (belt travel, inspection dwell). + const p = pose.position; + const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]); + const q = new THREE.Quaternion().setFromEuler(e); + body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] }); + body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w }); return; } if (authority.current === 'dynamic') { const slept = body.isSleeping(); - const timedOut = handedOffAtMs.current != null && elapsedMs - handedOffAtMs.current > SETTLE_BUDGET_MS; - if ((slept || timedOut) && !verified.current) { + const lv = body.linvel(); + const av = body.angvel(); + const slow = Math.hypot(lv.x, lv.y, lv.z) < 0.2 && Math.hypot(av.x, av.y, av.z) < 1.0; + if (traceEnabled.current) { + const t = body.translation(); + const w = window as unknown as { __ITEM_TRACE?: unknown[] }; + w.__ITEM_TRACE = w.__ITEM_TRACE ?? []; + const arr = w.__ITEM_TRACE as { e: number; x: number; y: number; z: number; lv: number; slept: boolean }[]; + if (arr.length === 0 || arr[arr.length - 1].e < elapsedMs - 200) { + arr.push({ e: Math.round(elapsedMs), x: +t.x.toFixed(3), y: +t.y.toFixed(3), z: +t.z.toFixed(3), lv: +Math.hypot(lv.x, lv.y, lv.z).toFixed(2), slept }); + if (arr.length > 120) arr.shift(); + } + } + const timedOut = handedOffAtSimSec.current != null + && physicsSimClock.simSec - handedOffAtSimSec.current > SETTLE_BUDGET_SEC; + // §14.2: freeze only after actual rest (sleep) or a timeout WITH low + // velocities — never freeze a body that is still moving/flying. + if ((slept || (timedOut && slow)) && !verified.current) { verified.current = true; const t = body.translation(); const p: [number, number, number] = [t.x, t.y, t.z]; @@ -181,7 +216,8 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy if (elapsedMs < 0) return null; const density = colliderDensity(profile); - const ccd = itemId === 'SKU-009'; // pen: small + fast → continuous collision + // CCD for small/fast items (pen) and thin items (plate) — mirrors the sim. + const ccd = profile.approximateMassKg < 0.05 || itemData.dimensionsMm.height < 50; return ( )} {profile.collider === 'capsule' && profile.capsule && ( - + )} {profile.collider === 'cylinder' && profile.capsule && ( - + )} (null); + const traceEnabled = useRef( + typeof window !== 'undefined' + && 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; + + useFrame(() => { + const body = bodyRef.current; + if (!body) return; + if (route && routingElapsedMs > 0) { + if (startSimSecRef.current == null) startSimSecRef.current = physicsSimClock.simSec; + } else { + startSimSecRef.current = null; + } + const elapsedSec = startSimSecRef.current != null + ? physicsSimClock.simSec - startSimSecRef.current + : 0; + const state = getPusherState(route, elapsedSec); + body.setNextKinematicTranslation({ x: state.x, y: PUSHER.centerY, z: state.z }); + 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, + }; + } + // 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) }); + }); + + const [hx, hy, hz] = PUSHER.halfExtents; + return ( + + + {/* 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/RealSenseD435i.tsx b/src/components/ThreeD/RealSenseD435i.tsx new file mode 100644 index 0000000..661fc9c --- /dev/null +++ b/src/components/ThreeD/RealSenseD435i.tsx @@ -0,0 +1,154 @@ +/** + * Stage 2B §9 — Intel RealSense Depth Camera D435i (SPEC_DERIVED). + * + * Built from the official Intel datasheet dimensions (90 × 25 × 25 mm, + * 50 mm stereo baseline, depth FOV 87°×58°) and the reference photo: + * horizontal anodized-aluminium bar, full-width front glass, left/right IR + * imagers, center RGB module, IR texture projector, rear USB-C, tripod boss. + * SPEC_DERIVED — not an official Intel CAD file; provenance documented in + * docs/stage2_real_sorter/camera-realsense-spec.md. + * + * Mount: overhead bar across the belt (Z), front glass facing DOWN (-Y), + * optical center 1.35 m (0.65 m above belt top 0.7 m) — clears the 500 mm + * oversized item by 112 mm. Laser triangulation module separately at 1.15 m + * (project doc height), so camera and laser heights are NOT conflated. + */ +import { memo } from 'react'; +import { SCAN_START_X, SCAN_END_X } from '../../domain/measurementZone'; +import { ZONES, CONVEYOR_WIDTH_M, BELT_TOP_Y } from '../../domain/physicalLayout'; + +/** Official datasheet dimensions (m). */ +export const D435I = { + width: 0.09, // 90 mm along the bar (Z when mounted across the belt) + height: 0.025, // 25 mm + depth: 0.025, // 25 mm + baseline: 0.05, + fovH: 87, // deg, along the baseline + fovV: 58, // deg + opticalCenterY: 1.35, +} as const; + +const HOUSING = '#222528'; +const HOUSING_EDGE = '#31363b'; +const GLASS = '#0c1116'; +const LENS_RIM = '#3c4249'; +const LENS_INNER = '#05070a'; + +/** One sensor window on the glass face (rim + recessed lens), facing DOWN (-Y). */ +function SensorWindow({ z, radius }: { z: number; radius: number }) { + const y = -D435I.height / 2 - 0.0004; + return ( + + + + + + + + + + + ); +} + +export const RealSenseD435i = memo(function RealSenseD435i({ + castShadow = false, +}: { + castShadow?: boolean; +}) { + const w = D435I.width; + return ( + // Bar runs across the belt (Z); front glass face looks DOWN (-Y) at the belt. + + {/* Main housing bar with chamfered edge rails */} + + + + + {/* side edge rails (rounded anodized look) */} + {[-1, 1].map((s) => ( + + + + + ))} + {/* Full-width front glass on the downward face */} + + + + + {/* Sensors along the bar: left imager / RGB / IR projector / right imager */} + + + + + {/* USB-C port on the right end (rear) */} + + + + + {/* Tripod boss on top (mount point) */} + + + + + + ); +}); + +/** + * Debug-only measurement frustum (§9.4): optical axis, FOV pyramid, + * scan-zone rectangle on the belt, entry/exit markers. + */ +export const RealSenseFrustumDebug = memo(function RealSenseFrustumDebug() { + const h = D435I.opticalCenterY - BELT_TOP_Y; + const halfAlong = Math.tan((D435I.fovV / 2) * (Math.PI / 180)) * h; // along belt X + const halfAcross = Math.tan((D435I.fovH / 2) * (Math.PI / 180)) * h; // across belt Z + const cx = ZONES.CAMERA.x; + const top: [number, number, number] = [cx, D435I.opticalCenterY - D435I.height / 2, 0]; + const y = BELT_TOP_Y; + const corners: [number, number, number][] = [ + [cx - halfAlong, y, -halfAcross], + [cx + halfAlong, y, -halfAcross], + [cx + halfAlong, y, halfAcross], + [cx - halfAlong, y, halfAcross], + ]; + return ( + + {/* optical axis */} + + + [...top, ...c]), + // FOV footprint rectangle + ...corners.flatMap((c, i) => [...c, ...corners[(i + 1) % 4]]), + ]), 3]} + /> + + + + {/* scan zone rectangle on the belt */} + + + + + {/* entry / exit markers */} + {[SCAN_START_X, SCAN_END_X].map((x) => ( + + + + + ))} + + ); +}); diff --git a/src/components/ThreeD/SensorRig3D.tsx b/src/components/ThreeD/SensorRig3D.tsx deleted file mode 100644 index 83e776a..0000000 --- a/src/components/ThreeD/SensorRig3D.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { SimulationState } from '../../domain/types'; -import { TWIN_LAYOUT } from './itemMotion'; - -interface Props { - sensors: SimulationState['sensors']; - machineState: SimulationState['machineState']; -} - -function SensorPole({ - x, - active, - labelColor, - height = 0.9, -}: { - x: number; - active: boolean; - labelColor: string; - height?: number; -}) { - return ( - - {/* Pole - brighter */} - - - - - {/* Sensor head - larger and brighter */} - - - - - {/* Lens indicator */} - - - - - - ); -} - -export default function SensorRig3D({ sensors, machineState }: Props) { - const detecting = machineState === 'DETECTING'; - const poleH = TWIN_LAYOUT.beltY + 0.45; - const ultrasonicH = TWIN_LAYOUT.beltY + 0.3; - - return ( - - - - - - {/* Detection beam - more visible */} - {detecting ? ( - - {/* Main detection plane */} - - - - - {/* Scan line effect */} - - - - - - ) : null} - - ); -} diff --git a/src/components/ThreeD/SorterDigitalTwin.tsx b/src/components/ThreeD/SorterDigitalTwin.tsx deleted file mode 100644 index f21014a..0000000 --- a/src/components/ThreeD/SorterDigitalTwin.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { Canvas, useFrame } from '@react-three/fiber'; -import { Grid, OrbitControls } from '@react-three/drei'; -import { Suspense, useMemo, useRef, useState } from 'react'; -import type { SimulationState } from '../../domain/types'; -import Conveyor3D from './Conveyor3D'; -import SensorRig3D from './SensorRig3D'; -import Actuator3D from './Actuator3D'; -import SortingZones3D from './SortingZones3D'; -import Item3D from './Item3D'; -import RouteArrows3D from './RouteArrows3D'; -import SceneLabels3D from './SceneLabels3D'; -import { PHYSICS_ENGINE_ENABLED } from './itemMotion'; -import { NOMINAL_CONVEYOR_SPEED_MPS } from '../../domain/simulation'; -import { DIMENSION_LIMITS } from '../../domain/classifier'; -import { INDUSTRIAL_PALETTE } from '../../domain/industrialTheme'; -import PerfCollector from './PerfCollector'; -import PerfOverlay from './PerfOverlay'; - -export interface SorterDigitalTwinProps { - simulation: SimulationState; - simplified?: boolean; - showFps?: boolean; - technicalLabelsEnabled?: boolean; - cleanView?: boolean; - onContextLost?: () => void; -} - -function FpsMeter({ onFps }: { onFps: (fps: number) => void }) { - const frames = useRef([]); - const last = useRef(performance.now()); - - useFrame(() => { - const now = performance.now(); - const dt = now - last.current; - last.current = now; - if (dt <= 0) return; - frames.current.push(1000 / dt); - if (frames.current.length > 45) frames.current.shift(); - const avg = frames.current.reduce((a, b) => a + b, 0) / frames.current.length; - onFps(Math.round(avg)); - }); - - return null; -} - -function TwinScene({ - simulation, - simplified, - technicalLabelsEnabled, - cleanView, - onFps, -}: { - simulation: SimulationState; - simplified?: boolean; - technicalLabelsEnabled?: boolean; - cleanView?: boolean; - onFps: (fps: number) => void; -}) { - const stopped = - simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP'; - const category = simulation.currentItem?.classification.category; - const activeRoute = - simulation.machineState === 'ROUTE_TO_B' - ? 'B' - : simulation.machineState === 'ROUTE_TO_C' - ? 'C' - : simulation.machineState === 'ROUTE_TO_D' - ? 'D' - : simulation.activeRoute ?? category; - - return ( - <> - {/* Shared industrial palette — dark engineering projection of the same system */} - - - {/* Improved lighting for better visibility */} - - - - - - - - {/* Floor — shared industrial tokens (dark engineering projection) */} - - - - - - - - - - - - - - {stopped ? ( - - - - - ) : null} - - - - - ); -} - -export default function SorterDigitalTwin({ - simulation, - simplified = false, - showFps = true, - technicalLabelsEnabled = false, - cleanView = true, - onContextLost, -}: SorterDigitalTwinProps) { - const [fps, setFps] = useState(0); - const perfEnabled = useMemo( - () => - typeof window !== 'undefined' && - new URLSearchParams(window.location.search).get('perf') === '1', - [], - ); - const category = simulation.currentItem?.classification.category; - const command = simulation.machineState.startsWith('ROUTE_TO_') - ? simulation.machineState - : category - ? `ROUTE_TO_${category}` - : 'IDLE'; - - return ( -
-
- {simulation.machineState} - {simulation.scenario.name} - {category ? Zone {category} : null} - {command} - Conveyor {NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)} m/s - - Min {DIMENSION_LIMITS.min.width}×{DIMENSION_LIMITS.min.depth}×{DIMENSION_LIMITS.min.height} mm - - {showFps ? FPS ~{fps} : null} - - Motion: {PHYSICS_ENGINE_ENABLED ? 'physics' : 'state-machine'} - -
- -
- { - const canvas = gl.domElement; - const handleLost = (event: Event) => { - event.preventDefault(); - // Ignore teardown when switching to 2D / unmounting the canvas. - if (!canvas.isConnected) return; - onContextLost?.(); - }; - canvas.addEventListener('webglcontextlost', handleLost, false); - }} - > - - - {perfEnabled ? ( - - ) : null} - - -
- {perfEnabled ? : null} -
- ); -} diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index 736caac..b20ddaa 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -15,13 +15,15 @@ import type { Mesh, Group } from 'three'; import * as THREE from 'three'; import type { ContinuousPlaybackState, CasePhase } from '../../domain/continuousPlayback'; import { isDetectionActive, isRoutingActive, getPhaseProgress } from '../../domain/continuousPlayback'; -import { getPhysicalItemPose } from '../../domain/physicalItemMotion'; +import { getPhysicalItemPose, getRoutingStartMs } from '../../domain/physicalItemMotion'; import { shouldShowBoundingBox, shouldShowScanEffect, shouldShowShapeOutline, shouldHighlightCamera } from '../../domain/inspectionViewModel'; -import { getMeasurementData, shouldShowLaserBeam, shouldShowStepperPulse, shouldShowPointCloud, shouldShowActuator } from '../../domain/measurementSystem'; +import { getMeasurementData, shouldShowLaserBeam, shouldShowStepperPulse, shouldShowPointCloud } from '../../domain/measurementSystem'; import { ITEMS } from '../../data/items'; import type { Category } from '../../domain/types'; import { PhysicalPlaybackItem } from './PhysicalPlaybackItem'; import { PhysicalPlaybackItemPhysics } from './PhysicalPlaybackItemPhysics'; +import { PusherMechanism } from './PusherMechanism'; +import { RealSenseD435i, RealSenseFrustumDebug, D435I } from './RealSenseD435i'; import { ConveyorCadModel, CONVEYOR_CAD_SPAN_X, preloadConveyorCad } from './ConveyorCadModel'; import { SorterPhysicsWorld } from './SorterPhysics'; import { deriveSorterVisualState } from '../../domain/sorterVisualState'; @@ -82,6 +84,10 @@ export interface SorterDigitalTwinContinuousProps { qualityMode?: QualityMode; stage0?: Stage0Config; stage1?: Stage1Config; + /** ?debug=1 — debug overlays (measurement frustum etc.). */ + debugOverlays?: boolean; + /** ?debug=1&physics=1 — collider/frustum physics diagnostics. */ + physicsDebug?: boolean; } /** @@ -139,6 +145,28 @@ const CONVEYOR_LENGTH = CONVEYOR_END_X - CONVEYOR_START_X; void CONVEYOR_LENGTH; /** Cinematic camera controller - smoothly transitions between camera angles */ +/** + * Debug-only (?debug=1&camera=off&cam=px,py,pz,tx,ty,tz): one-shot camera + * placement for engineering captures. Ignored without ?debug=1. + */ +function DebugCameraPosition() { + const camera = useThree((s) => s.camera); + const invalidate = useThree((s) => s.invalidate); + useEffect(() => { + const q = new URLSearchParams(window.location.search); + if (q.get('debug') !== '1') return; + const raw = q.get('cam'); + if (!raw) return; + const n = raw.split(',').map(Number); + if (n.length !== 6 || n.some((v) => !Number.isFinite(v))) return; + camera.position.set(n[0], n[1], n[2]); + camera.lookAt(n[3], n[4], n[5]); + camera.updateMatrixWorld(); + invalidate(); + }, [camera, invalidate]); + return null; +} + function CinematicCameraController({ playback, enabled, @@ -368,61 +396,6 @@ function LaserBeam({ active, itemY }: { active: boolean; itemY: number }) { ); } -/** Stereo camera lenses with view cones */ -function StereoCameraLenses({ active, itemPosition }: { active: boolean; itemPosition: [number, number, number] }) { - const baseline = STEREO_CAMERA.baseline; - const mountY = STEREO_CAMERA.mountY; - const cameraX = ZONES.CAMERA.x; - - return ( - - {/* Left lens */} - - - - - {/* Right lens */} - - - - - {/* View cones when active */} - {active && ( - <> - - - - )} - - ); -} - /** Point cloud dots around item during stereo analysis */ function PointCloud({ active, itemPosition, scale, isRound }: { active: boolean; @@ -465,42 +438,6 @@ function PointCloud({ active, itemPosition, scale, isRound }: { ); } -/** Actuator/pusher animation during routing */ -function ActuatorPusher({ active, category, shadows = false }: { active: boolean; category: Category | null; shadows?: boolean }) { - const pusherRef = useRef(null); - const extendRef = useRef(0); - - useFrame((_, delta) => { - if (pusherRef.current) { - const target = active && (category === 'C' || category === 'D') ? 0.15 : 0; - extendRef.current += (target - extendRef.current) * delta * 5; - - const direction = category === 'C' ? 1 : -1; - pusherRef.current.position.z = direction * extendRef.current; - } - }); - - const gateX = ZONES.GATE.x; - const color = category === 'C' ? COLORS.routeC : category === 'D' ? COLORS.routeD : COLORS.gateFrame; - - return ( - - - - - ); -} - /** Support leg from floor to frame */ function SupportLeg({ x }: { x: number }) { const legHeight = FRAME_TOP_Y; @@ -830,7 +767,7 @@ function RouteChute({ gateX, targetZ, color, active }: { {/* Chute surface — steep gravity chute toward the cage open front */} @@ -843,11 +780,11 @@ function RouteChute({ gateX, targetZ, color, active }: { /> {/* Side rails — follow the chute pitch */} - + - + @@ -879,19 +816,42 @@ function CameraRig({ active }: { active: boolean }) { - {/* Camera unit */} - - - + {/* Mount arm from cross beam down to the camera bracket */} + + + - {/* Camera lens */} - - - + {/* Camera bracket plate (attaches to the tripod boss) */} + + + + + {/* Intel RealSense D435i (SPEC_DERIVED, official datasheet dimensions) */} + + + {/* Status LED while measuring */} + + + + + + {/* Line-laser triangulation module at 1.15 m (separate from camera + optical center 1.35 m — project doc height) */} + + + + + + + {/* Laser emitters on support poles */} @@ -1179,6 +1139,8 @@ function ContinuousScene({ stage1, maxVisibleItems = MAX_VISIBLE_ITEMS, shadowsEnabled = false, + debugOverlays = false, + physicsDebug = false, }: { playback: ContinuousPlaybackState; simplified: boolean; @@ -1189,6 +1151,8 @@ function ContinuousScene({ maxVisibleItems?: number; /** Stage 2 visual pass: PCF shadows + studio environment on capable quality modes. */ shadowsEnabled?: boolean; + debugOverlays?: boolean; + physicsDebug?: boolean; }) { const category = playback.targetCategory; const phase = playback.currentPhase; @@ -1214,7 +1178,6 @@ function ContinuousScene({ const showLaser = shouldShowLaserBeam(phase); const showPulse = shouldShowStepperPulse(phase); const showCloud = shouldShowPointCloud(phase); - const showActuator = shouldShowActuator(phase); // Compute physical items based on elapsed time. Cap the number of // simultaneously rendered items to keep the scene lightweight. @@ -1322,13 +1285,14 @@ function ContinuousScene({ ) : ( <> - {/* Stage 2 default: premium industrial rig — low ambient, key with - PCF shadows, soft fill, cool rim (Stage 0 proven values) */} - - + {/* Stage 2 default: premium industrial rig — readable ambient, key with + PCF shadows, soft fill, cool rim (Stage 0 proven values, brightened + in 2B so brackets/rollers read as volumes from every angle) */} + + - - + + + {/* low front fill so the +Z face (camera side) never goes black */} + {/* Procedural studio environment (no external HDRI — offline-safe) */} {protoShadows && ( @@ -1432,12 +1398,12 @@ function ContinuousScene({ active={activeRoute === 'D'} /> - {/* Camera rig - overhead above belt */} + {/* Camera rig - overhead above belt, carries the D435i + line laser */} - - {/* Stereo camera lenses */} - - + + {/* §9.4 debug: optical axis / FOV / measurement zone (debug only) */} + {(physicsDebug || debugOverlays) && } + {/* Laser beam for height measurement */} @@ -1460,15 +1426,19 @@ function ContinuousScene({ {/* Gate/diverter */} - {/* Actuator pusher for routing */} - - {/* Route arrows on belt surface */} {/* Items: kinematic on the belt (domain truth), rigid-body physics - after the drop handoff, verified against the domain receiver */} + after the drop handoff, verified against the domain receiver. + The angled paddle diverter lives in the same physics world and + physically contacts the items (Stage 2B §13). */} + {sceneItems.map(item => ( + {!cinematicActive && } + {/* OrbitControls - enabled when not in cinematic mode */}
{perfEnabled ? ( - - - - - {/* Highlight ring for active zone */} - {active && ( - - - - - )} - - ); -} - -/** Roll-cage for C/D zones — shared instanced mesh (exterior 1200×800×800 incl. wheels), open top */ -function RollCage({ - position, - size, - color, - active, -}: { - position: [number, number, number]; - size: [number, number, number]; - color: string; - active: boolean; -}) { - return ( - - - - {/* Floor highlight for active cage */} - {active && ( - - - - - )} - - ); -} - -export default function SortingZones3D({ activeCategory, activeRoute }: Props) { - const active = activeRoute ?? activeCategory; - const cage = TWIN_LAYOUT.rollCageSize; - const beltY = TWIN_LAYOUT.beltY; - - return ( - - {/* Zone A feed — pad under belt (aligned to shared belt height) */} - - - {/* Zone B main sorter — green, straight */} - - - {/* Roll-cage C 1200×800×800 mm — orange, ground-origin shared mesh */} - - - {/* Roll-cage D 1200×800×800 mm — purple, ground-origin shared mesh */} - - - ); -} diff --git a/src/domain/cinematicCamera.ts b/src/domain/cinematicCamera.ts index beb289d..8af2dac 100644 --- a/src/domain/cinematicCamera.ts +++ b/src/domain/cinematicCamera.ts @@ -54,9 +54,9 @@ const VIEWPORT_ADJUSTMENTS: Record> = { overview: { - position: [5.0, 3.5, 5.5], - target: [0, BELT_TOP_Y, 0], - fov: 50, + position: [4.3, 2.9, 4.6], + target: [0.1, BELT_TOP_Y, 0], + fov: 46, }, feedCloseup: { position: [ZONES.A.x + 1.5, 2.0, 2.0], diff --git a/src/domain/continuousMeasurement.test.ts b/src/domain/continuousMeasurement.test.ts new file mode 100644 index 0000000..43cf480 --- /dev/null +++ b/src/domain/continuousMeasurement.test.ts @@ -0,0 +1,142 @@ +/** + * Stage 2B §10–12, §25 — continuous measurement: the item NEVER stops under + * the camera; scan progress is position-based; classification completes + * before mechanism contact; physics handoff does not pre-position the item + * on the chute before the paddle touches it. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { CASE_PHASES, createPlaybackState, startPlayback, updatePlayback } from './continuousPlayback'; +import { getPhysicalItemPose, getDropHandoffTimeMs, getRoutingStartMs } from './physicalItemMotion'; +import { deriveSorterVisualState } from './sorterVisualState'; +import { + SCAN_START_X, SCAN_END_X, CLASSIFICATION_DEADLINE_X, MECHANISM_CONTACT_X, + getScanProgress, getScanWindow, +} from './measurementZone'; +import { ZONES, CONVEYOR_SPEED_MPS, BELT_TOP_Y } from './physicalLayout'; +import { resolveItem } from '../data/resolveItem'; +import { initRapier, simulateDrop } from './physicsDropSim'; + +function poseAt(skuId: string, category: 'B' | 'C' | 'D', elapsedMs: number) { + const item = resolveItem(skuId); + return getPhysicalItemPose({ + caseId: 't', slotIndex: 0, dimensionsMm: item.dimensionsMm, + targetCategory: category, elapsedMs, faultType: undefined, + }); +} + +function phaseStarts(): Record { + const starts: Record = {}; + let acc = 0; + for (const p of CASE_PHASES) { starts[p.phase] = acc; acc += p.durationMs; } + return starts; +} + +describe('Stage 2B §10 — no stop under the camera', () => { + it('item moves at belt speed through the entire measurement window', () => { + const starts = phaseStarts(); + const detectionStart = starts['detection']; + const commandEnd = starts['routing']; + let prevX = -Infinity; + for (let t = detectionStart; t <= commandEnd; t += 50) { + const pose = poseAt('SKU-001', 'B', t); + expect(pose.position[0]).toBeGreaterThan(prevX); // strictly increasing + prevX = pose.position[0]; + } + // exact belt speed: x advances CONVEYOR_SPEED_MPS per second + const x1 = poseAt('SKU-001', 'B', detectionStart + 1000).position[0]; + const x2 = poseAt('SKU-001', 'B', detectionStart + 2000).position[0]; + expect(x2 - x1).toBeCloseTo(CONVEYOR_SPEED_MPS * 1.0, 5); + }); + + it('belt velocity stays positive during detection/measurement phases', () => { + let state = startPlayback(createPlaybackState()); + const starts = phaseStarts(); + const measurementMid = starts['measurement'] + 300; + // advance playback to mid-measurement + let guard = 0; + while (state.caseElapsedMs < measurementMid && guard < 100000) { + state = updatePlayback(state, 50); + guard += 50; + } + expect(state.currentPhase === 'measurement' || state.currentPhase === 'classification').toBe(true); + const vs = deriveSorterVisualState(state); + expect(vs.beltVelocityMps).toBeGreaterThan(0); + }); + + it('item reaches the gate exactly at routing start (no gate dwell, no overshoot)', () => { + const routingStart = getRoutingStartMs(); + const pose = poseAt('SKU-001', 'B', routingStart); + expect(pose.position[0]).toBeCloseTo(ZONES.GATE.x, 5); + // belt speed continuity: distance from A == speed * travel time + const feedStart = phaseStarts()['move_to_detection']; + const travelS = (routingStart - feedStart) / 1000; + expect(ZONES.GATE.x - ZONES.A.x).toBeCloseTo(CONVEYOR_SPEED_MPS * travelS, 5); + }); +}); + +describe('Stage 2B §12 — position-based scan progress', () => { + it('scan progress derives from item position, not time', () => { + expect(getScanProgress(SCAN_START_X)).toBe(0); + expect(getScanProgress((SCAN_START_X + SCAN_END_X) / 2)).toBeCloseTo(0.5, 6); + expect(getScanProgress(SCAN_END_X)).toBe(1); + expect(getScanProgress(SCAN_START_X - 1)).toBe(0); + expect(getScanProgress(SCAN_END_X + 1)).toBe(1); + expect(getScanWindow(ZONES.CAMERA.x)).toBe('scanning'); + expect(getScanWindow(ZONES.GATE.x)).toBe('complete'); + }); + + it('scan window matches item pose during continuous travel', () => { + const starts = phaseStarts(); + const routingStart = starts['routing']; + let sawScanning = false; + let sawComplete = false; + for (let t = starts['move_to_detection']; t < routingStart; t += 20) { + const x = poseAt('SKU-001', 'B', t).position[0]; + const w = getScanWindow(x); + if (w === 'scanning') sawScanning = true; + if (w === 'complete') sawComplete = true; + } + expect(sawScanning).toBe(true); + expect(sawComplete).toBe(true); // item leaves the frustum before routing + }); +}); + +describe('Stage 2B §11 — classification completes before mechanism contact', () => { + it('classification phase ends at or before the deadline position', () => { + const starts = phaseStarts(); + const classificationEnd = starts['command_sent']; + const x = poseAt('SKU-001', 'B', classificationEnd).position[0]; + expect(x).toBeLessThanOrEqual(CLASSIFICATION_DEADLINE_X + 1e-9); + expect(x).toBeLessThan(MECHANISM_CONTACT_X); // well ahead of the gate + }); +}); + +describe('Stage 2B §13 — handoff not before mechanism contact', () => { + beforeAll(async () => { await initRapier(); }); + + it('C/D handoff happens at the junction (belt center), never pre-positioned on the chute', () => { + const routingStart = getRoutingStartMs(); + for (const category of ['C', 'D'] as const) { + expect(getDropHandoffTimeMs(category, undefined)).toBe(routingStart); + const pose = poseAt('SKU-001', category, getDropHandoffTimeMs(category, undefined)!); + expect(Math.abs(pose.position[2])).toBeLessThan(0.01); // belt center z=0 + expect(pose.position[0]).toBeCloseTo(ZONES.GATE.x, 5); + expect(pose.position[1]).toBeCloseTo(BELT_TOP_Y + 0.1, 5); // box h/2 = 0.1 + } + }); + + it('paddle collider actually contacts the item collider on every C/D route', () => { + for (const [sku, zone] of [['SKU-004', 'C'], ['SKU-009', 'C'], ['SKU-011', 'C'], ['SKU-006', 'D'], ['SKU-007', 'D'], ['SKU-008', 'D']] as const) { + const r = simulateDrop(sku, zone); + expect(r.pusherContactMade).toBe(true); + } + }); + + it('no positional teleport: handoff pose is continuous with the pre-handoff kinematic pose', () => { + const routingStart = getRoutingStartMs(); + const before = poseAt('SKU-001', 'C', routingStart - 1); + const at = poseAt('SKU-001', 'C', routingStart); + expect(Math.abs(at.position[0] - before.position[0])).toBeLessThan(0.01); + expect(Math.abs(at.position[2] - before.position[2])).toBeLessThan(0.01); + }); +}); diff --git a/src/domain/continuousPlayback.test.ts b/src/domain/continuousPlayback.test.ts index 64187bd..efb523c 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(10); - expect(DEMO_PLAYLIST.length).toBe(10); + expect(PLAYLIST_LENGTH).toBe(11); + expect(DEMO_PLAYLIST.length).toBe(11); }); it('has no duplicate case ids', () => { diff --git a/src/domain/continuousPlayback.ts b/src/domain/continuousPlayback.ts index 75ea93c..e6ebefd 100644 --- a/src/domain/continuousPlayback.ts +++ b/src/domain/continuousPlayback.ts @@ -41,7 +41,9 @@ export interface PhaseConfig { */ export const CASE_PHASES: PhaseConfig[] = [ { phase: 'spawn', durationMs: 300, label: 'Spawn at A' }, - { phase: 'move_to_detection', durationMs: 2500, label: 'Moving to camera' }, + // Stage 2B §10: 1900 ms so that feed(300)+1900+600+1000+1000+1000 = 5500 ms + // == A->GATE distance (5.5 m) at 1.0 m/s — continuous motion, no dwell. + { phase: 'move_to_detection', durationMs: 1900, label: 'Moving to camera' }, { phase: 'detection', durationMs: 600, label: 'CV Detection' }, { phase: 'measurement', durationMs: 1000, label: 'Laser measurement' }, { phase: 'classification', durationMs: 1000, label: 'Classification' }, diff --git a/src/domain/demoPlaylist.ts b/src/domain/demoPlaylist.ts index 5396793..dac6b0c 100644 --- a/src/domain/demoPlaylist.ts +++ b/src/domain/demoPlaylist.ts @@ -80,6 +80,14 @@ export const DEMO_PLAYLIST: PlaylistCase[] = [ scenarioId: 'c_priority', expectedCategory: 'C', }, + { + id: 'cylinder_d', + title: 'Цилиндр (вал)', + description: 'Цилиндр 435×50мм лежит поперёк ленты, K=0.97 ≥ 0.7 — круглое сечение → D', + itemId: 'SKU-008', + scenarioId: 'round_object', + expectedCategory: 'D', + }, { id: 'low_confidence', title: 'Low confidence fallback', diff --git a/src/domain/measurementZone.ts b/src/domain/measurementZone.ts new file mode 100644 index 0000000..866ce9a --- /dev/null +++ b/src/domain/measurementZone.ts @@ -0,0 +1,42 @@ +/** + * Stage 2B §10–12 — continuous measurement zone (no stop under the camera). + * + * The Intel RealSense D435i optical center is mounted above the belt at + * ZONES.CAMERA.x; its ~86° horizontal FOV at the belt plane defines the scan + * window. Items cross it at constant belt speed — measurement progress is + * POSITION-based (never time-based), so it is correct at any belt speed, + * playback speed, pause and replay: + * + * scanProgress = clamp((itemX - scanStartX) / (scanEndX - scanStartX)) + * + * Classification must be READY before the classification deadline position, + * well ahead of the mechanism engagement point (gate). + */ + +import { ZONES } from './physicalLayout'; + +/** D435i frustum footprint on the belt: optical center 1.35 m (0.65 m above + * belt top 0.7 m); the bar is mounted ACROSS the belt, so the along-belt + * extent uses the 58° vertical FOV: 2·0.65·tan(29°) ≈ 0.72 m window. */ +export const SCAN_START_X = ZONES.CAMERA.x - 0.36; +export const SCAN_END_X = ZONES.CAMERA.x + 0.36; + +/** Classification must be complete by this belt position (gate at ZONES.GATE.x = 1.5 — 1.0 m margin). */ +export const CLASSIFICATION_DEADLINE_X = 0.5; + +/** Mechanism engagement point (paddle engages the item at the gate). */ +export const MECHANISM_CONTACT_X = ZONES.GATE.x; + +export type ScanWindow = 'approaching' | 'scanning' | 'complete'; + +/** Normalized scan progress from item belt position (0 = entering frustum, 1 = leaving). */ +export function getScanProgress(itemX: number): number { + const t = (itemX - SCAN_START_X) / (SCAN_END_X - SCAN_START_X); + return Math.max(0, Math.min(1, t)); +} + +export function getScanWindow(itemX: number): ScanWindow { + if (itemX < SCAN_START_X) return 'approaching'; + if (itemX <= SCAN_END_X) return 'scanning'; + return 'complete'; +} diff --git a/src/domain/physicalItemMotion.test.ts b/src/domain/physicalItemMotion.test.ts index d13071f..d577cbc 100644 --- a/src/domain/physicalItemMotion.test.ts +++ b/src/domain/physicalItemMotion.test.ts @@ -74,10 +74,12 @@ describe('physicalItemMotion', () => { }); it('B travels via belt transfer then drop chute before settling', () => { - const transfer = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 7200 }); + // routing span 5800..8300 (Stage 2B continuous-motion retiming): + // b_transfer t<0.35 -> <= 6675; chute 0.35..0.88 -> 6675..8000 + const transfer = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 6100 }); expect(transfer.surface).toBe('b_transfer'); expect(transfer.phase).toBe('routing'); - const chute = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 8200 }); + const chute = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 7600 }); expect(chute.surface).toBe('chute_b'); const settled = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 9000 }); expect(settled.surface).toBe('b_bin_floor'); diff --git a/src/domain/physicalItemMotion.ts b/src/domain/physicalItemMotion.ts index 52cd391..b8402c3 100644 --- a/src/domain/physicalItemMotion.ts +++ b/src/domain/physicalItemMotion.ts @@ -16,6 +16,8 @@ import { type SurfaceName, type Vec3, } from './conveyorNetwork'; +import { ZONES, CONVEYOR_SPEED_MPS } from './physicalLayout'; +import { SCAN_START_X, SCAN_END_X } from './measurementZone'; export type SurfaceType = SurfaceName; export type MotionPhase = 'feed' | 'inspection' | 'decision' | 'routing' | 'settled' | 'fault' | 'recover'; @@ -93,9 +95,12 @@ function applyJitter(pos: Vec3, rotY: number, jitter?: PoseInput['jitter']): { p /** * Stage 2 — case time (ms within case) at which the item is handed from * kinematic authority to rigid-body physics: - * - B: fraction 0.35 of the B travel span (end of b_transfer spur, belt edge); - * - C/D: fraction 0.12 of the C/D routing span — item fully past the belt - * edge and ON the gravity chute (tall items must clear the belt slab); + * - B: fraction 0.35 of the B travel span (end of b_transfer spur — the + * item physically loses belt support at the real spur edge); + * - C/D: routing start — the item is at the junction (belt center over the + * gate), where the cross-belt pusher engages it. The pusher kinematic + * body then physically CONTACTS the item and drives it onto the chute + * (Stage 2B §13: no timer-only teleport into the chute); * - fault cases: null (no physics handoff — domain fault pose is truth). */ export function getDropHandoffTimeMs( @@ -110,8 +115,13 @@ export function getDropHandoffTimeMs( const exitStart = starts['exit']; return routingStart + 0.35 * (exitStart - routingStart); } - const clearStart = starts['clear_gap']; - return routingStart + 0.12 * (clearStart - routingStart); + return routingStart; +} + +/** Case-time (ms) at which the routing phase begins (drives the paddle timeline). */ +export function getRoutingStartMs(): number { + const { starts } = phaseStartsFrom(CASE_PHASES); + return starts['routing']; } /** Jam / E-stop motion: freeze at junction, then recover (no settle into bin). */ @@ -186,8 +196,6 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose { const { starts, total } = phaseStartsFrom(CASE_PHASES); const feedStart = starts['move_to_detection']; - const inspectStart = starts['detection']; - const junctionStart = starts['measurement']; const routingStart = starts['routing']; const clearStart = starts['clear_gap']; @@ -200,17 +208,24 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose { if (elapsedMs <= feedStart) { const r = poseOnSurface('main_belt', 0, itemHeightM); pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed'; - } else if (elapsedMs <= inspectStart) { - const t = (elapsedMs - feedStart) / (inspectStart - feedStart); - const r = poseOnSurface('main_belt', t, itemHeightM); - pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed'; - } else if (elapsedMs <= junctionStart) { - const r = poseOnSurface('inspection_station', 0, itemHeightM); - pos = r.pos; rotY = r.rotY; surface = 'inspection_station'; phase = 'inspection'; } else if (elapsedMs <= routingStart) { - const t = (elapsedMs - junctionStart) / (routingStart - junctionStart); - const r = poseOnSurface('routing_junction', t, itemHeightM); - pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; phase = 'decision'; + // Stage 2B §10–12: CONTINUOUS belt travel at constant conveyor speed — + // no dwell under the camera. Phase timing (move_to_detection 1900 ms + + // detection 600 + measurement 1000 + classification 1000 + command_sent + // 1000 = 5500 ms) exactly matches A->GATE distance at 1.0 m/s, so the + // item arrives at the mechanism engagement point exactly at routing start. + const travelS = (elapsedMs - feedStart) / 1000; + const x = Math.min(SURFACES.main_belt.start[0] + CONVEYOR_SPEED_MPS * travelS, ZONES.GATE.x); + if (x <= ZONES.CAMERA.x) { + const t = (x - SURFACES.main_belt.start[0]) / (SURFACES.main_belt.end[0] - SURFACES.main_belt.start[0]); + const r = poseOnSurface('main_belt', t, itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'main_belt'; + } else { + const t = (x - ZONES.CAMERA.x) / (ZONES.GATE.x - ZONES.CAMERA.x); + const r = poseOnSurface('routing_junction', t, itemHeightM); + pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; + } + phase = x < SCAN_START_X ? 'feed' : x <= SCAN_END_X ? 'inspection' : 'decision'; } else if (category === 'B') { const exitStart = starts['exit']; const travelSpan = exitStart - routingStart; diff --git a/src/domain/physicalLayout.test.ts b/src/domain/physicalLayout.test.ts index f58567a..a0b1e14 100644 --- a/src/domain/physicalLayout.test.ts +++ b/src/domain/physicalLayout.test.ts @@ -21,7 +21,7 @@ import { getItemYOnBelt, } from './physicalLayout'; import { ITEMS } from '../data/items'; -import { DEMO_PLAYLIST } from './demoPlaylist'; +import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from './demoPlaylist'; describe('Physical Layout Constants', () => { it('belt top at 0.7m', () => { @@ -123,7 +123,7 @@ describe('Playlist Items Physical Constraints', () => { it('playlist has classification and safety cases', () => { expect(DEMO_PLAYLIST.length).toBeGreaterThanOrEqual(8); - expect(DEMO_PLAYLIST).toHaveLength(10); + expect(DEMO_PLAYLIST).toHaveLength(PLAYLIST_LENGTH); }); it('low confidence case still has B/C/D category', () => { diff --git a/src/domain/physicsDrop.test.ts b/src/domain/physicsDrop.test.ts index 1e46bbf..22f7a08 100644 --- a/src/domain/physicsDrop.test.ts +++ b/src/domain/physicsDrop.test.ts @@ -152,8 +152,9 @@ describe('Stage 2 §18.11–12 — domain result authority', () => { }); it('wrong-receiver drops are detected, never silently "fixed"', () => { - // Deliberately reversed pusher direction: bottle aimed away from D. - const bad = simulateDrop('SKU-007', 'D', { linvelScale: -1 }); + // Mechanism fault: domain classified D, but the paddle executed a C push — + // the item lands in C and the mismatch MUST be detected, not corrected. + const bad = simulateDrop('SKU-007', 'D', { wrongPusher: true }); expect(bad.insideExpectedReceiver).toBe(false); expect(receiverContains('D', bad.finalPosition)).toBe(false); }); diff --git a/src/domain/physicsDropSim.ts b/src/domain/physicsDropSim.ts index 3906b68..87d8468 100644 --- a/src/domain/physicsDropSim.ts +++ b/src/domain/physicsDropSim.ts @@ -17,6 +17,7 @@ import RAPIER from '@dimforge/rapier3d-compat'; import { getStaticColliders } from './physicsWorldLayout'; import { getVisualPhysicsProfile, type VisualPhysicsProfile } from './visualPhysicsProfiles'; import { getPhysicalItemPose, getDropHandoffTimeMs } from './physicalItemMotion'; +import { getPusherState, PUSHER } from './pusherMotion'; import { receiverContains, type ReceiverZone } from './receiverVolumes'; import { resolveItem } from '../data/resolveItem'; import { BELT_TOP_Y } from './physicalLayout'; @@ -61,6 +62,10 @@ function colliderDescFor(profile: VisualPhysicsProfile): RAPIER.ColliderDesc { desc = RAPIER.ColliderDesc.cylinder(hh, r); desc.setDensity(profile.approximateMassKg / (Math.PI * r * r * 2 * hh)); } + if (profile.colliderAxis === 'x') { + // Rapier capsule/cylinder axis is Y by default — rotate to lie along X. + desc.setRotation(quatFromEuler(0, 0, Math.PI / 2)); + } desc.setFriction(profile.friction); desc.setRestitution(profile.restitution); return desc; @@ -81,13 +86,12 @@ function handoffState(skuId: string, category: ReceiverZone) { faultType: undefined, }); const h = item.dimensionsMm.height / 1000; - const dirZ = category === 'D' ? -1 : 1; - const linvel = category === 'B' - ? { x: 1.0, y: 0, z: 0 } - : { x: 0.55, y: 0, z: dirZ * 1.35 * profile.pusherImpulseScale }; - const angvel = profile.canRoll - ? { x: category === 'B' ? 2.0 : 0.8, y: 0.4, z: 0 } - : { x: 0, y: 0.25, z: 0 }; + // B: belt carry-over off the spur edge. C/D: belt carry at the junction — + // Z motion comes from the kinematic pusher plate CONTACT (Stage 2B §13). + const linvel = { x: 1.0, y: 0, z: 0 }; + const angvel = profile.canRoll && category === 'B' + ? { x: 2.0, y: 0.4, z: 0 } + : { x: 0, y: 0, z: 0 }; return { profile, pose, linvel, angvel, itemHeightM: h }; } @@ -100,6 +104,8 @@ export interface DropSimResult { settledBySleep: boolean; settledByTimeout: boolean; stepsSimulated: number; + /** Physics engine confirmed contact between the pusher plate and the item. */ + pusherContactMade: boolean; /** Lowest belt/floor clearance observed (translation.y - itemHalfHeight). */ minClearanceM: number; /** Integrated |angvel| over the drop — distinguishes rolling from teleport-like slides. */ @@ -111,7 +117,15 @@ export interface DropSimResult { export function simulateDrop( skuId: string, category: ReceiverZone, - overrides?: { linvelScale?: number }, + overrides?: { + linvelScale?: number; + /** Fault injection: pusher executes the OPPOSITE side (mechanism fault). */ + wrongPusher?: boolean; + trace?: (sample: { + t: number; x: number; y: number; z: number; + vx: number; vy: number; vz: number; + wx: number; wy: number; wz: number; sleeping: boolean; + }) => void }, ): DropSimResult { const { profile, pose, linvel, angvel, itemHeightM } = handoffState(skuId, category); const scale = overrides?.linvelScale ?? 1; @@ -136,11 +150,37 @@ export function simulateDrop( .setAngvel(angvel) .setLinearDamping(profile.linearDamping) .setAngularDamping(profile.angularDamping) - .setCcdEnabled(profile.approximateMassKg < 0.05); // same rule as runtime (pen) + .setCcdEnabled(profile.approximateMassKg < 0.05 || itemHeightM < 0.05); // pen + thin plate const body = world.createRigidBody(bodyDesc); - world.createCollider(colliderDescFor(profile), body); + const itemCollider = world.createCollider(colliderDescFor(profile), body); - const halfH = itemHeightM / 2; + // Kinematic sorting pusher (C/D routes): physically contacts the item + // and drives it across the belt onto the chute — mirrors the runtime body. + let pusherBody: RAPIER.RigidBody | null = null; + let pusherCollider: RAPIER.Collider | null = null; + const pusherCategory = overrides?.wrongPusher && category !== 'B' + ? (category === 'C' ? 'D' : 'C') + : category; + if (category !== 'B') { + const initial = getPusherState(pusherCategory, 0); + pusherBody = world.createRigidBody( + RAPIER.RigidBodyDesc.kinematicPositionBased() + .setTranslation(initial.x, PUSHER.centerY, initial.z) + .setRotation(quatFromEuler(0, initial.yaw, 0)), + ); + pusherCollider = world.createCollider( + RAPIER.ColliderDesc.cuboid(...PUSHER.halfExtents).setFriction(0.15), + pusherBody, + ); + } + let pusherContactMade = false; + + // Clearance is measured against the collider's SMALLEST vertical + // half-extent, not itemHeightM/2 — a bottle lying on its side legitimately + // has its center ~r above the floor, 10× less than its standing half-height. + const halfH = profile.collider === 'cuboid' && profile.cuboidHalfExtents + ? Math.min(...profile.cuboidHalfExtents) + : (profile.capsule?.[0] ?? itemHeightM / 2); let minClearance = Infinity; let totalRotation = 0; let maxSpeed = 0; @@ -148,11 +188,26 @@ export function simulateDrop( let slept = false; const maxSteps = Math.round(SIM_SETTLE_SECONDS / SIM_DT); for (let i = 0; i < maxSteps; i += 1) { + if (pusherBody && pusherCollider) { + const ps = getPusherState(pusherCategory, i * SIM_DT); + pusherBody.setNextKinematicTranslation({ x: ps.x, y: PUSHER.centerY, z: ps.z }); + pusherBody.setNextKinematicRotation(quatFromEuler(0, ps.yaw, 0)); + } world.step(); steps += 1; + if (pusherCollider && !pusherContactMade) { + world.contactPairsWith(pusherCollider, (other) => { + if (other.handle === itemCollider.handle) pusherContactMade = true; + }); + } const t = body.translation(); const v = body.linvel(); const w = body.angvel(); + overrides?.trace?.({ + t: i * SIM_DT, x: t.x, y: t.y, z: t.z, + vx: v.x, vy: v.y, vz: v.z, wx: w.x, wy: w.y, wz: w.z, + sleeping: body.isSleeping(), + }); minClearance = Math.min(minClearance, t.y - halfH); totalRotation += Math.hypot(w.x, w.y, w.z) * SIM_DT; maxSpeed = Math.max(maxSpeed, Math.hypot(v.x, v.y, v.z)); @@ -171,6 +226,7 @@ export function simulateDrop( settledBySleep: slept, settledByTimeout: !slept, stepsSimulated: steps, + pusherContactMade, minClearanceM: minClearance, totalRotationRad: totalRotation, maxSpeedMps: maxSpeed, diff --git a/src/domain/physicsWorldLayout.ts b/src/domain/physicsWorldLayout.ts index b93bc5a..39ec901 100644 --- a/src/domain/physicsWorldLayout.ts +++ b/src/domain/physicsWorldLayout.ts @@ -29,16 +29,19 @@ export interface StaticColliderDef { } /** - * C/D chute pitch: line from (z=±0.35, y=0.66) to (z=±1.6, y=0.14). - * tan = 0.52/1.25 ≈ 0.416 > μ_combined for every SKU profile -> items slide. + * C/D chute: top surface line from (z=±0.24, y≈0.665 — flush UNDER the belt + * edge so even a 12mm pen cannot fall into a lip gap) to (z=±1.6, y≈0.135, + * cage open front). tan ≈ 0.39 > μ_combined for every SKU -> items slide. */ -export const CHUTE_PITCH = Math.atan2(0.52, 1.25); // ≈ 0.394 rad -export const CHUTE_LENGTH = Math.hypot(1.25, 0.52); // ≈ 1.354 m +export const CHUTE_PITCH = Math.atan2(0.53, 1.36); // ≈ 0.372 rad +export const CHUTE_LENGTH = Math.hypot(1.36, 0.53); // ≈ 1.460 m export const CHUTE_MID_Y = 0.40; -export const CHUTE_MID_Z = 0.975; -/** Chute plate center X (gate exit) and plate half width. */ -export const CHUTE_X = ZONES.GATE.x + 0.3; -export const CHUTE_HALF_W = 0.25; +export const CHUTE_MID_Z = 0.92; +/** Junction transfer plate: SPEC_DERIVED 1000mm wide (x 1.2..2.2) — covers + * the full junction diagonal plus the 435mm cylinder / 900mm carton with + * margin; converging side rails. */ +export const CHUTE_X = 1.7; +export const CHUTE_HALF_W = 0.5; /** B drop chute: line from (2.15, 0.70) to (2.85, 0.13), pitch ≈ 0.684 rad. */ export const B_CHUTE_PITCH = Math.atan2(BELT_TOP_Y - (CAGE_FLOOR_Y + 0.05), 0.7); @@ -50,22 +53,22 @@ function chuteColliders(targetZ: number, label: 'C' | 'D'): StaticColliderDef[] { id: `chute-${label}-floor`, halfExtents: [CHUTE_HALF_W, 0.01, CHUTE_LENGTH / 2], - // top surface: y≈0.655 at belt edge (z 0.35) -> 0.135 at cage edge (z 1.6) - position: [CHUTE_X, CHUTE_MID_Y - 0.015, midZ], + // top surface: y≈0.665 flush at belt edge (z 0.24) -> 0.135 at cage edge + position: [CHUTE_X, CHUTE_MID_Y - 0.01, midZ], rotation: rot, friction: 0.2, // smooth coated steel — gravity chute (VISUAL_PHYSICS_ESTIMATE) }, { id: `chute-${label}-rail-left`, halfExtents: [0.01, 0.035, CHUTE_LENGTH / 2], - position: [CHUTE_X - CHUTE_HALF_W - 0.01, CHUTE_MID_Y + 0.03, midZ], + position: [CHUTE_X - CHUTE_HALF_W - 0.01, CHUTE_MID_Y + 0.035, midZ], rotation: rot, friction: 0.3, }, { id: `chute-${label}-rail-right`, halfExtents: [0.01, 0.035, CHUTE_LENGTH / 2], - position: [CHUTE_X + CHUTE_HALF_W + 0.01, CHUTE_MID_Y + 0.03, midZ], + position: [CHUTE_X + CHUTE_HALF_W + 0.01, CHUTE_MID_Y + 0.035, midZ], rotation: rot, friction: 0.3, }, @@ -119,8 +122,8 @@ export function getStaticColliders(): StaticColliderDef[] { // Belt safety slab — items never pass through the belt surface. // Ends at the B spur end (2.15): beyond it the B drop chute takes over. { id: 'belt-slab', halfExtents: [(2.15 + 4.2) / 2, 0.012, CONVEYOR_WIDTH_M / 2], position: [(2.15 - 4.2) / 2, BELT_TOP_Y - 0.014, 0], rotation: [0, 0, 0], friction: 0.7 }, - // B transfer spur - { id: 'b-spur', halfExtents: [0.325, 0.02, (CONVEYOR_WIDTH_M - 0.06) / 2], position: [1.825, BELT_TOP_Y - 0.02, 0], rotation: [0, 0, 0], friction: 0.4 }, + // B transfer spur — top FLUSH with the belt slab (no 2mm trip step) + { id: 'b-spur', halfExtents: [0.325, 0.02, (CONVEYOR_WIDTH_M - 0.06) / 2], position: [1.825, BELT_TOP_Y - 0.022, 0], rotation: [0, 0, 0], friction: 0.4 }, ...chuteColliders(ZONES.C.z, 'C'), ...chuteColliders(ZONES.D.z, 'D'), ...receiverColliders(), diff --git a/src/domain/pusherMotion.ts b/src/domain/pusherMotion.ts new file mode 100644 index 0000000..ba77913 --- /dev/null +++ b/src/domain/pusherMotion.ts @@ -0,0 +1,111 @@ +/** + * Stage 2B §13 — angled paddle diverter (sorting mechanism) motion model. + * + * Pure domain timing module consumed by BOTH the runtime kinematic pusher + * body and the headless validation sim — identical motion, identical contact. + * + * Physical design (real angled-paddle diverter): + * - The paddle is parked beside the belt upstream of the chute and sweeps + * DIAGONALLY (matching the domain junction->cage diagonal dx=0.5, dz=1.35, + * ≈20°) so items are driven toward the cage CENTER — a straight Z push + * would strand the 900mm carton against the cage west frame (engineering + * reality discovered via headless drop sims). + * - The plate travels over the belt/chute surface (plate bottom ~2mm above + * the belt) and physically CONTACTS the item. No timer-only teleport: + * the item becomes dynamic at the routing junction and the paddle drives + * it onto the gravity chute; gravity takes over below the plate reach. + * + * Timeline is keyed to case time (routing phase elapsed) — deterministic. + */ + +import { BELT_TOP_Y } from './physicalLayout'; + +/** Pusher plate geometry (SPEC_DERIVED, matches visible Actuator3D plate). */ +export const PUSHER = { + /** Plate half extents [x, y, z] — 1000mm wide face across the junction. */ + halfExtents: [0.5, 0.14, 0.025] as [number, number, number], + /** Plate center Y — bottom 2mm above the belt (no pinch against the + * belt slab; still sweeps items down to ~14mm tall). */ + centerY: BELT_TOP_Y + 0.142, + /** Push direction (unit, XZ) — the junction->cage diagonal (0.5, 1.35). */ + dirX: 0.348, + dirZ: 0.937, + /** Paddle yaw so the face is perpendicular to the push direction. */ + yaw: Math.atan2(0.348, 0.937), + /** Item engagement point (junction end / gate center). */ + engageX: 1.5, + engageZ: 0, + /** Distance behind the item where the paddle parks (along -dir). */ + homeDistance: 0.4, + /** Stroke length: from park to deep at the cage mouth center (x≈2.0). */ + strokeLength: 1.62, + /** Stroke speed m/s (pneumatic paddle class). */ + strokeSpeed: 1.0, + /** Delay after routing start before the stroke begins. */ + armDelaySec: 0.15, + /** Hold at full extension before retract. */ + holdSec: 0.15, + /** Retract speed m/s. */ + retractSpeed: 2.0, +} as const; + +export type PusherPhase = 'rest' | 'armed' | 'extending' | 'hold' | 'retracting'; + +export interface PusherState { + active: boolean; + category: 'C' | 'D' | null; + phase: PusherPhase; + /** Plate center (world). */ + x: number; + z: number; + /** Paddle yaw (world, rad about Y). */ + yaw: number; + /** Instantaneous plate speed along the push direction (m/s, signed). */ + speed: number; +} + +/** + * Pusher state at `routingElapsedSec` seconds after the routing phase began. + * `category` is the DOMAIN routing decision (source of truth). + */ +export function getPusherState( + category: 'B' | 'C' | 'D' | null, + routingElapsedSec: number, +): PusherState { + if (category !== 'C' && category !== 'D') { + return { active: false, category: null, phase: 'rest', x: PUSHER.engageX, z: 0, yaw: 0, speed: 0 }; + } + const zSign = category === 'C' ? 1 : -1; + const dx = PUSHER.dirX; + const dz = PUSHER.dirZ * zSign; + const yaw = PUSHER.yaw * zSign; + const at = (s: number) => ({ + x: PUSHER.engageX + dx * s, + z: PUSHER.engageZ + dz * s, + }); + + const strokeSec = PUSHER.strokeLength / PUSHER.strokeSpeed; + const retractSec = (PUSHER.strokeLength + PUSHER.homeDistance) / PUSHER.retractSpeed; + + const t = routingElapsedSec - PUSHER.armDelaySec; + if (t < 0) { + const p = at(-PUSHER.homeDistance); + return { active: true, category, phase: 'armed', ...p, yaw, speed: 0 }; + } + if (t < strokeSec) { + const p = at(-PUSHER.homeDistance + PUSHER.strokeSpeed * t); + return { active: true, category, phase: 'extending', ...p, yaw, speed: PUSHER.strokeSpeed }; + } + const tHold = t - strokeSec; + if (tHold < PUSHER.holdSec) { + const p = at(-PUSHER.homeDistance + PUSHER.strokeLength); + return { active: true, category, phase: 'hold', ...p, yaw, speed: 0 }; + } + const tRet = tHold - PUSHER.holdSec; + if (tRet < retractSec) { + const p = at(-PUSHER.homeDistance + PUSHER.strokeLength - PUSHER.retractSpeed * tRet); + return { active: true, category, phase: 'retracting', ...p, yaw, speed: -PUSHER.retractSpeed }; + } + const p = at(-PUSHER.homeDistance); + return { active: true, category, phase: 'rest', ...p, yaw, speed: 0 }; +} diff --git a/src/domain/qualityMode.test.ts b/src/domain/qualityMode.test.ts index 31064f6..d3b1cf3 100644 --- a/src/domain/qualityMode.test.ts +++ b/src/domain/qualityMode.test.ts @@ -17,10 +17,10 @@ describe('qualityMode', () => { expect(detectQualityMode(1440, 20)).toBe('low'); }); - it('demo preset keeps effects off for stable FPS', () => { + it('demo preset: premium shadows on (Stage 2), expensive effects stay off for stable FPS', () => { const s = getQualitySettings('demo'); expect(s.effectsEnabled).toBe(false); - expect(s.shadows).toBe(false); + expect(s.shadows).toBe(true); // Stage 2: PCFSoft contact shadows in the default premium look expect(s.targetFps).toBe(60); }); diff --git a/src/domain/sorterVisualState.ts b/src/domain/sorterVisualState.ts index 7af25e4..201cbac 100644 --- a/src/domain/sorterVisualState.ts +++ b/src/domain/sorterVisualState.ts @@ -41,8 +41,9 @@ export interface SorterVisualState { itemAuthority: ItemAuthority; /** * Case-time fraction within the routing phase at which authority - * transfers to physics (pusher contact / belt edge). B: 0.35 (end of - * b_transfer spur); C/D: 0.06 (pusher contact at gate exit). + * transfers to physics. B: 0.35 (end of b_transfer spur — loss of belt + * support); C/D: 0 (routing start at the junction — the angled paddle + * physically contacts the item and drives it onto the chute, §13). */ dropHandoffFraction: number; } @@ -67,7 +68,7 @@ export function deriveSorterVisualState(playback: ContinuousPlaybackState): Sort let itemAuthority: ItemAuthority = 'domain'; let dropHandoffFraction = 1.1; // unreachable by default (no physics handoff) if (!playback.currentCase.faultType && phase === 'routing' && category) { - dropHandoffFraction = category === 'B' ? 0.35 : 0.12; + dropHandoffFraction = category === 'B' ? 0.35 : 0; itemAuthority = 'physics'; } diff --git a/src/domain/visualPhysicsProfiles.ts b/src/domain/visualPhysicsProfiles.ts index a6df3a9..ac6a2bf 100644 --- a/src/domain/visualPhysicsProfiles.ts +++ b/src/domain/visualPhysicsProfiles.ts @@ -17,9 +17,12 @@ export interface VisualPhysicsProfile { linearDamping: number; angularDamping: number; collider: ColliderKind; + /** Capsule/cylinder symmetry axis ('y' = upright, 'x' = lying across the belt). */ + colliderAxis?: 'x' | 'y'; /** * Collider half-extents [x,y,z] for cuboid; [radius, halfHeight] for - * capsule/cylinder. Derived from validated Stage 1 dimensions (m). + * capsule/cylinder. MUST match validated Stage 1 dimensionsMm so the + * collider bottom is flush with the visual item bottom (no spawn overlap). */ cuboidHalfExtents?: [number, number, number]; capsule?: [number, number]; @@ -44,8 +47,14 @@ const PROFILES: Record = { cuboidHalfExtents: [0.14, 0.06, 0.09], centerOfMassOffsetY: 0, pusherImpulseScale: 1.05, canRoll: false, }, - 'SKU-004': { // oversized carton 900x200x500 - sku: 'SKU-004', approximateMassKg: 3.2, friction: 0.6, restitution: 0.1, + 'SKU-005': { // pouf/ottoman d489 h264 — big soft fabric cylinder, UHMW slide + sku: 'SKU-005', approximateMassKg: 3.0, friction: 0.45, restitution: 0.1, + linearDamping: 0.3, angularDamping: 0.5, collider: 'cylinder', + colliderAxis: 'y', capsule: [0.2445, 0.132], centerOfMassOffsetY: 0, + pusherImpulseScale: 0.85, canRoll: false, + }, + 'SKU-004': { // oversized carton 900x200x500 — UHMW-coated chute strips + sku: 'SKU-004', approximateMassKg: 3.2, friction: 0.4, restitution: 0.1, linearDamping: 0.25, angularDamping: 0.5, collider: 'cuboid', cuboidHalfExtents: [0.45, 0.25, 0.1], centerOfMassOffsetY: -0.05, pusherImpulseScale: 0.75, canRoll: false, @@ -62,10 +71,11 @@ const PROFILES: Record = { capsule: [0.045, 0.095], centerOfMassOffsetY: -0.04, pusherImpulseScale: 1.15, canRoll: true, }, - 'SKU-008': { // cylinder d120 h200 — rolls - sku: 'SKU-008', approximateMassKg: 1.4, friction: 0.45, restitution: 0.3, + 'SKU-008': { // long cylinder 435x50x43 (dims are source of truth) — lies + // across the belt, axis X; rolls in Z when the pusher sweeps it + sku: 'SKU-008', approximateMassKg: 0.6, friction: 0.45, restitution: 0.2, linearDamping: 0.08, angularDamping: 0.12, collider: 'cylinder', - capsule: [0.06, 0.1], centerOfMassOffsetY: 0, + colliderAxis: 'x', capsule: [0.0215, 0.2175], centerOfMassOffsetY: 0, pusherImpulseScale: 0.9, canRoll: true, }, 'SKU-009': { // pen 12x145x12 — light, spins fast @@ -74,10 +84,10 @@ const PROFILES: Record = { capsule: [0.006, 0.066], centerOfMassOffsetY: 0, pusherImpulseScale: 1.3, canRoll: true, }, - 'SKU-011': { // pouf d450 h400 — big, slow rotation - sku: 'SKU-011', approximateMassKg: 2.8, friction: 0.6, restitution: 0.2, + 'SKU-011': { // pouf round 500x300x300 — h/2=0.15 flush with dims + sku: 'SKU-011', approximateMassKg: 2.8, friction: 0.6, restitution: 0.1, linearDamping: 0.4, angularDamping: 0.6, collider: 'cylinder', - capsule: [0.225, 0.175], centerOfMassOffsetY: 0, + colliderAxis: 'y', capsule: [0.225, 0.15], centerOfMassOffsetY: 0, pusherImpulseScale: 0.85, canRoll: false, }, }; diff --git a/src/pages/DetailsPage.tsx b/src/pages/DetailsPage.tsx index 68739c7..a6c5166 100644 --- a/src/pages/DetailsPage.tsx +++ b/src/pages/DetailsPage.tsx @@ -12,12 +12,14 @@ import { DEMO_STEPS } from '../data/demoSteps'; import { SCENARIOS } from '../data/scenarios'; import type { ScenarioId, SimulationState } from '../domain/types'; import type { DemoDirectorState } from '../domain/demoDirector'; +import type { ContinuousPlaybackState } from '../domain/continuousPlayback'; function scrollToId(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } interface DetailsPageProps { + playback: ContinuousPlaybackState; simulation: SimulationState; demoStepIndex: number; demoDirector: DemoDirectorState; @@ -32,6 +34,7 @@ interface DetailsPageProps { } export default function DetailsPage({ + playback, simulation, demoStepIndex, demoDirector, @@ -83,6 +86,7 @@ export default function DetailsPage({ /> (typeof window === 'undefined' ? 1200 : window.innerWidth)); const [contextLost, setContextLost] = useState(false); - const [autoCameraEnabled, setAutoCameraEnabled] = useState(true); + const [autoCameraEnabled, setAutoCameraEnabled] = useState( + () => + typeof window === 'undefined' || + new URLSearchParams(window.location.search).get('camera') !== 'off', + ); const [presentationMode, setPresentationMode] = useState(false); const [showEventLog, setShowEventLog] = useState(false); @@ -131,6 +135,13 @@ export default function MainPage({ return new URLSearchParams(window.location.search).get('debug') === '1'; }, []); + // Physics debug (?debug=1&physics=1): collider wireframes + frustum. + const physicsDebug = useMemo(() => { + if (typeof window === 'undefined') return false; + const params = new URLSearchParams(window.location.search); + return params.get('debug') === '1' && params.get('physics') === '1'; + }, []); + // Stage 2 §16: mobile tier from capability signals (not viewport width). const mobileTier: MobileTier = useMemo(() => decideMobileTier(collectMobileSignals()), []); // First-FPS watchdog: sustained low FPS on mobile drops to SVG permanently. @@ -269,6 +280,8 @@ export default function MainPage({ qualityMode={qualityMode} stage0={stage0} stage1={stage1} + debugOverlays={debugMode} + physicsDebug={physicsDebug} />