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>
This commit is contained in:
Даня Архипов
2026-07-30 12:00:09 +00:00
parent 40e9b18e8d
commit acc59ec912
81 changed files with 2530 additions and 878 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View 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
```

View 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
```

View 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).

View 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.

View 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"]
}
}

View 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`.

View 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."
}

View 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`.

View 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.

View 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"
}
}

View 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`.

View 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).

View 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
}
}
}

View 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"
}

View 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" }
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

View 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

View File

@@ -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
View 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`);
}

View File

@@ -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([]);

View File

@@ -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;

View File

@@ -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++) {

View File

@@ -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);
});

View File

@@ -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');

View File

@@ -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 () => {

View File

@@ -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";
}
}

View File

@@ -1 +1 @@
index-AagIOJbd.js
index-3cv8erXB.js

View File

@@ -1 +1 @@
4fcce5b
40e9b18

View File

@@ -1 +1 @@
20260715-2221
20260730-115319

View File

@@ -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"
}

View 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
View 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);

View File

@@ -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}

View File

@@ -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');

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}

View 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>
);
});

View 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>
);
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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],

View 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);
});
});

View File

@@ -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', () => {

View File

@@ -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' },

View File

@@ -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',

View 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';
}

View File

@@ -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');

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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
View 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 };
}

View File

@@ -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);
});

View File

@@ -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';
}

View File

@@ -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,
},
};

View File

@@ -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}

View File

@@ -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>