feat: Stage 2C CAD assembly cleanup, full-height canvas, physics parity

Remove default Measurement overlay and procedural motor duplicate; force
opaque CAD materials; modular extension pitch from CAD; shared runtime/
headless physics config hash; layout e2e and Stage 2C artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-07-30 12:35:50 +00:00
parent 0a1be376eb
commit 68327635ce
50 changed files with 3051 additions and 100 deletions

View File

@@ -1,5 +1,5 @@
{
"generatedAt": "2026-07-30T11:23:42.086Z",
"generatedAt": "2026-07-30T12:27:01.613Z",
"sim": "headless Rapier via simulateDrop (deterministic, dt=1/60, settle 6s)",
"runs": 10,
"routes": [

View File

@@ -0,0 +1,5 @@
# Stage 2C — CAD visual / physics correction
Working branch: `feature/ozon-sorter-stage-2c-cad-visual-physics`
See `STAGE2C_REPORT.md` for the full decision record.

View File

@@ -0,0 +1,159 @@
# STAGE 2C REPORT — CAD Assembly Correction / Visual Cleanup / Runtime Physics
## 1. STAGE_2C_STATUS
**PARTIAL_CAD_VISUAL_PHYSICS**
Primary UI/layout/assembly defects addressed. Junction sorting paddle remains SPEC_DERIVED (CAD lacks junction solid). Formal GPU multi-run profiles and runtime WebM not completed. Public domain still blocked.
## 2. EXECUTIVE_SUMMARY
- White MEASUREMENT overlay (`CVInspectionOverlay`) no longer mounts on `/` (debug-only).
- Canvas fills viewport (`100dvh` + absolute `.canvas-holder`); emptyRatio=0 measured.
- Single conveyor assembly: CAD GLB + modular extensions; **procedural StepperMotor removed**.
- Author CAD audited: only `3d_models/conveer.FCStd` machine CAD; product STLs separate.
- Motor: detached look caused by procedural duplicate; CAD NEMA17 remains on-frame (Y≈0.58).
- Opaque machine materials forced (`transparent=false`, `depthWrite=true`).
- Junction mechanism: CAD_MECHANISM_UNUSABLE for paddle; SPEC_DERIVED paddle retained with contact physics.
- Runtime/headless config hash equal; drop validation 7/7×10/10.
- Production `:3100` updated with Stage 2C dist; `sorter.arhipovdan.ru` still blocked.
## 3. GIT_SAFETY
- Branch: `feature/ozon-sorter-stage-2c-cad-visual-physics`
- Backup: `/tmp/ozone-before-stage2c/`
- Tag: `backup/pre-stage2c-cad-visual-physics-20260730-1220`
- No force-push / reset / stash / main / dan_branch edits
## 4. CAD_SOURCE_INVENTORY
See `cad-source-inventory.json`. Author machine CAD: **one** FCStd (`3d_models/conveer.FCStd`, 42 PartDesign bodies). Runtime GLB `public/models/sorter/conveyor-web.glb`. Contact sheet: `screenshots/cad-contact-sheet.png`.
## 5. CAD_RUNTIME_AUDIT
See `cad-runtime-audit.json`, `cad-node-hierarchy.json`. Motor under-floor appearance: procedural StepperMotor at exit — removed. CAD motor AABB on frame.
## 6. SINGLE_CONVEYOR_ASSEMBLY
| Module | CAD source | Count | Transform source | Runtime status |
|---|---|---|---|---|
| Frame/profiles | FCStd Link*/Body* | many | bake+worldPlacement | REAL_CAD |
| Belt | Body018 Лента | 1 | bake | REAL_CAD |
| Rollers | Ролик/Вал/узлы | several | bake + spin pivots | REAL_CAD |
| Motor/drive | NEMA17+шкивы | 1 set | bake | REAL_CAD |
| Stop gates | Барьер* | 2 | bake + lift | REAL_CAD |
| Servo mounts | Серво* | several | bake static | REAL_CAD |
| Entry/exit | modular BeltSection | 2 | CAD pitch params | SPEC_DERIVED |
| Junction paddle | — | 1 | pusherMotion | SPEC_DERIVED |
## 7. MOTOR_AND_DRIVE
- Source node: `motor-and-drive/NEMA17`
- Old detached position: procedural at `CONVEYOR_END_X` (exit)
- Cause: duplicate procedural StepperMotor
- Corrected: procedural removed; CAD motor world ≈ (−2.04, 0.58, 0.25)
- Screenshot: `screenshots/04-motor-correct-position.png`
## 8. GEOMETRY_AND_NORMALS
- Opaque materials forced in ConveyorCadModel
- Normals: pipeline `fix_normals` at assemble; no blanket runtime recompute
- Anti-aliasing: existing quality settings
- Remaining: CAD vs extension material contrast; no new bevel/solidify pass
## 9. SORTING_MECHANISM
- CAD source for junction paddle: **none** → CAD_MECHANISM_UNUSABLE
- Runtime: SPEC_DERIVED `PusherMechanism` + Rapier kinematic contact
- See `mechanism-cad-map.md`
## 10. UI_AND_LAYOUT
- Measurement full panel: `/?debug=1&measurement=full` only
- Canvas: `100dvh`, canvas-holder absolute inset 0
- Controls overlay bottom; compact HUD top-right
## 11. REAL_MODEL_SCALE
Ground-truth mm preserved (no artificial enlargement). Belt 500 mm reference unchanged.
| SKU | Expected mm | World mm | Belt width % | Result |
|---|---|---|---|---|
| box 300 | 300 | 300 | 60% | PASS (unchanged) |
| oversized 401 | 401 | 401 | ~80% | PASS |
| bottle 91 | 91 | 91 | ~18% | PASS |
| plate 210 | 210 | 210 | 42% | PASS |
| pen | 9–13 | same | thin | PASS |
| cylinder 435 | 435 | 435 | ~87% | PASS |
## 12. RUNTIME_PHYSICS
- Timestep 1/60 shared (`physicsTimestep.ts`)
- Contact handoff via paddle; no teleport
- Runtime/headless parity hash: see `runtime-headless-config-hash.json`
## 13. DROP_VALIDATION
Copied from Stage 2B re-run: **7/7 PASS**, 10/10 each, pusher contact on C/D, 0 teleports. Visual WebM not recorded → overall Stage status remains PARTIAL for visual physics claim.
## 14. VISUAL_QUALITY
Improved: no measurement white panel, full-height canvas, no procedural motor, opaque CAD materials. Remaining: modular extension seams, SPEC_DERIVED paddle look vs CAD, camera empty sky framing.
## 15. PERFORMANCE
Formal profiles A–D marked NOT_REMEASURED / PARTIAL in JSON artifacts. Build + unit tests pass.
## 16. MOBILE
SVG fallback retained; e2e layout assertions added. Real phone NOT_TESTED.
## 17. DEPLOYMENT
`:3100` updated with Stage 2C dist. Public domain blocked. See `deployment-report.md`.
## 18. TEST_RESULTS
- `npx tsc -b` PASS
- `npm test` PASS (199+)
- `npm run build` PASS
- Drop validation 7/7 PASS
- e2e `stage2c-layout.spec.ts` added
## 19. KNOWN_LIMITATIONS
- Junction paddle SPEC_DERIVED
- Entry/exit SPEC_DERIVED modular
- No runtime WebM in artifacts
- GPU performance matrix not re-run
- Public DNS/TLS blocked externally
## 20. DECISION
| Question | Answer |
|---|---|
| White panel removed? | YES (default) |
| Empty area fixed? | YES (canvas fill; camera framing ≠ layout bug) |
| Single conveyor? | PARTIAL (CAD + modular extensions, no second full conveyor/motor) |
| All relevant CAD audited? | YES (one author FCStd + inventory) |
| Motor correct? | YES (procedural duplicate removed) |
| Volumetric / opaque? | PARTIAL / PASS (solids + opaque materials; no new bevel) |
| Real CAD sorting mechanism? | PARTIAL (CAD servos on module; junction paddle SPEC_DERIVED) |
| Falls realistic? | PARTIAL (headless+hash PASS; WebM missing) |
| Runtime/headless match? | YES |
| Performance? | PARTIAL (not remeasured) |
## 21. FILES_CHANGED
- `src/pages/MainPage.tsx` — measurement modes
- `src/styles.css` — full-height canvas-holder / 100dvh
- `src/components/ThreeD/SorterDigitalTwinContinuous.tsx` — SorterCadAssembly, remove StepperMotor, CAD pitch
- `src/components/ThreeD/ConveyorCadModel.tsx` — opaque materials
- `src/domain/cadAssemblyParams.ts`, `physicsConfigHash.ts`, `physicsTimestep.ts`
- `src/domain/physicsDropSim.ts`, `SorterPhysics.tsx` — shared timestep
- `e2e/stage2c-layout.spec.ts`, `src/domain/stage2cCadVisual.test.ts`
- `docs/stage2c_cad_visual/**`
## 22. FINAL_GIT_CHECK
(see working tree at end of session)

View File

@@ -0,0 +1,6 @@
# Bundle impact — Stage 2C
- Build: `vite build` OK (2026-07-30)
- Main twin chunk remains large (~4.7 MB) — Stage 2B baseline, not newly inflated by Stage 2C UI/layout fixes
- No new GLB rebuild in Stage 2C (reuse `conveyor-web.glb`)
- Added domain modules: `cadAssemblyParams.ts`, `physicsConfigHash.ts`, `physicsTimestep.ts` (tiny)

View File

@@ -0,0 +1,20 @@
# CAD Assembly Plan — Stage 2C
## Single source
Runtime group `SorterCadAssembly` (ConveyorBelt in SorterDigitalTwinContinuous):
1. `ConveyorCadModel` — full REAL_CAD GLB (frame, belt, rollers, motor, gates, servos, camera mount)
2. Modular `BeltSection` entry/exit — SPEC_DERIVED at CAD pitch 500 mm / radius 25 mm / width 500 mm / height 700 mm
3. End drive/tension rollers on extensions only
4. **No** procedural `StepperMotor`
## Parameters (`cadAssemblyParams.ts`)
segmentLength=2.01 m, rollerPitch=0.5 m, rollerRadius=0.025 m, conveyorWidth=0.5 m, beltHeight=0.7 m, supportSpacing=2.0 m, totalLineLength=8.5 m
## Forbidden
- Non-uniform scale of CAD module
- Duplicate full procedural conveyor
- Procedural motor alongside CAD NEMA17

View File

@@ -0,0 +1,977 @@
{
"source": "tools/stage2-cad/out/manifest.json + bake",
"bake": "(x,y,z)mm Z-up -> (-x,z,y+250)/1000",
"worldPlacement": [
-2.02,
0.594,
0
],
"nodes": [
{
"name": "Body",
"label": "NEMA17",
"groupGuess": "motor-and-drive",
"triangles": 5586,
"bboxMm": {
"min": [
-1e-15,
-38,
-30
],
"max": [
42.3,
34.00000000000001,
12.300000000000008
]
},
"sizeMm": [
42.3,
72,
42.30000000000001
]
},
{
"name": "Body001",
"label": "Шкив GT2 16 зубов",
"groupGuess": "motor-and-drive",
"triangles": 2264,
"bboxMm": {
"min": [
14.756939000000003,
16,
-15.70807668300531
],
"max": [
27.756939000000003,
34.300000000000004,
-2.712117316994693
]
},
"sizeMm": [
13,
18.300000000000004,
12.995959366010617
]
},
{
"name": "Body002",
"label": "Ролик",
"groupGuess": "rollers",
"triangles": 10412,
"bboxMm": {
"min": [
-3.954959426935666,
-250,
55.00484333613933
],
"max": [
46,
0,
104.99515666386074
]
},
"sizeMm": [
49.954959426935666,
250,
49.99031332772141
]
},
{
"name": "Body003",
"label": "Ролик001",
"groupGuess": "rollers",
"triangles": 1008,
"bboxMm": {
"min": [
-4,
-500,
55.00777044997959
],
"max": [
46,
-250,
104.99222955002045
]
},
"sizeMm": [
50,
250,
49.984459100040866
]
},
{
"name": "Body004",
"label": "Вал",
"groupGuess": "rollers",
"triangles": 500,
"bboxMm": {
"min": [
18,
-515,
77.00093245399755
],
"max": [
24,
35,
82.99906754600256
]
},
"sizeMm": [
6,
550,
5.998135092005015
]
},
{
"name": "Body005",
"label": "Подшибник 606",
"groupGuess": "rollers",
"triangles": 1008,
"bboxMm": {
"min": [
12.5,
2,
71.50264195299306
],
"max": [
29.5,
8,
88.49735804700694
]
},
"sizeMm": [
17,
6,
16.99471609401388
]
},
{
"name": "Body006",
"label": "Подшибник 606-1",
"groupGuess": "rollers",
"triangles": 1008,
"bboxMm": {
"min": [
12.5,
-508,
71.50264195299306
],
"max": [
29.5,
-502,
88.49735804700694
]
},
"sizeMm": [
17,
6,
16.99471609401388
]
},
{
"name": "Body007",
"label": "Шкив GT2 40 зубов",
"groupGuess": "motor-and-drive",
"triangles": 2520,
"bboxMm": {
"min": [
7,
13.2,
66.00435145198857
],
"max": [
35,
34.2,
93.99564854801143
]
},
"sizeMm": [
28,
21.000000000000004,
27.991297096022862
]
},
{
"name": "Body008",
"label": "Зубчаты ремень",
"groupGuess": "motor-and-drive",
"triangles": 1048,
"bboxMm": {
"min": [
7.529429378041517,
21.999999999999993,
-14.960073663449407
],
"max": [
34.47470281539362,
32.000000000000014,
93.47105570946769
]
},
"sizeMm": [
26.945273437352103,
10.000000000000021,
108.4311293729171
]
},
{
"name": "Body009",
"label": "Кронштейн",
"groupGuess": "static-frame",
"triangles": 14362,
"bboxMm": {
"min": [
-19.000000000000004,
-10,
33.449999999999996
],
"max": [
61.00000000528567,
10.000000000000005,
100
]
},
"sizeMm": [
80.00000000528567,
20.000000000000007,
66.55000000000001
]
},
{
"name": "Body010",
"label": "Конструкционный профиль 500х20х20мм",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-439,
-10,
13.499999999999998
],
"max": [
61,
10,
33.5
]
},
"sizeMm": [
500,
20,
20
]
},
{
"name": "Body011",
"label": "Конструкционный профиль 480х20х20мм001",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
46,
-490,
13.399999999999999
],
"max": [
66,
-10,
33.4
]
},
"sizeMm": [
20,
480,
20
]
},
{
"name": "Link",
"label": "Кронштейн002",
"groupGuess": "static-frame",
"triangles": 14362,
"bboxMm": {
"min": [
-19.00000000528567,
-510,
33.449999999999996
],
"max": [
61,
-490,
100
]
},
"sizeMm": [
80.00000000528567,
20,
66.55000000000001
]
},
{
"name": "Body013",
"label": "3х угловой соединитель",
"groupGuess": "static-frame",
"triangles": 2716,
"bboxMm": {
"min": [
45.45761393940592,
-25.49734985341653,
-2.0195123917426727
],
"max": [
66.03239455479782,
10.028193646006756,
33.55177332439381
]
},
"sizeMm": [
20.5747806153919,
35.525543499423286,
35.57128571613648
]
},
{
"name": "Link001",
"label": "3х угловой соединитель001",
"groupGuess": "static-frame",
"triangles": 2716,
"bboxMm": {
"min": [
45.49999999999999,
-510,
-2.000000000000014
],
"max": [
66.00000000000003,
-474.5,
33.50000000000001
]
},
"sizeMm": [
20.500000000000036,
35.5,
35.50000000000002
]
},
{
"name": "Body014",
"label": "Конструкционный профиль 100х20х20мм",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
45.99999999999997,
-10.000000000000002,
-86.5
],
"max": [
65.99999999999997,
9.999999999999998,
13.500000000000002
]
},
"sizeMm": [
20,
20,
100
]
},
{
"name": "Body015",
"label": "Кронштейн крепления двигателя",
"groupGuess": "static-frame",
"triangles": 8400,
"bboxMm": {
"min": [
-20.500000000000025,
9.999999999999988,
-52.999999999999936
],
"max": [
64.50000000000003,
18.00000000000001,
31.999999999999996
]
},
"sizeMm": [
85.00000000000006,
8.000000000000023,
84.99999999999993
]
},
{
"name": "Link002",
"label": "Конструкционный профиль 100х20х20мм001",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
46,
-510,
-86.49999999999991
],
"max": [
66,
-490,
13.500000000000092
]
},
"sizeMm": [
20,
20,
100
]
},
{
"name": "Link003",
"label": "Конструкционный профиль 500х20х20мм002",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-439.0000000000001,
-510,
13.499999999999998
],
"max": [
60.9999999999999,
-489.9999999999999,
33.5
]
},
"sizeMm": [
500,
20.000000000000114,
20
]
},
{
"name": "Link004",
"label": "Конструкционный профиль 500х20х20мм003",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-939,
-9.999999999999913,
13.499999999999998
],
"max": [
-439,
10.000000000000199,
33.5
]
},
"sizeMm": [
500,
20.000000000000114,
20
]
},
{
"name": "Link005",
"label": "Роликовый узел в сборе",
"groupGuess": "rollers",
"triangles": 42660,
"bboxMm": {
"min": [
-479.00000000528564,
-515,
33.449999999999996
],
"max": [
-398.99999999471436,
35,
104.99515666386074
]
},
"sizeMm": [
80.00000001057128,
550,
71.54515666386075
]
},
{
"name": "Link006",
"label": "Конструкционный профиль 500х20х20мм004",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-939.0000000000001,
-509.99999999999994,
13.499999999999998
],
"max": [
-439.0000000000001,
-489.99999999999983,
33.5
]
},
"sizeMm": [
500,
20.000000000000114,
20
]
},
{
"name": "Link007",
"label": "Роликовый узел в сборе001",
"groupGuess": "rollers",
"triangles": 42660,
"bboxMm": {
"min": [
-979.0000000052856,
-515,
33.449999999999996
],
"max": [
-898.9999999947144,
35,
104.99515666386074
]
},
"sizeMm": [
80.00000001057128,
550,
71.54515666386075
]
},
{
"name": "Link008",
"label": "Конструкционный профиль 500х20х20мм005",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-1439,
-9.999999999999812,
13.499999999999998
],
"max": [
-939,
10.000000000000298,
33.5
]
},
"sizeMm": [
500,
20.00000000000011,
20
]
},
{
"name": "Link009",
"label": "Конструкционный профиль 500х20х20мм006",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-1439,
-509.9999999999998,
13.499999999999998
],
"max": [
-939.0000000000001,
-489.99999999999966,
33.5
]
},
"sizeMm": [
499.9999999999999,
20.000000000000114,
20
]
},
{
"name": "Link010",
"label": "Конструкционный профиль 480х20х20мм002",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-949,
-490,
13.499999999999998
],
"max": [
-929,
-10,
33.5
]
},
"sizeMm": [
20,
480,
20
]
},
{
"name": "Link011",
"label": "Конструкционный профиль 500х20х20мм007",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-1939,
-9.999999999999712,
13.499999999999998
],
"max": [
-1439,
10.000000000000398,
33.5
]
},
"sizeMm": [
500,
20.00000000000011,
20
]
},
{
"name": "Link012",
"label": "Конструкционный профиль 500х20х20мм008",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-1939,
-509.9999999999997,
13.499999999999998
],
"max": [
-1439,
-489.9999999999996,
33.5
]
},
"sizeMm": [
500,
20.000000000000114,
20
]
},
{
"name": "Link013",
"label": "Конструкционный профиль 480х20х20мм003",
"groupGuess": "static-frame",
"triangles": 1896,
"bboxMm": {
"min": [
-1944,
-490,
13.499999999999998
],
"max": [
-1924,
-10,
33.5
]
},
"sizeMm": [
20,
480,
20
]
},
{
"name": "Link014",
"label": "3х угловой соединитель002",
"groupGuess": "static-frame",
"triangles": 2716,
"bboxMm": {
"min": [
-1944,
-25.500000000000018,
-2.000000000000014
],
"max": [
-1923.5,
9.999999999999996,
33.5
]
},
"sizeMm": [
20.5,
35.500000000000014,
35.500000000000014
]
},
{
"name": "Link015",
"label": "3х угловой соединитель003",
"groupGuess": "static-frame",
"triangles": 2716,
"bboxMm": {
"min": [
-1944,
-510,
-2.000000000000014
],
"max": [
-1923.5,
-474.5,
33.5
]
},
"sizeMm": [
20.5,
35.5,
35.500000000000014
]
},
{
"name": "Link016",
"label": "Роликовый узел в сборе002",
"groupGuess": "rollers",
"triangles": 42660,
"bboxMm": {
"min": [
-1479.0000000052858,
-515,
33.449999999999996
],
"max": [
-1398.9999999947142,
35,
104.99515666386074
]
},
"sizeMm": [
80.00000001057151,
550,
71.54515666386075
]
},
{
"name": "Link017",
"label": "Роликовый узел в сборе003",
"groupGuess": "rollers",
"triangles": 42660,
"bboxMm": {
"min": [
-1939.0000000052858,
-515,
33.449999999999996
],
"max": [
-1858.9999999947142,
35,
104.99515666386074
]
},
"sizeMm": [
80.00000001057151,
550,
71.54515666386075
]
},
{
"name": "Body018",
"label": "Лента",
"groupGuess": "conveyor-belt",
"triangles": 1024,
"bboxMm": {
"min": [
-1924.9919187320213,
-500,
53.999999999999986
],
"max": [
46.99191873202125,
2.4e-14,
106.00000000000011
]
},
"sizeMm": [
1971.9838374640426,
500,
52.00000000000013
]
},
{
"name": "Body019",
"label": "Серво привод",
"groupGuess": "pusher-servo",
"triangles": 2580,
"bboxMm": {
"min": [
-867.5,
-547.9999999999999,
110
],
"max": [
-785.5,
-517.9999999999999,
165.2
]
},
"sizeMm": [
82,
30,
55.19999999999999
]
},
{
"name": "Body020",
"label": "Держатель-сервопривода",
"groupGuess": "pusher-servo",
"triangles": 50440,
"bboxMm": {
"min": [
-899.5000000000248,
-548,
14.999999999999844
],
"max": [
-753.4999999999839,
-510,
141.00000000000037
]
},
"sizeMm": [
146.00000000004093,
38,
126.00000000000053
]
},
{
"name": "Body021",
"label": "Держатель серво",
"groupGuess": "pusher-servo",
"triangles": 9516,
"bboxMm": {
"min": [
-851,
-510,
33.497873015872926
],
"max": [
-802,
-490,
63.497873015872926
]
},
"sizeMm": [
49,
20,
30
]
},
{
"name": "Link025",
"label": "серво001",
"groupGuess": "pusher-servo",
"triangles": 59956,
"bboxMm": {
"min": [
-1396.5000000000161,
-9.999999999999877,
14.999999999999844
],
"max": [
-1250.4999999999752,
48.00000000000013,
141.00000000000037
]
},
"sizeMm": [
146.00000000004093,
58.00000000000001,
126.00000000000053
]
},
{
"name": "Link026",
"label": "Серво привод001",
"groupGuess": "pusher-servo",
"triangles": 2580,
"bboxMm": {
"min": [
-1364.5,
17.99999999999995,
110
],
"max": [
-1282.5,
47.99999999999996,
165.2
]
},
"sizeMm": [
82,
30.000000000000007,
55.19999999999999
]
},
{
"name": "Link027",
"label": "Барьер001",
"groupGuess": "stop-gate",
"triangles": 20062,
"bboxMm": {
"min": [
-1871.1197656323916,
-495.1197656323927,
117.99999999999983
],
"max": [
-1333.383347775863,
42.75807358037436,
218.0000000000001
]
},
"sizeMm": [
537.7364178565285,
537.8778392127671,
100.00000000000028
]
},
{
"name": "Link028",
"label": "Крепление камеры001",
"groupGuess": "inspection-frame",
"triangles": 84536,
"bboxMm": {
"min": [
-550,
-538,
13.499999999999993
],
"max": [
-490,
38.000000000000114,
533.5
]
},
"sizeMm": [
60,
576.0000000000001,
520
]
},
{
"name": "Link029",
"label": "Барьер002",
"groupGuess": "stop-gate",
"triangles": 20062,
"bboxMm": {
"min": [
-1374.119765632393,
-542.6166522241368,
117.99999999999983
],
"max": [
-836.2419264196258,
-4.880234367608182,
218.0000000000001
]
},
"sizeMm": [
537.8778392127672,
537.7364178565286,
100.00000000000028
]
}
]
}

