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