feat: finish Stage 2B continuous sorter realism and local deploy
Unify CAD conveyor on / and /details, add RealSense D435i + contact paddle physics, keep items moving through scan, lock 7×10 drop validation, and ship Stage 2 artifacts with production on :3100 (permanent HTTPS still blocked). Co-authored-by: Cursor <cursoragent@cursor.com>
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 128 KiB |
37
docs/stage2_real_sorter/README.md
Normal file
@@ -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
|
||||
```
|
||||
183
docs/stage2_real_sorter/STAGE2_REPORT.md
Normal file
@@ -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
|
||||
```
|
||||
36
docs/stage2_real_sorter/bundle-impact.md
Normal file
@@ -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).
|
||||
50
docs/stage2_real_sorter/cad-conversion-pipeline.md
Normal file
@@ -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.
|
||||
83
docs/stage2_real_sorter/cad-inventory.json
Normal file
@@ -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"]
|
||||
}
|
||||
}
|
||||
50
docs/stage2_real_sorter/camera-realsense-spec.md
Normal file
@@ -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`.
|
||||
7
docs/stage2_real_sorter/console-errors.json
Normal file
@@ -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."
|
||||
}
|
||||
45
docs/stage2_real_sorter/continuous-measurement.md
Normal file
@@ -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`.
|
||||
58
docs/stage2_real_sorter/deployment-report.md
Normal file
@@ -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: <cloudflare tunnel id>.cfargotunnel.com
|
||||
Proxy: Proxied
|
||||
|
||||
# Or fix openresty on 185.160.137.162:
|
||||
server_name sorter.arhipovdan.ru;
|
||||
ssl_certificate ...;
|
||||
proxy_pass http://<owlprime-lan>: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.
|
||||
138
docs/stage2_real_sorter/drop-validation.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
51
docs/stage2_real_sorter/mechanism-map.md
Normal file
@@ -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`.
|
||||
64
docs/stage2_real_sorter/physics-architecture.md
Normal file
@@ -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).
|
||||
228
docs/stage2_real_sorter/physics-profiles.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
30
docs/stage2_real_sorter/public-domain-smoke.json
Normal file
@@ -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"
|
||||
}
|
||||
16
docs/stage2_real_sorter/real-sorter-validation.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
BIN
docs/stage2_real_sorter/screenshots/01-real-cad-overview.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
docs/stage2_real_sorter/screenshots/02-volumetric-rollers.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
docs/stage2_real_sorter/screenshots/03-volumetric-brackets.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
docs/stage2_real_sorter/screenshots/04-realsense-front.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
docs/stage2_real_sorter/screenshots/05-realsense-mounted.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 260 KiB |
BIN
docs/stage2_real_sorter/screenshots/07-continuous-scan-entry.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
docs/stage2_real_sorter/screenshots/08-continuous-scan-exit.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 304 KiB |
BIN
docs/stage2_real_sorter/screenshots/10-mechanism-contact.png
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
docs/stage2_real_sorter/screenshots/11-box-route-b.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
docs/stage2_real_sorter/screenshots/12-oversized-route-c.png
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
docs/stage2_real_sorter/screenshots/13-plate-route-d.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
docs/stage2_real_sorter/screenshots/14-cylinder-route-d.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 258 KiB |
BIN
docs/stage2_real_sorter/screenshots/16-premium-overview.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
docs/stage2_real_sorter/screenshots/17-mobile-public-domain.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 326 KiB |
1
docs/stage2_real_sorter/videos/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Videos not captured in this environment (no real-time WebGL recorder under SwiftShader). Capture via: BASE=<url> node scripts/stage2b-capture.mjs videos
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
55
e2e/helpers.ts
Normal file
@@ -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`);
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
25
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
index-AagIOJbd.js
|
||||
index-3cv8erXB.js
|
||||
|
||||
@@ -1 +1 @@
|
||||
4fcce5b
|
||||
40e9b18
|
||||
|
||||
@@ -1 +1 @@
|
||||
20260715-2221
|
||||
20260730-115319
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
92
scripts/drop-validation.mts
Normal file
@@ -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);
|
||||
270
scripts/stage2b-capture.mjs
Normal file
@@ -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://<tunnel> 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);
|
||||
10
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={
|
||||
<DetailsPage
|
||||
playback={playback}
|
||||
simulation={simulation}
|
||||
demoStepIndex={demoStepIndex}
|
||||
demoDirector={demoDirector}
|
||||
|
||||
@@ -5,12 +5,16 @@ import ThreeErrorBoundary from './ThreeD/ThreeErrorBoundary';
|
||||
import { prefer3DByDefault, useWebGLSupport } from './ThreeD/useWebGL';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
import type { DemoDirectorState } from '../domain/demoDirector';
|
||||
import type { ContinuousPlaybackState } from '../domain/continuousPlayback';
|
||||
|
||||
const SorterDigitalTwin = lazy(() => 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({
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<div className="three-loading">Загрузка 3D digital twin…</div>}>
|
||||
<SorterDigitalTwin
|
||||
simulation={simulation}
|
||||
<SorterDigitalTwinContinuous
|
||||
playback={playback}
|
||||
simplified={simplified}
|
||||
autoCameraEnabled
|
||||
onContextLost={() => {
|
||||
setContextLost(true);
|
||||
setViewMode('2d');
|
||||
|
||||
@@ -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 (
|
||||
<group>
|
||||
{/* Stop-gate — vertical lift mechanism */}
|
||||
<group position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY, 0]}>
|
||||
{/* Gate support posts (left and right) - larger and brighter */}
|
||||
<mesh position={[0, 0.38, -0.4]}>
|
||||
<boxGeometry args={[0.06, 0.76, 0.06]} />
|
||||
<meshStandardMaterial color={postColor} metalness={0.4} roughness={0.6} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.38, 0.4]}>
|
||||
<boxGeometry args={[0.06, 0.76, 0.06]} />
|
||||
<meshStandardMaterial color={postColor} metalness={0.4} roughness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Cross bar at top */}
|
||||
<mesh position={[0, 0.72, 0]}>
|
||||
<boxGeometry args={[0.08, 0.05, 0.88]} />
|
||||
<meshStandardMaterial color={postColor} metalness={0.4} roughness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Gate plate — moves up/down - larger and brighter */}
|
||||
<mesh position={[0, gateY, 0]}>
|
||||
<boxGeometry args={[0.1, 0.08, 0.72]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
|
||||
emissive={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
|
||||
emissiveIntensity={fault ? 0.6 : gateOpen ? 0.4 : 0.3}
|
||||
metalness={0.3}
|
||||
roughness={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Pusher C → roll-cage C (extended plate) - brighter orange */}
|
||||
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
|
||||
{/* Pusher base (stationary) - visible base */}
|
||||
<mesh position={[0, 0.12, 0.12]}>
|
||||
<boxGeometry args={[0.2, 0.2, 0.15]} />
|
||||
<meshStandardMaterial color="#b45309" roughness={0.7} metalness={0.2} />
|
||||
</mesh>
|
||||
|
||||
{/* Pusher plate (moves) - larger and brighter */}
|
||||
<mesh position={[0, 0.14, pusherC + 0.15]}>
|
||||
<boxGeometry args={[0.35, 0.2, 0.25]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherC === 'extended' ? '#fbbf24' : '#d97706'}
|
||||
emissive={actuators.pusherC === 'extended' ? '#fbbf24' : '#d97706'}
|
||||
emissiveIntensity={actuators.pusherC === 'extended' ? 0.6 : 0.2}
|
||||
roughness={0.5}
|
||||
metalness={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Pusher D → roll-cage D (extended plate) - brighter purple */}
|
||||
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
|
||||
{/* Pusher base (stationary) - visible base */}
|
||||
<mesh position={[0, 0.12, -0.12]}>
|
||||
<boxGeometry args={[0.2, 0.2, 0.15]} />
|
||||
<meshStandardMaterial color="#7c3aed" roughness={0.7} metalness={0.2} />
|
||||
</mesh>
|
||||
|
||||
{/* Pusher plate (moves) - larger and brighter */}
|
||||
<mesh position={[0, 0.14, -pusherD - 0.15]}>
|
||||
<boxGeometry args={[0.35, 0.2, 0.25]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherD === 'extended' ? '#d8b4fe' : '#a78bfa'}
|
||||
emissive={actuators.pusherD === 'extended' ? '#d8b4fe' : '#a78bfa'}
|
||||
emissiveIntensity={actuators.pusherD === 'extended' ? 0.6 : 0.2}
|
||||
roughness={0.5}
|
||||
metalness={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<group>
|
||||
{/* Feed conveyor body */}
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY - 0.12, 0]} receiveShadow={false} castShadow={false}>
|
||||
<boxGeometry args={[8.6, 0.18, 0.58]} />
|
||||
<meshStandardMaterial color={color} roughness={0.7} metalness={0.25} />
|
||||
</mesh>
|
||||
|
||||
{/* Belt surface */}
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY - 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[8.4, 0.5]} />
|
||||
<meshStandardMaterial color={beltColor} roughness={0.75} />
|
||||
</mesh>
|
||||
|
||||
{/* Conveyor side guards (borders) */}
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY + 0.06, 0.3]}>
|
||||
<boxGeometry args={[8.4, 0.12, 0.04]} />
|
||||
<meshStandardMaterial color={sideGuardColor} metalness={0.3} roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY + 0.06, -0.3]}>
|
||||
<boxGeometry args={[8.4, 0.12, 0.04]} />
|
||||
<meshStandardMaterial color={sideGuardColor} metalness={0.3} roughness={0.7} />
|
||||
</mesh>
|
||||
|
||||
{/* Rollers — spaced every 1.2m */}
|
||||
{[-3.6, -2.4, -1.2, 0, 1.2].map((x) => (
|
||||
<mesh key={x} position={[x, TWIN_LAYOUT.beltY - 0.08, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.52, 12]} />
|
||||
<meshStandardMaterial color={rollerColor} metalness={0.5} roughness={0.5} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Accumulator / buffer at end of feed conveyor */}
|
||||
<group position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY, 0]}>
|
||||
<mesh position={[0, -0.02, 0]}>
|
||||
<boxGeometry args={[1.05, 0.12, 0.82]} />
|
||||
<meshStandardMaterial color={accColor} roughness={0.65} emissive={accColor} emissiveIntensity={0.15} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0.12, 0.38]}>
|
||||
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
||||
<meshStandardMaterial color={INDUSTRIAL_PALETTE.sensorAccent} roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.12, -0.38]}>
|
||||
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
||||
<meshStandardMaterial color={INDUSTRIAL_PALETTE.sensorAccent} roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[-0.48, 0.12, 0]}>
|
||||
<boxGeometry args={[0.06, 0.28, 0.82]} />
|
||||
<meshStandardMaterial color={INDUSTRIAL_PALETTE.sensorAccent} roughness={0.7} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0.2, 0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[0.3, 0.2]} />
|
||||
<meshStandardMaterial
|
||||
color={INDUSTRIAL_PALETTE.lightFill}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
side={2}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof getVisualPhysicsProfile>): 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<RapierRigidBody>(null);
|
||||
const traceEnabled = useRef(
|
||||
typeof window !== 'undefined'
|
||||
&& new URLSearchParams(window.location.search).get('trace') === '1',
|
||||
);
|
||||
const authority = useRef<Authority>('kinematic');
|
||||
const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null);
|
||||
const handedOffAtMs = useRef<number | null>(null);
|
||||
const handedOffAtSimSec = useRef<number | null>(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 (
|
||||
<RigidBody
|
||||
@@ -200,10 +236,18 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
<CuboidCollider args={profile.cuboidHalfExtents} density={density} />
|
||||
)}
|
||||
{profile.collider === 'capsule' && profile.capsule && (
|
||||
<CapsuleCollider args={[profile.capsule[1], profile.capsule[0]]} density={density} />
|
||||
<CapsuleCollider
|
||||
args={[profile.capsule[1], profile.capsule[0]]}
|
||||
density={density}
|
||||
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||
/>
|
||||
)}
|
||||
{profile.collider === 'cylinder' && profile.capsule && (
|
||||
<CylinderCollider args={[profile.capsule[1], profile.capsule[0]]} density={density} />
|
||||
<CylinderCollider
|
||||
args={[profile.capsule[1], profile.capsule[0]]}
|
||||
density={density}
|
||||
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||
/>
|
||||
)}
|
||||
<ItemVisualContent
|
||||
caseData={caseData}
|
||||
|
||||
105
src/components/ThreeD/PusherMechanism.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Stage 2B §13 — angled paddle diverter (sorting mechanism).
|
||||
*
|
||||
* ONE component = visual paddle + kinematic rigid body with the SAME plate
|
||||
* geometry, driven by the SAME pure domain function getPusherState()
|
||||
* (also used by the headless validation sim) — visual and collider can never
|
||||
* drift apart. The paddle physically contacts items and drives them across
|
||||
* the belt onto the gravity chute; no timer-only teleport into the chute.
|
||||
*
|
||||
* SPEC_DERIVED: angled paddle diverter, 1000×280mm face, pneumatic cylinder,
|
||||
* linear guide rail, safety-yellow moving parts.
|
||||
*/
|
||||
import { memo, useRef } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { RigidBody, CuboidCollider, type RapierRigidBody } from '@react-three/rapier';
|
||||
import { getPusherState, PUSHER } from '../../domain/pusherMotion';
|
||||
import { physicsSimClock } from './SorterPhysics';
|
||||
import type { Category } from '../../domain/types';
|
||||
|
||||
const STEEL = '#8a949e';
|
||||
const DARK_STEEL = '#3a4046';
|
||||
const SAFETY_YELLOW = '#f5c518';
|
||||
|
||||
export const PusherMechanism = memo(function PusherMechanism({
|
||||
category,
|
||||
routingElapsedMs,
|
||||
castShadow = false,
|
||||
}: {
|
||||
category: Category | null;
|
||||
routingElapsedMs: number;
|
||||
castShadow?: boolean;
|
||||
}) {
|
||||
const bodyRef = useRef<RapierRigidBody>(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<number | null>(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 (
|
||||
<RigidBody
|
||||
ref={bodyRef}
|
||||
type="kinematicPosition"
|
||||
colliders={false}
|
||||
friction={0.15}
|
||||
>
|
||||
<CuboidCollider args={[hx, hy, hz]} />
|
||||
{/* Paddle face — safety yellow leading edge strip */}
|
||||
<mesh castShadow={castShadow} position={[0, 0, -hz - 0.002]}>
|
||||
<boxGeometry args={[hx * 2, hy * 2, 0.004]} />
|
||||
<meshStandardMaterial color={SAFETY_YELLOW} metalness={0.25} roughness={0.45} />
|
||||
</mesh>
|
||||
{/* Paddle plate */}
|
||||
<mesh castShadow={castShadow}>
|
||||
<boxGeometry args={[hx * 2, hy * 2, hz * 2]} />
|
||||
<meshStandardMaterial color={STEEL} metalness={0.85} roughness={0.35} />
|
||||
</mesh>
|
||||
{/* Stiffener ribs on the back of the plate */}
|
||||
{[-0.3, 0, 0.3].map((ox) => (
|
||||
<mesh key={ox} castShadow={castShadow} position={[ox, 0, hz + 0.015]}>
|
||||
<boxGeometry args={[0.04, hy * 2, 0.03]} />
|
||||
<meshStandardMaterial color={DARK_STEEL} metalness={0.8} roughness={0.4} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Pneumatic cylinder housing behind the plate */}
|
||||
<mesh castShadow={castShadow} position={[0, hy - 0.02, hz + 0.09]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.045, 0.045, 0.16, 20]} />
|
||||
<meshStandardMaterial color={DARK_STEEL} metalness={0.9} roughness={0.3} />
|
||||
</mesh>
|
||||
<mesh position={[0, hy - 0.02, hz + 0.19]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.02, 0.02, 0.06, 14]} />
|
||||
<meshStandardMaterial color={STEEL} metalness={0.95} roughness={0.2} />
|
||||
</mesh>
|
||||
</RigidBody>
|
||||
);
|
||||
});
|
||||
154
src/components/ThreeD/RealSenseD435i.tsx
Normal file
@@ -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 (
|
||||
<group position={[0, 0, z]}>
|
||||
<mesh position={[0, y, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[radius, 0.0016, 10, 24]} />
|
||||
<meshStandardMaterial color={LENS_RIM} metalness={0.8} roughness={0.35} />
|
||||
</mesh>
|
||||
<mesh position={[0, y + 0.0008, 0]}>
|
||||
<cylinderGeometry args={[radius * 0.72, radius * 0.72, 0.0016, 20]} />
|
||||
<meshStandardMaterial color={LENS_INNER} metalness={0.4} roughness={0.15} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
<group>
|
||||
{/* Main housing bar with chamfered edge rails */}
|
||||
<mesh castShadow={castShadow}>
|
||||
<boxGeometry args={[D435I.depth - 0.004, D435I.height - 0.004, w]} />
|
||||
<meshStandardMaterial color={HOUSING} metalness={0.7} roughness={0.42} />
|
||||
</mesh>
|
||||
{/* side edge rails (rounded anodized look) */}
|
||||
{[-1, 1].map((s) => (
|
||||
<mesh key={s} position={[s * (D435I.depth / 2 - 0.002), 0, 0]}>
|
||||
<boxGeometry args={[0.004, D435I.height, w - 0.006]} />
|
||||
<meshStandardMaterial color={HOUSING_EDGE} metalness={0.75} roughness={0.35} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Full-width front glass on the downward face */}
|
||||
<mesh position={[0, -D435I.height / 2 - 0.0006, 0]}>
|
||||
<boxGeometry args={[D435I.depth - 0.007, 0.0012, w - 0.008]} />
|
||||
<meshPhysicalMaterial
|
||||
color={GLASS}
|
||||
metalness={0.1}
|
||||
roughness={0.08}
|
||||
transparent
|
||||
opacity={0.82}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Sensors along the bar: left imager / RGB / IR projector / right imager */}
|
||||
<SensorWindow z={D435I.baseline / 2} radius={0.0065} />
|
||||
<SensorWindow z={-D435I.baseline / 2} radius={0.0065} />
|
||||
<SensorWindow z={0.012} radius={0.0042} />
|
||||
<SensorWindow z={-0.011} radius={0.0052} />
|
||||
{/* USB-C port on the right end (rear) */}
|
||||
<mesh position={[D435I.depth / 2 - 0.001, 0.002, w / 2 - 0.008]}>
|
||||
<boxGeometry args={[0.004, 0.006, 0.009]} />
|
||||
<meshStandardMaterial color="#0b0d0f" metalness={0.3} roughness={0.6} />
|
||||
</mesh>
|
||||
{/* Tripod boss on top (mount point) */}
|
||||
<mesh position={[0, D435I.height / 2 + 0.003, 0]}>
|
||||
<boxGeometry args={[0.012, 0.006, 0.02]} />
|
||||
<meshStandardMaterial color={HOUSING_EDGE} metalness={0.8} roughness={0.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<group>
|
||||
{/* optical axis */}
|
||||
<lineSegments>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[new Float32Array([
|
||||
...top, cx, y, 0,
|
||||
// FOV edges
|
||||
...corners.flatMap((c) => [...top, ...c]),
|
||||
// FOV footprint rectangle
|
||||
...corners.flatMap((c, i) => [...c, ...corners[(i + 1) % 4]]),
|
||||
]), 3]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color="#38bdf8" transparent opacity={0.55} />
|
||||
</lineSegments>
|
||||
{/* scan zone rectangle on the belt */}
|
||||
<mesh position={[(SCAN_START_X + SCAN_END_X) / 2, y + 0.003, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[SCAN_END_X - SCAN_START_X, CONVEYOR_WIDTH_M]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
{/* entry / exit markers */}
|
||||
{[SCAN_START_X, SCAN_END_X].map((x) => (
|
||||
<mesh key={x} position={[x, y + 0.004, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[0.015, CONVEYOR_WIDTH_M]} />
|
||||
<meshBasicMaterial color={x === SCAN_START_X ? '#22c55e' : '#ef4444'} transparent opacity={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
});
|
||||
@@ -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 (
|
||||
<group position={[x, 0, -0.6]}>
|
||||
{/* Pole - brighter */}
|
||||
<mesh position={[0, height / 2, 0]}>
|
||||
<boxGeometry args={[0.1, height, 0.1]} />
|
||||
<meshStandardMaterial color="#6b7a8f" metalness={0.3} roughness={0.7} />
|
||||
</mesh>
|
||||
{/* Sensor head - larger and brighter */}
|
||||
<mesh position={[0, height, 0.22]}>
|
||||
<boxGeometry args={[0.32, 0.2, 0.26]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? labelColor : '#3d4a5c'}
|
||||
emissive={active ? labelColor : '#000000'}
|
||||
emissiveIntensity={active ? 0.7 : 0}
|
||||
metalness={0.2}
|
||||
roughness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Lens indicator */}
|
||||
<mesh position={[0, height, 0.36]}>
|
||||
<cylinderGeometry args={[0.06, 0.06, 0.02, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? '#ffffff' : '#2a3444'}
|
||||
emissive={active ? labelColor : '#000000'}
|
||||
emissiveIntensity={active ? 0.9 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SensorRig3D({ sensors, machineState }: Props) {
|
||||
const detecting = machineState === 'DETECTING';
|
||||
const poleH = TWIN_LAYOUT.beltY + 0.45;
|
||||
const ultrasonicH = TWIN_LAYOUT.beltY + 0.3;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<SensorPole x={TWIN_LAYOUT.cameraX} active={sensors.camera.active || detecting} labelColor="#38bdf8" height={poleH} />
|
||||
<SensorPole x={TWIN_LAYOUT.laserX} active={sensors.laser.active} labelColor="#22d3ee" height={poleH} />
|
||||
<SensorPole x={TWIN_LAYOUT.ultrasonicX} active={sensors.ultrasound.active} labelColor="#67e8f9" height={ultrasonicH} />
|
||||
|
||||
{/* Detection beam - more visible */}
|
||||
{detecting ? (
|
||||
<group>
|
||||
{/* Main detection plane */}
|
||||
<mesh position={[TWIN_LAYOUT.cameraX, TWIN_LAYOUT.beltY + 0.4, 0]}>
|
||||
<boxGeometry args={[0.8, 0.03, 0.6]} />
|
||||
<meshStandardMaterial color="#38bdf8" transparent opacity={0.5} emissive="#38bdf8" emissiveIntensity={0.7} />
|
||||
</mesh>
|
||||
{/* Scan line effect */}
|
||||
<mesh position={[TWIN_LAYOUT.cameraX, TWIN_LAYOUT.beltY + 0.2, 0]}>
|
||||
<boxGeometry args={[0.02, 0.4, 0.6]} />
|
||||
<meshStandardMaterial color="#67e8f9" transparent opacity={0.6} emissive="#67e8f9" emissiveIntensity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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<number[]>([]);
|
||||
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 */}
|
||||
<color attach="background" args={[INDUSTRIAL_PALETTE.backgroundDark]} />
|
||||
|
||||
{/* Improved lighting for better visibility */}
|
||||
<ambientLight intensity={0.7} />
|
||||
<directionalLight position={[5, 8, 4]} intensity={1.1} />
|
||||
<directionalLight position={[-4, 4, -2]} intensity={0.4} />
|
||||
<hemisphereLight args={['#b8d4e8', '#1a2836', 0.3]} />
|
||||
|
||||
<Grid
|
||||
args={[16, 16]}
|
||||
cellSize={0.5}
|
||||
cellThickness={0.7}
|
||||
cellColor={INDUSTRIAL_PALETTE.metalDark}
|
||||
sectionSize={2}
|
||||
sectionThickness={1.2}
|
||||
sectionColor={INDUSTRIAL_PALETTE.frame}
|
||||
fadeDistance={16}
|
||||
infiniteGrid={false}
|
||||
position={[0, 0.001, 0]}
|
||||
/>
|
||||
|
||||
{/* Floor — shared industrial tokens (dark engineering projection) */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||
<planeGeometry args={[14, 10]} />
|
||||
<meshStandardMaterial color={INDUSTRIAL_PALETTE.backgroundDark} />
|
||||
</mesh>
|
||||
|
||||
<Conveyor3D stopped={stopped} />
|
||||
<SensorRig3D sensors={simulation.sensors} machineState={simulation.machineState} />
|
||||
<Actuator3D
|
||||
gate={simulation.gate}
|
||||
actuators={simulation.actuators}
|
||||
machineState={simulation.machineState}
|
||||
/>
|
||||
<SortingZones3D activeCategory={category} activeRoute={activeRoute} />
|
||||
<RouteArrows3D category={category} machineState={simulation.machineState} />
|
||||
<Item3D simulation={simulation} currentItem={simulation.currentItem} />
|
||||
<SceneLabels3D
|
||||
machineState={simulation.machineState}
|
||||
currentItem={simulation.currentItem}
|
||||
simplified={simplified}
|
||||
conveyorTargetMps={NOMINAL_CONVEYOR_SPEED_MPS}
|
||||
technicalLabelsEnabled={technicalLabelsEnabled}
|
||||
cleanView={cleanView}
|
||||
/>
|
||||
|
||||
{stopped ? (
|
||||
<mesh position={[0, 1.2, 0]}>
|
||||
<planeGeometry args={[6, 1.2]} />
|
||||
<meshStandardMaterial color={INDUSTRIAL_PALETTE.fault} transparent opacity={0.35} />
|
||||
</mesh>
|
||||
) : null}
|
||||
|
||||
<OrbitControls
|
||||
enablePan={!simplified}
|
||||
enableZoom
|
||||
maxPolarAngle={Math.PI / 2.1}
|
||||
minDistance={2.5}
|
||||
maxDistance={10}
|
||||
target={[0.6, 0.55, 0]}
|
||||
/>
|
||||
<FpsMeter onFps={onFps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`digital-twin-wrap ${simplified ? 'digital-twin-simplified' : ''}`}>
|
||||
<div className="digital-twin-hud" aria-live="polite">
|
||||
<span className="twin-chip">{simulation.machineState}</span>
|
||||
<span className="twin-chip">{simulation.scenario.name}</span>
|
||||
{category ? <span className={`twin-chip category-${category}`}>Zone {category}</span> : null}
|
||||
<span className="twin-chip">{command}</span>
|
||||
<span className="twin-chip">Conveyor {NOMINAL_CONVEYOR_SPEED_MPS.toFixed(2)} m/s</span>
|
||||
<span className="twin-chip">
|
||||
Min {DIMENSION_LIMITS.min.width}×{DIMENSION_LIMITS.min.depth}×{DIMENSION_LIMITS.min.height} mm
|
||||
</span>
|
||||
{showFps ? <span className="twin-chip">FPS ~{fps}</span> : null}
|
||||
<span className="twin-chip muted-chip">
|
||||
Motion: {PHYSICS_ENGINE_ENABLED ? 'physics' : 'state-machine'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="digital-twin-canvas">
|
||||
<Canvas
|
||||
camera={{ position: simplified ? [3.8, 2.6, 3.8] : [4.0, 3.0, 4.0], fov: 45 }}
|
||||
dpr={simplified ? [1, 1.25] : [1, 1.75]}
|
||||
gl={{ antialias: !simplified, powerPreference: 'high-performance' }}
|
||||
onCreated={({ gl }) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<TwinScene
|
||||
simulation={simulation}
|
||||
simplified={simplified}
|
||||
technicalLabelsEnabled={technicalLabelsEnabled}
|
||||
cleanView={cleanView}
|
||||
onFps={setFps}
|
||||
/>
|
||||
{perfEnabled ? (
|
||||
<PerfCollector
|
||||
enabled
|
||||
mode={simplified ? 'low' : 'demo'}
|
||||
shadows={false}
|
||||
antialias={!simplified}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
{perfEnabled ? <PerfOverlay enabled /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<group position={[cameraX, mountY, 0]}>
|
||||
{/* Left lens */}
|
||||
<mesh position={[0, 0, baseline / 2]}>
|
||||
<cylinderGeometry args={[0.02, 0.02, 0.015, 12]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? '#0ea5e9' : '#1e293b'}
|
||||
emissive={active ? '#0ea5e9' : '#000'}
|
||||
emissiveIntensity={active ? 0.3 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Right lens */}
|
||||
<mesh position={[0, 0, -baseline / 2]}>
|
||||
<cylinderGeometry args={[0.02, 0.02, 0.015, 12]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? '#0ea5e9' : '#1e293b'}
|
||||
emissive={active ? '#0ea5e9' : '#000'}
|
||||
emissiveIntensity={active ? 0.3 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
{/* View cones when active */}
|
||||
{active && (
|
||||
<>
|
||||
<Line
|
||||
points={[
|
||||
[0, 0, baseline / 2],
|
||||
[0, itemPosition[1] - mountY, itemPosition[2]],
|
||||
]}
|
||||
color="#0ea5e9"
|
||||
lineWidth={1}
|
||||
transparent
|
||||
opacity={0.3}
|
||||
/>
|
||||
<Line
|
||||
points={[
|
||||
[0, 0, -baseline / 2],
|
||||
[0, itemPosition[1] - mountY, itemPosition[2]],
|
||||
]}
|
||||
color="#0ea5e9"
|
||||
lineWidth={1}
|
||||
transparent
|
||||
opacity={0.3}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<Mesh>(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 (
|
||||
<mesh
|
||||
ref={pusherRef}
|
||||
position={[gateX + 0.3, BELT_Y + 0.08, 0]}
|
||||
castShadow={shadows}
|
||||
>
|
||||
<boxGeometry args={[0.15, 0.08, 0.06]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={active ? color : '#000'}
|
||||
emissiveIntensity={active ? 0.3 : 0}
|
||||
metalness={0.5}
|
||||
roughness={0.4}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }: {
|
||||
<group>
|
||||
{/* Chute surface — steep gravity chute toward the cage open front */}
|
||||
<mesh
|
||||
position={[CHUTE_X, CHUTE_MID_Y - 0.015, midZ]}
|
||||
position={[CHUTE_X, CHUTE_MID_Y - 0.01, midZ]}
|
||||
rotation={rot}
|
||||
>
|
||||
<boxGeometry args={[chuteWidth, 0.02, CHUTE_LENGTH]} />
|
||||
@@ -843,11 +780,11 @@ function RouteChute({ gateX, targetZ, color, active }: {
|
||||
/>
|
||||
</mesh>
|
||||
{/* Side rails — follow the chute pitch */}
|
||||
<mesh position={[CHUTE_X - CHUTE_HALF_W - 0.01, CHUTE_MID_Y + 0.03, midZ]} rotation={rot}>
|
||||
<mesh position={[CHUTE_X - CHUTE_HALF_W - 0.01, CHUTE_MID_Y + 0.035, midZ]} rotation={rot}>
|
||||
<boxGeometry args={[0.02, 0.07, CHUTE_LENGTH]} />
|
||||
<meshStandardMaterial color={color} metalness={0.5} roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh position={[CHUTE_X + CHUTE_HALF_W + 0.01, CHUTE_MID_Y + 0.03, midZ]} rotation={rot}>
|
||||
<mesh position={[CHUTE_X + CHUTE_HALF_W + 0.01, CHUTE_MID_Y + 0.035, midZ]} rotation={rot}>
|
||||
<boxGeometry args={[0.02, 0.07, CHUTE_LENGTH]} />
|
||||
<meshStandardMaterial color={color} metalness={0.5} roughness={0.4} />
|
||||
</mesh>
|
||||
@@ -879,19 +816,42 @@ function CameraRig({ active }: { active: boolean }) {
|
||||
<boxGeometry args={[0.05, 0.05, poleSpacing * 2 + 0.1]} />
|
||||
<meshStandardMaterial color={COLORS.gateFrame} metalness={0.5} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Camera unit */}
|
||||
<mesh position={[0, cameraY, 0]}>
|
||||
<boxGeometry args={[0.18, 0.1, 0.12]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? COLORS.sensorActive : '#1e3a5f'}
|
||||
emissive={active ? COLORS.sensorActive : '#000'}
|
||||
emissiveIntensity={active ? 0.5 : 0}
|
||||
/>
|
||||
{/* Mount arm from cross beam down to the camera bracket */}
|
||||
<mesh position={[0, (rigHeight + cameraY) / 2, 0]}>
|
||||
<boxGeometry args={[0.04, rigHeight - cameraY + 0.04, 0.04]} />
|
||||
<meshStandardMaterial color={COLORS.gateFrame} metalness={0.5} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Camera lens */}
|
||||
<mesh position={[0, cameraY - 0.06, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.035, 0.035, 0.02, 16]} />
|
||||
<meshStandardMaterial color="#0f172a" />
|
||||
{/* Camera bracket plate (attaches to the tripod boss) */}
|
||||
<mesh position={[0, cameraY + 0.022, 0]}>
|
||||
<boxGeometry args={[0.05, 0.006, 0.12]} />
|
||||
<meshStandardMaterial color={COLORS.gateFrame} metalness={0.6} roughness={0.35} />
|
||||
</mesh>
|
||||
{/* Intel RealSense D435i (SPEC_DERIVED, official datasheet dimensions) */}
|
||||
<group position={[0, cameraY, 0]}>
|
||||
<RealSenseD435i />
|
||||
{/* Status LED while measuring */}
|
||||
<mesh position={[D435I.depth / 2 + 0.002, 0.004, 0]}>
|
||||
<boxGeometry args={[0.002, 0.004, 0.006]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? '#22c55e' : '#14532d'}
|
||||
emissive={active ? '#22c55e' : '#000'}
|
||||
emissiveIntensity={active ? 1.2 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
{/* Line-laser triangulation module at 1.15 m (separate from camera
|
||||
optical center 1.35 m — project doc height) */}
|
||||
<mesh position={[0.12, 1.15, 0]} rotation={[0, 0, -0.35]}>
|
||||
<boxGeometry args={[0.05, 0.04, 0.09]} />
|
||||
<meshStandardMaterial color="#2b3138" metalness={0.7} roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh position={[0.135, 1.128, 0]} rotation={[0, 0, -0.35]}>
|
||||
<boxGeometry args={[0.012, 0.008, 0.05]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? '#ef4444' : '#7f1d1d'}
|
||||
emissive={active ? '#ef4444' : '#000'}
|
||||
emissiveIntensity={active ? 0.9 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Laser emitters on support poles */}
|
||||
<mesh position={[0, BELT_Y + 0.15, poleSpacing - 0.05]}>
|
||||
@@ -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) */}
|
||||
<ambientLight intensity={0.28} />
|
||||
<hemisphereLight args={['#2a3c58', '#141c2a', 0.8]} />
|
||||
{/* 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) */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<hemisphereLight args={['#39506e', '#1a2434', 1.1]} />
|
||||
<directionalLight
|
||||
position={[6, 9, 4]}
|
||||
intensity={2.2}
|
||||
intensity={2.6}
|
||||
castShadow={protoShadows}
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
@@ -1340,8 +1304,10 @@ function ContinuousScene({
|
||||
shadow-camera-far={25}
|
||||
shadow-bias={-0.0004}
|
||||
/>
|
||||
<directionalLight position={[-5, 6, -3]} intensity={0.35} />
|
||||
<directionalLight position={[2, 5, -8]} intensity={0.7} color="#bcd7ff" />
|
||||
<directionalLight position={[-5, 6, -3]} intensity={0.65} />
|
||||
<directionalLight position={[2, 5, -8]} intensity={0.9} color="#bcd7ff" />
|
||||
{/* low front fill so the +Z face (camera side) never goes black */}
|
||||
<directionalLight position={[1, 3, 8]} intensity={0.5} color="#cfdcf2" />
|
||||
{/* Procedural studio environment (no external HDRI — offline-safe) */}
|
||||
{protoShadows && (
|
||||
<Environment resolution={128} frames={1}>
|
||||
@@ -1432,12 +1398,12 @@ function ContinuousScene({
|
||||
active={activeRoute === 'D'}
|
||||
/>
|
||||
|
||||
{/* Camera rig - overhead above belt */}
|
||||
{/* Camera rig - overhead above belt, carries the D435i + line laser */}
|
||||
<CameraRig active={cameraHighlight} />
|
||||
|
||||
{/* Stereo camera lenses */}
|
||||
<StereoCameraLenses active={cameraHighlight} itemPosition={itemPos} />
|
||||
|
||||
|
||||
{/* §9.4 debug: optical axis / FOV / measurement zone (debug only) */}
|
||||
{(physicsDebug || debugOverlays) && <RealSenseFrustumDebug />}
|
||||
|
||||
{/* Laser beam for height measurement */}
|
||||
<LaserBeam active={showLaser} itemY={itemPos[1]} />
|
||||
|
||||
@@ -1460,15 +1426,19 @@ function ContinuousScene({
|
||||
{/* Gate/diverter */}
|
||||
<GateZone category={category} shadows={protoShadows} />
|
||||
|
||||
{/* Actuator pusher for routing */}
|
||||
<ActuatorPusher active={showActuator} category={category} shadows={protoShadows} />
|
||||
|
||||
{/* Route arrows on belt surface */}
|
||||
<RouteArrows activeRoute={activeRoute} />
|
||||
|
||||
{/* 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). */}
|
||||
<SorterPhysicsWorld running={playback.status === 'running'} speed={playback.speed}>
|
||||
<PusherMechanism
|
||||
category={category}
|
||||
routingElapsedMs={caseElapsedMs - getRoutingStartMs()}
|
||||
castShadow={protoShadows}
|
||||
/>
|
||||
{sceneItems.map(item => (
|
||||
<PhysicalPlaybackItemPhysics
|
||||
key={item.id}
|
||||
@@ -1516,6 +1486,8 @@ function ContinuousScene({
|
||||
overrideCategory={shotOverride?.category ?? null}
|
||||
/>
|
||||
|
||||
{!cinematicActive && <DebugCameraPosition />}
|
||||
|
||||
{/* OrbitControls - enabled when not in cinematic mode */}
|
||||
<OrbitControls
|
||||
enablePan={!simplified && !cinematicActive}
|
||||
@@ -1595,6 +1567,8 @@ export default function SorterDigitalTwinContinuous({
|
||||
qualityMode,
|
||||
stage0,
|
||||
stage1,
|
||||
debugOverlays = false,
|
||||
physicsDebug = false,
|
||||
}: SorterDigitalTwinContinuousProps) {
|
||||
const mode = qualityMode ?? detectQualityMode(typeof window !== 'undefined' ? window.innerWidth : 1200);
|
||||
const quality = getQualitySettings(mode);
|
||||
@@ -1611,7 +1585,7 @@ export default function SorterDigitalTwinContinuous({
|
||||
<div className="digital-twin-wrap continuous-twin">
|
||||
<div className="digital-twin-canvas continuous-canvas">
|
||||
<Canvas
|
||||
camera={{ position: [4.5, 3.5, 5.0], fov: 45 }}
|
||||
camera={{ position: [4.3, 2.9, 4.6], fov: 46 }}
|
||||
dpr={[1, quality.dprMax]}
|
||||
shadows={shadowsEnabled ? 'soft' : false}
|
||||
gl={{ antialias, powerPreference: 'high-performance' }}
|
||||
@@ -1648,6 +1622,8 @@ export default function SorterDigitalTwinContinuous({
|
||||
stage1={stage1}
|
||||
maxVisibleItems={quality.maxVisibleItems}
|
||||
shadowsEnabled={shadowsEnabled}
|
||||
debugOverlays={debugOverlays}
|
||||
physicsDebug={physicsDebug}
|
||||
/>
|
||||
{perfEnabled ? (
|
||||
<PerfCollector
|
||||
|
||||
@@ -21,6 +21,20 @@ import { getStaticColliders } from '../../domain/physicsWorldLayout';
|
||||
export const PHYSICS_DT = 1 / 60;
|
||||
const MAX_SUBSTEPS = 4;
|
||||
|
||||
/**
|
||||
* Physics-time clock (seconds actually simulated by THIS world instance).
|
||||
* Kinematic mechanisms must be driven by this clock — never by the domain
|
||||
* wall clock — because under render lag the stepper burns at most
|
||||
* MAX_SUBSTEPS per frame and physics time falls behind domain time. Driving
|
||||
* the pusher from domain time made the paddle sweep through items in a few
|
||||
* huge jumps (visible as items being smashed/tunneled on slow devices).
|
||||
*/
|
||||
export const physicsSimClock = { simSec: 0 };
|
||||
|
||||
export function resetPhysicsSimClock() {
|
||||
physicsSimClock.simSec = 0;
|
||||
}
|
||||
|
||||
/** Drop verification record (debug/e2e introspection, no secrets). */
|
||||
export interface DropResult {
|
||||
caseId: string;
|
||||
@@ -72,6 +86,7 @@ function RapierStepper({ running, speed }: { running: boolean; speed: number })
|
||||
} else {
|
||||
world.step();
|
||||
}
|
||||
physicsSimClock.simSec += PHYSICS_DT;
|
||||
accumulator.current -= PHYSICS_DT;
|
||||
steps += 1;
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { Category } from '../../domain/types';
|
||||
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
|
||||
import RollCageMesh from './RollCageMesh';
|
||||
|
||||
interface Props {
|
||||
activeCategory?: Category;
|
||||
activeRoute?: Category;
|
||||
}
|
||||
|
||||
function ZoneBox({
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
active,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
size: [number, number, number];
|
||||
color: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh>
|
||||
<boxGeometry args={size} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={active ? 0.6 : 0.2}
|
||||
emissive={color}
|
||||
emissiveIntensity={active ? 0.7 : 0.1}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Highlight ring for active zone */}
|
||||
{active && (
|
||||
<mesh position={[0, size[1] / 2 + 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[size[0] * 0.4, size[0] * 0.55, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={1.0}
|
||||
transparent
|
||||
opacity={0.8}
|
||||
side={2}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<group position={position}>
|
||||
<RollCageMesh color={color} active={active} />
|
||||
|
||||
{/* Floor highlight for active cage */}
|
||||
{active && (
|
||||
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[size[0] * 0.9, size[2] * 0.9]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.6}
|
||||
transparent
|
||||
opacity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
|
||||
const active = activeRoute ?? activeCategory;
|
||||
const cage = TWIN_LAYOUT.rollCageSize;
|
||||
const beltY = TWIN_LAYOUT.beltY;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Zone A feed — pad under belt (aligned to shared belt height) */}
|
||||
<ZoneBox
|
||||
position={[TWIN_LAYOUT.startX, beltY / 2, 0]}
|
||||
size={[1.0, beltY, 1.0]}
|
||||
color="#38bdf8"
|
||||
active={active === undefined}
|
||||
/>
|
||||
|
||||
{/* Zone B main sorter — green, straight */}
|
||||
<ZoneBox
|
||||
position={[TWIN_LAYOUT.zoneBX, beltY / 2, 0]}
|
||||
size={[1.2, beltY, 1.2]}
|
||||
color={ROUTE_COLORS.B}
|
||||
active={active === 'B'}
|
||||
/>
|
||||
|
||||
{/* Roll-cage C 1200×800×800 mm — orange, ground-origin shared mesh */}
|
||||
<RollCage
|
||||
position={[TWIN_LAYOUT.gateX + 0.4, 0, TWIN_LAYOUT.zoneCZ]}
|
||||
size={[cage.x, cage.y, cage.z]}
|
||||
color={ROUTE_COLORS.C}
|
||||
active={active === 'C'}
|
||||
/>
|
||||
|
||||
{/* Roll-cage D 1200×800×800 mm — purple, ground-origin shared mesh */}
|
||||
<RollCage
|
||||
position={[TWIN_LAYOUT.gateX + 0.4, 0, TWIN_LAYOUT.zoneDZ]}
|
||||
size={[cage.x, cage.y, cage.z]}
|
||||
color={ROUTE_COLORS.D}
|
||||
active={active === 'D'}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -54,9 +54,9 @@ const VIEWPORT_ADJUSTMENTS: Record<ViewportType, { heightMult: number; distMult:
|
||||
*/
|
||||
const BASE_CAMERA_CONFIGS: Record<CameraMode, Omit<CameraConfig, 'mode'>> = {
|
||||
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],
|
||||
|
||||
142
src/domain/continuousMeasurement.test.ts
Normal file
@@ -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<string, number> {
|
||||
const starts: Record<string, number> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
42
src/domain/measurementZone.ts
Normal file
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
111
src/domain/pusherMotion.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, VisualPhysicsProfile> = {
|
||||
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<string, VisualPhysicsProfile> = {
|
||||
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<string, VisualPhysicsProfile> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
|
||||
<ProductDemoSection
|
||||
playback={playback}
|
||||
simulation={simulation}
|
||||
demoStepTitle={currentDemoStep.title}
|
||||
demoDirector={demoDirector}
|
||||
|
||||
@@ -45,7 +45,11 @@ export default function MainPage({
|
||||
const webgl = useWebGLSupport();
|
||||
const [width, setWidth] = useState(() => (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}
|
||||
/>
|
||||
</Suspense>
|
||||
</ThreeErrorBoundary>
|
||||
|
||||