View File

@@ -0,0 +1,47 @@
{
"glb": "public/models/sorter/conveyor-web.glb",
"sizeBytes": 555484,
"sha256": "4ccb74e5b92a542cacdb5a322f08c8e23e52c2d582bf8e378be39f40b4c073a8",
"nodeCount": 42,
"groups": [
"motor-and-drive",
"rollers",
"static-frame",
"conveyor-belt",
"pusher-servo",
"stop-gate",
"inspection-frame"
],
"motor": {
"node": "motor-and-drive/NEMA17",
"worldAabbApprox": {
"min": [
-2.062,
0.564,
0.212
],
"max": [
-2.02,
0.606,
0.284
]
},
"causeOfDetachedMotor": "procedural StepperMotor at CONVEYOR_END_X — REMOVED in Stage 2C",
"cadMotorStatus": "on-frame near belt height Y≈0.585"
},
"materialsPolicy": {
"transparent": false,
"opacity": 1,
"depthWrite": true,
"side": "FrontSide"
},
"duplicatesRemoved": [
"StepperMotor procedural"
],
"remainingSpecDerived": [
"BeltSection entry/exit extensions",
"PusherMechanism junction paddle",
"RealSense D435i",
"chutes/cages"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
{"defaultRoute":"/?playback=paused","errors":[],"note":"Spot-check via Playwright load; not a full console scrape matrix"}

View File

@@ -0,0 +1,17 @@
# Deployment — Stage 2C
## Local production
- Container `owl-web-1` on `127.0.0.1:3100` hot-updated with Stage 2C `dist/` (asset `index-CA2EvFI-.js`)
- Preview control: `127.0.0.1:3101` (`vite preview`)
- Previous container/image retained; no rollback deleted
## Public domain
- `sorter.arhipovdan.ru` remains **BLOCKED_BY_DEPLOYMENT_ACCESS** (external A-record / openresty without this vhost)
- Required DNS (owner action): A/AAAA or CNAME pointing to the host that terminates TLS for this app and proxies to `127.0.0.1:3100` (or LAN equivalent)
- Cloudflare tunnel tokens are not published here
## Phone
- NOT_TESTED_ON_REAL_PHONE

View File

@@ -0,0 +1,138 @@
{
"generatedAt": "2026-07-30T12:27:01.613Z",
"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,16 @@
{
"slots": [
"painted-metal",
"brushed-metal",
"rubber-belt",
"dark-mechanical",
"safety-yellow"
],
"opaqueForced": true,
"transparencyAllowed": [
"RealSense glass only (SPEC_DERIVED)",
"laser beam debug"
],
"surfaceOnlyNodes": [],
"note": "FCStd bodies are PartDesign solids; no CAD_SURFACE_ONLY solidify applied"
}

View File

@@ -0,0 +1,23 @@
# Mechanism CAD Map — Stage 2C
## Author CAD nodes (conveer.FCStd → conveyor-web.glb)
| Group | Nodes (labels) | Role | Usable as junction paddle? |
|---|---|---|---|
| stop-gate | Барьер001, Барьер002 | Metering barriers on CAD module (~x CAD −0.8…−1.8 m) | No — wrong location/stroke for B/C/D junction |
| pusher-servo | Серво привод, Держатель-сервопривода, серво001, … | Side-mounted servo + holder on CAD module | Partial — REAL_CAD visual; no proven junction kinematics |
| motor-and-drive | NEMA17, шкивы, ремень | Drive at CAD module | Yes (drive only) |
## Junction sorting (x≈1.5 m, outside CAD module span)
CAD module world span X ≈ [−2.086, −0.076]. Domain junction / paddles live at x≈1.5 → **outside** author CAD solids.
**Decision:** keep `PusherMechanism` (SPEC_DERIVED angled paddle) as default kinematic contact body.
**CAD_MECHANISM_UNUSABLE** for junction paddle: no author solid at junction with matching stroke/face.
CAD `pusher-servo/*` remains visible as REAL_CAD static geometry on the metering module (not hidden).
## Contact events (runtime)
mechanismArmed → contactStarted → peakContactForce → contactEnded → physicsReleased → receiverEntered → itemSettled
(asserted in headless via pusher contact + receiver volumes; see drop-validation.json)

View File

@@ -0,0 +1 @@
{"profile":"B-cad-assembly","note":"No full 30s×3 GPU profile in this session; SwiftShader excluded","status":"NOT_REMEASURED","layoutCanvasHeightRatio":1.0}

View File

@@ -0,0 +1 @@
{"profile":"C-cad-physics","status":"NOT_REMEASURED","physicsTimestep":0.0166667,"runtimeHeadlessHashEqual":true}

View File

@@ -0,0 +1 @@
{"profile":"A-stage2-control","note":"Baseline Stage 2B not re-profiled in Stage 2C session","status":"NOT_REMEASURED"}

View File

@@ -0,0 +1 @@
{"profile":"D-final","status":"PARTIAL","budget":{"avgFpsMin":45,"p95MsMax":28,"drawCallsMax":250,"trisMax":500000},"measured":null,"note":"Formal multi-run GPU profile deferred; unit/e2e/build green"}

View File

@@ -0,0 +1,55 @@
{
"generatedAt": "2026-07-30T12:27:15.737Z",
"runtimeHash": "7ee15ad3e879a44a",
"headlessHash": "7ee15ad3e879a44a",
"equal": true,
"timestepEqual": true,
"timestepSec": 0.016666666666666666,
"snapshot": {
"timestep": 0.016666666666666666,
"gravity": [
0,
-9.81,
0
],
"pusherHalfExtents": [
0.5,
0.14,
0.025
],
"pusherCenterY": 0.842,
"pusherEngage": {
"x": 1.5,
"z": 0
},
"staticColliderCount": 25,
"staticColliderIds": [
"world-floor",
"belt-slab",
"b-spur",
"chute-C-floor",
"chute-C-rail-left",
"chute-C-rail-right",
"chute-D-floor",
"chute-D-rail-left",
"chute-D-rail-right",
"b-bin-floor",
"b-bin-wall-z+",
"b-bin-wall-z-",
"b-bin-wall-x+",
"b-bin-entry-lip",
"b-drop-chute",
"cage-C-floor",
"cage-C-wall-x-",
"cage-C-wall-x+",
"cage-C-wall-back",
"cage-C-entry-sill",
"cage-D-floor",
"cage-D-wall-x-",
"cage-D-wall-x+",
"cage-D-wall-back",
"cage-D-entry-sill"
],
"staticColliderFingerprint": "world-floor|8.00000,0.05000,6.00000|0.00000,-0.05000,0.00000|0.00000,0.00000,0.00000|0.8000;belt-slab|3.17500,0.01200,0.25000|-1.02500,0.68600,0.00000|0.00000,0.00000,0.00000|0.7000;b-spur|0.32500,0.02000,0.22000|1.82500,0.67800,0.00000|0.00000,0.00000,0.00000|0.4000;chute-C-floor|0.50000,0.01000,0.72981|1.70000,0.39000,0.92000|0.37160,0.00000,0.00000|0.2000;chute-C-rail-left|0.01000,0.03500,0.72981|1.19000,0.43500,0.92000|0.37160,0.00000,0.00000|0.3000;chute-C-rail-right|0.01000,0.03500,0.72981|2.21000,0.43500,0.92000|0.37160,0.00000,0.00000|0.3000;chute-D-floor|0.50000,0.01000,0.72981|1.70000,0.39000,-0.92000|-0.37160,0.00000,0.00000|0.2000;chute-D-rail-left|0.01000,0.03500,0.72981|1.19000,0.43500,-0.92000|-0.37160,0.00000,0.00000|0.3000;chute-D-rail-right|0.01000,0.03500,0.72981|2.21000,0.43500,-0.92000|-0.37160,0.00000,0.00000|0.3000;b-bin-floor|0.60000,0.02000,0.40000|3.35000,0.06000,0.00000|0.00000,0.00000,0.00000|0.8000;b-bin-wall-z+|0.60000,0.25000,0.01500|3.35000,0.33000,0.40000|0.00000,0.00000,0.00000|0.6000;b-bin-wall-z-|0.60000,0.25000,0.01500|3.35000,0.33000,-0.40000|0.00000,0.00000,0.00000|0.6000;b-bin-wall-x+|0.01500,0.25000,0.40000|3.95000,0.33000,0.00000|0.00000,0.00000,0.00000|0.6000;b-bin-entry-lip|0.01500,0.04000,0.40000|2.75000,0.12000,0.00000|0.00000,0.00000,0.00000|0.6000;b-drop-chute|0.46755,0.00800,0.21000|2.50000,0.39500,0.00000|0.00000,0.00000,-0.68339|0.2500;cage-C-floor|0.60000,0.04000,0.40000|2.00000,0.04000,2.00000|0.00000,0.00000,0.00000|0.8000;cage-C-wall-x-|0.02000,0.40000,0.40000|1.40000,0.40000,2.00000|0.00000,0.00000,0.00000|0.5000;cage-C-wall-x+|0.02000,0.40000,0.40000|2.60000,0.40000,2.00000|0.00000,0.00000,0.00000|0.5000;cage-C-wall-back|0.60000,0.40000,0.02000|2.00000,0.40000,2.40000|0.00000,0.00000,0.00000|0.5000;cage-C-entry-sill|0.60000,0.02000,0.01500|2.00000,0.10000,1.60000|0.00000,0.00000,0.00000|0.5000;cage-D-floor|0.60000,0.04000,0.40000|2.00000,0.04000,-2.00000|0.00000,0.00000,0.00000|0.8000;cage-D-wall-x-|0.02000,0.40000,0.40000|1.40000,0.40000,-2.00000|0.00000,0.00000,0.00000|0.5000;cage-D-wall-x+|0.02000,0.40000,0.40000|2.60000,0.40000,-2.00000|0.00000,0.00000,0.00000|0.5000;cage-D-wall-back|0.60000,0.40000,0.02000|2.00000,0.40000,-2.40000|0.00000,0.00000,0.00000|0.5000;cage-D-entry-sill|0.60000,0.02000,0.01500|2.00000,0.10000,-1.60000|0.00000,0.00000,0.00000|0.5000"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 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: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 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: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

View File

@@ -0,0 +1,16 @@
# Stage 2C runtime videos
Recorded WebM files are **not** shipped in this drop.
Evidence used instead:
- Headless drop validation `7/7 × 10/10` (`drop-validation.json`) with pusher contact asserts
- Runtime/headless config hash equality (`runtime-headless-config-hash.json`)
- Still screenshots `07-real-sorter-mechanism.png`, `08-mechanism-contact.png`, route finals
To capture locally (preview on :3101):
```bash
# example: use browser MediaRecorder or ffmpeg x11grab against the preview window
```
Do not treat this README as a substitute for visual PASS of contact trajectories.

View File

@@ -0,0 +1,27 @@
# Visual Physics Review — Stage 2C
## Layout
- Canvas heightRatio = 1.0, emptyRatio = 0 (Playwright + CDP)
- White MEASUREMENT (`CVInspectionOverlay`) unmounted on `/`
- Compact HUD (ITEM/STATUS/CATEGORY) retained top-right (allowed)
## Motor
- Cause of detached motor: procedural `StepperMotor` at CONVEYOR_END_X — **removed**
- CAD NEMA17 world AABB ≈ Y 0.56–0.61 (on frame under belt drive) — not floor-hidden
## Mechanism
- Junction paddle: SPEC_DERIVED (CAD lacks junction paddle solid)
- Contact-based C/D handoff unchanged; headless 7/7×10/10 with pusher contact
## Parity
- runtimeHash == headlessHash: 7ee15ad3e879a44a
## Remaining visual debt
- Entry/exit belt still SPEC_DERIVED (modular, not scaled CAD)
- CAD belt segment looks brighter/different from extensions
- Runtime WebM trajectories not recorded in this drop

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
test.setTimeout(90_000);
test.describe('Stage 2C layout + measurement panel', () => {
test('default / has no Measurement panel and canvas fills viewport height', async ({ page }) => {
await page.goto('/?playback=paused');
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await page.waitForFunction(() => !document.body.innerText.includes('Loading 3D'), {
timeout: 60_000,
});
await page.waitForTimeout(500);
await expect(page.locator('.cv-overlay')).toHaveCount(0);
await expect(page.getByTestId('measurement-compact')).toHaveCount(0);
const metrics = await page.evaluate(() => {
const vh = window.innerHeight;
const canvas = document.querySelector('canvas');
const svg = document.querySelector('[data-testid="main-svg-fallback"]');
const scene = canvas ?? svg;
if (!scene) return { ok: false as const, reason: 'no scene' };
const r = scene.getBoundingClientRect();
const heightRatio = r.height / vh;
const emptyBelow = Math.max(0, vh - (r.top + r.height));
const emptyRatio = emptyBelow / vh;
return {
ok: true as const,
heightRatio,
emptyRatio,
sceneTop: r.top,
sceneHeight: r.height,
vh,
};
});
expect(metrics.ok).toBe(true);
if (!metrics.ok) return;
expect(metrics.heightRatio).toBeGreaterThanOrEqual(0.85);
expect(metrics.emptyRatio).toBeLessThanOrEqual(0.2);
});
test('full Measurement panel only with debug=1&measurement=full', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/?debug=1&playback=paused');
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await expect(page.locator('.cv-overlay:not(.cv-overlay-compact)')).toHaveCount(0);
});
});

View File

@@ -35,7 +35,16 @@ function materialFor(slot: string): THREE.MeshStandardMaterial {
'dark-mechanical': { color: '#3a4148', metalness: 0.6, roughness: 0.5 },
'safety-yellow': { color: '#f5b301', metalness: 0.2, roughness: 0.55 },
};
MATERIALS[slot] = new THREE.MeshStandardMaterial(defs[slot] ?? defs['painted-metal']);
const def = defs[slot] ?? defs['painted-metal'];
// Stage 2C §9.1 — opaque machine metals: no accidental alpha / DoubleSide bleed.
MATERIALS[slot] = new THREE.MeshStandardMaterial({
...def,
transparent: false,
opacity: 1,
depthWrite: true,
depthTest: true,
side: THREE.FrontSide,
});
}
return MATERIALS[slot];
}
@@ -102,6 +111,11 @@ export function ConveyorCadModel({
mesh.material = materialFor(slotFor(name));
mesh.castShadow = shadows;
mesh.receiveShadow = shadows;
// Force opaque even if a future GLB embeds transparent materials.
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.transparent = false;
mat.opacity = 1;
mat.depthWrite = true;
if (name.startsWith('stop-gate/') || name.startsWith('rollers/Ролик') || name.startsWith('rollers/Вал')) {
toPivot.push(mesh);
}

View File

@@ -42,14 +42,9 @@ import {
BELT_TOP_Y,
BELT_THICKNESS_M,
CONVEYOR_WIDTH_M,
ROLLER_RADIUS_M,
ROLLER_SPACING_M,
FRAME_HEIGHT_M,
SIDE_GUARD_HEIGHT_M,
LEG_WIDTH_M,
MOTOR_WIDTH_M,
MOTOR_HEIGHT_M,
MOTOR_DEPTH_M,
DRIVE_ROLLER_RADIUS_M,
ZONES,
CAMERA_RIG,
@@ -61,6 +56,11 @@ import {
CAGE_FLOOR_Y,
CONVEYOR_SPEED_MPS,
} from '../../domain/physicalLayout';
import {
CAD_ROLLER_PITCH_M,
CAD_ROLLER_RADIUS_M,
CAD_SUPPORT_SPACING_M,
} from '../../domain/cadAssemblyParams';
import { CHUTE_PITCH, CHUTE_LENGTH, CHUTE_MID_Y, CHUTE_MID_Z, CHUTE_X, CHUTE_HALF_W } from '../../domain/physicsWorldLayout';
import PerfCollector from './PerfCollector';
import PerfOverlay from './PerfOverlay';
@@ -133,8 +133,8 @@ function getItemMaterial(itemId: string) {
// Physical layout constants
const BELT_Y = BELT_TOP_Y; // 0.7m - top of belt where items ride
const BELT_UNDERSIDE_Y = BELT_TOP_Y - BELT_THICKNESS_M; // 0.685m
const ROLLER_Y = BELT_UNDERSIDE_Y - ROLLER_RADIUS_M; // ~0.645m - roller center
const FRAME_TOP_Y = ROLLER_Y - ROLLER_RADIUS_M - 0.02; // Top of frame structure
const ROLLER_Y = BELT_UNDERSIDE_Y - CAD_ROLLER_RADIUS_M;
const FRAME_TOP_Y = ROLLER_Y - CAD_ROLLER_RADIUS_M - 0.02;
const CONVEYOR_START_X = -4.2;
const CONVEYOR_END_X = B_RECEIVER.transferEndX + 0.15; // main belt ends at short B spur tip
/** Max items rendered simultaneously (perf cap). */
@@ -267,12 +267,19 @@ function MotionTrail({
);
}
/** Static roller cylinder — no per-frame rotation (belt stripes show movement). */
/** Static roller — CAD pitch/radius for modular extensions (no non-uniform scale of CAD module). */
function StaticRoller({ position }: { position: [number, number, number] }) {
return (
<mesh position={position} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[ROLLER_RADIUS_M, ROLLER_RADIUS_M, CONVEYOR_WIDTH_M - 0.02, 8]} />
<meshStandardMaterial color={COLORS.rollers} metalness={0.4} roughness={0.5} />
<cylinderGeometry args={[CAD_ROLLER_RADIUS_M, CAD_ROLLER_RADIUS_M, CONVEYOR_WIDTH_M - 0.02, 16]} />
<meshStandardMaterial
color={COLORS.rollers}
metalness={0.85}
roughness={0.32}
transparent={false}
opacity={1}
depthWrite
/>
</mesh>
);
}
@@ -302,56 +309,7 @@ function BeltStripe({ baseOffset, elapsedMs }: { baseOffset: number; elapsedMs:
);
}
/** Stepper motor drive unit with pulse indicator */
function StepperMotor({ position, pulseActive }: { position: [number, number, number]; pulseActive: boolean }) {
const pulseRef = useRef<Mesh>(null);
const rotationRef = useRef(0);
useFrame((_, delta) => {
if (pulseRef.current && pulseActive) {
rotationRef.current += delta * 8;
pulseRef.current.rotation.z = rotationRef.current;
}
});
return (
<group position={position}>
{/* Motor body */}
<mesh position={[0, 0, CONVEYOR_WIDTH_M / 2 + MOTOR_DEPTH_M / 2 + 0.02]}>
<boxGeometry args={[MOTOR_WIDTH_M, MOTOR_HEIGHT_M, MOTOR_DEPTH_M]} />
<meshStandardMaterial color="#475569" metalness={0.6} roughness={0.3} />
</mesh>
{/* Motor shaft - rotates when active */}
<mesh
ref={pulseRef}
position={[0, 0, CONVEYOR_WIDTH_M / 2 + 0.01]}
rotation={[Math.PI / 2, 0, 0]}
>
<cylinderGeometry args={[0.015, 0.015, 0.03, 8]} />
<meshStandardMaterial color="#94a3b8" metalness={0.7} roughness={0.2} />
</mesh>
{/* Drive pulley */}
<mesh position={[0, 0, CONVEYOR_WIDTH_M / 2 - 0.02]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.035, 0.035, 0.025, 12]} />
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} />
</mesh>
{/* Belt to drive roller */}
<mesh position={[0, DRIVE_ROLLER_RADIUS_M / 2, CONVEYOR_WIDTH_M / 2 - 0.02]}>
<boxGeometry args={[0.01, DRIVE_ROLLER_RADIUS_M + 0.02, 0.015]} />
<meshStandardMaterial color="#1e293b" />
</mesh>
{/* Pulse indicator LED */}
<mesh position={[0, MOTOR_HEIGHT_M / 2 - 0.01, CONVEYOR_WIDTH_M / 2 + MOTOR_DEPTH_M + 0.025]}>
<sphereGeometry args={[0.008, 8, 8]} />
<meshStandardMaterial
color={pulseActive ? '#22d3ee' : '#475569'}
emissive={pulseActive ? '#22d3ee' : '#000'}
emissiveIntensity={pulseActive ? 0.8 : 0}
/>
</mesh>
</group>
);
}
/** StepperMotor removed in Stage 2C — CAD motor-and-drive/NEMA17 is the sole drive unit. */
/** Laser beam from rangefinder to item — static opacity, no useFrame. */
function LaserBeam({ active, itemY }: { active: boolean; itemY: number }) {
@@ -467,26 +425,27 @@ function SupportLeg({ x }: { x: number }) {
* Belt top surface at 0.7m (BELT_TOP_Y)
*/
/**
* Belt extension segment (SPEC_DERIVED) — entry/exit sections flanking the
* CAD conveyor module. Same 500 mm width / 700 mm belt top as the real unit.
* Belt extension segment (SPEC_DERIVED modular) — entry/exit flanking the CAD
* module. Uses CAD roller pitch (500 mm) and radius (25 mm); never scales the GLB.
*/
function BeltSection({ startX, endX, simplified, shadows }: { startX: number; endX: number; simplified: boolean; shadows: boolean }) {
const spacing = simplified ? ROLLER_SPACING_M * 2 : ROLLER_SPACING_M;
const spacing = simplified ? CAD_ROLLER_PITCH_M * 2 : CAD_ROLLER_PITCH_M;
const length = endX - startX;
const centerX = (startX + endX) / 2;
const rollerCount = Math.max(1, Math.floor(length / spacing));
const rollerY = BELT_Y - BELT_THICKNESS_M - CAD_ROLLER_RADIUS_M - 0.01;
const rollerPositions = useMemo(() => {
const positions: [number, number, number][] = [];
for (let i = 0; i < rollerCount; i++) {
positions.push([startX + spacing / 2 + i * spacing, ROLLER_Y, 0]);
positions.push([startX + spacing / 2 + i * spacing, rollerY, 0]);
}
return positions;
}, [rollerCount, spacing, startX]);
}, [rollerCount, spacing, startX, rollerY]);
const legPositions = useMemo(() => {
const positions: number[] = [];
for (let x = startX + 0.5; x < endX - 0.3; x += 2.0) {
for (let x = startX + 0.5; x < endX - 0.3; x += CAD_SUPPORT_SPACING_M) {
positions.push(x);
}
return positions;
@@ -496,23 +455,23 @@ function BeltSection({ startX, endX, simplified, shadows }: { startX: number; en
<group>
<mesh position={[centerX, BELT_Y - BELT_THICKNESS_M / 2, 0]} receiveShadow={shadows}>
<boxGeometry args={[length, BELT_THICKNESS_M, CONVEYOR_WIDTH_M]} />
<meshStandardMaterial color={COLORS.belt} roughness={0.85} metalness={0.05} />
<meshStandardMaterial color={COLORS.belt} roughness={0.85} metalness={0.05} transparent={false} opacity={1} depthWrite />
</mesh>
<mesh position={[centerX, BELT_Y + SIDE_GUARD_HEIGHT_M / 2, CONVEYOR_WIDTH_M / 2 + 0.02]}>
<boxGeometry args={[length, SIDE_GUARD_HEIGHT_M, 0.025]} />
<meshStandardMaterial color={COLORS.sideGuards} metalness={0.4} roughness={0.5} />
<meshStandardMaterial color={COLORS.sideGuards} metalness={0.55} roughness={0.45} transparent={false} opacity={1} depthWrite />
</mesh>
<mesh position={[centerX, BELT_Y + SIDE_GUARD_HEIGHT_M / 2, -CONVEYOR_WIDTH_M / 2 - 0.02]}>
<boxGeometry args={[length, SIDE_GUARD_HEIGHT_M, 0.025]} />
<meshStandardMaterial color={COLORS.sideGuards} metalness={0.4} roughness={0.5} />
<meshStandardMaterial color={COLORS.sideGuards} metalness={0.55} roughness={0.45} transparent={false} opacity={1} depthWrite />
</mesh>
<mesh position={[centerX, FRAME_TOP_Y + 0.025, CONVEYOR_WIDTH_M / 2 + 0.01]} castShadow={shadows}>
<boxGeometry args={[length, 0.05, 0.04]} />
<meshStandardMaterial color={COLORS.conveyorFrame} metalness={0.5} roughness={0.4} />
<meshStandardMaterial color={COLORS.conveyorFrame} metalness={0.55} roughness={0.4} transparent={false} opacity={1} depthWrite />
</mesh>
<mesh position={[centerX, FRAME_TOP_Y + 0.025, -CONVEYOR_WIDTH_M / 2 - 0.01]} castShadow={shadows}>
<boxGeometry args={[length, 0.05, 0.04]} />
<meshStandardMaterial color={COLORS.conveyorFrame} metalness={0.5} roughness={0.4} />
<meshStandardMaterial color={COLORS.conveyorFrame} metalness={0.55} roughness={0.4} transparent={false} opacity={1} depthWrite />
</mesh>
{rollerPositions.map((pos, i) => (
<StaticRoller key={i} position={pos} />
@@ -525,17 +484,16 @@ function BeltSection({ startX, endX, simplified, shadows }: { startX: number; en
}
/**
* Conveyor: CAD module (REAL_CAD) + entry/exit belt extensions (SPEC_DERIVED).
* Gate/roller/belt motion is driven by the domain visual-state adapter.
* SorterCadAssembly (inline): sole conveyor geometry source —
* REAL_CAD module + modular SPEC_DERIVED entry/exit at CAD pitch.
* No procedural motor (CAD NEMA17 only).
*/
function ConveyorBelt({ pulseActive, elapsedMs, simplified, shadows = false, gateOpen = false, beltVelocityMps = 0, rollerOmega = 0 }: { pulseActive: boolean; elapsedMs: number; simplified: boolean; shadows?: boolean; gateOpen?: boolean; beltVelocityMps?: number; rollerOmega?: number }) {
function ConveyorBelt({ pulseActive: _pulseActive, elapsedMs, simplified, shadows = false, gateOpen = false, beltVelocityMps = 0, rollerOmega = 0 }: { pulseActive: boolean; elapsedMs: number; simplified: boolean; shadows?: boolean; gateOpen?: boolean; beltVelocityMps?: number; rollerOmega?: number }) {
const stripeOffsets = simplified ? [-2, 0, 2] : [-3, -1, 0.5, 2];
const [cadStart, cadEnd] = CONVEYOR_CAD_SPAN_X;
return (
<group>
{/* CAD-derived conveyor module (real machine: frame, belt, rollers,
drive, metering gates, servo diverters, camera arch) */}
<group name="SorterCadAssembly" userData={{ role: 'single-cad-conveyor' }}>
<Suspense fallback={null}>
<ConveyorCadModel
gateOpen={gateOpen}
@@ -545,39 +503,30 @@ function ConveyorBelt({ pulseActive, elapsedMs, simplified, shadows = false, gat
/>
</Suspense>
{/* Entry extension: zone A feed into the CAD module */}
<BeltSection startX={CONVEYOR_START_X} endX={cadStart} simplified={simplified} shadows={shadows} />
{/* Exit extension: CAD module discharge to the B spur */}
<BeltSection startX={cadEnd} endX={CONVEYOR_END_X} simplified={simplified} shadows={shadows} />
{/* Belt stripes — deterministic movement synced to item (offset = time * 1 m/s) */}
{stripeOffsets.map((offset, i) => (
<BeltStripe key={i} baseOffset={offset} elapsedMs={elapsedMs} />
))}
{/* Drive roller at end (larger) */}
<mesh position={[CONVEYOR_END_X - 0.1, ROLLER_Y, 0]} rotation={[0, 0, Math.PI / 2]}>
<mesh position={[CONVEYOR_END_X - 0.1, BELT_Y - BELT_THICKNESS_M - CAD_ROLLER_RADIUS_M - 0.01, 0]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[DRIVE_ROLLER_RADIUS_M, DRIVE_ROLLER_RADIUS_M, CONVEYOR_WIDTH_M - 0.02, 16]} />
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} />
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} transparent={false} opacity={1} depthWrite />
</mesh>
{/* Tension roller at start (larger) */}
<mesh position={[CONVEYOR_START_X + 0.1, ROLLER_Y, 0]} rotation={[0, 0, Math.PI / 2]}>
<mesh position={[CONVEYOR_START_X + 0.1, BELT_Y - BELT_THICKNESS_M - CAD_ROLLER_RADIUS_M - 0.01, 0]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[DRIVE_ROLLER_RADIUS_M * 0.9, DRIVE_ROLLER_RADIUS_M * 0.9, CONVEYOR_WIDTH_M - 0.02, 16]} />
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} />
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} transparent={false} opacity={1} depthWrite />
</mesh>
{/* Stepper motor at drive end */}
<StepperMotor position={[CONVEYOR_END_X - 0.1, ROLLER_Y, 0]} pulseActive={pulseActive} />
{/* End caps / guards */}
<mesh position={[CONVEYOR_START_X, BELT_Y - 0.05, 0]}>
<boxGeometry args={[0.05, 0.12, CONVEYOR_WIDTH_M + 0.1]} />
<meshStandardMaterial color={COLORS.conveyorFrame} />
<meshStandardMaterial color={COLORS.conveyorFrame} transparent={false} opacity={1} depthWrite />
</mesh>
<mesh position={[CONVEYOR_END_X, BELT_Y - 0.05, 0]}>
<boxGeometry args={[0.05, 0.12, CONVEYOR_WIDTH_M + 0.1]} />
<meshStandardMaterial color={COLORS.conveyorFrame} />
<meshStandardMaterial color={COLORS.conveyorFrame} transparent={false} opacity={1} depthWrite />
</mesh>
</group>
);

View File

@@ -17,8 +17,9 @@ import { useRef, type ReactNode } from 'react';
import { useFrame } from '@react-three/fiber';
import { Physics, RigidBody, CuboidCollider, useRapier } from '@react-three/rapier';
import { getStaticColliders } from '../../domain/physicsWorldLayout';
import { PHYSICS_TIMESTEP_SEC } from '../../domain/physicsTimestep';
export const PHYSICS_DT = 1 / 60;
export const PHYSICS_DT = PHYSICS_TIMESTEP_SEC;
const MAX_SUBSTEPS = 4;
/**

View File

@@ -0,0 +1,45 @@
/**
* Stage 2C — modular CAD conveyor assembly parameters.
*
* Derived from 3d_models/conveer.FCStd (manifest Body and Link bbox mm) and
* OZON Track 3 layout (500 mm belt / 700 mm height). Extensions flanking the
* ~2.01 m CAD module use the same pitch/width/height — never non-uniform scale.
*/
/** Author CAD module length after bake+placement (m). */
export const CAD_MODULE_LENGTH_M = 2.01;
/** CAD roller assembly pitch along the line (profiles / Link005 spacing ~500 mm). */
export const CAD_ROLLER_PITCH_M = 0.5;
/** CAD roller outer radius — Body002 roller diameter 50 mm. */
export const CAD_ROLLER_RADIUS_M = 0.025;
/** Spec / CAD belt width. */
export const CAD_CONVEYOR_WIDTH_M = 0.5;
/** Belt top height from floor. */
export const CAD_BELT_HEIGHT_M = 0.7;
/** Support leg spacing along extensions. */
export const CAD_SUPPORT_SPACING_M = 2.0;
/** Full domain line length (entry A to B spur tip), meters. */
export const CAD_TOTAL_LINE_LENGTH_M = 8.5;
export const CAD_ASSEMBLY_PARAMS = {
segmentLength: CAD_MODULE_LENGTH_M,
rollerPitch: CAD_ROLLER_PITCH_M,
rollerRadius: CAD_ROLLER_RADIUS_M,
conveyorWidth: CAD_CONVEYOR_WIDTH_M,
beltHeight: CAD_BELT_HEIGHT_M,
supportSpacing: CAD_SUPPORT_SPACING_M,
totalLineLength: CAD_TOTAL_LINE_LENGTH_M,
} as const;
/** Structured correction transforms (bake is authoritative; JSX must not invent offsets). */
export const CAD_TRANSFORM_MANIFEST = {
bakeFormula: '(x,y,z)_mm_Zup -> (-x, z, y+250)/1000 Y-up meters',
worldPlacement: [-2.02, 0.594, 0] as [number, number, number],
moduleSpanX: [-2.086, -0.076] as [number, number],
motorNode: 'motor-and-drive/NEMA17',
motorWorldAabbApprox: {
min: [-2.062, 0.564, 0.212],
max: [-2.02, 0.606, 0.284],
note: 'On frame at belt height — not under floor. Detached motor was procedural StepperMotor.',
},
} as const;

View File

@@ -0,0 +1,89 @@
/**
* Stage 2C §11.1 — machine-readable runtime ↔ headless physics config hash.
* Both paths must consume the same static colliders, pusher geometry, dt, gravity.
* Hash is pure-JS (no Node crypto) so the module stays isomorphic.
*/
import { getStaticColliders } from './physicsWorldLayout';
import { PUSHER } from './pusherMotion';
import { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep';
export { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep';
export interface PhysicsConfigSnapshot {
timestep: number;
gravity: [number, number, number];
pusherHalfExtents: [number, number, number];
pusherCenterY: number;
pusherEngage: { x: number; z: number };
staticColliderCount: number;
staticColliderIds: string[];
staticColliderFingerprint: string;
}
function fingerprintColliders(): string {
const defs = getStaticColliders();
return defs
.map((d) =>
[
d.id,
d.halfExtents.map((n) => n.toFixed(5)).join(','),
d.position.map((n) => n.toFixed(5)).join(','),
d.rotation.map((n) => n.toFixed(5)).join(','),
d.friction.toFixed(4),
].join('|'),
)
.join(';');
}
/** FNV-1a 64-bit (as hex) — deterministic, isomorphic. */
function fnv1aHex(input: string): string {
let h = 0xcbf29ce484222325n;
const prime = 0x100000001b3n;
for (let i = 0; i < input.length; i++) {
h ^= BigInt(input.charCodeAt(i));
h = (h * prime) & 0xffffffffffffffffn;
}
return h.toString(16).padStart(16, '0');
}
export function buildPhysicsConfigSnapshot(timestep: number): PhysicsConfigSnapshot {
const defs = getStaticColliders();
return {
timestep,
gravity: PHYSICS_GRAVITY,
pusherHalfExtents: [...PUSHER.halfExtents] as [number, number, number],
pusherCenterY: PUSHER.centerY,
pusherEngage: { x: PUSHER.engageX, z: PUSHER.engageZ },
staticColliderCount: defs.length,
staticColliderIds: defs.map((d) => d.id),
staticColliderFingerprint: fingerprintColliders(),
};
}
export function hashPhysicsConfig(snapshot: PhysicsConfigSnapshot): string {
return fnv1aHex(JSON.stringify(snapshot));
}
export function getRuntimePhysicsConfigHash(): string {
return hashPhysicsConfig(buildPhysicsConfigSnapshot(PHYSICS_TIMESTEP_SEC));
}
export function getHeadlessPhysicsConfigHash(headlessTimestep = PHYSICS_TIMESTEP_SEC): string {
return hashPhysicsConfig(buildPhysicsConfigSnapshot(headlessTimestep));
}
export function assertRuntimeHeadlessParity(headlessTimestep = PHYSICS_TIMESTEP_SEC): {
runtime: string;
headless: string;
equal: boolean;
timestepEqual: boolean;
} {
const runtime = getRuntimePhysicsConfigHash();
const headless = getHeadlessPhysicsConfigHash(headlessTimestep);
return {
runtime,
headless,
equal: runtime === headless,
timestepEqual: headlessTimestep === PHYSICS_TIMESTEP_SEC,
};
}

View File

@@ -21,8 +21,9 @@ import { getPusherState, PUSHER } from './pusherMotion';
import { receiverContains, type ReceiverZone } from './receiverVolumes';
import { resolveItem } from '../data/resolveItem';
import { BELT_TOP_Y } from './physicalLayout';
import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep';
export const SIM_DT = 1 / 60;
export const SIM_DT = PHYSICS_TIMESTEP_SEC;
export const SIM_SETTLE_SECONDS = 6.0;
let rapierReady = false;

View File

@@ -0,0 +1,3 @@
/** Shared fixed physics timestep — runtime Rapier and headless must match. */
export const PHYSICS_TIMESTEP_SEC = 1 / 60;
export const PHYSICS_GRAVITY: [number, number, number] = [0, -9.81, 0];

View File

@@ -0,0 +1,52 @@
/**
* Stage 2C — unit tests for CAD assembly params + runtime/headless physics hash.
*/
import { describe, expect, it } from 'vitest';
import {
CAD_ASSEMBLY_PARAMS,
CAD_TRANSFORM_MANIFEST,
CAD_ROLLER_PITCH_M,
CAD_CONVEYOR_WIDTH_M,
CAD_BELT_HEIGHT_M,
} from './cadAssemblyParams';
import {
assertRuntimeHeadlessParity,
PHYSICS_TIMESTEP_SEC,
buildPhysicsConfigSnapshot,
} from './physicsConfigHash';
import { SIM_DT } from './physicsDropSim';
describe('Stage 2C CAD assembly params', () => {
it('preserves official belt width and height', () => {
expect(CAD_CONVEYOR_WIDTH_M).toBe(0.5);
expect(CAD_BELT_HEIGHT_M).toBe(0.7);
expect(CAD_ASSEMBLY_PARAMS.rollerPitch).toBe(CAD_ROLLER_PITCH_M);
});
it('documents motor on-frame AABB (not under floor)', () => {
const aabb = CAD_TRANSFORM_MANIFEST.motorWorldAabbApprox;
expect(aabb.min[1]).toBeGreaterThan(0.4);
expect(aabb.max[1]).toBeLessThan(0.8);
});
});
describe('Stage 2C runtime/headless physics parity', () => {
it('headless SIM_DT matches canonical timestep', () => {
expect(SIM_DT).toBe(PHYSICS_TIMESTEP_SEC);
});
it('hashes match for runtime and headless snapshots', () => {
const parity = assertRuntimeHeadlessParity(SIM_DT);
expect(parity.timestepEqual).toBe(true);
expect(parity.equal).toBe(true);
expect(parity.runtime).toBe(parity.headless);
expect(parity.runtime).toMatch(/^[a-f0-9]{16}$/);
});
it('snapshot includes static colliders and pusher geometry', () => {
const snap = buildPhysicsConfigSnapshot(PHYSICS_TIMESTEP_SEC);
expect(snap.staticColliderCount).toBeGreaterThan(5);
expect(snap.pusherHalfExtents[0]).toBe(0.5);
expect(snap.gravity).toEqual([0, -9.81, 0]);
});
});

View File

@@ -241,7 +241,16 @@ export default function MainPage({
const caseProgress = getCaseProgress(playback);
const measurementData = useMemo(() => getMeasurementData(playback), [playback]);
// Stage 2C §6: large white MEASUREMENT panel is debug-only.
// Default / — never mount. ?debug=1 — compact. ?debug=1&measurement=full — full panel.
const measurementMode = useMemo(() => {
if (typeof window === 'undefined') return 'off' as const;
const params = new URLSearchParams(window.location.search);
if (params.get('debug') !== '1') return 'off' as const;
return params.get('measurement') === 'full' ? 'full' as const : 'compact' as const;
}, []);
const showMeasurement =
measurementMode !== 'off' &&
!presentationMode &&
shouldShowMeasurement(playback.currentPhase) &&
(isRunning || isPaused) &&
@@ -306,9 +315,29 @@ export default function MainPage({
)}
</div>
{width >= 768 && (
{width >= 768 && measurementMode === 'full' && (
<CVInspectionOverlay data={measurementData} visible={showMeasurement} />
)}
{width >= 768 && measurementMode === 'compact' && showMeasurement && (
<div className="cv-overlay cv-overlay-compact" data-testid="measurement-compact">
<div className="cv-header">
<span className="cv-title">MEAS</span>
<span className="cv-status active">{Math.round((measurementData.confidence ?? 0) * 100)}%</span>
</div>
<div className="cv-body">
<div className="cv-row cv-compact">
<span className="cv-label">L×W×H</span>
<span className="cv-value cv-mono">
{measurementData.measuredLengthMm}×{measurementData.measuredWidthMm}×{measurementData.measuredHeightMm}
</span>
</div>
<div className="cv-row cv-compact">
<span className="cv-label">K</span>
<span className="cv-value cv-mono">{measurementData.roundnessK.toFixed(2)}</span>
</div>
</div>
</div>
)}
{!presentationMode && (!stage0.enabled || stage0.hud) && (
<div className="main-hud" data-testid="demo-hud">

View File

@@ -2046,9 +2046,12 @@ button:disabled {
.main-page {
position: relative;
width: 100vw;
height: 100vh;
height: 100dvh;
height: 100vh; /* fallback */
min-height: 100dvh;
overflow: hidden;
background: var(--bg);
padding-bottom: env(safe-area-inset-bottom, 0);
}
.main-demo-viewport {
@@ -2057,11 +2060,26 @@ button:disabled {
z-index: 1;
}
.main-demo-viewport .digital-twin-wrap {
/* Stage 2C §7 — canvas-holder must fill viewport; % height of auto parent was collapsing Canvas. */
.main-demo-viewport .canvas-holder {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 100%;
}
.main-demo-viewport .digital-twin-wrap {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
}
.main-demo-viewport .digital-twin-canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
min-height: 100% !important;
border: none;
@@ -2544,7 +2562,7 @@ button:disabled {
left: 16px;
z-index: 15;
width: 220px;
max-height: calc(100vh - 220px);
max-height: calc(100dvh - 220px);
overflow-y: auto;
border: 1px solid rgba(37, 99, 235, 0.2);
border-radius: 10px;
@@ -2553,6 +2571,16 @@ button:disabled {
box-shadow: 0 2px 16px rgba(37, 99, 235, 0.12);
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 10px;
pointer-events: auto;
}
.cv-overlay-compact {
width: 160px;
top: 88px;
left: auto;
right: 16px;
max-height: 120px;
pointer-events: none;
}
.cv-header {