55 Commits

Author SHA1 Message Date
root
f2ce611330 fix: align CV rules and refresh final documentation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:51:41 +02:00
root
d701339930 release: finalize clean submission repository
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:39:46 +02:00
root
21e83be26e release: consolidate web simulation and real CV prototype
Add cleaned RealSense/OpenCV CV under cv/ with secret-free example
config, MQTT disabled by default, and canonical README coverage for
web + CV relationship without claiming production integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:23:05 +02:00
root
3777fecacf docs: finalize submission materials
Document domain, launch/build commands, structure, env vars, cloud split,
physics contracts of the accepted baseline, and CV on drho1y-mvp_1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:13:30 +02:00
root
bb76963902 revert: restore last working simulation (13ce16b)
Roll back junction-contact / discharge / light-theme simulation changes
to the previously stable production tree while keeping linear history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:11:16 +02:00
root
496162a3db docs: finalize submission materials
Document domain, launch/build commands, structure, env vars, cloud split,
and the real CV prototype on drho1y-mvp_1 for the final submission.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:48 +02:00
Даня Архипов
6e3b6ae3f0 fix: align discharge sorting and camera classification
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 07:56:35 +00:00
Даня Архипов
30a9f7efb3 fix: synchronize product visuals and apply Ozon light theme
Stop React RigidBody props from fighting Rapier mesh sync on playback ticks, and convert the shell/scene to the centralized Ozon light palette.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 07:09:17 +00:00
Даня Архипов
dce7faee24 feat: add physical junction contact routing
Replace scripted junction handoff with kinematic CAD diverter colliders and contact-only B/C/D routing, validated by a 45-run engineering-derived matrix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 06:46:10 +00:00
Даня Архипов
1586e9d2ab feat: add stable physical conveyor foundation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 05:52:28 +00:00
Даня Архипов
13ce16bedc chore: consolidate documentation and clean repository
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 18:29:15 +00:00
Даня Архипов
4413f01ce4 chore: preserve pre-cleanup product baseline
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:48:00 +00:00
Даня Архипов
c0dedfcdfb docs: finalize Stage 2E report with commit identity
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:29:03 +00:00
Даня Архипов
30103ccefc feat: Stage 2E physics p95, runtime videos, domain audit, device-test QR
Measure Rapier world.step p95 (~0.2 ms) on GTX 1080, record real route WebMs,
expand runtime/headless hash parity, and document permanent-domain/phone blockers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:28:14 +00:00
Даня Архипов
014a46077d feat: Stage 2D hardware perf PASS, SPEC_DERIVED_CAD mechanism, device-test
Measure GTX 1080 matrix (A/B/C 60 FPS p95~17ms); selective CAD shadows and
DPR 1.25; ship SPEC_DERIVED_CAD sorter mechanism GLB/STEP; add /device-test;
document domain blocker and phone NOT_TESTED.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:01:44 +00:00
Даня Архипов
68327635ce 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>
2026-07-30 12:35:50 +00:00
Даня Архипов
0a1be376eb fix: restore default nginx MIME types so HTML/JS are not downloaded
A server-level types{} block replaced mime.types, so index/JS were served
as application/octet-stream; with nosniff the browser saved them as files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 12:16:49 +00:00
Даня Архипов
acc59ec912 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>
2026-07-30 12:00:09 +00:00
Даня Архипов
40e9b18e8d feat: stage 2 real CAD conveyor, hybrid physics drops, mobile policy (WIP)
- CAD pipeline: conveer.FCStd -> FreeCAD headless -> conveyor-web.glb
  (555KB Draco, 42 named nodes) integrated into main scene; gates and
  rollers animated from domain state
- Domain-driven adapter sorterVisualState.ts; per-SKU visual physics
  profiles (VISUAL_PHYSICS_ESTIMATE)
- Hybrid Rapier physics: kinematic belt travel -> dynamic drop handoff
  -> verified freeze; shared collider layout (physicsWorldLayout) used
  by runtime and headless validation sim (physicsDropSim + 13 tests)
- Gravity chutes / open roll-cage front redesign (drop tuning in progress:
  4/7 routes verified headless)
- Premium industrial visual: PBR, ACES, PCFSoft shadows, Lightformer env,
  UI cleanup behind ?debug=1
- Mobile capability policy: SVG fallback / Mobile Low / full tiers,
  Telegram WebView forced to SVG, e2e stubs for weak devices
- Performance benchmarks cad-light/physics/full pass; stage2 artifacts

tsc clean. Physics drop validation for 3 SKU routes still in progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 21:05:46 +00:00
Даня Архипов
394c513037 chore: checkpoint premium 3d stages 0 and 1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:05:27 +00:00
Даня Архипов
5cc27c6fc2 chore: add unique assets from other branches into dan_branch
Bring hardware/spec content from drho1y-mvp_1 (3d_models, arduino_code, backend_control, kicad, specification) so dan_branch holds the shared union of branch files without rewriting other branch tips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:32:56 +00:00
Даня Архипов
060971da75 docs: re-verify production identity and domain cutover status
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 08:43:37 +00:00
Даня Архипов
982f85dae1 chore: sync production release markers for 4fcce5b
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 22:22:23 +00:00
Даня Архипов
4fcce5bff9 ops: finalize production domain and hardware benchmark tooling
Add /version.json build identity, deploy 16e7930 release markers,
DNS/Named Tunnel prep docs, portable browser benchmark, and replay
stability probe. Keep Quick Tunnel until permanent domain cutover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 22:21:13 +00:00
Даня Архипов
16e7930304 fix: stabilize public demo and align sorter twins
Document arhipovdan.ru DNS/TLS blocker, add production smoke and GPU
perf tooling, unify twin layout, and expand visual regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 18:00:25 +00:00
Даня Архипов
b5985f33bb feat: ship production deploy, Playwright e2e, and agent implementer
Add Docker atomic deploy tooling, CI, Playwright smoke/visual suite,
shared industrial theme, physics invariants, quality-mode wiring,
and a worktree-isolated agent implementer MVP with hard safety limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 17:14:01 +00:00
Даня Архипов
985f7c327d feat: maximize sorter demo realism and add safe agent MVP
Wire live classifyItem into continuous playback, add jam/E-stop cases,
presenter controls (seek/speed/hotkeys), quality modes, demo scripts,
audit docs, and a verify-only autonomous agent CLI with hard safety limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 16:53:35 +00:00
root
e89c728dd2 perf(3d): static rollers, lite mode, B receiver bin timing, clean console 2026-07-09 18:39:59 +02:00
root
8e9ae4f62b fix(3d): unified conveyorNetwork, physical B receiver, C/D containment, belt sync, real STL 2026-07-09 17:18:32 +02:00
root
1528cfce10 fix: deterministic physical item motion in 3D demo
Add physicalItemMotion as the single source of item pose, synchronize belt/item speed at 1 m/s, route items through chutes, keep C/D items settled inside roll-cages, render historical settled items, and use STL models with explicit fallbacks.
2026-07-09 16:37:38 +02:00
root
39dc11c773 polish: final visual refinements for 3D demo
- Matte PVC belt material (roughness 0.85)
- Soft natural lighting with shadows
- Industrial color palette
- Compact HUD and CV overlay
- No UI overlap
- Items cast shadows for depth
2026-07-09 16:03:42 +02:00
root
a078d296e3 feat: cinematic camera playback for 3D demo
- Add cinematicCamera.ts with 9 camera modes
- Smooth camera transitions with lerp
- Camera follows item during movement
- Category-aware routing views (B/C/D)
- Viewport adaptation (desktop/laptop/mobile)
- Motion trail for movement feedback
- Auto camera toggle button
- 20 unit tests for camera logic
2026-07-09 15:53:50 +02:00
root
717a992b98 fix: physical realism for 3D demo
- Load STL models with fallback to primitives
- True physical scale (1 unit = 1 meter)
- Sensor rig at 1.35m above max item
- Roll-cages for C/D zones (1.2×0.8×0.8m)
- Chutes for physical routing
- HUD/overlay repositioned to avoid overlap
- Added physicalLayout tests
2026-07-09 15:39:07 +02:00
root
3aad908aaf feat: engineering measurement architecture in 3D demo
- Stepper motor length measurement (step counting)
- Laser rangefinder height measurement
- Stereo camera width/shape analysis
- PLC decision logic with C-priority
- Actuator pusher animation
- Full measurement overlay with live data
2026-07-09 14:35:58 +02:00
root
fa770f9da9 fix: physical conveyor realism - belt at 0.7m, item rides ON belt
- Create physicalLayout.ts with all dimensions
- Update conveyorPath.ts to use correct belt height
- Rebuild ConveyorBelt with support legs, rollers, drive motor
- Item Y = BELT_TOP_Y + itemHeight/2
2026-07-09 14:21:00 +02:00
root
35138c824c feat: add CV inspection overlay with visual effects
- Add CVInspectionOverlay component (industrial camera monitor style)
- Add inspectionViewModel adapter for playback → inspection data
- Add camera rig with overhead frame and laser emitters
- Add inspection zone highlight on belt
- Add animated scan line during detection
- Add bounding box wireframe during measurement
- Add shape outline (circle/square) during classification
- Show dimensions pass/fail, roundness warning, confidence warning
- Show C-priority indicator for edge cases

Tested on https://arhipovdan.ru/ — all phases display correctly
2026-07-09 14:07:50 +02:00
root
d9bf861960 fix: add nginx SPA fallback for BrowserRouter routing
Root cause: Docker container was 4 days old and nginx had no
try_files fallback for SPA routes like /details.

Changes:
- Add nginx.conf with try_files $uri $uri/ /index.html
- Update Dockerfile to copy nginx config
- Add production browser QA script

Production verified:
- https://arhipovdan.ru/ shows full-screen 3D demo
- https://arhipovdan.ru/details shows detailed page
2026-07-09 13:46:51 +02:00
root
ae2f38d6cf feat: implement continuous 8-case playback engine for main page
- Add continuousPlayback.ts with phase-based state machine
- Add conveyorPath.ts for physics-accurate item positioning (1m=1unit, 1m/s)
- Create SorterDigitalTwinContinuous.tsx for main page 3D scene
- Update MainPage to use ContinuousPlaybackState
- Add 16 unit tests for playback logic
- Add Playwright test script for browser QA
- Screenshots: case1_start, case1_detection, case1_routing, case2_started, case8_or_finished

Timeline per case: ~6.3s (spawn → detection → classification → routing → exit)
Total playback: ~50s for all 8 cases
2026-07-09 13:41:24 +02:00
root
05029e6f51 feat: add routing with MainPage (full-screen 3D) and DetailsPage
- Add react-router-dom for routing
- Create MainPage with full-screen 3D demo viewport
- Create DetailsPage with all existing documentation blocks
- Create demoPlaylist.ts with 8 showcase cases
- Add styles for main page HUD, controls, progress bar
- Routes: / → MainPage, /details → DetailsPage, * → redirect to /
2026-07-09 13:27:13 +02:00
root
600699f155 fix: prove 3D item movement and update visual fix report
- Document movement proof via manual demo steps
- Item moves from zone A to zone B through full cycle:
  Detection → Classification → Decision → Command → Routing
- Add README to docs/visual_fix_screenshots/
- Update report with final verification results

Verified: build, 43 tests, docker, curl all OK.
3D works on 1440px laptop, movement visually confirmed.
2026-07-04 20:10:57 +02:00
root
4ffafa699a fix: improve 3D digital twin visual clarity
- Increase item visual scale ×2.5 with glow ring
- Move camera closer [4.0, 3.0, 4.0]
- Improve scene contrast (brighter conveyor, floor)
- Add glow to active routes
- Enhance gate/pusher visibility
- Add highlight rings to active zones
- Brighten sensors with detection effects
- Update visual fix report with final verification

Based on REAL_3D_AUDIT_REPORT.md P0/P1 fixes.
Verified: build, 43 tests, docker, curl all OK.
3D works on 1440px laptop (not 2D fallback).
2026-07-04 20:01:39 +02:00
root
f9eb0819c0 fix: improve 3D digital twin visual clarity
- Increase item visual scale ×2.5 with glow ring
- Move camera closer [4.0, 3.0, 4.0]
- Improve scene contrast (brighter conveyor, floor)
- Add glow to active routes
- Enhance gate/pusher visibility
- Add highlight rings to active zones
- Brighten sensors with detection effects

Based on REAL_3D_AUDIT_REPORT.md P0/P1 fixes.
2026-07-04 19:48:57 +02:00
root
29035dad5f docs: add honest 3D verification checklist and update QA report
CHANGES:
1. README.md:
   - Added "3D Verification Checklist" section
   - Important note: must click "3D Digital Twin" toggle first
   - Desktop Chrome manual verification steps
   - Mobile verification checklist

2. BLACK_SCREEN_FIX_REPORT.md:
   - Updated Visual QA section with detailed test steps
   - Test 1-5: Initial load → 2D fallback → 3D toggle → Auto demo in 3D
   - Clarified: Browser MCP verified both 2D and 3D modes
   - Removed false claims, added honest limitations

VERIFIED (Browser MCP):
-  2D fallback works
-  3D scene renders (WebGL available, FPS ~60)
-  Auto demo runs in 3D mode
-  NO BLACK SCREEN in both modes

NOT VERIFIED (Requires manual check):
- ⚠️ Desktop Chrome real device
- ⚠️ Mobile real device (<640px)
- ⚠️ STL model loading in production

CONCLUSION:
Black screen fix is solid (ErrorBoundary + throttled RAF works).
3D rendering confirmed via browser MCP.
Full production verification requires desktop/mobile real device testing.
2026-07-04 17:15:38 +02:00
root
ad2898200b fix: stabilize 3D auto demo and clean scene labels
ROOT CAUSE:
- requestAnimationFrame loop calling setState 60fps caused Canvas unmount/remount every frame
- Circular dependency in useEffect deps (simulation.currentItem) caused infinite re-render loop
- No ErrorBoundary meant Canvas errors showed black screen
- "Накопитель" label cluttered clean view

FIXES:
1. Throttled RAF Loop (App.tsx):
   - Update UI only every 100ms instead of every 16ms (60fps)
   - Removed circular dependency from useEffect deps
   - Added refs for proper RAF cleanup
   - Canvas no longer remounts every frame

2. ErrorBoundary (ThreeErrorBoundary.tsx):
   - Created error boundary for Canvas
   - Shows fallback UI instead of black screen
   - "Reload 3D" and "Use 2D Fallback" buttons
   - Auto-triggers 2D fallback on error

3. Clean Labels (SceneLabels3D.tsx):
   - Moved "Накопитель" label to technical mode only
   - Cleaner default 3D view
   - Less clutter, easier to see simulation

RESULT:
- NO MORE BLACK SCREEN 
- Stable auto demo 
- Clean 3D scene 
- Proper error handling 

STATUS:
- Build:  TypeScript successful (4.14s)
- Tests:  43/43 passed (1.19s)
- Docker:  Rebuilt successfully (8.08s)
- Domains:  All 200 OK
- Visual QA:  Verified via browser MCP (no black screen!)

CHANGES:
- Modified: App.tsx (+114), ProductDemoSection.tsx (+29), SceneLabels3D.tsx (+4), styles.css, README.md
- Created: ThreeErrorBoundary.tsx (new error boundary component)
- Total: +215 insertions, -24 deletions (5 files modified, 1 file created)
2026-07-04 17:09:47 +02:00
root
3e1f10a01a feat: 3D digital twin major rework - real models, clean labels, improved geometry
- Real STL Models: 6 models loaded (55%), fallback for 5 (45%)
- Clean Labels: 12+ → 5 default, step-based, technical toggle
- Geometry: Gate lift, pusher plates, conveyor rollers, roll-cage wireframes
- Demo Director: 12 steps, camera presets, auto-play logic (not integrated)
- Tests: +16 new (43/43 green), Build , Docker , Domains 

Remaining: Auto demo UI integration, camera transitions, docs updates, Visual QA
2026-07-04 16:45:43 +02:00
root
a700a146a3 fix: final parameter verification and source of truth documentation
- Add exact references to official specification (page/section/line) in INPUT_INFO_ANALYSIS.md
- Fix last hardcoded min dimensions in SceneLabels3D.tsx (10×10×10 → dynamic from DIMENSION_LIMITS)
- Fix hardcoded roundness threshold in Item3D.tsx (0.8 → dynamic from DIMENSION_LIMITS)
- Update THREE_D_FEASIBILITY.md checklist to reflect current parameters (10×10×2, K≥0.7)

Source of truth confirmed:
- Min dimensions: 10×10×2 mm (Постановка_Задача_3_сжато_2.pdf, стр. 2/17, строка 81)
- Roundness threshold: K ≥ 0.7 (Постановка_Задача_3_сжато_2.pdf, стр. 7/17, строка 245)
- Max dimensions: 450×320×320 mm (Постановка_Задача_3_сжато_2.pdf, стр. 2/17, строка 82)
- Conveyor speed: 1.0 м/с (Постановка_Задача_3_сжато_2.pdf, стр. 4/17, строка 188)

Tested: build OK, tests 16/16 passed, Docker OK, domains OK, smoke test OK
2026-07-04 16:12:12 +02:00
root
17c37c2580 feat: align project with OZON Track 3 official specification
- Fix critical parameters: min dimensions 10×10×2 mm (was 10×10×10)
- Fix roundness threshold: K ≥ 0.7 (was 0.8)
- Add input_info analysis document (INPUT_INFO_ANALYSIS.md)
- Update Hero: new title, badges (Min 10×10×2, K≥0.7→D, C priority), proof-line
- Add CurrentProofCard summary block: Category/Command/Target/Why (cyan border)
- Add CPriorityExplanation component (orange card, dedicated section)
- Update all docs: README, DEMO_SCRIPT (7-min timing), JURY_QA, SUBMISSION_CHECKLIST
- Update tests: 16/16 passed under new parameters
- Update 3D twin: dynamic DIMENSION_LIMITS display
- Update criteria cards: roundness ≥ 0.7
- Tested: build OK, Docker OK, domains OK (arhipovdan.ru, www, ai-shorts.ru)
2026-07-04 13:09:49 +02:00
root
e0a655db4f feat: align 3D digital twin with OZON Track 3 requirements 2026-07-04 12:39:57 +02:00
root
b749cef14e feat: add 3D digital twin visualization 2026-07-04 12:33:20 +02:00
root
a7025ca50c design: redesign product demo UI 2026-07-04 12:14:08 +02:00
root
cb38cd0002 design: redesign product demo UI 2026-07-04 01:44:32 +02:00
root
2d50cdb454 feat: add guided demo view 2026-07-04 01:21:59 +02:00
deploy
f3e2d16059 feat: add presentation demo mode 2026-07-04 01:09:10 +02:00
deploy
77069748ee feat: enhance sorter simulation demo 2026-07-04 01:02:01 +02:00
deploy
9395d25332 feat: add sorter simulation dashboard MVP 2026-07-04 00:26:08 +02:00
146 changed files with 201115 additions and 0 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
dist
.git
npm-debug.log
.DS_Store
.agent
releases
e2e
playwright-report
test-results
agent/reports
agent/state
docs
*.md
.git

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
push:
branches: [feature/**, dan_branch, main]
pull_request:
jobs:
build-test:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Start preview
run: |
npx vite preview --host 127.0.0.1 --port 3101 &
for i in $(seq 1 30); do curl -sf http://127.0.0.1:3101/ && break; sleep 1; done
- name: E2E smoke
run: npm run test:e2e
env:
PLAYWRIGHT_BASE_URL: http://127.0.0.1:3101
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: |
playwright-report/
test-results/
if-no-files-found: ignore

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
node_modules/
dist/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
playwright.config.js
playwright.config.d.ts
.env
.env.*
public/version.json
npm-debug.log*
test-results/
playwright-report/
blob-report/
.demo-preview.pid
.demo-preview.log
__pycache__/
*.pyc
cv/.venv/
cv/debug_frames/
cv/logs/
cv/config.yaml

2
3d_models/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.FCBak
/export

BIN
3d_models/conveer.FCStd Normal file

Binary file not shown.

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# .git is dockerignored — pass identity from host deploy script
ARG BUILD_COMMIT=unknown
ARG BUILD_BRANCH=unknown
ARG BUILD_RELEASE=unknown
ENV VITE_BUILD_COMMIT=$BUILD_COMMIT \
VITE_BUILD_BRANCH=$BUILD_BRANCH \
VITE_BUILD_RELEASE=$BUILD_RELEASE
RUN npm run build
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

179
README.md
View File

@@ -0,0 +1,179 @@
# OWL PRIME — Ozon Tech Track 3
## О проекте
OWL PRIME — инженерный контур предварительной сортировки товаров: веб-цифровой двойник конвейерной линии, классификация B/C/D, физическая маршрутизация в симуляции, рабочий CV-прототип на RealSense D415 и экспериментальный физический стенд.
Это не сертифицированный промышленный ПАК, а воспроизводимое решение хакатона: цифровой twin на проде, CV-прототип в репозитории, CAD/ассеты и документация для проверки судьями.
## Состав решения
1. **Web digital twin** — React/Three.js симуляция конвейера, измерения, classifier, diverters.
2. **CV-прототип**`cv/`: RealSense D415 + OpenCV → габариты / круг → B/C/D.
3. **CAD и runtime 3D** — авторский FreeCAD и GLB/STL для деплоя.
4. **Физический стенд** — лента, рама, камера над полотном, электроника/SCADA.
5. **Документация**`/documentation`, `docs/ENGINEERING.md`.
6. **Презентация**`presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf`.
## Демо
**Production:** https://arhipovdan.ru
| Route | Назначение |
|---|---|
| `/` | Непрерывная симуляция |
| `/documentation` | Инженерный статус |
| `*` | Редирект на `/` |
- **Desktop:** интерактивный WebGL 3D.
- **Mobile:** облегчённый 2D fallback (как в текущем baseline).
Идентичность сборки: `/version.json`.
## Основные возможности
- непрерывный CAD-конвейер и STL-товары;
- цифровой этап камеры / измерения;
- rule-based classifier B/C/D;
- CAD-дивертеры LEFT/RIGHT (45° / +45°);
- физика Rapier (лента 1 м/с);
- CV-прототип depth→B/C/D (не в live web);
- инженерная документация в приложении.
## Правила классификации
Официальные границы (`official_sources/doc-1783095831.pdf`), реализованы в web (`src/domain/classifier.ts`) и CV (`cv/classify.py`):
1. Габариты строго **> 10×10×10 мм** и **< 450×320×320 мм**, иначе → **C**.
2. Если габариты OK и **K > 0.8** (круг) → **D**.
3. Иначе → **B**.
4. **C-priority:** негабарит + круг → только **C**.
5. Граница: **K = 0.8 не круг** (→ B, не D). Одинаково в web и CV.
## Архитектура
**Web**
```
Product → measurement (digital) → classifier → route → twin → B/C/D receiver
```
**CV**
```
RealSense D415 → depth → segmentation → L×W×H + K → B/C/D → optional MQTT
```
CV **не подключён** к https://arhipovdan.ru. Общее — домен правил B/C/D, не live-канал кадров.
## Технологии
| Слой | Стек |
|---|---|
| Web | React, TypeScript, Three.js / R3F, Rapier, Vite, Vitest, Playwright |
| CV | Python, OpenCV, RealSense D415 (V4L2), optional MQTT (`paho-mqtt`) |
## Структура репозитория
```
.github/ CI
3d_models/ Авторский CAD (conveer.FCStd)
cv/ CV-прототип RealSense + OpenCV
docs/ Engineering notes
e2e/ Playwright smoke/routes
input_info/ Официальные входные пакеты
official_sources/ PDF с границами classifier
presentation/ Финальная презентация (один PDF)
public/ Runtime GLB/STL/draco
src/ Web twin
```
Конфиги деплоя: `Dockerfile`, `docker-compose.server.yml`, `nginx.conf`, `package.json`.
## Запуск web
Node.js 20+.
```bash
npm ci
npm run dev # http://127.0.0.1:3100
npm test -- --run
npm run build
npm run preview # http://127.0.0.1:3100
```
## Запуск CV
Python 3.10+. Live-режим требует Intel RealSense D415 и ffmpeg.
```bash
cd cv
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp config.example.yaml config.yaml # MQTT выключен по умолчанию
# без камеры:
python test_classify.py
python test_geometry.py
# с камерой:
./demo.sh # HUD http://127.0.0.1:8080/
./run.sh --preview --no-mqtt --no-motor
```
Подробности: [`cv/README.md`](cv/README.md). `npm` CV-зависимости не ставит.
## Конфигурация
- `.env` **не** коммитится.
- Web: опционально `VITE_BUILD_COMMIT` / `VITE_BUILD_BRANCH` / `VITE_BUILD_RELEASE` для `/version.json`.
- CV: `cv/config.example.yaml` → локальный `cv/config.yaml` (gitignored). MQTT/motor/routing **disabled by default**. Секреты не хранить в Git.
## CAD и модели
| Файл | Роль |
|---|---|
| `3d_models/conveer.FCStd` | Авторский CAD |
| `public/models/sorter/conveyor-clean.glb` | Runtime конвейер |
| `public/models/*.stl` | Модели товаров |
Крупные материалы для сдачи дополнительно зеркалируйте в облако; runtime-ассеты для деплоя остаются в Git.
## Физика (текущий main)
- скорость ленты **1.0 м/с**;
- timestep **1/60 с**;
- CCD для лёгких/тонких тел;
- diverters 45° / +45°;
- полный contact-only junction sorting **не fully validated**.
## Проверка
```bash
npm ci && npm test -- --run && npm run build
# E2E: preview :3101 + e2e/routes.spec.ts + e2e/smoke.spec.ts
cd cv && python -m compileall . && python test_classify.py && python test_geometry.py
```
Актуальный релиз: unit **196/196**, build PASS, focused E2E PASS, CV compile + unit без камеры PASS. Прод: `/` и `/documentation` → 200.
## Ограничения
- инженерный прототип, не industrial-certified ПАК;
- CV не live-интегрирован в web;
- live CV требует D415; на CI — только unit/compile;
- параметры симуляции требуют калибровки на стенде;
- mobile — 2D lite fallback;
- облачная ссылка на доп. материалы — по решению владельца.
## Материалы
- Презентация: [`presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf`](presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf)
- Production: https://arhipovdan.ru
- Cloud folder: `[ДОБАВИТЬ ССЫЛКУ]`
## Статус
**`main` — каноническое полное решение.** Другие ветки исторические и не нужны для запуска web или CV.

11
cv/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.pyc
debug_frames/
logs/
*.jpg
*.png
.pytest_cache/
config.yaml
.env
.env.*

23
cv/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Vision classifier for Intel RealSense D415 on Orange PI (aarch64)
FROM python:3.11-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgl1 \
libv4l-0 \
v4l-utils \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Камера пробрасывается через docker-compose (--device)
CMD ["python", "main.py", "-c", "config.yaml"]

177
cv/README.md Normal file
View File

@@ -0,0 +1,177 @@
# Real CV prototype — RealSense D415 + OpenCV
**Status:** WORKING_PROTOTYPE
**Production integrated:** NO (https://arhipovdan.ru does **not** consume this pipeline)
**Source:** consolidated from branch `drho1y-mvp_1` (`vision_classifier/`) into `cv/`
Same Track 3 B/C/D rules as the web twin; different input path (real depth camera vs simulated sensor).
## Purpose
Measure parcels on a conveyor with an Intel RealSense D415 (depth + color), estimate L×W×H and circularity, classify into zones **B / C / D**, optionally publish results over MQTT for hardware routing.
## Data flow
```
RealSense D415 (V4L2 depth + color)
→ OpenCV segmentation on depth (optional RGB flat detect)
→ measure L×W×H + circle_ratio
→ stabilize (median / vote → LOCK)
→ classify B/C/D
→ optional MQTT (category, dimensions, servo/motor topics)
```
## Entrypoints (start here)
| Command | Role |
|---|---|
| `./demo.sh` | Browser HUD demo on `:8080` (needs camera for live view) |
| `./run.sh --preview` | Live pipeline with JPEG preview frames |
| `./run.sh --once --no-mqtt --no-motor` | Single-shot / dry hardware |
| `.venv/bin/python test_classify.py` | Classifier unit checks **without camera** |
| `.venv/bin/python test_geometry.py` | Geometry helpers **without camera** |
Primary modules: `main.py` (live), `demo.py` (HUD), `classify.py` (rules), `measure.py` (depth metrics), `camera.py` (V4L2 RealSense).
## Classification rules (Track 3)
1. Dimensions must be strictly **> 10×10×10 mm** and **< 450×320×320 mm** → else **C**
2. Else if `circle_ratio > 0.8`**D**
(`K == 0.8` is **not** circular — same strict rule as web `classifier.ts`)
3. Else → **B**
Stabilization: median window + vote → **LOCK**. Uncertain cases fall back to zone **C** after N frames.
## File structure
```
cv/
main.py # live pipeline entry
demo.py / demo.sh # browser demo
run.sh # venv bootstrap + main.py
camera.py # RealSense via V4L2 + ffmpeg depth
measure.py # segmentation + dimensions
classify.py # B/C/D rules
stabilize.py # temporal LOCK
mqtt_bridge.py # optional MQTT (disabled by default)
calibrate.py # fx/fy + belt height calibration
align_color.py # RGB↔depth alignment helper
tracker.py # multi-object tracking assist
journal.py # decisions JSONL writer
demo_hud.py # HUD rendering
collect_log.py # log helper
test_classify.py # no-camera tests
test_geometry.py # no-camera tests
config.example.yaml # safe defaults (commit)
config.yaml # local only (gitignored)
requirements.txt
Dockerfile / docker-compose.yml
```
## Dependencies
**Software**
- Python **3.10+** (3.11 recommended; Docker image uses 3.11)
- `opencv-python-headless`, `numpy`, `PyYAML`, `pillow`, `paho-mqtt` — see `requirements.txt`
- System: **ffmpeg**, V4L2 (`v4l-utils` useful)
**Hardware (live mode)**
- Intel **RealSense D415** on USB3
- Linux host with `/dev/video*` depth+color nodes (Orange PI / x86)
`npm` / Node packages are **not** used here.
## Installation
```bash
cd cv
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
cp config.example.yaml config.yaml # optional; scripts auto-copy
```
Or simply:
```bash
cd cv
./demo.sh # creates .venv and config.yaml on first run
```
## Demo / tests without claiming live camera
Classifier and geometry (no RealSense required):
```bash
cd cv
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python test_classify.py
.venv/bin/python test_geometry.py
python3 -m compileall .
```
Live HUD (requires D415):
```bash
./demo.sh
# open http://127.0.0.1:8080/
```
Live pipeline:
```bash
./run.sh --preview --no-mqtt --no-motor
# or full hardware once MQTT/routing configured in local config.yaml:
./run.sh --preview
```
## Configuration
| File | Role |
|---|---|
| `config.example.yaml` | Committed safe defaults; **MQTT/motor/routing disabled** |
| `config.yaml` | Local overrides — **gitignored**; never commit credentials |
Optional MQTT (enable only locally):
```yaml
mqtt:
enabled: true
broker: "127.0.0.1"
port: 1883
user: "<your-user>"
password: "<your-password>"
```
CLI overrides: `--no-mqtt`, `--no-motor`, `--dry-route`, `--once`, `--preview`.
## Output schema (LOCK)
- Zone: `B` | `C` | `D`
- Dimensions mm: L×W×H
- `circle_ratio`
- Optional MQTT topics (when enabled): `vision/feedback/category`, `…/dimensions`, `…/circle_ratio`
- Optional JSONL: `logs/decisions.jsonl` (local, gitignored)
## Limitations
- Not connected to the web digital twin runtime.
- Requires calibrated intrinsics / belt height for accurate mm.
- Live demo needs a physical D415; CI hosts usually lack it.
- MQTT/servo/motor path is optional and site-specific.
## Troubleshooting
| Symptom | Check |
|---|---|
| No `/dev/video*` | USB3, `lsusb`, `v4l2-ctl --list-devices` |
| Depth empty | ffmpeg installed; correct `depth_device` |
| Wrong sizes | run `calibrate.py --length … --width …` |
| MQTT offline | expected when `mqtt.enabled: false` |
## Relation to web twin
Web (`src/domain/classifier.ts`) and CV (`classify.py`) implement the **same official bounds**. The public site uses a **digital sensor simulation**; this folder is the **hardware prototype** for future integration.

139
cv/align_color.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Автоподбор совмещения RGB↔depth для демо (сенсоры D415 разнесены).
Положите на ленту коробку с чёткими краями и запустите:
.venv/bin/python align_color.py
Скрипт ищет сдвиг (dx, dy) и масштаб цветного кадра, при которых края
на RGB совпадают с краями на карте глубины, и пишет результат в config.yaml.
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from pathlib import Path
import cv2
import numpy as np
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from camera import RealSenseV4L2
from demo_hud import align_color
def _edges_depth(depth_mm: np.ndarray) -> np.ndarray:
d = depth_mm.astype(np.float32)
d = cv2.medianBlur(d.astype(np.uint16), 5).astype(np.float32)
valid = d > 0
if valid.sum() < 1000:
return np.zeros(depth_mm.shape, np.uint8)
lo, hi = np.percentile(d[valid], [2, 98])
norm = np.clip((d - lo) / max(hi - lo, 1.0) * 255.0, 0, 255).astype(np.uint8)
return cv2.Canny(norm, 30, 90)
def _edges_color(color_bgr: np.ndarray) -> np.ndarray:
gray = cv2.cvtColor(color_bgr, cv2.COLOR_BGR2GRAY)
# тёмные сцены: выравниваем контраст перед Canny
gray = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(gray)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
return cv2.Canny(gray, 40, 120)
def _score(color_edges: np.ndarray, depth_band: np.ndarray, dx: float, dy: float, s: float) -> float:
warped = align_color(color_edges[..., None].repeat(3, axis=2), dx, dy, s)[..., 0]
return float(np.count_nonzero((warped > 0) & (depth_band > 0)))
def main() -> int:
parser = argparse.ArgumentParser(description="Совмещение RGB и depth")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
args = parser.parse_args()
cfg_path = Path(args.config)
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
cam_cfg = cfg["camera"]
print("[align] открываю камеру… на ленте должна лежать коробка с чёткими краями")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
use_color=True,
)
try:
# прогрев RGB: первые кадры бывают пустыми, плюс автоэкспозиция
for _ in range(30):
cam.read()
time.sleep(0.05)
depth_acc, color_acc = [], []
for _ in range(15):
pair = cam.read()
if pair is not None and not pair.color_is_depth_preview:
depth_acc.append(pair.depth_mm.astype(np.float32))
color_acc.append(pair.color_bgr.astype(np.float32))
time.sleep(0.06)
if len(color_acc) < 3:
print("[align] RGB не читается — проверьте use_color/USB")
return 1
depth = np.median(np.stack(depth_acc), axis=0).astype(np.uint16)
color = np.clip(np.mean(np.stack(color_acc), axis=0), 0, 255).astype(np.uint8)
de = _edges_depth(depth)
if np.count_nonzero(de) < 500:
print("[align] мало краёв на depth — положите коробку в центр кадра")
return 1
band = cv2.dilate(de, cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)))
ce = _edges_color(color)
# грубый перебор → уточнение
best = (0.0, 0.0, 1.0)
best_s = -1.0
for s in np.arange(0.90, 1.16, 0.05):
for dx in range(-80, 81, 8):
for dy in range(-60, 61, 8):
sc = _score(ce, band, dx, dy, float(s))
if sc > best_s:
best_s, best = sc, (float(dx), float(dy), float(s))
bdx, bdy, bs = best
for s in np.arange(bs - 0.04, bs + 0.045, 0.01):
for dx in np.arange(bdx - 8, bdx + 9, 2):
for dy in np.arange(bdy - 8, bdy + 9, 2):
sc = _score(ce, band, float(dx), float(dy), float(s))
if sc > best_s:
best_s, best = sc, (float(dx), float(dy), float(s))
base = _score(ce, band, 0, 0, 1.0)
dx, dy, s = best
print(f"[align] лучшее совмещение: dx={dx:.0f} dy={dy:.0f} scale={s:.2f} "
f"(совпадение краёв {best_s:.0f} против {base:.0f} без коррекции)")
text = cfg_path.read_text(encoding="utf-8")
text = re.sub(r"(?m)^(\s*color_dx:\s*)-?[\d.]+", rf"\g<1>{dx:.1f}", text)
text = re.sub(r"(?m)^(\s*color_dy:\s*)-?[\d.]+", rf"\g<1>{dy:.1f}", text)
text = re.sub(r"(?m)^(\s*color_scale:\s*)-?[\d.]+", rf"\g<1>{s:.3f}", text)
cfg_path.write_text(text, encoding="utf-8")
print(f"[align] записано в {cfg_path}")
# контрольная картинка
out = Path("debug_frames"); out.mkdir(exist_ok=True)
from camera import depth_colormap
aligned = align_color(color, dx, dy, s)
vis = cv2.addWeighted(aligned, 0.6, depth_colormap(depth), 0.4, 0)
cv2.imwrite(str(out / "align_check.jpg"), vis)
print(f"[align] проверка: debug_frames/align_check.jpg")
finally:
cam.release()
return 0
if __name__ == "__main__":
raise SystemExit(main())

188
cv/calibrate.py Normal file
View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Калибровка камеры для точных габаритов (правила ТЗ: >10×10×10, <450×320×320 мм).
Два шага:
1) пустая лента → высота belt_distance_mm;
2) коробка известного размера в центре → фокусное fx=fy.
Запуск (пример для коробки 300×200 мм, высотой ≥ 30 мм):
.venv/bin/python calibrate.py --length 300 --width 200
Результат пишется прямо в config.yaml (fx, fy, belt_distance_mm).
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from pathlib import Path
import cv2
import numpy as np
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from camera import RealSenseV4L2
from measure import segment_object
def _wait_key(prompt: str) -> None:
"""Ждёт одиночное нажатие: 1 — продолжить, q — выйти (Enter не нужен)."""
print(prompt + " [1 — продолжить, q — выйти]", flush=True)
if not sys.stdin.isatty():
# stdin не терминал (пайп/IDE) — читаем строку
line = sys.stdin.readline().strip().lower()
if line.startswith("q"):
raise KeyboardInterrupt
return
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch in ("1", "\r", "\n"): # Enter тоже принимаем на всякий случай
return
if ch in ("q", "Q", "\x03"): # q или Ctrl+C
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _collect_belt(cam: RealSenseV4L2, samples: int = 25) -> float:
vals = []
for _ in range(samples):
pair = cam.read()
if pair is None:
time.sleep(0.05)
continue
d = pair.depth_mm
h, w = d.shape
roi = d[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4]
valid = roi[(roi > 200) & (roi < 4000)]
if valid.size > 100:
vals.append(float(np.median(valid)))
time.sleep(0.04)
if not vals:
raise RuntimeError("Не вижу ленту: проверьте, что камера на 0.51.5 м над поверхностью")
return float(np.median(vals))
def _collect_focal(
cam: RealSenseV4L2,
belt_mm: float,
known_length_mm: float,
known_width_mm: float,
samples: int = 40,
) -> float:
"""fx=fy по площади minAreaRect в пикселях: f = z * sqrt(S_px / S_mm)."""
focals = []
for _ in range(samples):
pair = cam.read()
if pair is None:
time.sleep(0.05)
continue
seg = segment_object(pair.depth_mm, belt_distance_mm=belt_mm, min_area_px=400)
if seg is None:
time.sleep(0.04)
continue
mask, contour = seg
ys, xs = np.where(mask > 0)
z = pair.depth_mm[ys, xs].astype(np.float32)
z = z[z > 0]
if z.size < 100:
continue
z_med = float(np.median(z))
rect = cv2.minAreaRect(contour)
pw, ph = rect[1]
if pw < 10 or ph < 10:
continue
f = z_med * float(np.sqrt((pw * ph) / (known_length_mm * known_width_mm)))
focals.append(f)
time.sleep(0.04)
if len(focals) < 10:
raise RuntimeError(
f"Стабильно вижу коробку только в {len(focals)} кадрах из {samples}. "
"Коробка должна быть высотой ≥ 30 мм и лежать в центре кадра."
)
return float(np.median(focals))
def _patch_config(path: Path, fx: float, belt_mm: float) -> None:
text = path.read_text(encoding="utf-8")
text = re.sub(r"(?m)^(\s*fx:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
text = re.sub(r"(?m)^(\s*fy:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
text = re.sub(r"(?m)^(belt_distance_mm:\s*)[\d.]+", rf"\g<1>{belt_mm:.0f}", text)
path.write_text(text, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Калибровка fx/fy и высоты ленты")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
parser.add_argument("--length", type=float, required=True, help="Длина коробки, мм (рулеткой)")
parser.add_argument("--width", type=float, required=True, help="Ширина коробки, мм (рулеткой)")
parser.add_argument("--yes", action="store_true", help="Не ждать Enter (сцена уже готова на каждом шаге)")
args = parser.parse_args()
cfg_path = Path(args.config)
if not cfg_path.exists():
example = cfg_path.with_name("config.example.yaml")
if not example.exists():
raise SystemExit(f"Config not found: {cfg_path}")
cfg_path = example
print(f"[calib] using {cfg_path.name} (copy to config.yaml before saving results)")
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
cam_cfg = cfg["camera"]
print("[calib] открываю RealSense D415…")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=False,
)
try:
if not args.yes:
_wait_key("[calib] Шаг 1/2: УБЕРИТЕ всё с ленты")
belt_mm = _collect_belt(cam)
print(f"[calib] высота до ленты: {belt_mm:.0f} мм")
if not args.yes:
_wait_key(
f"[calib] Шаг 2/2: положите коробку {args.length:.0f}×{args.width:.0f} мм "
"в центр кадра"
)
time.sleep(1.0)
fx = _collect_focal(cam, belt_mm, args.length, args.width)
old_fx = float(cam_cfg.get("fx", 0))
print(f"[calib] фокусное fx=fy: {fx:.1f} (было {old_fx:.1f})")
if old_fx > 0:
k = fx / old_fx
print(f"[calib] габариты со старым fx были завышены/занижены в {k:.2f} раза")
_patch_config(cfg_path, fx, belt_mm)
print(f"[calib] записано в {cfg_path}: fx=fy={fx:.1f}, belt_distance_mm={belt_mm:.0f}")
print("[calib] проверьте: .venv/bin/python demo.py — размеры LWH должны совпадать с рулеткой")
except KeyboardInterrupt:
print("\n[calib] отменено, config.yaml не изменён")
return 1
finally:
cam.release()
return 0
if __name__ == "__main__":
raise SystemExit(main())

373
cv/camera.py Normal file
View File

@@ -0,0 +1,373 @@
"""Захват Depth (+опционально Color) с Intel RealSense D415 через V4L2/ffmpeg.
Классификация по ТЗ опирается на depth (габариты + круг в сечении).
RGB у D415 через сырой V4L2 часто пустой без librealsense —
тогда для превью используется colorize(depth).
"""
from __future__ import annotations
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from typing import Optional, Union
import cv2
import numpy as np
@dataclass
class FramePair:
color_bgr: np.ndarray
depth_mm: np.ndarray # uint16, миллиметры
timestamp_ms: float
color_is_depth_preview: bool = False
def _device_path(device: Union[str, int]) -> str:
if isinstance(device, int) or str(device).isdigit():
return f"/dev/video{int(device)}"
return str(device)
def _v4l2_index(device: Union[str, int]) -> int:
if isinstance(device, int):
return device
s = str(device).strip()
if s.isdigit():
return int(s)
if "video" in s:
return int(s.rsplit("video", 1)[-1])
raise ValueError(f"Некорректный V4L2 device: {device}")
def find_realsense_color_device(preferred: Union[str, int, None] = None) -> Optional[str]:
"""Найти RGB-ноду D415 (YUYV). Номера /dev/videoN плавают после переподключения."""
import glob
import os
def _formats(path: str) -> str:
try:
return subprocess.check_output(
["v4l2-ctl", "-d", path, "--list-formats-ext"],
stderr=subprocess.DEVNULL,
text=True,
timeout=2,
)
except (OSError, subprocess.SubprocessError):
return ""
preferred_path = _device_path(preferred) if preferred is not None else ""
scored: list[tuple[int, str]] = []
for path in sorted(glob.glob("/dev/video*")):
if not os.path.exists(path):
continue
fmt = _formats(path)
if "Z16" in fmt or "'GREY'" in fmt or "Greyscale" in fmt:
continue
score = 2 if ("YUYV" in fmt or "MJPG" in fmt or "Motion-JPEG" in fmt) else 0
if path == preferred_path:
score += 5
scored.append((score, path))
scored.sort(key=lambda x: (-x[0], x[1]))
for _, path in scored:
try:
idx = _v4l2_index(path)
except ValueError:
continue
cap = cv2.VideoCapture(idx, cv2.CAP_V4L2)
if not cap.isOpened():
continue
ok_frame = None
for _ in range(12):
ok, frame = cap.read()
if not ok or frame is None:
continue
if frame.ndim == 2:
break
if frame.ndim == 3 and frame.shape[2] == 2:
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_YUY2)
if frame.ndim == 3 and float(np.mean(frame)) > 8.0 and float(np.std(frame)) > 5.0:
ok_frame = frame
break
cap.release()
if ok_frame is not None:
return path
return None
def fill_depth_holes(depth_mm: np.ndarray, ksize: int = 5) -> np.ndarray:
"""Простое заполнение дыр в depth."""
d = depth_mm.copy()
mask = (d > 0).astype(np.uint8) * 255
if mask.mean() < 1:
return d
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
holes = ((closed > 0) & (d == 0)).astype(np.uint8) * 255
if holes.any():
scale = max(float(d.max()), 1.0)
img8 = np.clip(d.astype(np.float32) / scale * 255.0, 0, 255).astype(np.uint8)
filled8 = cv2.inpaint(img8, holes, 3, cv2.INPAINT_TELEA)
filled = (filled8.astype(np.float32) / 255.0 * scale).astype(np.uint16)
d[holes > 0] = filled[holes > 0]
med = cv2.medianBlur(d, 3)
valid = d > 0
d[valid] = med[valid]
return d
def depth_colormap(depth_mm: np.ndarray, max_mm: Optional[int] = None) -> np.ndarray:
valid = depth_mm[(depth_mm > 0) & (depth_mm < 10000)]
if max_mm is None:
max_mm = int(np.percentile(valid, 95)) if valid.size else 2000
max_mm = max(max_mm, 500)
clipped = np.clip(depth_mm.astype(np.float32), 0, max_mm)
norm = np.zeros_like(clipped, dtype=np.uint8)
mask = depth_mm > 0
norm[mask] = (clipped[mask] / max_mm * 255.0).astype(np.uint8)
return cv2.applyColorMap(norm, cv2.COLORMAP_JET)
class RealSenseV4L2:
"""D415: depth=/dev/video0 (Z16 gray16le). Color опционален."""
def __init__(
self,
depth_device: Union[str, int] = "/dev/video0",
color_device: Union[str, int] = "/dev/video4",
width: int = 640,
height: int = 480,
fps: int = 30,
depth_scale_mm: float = 1.0,
use_color: bool = True,
) -> None:
if shutil.which("ffmpeg") is None:
raise RuntimeError("Нужен ffmpeg для чтения depth Z16 с RealSense")
self.depth_scale_mm = float(depth_scale_mm)
self.width = int(width)
self.height = int(height)
self.fps = int(fps)
self.use_color = bool(use_color)
self._frame_bytes = self.width * self.height * 2
self.color_cap = None
self._depth_path = _device_path(depth_device)
self._lock = threading.Lock()
self._latest: Optional[np.ndarray] = None
self._stop = threading.Event()
self._ff: Optional[subprocess.Popen] = None
self._thread: Optional[threading.Thread] = None
self._start_depth_worker()
if self.use_color:
found = find_realsense_color_device(color_device)
if found is None:
print(f"[camera] RGB не найден (искали {color_device}) — в вебе будет colorize(depth)")
self.color_cap = None
else:
if _device_path(found) != _device_path(color_device):
print(f"[camera] RGB: {found} (в конфиге было {color_device})")
else:
print(f"[camera] RGB: {found}")
color_idx = _v4l2_index(found)
self.color_cap = cv2.VideoCapture(color_idx, cv2.CAP_V4L2)
if self.color_cap.isOpened():
self.color_cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
self.color_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
self.color_cap.set(cv2.CAP_PROP_FPS, self.fps)
self.color_cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
# прогрев автоэкспозиции — иначе первые кадры чёрные/зелёные
for _ in range(20):
self.color_cap.read()
else:
print("[camera] RGB VideoCapture не открылся")
self.color_cap = None
# Ждём первый кадр
deadline = time.time() + 5.0
while time.time() < deadline:
with self._lock:
if self._latest is not None:
break
time.sleep(0.05)
else:
self.release()
raise RuntimeError(
f"Не удалось читать depth с {self._depth_path}. "
"Проверьте USB3, что камера не занята другим процессом."
)
valid_pct = float(((self._latest > 0) & (self._latest < 5000)).mean() * 100)
print(f"[camera] depth OK, valid≈{valid_pct:.1f}% (лучше >30%; высота камеры 0.51.5 м)")
def _start_depth_worker(self) -> None:
self._ff = subprocess.Popen(
[
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-f",
"v4l2",
"-video_size",
f"{self.width}x{self.height}",
"-framerate",
str(self.fps),
"-pixel_format",
"gray16le",
"-i",
self._depth_path,
"-f",
"rawvideo",
"-pix_fmt",
"gray16le",
"-",
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=self._frame_bytes * 8,
)
self._thread = threading.Thread(target=self._depth_loop, name="rs-depth", daemon=True)
self._thread.start()
def _depth_loop(self) -> None:
assert self._ff is not None and self._ff.stdout is not None
while not self._stop.is_set():
raw = self._ff.stdout.read(self._frame_bytes)
if not raw or len(raw) != self._frame_bytes:
if self._ff.poll() is not None:
break
continue
depth = np.frombuffer(raw, dtype="<u2").reshape(self.height, self.width).copy()
depth[depth == 65535] = 0
if self.depth_scale_mm != 1.0:
depth = np.clip(depth.astype(np.float32) * self.depth_scale_mm, 0, 65535).astype(np.uint16)
depth = fill_depth_holes(depth)
with self._lock:
self._latest = depth
def _read_color(self, depth_mm: np.ndarray) -> tuple[np.ndarray, bool]:
if self.color_cap is not None:
ok, color = self.color_cap.read()
if ok and color is not None:
if color.shape[:2] != (self.height, self.width):
color = cv2.resize(color, (self.width, self.height), interpolation=cv2.INTER_LINEAR)
if color.ndim == 3 and color.shape[2] == 2:
color = cv2.cvtColor(color, cv2.COLOR_YUV2BGR_YUY2)
elif color.ndim == 2:
color = cv2.cvtColor(color, cv2.COLOR_GRAY2BGR)
# пустой YUYV-кадр после конвертации — ровный зелёный (mean>5,
# но вариации нет) → проверяем и разброс пикселей
if float(np.mean(color)) > 5.0 and float(np.std(color)) > 4.0:
return color, False
return depth_colormap(depth_mm), True
def read(self) -> Optional[FramePair]:
with self._lock:
depth = None if self._latest is None else self._latest.copy()
if depth is None:
return None
color, is_preview = self._read_color(depth)
return FramePair(
color_bgr=color,
depth_mm=depth,
timestamp_ms=time.time() * 1000.0,
color_is_depth_preview=is_preview,
)
def capture_background(self, samples: int = 15) -> np.ndarray:
"""Медианная карта глубины пустой сцены (лента + платформы/борта).
Позволяет сегментировать товар на неровном фоне и не сливать его
с накопителем: объект = то, что ближе фона на min_object_height_mm.
"""
frames = []
deadline = time.time() + 12.0
while len(frames) < samples and time.time() < deadline:
pair = self.read()
if pair is not None:
frames.append(pair.depth_mm.astype(np.float32))
time.sleep(0.04)
if len(frames) < max(3, samples // 3):
raise RuntimeError("Не удалось накопить кадры для фоновой карты")
stack = np.stack(frames)
stack[stack <= 0] = np.nan
bg = np.nanmedian(stack, axis=0)
return np.nan_to_num(bg, nan=0.0).astype(np.uint16)
def capture_background_rgb(self, samples: int = 10) -> Optional[np.ndarray]:
"""Усреднённый RGB-кадр пустой сцены — для детекции плоских товаров
(телефон и т.п.), которые не видны в depth."""
frames = []
deadline = time.time() + 8.0
while len(frames) < samples and time.time() < deadline:
pair = self.read()
if pair is not None and not pair.color_is_depth_preview:
frames.append(pair.color_bgr.astype(np.float32))
time.sleep(0.04)
if len(frames) < 3:
return None
return np.clip(np.mean(np.stack(frames), axis=0), 0, 255).astype(np.uint8)
def estimate_belt_distance_mm(self, samples: int = 30) -> float:
vals = []
h, w = self.height, self.width
rois = [
(h // 2 - 40, h // 2 + 40, w // 2 - 60, w // 2 + 60),
(h // 3 - 30, h // 3 + 30, w // 3 - 40, w // 3 + 40),
(2 * h // 3 - 30, 2 * h // 3 + 30, 2 * w // 3 - 40, 2 * w // 3 + 40),
(h // 4, 3 * h // 4, w // 4, 3 * w // 4),
]
for _ in range(samples):
pair = self.read()
if pair is None:
time.sleep(0.03)
continue
for y0, y1, x0, x1 in rois:
roi = pair.depth_mm[y0:y1, x0:x1]
valid = roi[(roi > 200) & (roi < 4000)]
if valid.size >= 50:
vals.append(float(np.median(valid)))
break
else:
valid = pair.depth_mm[(pair.depth_mm > 200) & (pair.depth_mm < 4000)]
if valid.size >= 50:
vals.append(float(np.median(valid)))
time.sleep(0.03)
if not vals:
raise RuntimeError(
"Не удалось оценить belt_distance_mm. "
"Поставьте камеру на 0.51.5 м над лентой (USB3), задайте belt_distance_mm в config.yaml вручную."
)
return float(np.median(vals))
def release(self) -> None:
self._stop.set()
if self.color_cap is not None:
self.color_cap.release()
self.color_cap = None
if self._ff is not None and self._ff.poll() is None:
self._ff.terminate()
try:
self._ff.wait(timeout=2)
except subprocess.TimeoutExpired:
self._ff.kill()
self._ff = None
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
def __enter__(self) -> "RealSenseV4L2":
return self
def __exit__(self, *args) -> None:
self.release()

148
cv/classify.py Normal file
View File

@@ -0,0 +1,148 @@
"""Классификация строго по правилам ТЗ трека 3."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Sequence, Tuple
from measure import ObjectMeasurement
class Category(str, Enum):
SUITABLE = "suitable" # Подходит для сортировки → B
OVERSIZE = "oversize" # Не подходит по габаритам → C
NEED_PACK = "need_pack" # Не подходит без доупаковки → D
@property
def zone(self) -> str:
return {
Category.SUITABLE: "B",
Category.OVERSIZE: "C",
Category.NEED_PACK: "D",
}[self]
@property
def ru_label(self) -> str:
return {
Category.SUITABLE: "Подходит для сортировки",
Category.OVERSIZE: "Не подходит для сортировки по габаритам",
Category.NEED_PACK: "Не подходит для сортировки без доупаковки",
}[self]
@property
def short_label(self) -> str:
"""Короткая метка для HUD и веб-статуса."""
return {
Category.SUITABLE: "ГОТОВ К СОРТИРОВКЕ",
Category.OVERSIZE: "НЕГАБАРИТ",
Category.NEED_PACK: "ТРЕБУЕТ ДОУПАКОВКИ",
}[self]
@dataclass
class ClassificationResult:
category: Category
dims_sorted_mm: Tuple[float, float, float]
circle_ratio: float
passes_size: bool
is_circular: bool
reason: str
def _sorted_dims(length: float, width: float, height: float) -> Tuple[float, float, float]:
a, b, c = sorted([float(length), float(width), float(height)], reverse=True)
return a, b, c
def check_size(
dims_sorted: Sequence[float],
min_mm: Sequence[float],
max_mm: Sequence[float],
) -> bool:
"""
ТЗ: габариты строго больше минимума и строго меньше максимума
по сопоставленным сторонам после сортировки.
"""
min_s = sorted([float(x) for x in min_mm], reverse=True)
max_s = sorted([float(x) for x in max_mm], reverse=True)
d = [float(x) for x in dims_sorted]
return all(d[i] > min_s[i] for i in range(3)) and all(d[i] < max_s[i] for i in range(3))
def classify(
measurement: ObjectMeasurement,
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
circle_ratio_threshold: float = 0.8,
) -> ClassificationResult:
"""
Порядок ТЗ:
1) габариты → иначе C (приоритет над кругом)
2) если r_in/r_out > 0.8 в любом сечении → D
(K == 0.8 НЕ круг — как в web classifier.ts)
3) иначе → B
"""
dims = _sorted_dims(measurement.length_mm, measurement.width_mm, measurement.height_mm)
passes = check_size(dims, min_mm, max_mm)
ratio = float(measurement.circle_ratio)
circular = ratio > float(circle_ratio_threshold)
clipped = bool(getattr(measurement, "clipped_by_frame", False))
if not passes or clipped:
reason = (
"объект обрезан краем кадра → габарит неполный, считаем негабаритом"
if clipped and passes
else "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм"
)
if clipped and not passes:
reason = "габариты вне допуска (в т.ч. обрезан кадром): нужно >10×10×10 и <450×320×320 мм"
return ClassificationResult(
category=Category.OVERSIZE,
dims_sorted_mm=dims,
circle_ratio=ratio,
passes_size=False,
is_circular=circular,
reason=reason,
)
if circular:
return ClassificationResult(
category=Category.NEED_PACK,
dims_sorted_mm=dims,
circle_ratio=ratio,
passes_size=True,
is_circular=True,
reason=f"круг в сечении: r_in/r_out={ratio:.3f} > {circle_ratio_threshold}",
)
return ClassificationResult(
category=Category.SUITABLE,
dims_sorted_mm=dims,
circle_ratio=ratio,
passes_size=True,
is_circular=False,
reason=f"габариты OK, круга нет: r_in/r_out={ratio:.3f} <= {circle_ratio_threshold}",
)
def classify_from_dims(
length_mm: float,
width_mm: float,
height_mm: float,
circle_ratio: float,
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
circle_ratio_threshold: float = 0.8,
) -> ClassificationResult:
fake = ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=height_mm,
circle_ratio=circle_ratio,
area_px=0,
centroid_px=(0, 0),
contour=None, # type: ignore
mask=None, # type: ignore
)
return classify(fake, min_mm, max_mm, circle_ratio_threshold)

151
cv/collect_log.py Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
Сбор логов классификации с камеры.
Кладите предметы по очереди — пишет CSV + печатает сводку.
.venv/bin/python collect_log.py
# Ctrl+C — стоп
"""
from __future__ import annotations
import csv
import sys
import time
from datetime import datetime
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from camera import RealSenseV4L2
from measure import measure_object, segment_object
from stabilize import DecisionStabilizer
def main() -> int:
cfg = yaml.safe_load(Path("config.yaml").read_text(encoding="utf-8"))
cam_cfg = cfg["camera"]
cls = cfg["classification"]
thr = float(cls.get("circle_ratio_threshold", 0.8))
out_dir = Path("debug_frames")
out_dir.mkdir(exist_ok=True)
stamp = datetime.now().strftime("%H%M%S")
csv_path = out_dir / f"log_{stamp}.csv"
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=False,
)
belt = float(cfg.get("belt_distance_mm") or 600)
print(f"[log] belt={belt:.0f} mm thr={thr}{csv_path}")
print("[log] Кладите КРУГ / ПРЯМОУГОЛЬНИК. Ctrl+C — стоп.\n")
stab = DecisionStabilizer(window=12, confirm_frames=8, lost_frames=12, enter_circle=thr)
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
f = csv_path.open("w", newline="", encoding="utf-8")
w = csv.writer(f)
w.writerow(
[
"t",
"present",
"L",
"W",
"H",
"top",
"sec",
"circle",
"raw_zone",
"lock",
"lock_zone",
"conf",
]
)
last_print = 0.0
n = 0
try:
while True:
pair = cam.read()
if pair is None:
time.sleep(0.02)
continue
n += 1
seg = segment_object(
pair.depth_mm,
belt_distance_mm=belt,
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
min_object_height_mm=float(cfg.get("min_object_height_mm", 5)),
min_area_px=int(cfg.get("min_object_area_px", 400)),
)
m = None
if seg is not None:
mask, contour = seg
m = measure_object(
pair.depth_mm, mask, contour, belt, fx, fy, cx, cy
)
d = stab.update(
m,
min_mm=cls.get("min_mm", [10, 10, 10]),
max_mm=cls.get("max_mm", [450, 320, 320]),
)
if m is None:
raw_zone = "-"
row = [time.time(), 0, "", "", "", "", "", "", raw_zone, int(d.locked), "", d.confidence_pct]
else:
dims = sorted([m.length_mm, m.width_mm, m.height_mm], reverse=True)
raw = "C"
if all(dims[i] > 10 and dims[i] < [450, 320, 320][i] for i in range(3)):
raw = "D" if m.circle_ratio >= thr else "B"
lz = d.result.category.zone if (d.locked and d.result) else ""
row = [
time.time(),
1,
round(dims[0], 1),
round(dims[1], 1),
round(dims[2], 1),
round(m.top_ratio, 3),
round(m.section_ratio, 3),
round(m.circle_ratio, 3),
raw,
int(d.locked),
lz,
d.confidence_pct,
]
w.writerow(row)
if n % 5 == 0:
f.flush()
now = time.time()
if now - last_print > 0.45:
last_print = now
if m is None:
print(f"[{n:05d}] пусто")
else:
lz = d.result.category.zone if (d.locked and d.result) else ""
print(
f"[{n:05d}] raw={row[8]} lock={lz or '':1s} conf={d.confidence_pct:3d}% | "
f"LWH={row[2]:.0f}×{row[3]:.0f}×{row[4]:.0f} | "
f"top={m.top_ratio:.3f} sec={m.section_ratio:.3f} circ={m.circle_ratio:.3f}"
)
except KeyboardInterrupt:
print(f"\n[log] сохранено {csv_path}")
finally:
f.close()
cam.release()
return 0
if __name__ == "__main__":
raise SystemExit(main())

90
cv/config.example.yaml Normal file
View File

@@ -0,0 +1,90 @@
# Vision classifier config EXAMPLE — copy to config.yaml and edit locally.
# Do not commit config.yaml (it may contain site-specific credentials).
camera:
depth_device: /dev/video0
color_device: /dev/video4
width: 640
height: 480
fps: 30
depth_scale_mm: 1.0
# D415 @ 640x480 approximate intrinsics — calibrate on your setup
fx: 564.0
fy: 564.0
cx: 320.0
cy: 240.0
color_dx: -40.0
color_dy: -8.0
color_scale: 1.030
belt_distance_mm: 594
belt_tolerance_mm: 25
min_object_height_mm: 20
min_object_area_px: 800
roi_margin:
top: 0.12
bottom: 0.02
left: 0.05
right: 0.12
max_objects_in_frame: 3
use_color: true
use_background_map: false
detect_flat_rgb: false
rgb_diff_threshold: 35
classification:
min_mm: [10, 10, 10]
max_mm: [450, 320, 320]
circle_ratio_threshold: 0.8
uncertain_after_frames: 45
uncertain_fallback_zone: C
# MQTT is OPTIONAL and disabled by default for safe local runs.
mqtt:
broker: "127.0.0.1"
port: 1883
user: ""
password: ""
client_id: "vision_classifier"
topic_result: "vision/feedback/category"
topic_dims: "vision/feedback/dimensions"
topic_circle: "vision/feedback/circle_ratio"
topic_debug: "vision/feedback/debug"
enabled: false
motor:
enabled: false
rpm: -200
current_percent: 50
microsteps: 16
stealthchop: true
disable_on_stop: false
routing:
enabled: false
zones:
B:
servo: 0
idle_angle: 0
divert_angle: 0
hold_ms: 500
C:
servo: 1
idle_angle: 0
divert_angle: 90
hold_ms: 800
D:
servo: 2
idle_angle: 0
divert_angle: 90
hold_ms: 800
cooldown_ms: 1500
runtime:
show_preview: false
save_debug_frames: false
debug_dir: "debug_frames"
decisions_log: "logs/decisions.jsonl"
preview_every_n: 3
process_every_n: 1
confirm_frames: 8

873
cv/demo.py Executable file
View File

@@ -0,0 +1,873 @@
#!/usr/bin/env python3
"""
Демо-режим хакатона с ползунками в браузере:
• высота до ленты (belt_distance_mm)
• порог уверенности (сколько кадров подряд одно и то же решение)
• мин. высота объекта, порог круга
Без MQTT / мотора / серво.
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlparse
import cv2
import numpy as np
import yaml
from camera import RealSenseV4L2
from classify import Category, ClassificationResult
from demo_hud import ContourSmoother, align_color, build_demo_frame
from journal import append_decision
from measure import (
is_plausible_measurement,
measure_flat_object,
measure_object,
merge_overlapping_measurements,
segment_objects,
segment_rgb_objects,
)
from stabilize import DecisionStabilizer
from tracker import MultiObjectTracker, slot_key
ZONE_TO_CATEGORY = {
"B": Category.SUITABLE,
"C": Category.OVERSIZE,
"D": Category.NEED_PACK,
}
HTML = r"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Трек 3 — демо</title>
<style>
:root {
--bg:#111114; --card:#1c1c22; --line:#33333c; --txt:#f2f2f4;
--muted:#a0a0ab; --acc:#2dd4bf; --b:#22c55e; --c:#ef4444; --d:#f59e0b;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--txt); font-family:system-ui,-apple-system,sans-serif; }
.top {
position:sticky; top:0; z-index:20;
background:var(--card); border-bottom:2px solid var(--acc);
padding:12px 16px 14px; box-shadow:0 8px 24px rgba(0,0,0,.45);
}
.top h1 { margin:0 0 4px; font-size:17px; }
.top .sub { margin:0 0 12px; color:var(--muted); font-size:12px; }
.sliders {
display:grid;
grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));
gap:12px 18px;
}
.sliders label {
display:flex; justify-content:space-between; align-items:baseline;
font-size:12px; margin-bottom:4px; color:var(--muted);
}
.sliders label b { color:var(--acc); font-size:14px; font-variant-numeric:tabular-nums; }
input[type=range] { width:100%; height:28px; accent-color:var(--acc); cursor:pointer; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; align-items:center; }
button {
border:0; border-radius:8px; padding:10px 14px; font-weight:700; cursor:pointer;
background:var(--acc); color:#042f2e;
}
button.sec { background:#2a2a32; color:var(--txt); }
#st {
flex:1; min-width:200px; padding:10px 12px; border-radius:8px;
background:#121218; border:1px solid var(--line); font-size:13px; line-height:1.45;
}
#st .zoneB { color:var(--b); font-weight:800; font-size:18px; }
#st .zoneC { color:var(--c); font-weight:800; font-size:18px; }
#st .zoneD { color:var(--d); font-weight:800; font-size:18px; }
.main {
display:flex; gap:12px; padding:12px; max-width:1400px;
margin:0 auto; align-items:flex-start;
}
.stage { flex:1; min-width:0; }
.stage img {
width:100%; height:auto; display:block;
border-radius:10px; border:1px solid var(--line); background:#000;
}
.side { width:320px; flex-shrink:0; display:flex; flex-direction:column; gap:10px; }
.side h3 {
margin:0; font-size:12px; text-transform:uppercase; letter-spacing:.08em;
color:var(--muted);
}
.card {
background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:10px; font-size:13px; line-height:1.5;
}
.card img {
width:100%; height:auto; display:block; border-radius:6px;
background:#000; margin-bottom:8px;
}
.card .hd { font-weight:800; font-size:14px; }
.card .mut { color:var(--muted); font-size:12px; }
#feed { display:flex; flex-direction:column; gap:8px; overflow-y:auto; max-height:60vh; }
.fitem {
display:flex; gap:8px; background:var(--card); border:1px solid var(--line);
border-radius:10px; padding:8px; font-size:12px; line-height:1.45;
}
.fitem img {
width:86px; height:64px; object-fit:cover; border-radius:6px;
background:#000; flex-shrink:0;
}
.fitem .hd { font-weight:800; font-size:13px; }
.fitem .mut { color:var(--muted); }
.zB { color:var(--b); } .zC { color:var(--c); } .zD { color:var(--d); }
.zU { color:#f97316; }
@media (max-width:900px) {
.main { flex-direction:column; }
.side { width:100%; }
}
</style>
</head>
<body>
<div class="top">
<h1>Трек 3 — демо классификации (B / C / D)</h1>
<p class="sub">Ползунки СВЕРХУ (отдельная панель). Картинка только камера. Выход: Ctrl+C в терминале. Обновите страницу Ctrl+F5.</p>
<div class="sliders">
<div>
<label>Высота до ленты, мм <b id="v_belt">600</b></label>
<input id="belt" type="range" min="300" max="2000" step="5" value="600"/>
</div>
<div>
<label>Порог уверенности, % <b id="v_conf">75</b></label>
<input id="conf" type="range" min="10" max="100" step="5" value="75"/>
</div>
<div>
<label>Мин. высота объекта, мм <b id="v_hmin">8</b></label>
<input id="hmin" type="range" min="2" max="80" step="1" value="8"/>
</div>
<div>
<label>Порог «круг» <b id="v_circ">0.80</b></label>
<input id="circ" type="range" min="0.50" max="0.95" step="0.01" value="0.80"/>
</div>
<div>
<label>Мин. площадь, px <b id="v_area">800</b></label>
<input id="area" type="range" min="100" max="5000" step="50" value="800"/>
</div>
</div>
<div class="actions">
<div id="st">Загрузка…</div>
<button type="button" id="auto">Авто-высота</button>
<button type="button" class="sec" id="reset">Сброс</button>
</div>
</div>
<div class="main">
<div class="stage">
<img id="f" src="/frame.jpg?t=0" alt="camera"/>
</div>
<aside class="side">
<h3>Текущий объект</h3>
<div id="live"><div class="card mut">объектов нет</div></div>
<h3>Лента</h3>
<div id="feed"><div class="card mut">пока пусто</div></div>
</aside>
</div>
<script>
const img = document.getElementById('f');
const ids = ['belt','conf','hmin','circ','area'];
const defaults = { belt:600, conf:75, hmin:8, circ:0.80, area:800 };
let dragging = false;
function syncLabels() {
v_belt.textContent = belt.value;
v_conf.textContent = conf.value;
v_hmin.textContent = hmin.value;
v_circ.textContent = Number(circ.value).toFixed(2);
v_area.textContent = area.value;
}
async function pushParams() {
syncLabels();
await fetch('/api/params', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
belt_mm: Number(belt.value),
confidence_pct: Number(conf.value),
min_object_height_mm: Number(hmin.value),
circle_threshold: Number(circ.value),
min_area_px: Number(area.value),
}),
});
}
async function pullStatus() {
try {
const s = await (await fetch('/api/status')).json();
let zoneHtml = '<span style="color:#888">объектов нет</span>';
if (s.objects && s.objects.length) {
zoneHtml = s.objects.map(o => {
const dims = o.dims ? (o.dims.map(x => Math.round(x)).join('×') + ' мм') : '';
const extra = ' · ' + dims + ' · круг ' + (o.ratio ?? '');
if (o.locked && o.uncertain)
return '<span style="color:#f97316;font-weight:800">#' + o.id + ' НЕУВЕРЕННО' + o.label + '</span>' + extra;
if (o.locked)
return '<span class="zone' + o.zone + '">#' + o.id + ' ' + o.label + ' ✓</span>' + extra;
return '<span style="color:#38bdf8">#' + o.id + ' анализ… ' + o.conf + '%</span>' + extra;
}).join('<br/>');
}
const stt = s.stats || {};
const statsLine = 'Итого: <b style="color:#22c55e">ГОТОВ ' + (stt.B || 0) +
'</b> · <b style="color:#ef4444">НЕГАБАРИТ ' + (stt.C || 0) +
'</b> · <b style="color:#f59e0b">ДОУПАКОВКА ' + (stt.D || 0) + '</b>' +
(stt.uncertain ? ' · неуверенно ' + stt.uncertain : '');
st.innerHTML = zoneHtml + '<br/>' + statsLine + '<br/>высота <b>' + s.belt_mm + '</b> мм';
renderLive(s.objects || []);
if (s.feed_seq !== window.__feedSeq) {
window.__feedSeq = s.feed_seq;
refreshFeed(s.feed || null);
}
if (!window.__inited && !dragging) {
belt.value = s.belt_mm;
conf.value = s.confidence_pct;
hmin.value = s.min_object_height_mm;
circ.value = s.circle_threshold;
area.value = s.min_area_px;
syncLabels();
window.__inited = true;
}
} catch (e) { st.textContent = 'Нет связи с demo.py — перезапустите ./demo.sh'; }
}
function zcls(o) {
if (o.uncertain) return 'zU';
return o.zone ? ('z' + o.zone) : '';
}
function dimsStr(d) {
return d ? d.map(x => Math.round(x)).join('×') + ' мм' : '';
}
function objSig(o) {
return o.id + '|' + (o.locked ? 'L' : 'P') + '|' + o.conf + '|' + (o.zone || '') +
'|' + (o.label || '') + '|' + (o.dims || []).map(x => Math.round(x)).join(',');
}
function renderLive(objs) {
const box = document.getElementById('live');
const sig = objs.map(objSig).join(';');
if (sig === window.__liveSig) return;
window.__liveSig = sig;
if (!objs.length) {
box.innerHTML = '<div class="card mut">объектов нет</div>';
return;
}
box.innerHTML = objs.map(o => {
const img = (o.locked && o.crop) ? '<img src="data:image/jpeg;base64,' + o.crop + '"/>' : '';
const head = o.locked
? '<span class="hd ' + zcls(o) + '">#' + o.id + ' ' + (o.uncertain ? 'НЕУВЕРЕННО' : '') + o.label + (o.zone ? ' · зона ' + o.zone : '') + '</span>'
: '<span class="hd" style="color:#38bdf8">#' + o.id + ' анализ… ' + o.conf + '%</span>';
const reason = o.reason ? '<div class="mut">' + o.reason + '</div>' : '';
return '<div class="card">' + img + head +
'<div>' + dimsStr(o.dims) + ' · круг ' + (o.ratio ?? '') + '</div>' + reason + '</div>';
}).join('');
}
function refreshFeed(items) {
const box = document.getElementById('feed');
if (!window.__feedKeys) window.__feedKeys = new Set();
if (!items || !items.length) {
if (!window.__feedKeys.size) box.innerHTML = '<div class="card mut">пока пусто</div>';
return;
}
if (window.__feedKeys.size === 0) box.innerHTML = '';
for (const it of items.slice().reverse()) {
const key = it.slot || ('#' + it.id);
if (window.__feedKeys.has(key)) continue;
window.__feedKeys.add(key);
const img = it.crop ? '<img src="data:image/jpeg;base64,' + it.crop + '"/>' : '<img/>';
const el = document.createElement('div');
el.className = 'fitem';
el.dataset.slot = key;
el.innerHTML = img + '<div>' +
'<div class="hd ' + zcls(it) + '">#' + it.id + ' ' + (it.uncertain ? 'НЕУВЕР' : '') + 'зона ' + it.zone + '</div>' +
'<div>' + it.label + '</div>' +
'<div class="mut">' + dimsStr(it.dims) + ' · круг ' + it.ratio + ' · ' + it.time + '</div>' +
'</div>';
box.insertBefore(el, box.firstChild);
}
}
ids.forEach(id => {
const el = document.getElementById(id);
el.addEventListener('pointerdown', () => { dragging = true; });
el.addEventListener('pointerup', () => { dragging = false; pushParams(); });
el.addEventListener('input', () => { syncLabels(); pushParams(); });
});
document.getElementById('auto').onclick = async () => {
st.textContent = 'Калибровка… уберите объекты с ленты';
const s = await (await fetch('/api/autocalib', {method:'POST'})).json();
if (s.ok) {
belt.value = Math.round(s.belt_mm);
syncLabels();
await pushParams();
} else st.textContent = 'Ошибка: ' + (s.error || '');
};
document.getElementById('reset').onclick = () => {
belt.value = defaults.belt; conf.value = defaults.conf;
hmin.value = defaults.hmin; circ.value = defaults.circ; area.value = defaults.area;
syncLabels(); pushParams();
};
setInterval(() => { img.src = '/frame.jpg?t=' + Date.now(); }, 280);
setInterval(pullStatus, 350);
pullStatus();
</script>
</body>
</html>
"""
class Params:
def __init__(self) -> None:
self.lock = threading.Lock()
self.belt_mm: float = 800.0
self.confidence_pct: int = 75 # порог фиксации
self.min_object_height_mm: float = 8.0
self.circle_threshold: float = 0.80
self.min_area_px: int = 400
# runtime status
self.confidence_now: int = 0
self.zone: Optional[str] = None
self.locked: bool = False
self.uncertain: bool = False
self.circle_ratio: Optional[float] = None
self.dims: Optional[tuple] = None
self.reason: str = ""
self.objects: list = [] # [{id, zone, label, dims, ratio, locked, uncertain, conf, crop}]
self.stats: dict = {} # счётчики за сессию
self.feed: list = [] # лента LOCK-событий (новые в конце)
self.feed_seq: int = 0 # версия ленты — клиент тянет только при изменении
self.feed_slots: set = set() # slot_key — один товар = одна карточка в ленте
self.crop_cache: dict = {} # track_id → base64, фиксируется при LOCK
self.jpeg: bytes = b""
self.last_print: str = ""
self.cam: Any = None
self.request_autocalib: bool = False
self.autocalib_result: Optional[Dict[str, Any]] = None
self.background: Optional[np.ndarray] = None # карта глубины пустой сцены
self.color_background: Optional[np.ndarray] = None # RGB пустой сцены (плоские товары)
STATE = Params()
def make_handler() -> type:
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
return
def _json(self, code: int, obj: Dict[str, Any]) -> None:
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
path = urlparse(self.path).path
if path.startswith("/frame.jpg"):
with STATE.lock:
data = STATE.jpeg
if not data:
self.send_error(503, "no frame yet")
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
elif path == "/api/status":
with STATE.lock:
self._json(
200,
{
"belt_mm": round(STATE.belt_mm),
"confidence_pct": STATE.confidence_pct,
"confidence_now": STATE.confidence_now,
"min_object_height_mm": STATE.min_object_height_mm,
"circle_threshold": STATE.circle_threshold,
"min_area_px": STATE.min_area_px,
"zone": STATE.zone,
"locked": STATE.locked,
"uncertain": STATE.uncertain,
"circle_ratio": STATE.circle_ratio,
"dims": STATE.dims,
"reason": STATE.reason,
"objects": STATE.objects,
"stats": STATE.stats,
"feed_seq": STATE.feed_seq,
"feed": STATE.feed,
},
)
elif path == "/api/feed":
with STATE.lock:
self._json(200, {"seq": STATE.feed_seq, "items": STATE.feed})
else:
body = HTML.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self) -> None:
path = urlparse(self.path).path
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8") or "{}")
except json.JSONDecodeError:
data = {}
if path == "/api/params":
with STATE.lock:
if "belt_mm" in data:
new_belt = float(np.clip(float(data["belt_mm"]), 200, 3000))
# ручная правка высоты → фоновая карта устарела
if abs(new_belt - STATE.belt_mm) > 2.0:
STATE.background = None
STATE.belt_mm = new_belt
if "confidence_pct" in data:
STATE.confidence_pct = int(np.clip(int(data["confidence_pct"]), 10, 100))
if "min_object_height_mm" in data:
STATE.min_object_height_mm = float(np.clip(float(data["min_object_height_mm"]), 1, 200))
if "circle_threshold" in data:
STATE.circle_threshold = float(np.clip(float(data["circle_threshold"]), 0.4, 0.99))
if "min_area_px" in data:
STATE.min_area_px = int(np.clip(int(data["min_area_px"]), 50, 20000))
self._json(200, {"ok": True})
elif path == "/api/autocalib":
with STATE.lock:
STATE.request_autocalib = True
STATE.autocalib_result = None
# ждём результат от цикла камеры
for _ in range(80):
time.sleep(0.1)
with STATE.lock:
if STATE.autocalib_result is not None:
self._json(200, STATE.autocalib_result)
return
self._json(500, {"ok": False, "error": "timeout"})
else:
self.send_error(404)
return Handler
def resolve_config_path(path: Path) -> Path:
if path.exists():
return path
example = path.with_name("config.example.yaml")
if example.exists():
return example
raise FileNotFoundError(f"Config not found: {path} (and no config.example.yaml)")
def load_config(path: Path) -> Dict[str, Any]:
resolved = resolve_config_path(Path(path))
with open(resolved, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def frames_needed(confidence_pct: int) -> int:
# 10% → 5, 100% → 12 кадров одной зоны после прогрева окна
return max(5, int(round(5 + (confidence_pct / 100.0) * 7)))
def crop_b64(img: np.ndarray, contour: np.ndarray, pad: int = 14, max_w: int = 260) -> Optional[str]:
"""Кроп объекта по bounding box контура → JPEG base64 для веб-панели."""
x, y, w, h = cv2.boundingRect(contour)
H, W = img.shape[:2]
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(W, x + w + pad), min(H, y + h + pad)
if x1 - x0 < 4 or y1 - y0 < 4:
return None
crop = img[y0:y1, x0:x1]
if crop.shape[1] > max_w:
s = max_w / crop.shape[1]
crop = cv2.resize(crop, (max_w, max(1, int(crop.shape[0] * s))))
ok, buf = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 78])
return base64.b64encode(buf.tobytes()).decode("ascii") if ok else None
def main() -> int:
parser = argparse.ArgumentParser(description="Демо классификации с ползунками")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
cfg = load_config(Path(args.config))
cam_cfg = cfg["camera"]
cls_cfg = cfg["classification"]
min_mm = cls_cfg.get("min_mm", [10, 10, 10])
max_mm = cls_cfg.get("max_mm", [450, 320, 320])
out_dir = Path(cfg.get("runtime", {}).get("debug_dir", "debug_frames"))
out_dir.mkdir(parents=True, exist_ok=True)
out_jpg = out_dir / "demo_live.jpg"
print("[demo] открываю RealSense D415…")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=bool(cfg.get("use_color", False)),
)
STATE.cam = cam
belt0 = float(cfg.get("belt_distance_mm") or 0)
if belt0 <= 0:
print("[demo] калибровка ленты — уберите объекты…")
belt0 = cam.estimate_belt_distance_mm()
with STATE.lock:
STATE.belt_mm = belt0
STATE.circle_threshold = float(cls_cfg.get("circle_ratio_threshold", 0.8))
STATE.min_object_height_mm = float(cfg.get("min_object_height_mm", 8))
STATE.min_area_px = int(cfg.get("min_object_area_px", 400))
STATE.confidence_pct = 75
print(f"[demo] belt_distance_mm = {belt0:.0f}")
max_objects = int(cfg.get("max_objects_in_frame", 3))
detect_flat_rgb = bool(cfg.get("detect_flat_rgb", False))
print(f"[demo] max_objects={max_objects}, flat_rgb={'ON' if detect_flat_rgb else 'OFF'}")
print("[demo] фоновая карта: кнопка «Авто-высота» на пустой ленте")
server = ThreadingHTTPServer((args.host, args.port), make_handler())
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"[demo] браузер → http://127.0.0.1:{args.port}/")
print("[demo] ползунки СВЕРХУ страницы (не на картинке)")
print("[demo] зона только после LOCK (медиана 12 кадров + голосование)")
print("[demo] Ctrl+C — выход\n")
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
fallback_zone = str(cls_cfg.get("uncertain_fallback_zone", "C")).upper()
thr0 = float(cls_cfg.get("circle_ratio_threshold", 0.8))
settings = {"confirm": frames_needed(75), "circ": thr0}
def make_stabilizer() -> DecisionStabilizer:
return DecisionStabilizer(
window=12,
confirm_frames=settings["confirm"],
lost_frames=12,
enter_circle=settings["circ"],
exit_circle=settings["circ"] - 0.08,
uncertain_after=int(cls_cfg.get("uncertain_after_frames", 45)),
fallback=ZONE_TO_CATEGORY.get(fallback_zone, Category.OVERSIZE),
)
# lost_frames=30 ≈ 1.52 с: глянцевые/тёмные предметы (мышка) дают
# кратковременные выпадения depth — трек не должен умирать от них
tracker = MultiObjectTracker(make_stabilizer, max_dist_px=120, lost_frames=30)
contour_smoother = ContourSmoother(alpha=0.3)
color_align = (
float(cam_cfg.get("color_dx", 0.0)),
float(cam_cfg.get("color_dy", 0.0)),
float(cam_cfg.get("color_scale", 1.0)),
)
decisions_log = Path(cfg.get("runtime", {}).get("decisions_log", "logs/decisions.jsonl"))
frame_i = 0
last_conf_setting = 75
last_circ = thr0
try:
while True:
# автокалибровка по запросу из UI
with STATE.lock:
need_auto = STATE.request_autocalib
if need_auto:
STATE.request_autocalib = False
if need_auto:
try:
print("[demo] автокалибровка: снимаю фоновую карту (сцена должна быть пустой)…")
bg = cam.capture_background(samples=15)
color_bg = cam.capture_background_rgb(samples=10)
h, w = bg.shape
center = bg[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4].astype(np.float32)
center = center[(center > 200) & (center < 4000)]
new_belt = float(np.median(center)) if center.size > 100 else cam.estimate_belt_distance_mm(samples=10)
with STATE.lock:
STATE.belt_mm = new_belt
STATE.background = bg
STATE.color_background = color_bg
STATE.autocalib_result = {"ok": True, "belt_mm": new_belt}
tracker.reset()
rgb_tag = "RGB-фон есть" if color_bg is not None else "RGB-фон недоступен"
print(f"[demo] высота = {new_belt:.0f} mm, фоновая карта активна, {rgb_tag}")
except Exception as exc:
with STATE.lock:
STATE.autocalib_result = {"ok": False, "error": str(exc)}
pair = cam.read()
if pair is None:
time.sleep(0.02)
continue
frame_i += 1
with STATE.lock:
belt_mm = STATE.belt_mm
conf_pct = STATE.confidence_pct
hmin = STATE.min_object_height_mm
circ_thr = STATE.circle_threshold
min_area = STATE.min_area_px
background = STATE.background
color_background = STATE.color_background
if conf_pct != last_conf_setting:
settings["confirm"] = frames_needed(conf_pct)
tracker.reset()
last_conf_setting = conf_pct
if abs(circ_thr - last_circ) > 1e-6:
settings["circ"] = float(circ_thr)
tracker.reset()
last_circ = circ_thr
# несколько объектов в кадре → трекер с ID
measurements = []
depth_union = None
seg_n = 0
for mask, contour in segment_objects(
pair.depth_mm,
belt_distance_mm=belt_mm,
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
min_object_height_mm=hmin,
min_area_px=min_area,
background_mm=background,
max_objects=max_objects,
):
seg_n += 1
depth_union = mask if depth_union is None else cv2.bitwise_or(depth_union, mask)
m = measure_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
min_object_height_mm=hmin,
)
if m is not None and is_plausible_measurement(m):
measurements.append(m)
# плоские товары — только если detect_flat_rgb: true (иначе тени → ложные C)
if (
detect_flat_rgb
and color_background is not None
and not pair.color_is_depth_preview
):
for mask, contour in segment_rgb_objects(
pair.color_bgr,
color_background,
min_area_px=min_area,
diff_threshold=int(cfg.get("rgb_diff_threshold", 35)),
max_objects=max(0, max_objects - len(measurements)),
exclude_mask=depth_union,
):
m = measure_flat_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
color_bgr=pair.color_bgr,
color_bg_bgr=color_background,
)
if m is not None and is_plausible_measurement(m):
measurements.append(m)
measurements = merge_overlapping_measurements(measurements, overlap_thr=0.5)
tracks, events = tracker.update(measurements, min_mm=min_mm, max_mm=max_mm)
rgb_available = not pair.color_is_depth_preview
if rgb_available and float(np.std(pair.color_bgr)) > 4.0:
base_img = pair.color_bgr
if base_img.shape[:2] != pair.depth_mm.shape[:2]:
base_img = cv2.resize(base_img, (pair.depth_mm.shape[1], pair.depth_mm.shape[0]))
base_img = align_color(base_img, *color_align)
else:
# без живого RGB — colorize(depth), иначе веб был бы чёрным
from camera import depth_colormap
base_img = depth_colormap(pair.depth_mm)
rgb_available = False
crops: Dict[int, Optional[str]] = {}
for tr in tracks:
if tr.measurement is not None:
crops[tr.track_id] = crop_b64(base_img, tr.measurement.contour)
crop_updates: Dict[int, str] = {}
feed_add = []
for ev in events:
r = ev.decision.result
tag = "UNCERTAIN→" if ev.decision.uncertain else "LOCK "
print(
f"[demo] #{ev.track_id} {tag}{r.category.zone} | {r.category.short_label} | "
f"LWH={tuple(round(x, 1) for x in r.dims_sorted_mm)} | circle={r.circle_ratio:.3f}"
)
append_decision(
decisions_log, r,
uncertain=ev.decision.uncertain, source="demo", track_id=ev.track_id,
)
L, W, H = r.dims_sorted_mm
tr_ev = next((t for t in tracks if t.track_id == ev.track_id), None)
cx, cy = (tr_ev.centroid if tr_ev else (0, 0))
sk = slot_key(cx, cy, L, W, H, r.category.zone)
crop = crops.get(ev.track_id)
if crop:
crop_updates[ev.track_id] = crop
feed_add.append({
"slot": sk,
"id": ev.track_id,
"time": time.strftime("%H:%M:%S"),
"zone": r.category.zone,
"label": r.category.short_label,
"dims": [round(x, 1) for x in r.dims_sorted_mm],
"ratio": round(r.circle_ratio, 3),
"uncertain": bool(ev.decision.uncertain),
"crop": crop,
})
with STATE.lock:
STATE.crop_cache.update(crop_updates)
crop_cache = dict(STATE.crop_cache)
# статус для веба: список объектов + «главный» (первый залоченный)
objects_json = []
primary = None
for tr in tracks:
d = tr.decision
m = tr.measurement
if d is None or m is None:
continue
is_locked = bool(d.locked and d.result is not None)
if is_locked:
obj = {
"id": tr.track_id,
"locked": True,
"uncertain": bool(d.uncertain),
"conf": 100,
"zone": d.result.category.zone,
"label": d.result.category.short_label,
"dims": [round(x, 1) for x in d.result.dims_sorted_mm],
"ratio": round(d.result.circle_ratio, 3),
"reason": d.result.reason,
"crop": crop_cache.get(tr.track_id),
}
else:
obj = {
"id": tr.track_id,
"locked": False,
"uncertain": False,
"conf": d.confidence_pct,
"zone": None,
"label": "анализ…",
"dims": [round(m.length_mm, 1), round(m.width_mm, 1), round(m.height_mm, 1)],
"ratio": round(m.circle_ratio, 3),
"reason": "",
"crop": None,
}
objects_json.append(obj)
if primary is None or (obj["locked"] and not primary["locked"]):
primary = obj
with STATE.lock:
STATE.objects = objects_json
STATE.stats = dict(tracker.stats)
if feed_add:
fresh = [it for it in feed_add if it["slot"] not in STATE.feed_slots]
for it in fresh:
STATE.feed_slots.add(it["slot"])
if fresh:
STATE.feed.extend(fresh)
STATE.feed = STATE.feed[-20:]
STATE.feed_seq += 1
if primary is not None:
STATE.confidence_now = primary["conf"]
STATE.locked = primary["locked"]
STATE.uncertain = primary["uncertain"]
STATE.zone = primary["zone"]
STATE.circle_ratio = primary["ratio"]
STATE.dims = tuple(primary["dims"])
STATE.reason = primary["reason"] or (
f"накопление {primary['conf']}% → ждём LOCK" if not primary["locked"] else ""
)
else:
STATE.confidence_now = 0
STATE.locked = False
STATE.uncertain = False
STATE.zone = None
STATE.circle_ratio = None
STATE.dims = None
STATE.reason = ""
hud = build_demo_frame(
base_img,
pair.depth_mm,
tracks,
belt_mm,
stats=tracker.stats,
confidence_pct=conf_pct,
rgb_available=rgb_available,
background_active=background is not None,
color_align=(0.0, 0.0, 1.0),
contour_smoother=contour_smoother,
)
ok, buf = cv2.imencode(".jpg", hud, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if ok:
jpeg = buf.tobytes()
with STATE.lock:
STATE.jpeg = jpeg
if frame_i % 3 == 0:
out_jpg.write_bytes(jpeg)
if seg_n > len(measurements) and STATE.last_print != "seg_drop":
print(f"[demo] depth: контуров {seg_n}, измерено {len(measurements)} "
f"(часть отфильтрована: низкая высота < {hmin:.0f} мм или шум)")
STATE.last_print = "seg_drop"
elif not tracks and STATE.last_print != "empty":
print("[demo] объектов нет")
STATE.last_print = "empty"
elif tracks and STATE.last_print in ("seg_drop", "empty"):
STATE.last_print = ""
time.sleep(0.03)
except KeyboardInterrupt:
print("\n[demo] stop")
finally:
server.shutdown()
cam.release()
return 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main())

19
cv/demo.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
if [[ ! -f config.yaml && -f config.example.yaml ]]; then
cp config.example.yaml config.yaml
echo "[cv] created config.yaml from config.example.yaml (MQTT disabled)"
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install -U pip
.venv/bin/pip install -r requirements.txt
fi
echo "Демо классификации (без моторов/серво)"
echo "Браузер: http://127.0.0.1:8080/"
exec .venv/bin/python demo.py "$@"

302
cv/demo_hud.py Normal file
View File

@@ -0,0 +1,302 @@
"""HUD демо: RGB-подложка + depth, несколько объектов с ID, кириллица через PIL."""
from __future__ import annotations
from functools import lru_cache
from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ZONE_COLOR = { # BGR
"B": (40, 180, 40),
"C": (40, 40, 220),
"D": (0, 165, 255),
}
UNCERTAIN_COLOR = (0, 130, 250) # оранжевый
PENDING_COLOR = (0, 255, 255) # жёлтый — идёт накопление
_FONT_CANDIDATES = [
"/usr/share/fonts/noto/NotoSans-Bold.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
]
@lru_cache(maxsize=8)
def _font(size: int) -> ImageFont.FreeTypeFont:
for path in _FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def _draw_texts(
img_bgr: np.ndarray,
texts: List[Tuple[int, int, str, Tuple[int, int, int], int]],
) -> np.ndarray:
"""texts: (x, y, строка, цвет BGR, размер). Кириллица через PIL."""
if not texts:
return img_bgr
pil = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil)
for x, y, s, bgr, size in texts:
rgb = (bgr[2], bgr[1], bgr[0])
draw.text((x, y), s, font=_font(size), fill=rgb, stroke_width=2, stroke_fill=(0, 0, 0))
return cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
class DepthSmoother:
"""Временное сглаживание depth только для отображения (не для измерений)."""
def __init__(self, alpha: float = 0.25) -> None:
self.alpha = float(alpha)
self._acc: Optional[np.ndarray] = None
def update(self, depth_mm: np.ndarray) -> np.ndarray:
d = depth_mm.astype(np.float32)
if self._acc is None or self._acc.shape != d.shape:
self._acc = d.copy()
valid = d > 0
self._acc[valid] = (1.0 - self.alpha) * self._acc[valid] + self.alpha * d[valid]
out = self._acc.astype(np.uint16)
out[~valid & (self._acc <= 0)] = 0
return out
class ContourSmoother:
"""Стабильная окантовка: EMA маски по каждому треку + аппроксимация контура,
плюс «примагничивание» контура к краям объекта на RGB (снимает остаточный
параллакс depth↔color и распухание depth-маски).
Только для отрисовки — измерения идут по сырому контуру.
"""
def __init__(self, alpha: float = 0.3, snap_alpha: float = 0.35) -> None:
self.alpha = float(alpha)
self.snap_alpha = float(snap_alpha)
self._acc: Dict[int, np.ndarray] = {}
self._snap: Dict[int, Tuple[float, float, float]] = {} # tid -> (dx, dy, shrink)
def smooth(
self, track_id: int, mask: np.ndarray, edge_img: Optional[np.ndarray] = None
) -> Optional[np.ndarray]:
m = mask.astype(np.float32) / 255.0
acc = self._acc.get(track_id)
if acc is None or acc.shape != m.shape:
acc = m.copy()
else:
acc = (1.0 - self.alpha) * acc + self.alpha * m
self._acc[track_id] = acc
soft = cv2.GaussianBlur((acc * 255.0).astype(np.uint8), (11, 11), 0)
_, binm = cv2.threshold(soft, 127, 255, cv2.THRESH_BINARY)
contours, _ = cv2.findContours(binm, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
contour = max(contours, key=cv2.contourArea)
pts = contour.reshape(-1, 2).astype(np.float32)
if edge_img is not None and pts.shape[0] >= 8:
pts = self._snap_to_edges(track_id, pts, edge_img)
contour = pts.reshape(-1, 1, 2).astype(np.int32)
eps = 0.008 * cv2.arcLength(contour, True)
return cv2.approxPolyDP(contour, eps, True)
def _snap_to_edges(
self, track_id: int, pts: np.ndarray, edge: np.ndarray
) -> np.ndarray:
"""Локальный поиск сдвига (±14 px) и поджатия контура, при которых под
контуром максимум RGB-краёв. Найденная поправка сглаживается по времени."""
H, W = edge.shape[:2]
xs, ys = pts[:, 0], pts[:, 1]
def score(dx: float, dy: float, f: float, c: np.ndarray) -> float:
x = np.clip((c[0] + f * (xs - c[0]) + dx).astype(np.int32), 0, W - 1)
y = np.clip((c[1] + f * (ys - c[1]) + dy).astype(np.int32), 0, H - 1)
return float(edge[y, x].mean()) - 0.6 * float(np.hypot(dx, dy))
c = pts.mean(axis=0)
best_dx, best_dy, best_s = 0.0, 0.0, score(0, 0, 1.0, c)
for dy in range(-14, 15, 2):
for dx in range(-14, 15, 2):
s = score(dx, dy, 1.0, c)
if s > best_s:
best_s, best_dx, best_dy = s, float(dx), float(dy)
best_f = 1.0
# только сдвиг и лёгкое РАСШИРЕНИЕ — поджатие (f<1) отрезало часть объекта
for f in (1.0, 1.04, 1.08):
s = score(best_dx, best_dy, f, c)
if s > best_s:
best_s, best_f = s, f
prev = self._snap.get(track_id, (0.0, 0.0, 1.0))
a = self.snap_alpha
sm = (
(1 - a) * prev[0] + a * best_dx,
(1 - a) * prev[1] + a * best_dy,
max(1.0, (1 - a) * prev[2] + a * best_f),
)
self._snap[track_id] = sm
out = pts.copy()
out[:, 0] = c[0] + sm[2] * (xs - c[0]) + sm[0]
out[:, 1] = c[1] + sm[2] * (ys - c[1]) + sm[1]
return out
def drop_missing(self, alive_ids: set) -> None:
for tid in list(self._acc.keys()):
if tid not in alive_ids:
del self._acc[tid]
self._snap.pop(tid, None)
def align_color(color_bgr: np.ndarray, dx: float, dy: float, scale: float) -> np.ndarray:
"""Совмещение RGB с depth: сдвиг+масштаб (у D415 сенсоры разнесены)."""
if abs(dx) < 0.5 and abs(dy) < 0.5 and abs(scale - 1.0) < 1e-3:
return color_bgr
h, w = color_bgr.shape[:2]
M = np.float32([
[scale, 0, dx + (1.0 - scale) * w / 2.0],
[0, scale, dy + (1.0 - scale) * h / 2.0],
])
return cv2.warpAffine(color_bgr, M, (w, h), flags=cv2.INTER_LINEAR)
def build_demo_frame(
color_bgr: np.ndarray,
depth_mm: np.ndarray,
tracks: list, # List[tracker.Track]
belt_mm: float,
stats: Optional[Dict[str, int]] = None,
confidence_pct: int = 75,
rgb_available: bool = False,
background_active: bool = False,
color_align: Tuple[float, float, float] = (0.0, 0.0, 1.0),
contour_smoother: Optional[ContourSmoother] = None,
) -> np.ndarray:
from camera import depth_colormap
# RGB для веба; если цвет недоступен — colorize(depth), НЕ чёрный экран
if rgb_available and color_bgr is not None and float(np.std(color_bgr)) > 4.0:
base = color_bgr
if base.shape[:2] != depth_mm.shape[:2]:
base = cv2.resize(base, (depth_mm.shape[1], depth_mm.shape[0]))
base = align_color(base, color_align[0], color_align[1], color_align[2])
view = base.copy()
rgb_ok = True
else:
view = depth_colormap(depth_mm)
view = cv2.medianBlur(view, 3)
base = view
rgb_ok = False
h, w = view.shape[:2]
# мягкое поле RGB-краёв для «примагничивания» контуров
edge_field: Optional[np.ndarray] = None
if rgb_ok and contour_smoother is not None and tracks:
gray = cv2.cvtColor(base, cv2.COLOR_BGR2GRAY)
gray = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(gray)
edge_field = cv2.Canny(cv2.GaussianBlur(gray, (5, 5), 0), 40, 120)
edge_field = cv2.GaussianBlur(edge_field, (13, 13), 0)
texts: List[Tuple[int, int, str, Tuple[int, int, int], int]] = []
max_conf_pending = 0
all_locked = bool(tracks)
alive_ids = set()
for tr in tracks:
m = tr.measurement
d = tr.decision
if m is None or d is None:
continue
alive_ids.add(tr.track_id)
locked = d.locked and d.result is not None
if locked:
color = UNCERTAIN_COLOR if d.uncertain else ZONE_COLOR.get(d.result.category.zone, PENDING_COLOR)
else:
color = PENDING_COLOR
all_locked = False
max_conf_pending = max(max_conf_pending, d.confidence_pct)
# bbox / класс — по сырому контуру измерения; сглаживание только для окантовки «анализ»
raw_contour = m.contour
draw_contour = raw_contour
if not locked and contour_smoother is not None:
sm = contour_smoother.smooth(tr.track_id, m.mask, edge_img=edge_field)
if sm is not None:
draw_contour = sm
if locked:
bx, by, bw, bh = cv2.boundingRect(raw_contour)
# небольшой запас, чтобы рамка не обрезала края
pad = 4
bx, by = max(0, bx - pad), max(0, by - pad)
bw = min(w - bx, bw + 2 * pad)
bh = min(h - by, bh + 2 * pad)
cv2.rectangle(view, (bx, by), (bx + bw, by + bh), color, 2, lineType=cv2.LINE_AA)
cl = max(8, min(bw, bh) // 5)
for px, py, sx, sy in ((bx, by, 1, 1), (bx + bw, by, -1, 1),
(bx, by + bh, 1, -1), (bx + bw, by + bh, -1, -1)):
cv2.line(view, (px, py), (px + sx * cl, py), color, 4, lineType=cv2.LINE_AA)
cv2.line(view, (px, py), (px, py + sy * cl), color, 4, lineType=cv2.LINE_AA)
contour = raw_contour
else:
cv2.drawContours(view, [draw_contour], -1, color, 2, lineType=cv2.LINE_AA)
contour = draw_contour
ccx, ccy = contour.reshape(-1, 2).mean(axis=0)
cv2.circle(view, (int(ccx), int(ccy)), 4, (0, 0, 255), -1, lineType=cv2.LINE_AA)
x0, y0, _, _ = cv2.boundingRect(contour)
tx = int(np.clip(x0, 4, w - 220))
ty = int(np.clip(y0 - 46, 4, h - 46))
if locked:
label = d.result.category.short_label
if d.uncertain:
label = "НЕУВЕРЕННО" + label
texts.append((tx, ty, f"#{tr.track_id} {label}", color, 20))
else:
texts.append((tx, ty, f"#{tr.track_id} анализ… {d.confidence_pct}%", color, 20))
dims = d.result.dims_sorted_mm if (locked and d.result) else (m.length_mm, m.width_mm, m.height_mm)
ratio = d.result.circle_ratio if (locked and d.result) else m.circle_ratio
src = " · RGB" if getattr(m, "source", "depth") == "rgb" else ""
texts.append(
(tx, ty + 24, f"{dims[0]:.0f}×{dims[1]:.0f}×{dims[2]:.0f} мм · круг {ratio:.2f}{src}", (235, 235, 235), 15)
)
if contour_smoother is not None:
contour_smoother.drop_missing(alive_ids)
view = _draw_texts(view, texts)
# надписи — в отдельной полосе НАД кадром, чтобы не закрывать камеру
top_bar = np.full((36, w, 3), 18, np.uint8)
st = stats or {}
stats_line = (
f"ГОТОВ {st.get('B', 0)} · НЕГАБАРИТ {st.get('C', 0)} · ДОУПАК {st.get('D', 0)}"
+ (f" · неувер. {st['uncertain']}" if st.get("uncertain") else "")
)
bg_tag = " · фон:карта" if background_active else ""
left = "объектов нет" if not tracks else f"объектов: {len(alive_ids)}"
top_texts = [
(10, 6, left, (150, 150, 255) if not tracks else (200, 230, 200), 17),
(max(200, w - 440), 8, f"{stats_line} | h={belt_mm:.0f}мм{bg_tag}", (200, 230, 200), 14),
]
top_bar = _draw_texts(top_bar, top_texts)
view = np.vstack([top_bar, view])
h = view.shape[0]
# прогресс уверенности внизу
bar_y = h - 8
cv2.rectangle(view, (0, bar_y), (w, h), (40, 40, 40), -1)
conf_show = 100 if (all_locked and tracks) else max_conf_pending
fill = int(w * min(1.0, conf_show / 100.0))
col = (40, 200, 40) if (all_locked and tracks) else (0, 200, 255)
cv2.rectangle(view, (0, bar_y), (fill, h), col, -1)
thr = int(w * confidence_pct / 100.0)
cv2.line(view, (thr, bar_y), (thr, h), (255, 255, 255), 1)
return view

25
cv/docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
vision:
build: .
container_name: realsense_vision
restart: unless-stopped
network_mode: host
privileged: true
devices:
- /dev/video0:/dev/video0
- /dev/video1:/dev/video1
- /dev/video2:/dev/video2
- /dev/video3:/dev/video3
- /dev/video4:/dev/video4
- /dev/video5:/dev/video5
volumes:
# Copy config.example.yaml → config.yaml locally before enabling MQTT/hardware.
- ./config.yaml:/app/config.yaml:ro
- ./debug_frames:/app/debug_frames
# Prefer: command with --no-mqtt until credentials are configured.
group_add:
- video
environment:
- QT_X11_NO_MITSHM=1
# Без GUI в контейнере по умолчанию; превью — через native run
command: ["python", "main.py", "-c", "config.yaml"]

47
cv/journal.py Normal file
View File

@@ -0,0 +1,47 @@
"""JSONL-журнал решений классификатора — метрики для отчёта и защиты.
Каждая строка — одно зафиксированное решение (LOCK):
время, зона, габариты, circle_ratio, флаг «неуверенно», причина.
Анализ (корректность, доля неуверенных, распределение зон):
.venv/bin/python -c "
import json;
rows=[json.loads(l) for l in open('logs/decisions.jsonl')];
from collections import Counter;
print(Counter(r['zone'] for r in rows));
print('uncertain:', sum(r['uncertain'] for r in rows), '/', len(rows))"
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from classify import ClassificationResult
def append_decision(
path: str | Path,
result: ClassificationResult,
uncertain: bool = False,
source: str = "main",
track_id: int | None = None,
) -> None:
entry = {
"track_id": track_id,
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"ts_ms": int(time.time() * 1000),
"zone": result.category.zone,
"category": result.category.value,
"label_ru": result.category.ru_label,
"dims_mm": [round(float(x), 1) for x in result.dims_sorted_mm],
"circle_ratio": round(float(result.circle_ratio), 4),
"uncertain": bool(uncertain),
"reason": result.reason,
"source": source,
}
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")

330
cv/main.py Executable file
View File

@@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""
Алгоритмическая часть трека 3: Intel RealSense D415 на Orange PI.
Пайплайн:
depth+color → сегментация объекта на ленте → габариты L×W×H + circle_ratio
→ классификация (B/C/D) → MQTT → сервоприводы Arduino (без правок arduino_code).
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional
import cv2
import yaml
from camera import RealSenseV4L2, depth_colormap
from classify import Category, ClassificationResult
from journal import append_decision
from measure import measure_flat_object, measure_object, segment_objects, segment_rgb_objects, is_plausible_measurement
from mqtt_bridge import MqttBridge
from stabilize import DecisionStabilizer
ZONE_TO_CATEGORY = {
"B": Category.SUITABLE,
"C": Category.OVERSIZE,
"D": Category.NEED_PACK,
}
def resolve_config_path(path: Path) -> Path:
if path.exists():
return path
example = path.with_name("config.example.yaml")
if example.exists():
return example
raise FileNotFoundError(f"Config not found: {path} (and no config.example.yaml)")
def load_config(path: Path) -> Dict[str, Any]:
resolved = resolve_config_path(Path(path))
with open(resolved, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def draw_overlay(
color_bgr,
depth_mm,
measurement,
result: Optional[ClassificationResult],
belt_mm: float,
):
vis = color_bgr.copy()
depth_vis = depth_colormap(depth_mm)
if measurement is not None:
cv2.drawContours(vis, [measurement.contour], -1, (0, 255, 0), 2)
cx, cy = measurement.centroid_px
cv2.circle(vis, (cx, cy), 4, (0, 0, 255), -1)
lines = [
f"L={measurement.length_mm:.0f} W={measurement.width_mm:.0f} H={measurement.height_mm:.0f} mm",
f"circle_ratio={measurement.circle_ratio:.3f}",
]
if result is not None:
lines.append(f"{result.category.zone}: {result.category.ru_label}")
y = 24
for line in lines:
cv2.putText(vis, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (20, 20, 20), 3, cv2.LINE_AA)
cv2.putText(vis, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 1, cv2.LINE_AA)
y += 22
cv2.putText(
vis,
f"belt={belt_mm:.0f}mm",
(10, vis.shape[0] - 12),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(200, 200, 200),
1,
cv2.LINE_AA,
)
return vis, depth_vis
def main() -> int:
parser = argparse.ArgumentParser(description="RealSense D415 classifier for hackathon track 3")
parser.add_argument(
"-c",
"--config",
default=str(Path(__file__).with_name("config.yaml")),
help="Путь к config.yaml",
)
parser.add_argument("--once", action="store_true", help="Один кадр и выход")
parser.add_argument("--no-mqtt", action="store_true", help="Не публиковать в MQTT")
parser.add_argument(
"--preview",
action="store_true",
help="Живое превью в debug_frames/live_*.jpg (без GTK-окон)",
)
parser.add_argument("--dry-route", action="store_true", help="Не двигать серво")
parser.add_argument("--no-motor", action="store_true", help="Не включать шаговик ленты")
args = parser.parse_args()
cfg = load_config(Path(args.config))
cam_cfg = cfg["camera"]
cls_cfg = cfg["classification"]
rt = cfg.get("runtime", {})
mqtt_cfg = dict(cfg.get("mqtt", {}))
routing_cfg = dict(cfg.get("routing", {}))
motor_cfg = dict(cfg.get("motor", {}))
if args.no_mqtt:
mqtt_cfg["enabled"] = False
if args.dry_route:
routing_cfg["enabled"] = False
if args.no_motor:
motor_cfg["enabled"] = False
mqtt_cfg["_routing"] = routing_cfg
mqtt_cfg["_motor"] = motor_cfg
show_preview = args.preview or bool(rt.get("show_preview", False))
save_debug = bool(rt.get("save_debug_frames", False)) or show_preview
debug_dir = Path(rt.get("debug_dir", "debug_frames"))
if save_debug or show_preview:
debug_dir.mkdir(parents=True, exist_ok=True)
live_color = debug_dir / "live_color.jpg"
live_depth = debug_dir / "live_depth.jpg"
preview_every = max(1, int(rt.get("preview_every_n", 3)))
print("[vision] открываю RealSense D415…")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=bool(cfg.get("use_color", False)),
)
belt_mm = float(cfg.get("belt_distance_mm") or 0)
if belt_mm <= 0:
print("[vision] калибровка плоскости ленты (уберите объекты)…")
belt_mm = cam.estimate_belt_distance_mm()
print(f"[vision] belt_distance_mm ≈ {belt_mm:.1f}")
else:
print(f"[vision] belt_distance_mm из конфига: {belt_mm:.1f}")
background = None
color_background = None
if bool(cfg.get("use_background_map", False)):
print("[vision] снимаю фоновую карту сцены — лента должна быть ПУСТОЙ…")
try:
background = cam.capture_background(samples=15)
print("[vision] фоновая карта активна (сегментация относительно фона)")
except RuntimeError as exc:
print(f"[vision] фоновая карта не снята ({exc}), работаю по скалярной высоте")
color_background = cam.capture_background_rgb(samples=10)
if color_background is not None:
print("[vision] RGB-фон снят — плоские товары (телефон) будут детектироваться")
bridge = MqttBridge(mqtt_cfg)
print(f"[vision] MQTT: {'OK' if bridge.connected else 'offline/disabled'}")
if bridge.connected:
bridge.start_conveyor()
if show_preview:
print(f"[vision] превью → {live_color} и {live_depth} (обновляются на лету)")
print("[vision] откройте файлы в IDE/файловом менеджере или: eog debug_frames/live_color.jpg")
confirm_need = int(rt.get("confirm_frames", 8))
process_every_n = max(1, int(rt.get("process_every_n", 1)))
frame_i = 0
thr = float(cls_cfg.get("circle_ratio_threshold", 0.8))
fallback_zone = str(cls_cfg.get("uncertain_fallback_zone", "C")).upper()
stabilizer = DecisionStabilizer(
window=12,
confirm_frames=confirm_need,
lost_frames=12,
enter_circle=thr,
exit_circle=thr - 0.08,
uncertain_after=int(cls_cfg.get("uncertain_after_frames", 45)),
fallback=ZONE_TO_CATEGORY.get(fallback_zone, Category.OVERSIZE),
)
last_routed_zone: Optional[str] = None
decisions_log = Path(rt.get("decisions_log", "logs/decisions.jsonl"))
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
try:
while True:
pair = cam.read()
if pair is None:
print("[vision] нет кадра", file=sys.stderr)
time.sleep(0.05)
continue
frame_i += 1
measurement = None
result = None
if frame_i % process_every_n == 0:
candidates = segment_objects(
pair.depth_mm,
belt_distance_mm=belt_mm,
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
min_object_height_mm=float(cfg.get("min_object_height_mm", 5)),
min_area_px=int(cfg.get("min_object_area_px", 800)),
background_mm=background,
max_objects=int(cfg.get("max_objects_in_frame", 3)),
roi_margin=cfg.get("roi_margin"),
)
seg = None
best = None # (score, mask, contour, measurement)
for mask, contour in candidates:
m_try = measure_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
min_object_height_mm=float(cfg.get("min_object_height_mm", 8)),
roi_margin=cfg.get("roi_margin"),
)
if m_try is None or not is_plausible_measurement(m_try):
continue
# приоритет: круглый и более высокий товар над шумом ленты
score = float(m_try.circle_ratio) * 2.0 + min(float(m_try.height_mm), 200.0) / 100.0
if best is None or score > best[0]:
best = (score, mask, contour, m_try)
if best is not None:
_, mask, contour, measurement = best
seg = (mask, contour)
else:
measurement = None
# depth ничего не видит → плоский товар (телефон) ищем по RGB
if (
measurement is None
and bool(cfg.get("detect_flat_rgb", False))
and color_background is not None
and not pair.color_is_depth_preview
):
rgb_objs = segment_rgb_objects(
pair.color_bgr,
color_background,
min_area_px=int(cfg.get("min_object_area_px", 800)),
diff_threshold=int(cfg.get("rgb_diff_threshold", 35)),
max_objects=1,
exclude_mask=seg[0] if seg is not None else None,
)
if rgb_objs:
mask, contour = rgb_objs[0]
measurement = measure_flat_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
color_bgr=pair.color_bgr,
color_bg_bgr=color_background,
)
if measurement is not None and not is_plausible_measurement(measurement):
measurement = None
decision = stabilizer.update(
measurement,
min_mm=cls_cfg.get("min_mm", [10, 10, 10]),
max_mm=cls_cfg.get("max_mm", [450, 320, 320]),
)
if decision.locked and decision.result is not None:
result = decision.result
zone = result.category.zone
if zone != last_routed_zone:
tag = "UNCERTAIN→" if decision.uncertain else "LOCK "
print(
f"[vision] {tag}{zone} | {result.category.ru_label} | "
f"dims={result.dims_sorted_mm} | ratio={result.circle_ratio:.3f} | {result.reason}"
)
append_decision(decisions_log, result, uncertain=decision.uncertain, source="main")
bridge.publish_result(result)
bridge.route(result.category)
last_routed_zone = zone
elif not decision.present:
last_routed_zone = None
if show_preview or save_debug:
vis, depth_vis = draw_overlay(pair.color_bgr, pair.depth_mm, measurement, result, belt_mm)
if save_debug and result is not None and not show_preview:
out = debug_dir / f"frame_{frame_i:06d}_{result.category.value}.jpg"
cv2.imwrite(str(out), vis)
# headless OpenCV: пишем JPEG вместо cv2.imshow
if show_preview and frame_i % preview_every == 0:
cv2.imwrite(str(live_color), vis)
cv2.imwrite(str(live_depth), depth_vis)
if args.once:
if result is not None:
print(result)
if show_preview:
vis, depth_vis = draw_overlay(pair.color_bgr, pair.depth_mm, measurement, result, belt_mm)
cv2.imwrite(str(live_color), vis)
cv2.imwrite(str(live_depth), depth_vis)
print(f"[vision] кадр сохранён: {live_color}")
break
except KeyboardInterrupt:
print("\n[vision] stop")
finally:
bridge.close()
cam.release()
return 0
if __name__ == "__main__":
# Чтобы импорты работали и как пакет, и как скрипт
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main())

719
cv/measure.py Normal file
View File

@@ -0,0 +1,719 @@
"""Сегментация и измерение по ТЗ трека 3 — без эвристик «подкрутки»."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Tuple
import cv2
import numpy as np
@dataclass
class ObjectMeasurement:
length_mm: float
width_mm: float
height_mm: float
circle_ratio: float # итог для классификатора: max устойчивых сечений
area_px: int
centroid_px: Tuple[int, int]
contour: np.ndarray
mask: np.ndarray
top_ratio: float = 0.0 # rin/rout вида сверху
section_ratio: float = 0.0 # лучший устойчивый 3D-срез
source: str = "depth" # "depth" | "rgb" (плоский товар, найден по RGB)
clipped_by_frame: bool = False # объект упирается в край кадра → габарит неполный
def is_plausible_measurement(
m: ObjectMeasurement,
min_footprint_mm: float = 15.0,
) -> bool:
"""Отсев шума depth/RGB до классификации (не путать с ТЗ-негабаритом).
Мелкие пятна и «0×0×0 мм» не должны попадать в трекер — иначе
check_size(<10 мм) даёт ложный класс C.
"""
L, W, H = float(m.length_mm), float(m.width_mm), float(m.height_mm)
if L <= 1.5 or W <= 1.5:
return False
if int(m.area_px) < 120:
return False
a, b, c = sorted((L, W, H), reverse=True)
if a < min_footprint_mm:
return False
if c <= 0.5:
return False
# RGB-шум: нет высоты и крошечное пятно на ленте
if getattr(m, "source", "depth") == "rgb" and H < 2.0 and a < 45.0:
return False
return True
def mask_iou(a: np.ndarray, b: np.ndarray) -> float:
"""Классический IoU масок."""
inter = int(np.count_nonzero((a > 0) & (b > 0)))
if inter == 0:
return 0.0
ua = int(np.count_nonzero(a > 0))
ub = int(np.count_nonzero(b > 0))
return inter / float(ua + ub - inter)
def mask_overlap_min(a: np.ndarray, b: np.ndarray) -> float:
"""Доля пересечения относительно меньшей маски (0..1).
≥0.5 ≈ «хотя бы половина одного объекта лежит на другом».
Удобнее IoU, когда кусок намного меньше целого.
"""
inter = int(np.count_nonzero((a > 0) & (b > 0)))
if inter == 0:
return 0.0
ua = int(np.count_nonzero(a > 0))
ub = int(np.count_nonzero(b > 0))
return inter / float(max(1, min(ua, ub)))
def merge_overlapping_masks(
items: List[Tuple[np.ndarray, np.ndarray]],
overlap_thr: float = 0.5,
near_gap_px: int = 14,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Склеить контуры при IoU/пересечении ≥ thr или узкой дыре depth (near_gap)."""
if len(items) <= 1:
return items
items = sorted(items, key=lambda ic: cv2.contourArea(ic[1]), reverse=True)
used = [False] * len(items)
out: List[Tuple[np.ndarray, np.ndarray]] = []
def _near(a: np.ndarray, b: np.ndarray) -> bool:
xa, ya, wa, ha = cv2.boundingRect(a)
xb, yb, wb, hb = cv2.boundingRect(b)
g = int(near_gap_px)
return not (
xa + wa + g < xb or xb + wb + g < xa or ya + ha + g < yb or yb + hb + g < ya
)
for i, (mask_i, _) in enumerate(items):
if used[i]:
continue
merged = mask_i.copy()
used[i] = True
changed = True
while changed:
changed = False
for j, (mask_j, _) in enumerate(items):
if used[j]:
continue
hit = (
mask_overlap_min(merged, mask_j) >= overlap_thr
or mask_iou(merged, mask_j) >= overlap_thr
or _near(merged, mask_j)
)
if hit:
merged = cv2.bitwise_or(merged, mask_j)
used[j] = True
changed = True
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
merged = cv2.morphologyEx(merged, cv2.MORPH_CLOSE, k, iterations=1)
contours, _ = cv2.findContours(merged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
continue
contour = max(contours, key=cv2.contourArea)
clean = np.zeros_like(merged)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
out.append((clean, contour))
return out
def merge_overlapping_measurements(
measurements: List[ObjectMeasurement],
overlap_thr: float = 0.5,
) -> List[ObjectMeasurement]:
"""Склеить измерения с пересекающимися масками (оставить более крупное)."""
if len(measurements) <= 1:
return measurements
ms = sorted(measurements, key=lambda m: m.area_px, reverse=True)
kept: List[ObjectMeasurement] = []
for m in ms:
drop = False
for k in kept:
if m.mask.shape != k.mask.shape:
continue
if mask_overlap_min(m.mask, k.mask) >= overlap_thr or mask_iou(m.mask, k.mask) >= overlap_thr:
drop = True
break
if not drop:
kept.append(m)
return kept
def apply_roi_margin(
mask: np.ndarray,
margin: Optional[dict] = None,
) -> np.ndarray:
"""Обнулить края кадра (ролики, борта, плата), доли 0..1 от H/W."""
if not margin:
return mask
h, w = mask.shape[:2]
top = int(h * float(margin.get("top", 0)))
bottom = int(h * float(margin.get("bottom", 0)))
left = int(w * float(margin.get("left", 0)))
right = int(w * float(margin.get("right", 0)))
out = mask.copy()
if top > 0:
out[:top, :] = 0
if bottom > 0:
out[h - bottom :, :] = 0
if left > 0:
out[:, :left] = 0
if right > 0:
out[:, w - right :] = 0
return out
def contour_touches_border(
contour: np.ndarray,
shape: Tuple[int, ...],
margin_px: int = 3,
roi_margin: Optional[dict] = None,
) -> bool:
"""True если контур упирается в край кадра/ROI — реальный размер может быть больше."""
h, w = int(shape[0]), int(shape[1])
top = int(h * float((roi_margin or {}).get("top", 0)))
bottom = int(h * float((roi_margin or {}).get("bottom", 0)))
left = int(w * float((roi_margin or {}).get("left", 0)))
right = int(w * float((roi_margin or {}).get("right", 0)))
y0, y1 = top + margin_px, h - bottom - 1 - margin_px
x0, x1 = left + margin_px, w - right - 1 - margin_px
pts = contour.reshape(-1, 2)
xs, ys = pts[:, 0], pts[:, 1]
return bool(
(xs <= x0).any()
or (xs >= x1).any()
or (ys <= y0).any()
or (ys >= y1).any()
)
def segment_objects(
depth_mm: np.ndarray,
belt_distance_mm: float,
belt_tolerance_mm: float = 25.0,
min_object_height_mm: float = 5.0,
min_area_px: int = 800,
background_mm: Optional[np.ndarray] = None,
max_objects: int = 3,
roi_margin: Optional[dict] = None,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Все объекты в кадре (крупнейшие первыми), до max_objects штук.
Порог высоты — как раньше (строгий): мягкий «ореол» раздувал маску на ленту
и ломал габариты/круг → путаница B/C/D.
"""
valid = (depth_mm > 50) & (depth_mm < 5000)
hmin = float(min_object_height_mm)
if background_mm is not None:
bg = background_mm.astype(np.float32)
d = depth_mm.astype(np.float32)
raised = valid & (bg > 50) & (d < bg - hmin)
near_belt_band = d > (bg - 520.0)
else:
raised = valid & (depth_mm < (belt_distance_mm - hmin))
near_belt_band = depth_mm > (belt_distance_mm - 450)
mask = (raised & near_belt_band).astype(np.uint8) * 255
mask = apply_roi_margin(mask, roi_margin)
k_open = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open, iterations=1)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
frame_area = mask.shape[0] * mask.shape[1]
raw: List[Tuple[np.ndarray, np.ndarray]] = []
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
area = cv2.contourArea(contour)
# крупные товары могут занимать почти весь кадр — не отсекать как шум
if area < min_area_px or area > frame_area * 0.92:
continue
clean = np.zeros_like(mask)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
raw.append((clean, contour))
# только явное пересечение / узкая щель — не склеивать соседние товары
merged = merge_overlapping_masks(raw, overlap_thr=0.5, near_gap_px=14)
return merged[:max_objects]
def segment_object(
depth_mm: np.ndarray,
belt_distance_mm: float,
belt_tolerance_mm: float = 25.0,
min_object_height_mm: float = 5.0,
min_area_px: int = 800,
background_mm: Optional[np.ndarray] = None,
roi_margin: Optional[dict] = None,
) -> Optional[Tuple[np.ndarray, np.ndarray]]:
"""Крупнейший объект (для main.py/calibrate.py — один товар в накопителе)."""
objs = segment_objects(
depth_mm,
belt_distance_mm,
belt_tolerance_mm=belt_tolerance_mm,
min_object_height_mm=min_object_height_mm,
min_area_px=min_area_px,
background_mm=background_mm,
max_objects=1,
roi_margin=roi_margin,
)
return objs[0] if objs else None
def _pixel_to_xy_mm(
u: float, v: float, z_mm: float, fx: float, fy: float, cx: float, cy: float
) -> Tuple[float, float]:
return (u - cx) * z_mm / fx, (v - cy) * z_mm / fy
def measure_object(
depth_mm: np.ndarray,
mask: np.ndarray,
contour: np.ndarray,
belt_distance_mm: float,
fx: float,
fy: float,
cx: float,
cy: float,
background_mm: Optional[np.ndarray] = None,
min_object_height_mm: float = 5.0,
roi_margin: Optional[dict] = None,
) -> Optional[ObjectMeasurement]:
ys, xs = np.where(mask > 0)
if xs.size < 50:
return None
z_vals = depth_mm[ys, xs].astype(np.float32)
z_vals = z_vals[z_vals > 0]
if z_vals.size < 50:
return None
z_med = float(np.median(z_vals))
# высота относительно локального фона (платформа/лента под объектом)
belt_local = belt_distance_mm
if background_mm is not None:
bg_vals = background_mm[ys, xs].astype(np.float32)
bg_vals = bg_vals[bg_vals > 50]
if bg_vals.size >= 50:
belt_local = float(np.median(bg_vals))
height_mm = max(0.0, belt_local - z_med)
pts_mm = []
for p in contour.reshape(-1, 2):
u, v = float(p[0]), float(p[1])
x, y = _pixel_to_xy_mm(u, v, z_med, fx, fy, cx, cy)
pts_mm.append([x, y])
pts_mm = np.asarray(pts_mm, dtype=np.float32)
if pts_mm.shape[0] < 5:
return None
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
rw, rh = rect[1]
length_mm = float(max(rw, rh))
width_mm = float(min(rw, rh))
touches_edge = contour_touches_border(contour, mask.shape, roi_margin=roi_margin)
# Негабарит «не влезает в кадр» только если реально занимает большую долю FOV.
# Лежачая бутылка может чуть касаться ROI — это не повод форсировать 500 мм.
span_x = float(xs.max() - xs.min())
span_y = float(ys.max() - ys.min())
mh = float((roi_margin or {}).get("top", 0.0)) + float((roi_margin or {}).get("bottom", 0.0))
mw = float((roi_margin or {}).get("left", 0.0)) + float((roi_margin or {}).get("right", 0.0))
usable_w = mask.shape[1] * max(0.5, 1.0 - mw)
usable_h = mask.shape[0] * max(0.5, 1.0 - mh)
spans_frame = (span_x >= 0.72 * usable_w) or (span_y >= 0.72 * usable_h)
clipped = bool(touches_edge and spans_frame)
# отсев шума ленты / руки на краю (низкий «холм» большой площади → не товар)
# но не отсекаем крупные обрезанные объекты — они уйдут в C
if not clipped and height_mm < 30.0 and max(length_mm, width_mm) > 150.0:
return None
# жёсткий пол — иначе складки ленты дают ложный C
if height_mm < max(20.0, float(min_object_height_mm)):
return None
# раньше >520 отбрасывали → ложный B на негабарите в FOV;
# оставляем измерение: classify отправит в C (>450)
if max(length_mm, width_mm) > 2000.0 and not clipped:
return None
# объект не помещается в кадр → габарит занижен; форсируем > max ТЗ
if clipped:
length_mm = max(length_mm, 500.0)
top_ratio = rin_rout(pts_mm)
section_ratio = 0.0
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
if cloud is not None:
section_ratio = robust_section_ratio(cloud)
# ТЗ: круг в любом сечении. Один шумный 3D-срез не считаем:
# D только если top>=0.8 ИЛИ ≥2 среза >=0.8 (внутри robust_section_ratio).
circle_ratio = max(top_ratio, section_ratio)
if clipped:
# обрезанный негабарит не классифицируем по кругу
circle_ratio = min(circle_ratio, 0.5)
m = cv2.moments(contour)
if m["m00"] > 0:
cx_px = int(m["m10"] / m["m00"])
cy_px = int(m["m01"] / m["m00"])
else:
cx_px, cy_px = int(xs.mean()), int(ys.mean())
return ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=height_mm,
circle_ratio=float(circle_ratio),
area_px=int(xs.size),
centroid_px=(cx_px, cy_px),
contour=contour,
mask=mask,
top_ratio=float(top_ratio),
section_ratio=float(section_ratio),
clipped_by_frame=bool(clipped),
)
def segment_rgb_objects(
color_bgr: np.ndarray,
color_bg_bgr: np.ndarray,
min_area_px: int = 800,
diff_threshold: int = 35,
max_objects: int = 3,
exclude_mask: Optional[np.ndarray] = None,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Плоские товары (телефон и т.п.) по разнице с RGB-фоном пустой ленты.
exclude_mask — зоны, уже найденные по depth (не дублируем объекты).
Тени (пропорциональное затемнение каналов) отбрасываются.
"""
if color_bgr.shape != color_bg_bgr.shape:
return []
fg = color_bgr.astype(np.float32)
bg = color_bg_bgr.astype(np.float32)
gray = np.max(np.abs(fg - bg), axis=2).astype(np.uint8)
_, m = cv2.threshold(gray, int(diff_threshold), 255, cv2.THRESH_BINARY)
ratio = (fg + 8.0) / (bg + 8.0)
r_med = np.median(ratio, axis=2)
r_spread = np.max(ratio, axis=2) - np.min(ratio, axis=2)
is_shadow = (r_med < 0.93) & (r_med > 0.38) & (r_spread < 0.14)
m[is_shadow] = 0
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k, iterations=1)
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=2)
if exclude_mask is not None:
excl = cv2.dilate(exclude_mask, cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)))
m[excl > 0] = 0
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out: List[Tuple[np.ndarray, np.ndarray]] = []
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
if cv2.contourArea(contour) < min_area_px or len(out) >= max_objects:
break
clean = np.zeros_like(m)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
out.append((clean, contour))
return out
def measure_flat_object(
depth_mm: np.ndarray,
mask: np.ndarray,
contour: np.ndarray,
belt_distance_mm: float,
fx: float,
fy: float,
cx: float,
cy: float,
background_mm: Optional[np.ndarray] = None,
color_bgr: Optional[np.ndarray] = None,
color_bg_bgr: Optional[np.ndarray] = None,
) -> Optional[ObjectMeasurement]:
"""Измерение товара, найденного по RGB: без отсева по минимальной высоте."""
ys, xs = np.where(mask > 0)
if xs.size < 50:
return None
z_plane = belt_distance_mm
if background_mm is not None:
bg_vals = background_mm[ys, xs].astype(np.float32)
bg_vals = bg_vals[bg_vals > 50]
if bg_vals.size >= 50:
z_plane = float(np.median(bg_vals))
z_vals = depth_mm[ys, xs].astype(np.float32)
z_vals = z_vals[z_vals > 0]
height_mm = max(0.0, z_plane - float(np.median(z_vals))) if z_vals.size >= 50 else 0.0
pts_mm = []
for p in contour.reshape(-1, 2):
x, y = _pixel_to_xy_mm(float(p[0]), float(p[1]), z_plane, fx, fy, cx, cy)
pts_mm.append([x, y])
pts_mm = np.asarray(pts_mm, dtype=np.float32)
if pts_mm.shape[0] < 3:
return None
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
rw, rh = rect[1]
length_mm = float(max(rw, rh))
width_mm = float(min(rw, rh))
if max(length_mm, width_mm) > 520.0 or max(length_mm, width_mm) < 5.0:
return None
top_ratio = rin_rout(pts_mm)
m = cv2.moments(contour)
if m["m00"] > 0:
cx_px, cy_px = int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"])
else:
cx_px, cy_px = int(xs.mean()), int(ys.mean())
return ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=float(height_mm),
circle_ratio=float(top_ratio),
area_px=int(xs.size),
centroid_px=(cx_px, cy_px),
contour=contour,
mask=mask,
top_ratio=float(top_ratio),
section_ratio=0.0,
source="rgb",
)
def rin_rout(pts_xy: np.ndarray) -> float:
"""ТЗ: r_in / r_out по выпуклой оболочке сечения."""
pts = np.asarray(pts_xy, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] < 3:
return 0.0
hull = cv2.convexHull(pts.reshape(-1, 1, 2))
hull_pts = hull.reshape(-1, 2)
if hull_pts.shape[0] < 3:
return 0.0
(_center, r_out) = cv2.minEnclosingCircle(hull)
r_out = float(r_out)
if r_out < 1e-6:
return 0.0
r_in = _inscribed_radius_mm(hull_pts)
if r_in <= 0:
return 0.0
return float(np.clip(r_in / r_out, 0.0, 1.0))
def robust_section_ratio(cloud: np.ndarray, thr: float = 0.8) -> float:
"""
Поперечные срезы. Из логов: у круга часто 12 среза ≥0.85, иногда медиана падает.
- ≥1 срез с score≥0.85 → принимаем (уверенный круг/дуга);
- иначе ≥2 среза ≥0.8 → max;
- иначе медиана (антишум для коробки).
"""
ratios = _section_ratios_3d(cloud)
if not ratios:
return 0.0
very = [r for r in ratios if r >= 0.85]
if very:
return float(max(very))
strong = [r for r in ratios if r >= thr]
if len(strong) >= 2:
return float(max(strong))
return float(np.median(ratios))
def _point_cloud(
depth_mm: np.ndarray,
mask: np.ndarray,
fx: float,
fy: float,
cx: float,
cy: float,
) -> Optional[np.ndarray]:
ys, xs = np.where(mask > 0)
if xs.size < 150:
return None
z = depth_mm[ys, xs].astype(np.float32)
ok = (z > 50) & (z < 5000)
xs, ys, z = xs[ok], ys[ok], z[ok]
if xs.size < 150:
return None
# детерминированный даунсэмпл (без random)
if xs.size > 4000:
step = int(np.ceil(xs.size / 4000))
xs, ys, z = xs[::step], ys[::step], z[::step]
X = (xs.astype(np.float32) - cx) * z / fx
Y = (ys.astype(np.float32) - cy) * z / fy
return np.column_stack([X, Y, z]).astype(np.float32)
def _section_ratios_3d(cloud: np.ndarray) -> List[float]:
mean = cloud.mean(axis=0)
centered = cloud - mean
try:
_u, s, vt = np.linalg.svd(centered, full_matrices=False)
except np.linalg.LinAlgError:
return []
out: List[float] = []
# только вдоль самой длинной оси — поперечные сечения цилиндра/коробки
for axis_i in range(min(1, vt.shape[0])):
if float(s[axis_i]) < 1e-6:
continue
axis = vt[axis_i]
axis = axis / (np.linalg.norm(axis) + 1e-9)
along = centered @ axis
ref = np.array([0.0, 0.0, 1.0], dtype=np.float32)
if abs(float(np.dot(axis, ref))) > 0.9:
ref = np.array([1.0, 0.0, 0.0], dtype=np.float32)
u = np.cross(axis, ref)
u /= np.linalg.norm(u) + 1e-9
v = np.cross(axis, u)
a0, a1 = float(np.percentile(along, 15)), float(np.percentile(along, 85))
if a1 - a0 < 10.0:
continue
for t in (0.2, 0.35, 0.5, 0.65, 0.8):
ca = a0 + t * (a1 - a0)
half = max(4.0, 0.06 * (a1 - a0))
band = np.abs(along - ca) <= half
if int(band.sum()) < 40:
continue
pts = centered[band]
sec = np.column_stack([pts @ u, pts @ v]).astype(np.float32)
out.append(_section_score(sec))
return out
def _section_score(sec: np.ndarray) -> float:
"""
Чистый rin/rout. Для дуги лежачего цилиндра (depth видит полкруга)
допускаем score 0.85 только при жёстком circle-fit:
малый residual, почти равные радиусы, покрытие ≥200°, bbox не «палка».
Прямоугольное сечение fit не проходит → остаётся rin/rout < 0.8.
"""
direct = rin_rout(sec)
if direct >= 0.8:
return float(direct)
fit = _fit_circle_arc(sec)
if fit is None:
return float(direct)
return float(max(direct, 0.85))
def _fit_circle_arc(pts: np.ndarray) -> Optional[Tuple[np.ndarray, float]]:
pts = np.asarray(pts, dtype=np.float64).reshape(-1, 2)
if pts.shape[0] < 35:
return None
c0 = pts.mean(axis=0)
x0 = pts - c0
try:
_u, s, _vt = np.linalg.svd(x0, full_matrices=False)
except np.linalg.LinAlgError:
return None
if s.shape[0] < 2 or float(s[0]) < 1e-6:
return None
# сечение не должно быть линией
if float(s[1] / (s[0] + 1e-9)) < 0.45:
return None
x, y = pts[:, 0], pts[:, 1]
A = np.column_stack([2 * x, 2 * y, np.ones_like(x)])
b = x * x + y * y
try:
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
except np.linalg.LinAlgError:
return None
cx_, cy_, c = sol
r2 = c + cx_ * cx_ + cy_ * cy_
if r2 <= 1.0:
return None
r = float(np.sqrt(r2))
rad = np.sqrt((x - cx_) ** 2 + (y - cy_) ** 2)
rel = float(np.sqrt(np.mean((rad - r) ** 2)) / (r + 1e-9))
rad_cv = float(rad.std() / (rad.mean() + 1e-9))
if rel > 0.04 or rad_cv > 0.04:
return None
bw = float(x.max() - x.min())
bh = float(y.max() - y.min())
aspect = max(bw, bh) / max(min(bw, bh), 1e-6)
# у круга/полукруга bbox близок к квадрату; у прямоугольника 2:1 — нет
if aspect > 1.45:
return None
if r < 0.40 * max(bw, bh) or r > 0.70 * max(bw, bh):
return None
ang = np.arctan2(y - cy_, x - cx_)
ang = np.sort(ang)
gaps = np.diff(ang)
gaps = np.append(gaps, ang[0] + 2 * np.pi - ang[-1])
coverage = float(2 * np.pi - gaps.max())
if coverage < np.deg2rad(200.0):
return None
return np.array([cx_, cy_], dtype=np.float32), r
def _inscribed_radius_mm(pts_xy_mm: np.ndarray, grid: int = 192) -> float:
x_min, y_min = pts_xy_mm.min(axis=0)
x_max, y_max = pts_xy_mm.max(axis=0)
span = max(float(x_max - x_min), float(y_max - y_min), 1.0)
pad = 8
inner = grid - 2 * pad
if inner < 16:
return 0.0
scale = inner / span
img = np.zeros((grid, grid), dtype=np.uint8)
pts_px = ((pts_xy_mm - np.array([x_min, y_min], dtype=np.float32)) * scale).astype(np.int32)
pts_px[:, 0] = np.clip(pts_px[:, 0] + pad, 0, grid - 1)
pts_px[:, 1] = np.clip(pts_px[:, 1] + pad, 0, grid - 1)
cv2.fillPoly(img, [pts_px], 255)
if img.max() == 0 or float((img > 0).mean()) > 0.98:
return 0.0
dist = cv2.distanceTransform(img, cv2.DIST_L2, 5)
return float(dist.max()) / scale
def circularity_ratio(pts_xy_mm: np.ndarray) -> float:
return rin_rout(pts_xy_mm)
# совместимость со старыми вызовами
def max_section_circle_ratio(
depth_mm: np.ndarray,
mask: np.ndarray,
top_pts_mm: np.ndarray,
fx: float,
fy: float,
cx: float,
cy: float,
) -> float:
top = rin_rout(top_pts_mm)
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
sec = robust_section_ratio(cloud) if cloud is not None else 0.0
return float(max(top, sec))
def section_rin_rout(sec: np.ndarray) -> float:
return rin_rout(sec)

172
cv/mqtt_bridge.py Normal file
View File

@@ -0,0 +1,172 @@
"""MQTT: публикация категории + команды серво/мотору (существующий API Arduino)."""
from __future__ import annotations
import json
import threading
import time
from typing import Any, Dict, Optional
import paho.mqtt.client as mqtt
from classify import Category, ClassificationResult
class MqttBridge:
def __init__(self, cfg: Dict[str, Any]) -> None:
self.cfg = cfg
self.enabled = bool(cfg.get("enabled", False))
self.routing_cfg = cfg.get("_routing", {})
self.motor_cfg = cfg.get("_motor", {})
self._last_route_ts = 0.0
self._last_category: Optional[str] = None
self.client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=cfg.get("client_id", "vision_classifier_opi"),
)
user = cfg.get("user")
password = cfg.get("password")
if user:
self.client.username_pw_set(user, password)
self._connected = False
if self.enabled:
try:
self.client.connect(cfg["broker"], int(cfg.get("port", 1883)), 30)
self.client.loop_start()
# короткая проверка
time.sleep(0.3)
self._connected = True
print(f"[mqtt] подключено к {cfg['broker']}:{cfg.get('port', 1883)}")
except Exception as exc:
print(f"[mqtt] не удалось подключиться: {exc}")
self._connected = False
@property
def connected(self) -> bool:
return self._connected
def start_conveyor(self) -> None:
"""Включить шаговик ленты через уже существующие топики motor/control/*."""
m = self.motor_cfg
if not m.get("enabled", False):
return
if not self.enabled or not self._connected:
return
rpm = int(m.get("rpm", 200))
current = int(m.get("current_percent", 50))
microsteps = int(m.get("microsteps", 16))
self.client.publish("motor/control/driver", "on", qos=1)
self.client.publish("motor/control/tmc/enable", "on", qos=1)
self.client.publish("motor/control/tmc/current_percent", str(current), qos=1)
self.client.publish("motor/control/tmc/microsteps", str(microsteps), qos=1)
if m.get("stealthchop", True):
self.client.publish("motor/control/tmc/stealthchop", "on", qos=1)
self.client.publish("motor/control/rpm", str(rpm), qos=1)
print(f"[mqtt] конвейер: driver ON, rpm={rpm}")
def stop_conveyor(self) -> None:
m = self.motor_cfg
if not m.get("enabled", False):
return
if not self.enabled or not self._connected:
return
self.client.publish("motor/control/rpm", "0", qos=1)
if m.get("disable_on_stop", False):
self.client.publish("motor/control/driver", "off", qos=1)
print("[mqtt] конвейер: rpm=0")
def publish_result(self, result: ClassificationResult) -> None:
if not self.enabled or not self._connected:
return
l, w, h = result.dims_sorted_mm
self.client.publish(
self.cfg.get("topic_result", "vision/feedback/category"),
result.category.value,
qos=1,
)
self.client.publish(
self.cfg.get("topic_dims", "vision/feedback/dimensions"),
f"{l:.1f},{w:.1f},{h:.1f}",
qos=0,
)
self.client.publish(
self.cfg.get("topic_circle", "vision/feedback/circle_ratio"),
f"{result.circle_ratio:.4f}",
qos=0,
)
payload = {
"category": result.category.value,
"zone": result.category.zone,
"label_ru": result.category.ru_label,
"dims_mm": [round(l, 1), round(w, 1), round(h, 1)],
"circle_ratio": round(result.circle_ratio, 4),
"reason": result.reason,
}
self.client.publish(
self.cfg.get("topic_debug", "vision/feedback/debug"),
json.dumps(payload, ensure_ascii=False),
qos=0,
)
def route(self, category: Category) -> None:
"""Отправка команды серво через servo/control/{ch}/angle|enable."""
routing = self.routing_cfg
if not routing.get("enabled", True):
return
if not self.enabled or not self._connected:
return
now = time.time() * 1000.0
cooldown = float(routing.get("cooldown_ms", 1500))
if category.value == self._last_category and (now - self._last_route_ts) < cooldown:
return
zones = routing.get("zones", {})
zone_key = category.zone
zone = zones.get(zone_key)
if not zone:
return
for zk, zcfg in zones.items():
ch = int(zcfg["servo"])
idle = int(zcfg.get("idle_angle", 0))
if zk == zone_key:
continue
self._set_servo(ch, idle, enable=True)
ch = int(zone["servo"])
divert = int(zone.get("divert_angle", 90))
idle = int(zone.get("idle_angle", 0))
hold_ms = int(zone.get("hold_ms", 800))
if category == Category.SUITABLE and divert == idle:
self._set_servo(ch, idle, enable=True)
print(f"[mqtt] зона B — пропуск (servo {ch} idle)")
else:
self._set_servo(ch, divert, enable=True)
print(f"[mqtt] зона {zone_key} — divert servo {ch}{divert}°")
def _return_idle(channel: int = ch, angle: int = idle, delay_s: float = hold_ms / 1000.0) -> None:
time.sleep(delay_s)
self._set_servo(channel, angle, enable=True)
threading.Thread(target=_return_idle, daemon=True).start()
self._last_category = category.value
self._last_route_ts = now
def _set_servo(self, channel: int, angle: int, enable: bool = True) -> None:
base = f"servo/control/{channel}"
self.client.publish(f"{base}/enable", "on" if enable else "off", qos=1)
self.client.publish(f"{base}/angle", str(int(angle)), qos=1)
def close(self) -> None:
try:
self.stop_conveyor()
except Exception:
pass
if self._connected:
self.client.loop_stop()
self.client.disconnect()

6
cv/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
opencv-python-headless>=4.8
numpy>=1.24
paho-mqtt>=2.0
PyYAML>=6.0
pillow>=10.0 # кириллица на HUD (demo_hud.py)
# ffmpeg должен быть в системе (pacman/apt: ffmpeg) — depth Z16 читается через него

17
cv/run.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
if [[ ! -f config.yaml && -f config.example.yaml ]]; then
cp config.example.yaml config.yaml
echo "[cv] created config.yaml from config.example.yaml (MQTT disabled)"
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install -U pip
.venv/bin/pip install -r requirements.txt
fi
exec .venv/bin/python main.py "$@"

307
cv/stabilize.py Normal file
View File

@@ -0,0 +1,307 @@
"""
Стабильный консенсус по кадрам.
Из логов RealSense: круг даёт sec≈0.820.87, но early LOCK на B
залипал навсегда. Поэтому:
• классификация по медиане окна;
• LOCK после прогрева;
• апгрейд B→D при устойчивом круге (≥ confirm кадров подряд);
• D→B и смена зоны после LOCK запрещены (пока объект не исчез).
"""
from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass
from typing import Deque, Optional, Sequence
from classify import Category, ClassificationResult, check_size
from measure import ObjectMeasurement
@dataclass
class StableDecision:
result: Optional[ClassificationResult]
locked: bool
confidence_pct: int
ratio_smooth: float
present: bool
uncertain: bool = False # LOCK по правилу «нет консенсуса → безопасная зона»
class DecisionStabilizer:
def __init__(
self,
window: int = 12,
confirm_frames: int = 8,
lost_frames: int = 12,
enter_circle: float = 0.80,
exit_circle: float = 0.72,
uncertain_after: int = 45,
fallback: Category = Category.OVERSIZE,
) -> None:
self.window = max(5, int(window))
self.confirm_frames = max(3, int(confirm_frames))
self.lost_frames = max(3, int(lost_frames))
self.enter_circle = float(enter_circle)
self.exit_circle = float(exit_circle)
# нет консенсуса за uncertain_after кадров → безопасная зона (ТЗ:
# неоднозначные товары не должны идти в основной поток)
self.uncertain_after = max(self.window + 5, int(uncertain_after))
self.fallback = fallback
self._ratios: Deque[float] = deque(maxlen=self.window)
self._tops: Deque[float] = deque(maxlen=self.window)
self._secs: Deque[float] = deque(maxlen=self.window)
self._Ls: Deque[float] = deque(maxlen=self.window)
self._Ws: Deque[float] = deque(maxlen=self.window)
self._Hs: Deque[float] = deque(maxlen=self.window)
self._zones: Deque[str] = deque(maxlen=self.window)
self._pending_zone: Optional[str] = None
self._pending_count: int = 0
self._upgrade_count: int = 0
self._locked: Optional[ClassificationResult] = None
self._miss: int = 0
self._frames_seen: int = 0
self._uncertain_locked: bool = False
def reset(self) -> None:
self._ratios.clear()
self._tops.clear()
self._secs.clear()
self._Ls.clear()
self._Ws.clear()
self._Hs.clear()
self._zones.clear()
self._pending_zone = None
self._pending_count = 0
self._upgrade_count = 0
self._locked = None
self._miss = 0
self._frames_seen = 0
self._uncertain_locked = False
def update(
self,
measurement: Optional[ObjectMeasurement],
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
) -> StableDecision:
if measurement is None:
self._miss += 1
if self._miss >= self.lost_frames:
self.reset()
return StableDecision(None, False, 0, 0.0, False)
if self._locked is not None:
return StableDecision(
self._locked, True, 100, self._locked.circle_ratio, True,
uncertain=self._uncertain_locked,
)
return StableDecision(None, False, 0, 0.0, False)
self._miss = 0
self._frames_seen += 1
self._ratios.append(float(measurement.circle_ratio))
self._tops.append(float(getattr(measurement, "top_ratio", measurement.circle_ratio)))
self._secs.append(float(getattr(measurement, "section_ratio", 0.0)))
self._Ls.append(float(measurement.length_mm))
self._Ws.append(float(measurement.width_mm))
self._Hs.append(float(measurement.height_mm))
ratio = _median(self._ratios)
top_m = _median(self._tops)
sec_m = _median(self._secs)
ratio_p75 = _percentile(self._ratios, 75)
# устойчивый круг: медиана > 0.8 ИЛИ (медиана сечений > 0.8 и ≥ половины окна сильные)
# K == 0.8 официально НЕ круг (строгое > threshold)
sec_strong = (
sum(1 for x in self._secs if x > self.enter_circle) / max(1, len(self._secs))
)
circular = ratio > self.enter_circle or (
sec_m > self.enter_circle and sec_strong >= 0.55
)
ratio_show = max(ratio, sec_m) if circular else ratio
L, W, H = _median(self._Ls), _median(self._Ws), _median(self._Hs)
dims = tuple(sorted([L, W, H], reverse=True))
passes = check_size(dims, min_mm, max_mm)
clipped = bool(getattr(measurement, "clipped_by_frame", False))
if clipped:
# неполный габарит из-за края кадра → безопасный негабарит
passes = False
if not passes:
instant = ClassificationResult(
category=Category.OVERSIZE,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=False,
is_circular=circular,
reason=(
"объект обрезан краем кадра → габарит неполный, считаем негабаритом"
if clipped
else "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм"
),
)
elif circular:
instant = ClassificationResult(
category=Category.NEED_PACK,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=True,
is_circular=True,
reason=(
f"круг: med={ratio:.3f} p75={ratio_p75:.3f} "
f"top={top_m:.3f} sec={sec_m:.3f} strong={sec_strong:.0%} "
f"> {self.enter_circle}"
),
)
else:
instant = ClassificationResult(
category=Category.SUITABLE,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=True,
is_circular=False,
reason=(
f"не круг: med={ratio:.3f} sec={sec_m:.3f} "
f"strong={sec_strong:.0%} <= {self.enter_circle}"
),
)
zone = instant.category.zone
self._zones.append(zone)
# --- уже есть LOCK ---
if self._locked is not None:
prev = self._locked.category
cur = instant.category
locked_dims = self._locked.dims_sorted_mm
# новый объект (габариты сильно сменились) — сброс LOCK и набор заново
if _dims_changed(locked_dims, dims, rel=0.28):
self._locked = None
self._uncertain_locked = False
self._pending_zone = zone
self._pending_count = 1
self._upgrade_count = 0
self._frames_seen = 1
conf = int(min(99, round(100.0 * 1 / max(1, self.confirm_frames))))
return StableDecision(instant, False, conf, ratio_show, True)
can_upgrade = (
(prev == Category.OVERSIZE and cur in (Category.SUITABLE, Category.NEED_PACK))
or (prev == Category.SUITABLE and cur == Category.NEED_PACK)
)
if can_upgrade:
self._upgrade_count += 1
need_up = max(5, self.confirm_frames // 2)
if self._upgrade_count >= need_up:
self._locked = instant
self._upgrade_count = 0
self._uncertain_locked = False # появился консенсус
else:
self._upgrade_count = 0
return StableDecision(
self._locked, True, 100, ratio_show, True,
uncertain=self._uncertain_locked,
)
# нет консенсуса слишком долго → «неуверенно», безопасная зона
if self._frames_seen >= self.uncertain_after:
fallback_result = ClassificationResult(
category=self.fallback,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=passes,
is_circular=circular,
reason="НЕУВЕРЕННО → безопасная зона: " + self._uncertain_reason(
ratio, dims, min_mm, max_mm
),
)
self._locked = fallback_result
self._uncertain_locked = True
return StableDecision(fallback_result, True, 100, ratio_show, True, uncertain=True)
# прогрев окна
if len(self._ratios) < self.window:
conf = int(min(99, round(100.0 * len(self._ratios) / self.window)))
return StableDecision(instant, False, conf, ratio_show, True)
votes = Counter(self._zones)
winner, win_n = votes.most_common(1)[0]
if winner != zone:
self._pending_zone = None
self._pending_count = 0
conf = int(round(100.0 * win_n / len(self._zones)))
return StableDecision(instant, False, conf, ratio_show, True)
need = max(self.confirm_frames, (self.window * 2 + 2) // 3)
if zone == self._pending_zone:
self._pending_count += 1
else:
self._pending_zone = zone
self._pending_count = 1
conf = int(min(100, round(100.0 * max(self._pending_count, win_n) / need)))
if self._pending_count >= need and win_n >= need:
self._locked = instant
self._uncertain_locked = False
return StableDecision(instant, True, 100, ratio_show, True)
return StableDecision(instant, False, conf, ratio_show, True)
def _uncertain_reason(
self,
ratio: float,
dims: Sequence[float],
min_mm: Sequence[float],
max_mm: Sequence[float],
) -> str:
votes = Counter(self._zones)
parts = [
f"нет консенсуса {self._frames_seen} кадров",
"голоса " + " ".join(f"{z}:{n}" for z, n in votes.most_common()),
]
if abs(ratio - self.enter_circle) <= 0.06:
parts.append(f"ratio {ratio:.3f} у порога {self.enter_circle}")
max_s = sorted([float(x) for x in max_mm], reverse=True)
min_s = sorted([float(x) for x in min_mm], reverse=True)
for d, mx, mn in zip(dims, max_s, min_s):
if abs(d - mx) <= 0.05 * mx:
parts.append(f"сторона {d:.0f} мм у лимита {mx:.0f}")
elif mn > 0 and abs(d - mn) <= max(3.0, 0.3 * mn):
parts.append(f"сторона {d:.0f} мм у минимума {mn:.0f}")
return "; ".join(parts)
def _median(vals: Deque[float]) -> float:
return _percentile(vals, 50)
def _dims_changed(
a: Sequence[float], b: Sequence[float], rel: float = 0.28
) -> bool:
"""True если хотя бы одна сторона изменилась больше чем на rel (новый объект)."""
if len(a) < 3 or len(b) < 3:
return False
for x, y in zip(a[:3], b[:3]):
base = max(abs(float(x)), abs(float(y)), 1.0)
if abs(float(x) - float(y)) / base > rel:
return True
return False
def _percentile(vals: Deque[float], q: float) -> float:
arr = sorted(vals)
n = len(arr)
if n == 0:
return 0.0
if n == 1:
return float(arr[0])
pos = (q / 100.0) * (n - 1)
lo = int(pos)
hi = min(lo + 1, n - 1)
frac = pos - lo
return float(arr[lo] * (1 - frac) + arr[hi] * frac)

118
cv/test_classify.py Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Юнит-тесты правил классификации (без камеры)."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from classify import Category, classify_from_dims
def test_suitable_box():
r = classify_from_dims(120, 80, 40, circle_ratio=0.5)
assert r.category == Category.SUITABLE
assert r.category.zone == "B"
def test_oversize_priority_over_circle():
# Большой цилиндр: габариты важнее круга
r = classify_from_dims(500, 100, 100, circle_ratio=0.95)
assert r.category == Category.OVERSIZE
assert r.category.zone == "C"
def test_too_small():
r = classify_from_dims(5, 5, 5, circle_ratio=0.2)
assert r.category == Category.OVERSIZE
def test_need_pack_cylinder():
r = classify_from_dims(100, 50, 50, circle_ratio=0.85)
assert r.category == Category.NEED_PACK
assert r.category.zone == "D"
def test_lying_bottle_must_be_D_not_B():
"""ТЗ: круг в ЛЮБОМ сечении. Лежачая бутылка сверху не круг, но сечение круглое."""
# имитация: top-view низкий, но итоговый circle_ratio после 3D-срезов высокий
r = classify_from_dims(220, 70, 70, circle_ratio=0.86)
assert r.category == Category.NEED_PACK
assert r.category.zone == "D"
def test_strict_circle_boundary():
"""Официально: круг только при K > 0.8. K == 0.8 → B (не D)."""
below = classify_from_dims(100, 50, 50, circle_ratio=0.799999)
assert below.category == Category.SUITABLE
assert below.category.zone == "B"
assert below.is_circular is False
exact = classify_from_dims(100, 50, 50, circle_ratio=0.8)
assert exact.category == Category.SUITABLE
assert exact.category.zone == "B"
assert exact.is_circular is False
above = classify_from_dims(100, 50, 50, circle_ratio=0.800001)
assert above.category == Category.NEED_PACK
assert above.category.zone == "D"
assert above.is_circular is True
def test_normal_non_circular_B():
r = classify_from_dims(120, 80, 40, circle_ratio=0.5)
assert r.category == Category.SUITABLE
assert r.category.zone == "B"
def test_normal_circular_above_threshold_D():
r = classify_from_dims(100, 50, 50, circle_ratio=0.81)
assert r.category == Category.NEED_PACK
assert r.category.zone == "D"
def test_oversized_circular_still_C():
r = classify_from_dims(500, 100, 100, circle_ratio=0.95)
assert r.category == Category.OVERSIZE
assert r.category.zone == "C"
def test_border_dims_strict():
"""ТЗ: строго больше 10×10×10 и строго меньше 450×320×320."""
# ровно на максимуме → C
assert classify_from_dims(450, 320, 320, 0.3).category == Category.OVERSIZE
# ровно на минимуме → C
assert classify_from_dims(10, 10, 10, 0.3).category == Category.OVERSIZE
# чуть внутри границ → B
assert classify_from_dims(449, 319, 319, 0.3).category == Category.SUITABLE
assert classify_from_dims(11, 11, 11, 0.3).category == Category.SUITABLE
# 321 мм влезает вдоль оси 450 → B (сопоставление после сортировки)
assert classify_from_dims(100, 321, 100, 0.3).category == Category.SUITABLE
# а вот две стороны > 320 уже не влезают → C
assert classify_from_dims(400, 330, 100, 0.3).category == Category.OVERSIZE
def test_dims_order_independent():
"""Стороны сопоставляются после сортировки — порядок L/W/H не важен."""
assert classify_from_dims(319, 449, 318, 0.3).category == Category.SUITABLE
assert classify_from_dims(318, 319, 449, 0.3).category == Category.SUITABLE
# ровно 320 по строгому правилу «меньше» → C, в любом порядке
assert classify_from_dims(320, 449, 319, 0.3).category == Category.OVERSIZE
assert classify_from_dims(319, 320, 449, 0.3).category == Category.OVERSIZE
if __name__ == "__main__":
test_suitable_box()
test_oversize_priority_over_circle()
test_too_small()
test_need_pack_cylinder()
test_lying_bottle_must_be_D_not_B()
test_strict_circle_boundary()
test_normal_non_circular_B()
test_normal_circular_above_threshold_D()
test_oversized_circular_still_C()
test_border_dims_strict()
test_dims_order_independent()
print("OK: all classification tests passed")

384
cv/test_geometry.py Normal file
View File

@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""Геометрические тесты: прямоугольник → B, круг → D (без камеры)."""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import cv2
sys.path.insert(0, str(Path(__file__).resolve().parent))
from measure import (
ObjectMeasurement,
_fit_circle_arc,
_section_score,
measure_object,
rin_rout,
section_rin_rout,
segment_object,
)
from stabilize import DecisionStabilizer
def _circle(n=64, r=40.0):
a = np.linspace(0, 2 * np.pi, n, endpoint=False)
return np.column_stack([50 + r * np.cos(a), 50 + r * np.sin(a)]).astype(np.float32)
def _rect(w=150.0, h=90.0, n=20):
pts = (
[[x, 0] for x in np.linspace(0, w, n)]
+ [[w, y] for y in np.linspace(0, h, n)]
+ [[x, h] for x in np.linspace(w, 0, n)]
+ [[0, y] for y in np.linspace(h, 0, n)]
)
return np.array(pts, dtype=np.float32)
def _round_rect(w=150.0, h=90.0, rad=8.0, n=12, ne=15):
pts = []
pts += [[x, 0] for x in np.linspace(rad, w - rad, ne)]
pts += [[w, y] for y in np.linspace(rad, h - rad, ne)]
pts += [[x, h] for x in np.linspace(w - rad, rad, ne)]
pts += [[0, y] for y in np.linspace(h - rad, rad, ne)]
corners = [
(rad, rad, np.pi, 1.5 * np.pi),
(w - rad, rad, 1.5 * np.pi, 2 * np.pi),
(w - rad, h - rad, 0, 0.5 * np.pi),
(rad, h - rad, 0.5 * np.pi, np.pi),
]
for cx, cy, a0, a1 in corners:
for a in np.linspace(a0, a1, n):
pts.append([cx + rad * np.cos(a), cy + rad * np.sin(a)])
return np.array(pts, dtype=np.float32)
def _arc(span_deg=252.0, r=35.0, n=80):
a0 = -np.deg2rad(span_deg) / 2
a1 = np.deg2rad(span_deg) / 2
a = np.linspace(a0, a1, n)
return np.column_stack([50 + r * np.cos(a), 50 + r * np.sin(a)]).astype(np.float32)
def test_geometry_ratios():
assert rin_rout(_circle()) >= 0.8, "круг сверху должен быть D"
assert rin_rout(_rect()) < 0.8, "прямоугольник должен быть B"
assert rin_rout(_round_rect()) < 0.8, "скруглённый прямоугольник — B"
# квадрат < 0.8 (теоретически ~0.707)
sq = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype=np.float32)
assert rin_rout(sq) < 0.8
# дуга цилиндра: fit → 0.85
arc = _arc()
score = _section_score(arc)
assert score >= 0.8, f"дуга цилиндра должна давать >=0.8, got {score}"
assert _fit_circle_arc(_rect()) is None, "прямоугольник не должен проходить circle-fit"
def test_stabilizer_box_vs_cylinder():
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
# коробка с редкими ложными пиками → LOCK B
s = DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
locked_zone = None
seq = [0.55, 0.84, 0.56, 0.58, 0.57, 0.59, 0.55, 0.56, 0.58, 0.57, 0.55, 0.56, 0.57, 0.58]
for r in seq:
m = ObjectMeasurement(120, 80, 40, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.5)
d = s.update(m)
if d.locked and d.result:
locked_zone = d.result.category.zone
assert locked_zone == "B", f"коробка должна LOCK B, got {locked_zone}"
# цилиндр → LOCK D
s2 = DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
locked_zone = None
for r in [0.90] * 16:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.88)
d = s2.update(m)
if d.locked and d.result:
locked_zone = d.result.category.zone
assert locked_zone == "D", f"цилиндр должен LOCK D, got {locked_zone}"
# после LOCK D не прыгаем в B
for r in [0.4] * 10:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.4)
d = s2.update(m)
assert d.locked and d.result and d.result.category.zone == "D"
# early B, then устойчивый круг → апгрейд в D (те же габариты)
s3 = DecisionStabilizer(window=8, confirm_frames=6, enter_circle=0.8)
for r in [0.55] * 20:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.5)
d = s3.update(m)
assert d.locked and d.result.category.zone == "B"
for r in [0.86] * 16:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=0.72, section_ratio=0.86)
d = s3.update(m)
assert d.locked and d.result.category.zone == "D", f"ожидали апгрейд B→D, got {d.result.category.zone}"
# смена объекта круг→коробка по габаритам → новый LOCK B
saw_reset = False
locked_b = False
for r in [0.45] * 25:
m = ObjectMeasurement(240, 120, 50, r, 1000, (1, 1), dummy, mask, top_ratio=0.45, section_ratio=0.27)
d = s3.update(m)
if d.present and not d.locked:
saw_reset = True
if d.locked and d.result and d.result.category.zone == "B":
locked_b = True
assert saw_reset, "при смене габаритов должен быть сброс LOCK"
assert locked_b, "коробка после смены должна LOCK B"
def test_background_map_splits_object_from_platform():
"""Цилиндр на платформе: скалярная высота сливает их, фоновая карта — нет."""
H, W = 480, 640
belt = 600
bg = np.full((H, W), belt, np.uint16)
bg[100:340, 130:470] = belt - 80 # платформа 80 мм — часть фона
depth = bg.copy()
yy, xx = np.ogrid[:H, :W]
circ = (yy - 220) ** 2 + (xx - 300) ** 2 <= 70**2
depth[circ] = belt - 80 - 60 # круглый предмет 60 мм на платформе
# старый способ: платформа+цилиндр в одном контуре (большая площадь)
seg_old = segment_object(depth, belt_distance_mm=belt, min_area_px=400)
assert seg_old is not None
area_old = int((seg_old[0] > 0).sum())
assert area_old > 240 * 340 * 0.8, "скалярный способ должен захватить платформу"
# с фоновой картой: только цилиндр
seg_bg = segment_object(depth, belt_distance_mm=belt, min_area_px=400, background_mm=bg)
assert seg_bg is not None
mask, contour = seg_bg
area = int((mask > 0).sum())
circle_area = np.pi * 70 * 70
assert abs(area - circle_area) / circle_area < 0.15, f"площадь {area} vs круг {circle_area:.0f}"
m = measure_object(
depth, mask, contour,
belt_distance_mm=belt, fx=670, fy=670, cx=320, cy=240,
background_mm=bg,
)
assert m is not None
assert abs(m.height_mm - 60) < 8, f"высота от платформы должна быть ~60, got {m.height_mm:.1f}"
assert m.top_ratio >= 0.8, f"вид сверху круг, got {m.top_ratio:.3f}"
def test_uncertain_fallback_to_safe_zone():
"""Нет консенсуса (ratio скачет у порога) → «неуверенно» → безопасная зона C."""
from classify import Category
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
s = DecisionStabilizer(window=8, confirm_frames=6, uncertain_after=25, fallback=Category.OVERSIZE)
seq = ([0.70] * 6 + [0.90] * 6) * 5 # блоками вокруг порога 0.8
final = None
for i, r in enumerate(seq):
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=r)
d = s.update(m)
if d.locked:
final = (i + 1, d)
break
assert final is not None, "fallback должен сработать"
n_frames, d = final
assert d.uncertain, "LOCK должен быть помечен как неуверенный"
assert d.result is not None and d.result.category.zone == "C"
assert "НЕУВЕРЕННО" in d.result.reason
assert n_frames <= 30, f"fallback должен сработать около 25 кадров, got {n_frames}"
# уверенная коробка не должна помечаться «неуверенно»
s2 = DecisionStabilizer(window=8, confirm_frames=6, uncertain_after=25)
got = None
for r in [0.5] * 20:
m = ObjectMeasurement(120, 80, 40, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=r)
d2 = s2.update(m)
if d2.locked:
got = d2
break
assert got is not None and not got.uncertain
assert got.result is not None and got.result.category.zone == "B"
def test_tracker_two_objects_ids_and_stats():
"""Коробка + цилиндр одновременно: два ID, статистика считает каждого один раз."""
from tracker import MultiObjectTracker
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
def factory():
return DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
t = MultiObjectTracker(factory, max_dist_px=120, lost_frames=8)
all_events = []
for _ in range(20):
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
top_ratio=0.55, section_ratio=0.5)
cyl = ObjectMeasurement(100, 50, 50, 0.90, 900, (500, 300), dummy, mask,
top_ratio=0.90, section_ratio=0.88)
tracks, events = t.update([box, cyl])
all_events.extend(events)
assert len(tracks) == 2, f"должно быть 2 трека, got {len(tracks)}"
ids = sorted(tr.track_id for tr in tracks)
assert ids == [1, 2], f"ID должны быть 1 и 2, got {ids}"
zones = sorted(ev.decision.result.category.zone for ev in all_events)
assert zones == ["B", "D"], f"события LOCK для B и D, got {zones}"
assert t.stats["B"] == 1 and t.stats["D"] == 1 and t.stats["total"] == 2
# объекты убрали → треки умирают (залоченные живут дольше), статистика остаётся
for _ in range(40):
tracks, _ = t.update([])
assert not tracks
assert t.stats["total"] == 2
# тот же товар на том же месте вернулся → без нового LOCK и без роста счётчика
events_back = []
for _ in range(15):
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
top_ratio=0.55, section_ratio=0.5)
tracks, events = t.update([box])
events_back.extend(events)
assert not events_back, "повторный захват уже учтённого товара не должен давать LOCK"
assert tracks and tracks[0].track_id == 1
assert tracks[0].frozen is not None and tracks[0].decision.locked
assert t.stats["B"] == 1 and t.stats["total"] == 2
# новый объект в другом месте получает следующий ID
box2 = ObjectMeasurement(200, 100, 60, 0.5, 1200, (300, 200), dummy, mask,
top_ratio=0.5, section_ratio=0.4)
for _ in range(20):
tracks, events = t.update([box2])
assert tracks and any(tr.track_id == 3 for tr in tracks)
assert t.stats["B"] == 2 and t.stats["total"] == 3
def test_tracker_slot_dedup_same_object():
"""Тот же товар на том же месте с новым track_id — без повторного LOCK."""
from tracker import MultiObjectTracker, slot_key
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
def factory():
return DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
t = MultiObjectTracker(factory, max_dist_px=120, lost_frames=8)
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
top_ratio=0.55, section_ratio=0.5)
all_events = []
for _ in range(20):
tracks, events = t.update([box])
all_events.extend(events)
assert all_events, "первый LOCK должен быть"
sk = slot_key(100, 100, 120, 80, 40, "B")
assert sk in t._seen_slots
# новый track_id, та же позиция — события нет, счётчик не растёт
for _ in range(40):
tracks, _ = t.update([])
box2 = ObjectMeasurement(118, 82, 41, 0.56, 1000, (102, 98), dummy, mask,
top_ratio=0.56, section_ratio=0.5)
extra = []
for _ in range(20):
tracks, events = t.update([box2])
extra.extend(events)
assert not extra, "повтор того же слота не должен давать LOCK"
assert t.stats["total"] == 1
def test_shadow_not_detected_as_object():
"""Тень на ленте (затемнение без смены цвета) не должна давать RGB-объект."""
from measure import segment_rgb_objects
H, W = 480, 640
bg = np.full((H, W, 3), (90, 130, 160), np.uint8) # коричневая лента BGR
color = bg.copy()
# мягкая тень: все каналы ×0.55 — типичная тень от коробки
color[150:300, 200:420] = (bg[150:300, 200:420].astype(np.float32) * 0.55).astype(np.uint8)
objs = segment_rgb_objects(color, bg, min_area_px=400, diff_threshold=35)
assert not objs, f"тень не должна детектироваться, got {len(objs)}"
def test_merge_overlapping_halves():
"""Две половины одного объекта с пересечением ≥50% → один контур."""
from measure import merge_overlapping_masks, mask_overlap_min
H, W = 100, 100
a = np.zeros((H, W), np.uint8)
b = np.zeros((H, W), np.uint8)
a[20:60, 20:60] = 255 # 40×40
b[20:60, 35:75] = 255 # перекрытие 40×25 = 1000 / 1600 = 0.625
assert mask_overlap_min(a, b) >= 0.5
ca, _ = cv2.findContours(a, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cb, _ = cv2.findContours(b, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
merged = merge_overlapping_masks([(a, ca[0]), (b, cb[0])], overlap_thr=0.5)
assert len(merged) == 1, f"ожидали 1 объект, got {len(merged)}"
def test_noise_measurement_rejected():
"""Шум 0×0×0 / мелкие RGB-пятна не должны классифицироваться как негабарит."""
from measure import ObjectMeasurement, is_plausible_measurement
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
noise = ObjectMeasurement(0.0, 0.0, 0.0, 0.1, 80, (10, 10), dummy, mask, source="rgb")
assert not is_plausible_measurement(noise)
speckle = ObjectMeasurement(12.0, 8.0, 0.0, 0.2, 500, (50, 50), dummy, mask, source="rgb")
assert not is_plausible_measurement(speckle)
ok = ObjectMeasurement(110.0, 70.0, 28.0, 0.6, 1200, (100, 100), dummy, mask)
assert is_plausible_measurement(ok)
def test_flat_phone_via_rgb():
"""Телефон 8 мм: depth не видит (порог 12 мм), RGB-фон находит → класс C."""
from classify import Category, classify
from measure import measure_flat_object, segment_rgb_objects
H, W = 480, 640
belt = 534
bg_color = np.full((H, W, 3), 120, np.uint8) # серая лента
color = bg_color.copy()
x0, y0, pw, ph = 250, 180, 172, 80 # ≈160×75 мм при fx=564, z=534
color[y0 : y0 + ph, x0 : x0 + pw] = (30, 30, 30) # тёмный телефон
depth = np.full((H, W), belt, np.uint16)
depth[y0 : y0 + ph, x0 : x0 + pw] = belt - 8 # всего 8 мм над лентой
rng = np.random.default_rng(0)
depth = (depth.astype(np.int32) + rng.integers(-3, 4, size=depth.shape)).astype(np.uint16)
objs = segment_rgb_objects(color, bg_color, min_area_px=400)
assert objs, "телефон должен найтись по RGB-фону"
mask, contour = objs[0]
m = measure_flat_object(
depth, mask, contour,
belt_distance_mm=belt, fx=564.0, fy=564.0, cx=320, cy=240,
background_mm=np.full((H, W), belt, np.uint16),
)
assert m is not None and m.source == "rgb"
assert abs(m.length_mm - 160) < 15, f"длина ~160, got {m.length_mm:.0f}"
assert abs(m.width_mm - 75) < 12, f"ширина ~75, got {m.width_mm:.0f}"
assert m.height_mm < 12, f"высота должна быть маленькой, got {m.height_mm:.0f}"
r = classify(m)
assert r.category == Category.OVERSIZE, "тоньше 10 мм → C по ТЗ (меньше минимума)"
if __name__ == "__main__":
test_geometry_ratios()
test_stabilizer_box_vs_cylinder()
test_background_map_splits_object_from_platform()
test_uncertain_fallback_to_safe_zone()
test_tracker_two_objects_ids_and_stats()
test_tracker_slot_dedup_same_object()
test_noise_measurement_rejected()
test_merge_overlapping_halves()
test_shadow_not_detected_as_object()
test_flat_phone_via_rgb()
print("OK: geometry + stabilizer tests passed")

279
cv/tracker.py Normal file
View File

@@ -0,0 +1,279 @@
"""Трекинг нескольких объектов в кадре: ID + статистика зон за сессию.
Сопоставление кадр-к-кадру по ближайшему центроиду. На каждый трек —
свой DecisionStabilizer. LOCK в ленту/MQTT — один раз на товар: после
фиксации запоминаем «отпечаток» (центр+габариты); если depth кратковременно
пропал и объект нашёлся снова рядом — трек возрождается без нового LOCK.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional, Sequence, Tuple
from classify import ClassificationResult
from measure import ObjectMeasurement
from stabilize import DecisionStabilizer, StableDecision
@dataclass
class Track:
track_id: int
stabilizer: DecisionStabilizer
centroid: Tuple[int, int]
miss: int = 0
counted_zone: Optional[str] = None
counted_uncertain: bool = False
measurement: Optional[ObjectMeasurement] = None
decision: Optional[StableDecision] = None
reported: bool = False # уже ушёл в ленту — повторно не пишем
frozen: Optional[StableDecision] = None # снимок первого LOCK (для возрождения)
@dataclass
class LockEvent:
track_id: int
decision: StableDecision
@dataclass
class _Fingerprint:
"""Товар уже зафиксирован — не считаем повторно при перезахвате."""
track_id: int
cx: int
cy: int
length_mm: float
width_mm: float
height_mm: float
zone: str
uncertain: bool
result: ClassificationResult
age: int = 0 # кадров без детекции
def slot_key(cx: int, cy: int, L: float, W: float, H: float, zone: str) -> str:
"""Стабильный ключ товара на ленте — один физический предмет = одна запись."""
a = sorted((L, W, H))
return f"{cx // 35}_{cy // 35}_{int(a[0] // 12)}_{int(a[1] // 12)}_{int(a[2] // 8)}_{zone}"
class MultiObjectTracker:
def __init__(
self,
stabilizer_factory: Callable[[], DecisionStabilizer],
max_dist_px: int = 120,
lost_frames: int = 12,
fingerprint_ttl: int = 450, # ~1520 с помнить «уже учтён»
) -> None:
self._factory = stabilizer_factory
self.max_dist_px = int(max_dist_px)
self.lost_frames = int(lost_frames)
self.fingerprint_ttl = int(fingerprint_ttl)
self._tracks: Dict[int, Track] = {}
self._fps: List[_Fingerprint] = []
self._seen_slots: Dict[str, _Fingerprint] = {} # уже учтённые товары (позиция+габариты)
self._next_id = 1
self.stats: Dict[str, int] = {"B": 0, "C": 0, "D": 0, "total": 0, "uncertain": 0}
def reset(self) -> None:
self._tracks.clear()
self._fps.clear()
def update(
self,
measurements: List[ObjectMeasurement],
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
) -> Tuple[List[Track], List[LockEvent]]:
free_meas = list(range(len(measurements)))
assigned: Dict[int, int] = {}
pairs = []
for tid, tr in self._tracks.items():
for mi in free_meas:
m = measurements[mi]
d2 = (tr.centroid[0] - m.centroid_px[0]) ** 2 + (tr.centroid[1] - m.centroid_px[1]) ** 2
pairs.append((d2, tid, mi))
for d2, tid, mi in sorted(pairs):
if tid in assigned or mi not in free_meas:
continue
if d2 > self.max_dist_px**2:
continue
assigned[tid] = mi
free_meas.remove(mi)
events: List[LockEvent] = []
for tid in list(self._tracks.keys()):
tr = self._tracks[tid]
# залоченный трек держим дольше — глянец даёт короткие выпадения depth
kill_after = self.lost_frames * 3 if tr.counted_zone is not None else self.lost_frames
if tid in assigned:
m = measurements[assigned[tid]]
tr.centroid = m.centroid_px
tr.miss = 0
tr.measurement = m
tr.decision = tr.stabilizer.update(m, min_mm=min_mm, max_mm=max_mm)
# после возрождения стабилизатор ещё «холодный» — держим прошлый LOCK на экране
if tr.reported and tr.frozen is not None and not (tr.decision and tr.decision.locked):
tr.decision = tr.frozen
self._account(tr, events)
if tr.reported:
self._touch_fp(tr)
else:
tr.miss += 1
tr.measurement = None
tr.decision = tr.stabilizer.update(None, min_mm=min_mm, max_mm=max_mm)
if tr.reported and tr.frozen is not None and not (tr.decision and tr.decision.locked):
tr.decision = tr.frozen
if tr.miss >= kill_after:
if tr.reported and tr.frozen is not None and tr.frozen.result is not None:
self._remember(tr)
del self._tracks[tid]
# старение отпечатков
for fp in self._fps:
fp.age += 1
self._fps = [fp for fp in self._fps if fp.age < self.fingerprint_ttl]
for mi in free_meas:
m = measurements[mi]
fp = self._match_fp(m)
if fp is not None:
# тот же товар вернулся после выпадения depth — без нового LOCK
fp.age = 0
fp.cx, fp.cy = m.centroid_px
frozen = StableDecision(
fp.result, True, 100, fp.result.circle_ratio, True, uncertain=fp.uncertain,
)
tr = Track(
track_id=fp.track_id,
stabilizer=self._factory(),
centroid=m.centroid_px,
measurement=m,
counted_zone=fp.zone,
counted_uncertain=fp.uncertain,
reported=True,
frozen=frozen,
decision=frozen,
)
self._tracks[tr.track_id] = tr
self._fps = [x for x in self._fps if x.track_id != fp.track_id]
continue
tr = Track(
track_id=self._next_id,
stabilizer=self._factory(),
centroid=m.centroid_px,
measurement=m,
)
self._next_id += 1
tr.decision = tr.stabilizer.update(m, min_mm=min_mm, max_mm=max_mm)
self._tracks[tr.track_id] = tr
self._account(tr, events)
alive = sorted(self._tracks.values(), key=lambda t: t.track_id)
return [t for t in alive if t.measurement is not None or t.miss < (
self.lost_frames * 3 if t.counted_zone else self.lost_frames
)], events
def _account(self, tr: Track, events: List[LockEvent]) -> None:
d = tr.decision
if d is None or not d.locked or d.result is None:
return
zone = d.result.category.zone
L, W, H = d.result.dims_sorted_mm
sk = slot_key(tr.centroid[0], tr.centroid[1], L, W, H, zone)
if tr.counted_zone is None:
if sk in self._seen_slots:
# тот же товар уже был в ленте/статистике — только показываем на экране
prev = self._seen_slots[sk]
tr.counted_zone = prev.zone
tr.counted_uncertain = prev.uncertain
tr.frozen = StableDecision(
prev.result, True, 100, prev.result.circle_ratio, True, uncertain=prev.uncertain,
)
tr.reported = True
tr.decision = tr.frozen
return
self.stats[zone] = self.stats.get(zone, 0) + 1
self.stats["total"] += 1
if d.uncertain:
self.stats["uncertain"] += 1
tr.counted_zone = zone
tr.counted_uncertain = d.uncertain
tr.frozen = StableDecision(
d.result, True, 100, d.result.circle_ratio, True, uncertain=d.uncertain,
)
self._seen_slots[sk] = _Fingerprint(
track_id=tr.track_id,
cx=tr.centroid[0],
cy=tr.centroid[1],
length_mm=float(L),
width_mm=float(W),
height_mm=float(H),
zone=zone,
uncertain=d.uncertain,
result=d.result,
)
if not tr.reported:
tr.reported = True
events.append(LockEvent(tr.track_id, tr.frozen))
elif tr.counted_zone != zone:
# смена зоны на экране/в счётчиках — в ленту повторно не пишем
self.stats[tr.counted_zone] = max(0, self.stats.get(tr.counted_zone, 0) - 1)
self.stats[zone] = self.stats.get(zone, 0) + 1
if tr.counted_uncertain and not d.uncertain:
self.stats["uncertain"] = max(0, self.stats["uncertain"] - 1)
tr.counted_uncertain = d.uncertain
tr.counted_zone = zone
tr.frozen = StableDecision(
d.result, True, 100, d.result.circle_ratio, True, uncertain=d.uncertain,
)
def _remember(self, tr: Track) -> None:
assert tr.frozen is not None and tr.frozen.result is not None
r = tr.frozen.result
L, W, H = r.dims_sorted_mm
self._fps = [fp for fp in self._fps if fp.track_id != tr.track_id]
self._fps.append(
_Fingerprint(
track_id=tr.track_id,
cx=tr.centroid[0],
cy=tr.centroid[1],
length_mm=float(L),
width_mm=float(W),
height_mm=float(H),
zone=tr.counted_zone or r.category.zone,
uncertain=tr.counted_uncertain,
result=r,
age=0,
)
)
def _touch_fp(self, tr: Track) -> None:
for fp in self._fps:
if fp.track_id == tr.track_id:
fp.age = 0
fp.cx, fp.cy = tr.centroid
def _match_fp(self, m: ObjectMeasurement) -> Optional[_Fingerprint]:
best: Optional[_Fingerprint] = None
best_d2 = self.max_dist_px**2
for fp in self._fps:
d2 = (fp.cx - m.centroid_px[0]) ** 2 + (fp.cy - m.centroid_px[1]) ** 2
if d2 > best_d2:
continue
if not _dims_close(fp.length_mm, fp.width_mm, fp.height_mm, m.length_mm, m.width_mm, m.height_mm):
continue
best, best_d2 = fp, d2
return best
def _dims_close(L0: float, W0: float, H0: float, L1: float, W1: float, H1: float, tol: float = 0.40) -> bool:
"""Габариты «похожи» (порядок осей уже отсортирован в classify, здесь — сырые L×W×H)."""
a = sorted((L0, W0, H0))
b = sorted((L1, W1, H1))
for x, y in zip(a, b):
if abs(x - y) / max(x, y, 1.0) > tol:
return False
return True

View File

@@ -0,0 +1,6 @@
services:
web:
build: .
ports:
- "127.0.0.1:3100:80"
restart: unless-stopped

86
docs/ENGINEERING.md Normal file
View File

@@ -0,0 +1,86 @@
# Engineering notes (canonical)
Companion to `README.md` and `/documentation`. Not a stage changelog.
## Coordinate conventions
- World units: **1 unit = 1 meter**.
- Belt travel primarily along **+X**; belt top Y ≈ **0.70 m**.
- Lateral: **+Z** = physical LEFT (category C), **Z** = physical RIGHT (category D).
- Sorter CAD module origin X = `0`; camera module `2.01`; clean module `4.02`.
- Longitudinal plane S for diverter timing is world X along the sorter module.
## Canonical constants (code)
| Symbol | Value | File |
|---|---|---|
| `DIVERTER_LEFT_SIGNED_DEG` | 45 | `src/domain/pusherMotion.ts` |
| `DIVERTER_RIGHT_SIGNED_DEG` | +45 | same |
| `rotationDurationSec()` | 0.50 | same (45° / 90°/s) |
| `OPENING_SAFETY_MARGIN_SEC` | 0.15 | same |
| Contact / clear planes | ≈1.0538 / 1.6000 | `buildDiverterPlanes` + mount hinge |
| Classifier min/max | exclusive 10³ / 450×320×320 | `src/domain/classifier.ts` |
| Roundness | K > 0.8 (K = 0.8 not circular); web + CV aligned | `classifier.ts` / `cv/classify.py` |
| Belt speed (sim) | 1.0 m/s | runtime / physics helpers |
| Physics timestep | 1/60 s | Rapier world step |
| `CONVEYOR_CAD_URL` | `/models/sorter/conveyor-clean.glb` | `ConveyorCadModel.tsx` |
| Presentation | `presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf` | 10 slides |
## Active source tree (runtime)
```
src/main.tsx
src/App.tsx
src/pages/{MainPage,DocumentationPage}.tsx
src/components/{AppNav,SorterScene,CVInspectionOverlay,BuildIdentityBadge}.tsx
src/components/ThreeD/* (active twin only)
src/domain/* (classifier, playback, layout, diverter, physics helpers)
src/data/{items,modelAssets,resolveItem,productionStatusSummary,demoPlaylist,scenarios}.ts
src/styles.css
```
## Asset provenance
| Role | Path |
|---|---|
| Author CAD | `3d_models/conveer.FCStd` |
| Runtime conveyor | `public/models/sorter/conveyor-clean.glb` |
| Products | `public/models/*.stl` from official STL ZIP |
| Classifier PDF | `official_sources/doc-1783095831.pdf` |
| Workspace / scoring PDFs | `input_info/doc-1783009942.pdf`, `doc-1783011400.pdf` |
## Physics roadmap (not completed)
1. Surface-velocity belt at 1 m/s with visual loop.
2. Contact-validated CAD diverter deflection for all playlist SKUs.
3. Calibrated per-SKU mass, COM, friction, damping.
4. Receiver capture verification under dynamic drops.
CCD for light/thin items exists in runtime/sim; that alone is **not** full contact validation.
## Compliance evidence rules
- Prefer present official files under `input_info/` and `official_sources/doc-1783095831.pdf`.
- Missing: `input_info/extracted/Постановка_Задача_3_сжато_2.pdf` — never claim it is available.
- Internal engineering criteria are not automatic Ozon pass/fail.
## Соответствие подтверждённым требованиям
Grounded only in present sources + current `main` code/tests (not a full official scorecard):
| Area | Official source present | Current implementation | Evidence | Status |
|---|---|---|---|---|
| B/C/D bounds & roundness | `official_sources/doc-1783095831.pdf` | web `classifier.ts`, CV `cv/classify.py` | unit tests | PARTIAL (PDF not re-parsed each pass) |
| Digital twin demo | workspace PDFs in `input_info/` | `/` + `/documentation` | e2e smoke/routes, production | PASS (demo present) |
| CAD conveyor | author pack / STL references | `3d_models/`, `conveyor-clean.glb` | checksums in `/documentation` | PASS (assets present) |
| Real measurement CV | Track 3 camera intent in briefs | `cv/` RealSense+OpenCV on `main` | `cv/README.md`, `test_classify.py` | PARTIAL (WORKING_PROTOTYPE, not live web) |
| Physical industrial line | scoring/workspace PDFs | web physics + optional MQTT CV | code; contact not fully validated | PARTIAL |
| Presentation | platform rules | `presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf` | 10-page PDF in repo | PASS (file present) |
## Real CV prototype
Path `cv/` — OpenCV + RealSense D415 depth pipeline. Same B/C/D domain as the web twin. **Not** consumed by https://arhipovdan.ru. Details: `cv/README.md`.
## Layout drawing
`docs/engineering/work-area-layout-source.png` — workspace layout provenance image retained for engineering reference.

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

55
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,55 @@
/** Shared e2e helpers — Stage 2B autostart means / opens already running. */
import { expect, type Page } from '@playwright/test';
/** Close the Demo Complete overlay if it is covering the controls. */
export async function dismissFinished(page: Page) {
const finished = page.getByTestId('demo-finished');
if (await finished.isVisible().catch(() => false)) {
await finished.getByRole('button', { name: /Replay/i }).click({ force: true });
await expect(finished).toHaveCount(0, { timeout: 10_000 });
}
}
/** Ensure playback is running (noop if already on Pause button). */
export async function ensureRunning(page: Page) {
await dismissFinished(page);
const pause = page.getByTestId('demo-pause');
if (await pause.isVisible().catch(() => false)) return;
const play = page.getByTestId('demo-play');
await expect(play.or(pause)).toBeVisible({ timeout: 15_000 });
if (await play.isVisible().catch(() => false)) {
await play.click({ force: true });
}
await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 });
}
export async function ensurePaused(page: Page) {
await dismissFinished(page);
const play = page.getByTestId('demo-play');
if (await play.isVisible().catch(() => false)) return;
await page.getByTestId('demo-pause').click({ force: true });
await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 });
}
/** Assert either Play or Pause control is present (autostart-safe). */
export async function expectPlaybackControl(page: Page) {
await expect(
page.getByTestId('demo-play').or(page.getByTestId('demo-pause')),
).toBeVisible({ timeout: 15_000 });
}
/**
* Open debug demo, pause, then seek to a case WHILE paused (seek preserves
* paused status — unlike seek from idle which auto-starts).
*/
export async function openPausedCase(page: Page, caseIndex: number, speed: '0.5' | '1' | '1.5' | '2' = '1') {
await page.goto('/?debug=1');
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('demo-hud')).toBeVisible();
await ensurePaused(page);
await page.getByTestId(`demo-speed-${speed}`).click();
await page.getByTestId(`demo-case-${caseIndex}`).click();
await dismissFinished(page);
await ensurePaused(page);
await expect(page.getByTestId('demo-case-label')).toHaveText(`${caseIndex + 1}/12`);
}

36
e2e/routes.spec.ts Normal file
View File

@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
test.describe('routes', () => {
test('two-page UI: simulation, documentation, unknown redirect', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/$/);
await expect(page.getByTestId('app-nav').first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('nav-simulation').first()).toHaveClass(/active/);
await page.getByTestId('nav-documentation').first().click();
await expect(page).toHaveURL(/\/documentation\/?$/);
await expect(page.getByTestId('documentation-page')).toBeVisible();
await expect(page.getByTestId('docs-production-status')).toContainText(
'FINAL_ENGINEERING_PROTOTYPE_READY',
);
await expect(page.getByTestId('docs-production-status')).toContainText('WORKING_PROTOTYPE');
await expect(page.getByTestId('nav-documentation').first()).toHaveClass(/active/);
await page.reload();
await expect(page).toHaveURL(/\/documentation\/?$/);
await expect(page.getByTestId('documentation-page')).toBeVisible();
await page.getByTestId('nav-simulation').first().click();
await expect(page).toHaveURL(/\/$/);
await expect(page.getByTestId('demo-hud')).toBeVisible({ timeout: 60_000 });
await page.goto('/details');
await expect(page).toHaveURL(/\/$/);
await page.goto('/device-test');
await expect(page).toHaveURL(/\/$/);
await page.goto('/old-route');
await expect(page).toHaveURL(/\/$/);
});
});

31
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';
import { ensureRunning, expectPlaybackControl } from './helpers';
test.describe('smoke', () => {
test('home opens, canvas loads, play works', async ({ page }) => {
const pageErrors: Error[] = [];
page.on('pageerror', (err) => pageErrors.push(err));
await page.goto('/');
const loading = page.locator('.three-loading');
const canvas = page.locator('canvas');
await expect(loading.or(canvas).first()).toBeVisible({ timeout: 30_000 });
await expect(canvas).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('demo-hud')).toBeVisible();
// Stage 2B: demo autostarts — Pause is present immediately; Play after pause.
await expectPlaybackControl(page);
await ensureRunning(page);
await expect(page.getByTestId('demo-status')).not.toHaveText('FINISHED');
const finished = page.getByTestId('demo-finished');
if (await finished.isVisible().catch(() => false)) {
await page.getByTestId('demo-play').click();
await expect(finished).toBeHidden({ timeout: 10_000 });
await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 });
}
expect(pageErrors, `pageerrors: ${pageErrors.map((e) => e.message).join('; ')}`).toEqual([]);
});
});

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#070b12" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230b1220'/%3E%3Crect x='3' y='7' width='10' height='2' fill='%233b82f6'/%3E%3C/svg%3E" />
<title>OZON Tech Sorter Simulation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

28546
input_info/doc-1783011400.pdf Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

56
nginx.conf Normal file
View File

@@ -0,0 +1,56 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Keep default MIME map (html/js/css). Do NOT put a server-level `types {}`
# here — it replaces mime.types and forces downloads (octet-stream + nosniff).
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Security headers (Stage 2B §24.3)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# gzip for text payloads (GLB/STL are already compressed/binary)
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/javascript image/svg+xml;
location = /version.json {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 3D models only — scoped types override (does not wipe html/js MIME)
location /models/ {
types {
model/gltf-binary glb;
model/stl stl;
application/octet-stream bin;
}
default_type application/octet-stream;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /draco/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

File diff suppressed because one or more lines are too long

2227
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "ozon-tech-sorter-simulation",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 3100",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1 --port 3100",
"test": "vitest run --config vitest.config.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"@dimforge/rapier3d-compat": "^0.19.3",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/postprocessing": "^3.0.4",
"@react-three/rapier": "^2.2.0",
"@vitejs/plugin-react": "latest",
"postprocessing": "^6.39.4",
"react": "latest",
"react-dom": "latest",
"react-router-dom": "^7.18.1",
"three": "^0.185.1",
"typescript": "latest",
"vite": "latest"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.185.0",
"vitest": "^4.1.9"
}
}

42
playwright.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3101';
const startServer = process.env.PLAYWRIGHT_START_SERVER === '1';
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
retries: 1,
forbidOnly: !!process.env.CI,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
viewport: { width: 1280, height: 720 },
},
expect: {
toHaveScreenshot: {
// Soft thresholds — WebGL/fonts can vary slightly across environments
threshold: 0.35,
maxDiffPixelRatio: 0.08,
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// Keep visual/e2e deterministic on one worker (CI + local)
// Production smoke is excluded via package.json --grep-invert @production
webServer: startServer
? {
command: 'npm run preview -- --host 127.0.0.1 --port 3101',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
}
: undefined,
});

Binary file not shown.

31
public/draco/README.md Normal file
View File

@@ -0,0 +1,31 @@
# Draco 3D Data Compression
Draco is an open-source library for compressing and decompressing 3D geometric meshes and point clouds. It is intended to improve the storage and transmission of 3D graphics.
[Website](https://google.github.io/draco/) | [GitHub](https://github.com/google/draco)
## Contents
This folder contains three utilities:
* `draco_decoder.js` — Emscripten-compiled decoder, compatible with any modern browser.
* `draco_decoder.wasm` — WebAssembly decoder, compatible with newer browsers and devices.
* `draco_wasm_wrapper.js` — JavaScript wrapper for the WASM decoder.
Each file is provided in two variations:
* **Default:** Latest stable builds, tracking the project's [master branch](https://github.com/google/draco).
* **glTF:** Builds targeted by the [glTF mesh compression extension](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_draco_mesh_compression), tracking the [corresponding Draco branch](https://github.com/google/draco/tree/gltf_2.0_draco_extension).
Either variation may be used with `DRACOLoader`:
```js
var dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('path/to/decoders/');
```
Further [documentation on GitHub](https://github.com/google/draco/tree/master/javascript/example#static-loading-javascript-decoder).
## License
[Apache License 2.0](https://github.com/google/draco/blob/master/LICENSE)

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,117 @@
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(k){var n=0;return function(){return n<k.length?{done:!1,value:k[n++]}:{done:!0}}};$jscomp.arrayIterator=function(k){return{next:$jscomp.arrayIteratorImpl(k)}};$jscomp.makeIterator=function(k){var n="undefined"!=typeof Symbol&&Symbol.iterator&&k[Symbol.iterator];return n?n.call(k):$jscomp.arrayIterator(k)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
$jscomp.ISOLATE_POLYFILLS=!1;$jscomp.FORCE_POLYFILL_PROMISE=!1;$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION=!1;$jscomp.getGlobal=function(k){k=["object"==typeof globalThis&&globalThis,k,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var n=0;n<k.length;++n){var l=k[n];if(l&&l.Math==Math)return l}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(k,n,l){if(k==Array.prototype||k==Object.prototype)return k;k[n]=l.value;return k};$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";
var $jscomp$lookupPolyfilledValue=function(k,n){var l=$jscomp.propertyToPolyfillSymbol[n];if(null==l)return k[n];l=k[l];return void 0!==l?l:k[n]};$jscomp.polyfill=function(k,n,l,p){n&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(k,n,l,p):$jscomp.polyfillUnisolated(k,n,l,p))};
$jscomp.polyfillUnisolated=function(k,n,l,p){l=$jscomp.global;k=k.split(".");for(p=0;p<k.length-1;p++){var h=k[p];if(!(h in l))return;l=l[h]}k=k[k.length-1];p=l[k];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(l,k,{configurable:!0,writable:!0,value:n})};
$jscomp.polyfillIsolated=function(k,n,l,p){var h=k.split(".");k=1===h.length;p=h[0];p=!k&&p in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var A=0;A<h.length-1;A++){var f=h[A];if(!(f in p))return;p=p[f]}h=h[h.length-1];l=$jscomp.IS_SYMBOL_NATIVE&&"es6"===l?p[h]:null;n=n(l);null!=n&&(k?$jscomp.defineProperty($jscomp.polyfills,h,{configurable:!0,writable:!0,value:n}):n!==l&&(void 0===$jscomp.propertyToPolyfillSymbol[h]&&(l=1E9*Math.random()>>>0,$jscomp.propertyToPolyfillSymbol[h]=$jscomp.IS_SYMBOL_NATIVE?
$jscomp.global.Symbol(h):$jscomp.POLYFILL_PREFIX+l+"$"+h),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[h],{configurable:!0,writable:!0,value:n})))};
$jscomp.polyfill("Promise",function(k){function n(){this.batch_=null}function l(f){return f instanceof h?f:new h(function(q,v){q(f)})}if(k&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return k;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)};
var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q<f.length;++q){var v=f[q];f[q]=null;try{v()}catch(z){this.asyncThrow_(z)}}}this.batch_=null};n.prototype.asyncThrow_=function(f){this.asyncExecuteFunction(function(){throw f;})};var h=function(f){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];this.isRejectionHandled_=!1;var q=this.createResolveAndReject_();
try{f(q.resolve,q.reject)}catch(v){q.reject(v)}};h.prototype.createResolveAndReject_=function(){function f(z){return function(O){v||(v=!0,z.call(q,O))}}var q=this,v=!1;return{resolve:f(this.resolveTo_),reject:f(this.reject_)}};h.prototype.resolveTo_=function(f){if(f===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof h)this.settleSameAsPromise_(f);else{a:switch(typeof f){case "object":var q=null!=f;break a;case "function":q=!0;break a;default:q=!1}q?this.resolveToNonPromiseObj_(f):
this.fulfill_(f)}};h.prototype.resolveToNonPromiseObj_=function(f){var q=void 0;try{q=f.then}catch(v){this.reject_(v);return}"function"==typeof q?this.settleSameAsThenable_(q,f):this.fulfill_(f)};h.prototype.reject_=function(f){this.settle_(2,f)};h.prototype.fulfill_=function(f){this.settle_(1,f)};h.prototype.settle_=function(f,q){if(0!=this.state_)throw Error("Cannot settle("+f+", "+q+"): Promise already settled in state"+this.state_);this.state_=f;this.result_=q;2===this.state_&&this.scheduleUnhandledRejectionCheck_();
this.executeOnSettledCallbacks_()};h.prototype.scheduleUnhandledRejectionCheck_=function(){var f=this;p(function(){if(f.notifyUnhandledRejection_()){var q=$jscomp.global.console;"undefined"!==typeof q&&q.error(f.result_)}},1)};h.prototype.notifyUnhandledRejection_=function(){if(this.isRejectionHandled_)return!1;var f=$jscomp.global.CustomEvent,q=$jscomp.global.Event,v=$jscomp.global.dispatchEvent;if("undefined"===typeof v)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):
"function"===typeof q?f=new q("unhandledrejection",{cancelable:!0}):(f=$jscomp.global.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.result_;return v(f)};h.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var f=0;f<this.onSettledCallbacks_.length;++f)A.asyncExecute(this.onSettledCallbacks_[f]);this.onSettledCallbacks_=null}};var A=new n;h.prototype.settleSameAsPromise_=function(f){var q=this.createResolveAndReject_();
f.callWhenSettled_(q.resolve,q.reject)};h.prototype.settleSameAsThenable_=function(f,q){var v=this.createResolveAndReject_();try{f.call(q,v.resolve,v.reject)}catch(z){v.reject(z)}};h.prototype.then=function(f,q){function v(t,x){return"function"==typeof t?function(D){try{z(t(D))}catch(R){O(R)}}:x}var z,O,ba=new h(function(t,x){z=t;O=x});this.callWhenSettled_(v(f,z),v(q,O));return ba};h.prototype.catch=function(f){return this.then(void 0,f)};h.prototype.callWhenSettled_=function(f,q){function v(){switch(z.state_){case 1:f(z.result_);
break;case 2:q(z.result_);break;default:throw Error("Unexpected state: "+z.state_);}}var z=this;null==this.onSettledCallbacks_?A.asyncExecute(v):this.onSettledCallbacks_.push(v);this.isRejectionHandled_=!0};h.resolve=l;h.reject=function(f){return new h(function(q,v){v(f)})};h.race=function(f){return new h(function(q,v){for(var z=$jscomp.makeIterator(f),O=z.next();!O.done;O=z.next())l(O.value).callWhenSettled_(q,v)})};h.all=function(f){var q=$jscomp.makeIterator(f),v=q.next();return v.done?l([]):new h(function(z,
O){function ba(D){return function(R){t[D]=R;x--;0==x&&z(t)}}var t=[],x=0;do t.push(void 0),x++,l(v.value).callWhenSettled_(ba(t.length-1),O),v=q.next();while(!v.done)})};return h},"es6","es3");$jscomp.owns=function(k,n){return Object.prototype.hasOwnProperty.call(k,n)};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(k,n){for(var l=1;l<arguments.length;l++){var p=arguments[l];if(p)for(var h in p)$jscomp.owns(p,h)&&(k[h]=p[h])}return k};
$jscomp.polyfill("Object.assign",function(k){return k||$jscomp.assign},"es6","es3");$jscomp.checkStringArgs=function(k,n,l){if(null==k)throw new TypeError("The 'this' value for String.prototype."+l+" must not be null or undefined");if(n instanceof RegExp)throw new TypeError("First argument to String.prototype."+l+" must not be a regular expression");return k+""};
$jscomp.polyfill("String.prototype.startsWith",function(k){return k?k:function(n,l){var p=$jscomp.checkStringArgs(this,n,"startsWith");n+="";var h=p.length,A=n.length;l=Math.max(0,Math.min(l|0,p.length));for(var f=0;f<A&&l<h;)if(p[l++]!=n[f++])return!1;return f>=A}},"es6","es3");
$jscomp.polyfill("Array.prototype.copyWithin",function(k){function n(l){l=Number(l);return Infinity===l||-Infinity===l?l:l|0}return k?k:function(l,p,h){var A=this.length;l=n(l);p=n(p);h=void 0===h?A:n(h);l=0>l?Math.max(A+l,0):Math.min(l,A);p=0>p?Math.max(A+p,0):Math.min(p,A);h=0>h?Math.max(A+h,0):Math.min(h,A);if(l<p)for(;p<h;)p in this?this[l++]=this[p++]:(delete this[l++],p++);else for(h=Math.min(h,A+p-l),l+=h-p;h>p;)--h in this?this[--l]=this[h]:delete this[--l];return this}},"es6","es3");
$jscomp.typedArrayCopyWithin=function(k){return k?k:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
$jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
var DracoDecoderModule=function(){var k="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(k=k||__filename);return function(n){function l(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b,c){var d=b+c;for(c=b;e[c]&&!(c>=d);)++c;if(16<c-b&&e.buffer&&va)return va.decode(e.subarray(b,c));for(d="";b<c;){var g=e[b++];if(g&128){var u=e[b++]&63;if(192==(g&224))d+=String.fromCharCode((g&31)<<6|u);else{var X=e[b++]&63;g=224==
(g&240)?(g&15)<<12|u<<6|X:(g&7)<<18|u<<12|X<<6|e[b++]&63;65536>g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}return d}function h(e,b){return e?p(ea,e,b):""}function A(){var e=ja.buffer;a.HEAP8=Y=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ea=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=V=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function f(e){if(a.onAbort)a.onAbort(e);
e="Aborted("+e+")";da(e);wa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function q(e){try{if(e==P&&fa)return new Uint8Array(fa);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){f(b)}}function v(){if(!fa&&(xa||ha)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return q(P)});
if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return q(P)})}function z(e){for(;0<e.length;)e.shift()(a)}function O(e){this.excPtr=e;this.ptr=e-24;this.set_type=function(b){V[this.ptr+4>>2]=b};this.get_type=function(){return V[this.ptr+4>>2]};this.set_destructor=function(b){V[this.ptr+8>>2]=b};this.get_destructor=function(){return V[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){Y[this.ptr+
12>>0]=b?1:0};this.get_caught=function(){return 0!=Y[this.ptr+12>>0]};this.set_rethrown=function(b){Y[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=Y[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){V[this.ptr+
16>>2]=b};this.get_adjusted_ptr=function(){return V[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ya(this.get_type()))return V[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function ba(){function e(){if(!la&&(la=!0,a.calledRun=!0,!wa)){za=!0;z(oa);Aa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)Ba.unshift(a.postRun.shift());z(Ba)}}if(!(0<aa)){if(a.preRun)for("function"==
typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)Ca.unshift(a.preRun.shift());z(Ca);0<aa||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);e()},1)):e())}}function t(){}function x(e){return(e||t).__cache__}function D(e,b){var c=x(b),d=c[e];if(d)return d;d=Object.create((b||t).prototype);d.ptr=e;return c[e]=d}function R(e){if("string"===typeof e){for(var b=0,c=0;c<e.length;++c){var d=e.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=
d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0<d){d=c+d-1;for(var g=0;g<e.length;++g){var u=e.charCodeAt(g);if(55296<=u&&57343>=u){var X=e.charCodeAt(++g);u=65536+((u&1023)<<10)|X&1023}if(127>=u){if(c>=d)break;b[c++]=u}else{if(2047>=u){if(c+1>=d)break;b[c++]=192|u>>6}else{if(65535>=u){if(c+2>=d)break;b[c++]=224|u>>12}else{if(c+3>=d)break;b[c++]=240|u>>18;b[c++]=128|u>>12&63}b[c++]=128|u>>6&63}b[c++]=128|u&63}}b[c]=0}e=r.alloc(b,Y);r.copy(b,Y,e);return e}return e}function pa(e){if("object"===typeof e){var b=
r.alloc(e,Y);r.copy(e,Y,b);return b}return e}function Z(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=Da();x(S)[this.ptr]=this}function Q(){this.ptr=Ea();x(Q)[this.ptr]=this}function W(){this.ptr=Fa();x(W)[this.ptr]=this}function w(){this.ptr=Ga();x(w)[this.ptr]=this}function C(){this.ptr=Ha();x(C)[this.ptr]=this}function F(){this.ptr=Ia();x(F)[this.ptr]=this}function G(){this.ptr=Ja();x(G)[this.ptr]=this}function E(){this.ptr=Ka();x(E)[this.ptr]=this}function T(){this.ptr=
La();x(T)[this.ptr]=this}function B(){throw"cannot construct a Status, no constructor in IDL";}function H(){this.ptr=Ma();x(H)[this.ptr]=this}function I(){this.ptr=Na();x(I)[this.ptr]=this}function J(){this.ptr=Oa();x(J)[this.ptr]=this}function K(){this.ptr=Pa();x(K)[this.ptr]=this}function L(){this.ptr=Qa();x(L)[this.ptr]=this}function M(){this.ptr=Ra();x(M)[this.ptr]=this}function N(){this.ptr=Sa();x(N)[this.ptr]=this}function y(){this.ptr=Ta();x(y)[this.ptr]=this}function m(){this.ptr=Ua();x(m)[this.ptr]=
this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},Aa,ka;a.ready=new Promise(function(e,b){Aa=e;ka=b});var Va=!1,Wa=!1;a.onRuntimeInitialized=function(){Va=!0;if(Wa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Wa=!0;if(Va&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3<e.length?!1:1==e[0]&&0<=e[1]&&5>=e[1]?!0:0!=e[0]||10<e[1]?!1:!0};var Xa=
Object.assign({},a),xa="object"==typeof window,ha="function"==typeof importScripts,Ya="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ya){var Za=require("fs"),qa=require("path");U=ha?qa.dirname(U)+"/":__dirname+"/";var $a=function(e,b){e=e.startsWith("file://")?new URL(e):qa.normalize(e);return Za.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=$a(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e,b,c){e=e.startsWith("file://")?
new URL(e):qa.normalize(e);Za.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1<process.argv.length&&process.argv[1].replace(/\\/g,"/");process.argv.slice(2);a.inspect=function(){return"[Emscripten Module object]"}}else if(xa||ha)ha?U=self.location.href:"undefined"!=typeof document&&document.currentScript&&(U=document.currentScript.src),k&&(U=k),U=0!==U.indexOf("blob:")?U.substr(0,U.replace(/[?#].*/,"").lastIndexOf("/")+1):"",$a=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.send(null);
return b.responseText},ha&&(ma=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=function(e,b,c){var d=new XMLHttpRequest;d.open("GET",e,!0);d.responseType="arraybuffer";d.onload=function(){200==d.status||0==d.status&&d.response?b(d.response):c()};d.onerror=c;d.send(null)};var ud=a.print||console.log.bind(console),da=a.printErr||console.warn.bind(console);Object.assign(a,Xa);Xa=null;var fa;a.wasmBinary&&(fa=a.wasmBinary);
"object"!=typeof WebAssembly&&f("no native wasm support detected");var ja,wa=!1,va="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,Y,ea,ca,V,Ca=[],oa=[],Ba=[],za=!1,aa=0,ra=null,ia=null;var P="draco_decoder.wasm";P.startsWith("data:application/octet-stream;base64,")||(P=l(P));var vd=0,wd=[null,[],[]],xd={b:function(e,b,c){(new O(e)).init(b,c);vd++;throw e;},a:function(){f("")},g:function(e,b,c){ea.copyWithin(e,b,b+c)},e:function(e){var b=ea.length;e>>>=0;if(2147483648<e)return!1;for(var c=
1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);A();var u=1;break a}catch(X){}u=void 0}if(u)return!0}return!1},f:function(e){return 52},d:function(e,b,c,d,g){return 70},c:function(e,b,c,d){for(var g=0,u=0;u<c;u++){var X=V[b>>2],ab=V[b+4>>2];b+=8;for(var sa=0;sa<ab;sa++){var ta=ea[X+sa],ua=wd[e];0===ta||10===ta?((1===e?ud:da)(p(ua,0)),ua.length=0):ua.push(ta)}g+=
ab}V[d>>2]=g;return 0}};(function(){function e(g,u){a.asm=g.exports;ja=a.asm.h;A();oa.unshift(a.asm.i);aa--;a.monitorRunDependencies&&a.monitorRunDependencies(aa);0==aa&&(null!==ra&&(clearInterval(ra),ra=null),ia&&(g=ia,ia=null,g()))}function b(g){e(g.instance)}function c(g){return v().then(function(u){return WebAssembly.instantiate(u,d)}).then(function(u){return u}).then(g,function(u){da("failed to asynchronously prepare wasm: "+u);f(u)})}var d={a:xd};aa++;a.monitorRunDependencies&&a.monitorRunDependencies(aa);
if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return fa||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")||P.startsWith("file://")||Ya||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(u){da("wasm streaming compile failed: "+u);da("falling back to ArrayBuffer instantiation");
return c(b)})})})().catch(ka);return{}})();var bb=a._emscripten_bind_VoidPtr___destroy___0=function(){return(bb=a._emscripten_bind_VoidPtr___destroy___0=a.asm.k).apply(null,arguments)},Da=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=function(){return(Da=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.l).apply(null,arguments)},cb=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(cb=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.m).apply(null,arguments)},db=a._emscripten_bind_DecoderBuffer___destroy___0=
function(){return(db=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.n).apply(null,arguments)},Ea=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Ea=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=a.asm.o).apply(null,arguments)},eb=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return(eb=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.p).apply(null,arguments)},fb=a._emscripten_bind_AttributeTransformData___destroy___0=
function(){return(fb=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.q).apply(null,arguments)},Fa=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Fa=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=a.asm.r).apply(null,arguments)},gb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(gb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.s).apply(null,arguments)},Ga=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ga=
a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.t).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_size_0=function(){return(hb=a._emscripten_bind_PointAttribute_size_0=a.asm.u).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=function(){return(ib=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.v).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(jb=a._emscripten_bind_PointAttribute_attribute_type_0=
a.asm.w).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(kb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.x).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(lb=a._emscripten_bind_PointAttribute_num_components_0=a.asm.y).apply(null,arguments)},mb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(mb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.z).apply(null,arguments)},nb=a._emscripten_bind_PointAttribute_byte_stride_0=
function(){return(nb=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.A).apply(null,arguments)},ob=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(ob=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.B).apply(null,arguments)},pb=a._emscripten_bind_PointAttribute_unique_id_0=function(){return(pb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.C).apply(null,arguments)},qb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(qb=a._emscripten_bind_PointAttribute___destroy___0=
a.asm.D).apply(null,arguments)},Ha=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Ha=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.E).apply(null,arguments)},rb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.F).apply(null,arguments)},sb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=
function(){return(sb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.G).apply(null,arguments)},tb=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(tb=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.H).apply(null,arguments)},ub=a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(ub=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.I).apply(null,arguments)},vb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=
function(){return(vb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.J).apply(null,arguments)},Ia=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ia=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=a.asm.K).apply(null,arguments)},wb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(wb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.L).apply(null,
arguments)},xb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(xb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.M).apply(null,arguments)},yb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(yb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.N).apply(null,arguments)},Ja=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Ja=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.O).apply(null,
arguments)},zb=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(zb=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.P).apply(null,arguments)},Ab=a._emscripten_bind_PointCloud_num_points_0=function(){return(Ab=a._emscripten_bind_PointCloud_num_points_0=a.asm.Q).apply(null,arguments)},Bb=a._emscripten_bind_PointCloud___destroy___0=function(){return(Bb=a._emscripten_bind_PointCloud___destroy___0=a.asm.R).apply(null,arguments)},Ka=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ka=
a._emscripten_bind_Mesh_Mesh_0=a.asm.S).apply(null,arguments)},Cb=a._emscripten_bind_Mesh_num_faces_0=function(){return(Cb=a._emscripten_bind_Mesh_num_faces_0=a.asm.T).apply(null,arguments)},Db=a._emscripten_bind_Mesh_num_attributes_0=function(){return(Db=a._emscripten_bind_Mesh_num_attributes_0=a.asm.U).apply(null,arguments)},Eb=a._emscripten_bind_Mesh_num_points_0=function(){return(Eb=a._emscripten_bind_Mesh_num_points_0=a.asm.V).apply(null,arguments)},Fb=a._emscripten_bind_Mesh___destroy___0=function(){return(Fb=
a._emscripten_bind_Mesh___destroy___0=a.asm.W).apply(null,arguments)},La=a._emscripten_bind_Metadata_Metadata_0=function(){return(La=a._emscripten_bind_Metadata_Metadata_0=a.asm.X).apply(null,arguments)},Gb=a._emscripten_bind_Metadata___destroy___0=function(){return(Gb=a._emscripten_bind_Metadata___destroy___0=a.asm.Y).apply(null,arguments)},Hb=a._emscripten_bind_Status_code_0=function(){return(Hb=a._emscripten_bind_Status_code_0=a.asm.Z).apply(null,arguments)},Ib=a._emscripten_bind_Status_ok_0=function(){return(Ib=
a._emscripten_bind_Status_ok_0=a.asm._).apply(null,arguments)},Jb=a._emscripten_bind_Status_error_msg_0=function(){return(Jb=a._emscripten_bind_Status_error_msg_0=a.asm.$).apply(null,arguments)},Kb=a._emscripten_bind_Status___destroy___0=function(){return(Kb=a._emscripten_bind_Status___destroy___0=a.asm.aa).apply(null,arguments)},Ma=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ma=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm.ba).apply(null,arguments)},
Lb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Lb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.ca).apply(null,arguments)},Mb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Mb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.da).apply(null,arguments)},Nb=a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Nb=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ea).apply(null,arguments)},Na=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=
function(){return(Na=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.fa).apply(null,arguments)},Ob=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Ob=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.ga).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt8Array_size_0=function(){return(Pb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ha).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Qb=a._emscripten_bind_DracoInt8Array___destroy___0=
a.asm.ia).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ja).apply(null,arguments)},Rb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Rb=a._emscripten_bind_DracoUInt8Array_GetValue_1=a.asm.ka).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Sb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.la).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt8Array___destroy___0=
function(){return(Tb=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ma).apply(null,arguments)},Pa=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(Pa=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.na).apply(null,arguments)},Ub=a._emscripten_bind_DracoInt16Array_GetValue_1=function(){return(Ub=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.oa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Vb=a._emscripten_bind_DracoInt16Array_size_0=
a.asm.pa).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Wb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.qa).apply(null,arguments)},Qa=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Qa=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=a.asm.ra).apply(null,arguments)},Xb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Xb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.sa).apply(null,arguments)},
Yb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Yb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.ta).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Zb=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ua).apply(null,arguments)},Ra=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=function(){return(Ra=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.va).apply(null,arguments)},$b=a._emscripten_bind_DracoInt32Array_GetValue_1=
function(){return($b=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.wa).apply(null,arguments)},ac=a._emscripten_bind_DracoInt32Array_size_0=function(){return(ac=a._emscripten_bind_DracoInt32Array_size_0=a.asm.xa).apply(null,arguments)},bc=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(bc=a._emscripten_bind_DracoInt32Array___destroy___0=a.asm.ya).apply(null,arguments)},Sa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Sa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=
a.asm.za).apply(null,arguments)},cc=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(cc=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.Aa).apply(null,arguments)},dc=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(dc=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.Ba).apply(null,arguments)},ec=a._emscripten_bind_DracoUInt32Array___destroy___0=function(){return(ec=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.Ca).apply(null,arguments)},Ta=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=
function(){return(Ta=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Da).apply(null,arguments)},fc=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(fc=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ea).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=a.asm.Fa).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(hc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=
a.asm.Ga).apply(null,arguments)},ic=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(ic=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ha).apply(null,arguments)},jc=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(jc=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Ia).apply(null,arguments)},kc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(kc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ja).apply(null,arguments)},
lc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(lc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ka).apply(null,arguments)},mc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(mc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.La).apply(null,arguments)},Ua=a._emscripten_bind_Decoder_Decoder_0=function(){return(Ua=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ma).apply(null,arguments)},nc=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(nc=
a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Na).apply(null,arguments)},oc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(oc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.Oa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(pc=a._emscripten_bind_Decoder_GetAttributeId_2=a.asm.Pa).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=
a.asm.Qa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(rc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Ra).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(sc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Sa).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(tc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Ta).apply(null,arguments)},
uc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(uc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ua).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Va).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=function(){return(wc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Wa).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=
function(){return(xc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Xa).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(yc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Ya).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(zc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Za).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(Ac=
a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm._a).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.$a).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=a.asm.ab).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(Dc=
a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm.bb).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.cb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Fc=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=a.asm.db).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=
function(){return(Gc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.eb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Hc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.fb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Ic=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=a.asm.gb).apply(null,arguments)},Jc=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=
function(){return(Jc=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.hb).apply(null,arguments)},Kc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Kc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.ib).apply(null,arguments)},Lc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Lc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=a.asm.jb).apply(null,arguments)},Mc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=
function(){return(Mc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.kb).apply(null,arguments)},Nc=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Nc=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.lb).apply(null,arguments)},Oc=a._emscripten_bind_Decoder___destroy___0=function(){return(Oc=a._emscripten_bind_Decoder___destroy___0=a.asm.mb).apply(null,arguments)},Pc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=function(){return(Pc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=
a.asm.nb).apply(null,arguments)},Qc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Qc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.ob).apply(null,arguments)},Rc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Rc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=a.asm.pb).apply(null,arguments)},Sc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=
function(){return(Sc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.qb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.rb).apply(null,arguments)},Uc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Uc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=a.asm.sb).apply(null,arguments)},Vc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=
function(){return(Vc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.tb).apply(null,arguments)},Wc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Wc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.ub).apply(null,arguments)},Xc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Xc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=a.asm.vb).apply(null,arguments)},Yc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=
function(){return(Yc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.wb).apply(null,arguments)},Zc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Zc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.xb).apply(null,arguments)},$c=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return($c=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=a.asm.yb).apply(null,arguments)},ad=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=
function(){return(ad=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.zb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.Ab).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(cd=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.Bb).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(dd=a._emscripten_enum_draco_DataType_DT_UINT8=
a.asm.Cb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_INT16=function(){return(ed=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Db).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(fd=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Eb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(gd=a._emscripten_enum_draco_DataType_DT_INT32=a.asm.Fb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_UINT32=
function(){return(hd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Gb).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(id=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Hb).apply(null,arguments)},jd=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(jd=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Ib).apply(null,arguments)},kd=a._emscripten_enum_draco_DataType_DT_FLOAT32=function(){return(kd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Jb).apply(null,
arguments)},ld=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(ld=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Kb).apply(null,arguments)},md=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(md=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Lb).apply(null,arguments)},nd=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(nd=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=a.asm.Mb).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_OK=function(){return(od=
a._emscripten_enum_draco_StatusCode_OK=a.asm.Nb).apply(null,arguments)},pd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(pd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Ob).apply(null,arguments)},qd=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(qd=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Pb).apply(null,arguments)},rd=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=function(){return(rd=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=
a.asm.Qb).apply(null,arguments)},sd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(sd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Rb).apply(null,arguments)},td=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(td=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Sb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Tb).apply(null,arguments)};a._free=function(){return(a._free=a.asm.Ub).apply(null,arguments)};
var ya=function(){return(ya=a.asm.Vb).apply(null,arguments)};a.___start_em_js=15856;a.___stop_em_js=15954;var la;ia=function b(){la||ba();la||(ia=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();ba();t.prototype=Object.create(t.prototype);t.prototype.constructor=t;t.prototype.__class__=t;t.__cache__={};a.WrapperObject=t;a.getCache=x;a.wrapPointer=D;a.castObject=function(b,c){return D(b.ptr,c)};a.NULL=D(0);a.destroy=function(b){if(!b.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";
b.__destroy__();delete x(b.__class__)[b.ptr]};a.compare=function(b,c){return b.ptr===c.ptr};a.getPointer=function(b){return b.ptr};a.getClass=function(b){return b.__class__};var r={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(r.needed){for(var b=0;b<r.temps.length;b++)a._free(r.temps[b]);r.temps.length=0;a._free(r.buffer);r.buffer=0;r.size+=r.needed;r.needed=0}r.buffer||(r.size+=128,r.buffer=a._malloc(r.size),r.buffer||f(void 0));r.pos=0},alloc:function(b,c){r.buffer||f(void 0);b=
b.length*c.BYTES_PER_ELEMENT;b=b+7&-8;r.pos+b>=r.size?(0<b||f(void 0),r.needed+=b,c=a._malloc(b),r.temps.push(c)):(c=r.buffer+r.pos,r.pos+=b);return c},copy:function(b,c,d){d>>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;g<b.length;g++)c[d+g]=b[g]}};Z.prototype=Object.create(t.prototype);Z.prototype.constructor=Z;Z.prototype.__class__=Z;Z.__cache__={};a.VoidPtr=Z;Z.prototype.__destroy__=Z.prototype.__destroy__=function(){bb(this.ptr)};S.prototype=
Object.create(t.prototype);S.prototype.constructor=S;S.prototype.__class__=S;S.__cache__={};a.DecoderBuffer=S;S.prototype.Init=S.prototype.Init=function(b,c){var d=this.ptr;r.prepare();"object"==typeof b&&(b=pa(b));c&&"object"===typeof c&&(c=c.ptr);cb(d,b,c)};S.prototype.__destroy__=S.prototype.__destroy__=function(){db(this.ptr)};Q.prototype=Object.create(t.prototype);Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.AttributeTransformData=Q;Q.prototype.transform_type=Q.prototype.transform_type=
function(){return eb(this.ptr)};Q.prototype.__destroy__=Q.prototype.__destroy__=function(){fb(this.ptr)};W.prototype=Object.create(t.prototype);W.prototype.constructor=W;W.prototype.__class__=W;W.__cache__={};a.GeometryAttribute=W;W.prototype.__destroy__=W.prototype.__destroy__=function(){gb(this.ptr)};w.prototype=Object.create(t.prototype);w.prototype.constructor=w;w.prototype.__class__=w;w.__cache__={};a.PointAttribute=w;w.prototype.size=w.prototype.size=function(){return hb(this.ptr)};w.prototype.GetAttributeTransformData=
w.prototype.GetAttributeTransformData=function(){return D(ib(this.ptr),Q)};w.prototype.attribute_type=w.prototype.attribute_type=function(){return jb(this.ptr)};w.prototype.data_type=w.prototype.data_type=function(){return kb(this.ptr)};w.prototype.num_components=w.prototype.num_components=function(){return lb(this.ptr)};w.prototype.normalized=w.prototype.normalized=function(){return!!mb(this.ptr)};w.prototype.byte_stride=w.prototype.byte_stride=function(){return nb(this.ptr)};w.prototype.byte_offset=
w.prototype.byte_offset=function(){return ob(this.ptr)};w.prototype.unique_id=w.prototype.unique_id=function(){return pb(this.ptr)};w.prototype.__destroy__=w.prototype.__destroy__=function(){qb(this.ptr)};C.prototype=Object.create(t.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.AttributeQuantizationTransform=C;C.prototype.InitFromAttribute=C.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!rb(c,b)};C.prototype.quantization_bits=
C.prototype.quantization_bits=function(){return sb(this.ptr)};C.prototype.min_value=C.prototype.min_value=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return tb(c,b)};C.prototype.range=C.prototype.range=function(){return ub(this.ptr)};C.prototype.__destroy__=C.prototype.__destroy__=function(){vb(this.ptr)};F.prototype=Object.create(t.prototype);F.prototype.constructor=F;F.prototype.__class__=F;F.__cache__={};a.AttributeOctahedronTransform=F;F.prototype.InitFromAttribute=F.prototype.InitFromAttribute=
function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!wb(c,b)};F.prototype.quantization_bits=F.prototype.quantization_bits=function(){return xb(this.ptr)};F.prototype.__destroy__=F.prototype.__destroy__=function(){yb(this.ptr)};G.prototype=Object.create(t.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.PointCloud=G;G.prototype.num_attributes=G.prototype.num_attributes=function(){return zb(this.ptr)};G.prototype.num_points=G.prototype.num_points=function(){return Ab(this.ptr)};
G.prototype.__destroy__=G.prototype.__destroy__=function(){Bb(this.ptr)};E.prototype=Object.create(t.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.Mesh=E;E.prototype.num_faces=E.prototype.num_faces=function(){return Cb(this.ptr)};E.prototype.num_attributes=E.prototype.num_attributes=function(){return Db(this.ptr)};E.prototype.num_points=E.prototype.num_points=function(){return Eb(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=function(){Fb(this.ptr)};T.prototype=
Object.create(t.prototype);T.prototype.constructor=T;T.prototype.__class__=T;T.__cache__={};a.Metadata=T;T.prototype.__destroy__=T.prototype.__destroy__=function(){Gb(this.ptr)};B.prototype=Object.create(t.prototype);B.prototype.constructor=B;B.prototype.__class__=B;B.__cache__={};a.Status=B;B.prototype.code=B.prototype.code=function(){return Hb(this.ptr)};B.prototype.ok=B.prototype.ok=function(){return!!Ib(this.ptr)};B.prototype.error_msg=B.prototype.error_msg=function(){return h(Jb(this.ptr))};
B.prototype.__destroy__=B.prototype.__destroy__=function(){Kb(this.ptr)};H.prototype=Object.create(t.prototype);H.prototype.constructor=H;H.prototype.__class__=H;H.__cache__={};a.DracoFloat32Array=H;H.prototype.GetValue=H.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Lb(c,b)};H.prototype.size=H.prototype.size=function(){return Mb(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){Nb(this.ptr)};I.prototype=Object.create(t.prototype);I.prototype.constructor=
I;I.prototype.__class__=I;I.__cache__={};a.DracoInt8Array=I;I.prototype.GetValue=I.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Ob(c,b)};I.prototype.size=I.prototype.size=function(){return Pb(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Qb(this.ptr)};J.prototype=Object.create(t.prototype);J.prototype.constructor=J;J.prototype.__class__=J;J.__cache__={};a.DracoUInt8Array=J;J.prototype.GetValue=J.prototype.GetValue=function(b){var c=
this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Rb(c,b)};J.prototype.size=J.prototype.size=function(){return Sb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Tb(this.ptr)};K.prototype=Object.create(t.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DracoInt16Array=K;K.prototype.GetValue=K.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Ub(c,b)};K.prototype.size=K.prototype.size=function(){return Vb(this.ptr)};
K.prototype.__destroy__=K.prototype.__destroy__=function(){Wb(this.ptr)};L.prototype=Object.create(t.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.DracoUInt16Array=L;L.prototype.GetValue=L.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Xb(c,b)};L.prototype.size=L.prototype.size=function(){return Yb(this.ptr)};L.prototype.__destroy__=L.prototype.__destroy__=function(){Zb(this.ptr)};M.prototype=Object.create(t.prototype);M.prototype.constructor=
M;M.prototype.__class__=M;M.__cache__={};a.DracoInt32Array=M;M.prototype.GetValue=M.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return $b(c,b)};M.prototype.size=M.prototype.size=function(){return ac(this.ptr)};M.prototype.__destroy__=M.prototype.__destroy__=function(){bc(this.ptr)};N.prototype=Object.create(t.prototype);N.prototype.constructor=N;N.prototype.__class__=N;N.__cache__={};a.DracoUInt32Array=N;N.prototype.GetValue=N.prototype.GetValue=function(b){var c=
this.ptr;b&&"object"===typeof b&&(b=b.ptr);return cc(c,b)};N.prototype.size=N.prototype.size=function(){return dc(this.ptr)};N.prototype.__destroy__=N.prototype.__destroy__=function(){ec(this.ptr)};y.prototype=Object.create(t.prototype);y.prototype.constructor=y;y.prototype.__class__=y;y.__cache__={};a.MetadataQuerier=y;y.prototype.HasEntry=y.prototype.HasEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return!!fc(d,b,c)};y.prototype.GetIntEntry=
y.prototype.GetIntEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return gc(d,b,c)};y.prototype.GetIntEntryArray=y.prototype.GetIntEntryArray=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d&&"object"===typeof d&&(d=d.ptr);hc(g,b,c,d)};y.prototype.GetDoubleEntry=y.prototype.GetDoubleEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=
c&&"object"===typeof c?c.ptr:R(c);return ic(d,b,c)};y.prototype.GetStringEntry=y.prototype.GetStringEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return h(jc(d,b,c))};y.prototype.NumEntries=y.prototype.NumEntries=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return kc(c,b)};y.prototype.GetEntryName=y.prototype.GetEntryName=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=
c.ptr);return h(lc(d,b,c))};y.prototype.__destroy__=y.prototype.__destroy__=function(){mc(this.ptr)};m.prototype=Object.create(t.prototype);m.prototype.constructor=m;m.prototype.__class__=m;m.__cache__={};a.Decoder=m;m.prototype.DecodeArrayToPointCloud=m.prototype.DecodeArrayToPointCloud=function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=pa(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return D(nc(g,b,c,d),B)};m.prototype.DecodeArrayToMesh=m.prototype.DecodeArrayToMesh=
function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=pa(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return D(oc(g,b,c,d),B)};m.prototype.GetAttributeId=m.prototype.GetAttributeId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return pc(d,b,c)};m.prototype.GetAttributeIdByName=m.prototype.GetAttributeIdByName=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?
c.ptr:R(c);return qc(d,b,c)};m.prototype.GetAttributeIdByMetadataEntry=m.prototype.GetAttributeIdByMetadataEntry=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d=d&&"object"===typeof d?d.ptr:R(d);return rc(g,b,c,d)};m.prototype.GetAttribute=m.prototype.GetAttribute=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return D(sc(d,b,c),w)};m.prototype.GetAttributeByUniqueId=m.prototype.GetAttributeByUniqueId=
function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return D(tc(d,b,c),w)};m.prototype.GetMetadata=m.prototype.GetMetadata=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return D(uc(c,b),T)};m.prototype.GetAttributeMetadata=m.prototype.GetAttributeMetadata=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return D(vc(d,b,c),T)};m.prototype.GetFaceFromMesh=m.prototype.GetFaceFromMesh=function(b,
c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!wc(g,b,c,d)};m.prototype.GetTriangleStripsFromMesh=m.prototype.GetTriangleStripsFromMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return xc(d,b,c)};m.prototype.GetTrianglesUInt16Array=m.prototype.GetTrianglesUInt16Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);
d&&"object"===typeof d&&(d=d.ptr);return!!yc(g,b,c,d)};m.prototype.GetTrianglesUInt32Array=m.prototype.GetTrianglesUInt32Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(g,b,c,d)};m.prototype.GetAttributeFloat=m.prototype.GetAttributeFloat=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(g,b,c,d)};m.prototype.GetAttributeFloatForAllPoints=
m.prototype.GetAttributeFloatForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(g,b,c,d)};m.prototype.GetAttributeIntForAllPoints=m.prototype.GetAttributeIntForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(g,b,c,d)};m.prototype.GetAttributeInt8ForAllPoints=m.prototype.GetAttributeInt8ForAllPoints=
function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Dc(g,b,c,d)};m.prototype.GetAttributeUInt8ForAllPoints=m.prototype.GetAttributeUInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ec(g,b,c,d)};m.prototype.GetAttributeInt16ForAllPoints=m.prototype.GetAttributeInt16ForAllPoints=function(b,c,d){var g=this.ptr;
b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Fc(g,b,c,d)};m.prototype.GetAttributeUInt16ForAllPoints=m.prototype.GetAttributeUInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Gc(g,b,c,d)};m.prototype.GetAttributeInt32ForAllPoints=m.prototype.GetAttributeInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&
(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Hc(g,b,c,d)};m.prototype.GetAttributeUInt32ForAllPoints=m.prototype.GetAttributeUInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ic(g,b,c,d)};m.prototype.GetAttributeDataArrayForAllPoints=m.prototype.GetAttributeDataArrayForAllPoints=function(b,c,d,g,u){var X=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&
"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);g&&"object"===typeof g&&(g=g.ptr);u&&"object"===typeof u&&(u=u.ptr);return!!Jc(X,b,c,d,g,u)};m.prototype.SkipAttributeTransform=m.prototype.SkipAttributeTransform=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);Kc(c,b)};m.prototype.GetEncodedGeometryType_Deprecated=m.prototype.GetEncodedGeometryType_Deprecated=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Lc(c,b)};m.prototype.DecodeBufferToPointCloud=
m.prototype.DecodeBufferToPointCloud=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return D(Mc(d,b,c),B)};m.prototype.DecodeBufferToMesh=m.prototype.DecodeBufferToMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return D(Nc(d,b,c),B)};m.prototype.__destroy__=m.prototype.__destroy__=function(){Oc(this.ptr)};(function(){function b(){a.ATTRIBUTE_INVALID_TRANSFORM=Pc();a.ATTRIBUTE_NO_TRANSFORM=Qc();
a.ATTRIBUTE_QUANTIZATION_TRANSFORM=Rc();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=Sc();a.INVALID=Tc();a.POSITION=Uc();a.NORMAL=Vc();a.COLOR=Wc();a.TEX_COORD=Xc();a.GENERIC=Yc();a.INVALID_GEOMETRY_TYPE=Zc();a.POINT_CLOUD=$c();a.TRIANGULAR_MESH=ad();a.DT_INVALID=bd();a.DT_INT8=cd();a.DT_UINT8=dd();a.DT_INT16=ed();a.DT_UINT16=fd();a.DT_INT32=gd();a.DT_UINT32=hd();a.DT_INT64=id();a.DT_UINT64=jd();a.DT_FLOAT32=kd();a.DT_FLOAT64=ld();a.DT_BOOL=md();a.DT_TYPES_COUNT=nd();a.OK=od();a.DRACO_ERROR=pd();a.IO_ERROR=qd();
a.INVALID_PARAMETER=rd();a.UNSUPPORTED_VERSION=sd();a.UNKNOWN_VERSION=td()}za?b():oa.unshift(b)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();a.Decoder.prototype.GetEncodedGeometryType=function(b){if(b.__class__&&b.__class__===a.DecoderBuffer)return a.Decoder.prototype.GetEncodedGeometryType_Deprecated(b);if(8>b.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();
"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,116 @@
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(h){var n=0;return function(){return n<h.length?{done:!1,value:h[n++]}:{done:!0}}};$jscomp.arrayIterator=function(h){return{next:$jscomp.arrayIteratorImpl(h)}};$jscomp.makeIterator=function(h){var n="undefined"!=typeof Symbol&&Symbol.iterator&&h[Symbol.iterator];return n?n.call(h):$jscomp.arrayIterator(h)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
$jscomp.ISOLATE_POLYFILLS=!1;$jscomp.FORCE_POLYFILL_PROMISE=!1;$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION=!1;$jscomp.getGlobal=function(h){h=["object"==typeof globalThis&&globalThis,h,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var n=0;n<h.length;++n){var k=h[n];if(k&&k.Math==Math)return k}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(h,n,k){if(h==Array.prototype||h==Object.prototype)return h;h[n]=k.value;return h};$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";
var $jscomp$lookupPolyfilledValue=function(h,n){var k=$jscomp.propertyToPolyfillSymbol[n];if(null==k)return h[n];k=h[k];return void 0!==k?k:h[n]};$jscomp.polyfill=function(h,n,k,p){n&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(h,n,k,p):$jscomp.polyfillUnisolated(h,n,k,p))};
$jscomp.polyfillUnisolated=function(h,n,k,p){k=$jscomp.global;h=h.split(".");for(p=0;p<h.length-1;p++){var l=h[p];if(!(l in k))return;k=k[l]}h=h[h.length-1];p=k[h];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(k,h,{configurable:!0,writable:!0,value:n})};
$jscomp.polyfillIsolated=function(h,n,k,p){var l=h.split(".");h=1===l.length;p=l[0];p=!h&&p in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var y=0;y<l.length-1;y++){var f=l[y];if(!(f in p))return;p=p[f]}l=l[l.length-1];k=$jscomp.IS_SYMBOL_NATIVE&&"es6"===k?p[l]:null;n=n(k);null!=n&&(h?$jscomp.defineProperty($jscomp.polyfills,l,{configurable:!0,writable:!0,value:n}):n!==k&&(void 0===$jscomp.propertyToPolyfillSymbol[l]&&(k=1E9*Math.random()>>>0,$jscomp.propertyToPolyfillSymbol[l]=$jscomp.IS_SYMBOL_NATIVE?
$jscomp.global.Symbol(l):$jscomp.POLYFILL_PREFIX+k+"$"+l),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[l],{configurable:!0,writable:!0,value:n})))};
$jscomp.polyfill("Promise",function(h){function n(){this.batch_=null}function k(f){return f instanceof l?f:new l(function(q,u){q(f)})}if(h&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return h;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)};
var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q<f.length;++q){var u=f[q];f[q]=null;try{u()}catch(A){this.asyncThrow_(A)}}}this.batch_=null};n.prototype.asyncThrow_=function(f){this.asyncExecuteFunction(function(){throw f;})};var l=function(f){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];this.isRejectionHandled_=!1;var q=this.createResolveAndReject_();
try{f(q.resolve,q.reject)}catch(u){q.reject(u)}};l.prototype.createResolveAndReject_=function(){function f(A){return function(F){u||(u=!0,A.call(q,F))}}var q=this,u=!1;return{resolve:f(this.resolveTo_),reject:f(this.reject_)}};l.prototype.resolveTo_=function(f){if(f===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof l)this.settleSameAsPromise_(f);else{a:switch(typeof f){case "object":var q=null!=f;break a;case "function":q=!0;break a;default:q=!1}q?this.resolveToNonPromiseObj_(f):
this.fulfill_(f)}};l.prototype.resolveToNonPromiseObj_=function(f){var q=void 0;try{q=f.then}catch(u){this.reject_(u);return}"function"==typeof q?this.settleSameAsThenable_(q,f):this.fulfill_(f)};l.prototype.reject_=function(f){this.settle_(2,f)};l.prototype.fulfill_=function(f){this.settle_(1,f)};l.prototype.settle_=function(f,q){if(0!=this.state_)throw Error("Cannot settle("+f+", "+q+"): Promise already settled in state"+this.state_);this.state_=f;this.result_=q;2===this.state_&&this.scheduleUnhandledRejectionCheck_();
this.executeOnSettledCallbacks_()};l.prototype.scheduleUnhandledRejectionCheck_=function(){var f=this;p(function(){if(f.notifyUnhandledRejection_()){var q=$jscomp.global.console;"undefined"!==typeof q&&q.error(f.result_)}},1)};l.prototype.notifyUnhandledRejection_=function(){if(this.isRejectionHandled_)return!1;var f=$jscomp.global.CustomEvent,q=$jscomp.global.Event,u=$jscomp.global.dispatchEvent;if("undefined"===typeof u)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):
"function"===typeof q?f=new q("unhandledrejection",{cancelable:!0}):(f=$jscomp.global.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.result_;return u(f)};l.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var f=0;f<this.onSettledCallbacks_.length;++f)y.asyncExecute(this.onSettledCallbacks_[f]);this.onSettledCallbacks_=null}};var y=new n;l.prototype.settleSameAsPromise_=function(f){var q=this.createResolveAndReject_();
f.callWhenSettled_(q.resolve,q.reject)};l.prototype.settleSameAsThenable_=function(f,q){var u=this.createResolveAndReject_();try{f.call(q,u.resolve,u.reject)}catch(A){u.reject(A)}};l.prototype.then=function(f,q){function u(w,B){return"function"==typeof w?function(R){try{A(w(R))}catch(Z){F(Z)}}:B}var A,F,v=new l(function(w,B){A=w;F=B});this.callWhenSettled_(u(f,A),u(q,F));return v};l.prototype.catch=function(f){return this.then(void 0,f)};l.prototype.callWhenSettled_=function(f,q){function u(){switch(A.state_){case 1:f(A.result_);
break;case 2:q(A.result_);break;default:throw Error("Unexpected state: "+A.state_);}}var A=this;null==this.onSettledCallbacks_?y.asyncExecute(u):this.onSettledCallbacks_.push(u);this.isRejectionHandled_=!0};l.resolve=k;l.reject=function(f){return new l(function(q,u){u(f)})};l.race=function(f){return new l(function(q,u){for(var A=$jscomp.makeIterator(f),F=A.next();!F.done;F=A.next())k(F.value).callWhenSettled_(q,u)})};l.all=function(f){var q=$jscomp.makeIterator(f),u=q.next();return u.done?k([]):new l(function(A,
F){function v(R){return function(Z){w[R]=Z;B--;0==B&&A(w)}}var w=[],B=0;do w.push(void 0),B++,k(u.value).callWhenSettled_(v(w.length-1),F),u=q.next();while(!u.done)})};return l},"es6","es3");$jscomp.owns=function(h,n){return Object.prototype.hasOwnProperty.call(h,n)};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(h,n){for(var k=1;k<arguments.length;k++){var p=arguments[k];if(p)for(var l in p)$jscomp.owns(p,l)&&(h[l]=p[l])}return h};
$jscomp.polyfill("Object.assign",function(h){return h||$jscomp.assign},"es6","es3");$jscomp.checkStringArgs=function(h,n,k){if(null==h)throw new TypeError("The 'this' value for String.prototype."+k+" must not be null or undefined");if(n instanceof RegExp)throw new TypeError("First argument to String.prototype."+k+" must not be a regular expression");return h+""};
$jscomp.polyfill("String.prototype.startsWith",function(h){return h?h:function(n,k){var p=$jscomp.checkStringArgs(this,n,"startsWith");n+="";var l=p.length,y=n.length;k=Math.max(0,Math.min(k|0,p.length));for(var f=0;f<y&&k<l;)if(p[k++]!=n[f++])return!1;return f>=y}},"es6","es3");
$jscomp.polyfill("Array.prototype.copyWithin",function(h){function n(k){k=Number(k);return Infinity===k||-Infinity===k?k:k|0}return h?h:function(k,p,l){var y=this.length;k=n(k);p=n(p);l=void 0===l?y:n(l);k=0>k?Math.max(y+k,0):Math.min(k,y);p=0>p?Math.max(y+p,0):Math.min(p,y);l=0>l?Math.max(y+l,0):Math.min(l,y);if(k<p)for(;p<l;)p in this?this[k++]=this[p++]:(delete this[k++],p++);else for(l=Math.min(l,y+p-k),k+=l-p;l>p;)--l in this?this[--k]=this[l]:delete this[--k];return this}},"es6","es3");
$jscomp.typedArrayCopyWithin=function(h){return h?h:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
$jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
var DracoDecoderModule=function(){var h="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(h=h||__filename);return function(n){function k(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b){if(e){var c=ia;var d=e+b;for(b=e;c[b]&&!(b>=d);)++b;if(16<b-e&&c.buffer&&ra)c=ra.decode(c.subarray(e,b));else{for(d="";e<b;){var g=c[e++];if(g&128){var t=c[e++]&63;if(192==(g&224))d+=String.fromCharCode((g&31)<<6|t);else{var aa=c[e++]&
63;g=224==(g&240)?(g&15)<<12|t<<6|aa:(g&7)<<18|t<<12|aa<<6|c[e++]&63;65536>g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}c=d}}else c="";return c}function l(){var e=ja.buffer;a.HEAP8=W=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ia=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=Y=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function y(e){if(a.onAbort)a.onAbort(e);
e="Aborted("+e+")";da(e);sa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function f(e){try{if(e==P&&ea)return new Uint8Array(ea);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){y(b)}}function q(){if(!ea&&(ta||fa)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return f(P)});
if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return f(P)})}function u(e){for(;0<e.length;)e.shift()(a)}function A(e){this.excPtr=e;this.ptr=e-24;this.set_type=function(b){Y[this.ptr+4>>2]=b};this.get_type=function(){return Y[this.ptr+4>>2]};this.set_destructor=function(b){Y[this.ptr+8>>2]=b};this.get_destructor=function(){return Y[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){W[this.ptr+
12>>0]=b?1:0};this.get_caught=function(){return 0!=W[this.ptr+12>>0]};this.set_rethrown=function(b){W[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=W[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){Y[this.ptr+
16>>2]=b};this.get_adjusted_ptr=function(){return Y[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ua(this.get_type()))return Y[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function F(){function e(){if(!la&&(la=!0,a.calledRun=!0,!sa)){va=!0;u(oa);wa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)xa.unshift(a.postRun.shift());u(xa)}}if(!(0<ba)){if(a.preRun)for("function"==
typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)ya.unshift(a.preRun.shift());u(ya);0<ba||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);e()},1)):e())}}function v(){}function w(e){return(e||v).__cache__}function B(e,b){var c=w(b),d=c[e];if(d)return d;d=Object.create((b||v).prototype);d.ptr=e;return c[e]=d}function R(e){if("string"===typeof e){for(var b=0,c=0;c<e.length;++c){var d=e.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=
d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0<d){d=c+d-1;for(var g=0;g<e.length;++g){var t=e.charCodeAt(g);if(55296<=t&&57343>=t){var aa=e.charCodeAt(++g);t=65536+((t&1023)<<10)|aa&1023}if(127>=t){if(c>=d)break;b[c++]=t}else{if(2047>=t){if(c+1>=d)break;b[c++]=192|t>>6}else{if(65535>=t){if(c+2>=d)break;b[c++]=224|t>>12}else{if(c+3>=d)break;b[c++]=240|t>>18;b[c++]=128|t>>12&63}b[c++]=128|t>>6&63}b[c++]=128|t&63}}b[c]=0}e=r.alloc(b,W);r.copy(b,W,e);return e}return e}function Z(e){if("object"===
typeof e){var b=r.alloc(e,W);r.copy(e,W,b);return b}return e}function X(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=za();w(S)[this.ptr]=this}function Q(){this.ptr=Aa();w(Q)[this.ptr]=this}function V(){this.ptr=Ba();w(V)[this.ptr]=this}function x(){this.ptr=Ca();w(x)[this.ptr]=this}function D(){this.ptr=Da();w(D)[this.ptr]=this}function G(){this.ptr=Ea();w(G)[this.ptr]=this}function H(){this.ptr=Fa();w(H)[this.ptr]=this}function E(){this.ptr=Ga();w(E)[this.ptr]=
this}function T(){this.ptr=Ha();w(T)[this.ptr]=this}function C(){throw"cannot construct a Status, no constructor in IDL";}function I(){this.ptr=Ia();w(I)[this.ptr]=this}function J(){this.ptr=Ja();w(J)[this.ptr]=this}function K(){this.ptr=Ka();w(K)[this.ptr]=this}function L(){this.ptr=La();w(L)[this.ptr]=this}function M(){this.ptr=Ma();w(M)[this.ptr]=this}function N(){this.ptr=Na();w(N)[this.ptr]=this}function O(){this.ptr=Oa();w(O)[this.ptr]=this}function z(){this.ptr=Pa();w(z)[this.ptr]=this}function m(){this.ptr=
Qa();w(m)[this.ptr]=this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},wa,ka;a.ready=new Promise(function(e,b){wa=e;ka=b});var Ra=!1,Sa=!1;a.onRuntimeInitialized=function(){Ra=!0;if(Sa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Sa=!0;if(Ra&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3<e.length?!1:1==e[0]&&0<=e[1]&&5>=e[1]?!0:0!=e[0]||10<
e[1]?!1:!0};var Ta=Object.assign({},a),ta="object"==typeof window,fa="function"==typeof importScripts,Ua="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ua){var Va=require("fs"),pa=require("path");U=fa?pa.dirname(U)+"/":__dirname+"/";var Wa=function(e,b){e=e.startsWith("file://")?new URL(e):pa.normalize(e);return Va.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=Wa(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e,
b,c){e=e.startsWith("file://")?new URL(e):pa.normalize(e);Va.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1<process.argv.length&&process.argv[1].replace(/\\/g,"/");process.argv.slice(2);a.inspect=function(){return"[Emscripten Module object]"}}else if(ta||fa)fa?U=self.location.href:"undefined"!=typeof document&&document.currentScript&&(U=document.currentScript.src),h&&(U=h),U=0!==U.indexOf("blob:")?U.substr(0,U.replace(/[?#].*/,"").lastIndexOf("/")+1):"",Wa=function(e){var b=new XMLHttpRequest;b.open("GET",
e,!1);b.send(null);return b.responseText},fa&&(ma=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=function(e,b,c){var d=new XMLHttpRequest;d.open("GET",e,!0);d.responseType="arraybuffer";d.onload=function(){200==d.status||0==d.status&&d.response?b(d.response):c()};d.onerror=c;d.send(null)};a.print||console.log.bind(console);var da=a.printErr||console.warn.bind(console);Object.assign(a,Ta);Ta=null;var ea;a.wasmBinary&&
(ea=a.wasmBinary);"object"!=typeof WebAssembly&&y("no native wasm support detected");var ja,sa=!1,ra="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,W,ia,ca,Y,ya=[],oa=[],xa=[],va=!1,ba=0,qa=null,ha=null;var P="draco_decoder_gltf.wasm";P.startsWith("data:application/octet-stream;base64,")||(P=k(P));var pd=0,qd={b:function(e,b,c){(new A(e)).init(b,c);pd++;throw e;},a:function(){y("")},d:function(e,b,c){ia.copyWithin(e,b,b+c)},c:function(e){var b=ia.length;e>>>=0;if(2147483648<e)return!1;
for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);l();var t=1;break a}catch(aa){}t=void 0}if(t)return!0}return!1}};(function(){function e(g,t){a.asm=g.exports;ja=a.asm.e;l();oa.unshift(a.asm.f);ba--;a.monitorRunDependencies&&a.monitorRunDependencies(ba);0==ba&&(null!==qa&&(clearInterval(qa),qa=null),ha&&(g=ha,ha=null,g()))}function b(g){e(g.instance)}
function c(g){return q().then(function(t){return WebAssembly.instantiate(t,d)}).then(function(t){return t}).then(g,function(t){da("failed to asynchronously prepare wasm: "+t);y(t)})}var d={a:qd};ba++;a.monitorRunDependencies&&a.monitorRunDependencies(ba);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return ea||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")||
P.startsWith("file://")||Ua||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(t){da("wasm streaming compile failed: "+t);da("falling back to ArrayBuffer instantiation");return c(b)})})})().catch(ka);return{}})();var Xa=a._emscripten_bind_VoidPtr___destroy___0=function(){return(Xa=a._emscripten_bind_VoidPtr___destroy___0=a.asm.h).apply(null,arguments)},za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=
function(){return(za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.i).apply(null,arguments)},Ya=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(Ya=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.j).apply(null,arguments)},Za=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return(Za=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.k).apply(null,arguments)},Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=
a.asm.l).apply(null,arguments)},$a=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return($a=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.m).apply(null,arguments)},ab=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return(ab=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.n).apply(null,arguments)},Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=
a.asm.o).apply(null,arguments)},bb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(bb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.p).apply(null,arguments)},Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.q).apply(null,arguments)},cb=a._emscripten_bind_PointAttribute_size_0=function(){return(cb=a._emscripten_bind_PointAttribute_size_0=a.asm.r).apply(null,arguments)},db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=
function(){return(db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.s).apply(null,arguments)},eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(eb=a._emscripten_bind_PointAttribute_attribute_type_0=a.asm.t).apply(null,arguments)},fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(fb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.u).apply(null,arguments)},gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(gb=a._emscripten_bind_PointAttribute_num_components_0=
a.asm.v).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(hb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.w).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return(ib=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.x).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(jb=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.y).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_unique_id_0=
function(){return(kb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.z).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(lb=a._emscripten_bind_PointAttribute___destroy___0=a.asm.A).apply(null,arguments)},Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.B).apply(null,arguments)},mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=
function(){return(mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.C).apply(null,arguments)},nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return(nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.D).apply(null,arguments)},ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.E).apply(null,arguments)},pb=
a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(pb=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.F).apply(null,arguments)},qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return(qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.G).apply(null,arguments)},Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=
a.asm.H).apply(null,arguments)},rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.I).apply(null,arguments)},sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.J).apply(null,arguments)},tb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(tb=
a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.K).apply(null,arguments)},Fa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Fa=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.L).apply(null,arguments)},ub=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(ub=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.M).apply(null,arguments)},vb=a._emscripten_bind_PointCloud_num_points_0=function(){return(vb=a._emscripten_bind_PointCloud_num_points_0=a.asm.N).apply(null,
arguments)},wb=a._emscripten_bind_PointCloud___destroy___0=function(){return(wb=a._emscripten_bind_PointCloud___destroy___0=a.asm.O).apply(null,arguments)},Ga=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ga=a._emscripten_bind_Mesh_Mesh_0=a.asm.P).apply(null,arguments)},xb=a._emscripten_bind_Mesh_num_faces_0=function(){return(xb=a._emscripten_bind_Mesh_num_faces_0=a.asm.Q).apply(null,arguments)},yb=a._emscripten_bind_Mesh_num_attributes_0=function(){return(yb=a._emscripten_bind_Mesh_num_attributes_0=
a.asm.R).apply(null,arguments)},zb=a._emscripten_bind_Mesh_num_points_0=function(){return(zb=a._emscripten_bind_Mesh_num_points_0=a.asm.S).apply(null,arguments)},Ab=a._emscripten_bind_Mesh___destroy___0=function(){return(Ab=a._emscripten_bind_Mesh___destroy___0=a.asm.T).apply(null,arguments)},Ha=a._emscripten_bind_Metadata_Metadata_0=function(){return(Ha=a._emscripten_bind_Metadata_Metadata_0=a.asm.U).apply(null,arguments)},Bb=a._emscripten_bind_Metadata___destroy___0=function(){return(Bb=a._emscripten_bind_Metadata___destroy___0=
a.asm.V).apply(null,arguments)},Cb=a._emscripten_bind_Status_code_0=function(){return(Cb=a._emscripten_bind_Status_code_0=a.asm.W).apply(null,arguments)},Db=a._emscripten_bind_Status_ok_0=function(){return(Db=a._emscripten_bind_Status_ok_0=a.asm.X).apply(null,arguments)},Eb=a._emscripten_bind_Status_error_msg_0=function(){return(Eb=a._emscripten_bind_Status_error_msg_0=a.asm.Y).apply(null,arguments)},Fb=a._emscripten_bind_Status___destroy___0=function(){return(Fb=a._emscripten_bind_Status___destroy___0=
a.asm.Z).apply(null,arguments)},Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm._).apply(null,arguments)},Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.$).apply(null,arguments)},Hb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Hb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.aa).apply(null,arguments)},Ib=
a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Ib=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ba).apply(null,arguments)},Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return(Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.ca).apply(null,arguments)},Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.da).apply(null,arguments)},Kb=a._emscripten_bind_DracoInt8Array_size_0=
function(){return(Kb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ea).apply(null,arguments)},Lb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Lb=a._emscripten_bind_DracoInt8Array___destroy___0=a.asm.fa).apply(null,arguments)},Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ga).apply(null,arguments)},Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=
a.asm.ha).apply(null,arguments)},Nb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Nb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.ia).apply(null,arguments)},Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return(Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ja).apply(null,arguments)},La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.ka).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=
function(){return(Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.la).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Qb=a._emscripten_bind_DracoInt16Array_size_0=a.asm.ma).apply(null,arguments)},Rb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Rb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.na).apply(null,arguments)},Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=
a.asm.oa).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.pa).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Tb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.qa).apply(null,arguments)},Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ra).apply(null,arguments)},Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=
function(){return(Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.sa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return(Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.ta).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt32Array_size_0=function(){return(Wb=a._emscripten_bind_DracoInt32Array_size_0=a.asm.ua).apply(null,arguments)},Xb=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(Xb=a._emscripten_bind_DracoInt32Array___destroy___0=
a.asm.va).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=a.asm.wa).apply(null,arguments)},Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.xa).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(Zb=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.ya).apply(null,arguments)},$b=a._emscripten_bind_DracoUInt32Array___destroy___0=
function(){return($b=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.za).apply(null,arguments)},Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return(Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Aa).apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ba).apply(null,arguments)},bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=
a.asm.Ca).apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=a.asm.Da).apply(null,arguments)},dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ea).apply(null,arguments)},ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Fa).apply(null,
arguments)},fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ga).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ha).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(hc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.Ia).apply(null,arguments)},Qa=a._emscripten_bind_Decoder_Decoder_0=
function(){return(Qa=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ja).apply(null,arguments)},ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Ka).apply(null,arguments)},jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.La).apply(null,arguments)},kc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(kc=a._emscripten_bind_Decoder_GetAttributeId_2=
a.asm.Ma).apply(null,arguments)},lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=a.asm.Na).apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Oa).apply(null,arguments)},nc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(nc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Pa).apply(null,arguments)},
oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Qa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(pc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ra).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Sa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=
function(){return(rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Ta).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return(sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Ua).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Va).apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(uc=
a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Wa).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm.Xa).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.Ya).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=
a.asm.Za).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm._a).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.$a).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=
a.asm.ab).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.bb).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.cb).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=
a.asm.db).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.eb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.fb).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=
a.asm.gb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return(Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.hb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.ib).apply(null,arguments)},Jc=a._emscripten_bind_Decoder___destroy___0=function(){return(Jc=a._emscripten_bind_Decoder___destroy___0=a.asm.jb).apply(null,arguments)},Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=
function(){return(Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=a.asm.kb).apply(null,arguments)},Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.lb).apply(null,arguments)},Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=
a.asm.mb).apply(null,arguments)},Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return(Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.nb).apply(null,arguments)},Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.ob).apply(null,arguments)},Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=
a.asm.pb).apply(null,arguments)},Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return(Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.qb).apply(null,arguments)},Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.rb).apply(null,arguments)},Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=
a.asm.sb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.tb).apply(null,arguments)},Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.ub).apply(null,arguments)},Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return(Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=
a.asm.vb).apply(null,arguments)},Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return(Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.wb).apply(null,arguments)},Xc=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(Xc=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.xb).apply(null,arguments)},Yc=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(Yc=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.yb).apply(null,arguments)},Zc=
a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(Zc=a._emscripten_enum_draco_DataType_DT_UINT8=a.asm.zb).apply(null,arguments)},$c=a._emscripten_enum_draco_DataType_DT_INT16=function(){return($c=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Ab).apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(ad=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Bb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INT32=
a.asm.Cb).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return(cd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Db).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(dd=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Eb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(ed=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Fb).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=
function(){return(fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Gb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Hb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(hd=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Ib).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=
a.asm.Jb).apply(null,arguments)},jd=a._emscripten_enum_draco_StatusCode_OK=function(){return(jd=a._emscripten_enum_draco_StatusCode_OK=a.asm.Kb).apply(null,arguments)},kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Lb).apply(null,arguments)},ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Mb).apply(null,arguments)},md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=
function(){return(md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=a.asm.Nb).apply(null,arguments)},nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Ob).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Pb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Qb).apply(null,arguments)};
a._free=function(){return(a._free=a.asm.Rb).apply(null,arguments)};var ua=function(){return(ua=a.asm.Sb).apply(null,arguments)};a.___start_em_js=11660;a.___stop_em_js=11758;var la;ha=function b(){la||F();la||(ha=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();F();v.prototype=Object.create(v.prototype);v.prototype.constructor=v;v.prototype.__class__=v;v.__cache__={};a.WrapperObject=v;a.getCache=w;a.wrapPointer=B;a.castObject=function(b,
c){return B(b.ptr,c)};a.NULL=B(0);a.destroy=function(b){if(!b.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";b.__destroy__();delete w(b.__class__)[b.ptr]};a.compare=function(b,c){return b.ptr===c.ptr};a.getPointer=function(b){return b.ptr};a.getClass=function(b){return b.__class__};var r={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(r.needed){for(var b=0;b<r.temps.length;b++)a._free(r.temps[b]);r.temps.length=0;a._free(r.buffer);r.buffer=0;r.size+=
r.needed;r.needed=0}r.buffer||(r.size+=128,r.buffer=a._malloc(r.size),r.buffer||y(void 0));r.pos=0},alloc:function(b,c){r.buffer||y(void 0);b=b.length*c.BYTES_PER_ELEMENT;b=b+7&-8;r.pos+b>=r.size?(0<b||y(void 0),r.needed+=b,c=a._malloc(b),r.temps.push(c)):(c=r.buffer+r.pos,r.pos+=b);return c},copy:function(b,c,d){d>>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;g<b.length;g++)c[d+g]=b[g]}};X.prototype=Object.create(v.prototype);X.prototype.constructor=
X;X.prototype.__class__=X;X.__cache__={};a.VoidPtr=X;X.prototype.__destroy__=X.prototype.__destroy__=function(){Xa(this.ptr)};S.prototype=Object.create(v.prototype);S.prototype.constructor=S;S.prototype.__class__=S;S.__cache__={};a.DecoderBuffer=S;S.prototype.Init=S.prototype.Init=function(b,c){var d=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);Ya(d,b,c)};S.prototype.__destroy__=S.prototype.__destroy__=function(){Za(this.ptr)};Q.prototype=Object.create(v.prototype);
Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.AttributeTransformData=Q;Q.prototype.transform_type=Q.prototype.transform_type=function(){return $a(this.ptr)};Q.prototype.__destroy__=Q.prototype.__destroy__=function(){ab(this.ptr)};V.prototype=Object.create(v.prototype);V.prototype.constructor=V;V.prototype.__class__=V;V.__cache__={};a.GeometryAttribute=V;V.prototype.__destroy__=V.prototype.__destroy__=function(){bb(this.ptr)};x.prototype=Object.create(v.prototype);x.prototype.constructor=
x;x.prototype.__class__=x;x.__cache__={};a.PointAttribute=x;x.prototype.size=x.prototype.size=function(){return cb(this.ptr)};x.prototype.GetAttributeTransformData=x.prototype.GetAttributeTransformData=function(){return B(db(this.ptr),Q)};x.prototype.attribute_type=x.prototype.attribute_type=function(){return eb(this.ptr)};x.prototype.data_type=x.prototype.data_type=function(){return fb(this.ptr)};x.prototype.num_components=x.prototype.num_components=function(){return gb(this.ptr)};x.prototype.normalized=
x.prototype.normalized=function(){return!!hb(this.ptr)};x.prototype.byte_stride=x.prototype.byte_stride=function(){return ib(this.ptr)};x.prototype.byte_offset=x.prototype.byte_offset=function(){return jb(this.ptr)};x.prototype.unique_id=x.prototype.unique_id=function(){return kb(this.ptr)};x.prototype.__destroy__=x.prototype.__destroy__=function(){lb(this.ptr)};D.prototype=Object.create(v.prototype);D.prototype.constructor=D;D.prototype.__class__=D;D.__cache__={};a.AttributeQuantizationTransform=
D;D.prototype.InitFromAttribute=D.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!mb(c,b)};D.prototype.quantization_bits=D.prototype.quantization_bits=function(){return nb(this.ptr)};D.prototype.min_value=D.prototype.min_value=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return ob(c,b)};D.prototype.range=D.prototype.range=function(){return pb(this.ptr)};D.prototype.__destroy__=D.prototype.__destroy__=function(){qb(this.ptr)};G.prototype=
Object.create(v.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.AttributeOctahedronTransform=G;G.prototype.InitFromAttribute=G.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!rb(c,b)};G.prototype.quantization_bits=G.prototype.quantization_bits=function(){return sb(this.ptr)};G.prototype.__destroy__=G.prototype.__destroy__=function(){tb(this.ptr)};H.prototype=Object.create(v.prototype);H.prototype.constructor=H;H.prototype.__class__=
H;H.__cache__={};a.PointCloud=H;H.prototype.num_attributes=H.prototype.num_attributes=function(){return ub(this.ptr)};H.prototype.num_points=H.prototype.num_points=function(){return vb(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){wb(this.ptr)};E.prototype=Object.create(v.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.Mesh=E;E.prototype.num_faces=E.prototype.num_faces=function(){return xb(this.ptr)};E.prototype.num_attributes=E.prototype.num_attributes=
function(){return yb(this.ptr)};E.prototype.num_points=E.prototype.num_points=function(){return zb(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=function(){Ab(this.ptr)};T.prototype=Object.create(v.prototype);T.prototype.constructor=T;T.prototype.__class__=T;T.__cache__={};a.Metadata=T;T.prototype.__destroy__=T.prototype.__destroy__=function(){Bb(this.ptr)};C.prototype=Object.create(v.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.Status=C;C.prototype.code=
C.prototype.code=function(){return Cb(this.ptr)};C.prototype.ok=C.prototype.ok=function(){return!!Db(this.ptr)};C.prototype.error_msg=C.prototype.error_msg=function(){return p(Eb(this.ptr))};C.prototype.__destroy__=C.prototype.__destroy__=function(){Fb(this.ptr)};I.prototype=Object.create(v.prototype);I.prototype.constructor=I;I.prototype.__class__=I;I.__cache__={};a.DracoFloat32Array=I;I.prototype.GetValue=I.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gb(c,
b)};I.prototype.size=I.prototype.size=function(){return Hb(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Ib(this.ptr)};J.prototype=Object.create(v.prototype);J.prototype.constructor=J;J.prototype.__class__=J;J.__cache__={};a.DracoInt8Array=J;J.prototype.GetValue=J.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Jb(c,b)};J.prototype.size=J.prototype.size=function(){return Kb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Lb(this.ptr)};
K.prototype=Object.create(v.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DracoUInt8Array=K;K.prototype.GetValue=K.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Mb(c,b)};K.prototype.size=K.prototype.size=function(){return Nb(this.ptr)};K.prototype.__destroy__=K.prototype.__destroy__=function(){Ob(this.ptr)};L.prototype=Object.create(v.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.DracoInt16Array=
L;L.prototype.GetValue=L.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Pb(c,b)};L.prototype.size=L.prototype.size=function(){return Qb(this.ptr)};L.prototype.__destroy__=L.prototype.__destroy__=function(){Rb(this.ptr)};M.prototype=Object.create(v.prototype);M.prototype.constructor=M;M.prototype.__class__=M;M.__cache__={};a.DracoUInt16Array=M;M.prototype.GetValue=M.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Sb(c,b)};
M.prototype.size=M.prototype.size=function(){return Tb(this.ptr)};M.prototype.__destroy__=M.prototype.__destroy__=function(){Ub(this.ptr)};N.prototype=Object.create(v.prototype);N.prototype.constructor=N;N.prototype.__class__=N;N.__cache__={};a.DracoInt32Array=N;N.prototype.GetValue=N.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Vb(c,b)};N.prototype.size=N.prototype.size=function(){return Wb(this.ptr)};N.prototype.__destroy__=N.prototype.__destroy__=function(){Xb(this.ptr)};
O.prototype=Object.create(v.prototype);O.prototype.constructor=O;O.prototype.__class__=O;O.__cache__={};a.DracoUInt32Array=O;O.prototype.GetValue=O.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Yb(c,b)};O.prototype.size=O.prototype.size=function(){return Zb(this.ptr)};O.prototype.__destroy__=O.prototype.__destroy__=function(){$b(this.ptr)};z.prototype=Object.create(v.prototype);z.prototype.constructor=z;z.prototype.__class__=z;z.__cache__={};a.MetadataQuerier=
z;z.prototype.HasEntry=z.prototype.HasEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return!!ac(d,b,c)};z.prototype.GetIntEntry=z.prototype.GetIntEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return bc(d,b,c)};z.prototype.GetIntEntryArray=z.prototype.GetIntEntryArray=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===
typeof c?c.ptr:R(c);d&&"object"===typeof d&&(d=d.ptr);cc(g,b,c,d)};z.prototype.GetDoubleEntry=z.prototype.GetDoubleEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return dc(d,b,c)};z.prototype.GetStringEntry=z.prototype.GetStringEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return p(ec(d,b,c))};z.prototype.NumEntries=z.prototype.NumEntries=function(b){var c=this.ptr;
b&&"object"===typeof b&&(b=b.ptr);return fc(c,b)};z.prototype.GetEntryName=z.prototype.GetEntryName=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return p(gc(d,b,c))};z.prototype.__destroy__=z.prototype.__destroy__=function(){hc(this.ptr)};m.prototype=Object.create(v.prototype);m.prototype.constructor=m;m.prototype.__class__=m;m.__cache__={};a.Decoder=m;m.prototype.DecodeArrayToPointCloud=m.prototype.DecodeArrayToPointCloud=function(b,c,d){var g=
this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(ic(g,b,c,d),C)};m.prototype.DecodeArrayToMesh=m.prototype.DecodeArrayToMesh=function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(jc(g,b,c,d),C)};m.prototype.GetAttributeId=m.prototype.GetAttributeId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
(c=c.ptr);return kc(d,b,c)};m.prototype.GetAttributeIdByName=m.prototype.GetAttributeIdByName=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return lc(d,b,c)};m.prototype.GetAttributeIdByMetadataEntry=m.prototype.GetAttributeIdByMetadataEntry=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d=d&&"object"===typeof d?d.ptr:R(d);return mc(g,b,c,d)};m.prototype.GetAttribute=
m.prototype.GetAttribute=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(nc(d,b,c),x)};m.prototype.GetAttributeByUniqueId=m.prototype.GetAttributeByUniqueId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(oc(d,b,c),x)};m.prototype.GetMetadata=m.prototype.GetMetadata=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return B(pc(c,b),T)};m.prototype.GetAttributeMetadata=m.prototype.GetAttributeMetadata=
function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(qc(d,b,c),T)};m.prototype.GetFaceFromMesh=m.prototype.GetFaceFromMesh=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!rc(g,b,c,d)};m.prototype.GetTriangleStripsFromMesh=m.prototype.GetTriangleStripsFromMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);
return sc(d,b,c)};m.prototype.GetTrianglesUInt16Array=m.prototype.GetTrianglesUInt16Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!tc(g,b,c,d)};m.prototype.GetTrianglesUInt32Array=m.prototype.GetTrianglesUInt32Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!uc(g,b,c,d)};m.prototype.GetAttributeFloat=m.prototype.GetAttributeFloat=
function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!vc(g,b,c,d)};m.prototype.GetAttributeFloatForAllPoints=m.prototype.GetAttributeFloatForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!wc(g,b,c,d)};m.prototype.GetAttributeIntForAllPoints=m.prototype.GetAttributeIntForAllPoints=function(b,c,d){var g=this.ptr;
b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!xc(g,b,c,d)};m.prototype.GetAttributeInt8ForAllPoints=m.prototype.GetAttributeInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!yc(g,b,c,d)};m.prototype.GetAttributeUInt8ForAllPoints=m.prototype.GetAttributeUInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=
b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(g,b,c,d)};m.prototype.GetAttributeInt16ForAllPoints=m.prototype.GetAttributeInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(g,b,c,d)};m.prototype.GetAttributeUInt16ForAllPoints=m.prototype.GetAttributeUInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(g,b,c,d)};m.prototype.GetAttributeInt32ForAllPoints=m.prototype.GetAttributeInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(g,b,c,d)};m.prototype.GetAttributeUInt32ForAllPoints=m.prototype.GetAttributeUInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===
typeof d&&(d=d.ptr);return!!Dc(g,b,c,d)};m.prototype.GetAttributeDataArrayForAllPoints=m.prototype.GetAttributeDataArrayForAllPoints=function(b,c,d,g,t){var aa=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);g&&"object"===typeof g&&(g=g.ptr);t&&"object"===typeof t&&(t=t.ptr);return!!Ec(aa,b,c,d,g,t)};m.prototype.SkipAttributeTransform=m.prototype.SkipAttributeTransform=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);Fc(c,
b)};m.prototype.GetEncodedGeometryType_Deprecated=m.prototype.GetEncodedGeometryType_Deprecated=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gc(c,b)};m.prototype.DecodeBufferToPointCloud=m.prototype.DecodeBufferToPointCloud=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(Hc(d,b,c),C)};m.prototype.DecodeBufferToMesh=m.prototype.DecodeBufferToMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===
typeof c&&(c=c.ptr);return B(Ic(d,b,c),C)};m.prototype.__destroy__=m.prototype.__destroy__=function(){Jc(this.ptr)};(function(){function b(){a.ATTRIBUTE_INVALID_TRANSFORM=Kc();a.ATTRIBUTE_NO_TRANSFORM=Lc();a.ATTRIBUTE_QUANTIZATION_TRANSFORM=Mc();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=Nc();a.INVALID=Oc();a.POSITION=Pc();a.NORMAL=Qc();a.COLOR=Rc();a.TEX_COORD=Sc();a.GENERIC=Tc();a.INVALID_GEOMETRY_TYPE=Uc();a.POINT_CLOUD=Vc();a.TRIANGULAR_MESH=Wc();a.DT_INVALID=Xc();a.DT_INT8=Yc();a.DT_UINT8=Zc();a.DT_INT16=
$c();a.DT_UINT16=ad();a.DT_INT32=bd();a.DT_UINT32=cd();a.DT_INT64=dd();a.DT_UINT64=ed();a.DT_FLOAT32=fd();a.DT_FLOAT64=gd();a.DT_BOOL=hd();a.DT_TYPES_COUNT=id();a.OK=jd();a.DRACO_ERROR=kd();a.IO_ERROR=ld();a.INVALID_PARAMETER=md();a.UNSUPPORTED_VERSION=nd();a.UNKNOWN_VERSION=od()}va?b():oa.unshift(b)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();a.Decoder.prototype.GetEncodedGeometryType=function(b){if(b.__class__&&b.__class__===a.DecoderBuffer)return a.Decoder.prototype.GetEncodedGeometryType_Deprecated(b);
if(8>b.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);

BIN
public/models/bottle.stl Normal file

Binary file not shown.

BIN
public/models/box-300.stl Normal file

Binary file not shown.

BIN
public/models/box-400.stl Normal file

Binary file not shown.

BIN
public/models/cylinder.stl Normal file

Binary file not shown.

BIN
public/models/detergent.stl Normal file

Binary file not shown.

BIN
public/models/lunchbox.stl Normal file

Binary file not shown.

BIN
public/models/pen.stl Normal file

Binary file not shown.

BIN
public/models/plate.stl Normal file

Binary file not shown.

BIN
public/models/pouf.stl Normal file

Binary file not shown.

Binary file not shown.

131
src/App.tsx Normal file
View File

@@ -0,0 +1,131 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import MainPage from './pages/MainPage';
import DocumentationPage from './pages/DocumentationPage';
import {
createPlaybackState,
startPlayback,
pausePlayback,
resumePlayback,
stopPlayback,
updatePlayback,
seekToCase,
seekNextCase,
seekPrevCase,
setPlaybackSpeed,
type ContinuousPlaybackState,
type PlaybackSpeed,
} from './domain/continuousPlayback';
function AppContent() {
const [playback, setPlayback] = useState<ContinuousPlaybackState>(() => createPlaybackState());
const playbackRafRef = useRef<number | null>(null);
const playbackLastRef = useRef(performance.now());
const PLAYBACK_INTERVAL = 50;
// Public page autostarts the sorter loop; ?playback=paused keeps it idle
// (used by screenshot/debug tooling). First item is gated in the 3D scene
// until PRODUCT_ASSETS_READY (see SorterDigitalTwinContinuous).
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('playback') === 'paused') return;
setPlayback((prev) => (prev.status === 'idle' ? startPlayback(prev) : prev));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (playback.status !== 'running') {
if (playbackRafRef.current) {
cancelAnimationFrame(playbackRafRef.current);
playbackRafRef.current = null;
}
return;
}
const tick = () => {
const now = performance.now();
const deltaMs = now - playbackLastRef.current;
if (deltaMs >= PLAYBACK_INTERVAL) {
playbackLastRef.current = now;
setPlayback((prev) => updatePlayback(prev, deltaMs));
}
playbackRafRef.current = requestAnimationFrame(tick);
};
playbackLastRef.current = performance.now();
playbackRafRef.current = requestAnimationFrame(tick);
return () => {
if (playbackRafRef.current) {
cancelAnimationFrame(playbackRafRef.current);
playbackRafRef.current = null;
}
};
}, [playback.status]);
const handleMainPlay = useCallback(() => {
setPlayback((prev) => {
if (prev.status === 'paused') {
return resumePlayback(prev);
}
return startPlayback(prev);
});
}, []);
const handleMainPause = useCallback(() => {
setPlayback((prev) => pausePlayback(prev));
}, []);
const handleMainStop = useCallback(() => {
setPlayback(stopPlayback);
}, []);
const handleSeekCase = useCallback((index: number) => {
setPlayback((prev) => seekToCase(prev, index));
}, []);
const handleSeekNext = useCallback(() => {
setPlayback((prev) => seekNextCase(prev));
}, []);
const handleSeekPrev = useCallback(() => {
setPlayback((prev) => seekPrevCase(prev));
}, []);
const handleSetSpeed = useCallback((speed: PlaybackSpeed) => {
setPlayback((prev) => setPlaybackSpeed(prev, speed));
}, []);
return (
<Routes>
<Route
path="/"
element={
<MainPage
playback={playback}
onPlay={handleMainPlay}
onPause={handleMainPause}
onStop={handleMainStop}
onSeekCase={handleSeekCase}
onSeekNext={handleSeekNext}
onSeekPrev={handleSeekPrev}
onSetSpeed={handleSetSpeed}
/>
}
/>
<Route path="/documentation" element={<DocumentationPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AppContent />
</BrowserRouter>
);
}

35
src/components/AppNav.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { NavLink } from 'react-router-dom';
interface AppNavProps {
/** Overlay (desktop sim), bar (mobile full-width), solid (docs header). */
variant?: 'overlay' | 'bar' | 'solid';
}
/**
* Product navigation — only Simulation and Documentation.
*/
export default function AppNav({ variant = 'solid' }: AppNavProps) {
return (
<nav
className={`app-nav app-nav-${variant}`}
aria-label="Основная навигация"
data-testid="app-nav"
>
<NavLink
to="/"
end
className={({ isActive }) => `app-nav-link${isActive ? ' active' : ''}`}
data-testid="nav-simulation"
>
Симуляция
</NavLink>
<NavLink
to="/documentation"
className={({ isActive }) => `app-nav-link${isActive ? ' active' : ''}`}
data-testid="nav-documentation"
>
Документация
</NavLink>
</nav>
);
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
export interface VersionInfo {
commit: string;
branch: string;
builtAt: string;
release: string;
}
/**
* Engineering-only build chip. Hidden in presentation mode.
* Visible with ?perf=1 or ?build=1.
*/
export default function BuildIdentityBadge() {
const [info, setInfo] = useState<VersionInfo | null>(null);
const [show, setShow] = useState(false);
const [inPresentation, setInPresentation] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setShow(params.get('perf') === '1' || params.get('build') === '1');
}, []);
useEffect(() => {
if (!show) return;
let cancelled = false;
fetch('/version.json', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (!cancelled && j?.commit) setInfo(j as VersionInfo);
})
.catch(() => undefined);
const id = window.setInterval(() => {
setInPresentation(!!document.querySelector('.presentation-mode'));
}, 500);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [show]);
if (!show || !info || inPresentation) return null;
return (
<div className="build-identity" data-testid="build-identity" title={`${info.branch} · ${info.builtAt}`}>
<span className="build-identity-label">build</span>
<span className="build-identity-commit">{info.commit}</span>
<span className="build-identity-release">{info.release}</span>
</div>
);
}

View File

@@ -0,0 +1,227 @@
/**
* CV Inspection Overlay — industrial measurement system monitor.
* Shows stepper, laser, stereo camera data and classification results.
*/
import type { MeasurementData } from '../domain/measurementSystem';
import { getStageLabel } from '../domain/measurementSystem';
import { DIMENSION_LIMITS, isCircularCrossSection } from '../domain/classifier';
interface CVInspectionOverlayProps {
data: MeasurementData;
visible: boolean;
}
const SHAPE_ICONS: Record<string, string> = {
box: '▭',
round: '◯',
irregular: '◇',
};
export default function CVInspectionOverlay({ data, visible }: CVInspectionOverlayProps) {
if (!visible) return null;
const {
stage,
stepCount,
mmPerStep,
measuredLengthMm,
pulseActive,
laserDistanceMm,
laserMountHeightMm,
measuredHeightMm,
laserBeamActive,
measuredWidthMm,
roundnessK,
stereoActive,
confidence,
dimensionsPass,
shapeResult,
finalCategory,
command,
cPriorityApplied,
isLowConfidence,
classificationReason,
classificationLabel,
itemTitle,
itemDimensions,
} = data;
const confidencePercent = Math.round(confidence * 100);
const roundnessPercent = Math.round(roundnessK * 100);
const stageLabel = getStageLabel(stage);
const categoryColors: Record<string, string> = {
B: '#22c55e',
C: '#f97316',
D: '#8b5cf6',
};
const isActive = stage !== 'idle';
return (
<div className="cv-overlay">
<div className="cv-header">
<span className="cv-icon"></span>
<span className="cv-title">MEASUREMENT</span>
<span className={`cv-status ${isActive ? 'active' : 'idle'}`}>{stageLabel}</span>
</div>
<div className="cv-body">
{/* Item info */}
<div className="cv-row cv-item-row">
<span className="cv-label">ITEM</span>
<span className="cv-value">{itemTitle}</span>
</div>
<div className="cv-divider" />
{/* Stepper motor section */}
<div className="cv-section-header">
<span className={`cv-indicator ${pulseActive ? 'pulse' : ''}`}></span>
STEPPER LENGTH
</div>
<div className="cv-row cv-compact">
<span className="cv-label">Pulses</span>
<span className="cv-value cv-mono">
{stepCount.toLocaleString()}
{pulseActive && <span className="cv-blink"> </span>}
</span>
</div>
<div className="cv-row cv-compact">
<span className="cv-label">mm/step</span>
<span className="cv-value cv-mono">{mmPerStep.toFixed(3)}</span>
</div>
<div className="cv-row">
<span className="cv-label">Length</span>
<span className="cv-value cv-result">{measuredLengthMm} mm</span>
</div>
<div className="cv-divider" />
{/* Laser rangefinder section */}
<div className="cv-section-header">
<span className={`cv-indicator ${laserBeamActive ? 'active' : ''}`}></span>
LASER HEIGHT
</div>
<div className="cv-row cv-compact">
<span className="cv-label">Mount</span>
<span className="cv-value cv-mono">{laserMountHeightMm} mm</span>
</div>
<div className="cv-row cv-compact">
<span className="cv-label">Distance</span>
<span className="cv-value cv-mono">{laserDistanceMm} mm</span>
</div>
<div className="cv-row">
<span className="cv-label">Height</span>
<span className="cv-value cv-result">{measuredHeightMm} mm</span>
</div>
<div className="cv-divider" />
{/* Stereo camera section */}
<div className="cv-section-header">
<span className={`cv-indicator ${stereoActive ? 'active' : ''}`}></span>
STEREO WIDTH/SHAPE
</div>
<div className="cv-row">
<span className="cv-label">Width</span>
<span className="cv-value cv-result">{measuredWidthMm} mm</span>
</div>
<div className="cv-row">
<span className="cv-label">Shape</span>
<span className="cv-value cv-shape">
<span className="shape-icon">{SHAPE_ICONS[shapeResult]}</span>
{shapeResult}
</span>
</div>
<div className="cv-row">
<span className="cv-label">Roundness</span>
<span className={`cv-value ${isCircularCrossSection(roundnessK) ? 'warning' : ''}`}>
K = {roundnessK.toFixed(2)} ({roundnessPercent}%)
{isCircularCrossSection(roundnessK) && (
<span className="cv-flag"> &gt;{DIMENSION_LIMITS.roundnessThreshold}</span>
)}
</span>
</div>
<div className="cv-divider" />
{/* Decision section */}
<div className="cv-section-header">
<span className="cv-indicator"></span>
PLC DECISION
</div>
<div className="cv-row">
<span className="cv-label">Dims</span>
<span className={`cv-value ${dimensionsPass ? 'pass' : 'fail'}`}>
{dimensionsPass ? 'PASS' : 'FAIL'}
<span className="cv-dims-detail">
{' '}({itemDimensions.width}×{itemDimensions.depth}×{itemDimensions.height})
</span>
</span>
</div>
<div className="cv-row">
<span className="cv-label">Confidence</span>
<span className={`cv-value ${isLowConfidence ? 'warning' : ''}`}>
{confidencePercent}%
{isLowConfidence && <span className="cv-flag"> LOW</span>}
</span>
</div>
{finalCategory && (
<div className="cv-row cv-result-row">
<span className="cv-label">CLASS</span>
<span
className="cv-value cv-category"
style={{ color: categoryColors[finalCategory] }}
>
{finalCategory}
{cPriorityApplied && <span className="cv-priority"> (C priority)</span>}
</span>
</div>
)}
{classificationReason && (
<div className="cv-row cv-reason-row">
<span className="cv-label">RULE</span>
<span className="cv-value cv-reason">
{classificationLabel ? `${classificationLabel}` : ''}
{classificationReason}
</span>
</div>
)}
<div className="cv-row cv-command-row">
<span className="cv-label">CMD</span>
<span
className="cv-value cv-command"
style={{ color: finalCategory ? categoryColors[finalCategory] : '#64748b' }}
>
{command}
</span>
</div>
{/* Warnings */}
{cPriorityApplied && (
<div className="cv-warning cv-cpriority">
<span className="warning-icon"></span>
<span className="warning-text">Dims fail overrides roundness C</span>
</div>
)}
{isLowConfidence && (
<div className="cv-warning">
<span className="warning-icon"></span>
<span className="warning-text">Low confidence, rule-based fallback</span>
</div>
)}
</div>
<div className="cv-footer">
<span className="cv-live"> LIVE</span>
<span className="cv-fps">PLC</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,302 @@
import type { Category, MachineState, SimulationState } from '../domain/types';
const routeColors: Record<Category, string> = {
B: '#4ade80',
C: '#f59e0b',
D: '#c084fc',
};
function progressForState(state: MachineState, elapsedMs: number): number {
const ratios: Partial<Record<MachineState, [number, number, number]>> = {
MOVING_TO_CAMERA: [0.06, 0.34, 1200],
DETECTING: [0.34, 0.36, 900],
MOVING_TO_GATE: [0.36, 0.64, 1300],
WAITING_AT_GATE: [0.64, 0.65, 800],
CLASSIFYING: [0.65, 0.66, 700],
ROUTE_TO_B: [0.66, 0.93, 1200],
ROUTE_TO_C: [0.66, 0.78, 1200],
ROUTE_TO_D: [0.66, 0.78, 1200],
RETURN_HOME: [0.93, 0.94, 700],
};
const segment = ratios[state];
if (!segment) {
return state === 'IDLE' ? 0 : 0.65;
}
const [from, to, duration] = segment;
return from + (to - from) * Math.min(elapsedMs / duration, 1);
}
function itemPosition(simulation: SimulationState): { x: number; y: number } {
const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs);
const baseX = 126 + progress * 790;
const beltY = 360;
if (simulation.machineState === 'ROUTE_TO_C') {
return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 };
}
if (simulation.machineState === 'ROUTE_TO_D') {
return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 };
}
return { x: baseX, y: beltY };
}
function DimensionLine({ x1, y1, x2, y2, label }: { x1: number; y1: number; x2: number; y2: number; label: string }) {
const labelX = (x1 + x2) / 2;
const labelY = (y1 + y2) / 2;
return (
<g className="dimension-line">
<line x1={x1} y1={y1} x2={x2} y2={y2} />
<circle cx={x1} cy={y1} r="3" />
<circle cx={x2} cy={y2} r="3" />
<text x={labelX} y={labelY - 8}>{label}</text>
</g>
);
}
function LegendItem({ color, label }: { color: string; label: string }) {
return (
<g className="legend-item">
<rect width="10" height="10" fill={color} rx="2" />
<text x="16" y="10">{label}</text>
</g>
);
}
interface SorterSceneProps {
simulation: SimulationState;
variant?: 'full' | 'simple';
}
export default function SorterScene({ simulation, variant = 'full' }: SorterSceneProps) {
const current = simulation.currentItem;
const category = current?.classification.category;
const color = category ? routeColors[category] : '#38bdf8';
const position = itemPosition(simulation);
const detecting = simulation.machineState === 'DETECTING';
const classifying = simulation.machineState === 'CLASSIFYING' || simulation.machineState === 'WAITING_AT_GATE';
const routeVisible = simulation.machineState.startsWith('ROUTE_TO_');
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
const itemWidth = current ? Math.max(24, Math.min(74, current.item.dimensionsMm.width / 6)) : 56;
const itemHeight = current ? Math.max(18, Math.min(58, current.item.dimensionsMm.depth / 5.5)) : 48;
const isSimple = variant === 'simple';
return (
<div className={`scene-wrap scene-${variant}`}>
{!isSimple ? (
<div className="scene-title-row">
<div>
<p className="eyebrow">Work zone 6000 x 10000 mm / conveyor 500 mm</p>
<h2>Engineering layout, sensors and routing commands</h2>
</div>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
) : (
<div className="scene-simple-header">
<p className="eyebrow">Конвейер · камера · classifier · gate · зоны B/C/D</p>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
)}
<svg viewBox="0 0 1120 720" role="img" aria-label="Sorter simulation scene" className="sorter-svg">
<defs>
<pattern id={`gridMinor-${variant}`} width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#12283b" strokeWidth="1" />
</pattern>
<pattern id={`gridMajor-${variant}`} width="120" height="120" patternUnits="userSpaceOnUse">
<rect width="120" height="120" fill={`url(#gridMinor-${variant})`} />
<path d="M 120 0 L 0 0 0 120" fill="none" stroke="#244863" strokeWidth="1.4" />
</pattern>
{(['B', 'C', 'D'] as Category[]).map((route) => (
<marker key={route} id={`arrow${route}-${variant}`} markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill={routeColors[route]} />
</marker>
))}
</defs>
<rect x="24" y="26" width="1072" height="632" rx="12" fill="#07111d" stroke="#244863" />
{!isSimple ? (
<rect x="64" y="82" width="928" height="520" fill={`url(#gridMajor-${variant})`} opacity="0.9" />
) : null}
{!isSimple ? (
<>
<text x="78" y="74" className="scale-label left-label">Scaled plan: 6000 mm x 10000 mm work cell</text>
<DimensionLine x1={64} y1={626} x2={992} y2={626} label="6000 mm work zone width" />
<DimensionLine x1={1024} y1={82} x2={1024} y2={602} label="10000 mm work zone length" />
<DimensionLine x1={92} y1={314} x2={92} y2={406} label="500 mm conveyor" />
</>
) : null}
<g className="zone zone-a">
<rect x="88" y="292" width="128" height="136" rx="8" />
<text x="152" y={isSimple ? 370 : 282}>{isSimple ? 'A' : 'A feed zone'}</text>
</g>
<g className={`zone zone-b ${category === 'B' ? 'zone-active' : ''}`}>
<rect x="842" y="292" width="136" height="136" rx="8" />
<text x="910" y={isSimple ? 370 : 282} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'B' : 'B main sorter'}
</text>
</g>
<g className={`zone zone-d ${category === 'D' ? 'zone-active' : ''}`}>
<rect x="642" y="112" width="210" height="124" rx="8" />
<text x="747" y={isSimple ? 185 : 102} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'D' : 'D roll-cage 1200 x 800 x 800 mm'}
</text>
</g>
<g className={`zone zone-c ${category === 'C' ? 'zone-active' : ''}`}>
<rect x="642" y="486" width="210" height="124" rx="8" />
<text x="747" y={isSimple ? 560 : 632} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'C' : 'C roll-cage 1200 x 800 x 800 mm'}
</text>
</g>
<rect className={stopped ? 'conveyor stopped' : 'conveyor'} x="106" y="314" width="850" height="92" rx="6" />
<line x1="126" y1="360" x2="936" y2="360" className="belt-center" />
{!isSimple
? Array.from({ length: 18 }).map((_, index) => (
<line key={index} x1={132 + index * 44} y1="324" x2={158 + index * 44} y2="396" className="roller-line" />
))
: null}
<g className={simulation.sensors.camera.active ? 'device active' : 'device'}>
<rect x="350" y="218" width="76" height="50" rx="6" />
<line x1="388" y1="268" x2="388" y2="314" />
<text x="388" y="208">{isSimple ? 'Camera' : 'Camera / bbox'}</text>
</g>
{!isSimple ? (
<>
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
<rect x="474" y="218" width="76" height="50" rx="6" />
<line x1="512" y1="268" x2="512" y2="314" />
<text x="512" y="208">Laser height</text>
</g>
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<circle cx="646" cy="243" r="28" />
<line x1="646" y1="271" x2="646" y2="314" />
<text x="646" y="208">Ultrasonic gate</text>
</g>
</>
) : (
<g className={classifying || simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<rect x="520" y="218" width="110" height="50" rx="6" />
<line x1="575" y1="268" x2="575" y2="314" />
<text x="575" y="208">Classifier</text>
</g>
)}
<g className={simulation.gate.open ? 'gate open' : 'gate closed'}>
<line x1="708" y1="296" x2="708" y2="424" />
<text x="746" y="300">{isSimple ? 'Gate' : `Stop-gate ${simulation.gate.open ? 'open' : 'closed'}`}</text>
</g>
{!isSimple ? (
<>
<g className={`pusher ${simulation.actuators.pusherC}`}>
<rect x="625" y="424" width="174" height="34" rx="6" />
<text x="712" y="476">Pusher C command</text>
</g>
<g className={`pusher ${simulation.actuators.pusherD}`}>
<rect x="625" y="262" width="174" height="34" rx="6" />
<text x="712" y="256">Pusher D command</text>
</g>
</>
) : null}
<line
x1="706"
y1="360"
x2="928"
y2="360"
className={`route-guide route-b ${category === 'B' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowB-${variant})`}
/>
<line
x1="706"
y1="376"
x2="748"
y2="548"
className={`route-guide route-c ${category === 'C' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowC-${variant})`}
/>
<line
x1="706"
y1="344"
x2="748"
y2="174"
className={`route-guide route-d ${category === 'D' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowD-${variant})`}
/>
{routeVisible && category ? (
<g className="route-command">
<rect x="788" y="326" width="156" height="34" rx="8" fill={routeColors[category]} />
<text x="866" y="348">{simulation.machineState}</text>
</g>
) : null}
{current ? (
<g>
<rect
x={position.x - itemWidth / 2}
y={position.y - itemHeight / 2}
width={itemWidth}
height={itemHeight}
rx={current.item.shape.includes('round') || current.item.shape.includes('cylinder') ? Math.min(itemWidth, itemHeight) / 2 : 5}
fill={color}
opacity="0.92"
stroke="#ffffff"
strokeWidth="1.4"
/>
<text x={position.x} y={position.y + itemHeight / 2 + 18} className="item-label">
{isSimple ? current.item.name : current.item.id}
</text>
{detecting ? (
<g className="bbox">
<rect
x={position.x - itemWidth / 2 - 10}
y={position.y - itemHeight / 2 - 10}
width={itemWidth + 20}
height={itemHeight + 20}
/>
{!isSimple ? (
<text x={position.x} y={position.y - itemHeight / 2 - 18}>
bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm
</text>
) : null}
</g>
) : null}
</g>
) : null}
{stopped ? (
<g className="fault-overlay">
<rect x="610" y="286" width="210" height="148" rx="10" />
<text x="715" y="350">{simulation.machineState}</text>
<text x="715" y="376">Conveyor stopped, reset required</text>
</g>
) : null}
{!isSimple ? (
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B main sorter" />
<g transform="translate(140 0)"><LegendItem color="#f59e0b" label="C oversize" /></g>
<g transform="translate(270 0)"><LegendItem color="#c084fc" label="D shape / repack" /></g>
<g transform="translate(430 0)"><LegendItem color="#38bdf8" label="camera / laser / ultrasonic active" /></g>
<g transform="translate(700 0)"><LegendItem color="#fb3d4e" label="stop-gate / fault" /></g>
</g>
) : (
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B" />
<g transform="translate(70 0)"><LegendItem color="#f59e0b" label="C" /></g>
<g transform="translate(140 0)"><LegendItem color="#c084fc" label="D" /></g>
<g transform="translate(210 0)"><LegendItem color="#38bdf8" label="active" /></g>
<g transform="translate(320 0)"><LegendItem color="#fb3d4e" label="fault" /></g>
</g>
)}
</svg>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
/**
* R3F performance sampler. Mount only when ?perf=1 or enabled prop.
* Exposes window.__PERF_SNAPSHOT__ (getter) and window.__PERF_RESET__.
*/
import { useFrame, useThree } from '@react-three/fiber';
import { useEffect, useRef } from 'react';
import type { WebGLRenderer } from 'three';
import {
computeFrameTimeStats,
emptyPerfSnapshot,
isHardwareAccelerated,
isPerfQueryEnabled,
roundPerfSnapshot,
type PerfSnapshot,
} from '../../domain/perfMetrics';
const MAX_SAMPLES = 600;
export interface PerfCollectorProps {
/** Force-enable even without ?perf=1 */
enabled?: boolean;
mode?: string;
shadows?: boolean;
antialias?: boolean;
}
function readRendererString(gl: WebGLRenderer): string {
try {
const ctx = gl.getContext() as WebGLRenderingContext;
const dbg = ctx.getExtension('WEBGL_debug_renderer_info');
if (dbg) {
return String(ctx.getParameter(dbg.UNMASKED_RENDERER_WEBGL) ?? 'unknown');
}
return String(ctx.getParameter(ctx.RENDERER) ?? 'unknown');
} catch {
return 'unknown';
}
}
function readHeapMb(): number {
const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory;
if (!mem) return 0;
return mem.usedJSHeapSize / (1024 * 1024);
}
function PerfCollectorInner({
mode = 'demo',
shadows = false,
antialias = false,
}: Omit<PerfCollectorProps, 'enabled'>) {
const { gl } = useThree();
const frameTimes = useRef<number[]>([]);
const lastTs = useRef(0);
const meta = useRef({ mode, shadows, antialias, renderer: 'unknown' });
meta.current = { ...meta.current, mode, shadows, antialias };
const buildSnapshot = (): PerfSnapshot => {
const stats = computeFrameTimeStats(frameTimes.current);
const info = gl.info;
const renderer = meta.current.renderer || readRendererString(gl);
return roundPerfSnapshot(
emptyPerfSnapshot({
mode: meta.current.mode,
renderer,
...stats,
drawCalls: info.render.calls,
triangles: info.render.triangles,
geometries: info.memory.geometries,
textures: info.memory.textures,
programs: info.programs?.length ?? 0,
heapMb: readHeapMb(),
dpr: gl.getPixelRatio(),
shadows: meta.current.shadows,
antialias: meta.current.antialias,
hardwareAccelerated: isHardwareAccelerated(renderer),
}),
);
};
useEffect(() => {
meta.current.renderer = readRendererString(gl);
const reset = () => {
frameTimes.current = [];
lastTs.current = 0;
gl.info.reset();
};
Object.defineProperty(window, '__PERF_SNAPSHOT__', {
configurable: true,
enumerable: true,
get: () => buildSnapshot(),
});
window.__PERF_RESET__ = reset;
return () => {
reset();
try {
delete window.__PERF_SNAPSHOT__;
} catch {
/* ignore */
}
delete window.__PERF_RESET__;
};
}, [gl]);
useFrame((_state, delta) => {
// Prefer measured rAF delta; fall back to clock delta (seconds → ms)
const now = performance.now();
let dtMs: number;
if (lastTs.current > 0) {
dtMs = now - lastTs.current;
} else {
dtMs = delta * 1000;
}
lastTs.current = now;
// Ignore absurd spikes from tab backgrounding
if (dtMs <= 0 || dtMs > 500) return;
const buf = frameTimes.current;
buf.push(dtMs);
if (buf.length > MAX_SAMPLES) buf.shift();
});
return null;
}
/**
* Safe wrapper: returns null unless enabled or ?perf=1.
* Keeps demos free of sampling overhead by default.
*/
export default function PerfCollector({
enabled,
mode,
shadows,
antialias,
}: PerfCollectorProps) {
const active = enabled === true || (enabled !== false && isPerfQueryEnabled());
if (!active) return null;
return <PerfCollectorInner mode={mode} shadows={shadows} antialias={antialias} />;
}

View File

@@ -0,0 +1,128 @@
/**
* Compact FPS / GPU overlay. Visible only with ?perf=1 and outside presentation mode.
*/
import { useEffect, useState } from 'react';
import {
isPerfQueryEnabled,
type PerfSnapshot,
emptyPerfSnapshot,
} from '../../domain/perfMetrics';
import type { PhysicsPerfSnapshot } from '../../domain/physicsPerf';
const POLL_MS = 500;
export interface PerfOverlayProps {
/** Force show (still hidden in presentation mode via CSS / class check) */
enabled?: boolean;
}
export default function PerfOverlay({ enabled }: PerfOverlayProps) {
const [visible, setVisible] = useState(
() => enabled === true || (enabled !== false && isPerfQueryEnabled()),
);
const [snap, setSnap] = useState<PerfSnapshot>(() => emptyPerfSnapshot());
const [phys, setPhys] = useState<PhysicsPerfSnapshot | null>(null);
const [inPresentation, setInPresentation] = useState(false);
useEffect(() => {
if (enabled === false) {
setVisible(false);
return;
}
setVisible(enabled === true || isPerfQueryEnabled());
}, [enabled]);
useEffect(() => {
if (!visible) return;
const poll = () => {
const s = window.__PERF_SNAPSHOT__;
if (s) setSnap(s);
const p = window.__PHYSICS_PERF__;
if (p) setPhys(p);
setInPresentation(!!document.querySelector('.presentation-mode'));
};
poll();
const id = window.setInterval(poll, POLL_MS);
return () => window.clearInterval(id);
}, [visible]);
if (!visible || inPresentation) return null;
const soft = !snap.hardwareAccelerated;
const exportBenchmark = () => {
const payload = {
exportedAt: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent,
viewport: { width: window.innerWidth, height: window.innerHeight },
snapshot: snap,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `sorter-benchmark-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<div
className="perf-overlay"
data-testid="perf-overlay"
aria-label="Performance metrics"
>
<div className="perf-overlay-title">PERF</div>
<div>
FPS {snap.averageFps.toFixed(0)}
<span className="perf-muted"> (min {snap.minimumFps.toFixed(0)})</span>
</div>
<div>
p95 {snap.p95FrameTimeMs.toFixed(1)}ms
<span className="perf-muted"> / p99 {snap.p99FrameTimeMs.toFixed(1)}ms</span>
</div>
{phys && phys.count > 0 ? (
<div data-testid="physics-perf-line">
phys p95 {phys.p95Ms.toFixed(2)}ms
<span className="perf-muted">
{' '}
· avg {phys.avgMs.toFixed(2)} · n={phys.count}
</span>
</div>
) : null}
<div>
draws {snap.drawCalls}
<span className="perf-muted"> · tris {snap.triangles}</span>
</div>
<div>
geo {snap.geometries}
<span className="perf-muted">
{' '}
· tex {snap.textures} · prog {snap.programs}
</span>
</div>
<div>
heap {snap.heapMb.toFixed(1)}MB
<span className="perf-muted">
{' '}
· dpr {snap.dpr} · {snap.mode}
</span>
</div>
<div className={soft ? 'perf-soft' : 'perf-hw'} title={snap.renderer}>
{soft ? 'SW' : 'GPU'} {snap.renderer.slice(0, 42)}
{snap.renderer.length > 42 ? '…' : ''}
</div>
<button
type="button"
className="perf-export-btn"
data-testid="perf-export"
onClick={exportBenchmark}
>
Export benchmark
</button>
</div>
);
}

View File

@@ -0,0 +1,229 @@
import { memo, useEffect, useMemo } from 'react';
import { getPhysicalItemPose } from '../../domain/physicalItemMotion';
import { getModelAsset } from '../../data/modelAssets';
import { resolveItem } from '../../data/resolveItem';
import type { PlaylistCase } from '../../domain/demoPlaylist';
import { classifyItem } from '../../domain/classifier';
import { getRenderedItemDimensions } from '../../domain/physicalLayout';
import * as THREE from 'three';
import RealItemModel, { isProductAssetReady } from './RealItemModel';
import { ItemVerificationOverlay } from './RealModelVerification';
const COLORS = {
B: '#16a34a',
C: '#ea580c',
D: '#7c3aed',
sensorAccent: '#3b82f6',
};
/** Base real-model materials per SKU (Stage 1 §20 — basic, form-revealing). */
const ITEM_MATERIALS: Record<string, { color: string; roughness: number; metalness?: number }> = {
'SKU-001': { color: '#b68b58', roughness: 0.82 }, // cardboard
'SKU-002': { color: '#e8eef6', roughness: 0.5 }, // lunchbox plastic
'SKU-003': { color: '#93c5fd', roughness: 0.38 }, // detergent jug plastic
'SKU-004': { color: '#c49a6c', roughness: 0.82 }, // cardboard
'SKU-005': { color: '#a78bfa', roughness: 0.92 }, // soft pouf fabric
'SKU-006': { color: '#f8fafc', roughness: 0.42 }, // plate ceramic
'SKU-007': { color: '#7dd3fc', roughness: 0.28 }, // bottle plastic
'SKU-008': { color: '#cbd5e1', roughness: 0.35, metalness: 0.15 },
'SKU-009': { color: '#475569', roughness: 0.5 }, // pen body
};
interface RenderProps {
color: string;
accentColor: string;
emissiveIntensity: number;
roughness: number;
metalness: number;
}
function FallbackPrimitive({ type, color, accentColor, emissiveIntensity, roughness, metalness, w, h, d, castShadow }: RenderProps & {
type: 'box' | 'cylinder' | 'sphere';
w: number; h: number; d: number;
castShadow?: boolean;
}) {
const geometry = useMemo<THREE.BufferGeometry>(() => {
if (type === 'cylinder' || type === 'sphere') {
const r = Math.max(w, d) / 2;
return new THREE.CylinderGeometry(r, r, h, 16);
}
return new THREE.BoxGeometry(w, h, d);
}, [type, w, h, d]);
useEffect(() => () => geometry.dispose(), [geometry]);
return (
<mesh geometry={geometry} castShadow={castShadow}>
<meshStandardMaterial color={color} emissive={accentColor} emissiveIntensity={emissiveIntensity} roughness={roughness} metalness={metalness} />
</mesh>
);
}
/** Inner visual content of an item (shared by kinematic and physics drivers). */
export function ItemVisualContent({
caseData,
phase,
surface,
isSettled,
castShadow = false,
verifySku = null,
onVisualReady,
}: {
caseData: PlaylistCase;
phase: string;
surface: string;
isSettled: boolean;
castShadow?: boolean;
verifySku?: string | null;
/** Fires once the visible mesh (real or procedural) is ready to show. */
onVisualReady?: () => void;
}) {
const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]);
const classification = useMemo(() => classifyItem(itemData), [itemData]);
const itemId = itemData.id.replace('-LC', '');
const asset = getModelAsset(itemId);
const dims = getRenderedItemDimensions(itemData.dimensionsMm);
const isRouting = phase === 'routing';
const onTransport = surface === 'main_belt'
|| surface === 'inspection_station'
|| surface === 'routing_junction'
|| surface === 'b_transfer';
const routeAccent = COLORS[classification.category] ?? COLORS.sensorAccent;
const material = ITEM_MATERIALS[itemId] ?? { color: '#d8c3a5', roughness: 0.75 };
const bodyColor = phase === 'fault' ? '#ef4444' : material.color;
const accentColor = isSettled ? '#94a3b8' : routeAccent;
const emissiveIntensity = phase === 'fault' ? 0.25 : isRouting ? 0.12 : isSettled ? 0.01 : 0.03;
const metalness = material.metalness ?? 0.05;
// Real official model is the default when the manifest provides one;
// procedural fallback only for missing assets or load failure (Stage 1 §15.1).
const useReal = Boolean(asset?.defaultRealAsset && asset?.runtimePath);
const fallbackType = asset?.fallbackPrimitive ?? 'box';
// Pose position is the EXPECTED bbox center (surfaceY + h/2). Real models use
// a bottom-center pivot, so the mesh is offset down by half the model height.
// Contact epsilon vs the surface is therefore exactly 0 mm by construction.
const modelHeightM = asset?.worldExpectedMm
? asset.worldExpectedMm.y / 1000
: dims.height;
const pivotOffsetY = -modelHeightM / 2;
// Procedural / already-cached assets are ready immediately.
useEffect(() => {
if (!useReal || !asset?.runtimePath || isProductAssetReady(asset.runtimePath)) {
onVisualReady?.();
}
}, [useReal, asset?.runtimePath, caseData.id, onVisualReady]);
const fallback = (
<FallbackPrimitive
type={fallbackType}
color={bodyColor}
accentColor={accentColor}
emissiveIntensity={emissiveIntensity}
roughness={material.roughness}
metalness={metalness}
w={dims.width}
h={dims.height}
d={dims.depth}
castShadow={castShadow}
/>
);
const verifying = verifySku != null && verifySku === itemId && asset != null;
return (
<>
{useReal && asset ? (
<group position={[0, pivotOffsetY, 0]}>
<RealItemModel
asset={asset}
material={{
color: bodyColor,
emissive: accentColor,
emissiveIntensity,
roughness: material.roughness,
metalness,
}}
castShadow={castShadow}
fallback={fallback}
onReady={onVisualReady}
/>
</group>
) : (
fallback
)}
{verifying && asset && (
<ItemVerificationOverlay
asset={asset}
pivotOffsetY={pivotOffsetY}
cardY={modelHeightM + 0.3}
fallbackSizeM={{ x: dims.width, y: dims.height, z: dims.depth }}
/>
)}
{isSettled && (
<mesh position={[0, -dims.height / 2 + 0.003, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[Math.max(dims.width, dims.depth) * 0.35, Math.max(dims.width, dims.depth) * 0.42, 20]} />
<meshBasicMaterial color={routeAccent} transparent opacity={0.5} />
</mesh>
)}
{onTransport && (
<mesh position={[0, -dims.height / 2 + 0.001, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[Math.max(dims.width, dims.depth) / 2 + 0.01, 16]} />
<meshStandardMaterial color="#475569" transparent opacity={0.15} />
</mesh>
)}
</>
);
}
export const PhysicalPlaybackItem = memo(function PhysicalPlaybackItem({
caseData,
elapsedMs,
slotIndex = 0,
jitter,
castShadow = false,
verifySku = null,
}: {
caseData: PlaylistCase;
elapsedMs: number;
slotIndex?: number;
jitter?: { x: number; z: number; yaw: number };
castShadow?: boolean;
/** Stage 1 verification: SKU to overlay (null = off, 'follow' handled by caller passing current SKU). */
verifySku?: string | null;
}) {
const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]);
const classification = useMemo(() => classifyItem(itemData), [itemData]);
const pose = getPhysicalItemPose({
caseId: caseData.id,
slotIndex,
dimensionsMm: itemData.dimensionsMm,
targetCategory: classification.category,
elapsedMs,
faultType: caseData.faultType,
jitter,
});
const { position, rotation, phase, surface, isSettled } = pose;
if (elapsedMs < 0) return null;
return (
<group position={position} rotation={rotation}>
<ItemVisualContent
caseData={caseData}
phase={phase}
surface={surface}
isSettled={isSettled}
castShadow={castShadow}
verifySku={verifySku}
/>
</group>
);
});

View File

@@ -0,0 +1,296 @@
/**
* Stage 2 — item with hybrid kinematic/dynamic authority (Rapier).
*
* Authority flow (see docs/stage2_real_sorter/physics-architecture.md):
* 1. kinematicPosition — follows getPhysicalItemPose exactly (domain truth);
* 2. at getDropHandoffTimeMs → dynamic with deterministic initial velocity
* (B: belt edge carry-over; C/D: pusher impulse, scaled per SKU profile);
* 3. gravity/collision/friction/restitution/angular velocity govern the drop;
* 4. on sleep (or controlled 4.5 s timeout) the final position is verified
* against the DOMAIN-decided receiver volume and the body is frozen
* (kinematic) — no drift, clean replay, no teleportation at any point.
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import {
RigidBody,
CuboidCollider,
CapsuleCollider,
CylinderCollider,
type RapierRigidBody,
} from '@react-three/rapier';
import { RigidBodyType } from '@dimforge/rapier3d-compat';
import { getPhysicalItemPose, getDropHandoffTimeMs } from '../../domain/physicalItemMotion';
import { getVisualPhysicsProfile } from '../../domain/visualPhysicsProfiles';
import { resolveItem } from '../../data/resolveItem';
import { classifyItem } from '../../domain/classifier';
import { receiverContains } from '../../domain/receiverVolumes';
import type { PlaylistCase } from '../../domain/demoPlaylist';
import { ItemVisualContent } from './PhysicalPlaybackItem';
import { recordDropResult, physicsSimClock } from './SorterPhysics';
import { getModelAsset } from '../../data/modelAssets';
import { isProductAssetReady } from './RealItemModel';
type Authority = 'kinematic' | 'dynamic' | 'frozen';
/** Controlled settle budget after handoff — in PHYSICS-simulated seconds,
* not domain ms: under render lag domain time races ahead of the stepper,
* and a domain-ms budget would freeze items mid-flight (§14.2). */
const SETTLE_BUDGET_SEC = 4.5;
function colliderDensity(profile: ReturnType<typeof getVisualPhysicsProfile>): number {
if (profile.collider === 'cuboid' && profile.cuboidHalfExtents) {
const [hx, hy, hz] = profile.cuboidHalfExtents;
return profile.approximateMassKg / (8 * hx * hy * hz);
}
const [r, hh] = profile.capsule ?? [0.05, 0.1];
const volume = profile.collider === 'capsule'
? Math.PI * r * r * (2 * hh + (4 / 3) * r)
: Math.PI * r * r * 2 * hh;
return profile.approximateMassKg / volume;
}
export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhysics({
caseData,
elapsedMs,
slotIndex = 0,
jitter,
castShadow = false,
verifySku = null,
}: {
caseData: PlaylistCase;
elapsedMs: number;
slotIndex?: number;
jitter?: { x: number; z: number; yaw: number };
castShadow?: boolean;
verifySku?: string | null;
}) {
const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]);
const classification = useMemo(() => classifyItem(itemData), [itemData]);
const category = classification.category as 'B' | 'C' | 'D';
const itemId = itemData.id.replace('-LC', '');
const profile = getVisualPhysicsProfile(itemId);
const handoffMs = getDropHandoffTimeMs(classification.category, caseData.faultType);
const bodyRef = useRef<RapierRigidBody>(null);
const traceEnabled = useRef(
typeof window !== 'undefined'
&& new URLSearchParams(window.location.search).get('trace') === '1',
);
const authority = useRef<Authority>('kinematic');
const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null);
const handedOffAtSimSec = useRef<number | null>(null);
const verified = useRef(false);
const asset = getModelAsset(itemId);
const needsRealAsset = Boolean(asset?.defaultRealAsset && asset?.runtimePath);
const [spawned, setSpawned] = useState(
() => !needsRealAsset || isProductAssetReady(asset?.runtimePath),
);
const onVisualReady = useCallback(() => {
setSpawned(true);
}, []);
const pose = getPhysicalItemPose({
caseId: caseData.id,
slotIndex,
dimensionsMm: itemData.dimensionsMm,
targetCategory: classification.category,
elapsedMs,
faultType: caseData.faultType,
jitter,
});
const handoffPose = useMemo(() => {
if (handoffMs == null) return null;
return getPhysicalItemPose({
caseId: caseData.id,
slotIndex,
dimensionsMm: itemData.dimensionsMm,
targetCategory: classification.category,
elapsedMs: handoffMs,
faultType: caseData.faultType,
jitter,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handoffMs, caseData.id]);
// Reset authority whenever a new case mounts this body. The Rapier body is
// reused across cases, so a case that ended while still DYNAMIC (settle
// budget cut short under render lag) must be forced back to kinematic —
// otherwise setNextKinematicTranslation is a no-op and the next case's item
// is stuck invisibly mid-scene.
useEffect(() => {
authority.current = 'kinematic';
frozenPose.current = null;
handedOffAtSimSec.current = null;
verified.current = false;
const ready = !needsRealAsset || isProductAssetReady(asset?.runtimePath);
setSpawned(ready);
const body = bodyRef.current;
if (body) {
body.setBodyType(RigidBodyType.KinematicPositionBased, false);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
const p = pose.position;
body.setTranslation({ x: p[0], y: p[1], z: p[2] }, true);
const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
const q = new THREE.Quaternion().setFromEuler(e);
body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset on case id only
}, [caseData.id]);
useFrame(() => {
const body = bodyRef.current;
if (!body) return;
// PREPARING: hold at spawn pose, zero velocity, keep invisible until visual ready.
if (!spawned) {
const p = pose.position;
body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] });
const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
const q = new THREE.Quaternion().setFromEuler(e);
body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w });
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
return;
}
if (authority.current === 'kinematic') {
// Physics handoff at pusher contact / belt edge — never for fault cases.
// MUST be checked BEFORE the kinematic drive: under render lag a single
// frame can jump several seconds past handoffMs, and pose(elapsedMs) is
// then already deep inside the receiver. Applying setNextKinematic*
// from that pose in the same frame as the dynamic switch teleports the
// body (forbidden) — the next-step kinematic target still applies.
if (handoffMs != null && handoffPose && elapsedMs >= handoffMs) {
const hp = handoffPose.position;
body.setTranslation({ x: hp[0], y: hp[1], z: hp[2] }, true);
const he = new THREE.Euler(handoffPose.rotation[0], handoffPose.rotation[1], handoffPose.rotation[2]);
const hq = new THREE.Quaternion().setFromEuler(he);
body.setRotation({ x: hq.x, y: hq.y, z: hq.z, w: hq.w }, true);
body.setBodyType(RigidBodyType.Dynamic, true);
// Deterministic initial velocity: belt carry-over only — for C/D the
// Z motion comes from the kinematic paddle CONTACT (Stage 2B §13).
body.setLinvel({ x: 1.0, y: 0, z: 0 }, true);
if (profile.canRoll && category === 'B') {
body.setAngvel({ x: 2.0, y: 0.4, z: 0 }, true);
} else {
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
}
authority.current = 'dynamic';
handedOffAtSimSec.current = physicsSimClock.simSec;
return;
}
// Kinematic drive: domain pose is truth (belt travel, inspection dwell).
const p = pose.position;
const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
const q = new THREE.Quaternion().setFromEuler(e);
body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] });
body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w });
return;
}
if (authority.current === 'dynamic') {
const slept = body.isSleeping();
const lv = body.linvel();
const av = body.angvel();
const slow = Math.hypot(lv.x, lv.y, lv.z) < 0.2 && Math.hypot(av.x, av.y, av.z) < 1.0;
if (traceEnabled.current) {
const t = body.translation();
const w = window as unknown as { __ITEM_TRACE?: unknown[] };
w.__ITEM_TRACE = w.__ITEM_TRACE ?? [];
const arr = w.__ITEM_TRACE as { e: number; x: number; y: number; z: number; lv: number; slept: boolean }[];
if (arr.length === 0 || arr[arr.length - 1].e < elapsedMs - 200) {
arr.push({ e: Math.round(elapsedMs), x: +t.x.toFixed(3), y: +t.y.toFixed(3), z: +t.z.toFixed(3), lv: +Math.hypot(lv.x, lv.y, lv.z).toFixed(2), slept });
if (arr.length > 120) arr.shift();
}
}
const timedOut = handedOffAtSimSec.current != null
&& physicsSimClock.simSec - handedOffAtSimSec.current > SETTLE_BUDGET_SEC;
// §14.2: freeze only after actual rest (sleep) or a timeout WITH low
// velocities — never freeze a body that is still moving/flying.
if ((slept || (timedOut && slow)) && !verified.current) {
verified.current = true;
const t = body.translation();
const p: [number, number, number] = [t.x, t.y, t.z];
recordDropResult({
caseId: caseData.id,
itemId,
expectedZone: category,
finalPosition: p,
insideExpectedReceiver: receiverContains(category, p),
settledByTimeout: !slept,
timestampMs: Date.now(),
});
const r = body.rotation();
frozenPose.current = { p, q: new THREE.Quaternion(r.x, r.y, r.z, r.w) };
body.setBodyType(RigidBodyType.KinematicPositionBased, false);
body.setLinvel({ x: 0, y: 0, z: 0 }, false);
body.setAngvel({ x: 0, y: 0, z: 0 }, false);
authority.current = 'frozen';
}
return;
}
// frozen: hold the verified rest pose (no drift across replays).
if (frozenPose.current) {
const { p, q } = frozenPose.current;
body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] });
body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w });
}
});
if (elapsedMs < 0) return null;
const density = colliderDensity(profile);
// CCD for small/fast items (pen) and thin items (plate) — mirrors the sim.
const ccd = profile.approximateMassKg < 0.05 || itemData.dimensionsMm.height < 50;
return (
<RigidBody
ref={bodyRef}
type="kinematicPosition"
colliders={false}
friction={profile.friction}
restitution={profile.restitution}
linearDamping={profile.linearDamping}
angularDamping={profile.angularDamping}
ccd={ccd}
enabledRotations={[true, true, true]}
position={pose.position}
>
{/* Colliders only after visual ready — avoids stale/orphan contact. */}
{spawned && profile.collider === 'cuboid' && profile.cuboidHalfExtents && (
<CuboidCollider args={profile.cuboidHalfExtents} density={density} />
)}
{spawned && profile.collider === 'capsule' && profile.capsule && (
<CapsuleCollider
args={[profile.capsule[1], profile.capsule[0]]}
density={density}
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
/>
)}
{spawned && profile.collider === 'cylinder' && profile.capsule && (
<CylinderCollider
args={[profile.capsule[1], profile.capsule[0]]}
density={density}
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
/>
)}
<group visible={spawned}>
<ItemVisualContent
caseData={caseData}
phase={pose.phase}
surface={pose.surface}
isSettled={authority.current === 'frozen' ? true : pose.isSettled}
castShadow={castShadow && spawned}
verifySku={verifySku}
onVisualReady={onVisualReady}
/>
</group>
</RigidBody>
);
});

View File

@@ -0,0 +1,22 @@
/**
* PostProcessingSpike — Stage 0 cost-measurement only.
*
* Loaded lazily (separate chunk) and mounted ONLY in stage0 prototype mode
* with post=1. Default route never downloads @react-three/postprocessing.
* Deliberately cheap set: Bloom + Vignette + Noise + SMAA. No DoF, no SSR,
* no motion blur, no TAA, no volumetrics.
*/
import { EffectComposer, Bloom, Vignette, Noise, SMAA } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
export default function PostProcessingSpike() {
return (
<EffectComposer multisampling={0}>
<SMAA />
<Bloom intensity={0.35} luminanceThreshold={0.75} luminanceSmoothing={0.2} mipmapBlur />
<Noise premultiply blendFunction={BlendFunction.SCREEN} opacity={0.25} />
<Vignette offset={0.25} darkness={0.55} eskil={false} />
</EffectComposer>
);
}

View File

@@ -0,0 +1,193 @@
/**
* RealItemModel — единый loader для официальных real-model ассетов.
*
* Контракт (одинаковый для STL сейчас и GLB позже):
* manifest (modelAssets.ts) → нормализованная геометрия → mesh.
*
* Нормализация запекается в клон геометрии один раз при загрузке:
* 1. rotation из manifest (в мм-пространстве источника);
* 2. uniform scale 0.001 (mm → meters);
* 3. pivot → bottom-center (центр footprint по X/Z, низ по Y);
* 4. computeVertexNormals.
*
* Shared loader cache никогда не мутируется (clone перед transforms),
* dispose вызывается только для локального клона.
*/
import { Component, Suspense, useEffect, useMemo, type ReactNode } from 'react';
import * as THREE from 'three';
import { useLoader } from '@react-three/fiber';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
import type { ModelAsset } from '../../data/modelAssets';
export interface RealItemMaterial {
color: string;
emissive?: string;
emissiveIntensity?: number;
roughness?: number;
metalness?: number;
}
interface InnerProps {
asset: ModelAsset;
material: RealItemMaterial;
castShadow?: boolean;
receiveShadow?: boolean;
onReady?: () => void;
}
const readyUrls = new Set<string>();
const inflight = new Map<string, Promise<void>>();
export function isProductAssetReady(runtimePath: string | null | undefined): boolean {
if (!runtimePath) return true;
return readyUrls.has(runtimePath);
}
export function markProductAssetReady(runtimePath: string): void {
readyUrls.add(runtimePath);
}
/**
* Preload a runtime STL into the shared loader cache and resolve when ready.
* Deduped by URL — concurrent callers share one Promise.
*/
export function preloadRealItemModelAsync(runtimePath: string): Promise<void> {
if (readyUrls.has(runtimePath)) return Promise.resolve();
const existing = inflight.get(runtimePath);
if (existing) return existing;
// Warm R3F useLoader cache (deduped).
useLoader.preload(STLLoader, runtimePath);
const promise = new Promise<void>((resolve, reject) => {
const loader = new STLLoader();
loader.load(
runtimePath,
() => {
readyUrls.add(runtimePath);
inflight.delete(runtimePath);
resolve();
},
undefined,
(err) => {
inflight.delete(runtimePath);
reject(err);
},
);
});
inflight.set(runtimePath, promise);
return promise;
}
/** Preload a runtime asset into the shared loader cache (deduped by URL). */
export function preloadRealItemModel(runtimePath: string): void {
void preloadRealItemModelAsync(runtimePath);
}
/**
* Normalize a freshly cloned geometry per manifest rules.
* Returns the clone with transforms BAKED IN (pivot = bottom-center, meters).
*/
export function normalizeGeometryClone(source: THREE.BufferGeometry, asset: ModelAsset): THREE.BufferGeometry {
const g = source.clone();
const [rx, ry, rz] = asset.rotation;
if (rx) g.rotateX(rx);
if (ry) g.rotateY(ry);
if (rz) g.rotateZ(rz);
g.scale(0.001, 0.001, 0.001); // mm → m, uniform (scaleMode: 'uniform-mm-to-m')
g.computeBoundingBox();
const bb = g.boundingBox!;
const cx = (bb.min.x + bb.max.x) / 2;
const cz = (bb.min.z + bb.max.z) / 2;
g.translate(-cx, -bb.min.y, -cz); // pivotMode: 'bottom-center'
g.computeVertexNormals();
g.computeBoundingBox();
return g;
}
function RealItemModelInner({ asset, material, castShadow, receiveShadow, onReady }: InnerProps) {
const shared = useLoader(STLLoader, asset.runtimePath!) as THREE.BufferGeometry;
const geometry = useMemo(() => normalizeGeometryClone(shared, asset), [shared, asset]);
useEffect(() => () => geometry.dispose(), [geometry]);
useEffect(() => {
if (asset.runtimePath) markProductAssetReady(asset.runtimePath);
onReady?.();
}, [asset.runtimePath, geometry, onReady]);
return (
<mesh geometry={geometry} castShadow={castShadow} receiveShadow={receiveShadow}>
<meshStandardMaterial
color={material.color}
emissive={material.emissive ?? material.color}
emissiveIntensity={material.emissiveIntensity ?? 0.05}
roughness={material.roughness ?? 0.6}
metalness={material.metalness ?? 0.05}
/>
</mesh>
);
}
interface BoundaryProps {
fallback: ReactNode;
children: ReactNode;
onError?: (error: Error) => void;
}
interface BoundaryState {
failed: boolean;
}
/** Per-item error boundary: load failure → procedural fallback, no scene crash. */
class ItemModelErrorBoundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { failed: false };
static getDerivedStateFromError(): BoundaryState {
return { failed: true };
}
componentDidCatch(error: Error): void {
// eslint-disable-next-line no-console
console.warn('[RealItemModel] asset load failed, procedural fallback engaged:', error.message);
this.props.onError?.(error);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
export interface RealItemModelProps extends InnerProps {
/**
* Procedural fallback for load failure only.
* Suspense placeholder stays invisible so spawn is atomic (no flash-then-swap).
*/
fallback: ReactNode;
onError?: (error: Error) => void;
/** When true, Suspense shows fallback (legacy). Default: invisible placeholder. */
showSuspenseFallback?: boolean;
}
export default function RealItemModel({
fallback,
showSuspenseFallback = false,
onReady,
onError,
...inner
}: RealItemModelProps) {
if (!inner.asset.runtimePath) {
return <>{fallback}</>;
}
return (
<ItemModelErrorBoundary
fallback={fallback}
onError={(err) => {
onReady?.();
onError?.(err);
}}
>
<Suspense fallback={showSuspenseFallback ? fallback : null}>
<RealItemModelInner {...inner} onReady={onReady} />
</Suspense>
</ItemModelErrorBoundary>
);
}

View File

@@ -0,0 +1,168 @@
/**
* RealModelVerification — Stage 1 debug overlay (?stage1=1&verify=real-models).
*
* Read-only instrumentation: bounding box, axes, pivot marker, bottom contact
* plane и информационная панель (источник, формат, размеры, статус валидации).
* Рендерится только в verification mode; business state не изменяется.
*
* Использование: <ItemVerificationOverlay> внутри pose-группы товара
* (PhysicalPlaybackItem) — следует за товаром по маршруту B/C/D.
*/
import { useEffect, useMemo } from 'react';
import * as THREE from 'three';
import { Html } from '@react-three/drei';
import { useLoader } from '@react-three/fiber';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
import type { ModelAsset } from '../../data/modelAssets';
import { normalizeGeometryClone } from './RealItemModel';
export interface SizeM {
x: number;
y: number;
z: number;
}
const AXIS_COLORS = { x: '#ef4444', y: '#22c55e', z: '#3b82f6' } as const;
/** Wire bbox + axis tripod + pivot marker + bottom contact plane (bottom-center space). */
function VerificationGizmos({ sizeM }: { sizeM: SizeM }) {
const boxEdges = useMemo(() => {
const box = new THREE.BoxGeometry(sizeM.x, sizeM.y, sizeM.z);
const edges = new THREE.EdgesGeometry(box);
box.dispose();
return edges;
}, [sizeM.x, sizeM.y, sizeM.z]);
const axes = useMemo(() => ({
x: new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0.15, 0, 0)]),
y: new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0.15, 0)]),
z: new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0.15)]),
}), []);
useEffect(() => () => {
boxEdges.dispose();
axes.x.dispose();
axes.y.dispose();
axes.z.dispose();
}, [boxEdges, axes]);
return (
<group>
{/* bounding box of the normalized model (bottom-center pivot) */}
<lineSegments geometry={boxEdges} position={[0, sizeM.y / 2, 0]}>
<lineBasicMaterial color="#facc15" />
</lineSegments>
{/* axis tripod at pivot (footprint center, bottom point) */}
<lineSegments geometry={axes.x}><lineBasicMaterial color={AXIS_COLORS.x} /></lineSegments>
<lineSegments geometry={axes.y}><lineBasicMaterial color={AXIS_COLORS.y} /></lineSegments>
<lineSegments geometry={axes.z}><lineBasicMaterial color={AXIS_COLORS.z} /></lineSegments>
{/* pivot marker */}
<mesh position={[0, 0.004, 0]}>
<sphereGeometry args={[0.008, 12, 8]} />
<meshBasicMaterial color="#facc15" depthTest={false} />
</mesh>
{/* bottom contact plane (item footprint on the surface) */}
<mesh position={[0, 0.0005, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[sizeM.x, sizeM.z]} />
<meshBasicMaterial color="#22c55e" transparent opacity={0.25} depthWrite={false} />
</mesh>
</group>
);
}
function InfoCard({ asset, measuredM, y }: {
asset: ModelAsset;
measuredM: SizeM | null;
y: number;
}) {
const mm = (m: number) => (m * 1000).toFixed(1);
const expected = asset.worldExpectedMm;
const rows: Array<[string, string]> = [
['Model', asset.displayName],
['Badge', asset.defaultRealAsset ? 'REAL (official)' : 'FALLBACK · NO_EXACT_OFFICIAL_MODEL'],
['Source', asset.sourceFile ?? 'n/a'],
['SHA-256', asset.sourceSha256 ? `${asset.sourceSha256.slice(0, 12)}` : 'n/a'],
['Format', asset.runtimeFormat ?? 'procedural'],
['File size', asset.fileSizeBytes != null ? `${(asset.fileSizeBytes / 1024).toFixed(0)} KB` : 'n/a'],
['Triangles', asset.triangleCount != null ? String(asset.triangleCount) : 'n/a'],
['Conversion', asset.conversionStatus],
['Pivot', asset.pivotMode],
];
if (expected) rows.push(['Expected x/y/z mm', `${expected.x} / ${expected.y} / ${expected.z}`]);
if (measuredM) rows.push(['Measured x/y/z mm', `${mm(measuredM.x)} / ${mm(measuredM.y)} / ${mm(measuredM.z)}`]);
rows.push(['Validation', asset.validationStatus]);
return (
<Html position={[0, y, 0]} center style={{ pointerEvents: 'none' }}>
<div style={{
fontFamily: 'ui-monospace, monospace',
fontSize: '10px',
lineHeight: 1.45,
color: '#e2e8f0',
background: 'rgba(2, 6, 23, 0.88)',
border: '1px solid #334155',
borderRadius: '6px',
padding: '8px 10px',
whiteSpace: 'nowrap',
transform: 'translateY(-100%)',
}}>
<div style={{ fontWeight: 700, color: '#facc15', marginBottom: 4 }}>
STAGE1 VERIFY · {asset.itemId}
</div>
{rows.map(([k, v]) => (
<div key={k}>
<span style={{ color: '#64748b' }}>{k}: </span>
<span>{v}</span>
</div>
))}
</div>
</Html>
);
}
/** Real-asset branch: measures the actual runtime file via the shared loader cache. */
function RealAssetVerification({ asset, cardY }: { asset: ModelAsset; cardY: number }) {
const shared = useLoader(STLLoader, asset.runtimePath!) as THREE.BufferGeometry;
const measuredM = useMemo<SizeM>(() => {
const g = normalizeGeometryClone(shared, asset);
g.computeBoundingBox();
const bb = g.boundingBox!;
const size = { x: bb.max.x - bb.min.x, y: bb.max.y - bb.min.y, z: bb.max.z - bb.min.z };
g.dispose();
return size;
}, [shared, asset]);
return (
<group>
<VerificationGizmos sizeM={measuredM} />
<InfoCard asset={asset} measuredM={measuredM} y={cardY} />
</group>
);
}
/**
* Overlay for one item in its pose group. `pivotOffsetY` is the same local Y
* offset used by the rendered mesh (bottom-center compensation), `cardY` the
* height for the info card (item top + margin, in the same local space).
*/
export function ItemVerificationOverlay({ asset, pivotOffsetY, cardY, fallbackSizeM }: {
asset: ModelAsset;
pivotOffsetY: number;
cardY: number;
fallbackSizeM: SizeM;
}) {
return (
<group position={[0, pivotOffsetY, 0]}>
{asset.runtimePath ? (
<RealAssetVerification asset={asset} cardY={cardY} />
) : (
<group>
<VerificationGizmos sizeM={fallbackSizeM} />
<InfoCard asset={asset} measuredM={null} y={cardY} />
</group>
)}
</group>
);
}
export default function RealModelVerification() {
return null;
}

View File

@@ -0,0 +1,154 @@
/**
* Stage 2B §9 — Intel RealSense Depth Camera D435i (SPEC_DERIVED).
*
* Built from the official Intel datasheet dimensions (90 × 25 × 25 mm,
* 50 mm stereo baseline, depth FOV 87°×58°) and the reference photo:
* horizontal anodized-aluminium bar, full-width front glass, left/right IR
* imagers, center RGB module, IR texture projector, rear USB-C, tripod boss.
* SPEC_DERIVED — not an official Intel CAD file; provenance documented in
* docs/stage2_real_sorter/camera-realsense-spec.md.
*
* Mount: overhead bar across the belt (Z), front glass facing DOWN (-Y),
* optical center 1.35 m (0.65 m above belt top 0.7 m) — clears the 500 mm
* oversized item by 112 mm. Laser triangulation module separately at 1.15 m
* (project doc height), so camera and laser heights are NOT conflated.
*/
import { memo } from 'react';
import { SCAN_START_X, SCAN_END_X } from '../../domain/measurementZone';
import { ZONES, CONVEYOR_WIDTH_M, BELT_TOP_Y } from '../../domain/physicalLayout';
/** Official datasheet dimensions (m). */
export const D435I = {
width: 0.09, // 90 mm along the bar (Z when mounted across the belt)
height: 0.025, // 25 mm
depth: 0.025, // 25 mm
baseline: 0.05,
fovH: 87, // deg, along the baseline
fovV: 58, // deg
opticalCenterY: 1.35,
} as const;
const HOUSING = '#222528';
const HOUSING_EDGE = '#31363b';
const GLASS = '#0c1116';
const LENS_RIM = '#3c4249';
const LENS_INNER = '#05070a';
/** One sensor window on the glass face (rim + recessed lens), facing DOWN (-Y). */
function SensorWindow({ z, radius }: { z: number; radius: number }) {
const y = -D435I.height / 2 - 0.0004;
return (
<group position={[0, 0, z]}>
<mesh position={[0, y, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[radius, 0.0016, 10, 24]} />
<meshStandardMaterial color={LENS_RIM} metalness={0.8} roughness={0.35} />
</mesh>
<mesh position={[0, y + 0.0008, 0]}>
<cylinderGeometry args={[radius * 0.72, radius * 0.72, 0.0016, 20]} />
<meshStandardMaterial color={LENS_INNER} metalness={0.4} roughness={0.15} />
</mesh>
</group>
);
}
export const RealSenseD435i = memo(function RealSenseD435i({
castShadow = false,
}: {
castShadow?: boolean;
}) {
const w = D435I.width;
return (
// Bar runs across the belt (Z); front glass face looks DOWN (-Y) at the belt.
<group>
{/* Main housing bar with chamfered edge rails */}
<mesh castShadow={castShadow}>
<boxGeometry args={[D435I.depth - 0.004, D435I.height - 0.004, w]} />
<meshStandardMaterial color={HOUSING} metalness={0.7} roughness={0.42} />
</mesh>
{/* side edge rails (rounded anodized look) */}
{[-1, 1].map((s) => (
<mesh key={s} position={[s * (D435I.depth / 2 - 0.002), 0, 0]}>
<boxGeometry args={[0.004, D435I.height, w - 0.006]} />
<meshStandardMaterial color={HOUSING_EDGE} metalness={0.75} roughness={0.35} />
</mesh>
))}
{/* Full-width front glass on the downward face */}
<mesh position={[0, -D435I.height / 2 - 0.0006, 0]}>
<boxGeometry args={[D435I.depth - 0.007, 0.0012, w - 0.008]} />
<meshPhysicalMaterial
color={GLASS}
metalness={0.1}
roughness={0.08}
transparent
opacity={0.82}
/>
</mesh>
{/* Sensors along the bar: left imager / RGB / IR projector / right imager */}
<SensorWindow z={D435I.baseline / 2} radius={0.0065} />
<SensorWindow z={-D435I.baseline / 2} radius={0.0065} />
<SensorWindow z={0.012} radius={0.0042} />
<SensorWindow z={-0.011} radius={0.0052} />
{/* USB-C port on the right end (rear) */}
<mesh position={[D435I.depth / 2 - 0.001, 0.002, w / 2 - 0.008]}>
<boxGeometry args={[0.004, 0.006, 0.009]} />
<meshStandardMaterial color="#0b0d0f" metalness={0.3} roughness={0.6} />
</mesh>
{/* Tripod boss on top (mount point) */}
<mesh position={[0, D435I.height / 2 + 0.003, 0]}>
<boxGeometry args={[0.012, 0.006, 0.02]} />
<meshStandardMaterial color={HOUSING_EDGE} metalness={0.8} roughness={0.4} />
</mesh>
</group>
);
});
/**
* Debug-only measurement frustum (§9.4): optical axis, FOV pyramid,
* scan-zone rectangle on the belt, entry/exit markers.
*/
export const RealSenseFrustumDebug = memo(function RealSenseFrustumDebug() {
const h = D435I.opticalCenterY - BELT_TOP_Y;
const halfAlong = Math.tan((D435I.fovV / 2) * (Math.PI / 180)) * h; // along belt X
const halfAcross = Math.tan((D435I.fovH / 2) * (Math.PI / 180)) * h; // across belt Z
const cx = ZONES.CAMERA.x;
const top: [number, number, number] = [cx, D435I.opticalCenterY - D435I.height / 2, 0];
const y = BELT_TOP_Y;
const corners: [number, number, number][] = [
[cx - halfAlong, y, -halfAcross],
[cx + halfAlong, y, -halfAcross],
[cx + halfAlong, y, halfAcross],
[cx - halfAlong, y, halfAcross],
];
return (
<group>
{/* optical axis */}
<lineSegments>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[new Float32Array([
...top, cx, y, 0,
// FOV edges
...corners.flatMap((c) => [...top, ...c]),
// FOV footprint rectangle
...corners.flatMap((c, i) => [...c, ...corners[(i + 1) % 4]]),
]), 3]}
/>
</bufferGeometry>
<lineBasicMaterial color="#38bdf8" transparent opacity={0.55} />
</lineSegments>
{/* scan zone rectangle on the belt */}
<mesh position={[(SCAN_START_X + SCAN_END_X) / 2, y + 0.003, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[SCAN_END_X - SCAN_START_X, CONVEYOR_WIDTH_M]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.12} />
</mesh>
{/* entry / exit markers */}
{[SCAN_START_X, SCAN_END_X].map((x) => (
<mesh key={x} position={[x, y + 0.004, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.015, CONVEYOR_WIDTH_M]} />
<meshBasicMaterial color={x === SCAN_START_X ? '#22c55e' : '#ef4444'} transparent opacity={0.6} />
</mesh>
))}
</group>
);
});

View File

@@ -0,0 +1,185 @@
/**
* RollCageMesh — честная сетчатая модель роллтейнера C/D по ground truth.
*
* Exterior bounding box: 1200 × 800 × 800 мм (включая колёса) — ROLL_CAGE.
* Открытый верх, читаемая сетка стен (~100мм), пол-панель на CAGE_FLOOR_Y.
*
* Вся геометрия — 3 instanced draw call (трубы+прутья, колёса) + 1 mesh (пол).
* Shared roll-cage mesh for C/D receivers.
*
* Классификация узла (Stage 1 §13.4): PROCEDURAL_FALLBACK — официальной
* CAD-модели роллтейнера в архивах нет; размеры соответствуют спецификации.
*/
import { useEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import { ROLL_CAGE, CAGE_FLOOR_Y } from '../../domain/physicalLayout';
const { width: W, depth: D, height: H, wheelRadius: WR, frameThickness: FT } = ROLL_CAGE;
const WHEEL_D = WR * 2; // 0.08m — cage floor height (CAGE_FLOOR_Y)
const BODY_H = H - WHEEL_D; // frame body above wheels; total exterior = H exactly
const ROD = 0.008; // grid rod thickness (8mm wire)
const GRID_STEP = 0.1; // ~100mm grid pitch
interface CageInstances {
boxes: THREE.Matrix4[];
wheels: THREE.Matrix4[];
}
/**
* Stage 2: roll cages are 3-sided with an OPEN FRONT on the conveyor-facing
* side (real roll-container design) plus a 40mm sill — items enter through
* the opening from the gravity chute. Matches physicsWorldLayout colliders.
*/
export type CageOpenSide = 'z-' | 'z+' | 'none';
function boxInstance(x: number, y: number, z: number, sx: number, sy: number, sz: number): THREE.Matrix4 {
return new THREE.Matrix4().compose(
new THREE.Vector3(x, y, z),
new THREE.Quaternion(),
new THREE.Vector3(sx, sy, sz),
);
}
/** Deterministic instance layout for the cage (built once per open side). */
function buildInstances(openSide: CageOpenSide): CageInstances {
const boxes: THREE.Matrix4[] = [];
const yBot = WHEEL_D; // bottom of frame body
const yTop = H; // top of frame body (exterior top)
const openSign = openSide === 'z-' ? -1 : openSide === 'z+' ? 1 : 0;
// 4 corner posts
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
boxes.push(boxInstance(sx * (W / 2 - FT / 2), yBot + BODY_H / 2, sz * (D / 2 - FT / 2), FT, BODY_H, FT));
}
}
// bottom + top frame rectangles (skip the open side's tubes; sill added below)
for (const y of [yBot + FT / 2, yTop - FT / 2]) {
for (const sz of [-1, 1]) {
if (sz === openSign && y === yTop - FT / 2) continue; // open front: no top tube
boxes.push(boxInstance(0, y, sz * (D / 2 - FT / 2), W, FT, FT));
}
boxes.push(boxInstance(W / 2 - FT / 2, y, 0, FT, FT, D - FT * 2));
boxes.push(boxInstance(-(W / 2 - FT / 2), y, 0, FT, FT, D - FT * 2));
}
// 40mm sill across the open front (matches entry-sill collider)
if (openSign !== 0) {
boxes.push(boxInstance(0, yBot + 0.02, openSign * (D / 2 - FT / 2), W, 0.04, FT));
}
// grid walls between frames (interior span)
const yGridBot = yBot + FT;
const yGridTop = yTop - FT;
const gridH = yGridTop - yGridBot;
const yMid = yGridBot + gridH / 2;
const xInner = W / 2 - FT; // inner half-width
const zInner = D / 2 - FT;
// front/back walls (z = ±(D/2 ROD/2)): vertical + horizontal rods
const vCols = Math.floor((xInner * 2) / GRID_STEP) - 1; // exclude corners (posts)
const hRows = Math.max(1, Math.round(gridH / GRID_STEP) - 1);
for (const sz of [-1, 1]) {
if (sz === openSign) continue; // open front: no grid wall
const z = sz * (D / 2 - ROD / 2);
for (let i = 1; i <= vCols; i++) {
const x = -xInner + (i * (xInner * 2)) / (vCols + 1);
boxes.push(boxInstance(x, yMid, z, ROD, gridH, ROD));
}
for (let r = 1; r <= hRows; r++) {
const y = yGridBot + (r * gridH) / (hRows + 1);
boxes.push(boxInstance(0, y, z, W - FT * 2, ROD, ROD));
}
}
// side walls (x = ±(W/2 ROD/2))
const sCols = Math.floor((zInner * 2) / GRID_STEP) - 1;
for (const sx of [-1, 1]) {
const x = sx * (W / 2 - ROD / 2);
for (let i = 1; i <= sCols; i++) {
const z = -zInner + (i * (zInner * 2)) / (sCols + 1);
boxes.push(boxInstance(x, yMid, z, ROD, gridH, ROD));
}
for (let r = 1; r <= hRows; r++) {
const y = yGridBot + (r * gridH) / (hRows + 1);
boxes.push(boxInstance(x, y, 0, ROD, ROD, D - FT * 2));
}
}
// caster wheels (lying cylinders)
const wheels: THREE.Matrix4[] = [];
const wheelQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, Math.PI / 2));
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
wheels.push(new THREE.Matrix4().compose(
new THREE.Vector3(sx * (W / 2 - 0.08), WR, sz * (D / 2 - 0.08)),
wheelQuat,
new THREE.Vector3(1, 1, 1),
));
}
}
return { boxes, wheels };
}
export default function RollCageMesh({ color, active = false, shadows = false, openSide = 'none' }: {
color: string;
active?: boolean;
shadows?: boolean;
openSide?: CageOpenSide;
}) {
const instances = useMemo(() => buildInstances(openSide), [openSide]);
const boxGeo = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []);
const wheelGeo = useMemo(() => new THREE.CylinderGeometry(WR, WR, 0.03, 12), []);
const boxesRef = useRef<THREE.InstancedMesh>(null);
const wheelsRef = useRef<THREE.InstancedMesh>(null);
useEffect(() => {
const boxes = boxesRef.current;
if (boxes) {
instances.boxes.forEach((m, i) => boxes.setMatrixAt(i, m));
boxes.instanceMatrix.needsUpdate = true;
}
const wheels = wheelsRef.current;
if (wheels) {
instances.wheels.forEach((m, i) => wheels.setMatrixAt(i, m));
wheels.instanceMatrix.needsUpdate = true;
}
}, [instances]);
useEffect(() => () => {
boxGeo.dispose();
wheelGeo.dispose();
}, [boxGeo, wheelGeo]);
const emissiveIntensity = active ? 0.35 : 0;
return (
<group>
{/* frame + grid walls: single instanced draw call */}
<instancedMesh
ref={boxesRef}
args={[boxGeo, undefined, instances.boxes.length]}
castShadow={shadows}
>
<meshStandardMaterial
color={color}
metalness={0.6}
roughness={0.35}
emissive={color}
emissiveIntensity={emissiveIntensity}
/>
</instancedMesh>
{/* caster wheels: single instanced draw call */}
<instancedMesh ref={wheelsRef} args={[wheelGeo, undefined, instances.wheels.length]}>
<meshStandardMaterial color="#475569" metalness={0.7} roughness={0.3} />
</instancedMesh>
{/* interior floor pan where items rest (top at CAGE_FLOOR_Y) */}
<mesh position={[0, CAGE_FLOOR_Y - 0.005, 0]} receiveShadow={shadows}>
<boxGeometry args={[W - FT, 0.01, D - FT]} />
<meshStandardMaterial color="#1e293b" metalness={0.3} roughness={0.7} />
</mesh>
</group>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
/**
* Stage 2 — physics world for the sorter drop segment.
*
* Hybrid authority (docs/stage2_real_sorter/physics-architecture.md):
* - items on the belt are KINEMATIC (domain pose is truth);
* - at the drop handoff (pusher contact / belt edge) the body switches to
* DYNAMIC with deterministic initial velocity;
* - static colliders mirror the visible chute / receiver geometry
* (documented hidden colliders, same dimensions as the visuals).
*
* Determinism: fixed dt = 1/60, max 4 substeps/frame, no unseeded randomness.
* Physics freezes when the domain clock is paused (documented simulation
* assumption — belt, gate and items halt together; EMERGENCY_STOP creates no
* new impulses).
*
* Stage 2E: Rapier step timing via PhysicsPerfSampler (?perf=1 | ?physicsPerf=1).
* Render time is NOT included in physics p95.
*/
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';
import {
PhysicsPerfSampler,
isPhysicsPerfQueryEnabled,
type PhysicsPerfSnapshot,
} from '../../domain/physicsPerf';
export const PHYSICS_DT = PHYSICS_TIMESTEP_SEC;
export const PHYSICS_MAX_SUBSTEPS = 4;
const MAX_SUBSTEPS = PHYSICS_MAX_SUBSTEPS;
/**
* Physics-time clock (seconds actually simulated by THIS world instance).
* Kinematic mechanisms must be driven by this clock — never by the domain
* wall clock — because under render lag the stepper burns at most
* MAX_SUBSTEPS per frame and physics time falls behind domain time.
*/
export const physicsSimClock = { simSec: 0 };
export function resetPhysicsSimClock() {
physicsSimClock.simSec = 0;
}
/** Drop verification record (debug/e2e introspection, no secrets). */
export interface DropResult {
caseId: string;
itemId: string;
expectedZone: 'B' | 'C' | 'D';
finalPosition: [number, number, number];
insideExpectedReceiver: boolean;
settledByTimeout: boolean;
timestampMs: number;
}
declare global {
interface Window {
__DROP_RESULTS?: DropResult[];
__PHYSICS_PERF__?: PhysicsPerfSnapshot;
__PHYSICS_PERF_RESET__?: () => void;
}
}
export function recordDropResult(result: DropResult) {
if (typeof window !== 'undefined') {
window.__DROP_RESULTS = [...(window.__DROP_RESULTS ?? []).slice(-49), result];
}
}
function readWorldMeta(world: {
bodies?: { len: () => number };
colliders?: { len: () => number };
}): { activeBodies: number; sleepingBodies: number; colliders: number; contactPairs: number } {
try {
// @react-three/rapier wraps Rapier world; body counts via forEach when available
const w = world as unknown as {
forEachRigidBody?: (cb: (b: { isSleeping: () => boolean; numColliders: () => number }) => void) => void;
bodies?: { len: () => number };
colliders?: { len: () => number };
};
let active = 0;
let sleeping = 0;
let colliders = 0;
if (typeof w.forEachRigidBody === 'function') {
w.forEachRigidBody((b) => {
if (b.isSleeping()) sleeping += 1;
else active += 1;
try {
colliders += b.numColliders();
} catch {
/* ignore */
}
});
} else {
active = w.bodies?.len?.() ?? 0;
colliders = w.colliders?.len?.() ?? 0;
}
return { activeBodies: active, sleepingBodies: sleeping, colliders, contactPairs: 0 };
} catch {
return { activeBodies: 0, sleepingBodies: 0, colliders: 0, contactPairs: 0 };
}
}
/** Steps the Rapier world with a fixed dt, scaled by domain playback speed. */
function RapierStepper({ running, speed }: { running: boolean; speed: number }) {
const { world } = useRapier();
const accumulator = useRef(0);
const sampler = useRef(new PhysicsPerfSampler(PHYSICS_DT, MAX_SUBSTEPS));
const perfOn = useRef(false);
// Latch query once (and expose reset) — no React state.
if (typeof window !== 'undefined' && !perfOn.current) {
perfOn.current = isPhysicsPerfQueryEnabled();
if (perfOn.current) {
window.__PHYSICS_PERF_RESET__ = () => sampler.current.reset();
}
}
useFrame((_, delta) => {
if (!running) return;
accumulator.current += Math.min(delta, 0.1) * speed;
let steps = 0;
let framePhysicsMs = 0;
while (accumulator.current >= PHYSICS_DT && steps < MAX_SUBSTEPS) {
if (perfOn.current) {
const t0 = performance.now();
world.step();
framePhysicsMs += performance.now() - t0;
} else {
world.step();
}
physicsSimClock.simSec += PHYSICS_DT;
accumulator.current -= PHYSICS_DT;
steps += 1;
}
if (perfOn.current && steps > 0) {
// Record per-frame physics cost (sum of substeps this frame), not render.
sampler.current.pushStepMs(framePhysicsMs, steps);
window.__PHYSICS_PERF__ = sampler.current.snapshot(readWorldMeta(world));
}
if (steps === MAX_SUBSTEPS) accumulator.current = 0;
});
return null;
}
/** Static colliders for the whole working area (fixed bodies, cheap cuboids).
* Layout data lives in domain/physicsWorldLayout — shared with headless tests. */
export function SorterStaticColliders() {
return (
<RigidBody type="fixed" colliders={false}>
{getStaticColliders().map((c) => (
<CuboidCollider
key={c.id}
args={c.halfExtents}
position={c.position}
rotation={c.rotation}
friction={c.friction}
/>
))}
</RigidBody>
);
}
export function SorterPhysicsWorld({
running,
speed,
children,
}: {
running: boolean;
speed: number;
children: ReactNode;
}) {
return (
<Physics updateLoop="independent" paused timeStep={PHYSICS_DT} gravity={[0, -9.81, 0]}>
<RapierStepper running={running} speed={speed} />
<SorterStaticColliders />
{children}
</Physics>
);
}

View File

@@ -0,0 +1,143 @@
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
onError?: (error: Error) => void;
/** Explicit switch-to-2D action (replaces the old dead `use-2d-fallback` event). */
onUse2D?: () => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* ErrorBoundary для 3D Canvas.
* Ловит ошибки Three.js/WebGL и показывает fallback вместо чёрного экрана.
*/
export default class ThreeErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: unknown) {
console.error('3D Canvas error caught by ErrorBoundary:', error, errorInfo);
this.props.onError?.(error);
}
handleReload = () => {
this.setState({ hasError: false, error: null });
window.location.reload();
};
handleUse2D = () => {
this.setState({ hasError: false, error: null });
this.props.onUse2D?.();
};
render() {
if (this.state.hasError) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
padding: '24px',
border: '1px solid rgba(251, 61, 78, 0.3)',
borderRadius: '12px',
background: 'rgba(251, 61, 78, 0.05)',
color: '#e5f2ff',
}}
>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginBottom: '16px', color: '#fb3d4e' }}
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<h3 style={{ margin: '0 0 8px 0', fontSize: '18px', fontWeight: 600 }}>
3D Scene Failed
</h3>
<p style={{ margin: '0 0 20px 0', color: 'rgba(229, 242, 255, 0.7)', fontSize: '14px', textAlign: 'center', maxWidth: '400px' }}>
3D rendering encountered an error. You can reload or switch to stable 2D fallback.
</p>
{import.meta.env.DEV && this.state.error && (
<pre
style={{
fontSize: '12px',
color: '#fb3d4e',
background: 'rgba(0, 0, 0, 0.3)',
padding: '12px',
borderRadius: '6px',
maxWidth: '100%',
overflow: 'auto',
marginBottom: '20px',
}}
>
{this.state.error.message}
</pre>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={this.handleReload}
style={{
padding: '10px 20px',
background: 'rgba(56, 189, 248, 0.15)',
border: '1px solid rgba(56, 189, 248, 0.3)',
borderRadius: '8px',
color: '#38bdf8',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
Reload 3D
</button>
{this.props.onUse2D && (
<button
type="button"
onClick={this.handleUse2D}
style={{
padding: '10px 20px',
background: 'rgba(148, 163, 184, 0.15)',
border: '1px solid rgba(148, 163, 184, 0.3)',
borderRadius: '8px',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
Use 2D Fallback
</button>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
export function detectWebGL(): boolean {
try {
const canvas = document.createElement('canvas');
return Boolean(
canvas.getContext('webgl2') ||
canvas.getContext('webgl') ||
canvas.getContext('experimental-webgl'),
);
} catch {
return false;
}
}
export function useWebGLSupport(): boolean {
const [supported, setSupported] = useState(true);
useEffect(() => {
setSupported(detectWebGL());
}, []);
return supported;
}
export function prefer3DByDefault(width: number, webgl: boolean): boolean {
return webgl && width >= 640;
}

111
src/data/items.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { Item } from '../domain/types';
export const ITEMS: Item[] = [
{
id: 'SKU-001',
name: 'Box 300x200x200',
dimensionsMm: { width: 300, depth: 200, height: 200 },
roundness: 0.12,
confidence: 0.94,
shape: 'box',
expectedCategory: 'B',
},
{
id: 'SKU-002',
name: 'Lunchbox 201x152x62',
dimensionsMm: { width: 201, depth: 152, height: 62 },
roundness: 0.22,
confidence: 0.91,
shape: 'rectangular prism',
expectedCategory: 'B',
},
{
id: 'SKU-003',
name: 'Detergent 259x179x278',
dimensionsMm: { width: 259, depth: 179, height: 278 },
roundness: 0.38,
confidence: 0.88,
shape: 'bottle box',
expectedCategory: 'B',
},
{
id: 'SKU-004',
name: 'Oversized box 401x300x400',
dimensionsMm: { width: 401, depth: 300, height: 400 },
roundness: 0.18,
confidence: 0.9,
shape: 'oversized box',
expectedCategory: 'C',
},
{
id: 'SKU-005',
name: 'Pouf 489x264x489',
dimensionsMm: { width: 489, depth: 264, height: 489 },
roundness: 0.74,
confidence: 0.86,
shape: 'soft bulky item',
expectedCategory: 'C',
},
{
id: 'SKU-006',
name: 'Plate 210x209x27',
dimensionsMm: { width: 210, depth: 209, height: 27 },
roundness: 0.95,
confidence: 0.89,
shape: 'round plate',
expectedCategory: 'D',
},
{
id: 'SKU-007',
name: 'Bottle 91x91x305',
dimensionsMm: { width: 91, depth: 91, height: 305 },
roundness: 0.92,
confidence: 0.93,
shape: 'cylinder bottle',
expectedCategory: 'D',
},
{
id: 'SKU-008',
name: 'Cylinder 435x50x43',
dimensionsMm: { width: 435, depth: 50, height: 43 },
roundness: 0.88,
confidence: 0.87,
shape: 'long cylinder',
expectedCategory: 'D',
},
{
id: 'SKU-009',
name: 'Pen 9x13x148',
dimensionsMm: { width: 9, depth: 13, height: 148 },
roundness: 0.66,
confidence: 0.84,
shape: 'thin item',
expectedCategory: 'C',
},
{
id: 'SKU-010',
name: 'Near-max box 449x319x319',
dimensionsMm: { width: 449, depth: 319, height: 319 },
roundness: 0.2,
confidence: 0.9,
shape: 'boundary box',
expectedCategory: 'B',
},
{
id: 'SKU-011',
name: 'Oversized round 500x300x300',
dimensionsMm: { width: 500, depth: 300, height: 300 },
roundness: 0.93,
confidence: 0.88,
shape: 'oversized round cylinder',
expectedCategory: 'C',
},
];
export function getItem(id: string): Item {
const item = ITEMS.find((candidate) => candidate.id === id);
if (!item) {
throw new Error(`Unknown item id: ${id}`);
}
return item;
}

View File

@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest';
import {
MODEL_ASSETS,
ARCHIVE_ONLY_MODELS,
getModelAsset,
getRealAssets,
getProceduralAssets,
getManifestStats,
} from './modelAssets';
// Runtime files under public/ (project convention: import.meta.glob instead of node:fs)
const RUNTIME_MODEL_FILES = Object.keys(
import.meta.glob('../../public/models/*.stl', { eager: true, query: '?url', import: 'default' }),
).map((p) => p.replace(/^.*\/public/, ''));
describe('modelAssets (Stage 1 real-model manifest)', () => {
describe('MODEL_ASSETS', () => {
it('should contain assets for all scenario items', () => {
const expectedIds = [
'SKU-001', 'SKU-002', 'SKU-003', 'SKU-004', 'SKU-005', 'SKU-006',
'SKU-007', 'SKU-008', 'SKU-009', 'SKU-010', 'SKU-011',
];
const actualIds = MODEL_ASSETS.map((asset) => asset.itemId);
expectedIds.forEach((id) => {
expect(actualIds).toContain(id);
});
});
it('should have a unique SKU per entry', () => {
const ids = MODEL_ASSETS.map((asset) => asset.itemId);
expect(new Set(ids).size).toBe(ids.length);
});
it('should define a fallback primitive for every asset', () => {
MODEL_ASSETS.forEach((asset) => {
expect(asset.fallbackPrimitive).toMatch(/^(box|cylinder|sphere)$/);
});
});
it('should have valid expected dimensions for every asset', () => {
MODEL_ASSETS.forEach((asset) => {
expect(asset.dimensions.width).toBeGreaterThan(0);
expect(asset.dimensions.depth).toBeGreaterThan(0);
expect(asset.dimensions.height).toBeGreaterThan(0);
});
});
it('runtime paths must exist on disk for every real asset', () => {
for (const asset of MODEL_ASSETS) {
if (asset.defaultRealAsset) {
expect(asset.runtimePath, `${asset.itemId} runtimePath`).toBeTruthy();
expect(
RUNTIME_MODEL_FILES,
`${asset.itemId}${asset.runtimePath} must exist in public/models`,
).toContain(asset.runtimePath);
} else {
expect(asset.runtimePath).toBeNull();
}
}
});
it('real assets must declare provenance (archive, file, sha256)', () => {
for (const asset of getRealAssets()) {
expect(asset.sourceArchive).toBe('input_info/doc-1782987733.zip');
expect(asset.sourceFile).toBeTruthy();
expect(asset.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
expect(asset.runtimeSha256).toMatch(/^[0-9a-f]{64}$/);
expect(asset.sourceBoundingBoxMm).not.toBeNull();
expect(asset.worldExpectedMm).not.toBeNull();
expect(asset.triangleCount).toBeGreaterThan(0);
expect(asset.fileSizeBytes).toBeGreaterThan(0);
}
});
it('real assets use bottom-center pivot and uniform mm→m scale only', () => {
for (const asset of getRealAssets()) {
expect(asset.pivotMode).toBe('bottom-center');
expect(asset.scaleMode).toBe('uniform-mm-to-m');
}
});
it('must NOT silently substitute another model for a SKU', () => {
// SKU-011 previously reused cylinder.stl — forbidden now.
const sku011 = getModelAsset('SKU-011');
expect(sku011?.defaultRealAsset).toBe(false);
expect(sku011?.runtimePath).toBeNull();
expect(sku011?.notes).toContain('NO_EXACT_OFFICIAL_MODEL');
});
});
describe('official test-set coverage', () => {
it('integrates the 9 official models that have matching SKUs', () => {
const realIds = getRealAssets().map((a) => a.itemId).sort();
expect(realIds).toEqual([
'SKU-001', 'SKU-002', 'SKU-003', 'SKU-004', 'SKU-005',
'SKU-006', 'SKU-007', 'SKU-008', 'SKU-009',
]);
});
it('marks SKUs without an exact official model honestly', () => {
for (const id of ['SKU-010', 'SKU-011']) {
const asset = getModelAsset(id);
expect(asset?.defaultRealAsset).toBe(false);
expect(asset?.notes).toContain('NO_EXACT_OFFICIAL_MODEL');
}
});
it('documents archive-only official models (bag, helmet)', () => {
const names = ARCHIVE_ONLY_MODELS.map((m) => m.displayName);
expect(names).toContain('Мешок');
expect(names).toContain('Шлем');
ARCHIVE_ONLY_MODELS.forEach((m) => {
expect(m.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
});
});
it('respects runtime file budgets (<= 1.5MB hard, <= 100k tris acceptable)', () => {
for (const asset of getRealAssets()) {
expect(asset.fileSizeBytes!, `${asset.itemId} file size`).toBeLessThanOrEqual(1.5 * 1024 * 1024);
expect(asset.triangleCount!, `${asset.itemId} triangles`).toBeLessThanOrEqual(100_000);
}
});
});
describe('getModelAsset', () => {
it('should return asset for valid item ID', () => {
const asset = getModelAsset('SKU-006');
expect(asset).toBeDefined();
expect(asset?.displayName).toBe('Тарелка');
expect(asset?.categoryScenario).toBe('D');
});
it('should return undefined for invalid item ID', () => {
const asset = getModelAsset('SKU-999');
expect(asset).toBeUndefined();
});
});
describe('getManifestStats', () => {
it('should return correct totals', () => {
const stats = getManifestStats();
expect(stats.total).toBe(MODEL_ASSETS.length);
expect(stats.real).toBe(getRealAssets().length);
expect(stats.procedural).toBe(getProceduralAssets().length);
expect(stats.real + stats.procedural).toBe(stats.total);
expect(stats.realPercentage).toBe(Math.round((stats.real / stats.total) * 100));
});
});
});

449
src/data/modelAssets.ts Normal file
View File

@@ -0,0 +1,449 @@
/**
* Model Assets Manifest — единый источник сведений о 3D-ассетах товаров.
*
* Stage 1 (real models): каждая запись фиксирует происхождение (архив OZON,
* файл, SHA-256), измеренный исходный bounding box, transform нормализации
* (rotation → uniform mm→m scale → bottom-center pivot) и бюджеты.
*
* Source measurements: scripts/stage1-analyze-stl.mjs (см. docs/stage1_real_models/).
* Decimation: scripts/stage1-decimate-stl.mjs (vertex clustering, воспроизводимо).
* Validation: scripts/validate-real-models.mjs (НЕ редактировать статусы вручную).
*/
import type { Category, DimensionsMm } from '../domain/types';
export interface SourceBoundingBoxMm {
min: [number, number, number];
max: [number, number, number];
size: [number, number, number];
}
export interface ModelAsset {
/** Item ID from items.ts (e.g. 'SKU-006') */
itemId: string;
/** Display name (Russian, official test-set name) */
displayName: string;
/** Expected category scenario */
categoryScenario: Category;
/** Expected physical dimensions in mm (domain truth, items.ts) */
dimensions: DimensionsMm;
// ---------------- Provenance ----------------
/** 'official-stl' = byte-identical archive file; 'official-stl-decimated' = derived from archive STL by reproducible decimation; 'none' = no official model */
sourceType: 'official-stl' | 'official-stl-decimated' | 'none';
/** Archive the source file came from (repo-relative) */
sourceArchive: string | null;
/** File name inside the archive */
sourceFile: string | null;
/** SHA-256 of the source file inside the archive */
sourceSha256: string | null;
// ---------------- Runtime asset ----------------
/** Frontend asset path under public/ (null if no real asset) */
runtimePath: string | null;
/** SHA-256 of the runtime file */
runtimeSha256: string | null;
runtimeFormat: 'binary-stl' | null;
/** Measured triangle count of the runtime file */
triangleCount: number | null;
/** Runtime file size */
fileSizeBytes: number | null;
// ---------------- Normalization ----------------
/** Measured source STL bounding box (mm, source axes) */
sourceBoundingBoxMm: SourceBoundingBoxMm | null;
/** Expected size along world X/Y/Z AFTER rotation (orientation-aware, mm) */
worldExpectedMm: { x: number; y: number; z: number } | null;
/** Euler rotation (radians) applied in source mm space before scaling */
rotation: [number, number, number];
/** Human-readable axis/orientation decision */
axisMapping: string;
/** Pivot convention after normalization */
pivotMode: 'bottom-center';
/** Scale convention: source mm → scene meters, uniform */
scaleMode: 'uniform-mm-to-m';
// ---------------- Policy ----------------
/** true = real model is the default; false = fallback is the default (honest marking) */
defaultRealAsset: boolean;
/** Fallback primitive for load failure / low mode */
fallbackPrimitive: 'box' | 'cylinder' | 'sphere';
/** Provenance of the runtime geometry */
conversionStatus: 'original' | 'decimated-cell-1mm' | 'decimated-cell-2mm' | 'not-applicable';
/** Filled by validate-real-models.mjs — never hand-edited */
validationStatus: 'pending' | 'pass' | 'fail';
/** Preload with the default playlist scene (budget-controlled) */
preload: boolean;
notes?: string;
}
const ARCHIVE_STL = 'input_info/doc-1782987733.zip';
/**
* Model assets manifest.
*
* All runtime STLs are official OZON test-set models (or reproducible
* decimations of them), units = mm, rendered with uniform 0.001 scale.
* No silent substitutions: SKUs without an exact official model are marked
* NO_EXACT_OFFICIAL_MODEL and use an honestly labelled procedural fallback.
*/
export const MODEL_ASSETS: ModelAsset[] = [
{
itemId: 'SKU-001',
displayName: 'Короб 300×200×200',
categoryScenario: 'B',
dimensions: { width: 300, depth: 200, height: 200 },
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Короб 300х200х200.stl',
sourceSha256: '4ac9046bdef5bad2e7e50062fea30ead9132ebb6bdf23edeceb1f61c276f6e38',
runtimePath: '/models/box-300.stl',
runtimeSha256: '4ac9046bdef5bad2e7e50062fea30ead9132ebb6bdf23edeceb1f61c276f6e38',
runtimeFormat: 'binary-stl',
triangleCount: 592,
fileSizeBytes: 29684,
sourceBoundingBoxMm: { min: [-150.5, 0, -100], max: [150.5, 200.5, 100], size: [301, 200.5, 200] },
worldExpectedMm: { x: 300, y: 200, z: 200 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up, bottom at y=0 in source',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, byte-identical to archive (checksum match).',
},
{
itemId: 'SKU-002',
displayName: 'ЛанчБокс',
categoryScenario: 'B',
dimensions: { width: 201, depth: 152, height: 62 },
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/ЛанчБокс.stl',
sourceSha256: '3ad0f231777e7fe1ac55ac55e40074baffc3561c1fe082bb50dd47161f5c5c99',
runtimePath: '/models/lunchbox.stl',
runtimeSha256: '3ad0f231777e7fe1ac55ac55e40074baffc3561c1fe082bb50dd47161f5c5c99',
runtimeFormat: 'binary-stl',
triangleCount: 11574,
fileSizeBytes: 578784,
sourceBoundingBoxMm: { min: [-100.499, -55.8, -76.2], max: [100.496, 6.5, 76.2], size: [200.995, 62.3, 152.4] },
worldExpectedMm: { x: 201, y: 62, z: 152 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; source pivot below center — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL. Lid-down container; bottom-center pivot baked at load.',
},
{
itemId: 'SKU-003',
displayName: 'Моющее средство',
categoryScenario: 'B',
dimensions: { width: 259, depth: 179, height: 278 },
sourceType: 'official-stl-decimated',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Моющее средство.STL',
sourceSha256: '9a8239c0d079084ff45337f88a7169df75ef010c445a87ad3a5746d11ab154dd',
runtimePath: '/models/detergent.stl',
runtimeSha256: '1b93c69affe0a7b5fe67973d3c9ca2d7ca7f3daace9b26e386ce7cd89b78da92',
runtimeFormat: 'binary-stl',
triangleCount: 29458,
fileSizeBytes: 1472984,
sourceBoundingBoxMm: { min: [28.316, 1.054, 0.019], max: [287.345, 279.218, 179.252], size: [259.029, 278.164, 179.232] },
worldExpectedMm: { x: 259, y: 278, z: 179 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; source offset from origin — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
conversionStatus: 'decimated-cell-2mm',
validationStatus: 'pending',
preload: false,
notes: 'Official STL decimated 72,752→29,458 tris (vertex clustering, cell 2mm) to fit the 1.5MB budget; bbox preserved within 0.24mm.',
},
{
itemId: 'SKU-004',
displayName: 'Короб 400×400×300 (негабарит)',
categoryScenario: 'C',
dimensions: { width: 401, depth: 300, height: 400 },
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Короб 400х400х300.stl',
sourceSha256: '05c4ec56883f085e1dafc9bd43fb87ce9993071122e19983192b72120daa9f68',
runtimePath: '/models/box-400.stl',
runtimeSha256: '05c4ec56883f085e1dafc9bd43fb87ce9993071122e19983192b72120daa9f68',
runtimeFormat: 'binary-stl',
triangleCount: 536,
fileSizeBytes: 26884,
sourceBoundingBoxMm: { min: [-200.5, 0, -200], max: [200.5, 300.5, 200], size: [401, 300.5, 400] },
worldExpectedMm: { x: 401, y: 300, z: 400 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up, bottom at y=0 in source',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, byte-identical to archive.',
},
{
itemId: 'SKU-005',
displayName: 'Пуфик',
categoryScenario: 'C',
dimensions: { width: 489, depth: 264, height: 489 },
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Пуфик.stl',
sourceSha256: '1761f3b2d9e5781a11f09e868d2c14e59d3c03bd5f0c19a006f28648de9f66d1',
runtimePath: '/models/pouf.stl',
runtimeSha256: '1761f3b2d9e5781a11f09e868d2c14e59d3c03bd5f0c19a006f28648de9f66d1',
runtimeFormat: 'binary-stl',
triangleCount: 12880,
fileSizeBytes: 644084,
sourceBoundingBoxMm: { min: [-244.452, -126, -124.452], max: [244.452, 138, 364.452], size: [488.905, 264, 488.905] },
worldExpectedMm: { x: 489, y: 264, z: 489 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; source center-offset pivot — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
conversionStatus: 'original',
validationStatus: 'pending',
preload: false,
notes: 'Official STL, round soft pouf. 489mm width exceeds the 450mm gate limit on purpose (C scenario).',
},
{
itemId: 'SKU-006',
displayName: 'Тарелка',
categoryScenario: 'D',
dimensions: { width: 210, depth: 209, height: 27 },
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Тарелка.stl',
sourceSha256: '9bd0fece5fca87951d9c05576f054d0f61da76b56f0ca1387ddcfce816b23f47',
runtimePath: '/models/plate.stl',
runtimeSha256: '9bd0fece5fca87951d9c05576f054d0f61da76b56f0ca1387ddcfce816b23f47',
runtimeFormat: 'binary-stl',
triangleCount: 2504,
fileSizeBytes: 125284,
sourceBoundingBoxMm: { min: [-104.755, -4.56, -104.793], max: [104.755, 21.967, 104.644], size: [209.511, 26.527, 209.437] },
worldExpectedMm: { x: 210, y: 27, z: 209 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, round plate (D scenario).',
},
{
itemId: 'SKU-007',
displayName: 'Бутылка',
categoryScenario: 'D',
dimensions: { width: 91, depth: 91, height: 305 },
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Бутылка.stl',
sourceSha256: '9a8c64f1c26a2f2e2539b5e283b36ca78158cb8d2f0f66d3201dbcc784417375',
runtimePath: '/models/bottle.stl',
runtimeSha256: '9a8c64f1c26a2f2e2539b5e283b36ca78158cb8d2f0f66d3201dbcc784417375',
runtimeFormat: 'binary-stl',
triangleCount: 6522,
fileSizeBytes: 326184,
sourceBoundingBoxMm: { min: [-45.7, 0, -45.659], max: [45.535, 305, 45.659], size: [91.235, 305, 91.318] },
worldExpectedMm: { x: 91, y: 305, z: 91 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up, bottom at y=0 in source',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, standing bottle (D scenario).',
},
{
itemId: 'SKU-008',
displayName: 'Цилиндр',
categoryScenario: 'D',
dimensions: { width: 435, depth: 50, height: 43 },
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Цилиндр.stl',
sourceSha256: '7aab451e1fd2154e4a2301e12642d17ccee62fc950dddf7de5e54c89453254e5',
runtimePath: '/models/cylinder.stl',
runtimeSha256: '7aab451e1fd2154e4a2301e12642d17ccee62fc950dddf7de5e54c89453254e5',
runtimeFormat: 'binary-stl',
triangleCount: 2152,
fileSizeBytes: 107684,
sourceBoundingBoxMm: { min: [-83, -19, -25], max: [352, 24, 25], size: [435, 43, 50] },
worldExpectedMm: { x: 435, y: 43, z: 50 },
rotation: [0, 0, 0],
axisMapping: 'x→width (long axis, along travel), y→height, z→depth; source offset — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
conversionStatus: 'original',
validationStatus: 'pending',
preload: false,
notes: 'Official STL, long cylinder lying along the belt.',
},
{
itemId: 'SKU-009',
displayName: 'Ручка',
categoryScenario: 'C',
dimensions: { width: 9, depth: 13, height: 148 },
sourceType: 'official-stl-decimated',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Ручка.stl',
sourceSha256: '9c1d3b27b9a5e4f1e65a43ac953f74695bfa0d9550dd97963bfcb7bb39f38720',
runtimePath: '/models/pen.stl',
runtimeSha256: 'eb4baf50e4910c9be3eaa092da3720fe2d5dcfd0d9a056e4480af6b878561df5',
runtimeFormat: 'binary-stl',
triangleCount: 4800,
fileSizeBytes: 240084,
sourceBoundingBoxMm: { min: [36.143, 91.878, 53.556], max: [45.142, 105.031, 202.016], size: [8.999, 13.153, 148.46] },
worldExpectedMm: { x: 148, y: 13, z: 9 },
rotation: [0, Math.PI / 2, 0],
axisMapping: 'DEMO ORIENTATION: lying. Source long axis Z(148) rotated to world X (along travel); world Y=13 (depth), world Z=9 (width). Domain height 148 is the pen LENGTH (standing interpretation in items.ts).',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
conversionStatus: 'decimated-cell-1mm',
validationStatus: 'pending',
preload: true,
notes: 'Official STL decimated 40,926→4,800 tris (cell 1mm). Lying demo orientation: a 148mm pen cannot stand stably on a moving belt; documented per Stage 1 §12.',
},
{
itemId: 'SKU-010',
displayName: 'Boundary box 450×320×320',
categoryScenario: 'B',
dimensions: { width: 450, depth: 320, height: 320 },
sourceType: 'none',
sourceArchive: null,
sourceFile: null,
sourceSha256: null,
runtimePath: null,
runtimeSha256: null,
runtimeFormat: null,
triangleCount: null,
fileSizeBytes: null,
sourceBoundingBoxMm: null,
worldExpectedMm: null,
rotation: [0, 0, 0],
axisMapping: 'n/a',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: false,
fallbackPrimitive: 'box',
conversionStatus: 'not-applicable',
validationStatus: 'pending',
preload: false,
notes: 'NO_EXACT_OFFICIAL_MODEL — synthetic boundary-limit test case (exactly at 450×320×320 gate limit); no official counterpart exists in the OZON archives. Honest procedural box, dimension-accurate.',
},
{
itemId: 'SKU-011',
displayName: 'Oversized round 500×300×300',
categoryScenario: 'C',
dimensions: { width: 500, depth: 300, height: 300 },
sourceType: 'none',
sourceArchive: null,
sourceFile: null,
sourceSha256: null,
runtimePath: null,
runtimeSha256: null,
runtimeFormat: null,
triangleCount: null,
fileSizeBytes: null,
sourceBoundingBoxMm: null,
worldExpectedMm: null,
rotation: [0, 0, 0],
axisMapping: 'n/a',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: false,
fallbackPrimitive: 'cylinder',
conversionStatus: 'not-applicable',
validationStatus: 'pending',
preload: false,
notes: 'NO_EXACT_OFFICIAL_MODEL — no 500×300×300 round item in the official set. Previously reused cylinder.stl (silent substitution, removed in Stage 1). Honest procedural cylinder, dimension-accurate.',
},
];
/**
* Archive-only official models with no matching SKU in the app scenario set.
* Listed for inventory completeness (not loaded at runtime).
*/
export const ARCHIVE_ONLY_MODELS = [
{
displayName: 'Мешок',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Мешок.stl',
sourceSha256: '74b3118643c0cd06ed639da1513b6db1b8d6d0a38e708cf2f8393d69263cd97b',
triangleCount: 21228,
fileSizeBytes: 5675429,
notes: 'ASCII STL, ~183×175×199mm. No SKU in the app scenario set.',
},
{
displayName: 'Шлем',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Шлем.stl',
sourceSha256: '660429b26d576771cd02ff275b489dcb134b17bf83b0cab969f589b4d6f6192d',
triangleCount: 55159,
fileSizeBytes: 2758034,
notes: '~280×297×356mm. No SKU in the app scenario set.',
},
] as const;
/** Get model asset for item ID. */
export function getModelAsset(itemId: string): ModelAsset | undefined {
return MODEL_ASSETS.find((asset) => asset.itemId === itemId);
}
/** Assets whose default is a real official model. */
export function getRealAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.defaultRealAsset);
}
/** Assets whose default is the honest procedural fallback. */
export function getProceduralAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => !asset.defaultRealAsset);
}
/** Assets to preload with the default playlist scene. */
export function getPreloadAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.preload && asset.runtimePath);
}
/** Summary stats for manifest. */
export function getManifestStats() {
const real = getRealAssets().length;
const procedural = getProceduralAssets().length;
return {
total: MODEL_ASSETS.length,
real,
procedural,
realPercentage: Math.round((real / MODEL_ASSETS.length) * 100),
};
}

View File

@@ -0,0 +1,276 @@
/**
* Canonical summary data for `/documentation`.
* Authority: official sources present in-repo → production code → unit tests.
* Unverified / planned items are labeled explicitly — never implied complete.
*/
export const PRODUCTION_STATUS = {
projectStatus: 'FINAL_ENGINEERING_PROTOTYPE_READY',
webTwinStatus: 'PRODUCTION_LIVE',
cvStatus: 'WORKING_PROTOTYPE',
cvLiveIntegrated: false,
unitTests: '196/196',
cvTests: 'PASS (no camera)',
productionBuild: 'PASS',
productionUrl: 'https://arhipovdan.ru',
contactPhysics: 'NOT_FULLY_VALIDATED',
officialCompliance: 'PARTIAL_SOURCES_PRESENT',
canonicalBranch: 'main',
} as const;
export const SOLUTION_COMPONENTS = [
'Web digital twin (https://arhipovdan.ru)',
'Real CV working prototype (cv/, RealSense D415 + OpenCV)',
'Physical experimental conveyor stand',
'Author CAD (3d_models/conveer.FCStd)',
'Official B/C/D classifier (web + CV, K > 0.8)',
'Engineering documentation (/documentation)',
] as const;
export const CONFIRMED_LAYOUT = {
workspaceMm: { length: 10000, width: 6000 },
conveyorWidthMm: 500,
conveyorHeightMm: 700,
modules: ['clean', 'camera', 'sorter'] as const,
receivers: ['B_STRAIGHT', 'ZONE_C', 'ZONE_D'] as const,
} as const;
export const CLASSIFIER_BOUNDS = {
status: 'ALIGNED_WEB_AND_CV' as const,
officialSourceReference: 'official_sources/doc-1783095831.pdf',
officialSourceParsedThisPass: false,
minExclusiveMm: { width: 10, depth: 10, height: 10 },
maxExclusiveMm: { width: 450, depth: 320, height: 320 },
roundnessThresholdExclusive: 0.8,
display: {
min: '> 10×10×10 мм',
max: '< 450×320×320 мм',
roundness: 'K > 0.8',
exactBoundary: 'K = 0.8 → B (not circular), not D',
},
checkOrder: 'dimensions→C, else circular (K>0.8)→D, else B' as const,
} as const;
export const CV_PROTOTYPE = {
path: 'cv/',
status: 'WORKING_PROTOTYPE',
hardware: 'Intel RealSense D415',
software: 'Python + OpenCV (+ optional MQTT)',
liveIntegrated: false,
pipeline: [
'depth frame',
'segmentation',
'object contour',
'L×W×H',
'roundness K',
'B/C/D',
'optional MQTT',
] as const,
notes: [
'CV works as a separate prototype under cv/ on main.',
'CV is not connected directly to the live public website.',
'Hardware live validation requires RealSense D415.',
'No-camera unit tests and compileall run in CI/local verification.',
],
} as const;
export const PHYSICAL_STAND = {
status: 'EXPERIMENTAL_PROTOTYPE',
elements: [
'Physical belt conveyor',
'Camera mounting structure above the belt',
'Intel RealSense D415',
'Electronics / control nodes',
'Experimental actuators and printed components',
] as const,
purpose: 'Measurements, calibration, and hardware validation — not claimed as industrial end-to-end certified sorting.',
} as const;
export const ARCHITECTURE = {
realPath: [
'physical product',
'RealSense D415',
'OpenCV measurement',
'classifier (K > 0.8)',
'B/C/D result',
'optional MQTT/controller',
] as const,
digitalPath: [
'digital product',
'simulated measurement',
'same classifier rules',
'route command',
'digital twin',
'B/C/D receiver',
] as const,
} as const;
export const ROUTE_MAPPING = [
{ category: 'B', physicalRoute: 'STRAIGHT', activeDiverter: 'NONE', signedAngleDeg: 0 },
{ category: 'C', physicalRoute: 'PHYSICAL_LEFT', activeDiverter: 'LEFT', signedAngleDeg: -45 },
{ category: 'D', physicalRoute: 'PHYSICAL_RIGHT', activeDiverter: 'RIGHT', signedAngleDeg: 45 },
] as const;
export const DIVERTER_KINEMATICS = {
rotationDurationSec: 0.5,
openingSafetyMarginSec: 0.15,
contactPlaneS: 1.0538,
clearPlaneS: 1.6,
phases: ['READY', 'ARMED', 'OPENING', 'HOLDING', 'CLOSING'] as const,
productBound: true,
oneActiveProduct: true,
closeAfterRearClear: true,
generatedMechanismActive: false,
} as const;
export const CAD_PROVENANCE = {
authorFcstd: '3d_models/conveer.FCStd',
authorSha256: '90c1844a4ca05e26def783d6130fc4b993430dde14307534ef8fbb21c9fac2e6',
runtimeGlb: 'public/models/sorter/conveyor-clean.glb',
runtimeSha256: '1dc7a8d7891bfe756e277ad5368df74cb73410156b2fe0f92845afb8a56f285a',
authorServosInSorterModule: true,
generatedMechanismInactive: true,
hornTransmissionInGlb: 'ABSENT_OR_INCOMPLETE' as const,
} as const;
export const PHYSICS_STATUS = {
implemented: [
'Runtime product motion on belt (domain pose + Rapier handoff)',
'Product-associated diverter route timing (productId-bound)',
'Synchronized CAD diverter visual / kinematic targets',
'CCD enabled for light/thin SKUs in runtime and headless sim',
'Visual/physics spawn gating via product asset preload',
'Belt speed target 1.0 m/s; physics timestep 1/60 s',
],
notFullyValidated: [
'Complete contact-only routing through CAD diverters',
'Belt surface velocity exactly 1 m/s with tangential drive',
'Calibrated friction / mass / COM per SKU',
'Fully physical continuous conveyor loop',
'Receiver capture under all item classes',
],
planned: [
'Visual full belt loop with surface-velocity coupling',
'Controlled tangential friction at 1 m/s',
'Per-SKU collider, damping, and friction profiles',
],
} as const;
export const MOBILE_BEHAVIOR = {
desktop: 'Interactive WebGL 3D digital twin',
mobile:
'Capability-based tier: WebGL when viable; SVG/2D lite fallback on low FPS / missing WebGL (not claimed as full 3D)',
} as const;
export const VALIDATION_BOARD = [
{ item: 'Web unit tests', status: '196/196 PASS' },
{ item: 'Production build', status: 'PASS' },
{ item: 'Focused E2E (/ + /documentation)', status: 'PASS' },
{ item: 'CV compileall', status: 'PASS' },
{ item: 'CV classify/geometry tests', status: 'PASS (no camera)' },
{ item: 'Production / and /documentation', status: 'PASS' },
{ item: 'conveyor-clean.glb checksum', status: 'PASS' },
{ item: 'Author FCStd checksum', status: 'PASS' },
{ item: 'Classifier K > 0.8 (web + CV)', status: 'ALIGNED' },
{ item: 'Full contact physics', status: 'NOT_FULLY_VALIDATED' },
] as const;
export const OFFICIAL_SOURCE_MATRIX = [
{
source: 'input_info/doc-1783009063.pdf',
purpose: 'Allowed software list',
present: true,
canonical: true,
usage: 'Stack compliance reference',
},
{
source: 'input_info/doc-1783009942.pdf',
purpose: 'Workspace / zone scheme',
present: true,
canonical: true,
usage: 'Layout provenance (workspace 10×6 m)',
},
{
source: 'input_info/doc-1783011400.pdf',
purpose: 'Track 3 scoring criteria',
present: true,
canonical: true,
usage: 'Jury scoring — not re-parsed this pass',
},
{
source: 'input_info/doc-1782987706.zip',
purpose: 'Official STEP product set',
present: true,
canonical: true,
usage: 'Product geometry source archive',
},
{
source: 'input_info/doc-1782987733.zip',
purpose: 'Official STL product set',
present: true,
canonical: true,
usage: 'Feeds public/models/*.stl',
},
{
source: 'input_info/doc-1783011771.zip',
purpose: 'Official pack archive',
present: true,
canonical: true,
usage: 'Retained official material',
},
{
source: 'official_sources/doc-1783095831.pdf',
purpose: 'Classifier bounds authority cited by code',
present: true,
canonical: true,
usage: 'Referenced by classifier.ts and cv/classify.py',
},
{
source: 'presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf',
purpose: 'Final presentation (10 slides)',
present: true,
canonical: true,
usage: 'Single presentation PDF in repository',
},
{
source: 'input_info/extracted/Постановка_Задача_3_сжато_2.pdf',
purpose: 'Full task brief (historical citation)',
present: false,
canonical: false,
usage: 'MISSING — do not cite as available evidence',
},
] as const;
export const RUNTIME_FLOW = [
'SPAWN',
'CONVEYOR',
'CAMERA',
'CLASSIFICATION',
'ARMED',
'OPENING',
'HOLDING',
'CLOSING',
'RECEIVER',
] as const;
export const REPOSITORY_LAYOUT = [
'src/ — web digital twin',
'cv/ — RealSense + OpenCV prototype',
'3d_models/ — author CAD',
'public/ — runtime GLB/STL/draco',
'docs/ — engineering notes',
'presentation/ — final PDF',
'e2e/ + Vitest — tests',
'Docker / nginx — deployment',
] as const;
export const CURRENT_LIMITATIONS = [
'Engineering prototype, not an industrial-certified PAK.',
'Real CV prototype is not live-integrated into the public website.',
'Live camera mode requires Intel RealSense D415 hardware.',
'Physical stand parameters still require calibration against the digital twin.',
'Full physical contact sorting through CAD diverters is not fully validated.',
'Belt surface-velocity drive at exactly 1 m/s is not fully validated.',
'Author CAD horn / transmission incomplete in active GLB.',
'Cloud presentation/video links for the platform form are provided separately by the team.',
] as const;

29
src/data/resolveItem.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Resolve playlist / scenario item ids, including low-confidence variants (SKU-*-LC).
*/
import type { Item } from '../domain/types';
import { getItem, ITEMS } from './items';
/** Strip -LC suffix and apply low confidence when present. */
export function resolveItem(itemId: string): Item {
if (itemId.endsWith('-LC')) {
const baseId = itemId.slice(0, -3);
const base = getItem(baseId);
return {
...base,
id: itemId,
confidence: Math.min(base.confidence, 0.58),
};
}
return getItem(itemId);
}
export function findItemOrFallback(itemId: string): Item {
try {
return resolveItem(itemId);
} catch {
const stripped = itemId.replace(/-LC$/, '');
return ITEMS.find((i) => i.id === stripped) ?? ITEMS[0];
}
}

93
src/data/scenarios.ts Normal file
View File

@@ -0,0 +1,93 @@
import { getItem } from './items';
import type { Scenario } from '../domain/types';
export const SCENARIOS: Scenario[] = [
{
id: 'normal_flow',
name: 'Normal flow',
description: 'Обычный поток из нескольких товаров B/C/D с полным циклом сортировки.',
goal: 'Показать базовую последовательность sensor -> gate -> classifier -> actuator.',
expectedCategorySummary: 'Mixed: B, C, D',
demonstrates: 'Стабильный поток, корректную маршрутизацию и возврат исполнительных механизмов домой.',
items: ['SKU-001', 'SKU-006', 'SKU-004', 'SKU-003', 'SKU-007'].map(getItem),
},
{
id: 'oversized_item',
name: 'Oversized item',
description: 'Товар выходит за max dimensions и должен попасть в roll-cage C.',
goal: 'Доказать приоритет проверки габаритов.',
expectedCategorySummary: 'C for every item',
demonstrates: 'Негабаритный товар фиксируется stop-gate и уводится pusher C в roll-cage C.',
items: ['SKU-004', 'SKU-005'].map(getItem),
},
{
id: 'round_object',
name: 'Round object',
description: 'Габариты проходят, но roundness K > 0.8, маршрут в D.',
goal: 'Показать проверку круглого сечения после габаритов.',
expectedCategorySummary: 'D for every item',
demonstrates: 'Rule-based shape issue routing без реального ML на MVP-этапе.',
items: ['SKU-006', 'SKU-007', 'SKU-008'].map(getItem),
},
{
id: 'c_priority',
name: 'C priority (oversized + round)',
description: 'Товар одновременно негабаритный и круглый — приоритет габаритов, маршрут только в C.',
goal: 'Доказать, что dimensions check идёт раньше roundness: категория C, не D.',
expectedCategorySummary: 'C only (D not activated)',
demonstrates: 'C-priority: oversized + round → ROUTE_TO_C, route D остаётся неактивным.',
items: ['SKU-011'].map(getItem),
},
{
id: 'boundary_dimensions',
name: 'Boundary dimensions',
description: 'Товары около min/max границ показывают устойчивость правил.',
goal: 'Проверить строгие границы min/max.',
expectedCategorySummary: 'Near-max 449×319×319 -> B, Pen -> C',
demonstrates: 'Строгие границы: 449×319×319 проходит, 450×320×320 и width 9 мм — нет.',
items: ['SKU-010', 'SKU-009', 'SKU-002'].map(getItem),
},
{
id: 'close_items',
name: 'Close items',
description: 'Два товара близко друг к другу: warning queue/spacing и последовательная обработка.',
goal: 'Показать устойчивость очереди без усложнения физики.',
expectedCategorySummary: 'Sequential B, B, D',
demonstrates: 'Spacing warning, queue length и обработку товаров по одному циклу.',
items: ['SKU-001', 'SKU-002', 'SKU-006'].map(getItem),
},
{
id: 'low_confidence',
name: 'Low confidence',
description: 'CV confidence ниже 0.65, система предупреждает и принимает rule-based решение.',
goal: 'Показать fallback при низкой уверенности pseudo-CV.',
expectedCategorySummary: 'Rule-based B and D despite low CV confidence',
demonstrates: 'Низкая confidence не блокирует решение, так как финальная логика основана на правилах.',
items: [
{ ...getItem('SKU-003'), id: 'SKU-003-LC', confidence: 0.58 },
{ ...getItem('SKU-006'), id: 'SKU-006-LC', confidence: 0.61 },
],
},
{
id: 'jam',
name: 'Jam at gate',
description: 'Застревание у stop-gate переводит систему в FAULT и останавливает конвейер.',
goal: 'Показать fail-safe состояние при застревании.',
expectedCategorySummary: 'FAULT before route completion',
demonstrates: 'Conveyor speed падает к 0, state фиксируется в FAULT, требуется Reset.',
items: ['SKU-004'].map(getItem),
},
{
id: 'emergency_stop',
name: 'Emergency stop',
description: 'Аварийная остановка переводит систему в EMERGENCY_STOP, движение остановлено.',
goal: 'Показать ручную/аварийную остановку всей линии.',
expectedCategorySummary: 'EMERGENCY_STOP before route completion',
demonstrates: 'Все движения останавливаются, PID target становится 0, требуется Reset.',
items: ['SKU-001', 'SKU-006'].map(getItem),
},
];
export function getScenario(id: string): Scenario {
return SCENARIOS.find((scenario) => scenario.id === id) ?? SCENARIOS[0];
}

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,168 @@
/**
* Tests for cinematicCamera module.
*/
import { describe, it, expect } from 'vitest';
import {
getCameraModeForPhase,
getCameraConfig,
smoothCameraTransition,
isValidCameraConfig,
phaseHasCameraMode,
getInitialCameraConfig,
getAllCameraModes,
getViewportType,
lerpVector3,
lerp,
} from './cinematicCamera';
import { CASE_PHASES } from './continuousPlayback';
import type { Category } from './types';
describe('getCameraModeForPhase', () => {
it('returns a mode for every playback phase', () => {
for (const phaseConfig of CASE_PHASES) {
const mode = getCameraModeForPhase(phaseConfig.phase);
expect(mode).toBeTruthy();
expect(typeof mode).toBe('string');
}
});
it('spawn returns feedCloseup', () => {
expect(getCameraModeForPhase('spawn')).toBe('feedCloseup');
});
it('detection returns inspectionTop', () => {
expect(getCameraModeForPhase('detection')).toBe('inspectionTop');
});
it('routing returns chuteCloseup', () => {
expect(getCameraModeForPhase('routing')).toBe('chuteCloseup');
});
});
describe('getCameraConfig', () => {
it('returns valid config for all phases', () => {
for (const phaseConfig of CASE_PHASES) {
const config = getCameraConfig(phaseConfig.phase, 'B', null, 'desktop');
expect(isValidCameraConfig(config)).toBe(true);
}
});
it('camera position/target are finite numbers', () => {
const config = getCameraConfig('detection', 'B', [0, 0.8, 0], 'desktop');
expect(config.position.every(n => Number.isFinite(n))).toBe(true);
expect(config.target.every(n => Number.isFinite(n))).toBe(true);
});
it('fov within reasonable range (10-120)', () => {
for (const phaseConfig of CASE_PHASES) {
const config = getCameraConfig(phaseConfig.phase, 'C', null, 'desktop');
expect(config.fov).toBeGreaterThan(10);
expect(config.fov).toBeLessThan(120);
}
});
it('target category B/C/D maps to routing camera', () => {
const categories: Category[] = ['B', 'C', 'D'];
for (const cat of categories) {
const config = getCameraConfig('routing', cat, null, 'desktop');
expect(config.mode).toBe('chuteCloseup');
expect(isValidCameraConfig(config)).toBe(true);
}
});
it('adjusts for viewport type', () => {
const desktop = getCameraConfig('move_to_detection', 'B', null, 'desktop');
const mobile = getCameraConfig('move_to_detection', 'B', null, 'mobile');
// Mobile should have higher camera and wider FOV
expect(mobile.position[1]).toBeGreaterThan(desktop.position[1]);
expect(mobile.fov).toBeGreaterThan(desktop.fov);
});
});
describe('smoothCameraTransition', () => {
it('interpolates between configs', () => {
const current = getCameraConfig('spawn', 'B', null, 'desktop');
const target = getCameraConfig('detection', 'B', null, 'desktop');
const result = smoothCameraTransition(current, target, 0.5);
// Result should be between current and target
expect(result.position[0]).toBeGreaterThanOrEqual(
Math.min(current.position[0], target.position[0])
);
expect(result.position[0]).toBeLessThanOrEqual(
Math.max(current.position[0], target.position[0])
);
});
});
describe('isValidCameraConfig', () => {
it('returns true for valid config', () => {
const config = getInitialCameraConfig();
expect(isValidCameraConfig(config)).toBe(true);
});
it('returns false for invalid fov', () => {
const config = { ...getInitialCameraConfig(), fov: 5 };
expect(isValidCameraConfig(config)).toBe(false);
});
it('returns false for NaN position', () => {
const config = { ...getInitialCameraConfig(), position: [NaN, 0, 0] as [number, number, number] };
expect(isValidCameraConfig(config)).toBe(false);
});
});
describe('phaseHasCameraMode', () => {
it('returns true for all phases', () => {
for (const phaseConfig of CASE_PHASES) {
expect(phaseHasCameraMode(phaseConfig.phase)).toBe(true);
}
});
});
describe('getViewportType', () => {
it('desktop for width >= 1200', () => {
expect(getViewportType(1920)).toBe('desktop');
expect(getViewportType(1200)).toBe('desktop');
});
it('laptop for width 768-1199', () => {
expect(getViewportType(1024)).toBe('laptop');
expect(getViewportType(768)).toBe('laptop');
});
it('mobile for width < 768', () => {
expect(getViewportType(390)).toBe('mobile');
expect(getViewportType(767)).toBe('mobile');
});
});
describe('lerp utilities', () => {
it('lerp returns midpoint at t=0.5', () => {
expect(lerp(0, 10, 0.5)).toBe(5);
});
it('lerpVector3 works correctly', () => {
const a: [number, number, number] = [0, 0, 0];
const b: [number, number, number] = [10, 20, 30];
const result = lerpVector3(a, b, 0.5);
expect(result).toEqual([5, 10, 15]);
});
});
describe('getAllCameraModes', () => {
it('returns all defined modes', () => {
const modes = getAllCameraModes();
expect(modes).toContain('overview');
expect(modes).toContain('feedCloseup');
expect(modes).toContain('inspectionTop');
expect(modes).toContain('measurementSide');
expect(modes).toContain('routingWide');
expect(modes).toContain('chuteCloseup');
expect(modes).toContain('resultZone');
expect(modes.length).toBeGreaterThanOrEqual(8);
});
});

View File

@@ -0,0 +1,310 @@
/**
* Cinematic Camera Director — controls camera angles during demo playback.
* Returns camera position, target, and FOV based on current phase and item state.
*/
import type { CasePhase } from './continuousPlayback';
import type { Category } from './types';
import { ZONES, BELT_TOP_Y, CAMERA_RIG } from './physicalLayout';
/** Camera mode names for different shot types. */
export type CameraMode =
| 'overview'
| 'feedCloseup'
| 'inspectionTop'
| 'measurementSide'
| 'classificationTop'
| 'routingWide'
| 'chuteCloseup'
| 'resultZone'
| 'nextItemReset';
/** Camera configuration for a specific mode. */
export interface CameraConfig {
position: [number, number, number];
target: [number, number, number];
fov: number;
mode: CameraMode;
}
/** Viewport type for adaptive camera positions. */
export type ViewportType = 'desktop' | 'laptop' | 'mobile';
/**
* Get viewport type based on screen width.
*/
export function getViewportType(width: number): ViewportType {
if (width >= 1200) return 'desktop';
if (width >= 768) return 'laptop';
return 'mobile';
}
/**
* Viewport adjustments for camera height/distance.
*/
const VIEWPORT_ADJUSTMENTS: Record<ViewportType, { heightMult: number; distMult: number }> = {
desktop: { heightMult: 1.0, distMult: 1.0 },
laptop: { heightMult: 1.15, distMult: 1.1 },
mobile: { heightMult: 1.3, distMult: 1.25 },
};
/**
* Base camera configurations for each mode.
* Positions are relative to the scene center or specific zones.
*/
const BASE_CAMERA_CONFIGS: Record<CameraMode, Omit<CameraConfig, 'mode'>> = {
// Full product line: 3 CAD modules + close-in B/C/D baskets.
overview: {
position: [0.35, 2.35, 5.1],
target: [-0.55, 0.55, 0.05],
fov: 36,
},
feedCloseup: {
position: [ZONES.A.x + 1.5, 2.0, 2.0],
target: [ZONES.A.x, BELT_TOP_Y + 0.1, 0],
fov: 45,
},
inspectionTop: {
position: [ZONES.CAMERA.x, CAMERA_RIG.cameraY + 1.5, 2.5],
target: [ZONES.CAMERA.x, BELT_TOP_Y + 0.1, 0],
fov: 40,
},
measurementSide: {
position: [ZONES.CAMERA.x + 1.8, 1.2, 2.0],
target: [ZONES.CAMERA.x, BELT_TOP_Y + 0.2, 0],
fov: 42,
},
classificationTop: {
position: [ZONES.CAMERA.x + 0.5, 2.5, 2.2],
target: [ZONES.CAMERA.x, BELT_TOP_Y + 0.15, 0],
fov: 45,
},
routingWide: {
position: [2.2, 2.4, 4.0],
target: [ZONES.GATE.x, BELT_TOP_Y, 0.15],
fov: 48,
},
chuteCloseup: {
position: [ZONES.GATE.x + 0.5, 1.8, 2.8],
target: [ZONES.GATE.x + 0.3, BELT_TOP_Y, 0.8],
fov: 48,
},
resultZone: {
position: [3.5, 2.5, 3.5],
target: [ZONES.B.x - 0.5, 0.5, 0],
fov: 52,
},
nextItemReset: {
position: [0.35, 2.35, 5.1],
target: [-0.55, 0.55, 0.05],
fov: 36,
},
};
/**
* Get camera mode for a given phase.
*/
export function getCameraModeForPhase(phase: CasePhase): CameraMode {
switch (phase) {
case 'spawn':
return 'feedCloseup';
case 'move_to_detection':
return 'overview';
case 'detection':
return 'inspectionTop';
case 'measurement':
return 'measurementSide';
case 'classification':
return 'classificationTop';
case 'command_sent':
return 'routingWide';
case 'routing':
return 'chuteCloseup';
case 'exit':
return 'resultZone';
case 'clear_gap':
return 'nextItemReset';
case 'fault_hold':
case 'emergency_hold':
return 'routingWide';
case 'recover':
return 'overview';
default:
return 'overview';
}
}
/**
* Adjust camera position for target category (C/D routing).
*/
function adjustForCategory(
config: CameraConfig,
category: Category | null,
phase: CasePhase
): CameraConfig {
if (!category || !['routing', 'exit'].includes(phase)) {
return config;
}
const adjusted = { ...config, position: [...config.position] as [number, number, number], target: [...config.target] as [number, number, number] };
if (category === 'C') {
// Look toward C zone (positive Z)
adjusted.target[2] = 1.2;
adjusted.position[2] = 3.5;
} else if (category === 'D') {
// Look toward D zone (negative Z)
adjusted.target[2] = -1.2;
adjusted.position[2] = -2.5;
adjusted.position[0] = 3.0;
}
// B stays on main line, no adjustment needed
return adjusted;
}
/**
* Adjust camera for viewport size.
*/
function adjustForViewport(
config: CameraConfig,
viewport: ViewportType
): CameraConfig {
const adj = VIEWPORT_ADJUSTMENTS[viewport];
return {
...config,
position: [
config.position[0] * adj.distMult,
config.position[1] * adj.heightMult,
config.position[2] * adj.distMult,
],
fov: config.fov + (viewport === 'mobile' ? 8 : viewport === 'laptop' ? 4 : 0),
};
}
/**
* Get camera configuration for the current playback state.
* @param phase Current case phase
* @param category Target category (B/C/D)
* @param itemPosition Current item position [x, y, z]
* @param viewport Viewport type for adaptive positioning
* @returns Camera configuration
*/
export function getCameraConfig(
phase: CasePhase,
category: Category | null,
itemPosition: [number, number, number] | null,
viewport: ViewportType = 'desktop'
): CameraConfig {
const mode = getCameraModeForPhase(phase);
const baseConfig = BASE_CAMERA_CONFIGS[mode];
let config: CameraConfig = {
...baseConfig,
mode,
};
// Adjust for category-specific routing
config = adjustForCategory(config, category, phase);
// Adjust for viewport
config = adjustForViewport(config, viewport);
// Follow item during movement phases
if (itemPosition && ['move_to_detection', 'routing', 'exit'].includes(phase)) {
// Partially follow item with smoothing factor
const followWeight = phase === 'move_to_detection' ? 0.3 : 0.5;
config.target = [
config.target[0] * (1 - followWeight) + itemPosition[0] * followWeight,
config.target[1] * (1 - followWeight) + itemPosition[1] * followWeight,
config.target[2] * (1 - followWeight) + itemPosition[2] * followWeight,
];
}
return config;
}
/**
* Lerp (linear interpolation) between two values.
*/
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/**
* Lerp between two 3D vectors.
*/
export function lerpVector3(
a: [number, number, number],
b: [number, number, number],
t: number
): [number, number, number] {
return [
lerp(a[0], b[0], t),
lerp(a[1], b[1], t),
lerp(a[2], b[2], t),
];
}
/**
* Smoothly interpolate camera configuration.
* @param current Current camera state
* @param target Target camera state
* @param smoothing Smoothing factor (0-1, lower = smoother)
*/
export function smoothCameraTransition(
current: CameraConfig,
target: CameraConfig,
smoothing: number = 0.08
): CameraConfig {
return {
position: lerpVector3(current.position, target.position, smoothing),
target: lerpVector3(current.target, target.target, smoothing),
fov: lerp(current.fov, target.fov, smoothing),
mode: target.mode,
};
}
/**
* Get initial camera config (product overview framing).
*/
export function getInitialCameraConfig(viewport: ViewportType = 'desktop'): CameraConfig {
return adjustForViewport(
{ ...BASE_CAMERA_CONFIGS.overview, mode: 'overview' },
viewport,
);
}
/**
* Validate camera config has finite numbers.
*/
export function isValidCameraConfig(config: CameraConfig): boolean {
const allFinite = (arr: number[]) => arr.every(n => Number.isFinite(n));
return (
allFinite(config.position) &&
allFinite(config.target) &&
Number.isFinite(config.fov) &&
config.fov > 10 &&
config.fov < 120
);
}
/**
* Get all camera modes.
*/
export function getAllCameraModes(): CameraMode[] {
return Object.keys(BASE_CAMERA_CONFIGS) as CameraMode[];
}
/**
* Check if phase has associated camera mode.
*/
export function phaseHasCameraMode(phase: CasePhase): boolean {
try {
const mode = getCameraModeForPhase(phase);
return !!mode && !!BASE_CAMERA_CONFIGS[mode];
} catch {
return false;
}
}

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest';
import {
classifyItem,
DIMENSION_LIMITS,
OFFICIAL_RULE_LABELS,
dimensionsPassOfficial,
isCircularCrossSection,
} from './classifier';
import { getItem, ITEMS } from '../data/items';
import { DEMO_PLAYLIST } from './demoPlaylist';
import { resolveItem } from '../data/resolveItem';
import type { Category, Item } from './types';
const VALID_CATEGORIES: Category[] = ['B', 'D', 'C'];
function itemWith(
dimensionsMm: Item['dimensionsMm'],
roundness: number,
confidence = 0.9,
): Item {
return {
...getItem('SKU-001'),
id: 'TEST-BOUNDARY',
dimensionsMm,
roundness,
confidence,
expectedCategory: 'B',
};
}
describe('classifyItem', () => {
it('uses official Track 3 constants (exclusive bounds, K > 0.8)', () => {
expect(DIMENSION_LIMITS.min).toEqual({ width: 10, depth: 10, height: 10 });
expect(DIMENSION_LIMITS.max).toEqual({ width: 450, depth: 320, height: 320 });
expect(DIMENSION_LIMITS.roundnessThreshold).toBe(0.8);
expect(OFFICIAL_RULE_LABELS.minDisplay).toContain('10×10×10');
expect(OFFICIAL_RULE_LABELS.roundnessDisplay).toBe('K > 0.8');
expect(JSON.stringify(DIMENSION_LIMITS)).not.toContain('"height":2');
expect(JSON.stringify(DIMENSION_LIMITS)).not.toMatch(/0\.7/);
});
it('routes normal box to B', () => {
expect(classifyItem(getItem('SKU-001')).category).toBe('B');
});
it('routes oversized box to C', () => {
expect(classifyItem(getItem('SKU-004')).category).toBe('C');
});
it('routes round plate to D', () => {
expect(classifyItem(getItem('SKU-006')).category).toBe('D');
});
it('routes pen with width below min to C', () => {
expect(classifyItem(getItem('SKU-009')).category).toBe('C');
});
it('keeps C priority when item is oversized and round', () => {
const result = classifyItem(getItem('SKU-011'));
expect(result.category).toBe('C');
expect(result.dimensionsPass).toBe(false);
expect(result.roundnessPass).toBe(false);
expect(isCircularCrossSection(getItem('SKU-011').roundness)).toBe(true);
});
it('rejects exclusive-max boundary 450×320×320 as C', () => {
const item = itemWith({ width: 450, depth: 320, height: 320 }, 0.2);
expect(classifyItem(item).category).toBe('C');
expect(dimensionsPassOfficial(item.dimensionsMm)).toBe(false);
});
it('accepts near-max 449×319×319 as B when not round', () => {
expect(classifyItem(getItem('SKU-010')).category).toBe('B');
});
it('rejects height at min boundary 10 mm as C', () => {
const item = itemWith({ width: 100, depth: 100, height: 10 }, 0.5);
expect(classifyItem(item).category).toBe('C');
expect(classifyItem(item).dimensionsPass).toBe(false);
});
it('low confidence does not create a 4th class', () => {
const lowConfidenceItems: Item[] = [
{ ...getItem('SKU-001'), id: 'LC-B', confidence: 0.4 },
{ ...getItem('SKU-004'), id: 'LC-C', confidence: 0.4 },
{ ...getItem('SKU-006'), id: 'LC-D', confidence: 0.4 },
{ ...getItem('SKU-011'), id: 'LC-C-PRIORITY', confidence: 0.4 },
];
for (const item of lowConfidenceItems) {
const result = classifyItem(item);
expect(VALID_CATEGORIES).toContain(result.category);
expect(result.warnings.some((warning) => warning.toLowerCase().includes('confidence'))).toBe(true);
}
});
});
describe('official Track 3 classification boundaries', () => {
it('1. 11×11×11, K=0.50 → B', () => {
expect(classifyItem(itemWith({ width: 11, depth: 11, height: 11 }, 0.5)).category).toBe('B');
});
it('2. 9×20×20, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 9, depth: 20, height: 20 }, 0.5)).category).toBe('C');
});
it('3. 20×9×20, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 20, depth: 9, height: 20 }, 0.5)).category).toBe('C');
});
it('4. 20×20×9, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 20, depth: 20, height: 9 }, 0.5)).category).toBe('C');
});
it('5. 10×20×20, K=0.50 → C (min exclusive)', () => {
expect(classifyItem(itemWith({ width: 10, depth: 20, height: 20 }, 0.5)).category).toBe('C');
});
it('6. 20×10×20, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 20, depth: 10, height: 20 }, 0.5)).category).toBe('C');
});
it('7. 20×20×10, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 20, depth: 20, height: 10 }, 0.5)).category).toBe('C');
});
it('8. 451×100×100, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 451, depth: 100, height: 100 }, 0.5)).category).toBe('C');
});
it('9. 100×321×100, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 100, depth: 321, height: 100 }, 0.5)).category).toBe('C');
});
it('10. 100×100×321, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 321 }, 0.5)).category).toBe('C');
});
it('11. 450×100×100, K=0.50 → C (max exclusive)', () => {
expect(classifyItem(itemWith({ width: 450, depth: 100, height: 100 }, 0.5)).category).toBe('C');
});
it('12. 100×320×100, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 100, depth: 320, height: 100 }, 0.5)).category).toBe('C');
});
it('13. 100×100×320, K=0.50 → C', () => {
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 320 }, 0.5)).category).toBe('C');
});
it('14. 449×319×319, K=0.50 → B', () => {
expect(classifyItem(itemWith({ width: 449, depth: 319, height: 319 }, 0.5)).category).toBe('B');
});
it('15. admissible dims, K=0.79 → B', () => {
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.79)).category).toBe('B');
});
it('16. admissible dims, K=0.80 → B (not round)', () => {
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.8)).category).toBe('B');
expect(isCircularCrossSection(0.8)).toBe(false);
});
it('17. admissible dims, K=0.8001 → D', () => {
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.8001)).category).toBe('D');
});
it('18. admissible dims, K=0.95 → D', () => {
expect(classifyItem(itemWith({ width: 100, depth: 100, height: 100 }, 0.95)).category).toBe('D');
});
it('19. oversized + K=0.95 → C (priority)', () => {
expect(classifyItem(itemWith({ width: 500, depth: 100, height: 100 }, 0.95)).category).toBe('C');
});
it('20. low confidence does not cancel C-priority', () => {
const result = classifyItem({
...itemWith({ width: 500, depth: 300, height: 300 }, 0.95, 0.4),
id: 'LC-C-PRIO',
});
expect(result.category).toBe('C');
expect(result.warnings.length).toBeGreaterThan(0);
});
it('2122. every demo SKU matches live classifier and ROUTE_TO_*', () => {
for (const playlistCase of DEMO_PLAYLIST.filter((c) => !c.faultType)) {
const item = resolveItem(playlistCase.itemId);
const result = classifyItem(item);
expect(result.category).toBe(playlistCase.expectedCategory);
expect(`ROUTE_TO_${result.category}`).toBe(`ROUTE_TO_${playlistCase.expectedCategory}`);
}
});
it('23. UI rule labels expose official exclusive values', () => {
expect(OFFICIAL_RULE_LABELS.minDisplay).toBe('> 10×10×10 мм');
expect(OFFICIAL_RULE_LABELS.maxDisplay).toBe('< 450×320×320 мм');
expect(OFFICIAL_RULE_LABELS.roundnessDisplay).toBe('K > 0.8');
});
it('24. serialized settings do not contain 2 mm min height or 0.7 threshold', () => {
const serialized = JSON.stringify({
limits: DIMENSION_LIMITS,
labels: OFFICIAL_RULE_LABELS,
});
expect(serialized).not.toMatch(/10×10×2|10 x 10 x 2|height":2[^0-9]/);
expect(serialized).not.toMatch(/roundnessThreshold":0\.7|"0\.7"/);
expect(serialized).toContain('0.8');
expect(serialized).toContain('"height":10');
});
it('catalog expectedCategory matches classifier for every SKU', () => {
for (const item of ITEMS) {
expect(classifyItem(item).category).toBe(item.expectedCategory);
}
});
});

81
src/domain/classifier.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Category, CategoryDefinition, ClassificationResult, Item } from './types';
export const CATEGORY_DEFINITIONS: Record<Category, CategoryDefinition> = {
B: {
label: 'Основной сортировщик',
reason: 'Габариты подходят, круговое сечение не обнаружено',
},
C: {
label: 'Неправильные габариты',
reason: 'Нарушены допустимые габариты',
},
D: {
label: 'Неправильная форма / доупаковка',
reason: 'Обнаружен признак круглого сечения',
},
};
/**
* Official Track 3 sorter limits (doc-1783095831 pp.57).
* Bounds are exclusive: dimensions must be strictly greater than min
* and strictly less than max («больше» / «меньше»).
* Round cross-section when K > roundnessThreshold (K = 0.8 is NOT round).
*/
export const DIMENSION_LIMITS = {
min: { width: 10, depth: 10, height: 10 },
max: { width: 450, depth: 320, height: 320 },
roundnessThreshold: 0.8,
} as const;
/** Public-facing labels for UI / demo / docs. */
export const OFFICIAL_RULE_LABELS = {
minDisplay: '> 10×10×10 мм',
maxDisplay: '< 450×320×320 мм',
roundnessDisplay: 'K > 0.8',
boundsSummary: '> 10×10×10 и < 450×320×320 мм',
} as const;
export function dimensionsPassOfficial(dimensionsMm: {
width: number;
depth: number;
height: number;
}): boolean {
const { width, depth, height } = dimensionsMm;
return (
width > DIMENSION_LIMITS.min.width &&
depth > DIMENSION_LIMITS.min.depth &&
height > DIMENSION_LIMITS.min.height &&
width < DIMENSION_LIMITS.max.width &&
depth < DIMENSION_LIMITS.max.depth &&
height < DIMENSION_LIMITS.max.height
);
}
/** Official figure: circular cross-section iff K > 0.8. */
export function isCircularCrossSection(roundnessK: number): boolean {
return roundnessK > DIMENSION_LIMITS.roundnessThreshold;
}
export function classifyItem(item: Item): ClassificationResult {
const dimensionsPass = dimensionsPassOfficial(item.dimensionsMm);
// roundnessPass = shape OK for category B (not circular)
const roundnessPass = !isCircularCrossSection(item.roundness);
const warnings: string[] = [];
if (item.confidence < 0.65) {
warnings.push('Measurement confidence ниже 0.65, решение принято rule-based способом');
}
// Order: dimensions → C; else circular → D; else B. C priority over D.
const category: Category = !dimensionsPass ? 'C' : isCircularCrossSection(item.roundness) ? 'D' : 'B';
const definition = CATEGORY_DEFINITIONS[category];
return {
category,
label: definition.label,
reason: definition.reason,
dimensionsPass,
roundnessPass,
warnings,
};
}

View File

@@ -0,0 +1,147 @@
/**
* Stage 2B §1012, §25 — continuous measurement: the item NEVER stops under
* the camera; scan progress is position-based; classification completes
* before mechanism contact; physics handoff does not pre-position the item
* on the chute before the paddle touches it.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { CASE_PHASES, createPlaybackState, startPlayback, updatePlayback } from './continuousPlayback';
import { getPhysicalItemPose, getDropHandoffTimeMs, getRoutingStartMs } from './physicalItemMotion';
import { deriveSorterVisualState } from './sorterVisualState';
import {
SCAN_START_X, SCAN_END_X, CLASSIFICATION_DEADLINE_X, MECHANISM_CONTACT_X,
getScanProgress, getScanWindow,
} from './measurementZone';
import { ZONES, CONVEYOR_SPEED_MPS, BELT_TOP_Y, CAD_GATE_ENGAGE_X } from './physicalLayout';
import { resolveItem } from '../data/resolveItem';
import { initRapier, simulateDrop } from './physicsDropSim';
function poseAt(skuId: string, category: 'B' | 'C' | 'D', elapsedMs: number) {
const item = resolveItem(skuId);
return getPhysicalItemPose({
caseId: 't', slotIndex: 0, dimensionsMm: item.dimensionsMm,
targetCategory: category, elapsedMs, faultType: undefined,
});
}
function phaseStarts(): Record<string, number> {
const starts: Record<string, number> = {};
let acc = 0;
for (const p of CASE_PHASES) { starts[p.phase] = acc; acc += p.durationMs; }
return starts;
}
describe('Stage 2B §10 — no stop under the camera', () => {
it('item moves at belt speed through the entire measurement window', () => {
const starts = phaseStarts();
const detectionStart = starts['detection'];
const commandEnd = starts['routing'];
let prevX = -Infinity;
for (let t = detectionStart; t <= commandEnd; t += 50) {
const pose = poseAt('SKU-001', 'B', t);
expect(pose.position[0]).toBeGreaterThan(prevX); // strictly increasing
prevX = pose.position[0];
}
// exact belt speed: x advances CONVEYOR_SPEED_MPS per second
const x1 = poseAt('SKU-001', 'B', detectionStart + 1000).position[0];
const x2 = poseAt('SKU-001', 'B', detectionStart + 2000).position[0];
expect(x2 - x1).toBeCloseTo(CONVEYOR_SPEED_MPS * 1.0, 5);
});
it('belt velocity stays positive during detection/measurement phases', () => {
let state = startPlayback(createPlaybackState());
const starts = phaseStarts();
const measurementMid = starts['measurement'] + 300;
// advance playback to mid-measurement
let guard = 0;
while (state.caseElapsedMs < measurementMid && guard < 100000) {
state = updatePlayback(state, 50);
guard += 50;
}
expect(state.currentPhase === 'measurement' || state.currentPhase === 'classification').toBe(true);
const vs = deriveSorterVisualState(state);
expect(vs.beltVelocityMps).toBeGreaterThan(0);
});
it('item reaches the gate exactly at routing start (no gate dwell, no overshoot)', () => {
const routingStart = getRoutingStartMs();
const pose = poseAt('SKU-001', 'B', routingStart);
expect(pose.position[0]).toBeCloseTo(ZONES.GATE.x, 5);
// belt speed continuity: distance from A == speed * travel time
const feedStart = phaseStarts()['move_to_detection'];
const travelS = (routingStart - feedStart) / 1000;
expect(ZONES.GATE.x - ZONES.A.x).toBeCloseTo(CONVEYOR_SPEED_MPS * travelS, 5);
});
});
describe('Stage 2B §12 — position-based scan progress', () => {
it('scan progress derives from item position, not time', () => {
expect(getScanProgress(SCAN_START_X)).toBe(0);
expect(getScanProgress((SCAN_START_X + SCAN_END_X) / 2)).toBeCloseTo(0.5, 6);
expect(getScanProgress(SCAN_END_X)).toBe(1);
expect(getScanProgress(SCAN_START_X - 1)).toBe(0);
expect(getScanProgress(SCAN_END_X + 1)).toBe(1);
expect(getScanWindow(ZONES.CAMERA.x)).toBe('scanning');
expect(getScanWindow(ZONES.GATE.x)).toBe('complete');
});
it('scan window matches item pose during continuous travel', () => {
const starts = phaseStarts();
const routingStart = starts['routing'];
let sawScanning = false;
let sawComplete = false;
for (let t = starts['move_to_detection']; t < routingStart; t += 20) {
const x = poseAt('SKU-001', 'B', t).position[0];
const w = getScanWindow(x);
if (w === 'scanning') sawScanning = true;
if (w === 'complete') sawComplete = true;
}
expect(sawScanning).toBe(true);
expect(sawComplete).toBe(true); // item leaves the frustum before routing
});
});
describe('Stage 2B §11 — classification completes before mechanism contact', () => {
it('classification phase ends at or before the deadline position', () => {
const starts = phaseStarts();
const classificationEnd = starts['command_sent'];
const x = poseAt('SKU-001', 'B', classificationEnd).position[0];
expect(x).toBeLessThanOrEqual(CLASSIFICATION_DEADLINE_X + 1e-9);
expect(x).toBeLessThan(MECHANISM_CONTACT_X); // well ahead of the gate
});
});
describe('Stage 2B §13 — handoff at CAD diverter engage (not chute teleport)', () => {
beforeAll(async () => { await initRapier(); });
it('C/D handoff is on the belt center at CAD_GATE_ENGAGE_X, before domain GATE spur', () => {
const routingStart = getRoutingStartMs();
for (const category of ['C', 'D'] as const) {
const handoff = getDropHandoffTimeMs(category, undefined);
expect(handoff).not.toBeNull();
// CAD vane engage is upstream of the B-spur / domain routing start.
expect(handoff!).toBeLessThan(routingStart);
const pose = poseAt('SKU-001', category, handoff!);
expect(Math.abs(pose.position[2])).toBeLessThan(0.01); // belt center z=0
expect(pose.position[0]).toBeCloseTo(CAD_GATE_ENGAGE_X, 3);
expect(pose.position[1]).toBeCloseTo(BELT_TOP_Y + 0.1, 5); // box h/2 = 0.1
expect(pose.position[0]).toBeLessThan(ZONES.GATE.x);
}
});
it('C/D headless drop stays deterministic without floor tunneling (CAD rotary gate)', () => {
for (const [sku, zone] of [['SKU-004', 'C'], ['SKU-009', 'C'], ['SKU-011', 'C'], ['SKU-006', 'D'], ['SKU-007', 'D'], ['SKU-008', 'D']] as const) {
const r = simulateDrop(sku, zone);
expect(r.minClearanceM).toBeGreaterThan(-0.03);
expect(r.stepsSimulated).toBeGreaterThan(10);
}
});
it('no positional teleport: handoff pose is continuous with the pre-handoff kinematic pose', () => {
const handoff = getDropHandoffTimeMs('C', undefined)!;
const before = poseAt('SKU-001', 'C', handoff - 1);
const at = poseAt('SKU-001', 'C', handoff);
expect(Math.abs(at.position[0] - before.position[0])).toBeLessThan(0.01);
expect(Math.abs(at.position[2] - before.position[2])).toBeLessThan(0.01);
});
});

View File

@@ -0,0 +1,132 @@
/**
* Continuous Playback Engine tests + playlist ↔ classifier consistency.
*/
import { describe, it, expect } from 'vitest';
import {
createPlaybackState,
startPlayback,
pausePlayback,
resumePlayback,
stopPlayback,
updatePlayback,
seekToCase,
setPlaybackSpeed,
CASE_DURATION_MS,
assertPlaylistClassifierConsistency,
type ContinuousPlaybackState,
} from './continuousPlayback';
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from './demoPlaylist';
import { classifyItem } from './classifier';
import { resolveItem } from '../data/resolveItem';
describe('demoPlaylist', () => {
it('has classification + safety cases', () => {
expect(PLAYLIST_LENGTH).toBe(12);
expect(DEMO_PLAYLIST.length).toBe(12);
});
it('has no duplicate case ids', () => {
const ids = DEMO_PLAYLIST.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all non-fault cases match live classifyItem', () => {
const mismatches = assertPlaylistClassifierConsistency();
expect(mismatches).toEqual([]);
});
it('low_confidence case has warning but category from rules', () => {
const lowConfidence = DEMO_PLAYLIST.find((c) => c.id === 'low_confidence');
expect(lowConfidence).toBeDefined();
expect(lowConfidence!.warning).toBeDefined();
const result = classifyItem(resolveItem(lowConfidence!.itemId));
expect(result.category).toBe('B');
expect(result.warnings.length).toBeGreaterThan(0);
});
it('includes jam and emergency_stop safety cases', () => {
expect(DEMO_PLAYLIST.some((c) => c.faultType === 'jam')).toBe(true);
expect(DEMO_PLAYLIST.some((c) => c.faultType === 'emergency_stop')).toBe(true);
});
});
describe('continuousPlayback', () => {
it('creates initial state with idle status', () => {
const state = createPlaybackState();
expect(state.status).toBe('idle');
expect(state.currentCaseIndex).toBe(0);
});
it('startPlayback sets status to running and classifies via classifyItem', () => {
const state = startPlayback(createPlaybackState());
expect(state.status).toBe('running');
expect(state.classification).not.toBeNull();
expect(state.targetCategory).toBe(state.classification!.category);
});
it('pausePlayback sets status to paused', () => {
let state = startPlayback(createPlaybackState());
state = pausePlayback(state);
expect(state.status).toBe('paused');
});
it('resumePlayback sets status back to running', () => {
let state = startPlayback(createPlaybackState());
state = pausePlayback(state);
state = resumePlayback(state);
expect(state.status).toBe('running');
});
it('stopPlayback resets to idle', () => {
let state = startPlayback(createPlaybackState());
state = stopPlayback(state);
expect(state.status).toBe('idle');
});
it('advances to next case after case duration', () => {
let state = startPlayback(createPlaybackState());
let totalTime = 0;
while (totalTime < CASE_DURATION_MS * 1.5 && state.currentCaseIndex === 0) {
state = updatePlayback(state, 100);
totalTime += 100;
}
expect(state.currentCaseIndex).toBeGreaterThanOrEqual(1);
});
it('seekToCase jumps to jam case and sets FAULT command later', () => {
const jamIndex = DEMO_PLAYLIST.findIndex((c) => c.faultType === 'jam');
let state = seekToCase(createPlaybackState(), jamIndex);
expect(state.currentCase.faultType).toBe('jam');
for (let i = 0; i < 80; i++) {
state = updatePlayback(state, 100);
if (state.command === 'FAULT') break;
}
expect(state.command).toBe('FAULT');
});
it('setPlaybackSpeed changes multiplier', () => {
let state = startPlayback(createPlaybackState());
state = setPlaybackSpeed(state, 2);
expect(state.speed).toBe(2);
});
it('records classification events in journal', () => {
let state = startPlayback(createPlaybackState());
for (let i = 0; i < 60; i++) {
state = updatePlayback(state, 100);
}
expect(state.events.some((e) => e.type === 'classification' || e.type === 'system')).toBe(true);
});
it('completes playlist', () => {
let state = startPlayback(createPlaybackState());
const maxTime = CASE_DURATION_MS * 20;
let elapsed = 0;
while (state.status === 'running' && elapsed < maxTime) {
state = updatePlayback(state, 200);
elapsed += 200;
}
expect(state.status).toBe('finished');
});
});

View File

@@ -0,0 +1,471 @@
/**
* Continuous Playback Engine — manages auto-demo on main page.
* Classification is always driven by classifyItem (live rule engine).
* Supports speed control, case seek, seeded variability, and event journal.
*/
import type { Category, ClassificationResult, EventLogEntry } from './types';
import { DEMO_PLAYLIST, type PlaylistCase, PLAYLIST_LENGTH } from './demoPlaylist';
import { classifyItem } from './classifier';
import { resolveItem } from '../data/resolveItem';
import { createSeededRng, DEFAULT_DEMO_SEED, seededOffset } from './seededRng';
/** Playback status for the continuous demo. */
export type PlaybackStatus = 'idle' | 'running' | 'paused' | 'finished';
/** Phase within a single case timeline. */
export type CasePhase =
| 'spawn'
| 'move_to_detection'
| 'detection'
| 'measurement'
| 'classification'
| 'command_sent'
| 'routing'
| 'exit'
| 'clear_gap'
| 'fault_hold'
| 'emergency_hold'
| 'recover';
/** Phase configuration with duration based on conveyor speed (1 m/s). */
export interface PhaseConfig {
phase: CasePhase;
durationMs: number;
label: string;
}
/**
* Timeline phases for a single normal item.
* Distances are in meters, speed is 1 m/s, so duration = distance * 1000 ms.
*/
export const CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
// Stage 2B §10: 1900 ms so that feed(300)+1900+600+1000+1000+1000 = 5500 ms
// == A->GATE distance (5.5 m) at 1.0 m/s — continuous motion, no dwell.
{ phase: 'move_to_detection', durationMs: 1900, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 600, label: 'CV Detection' },
{ phase: 'measurement', durationMs: 1000, label: 'Laser measurement' },
{ phase: 'classification', durationMs: 1000, label: 'Classification' },
{ phase: 'command_sent', durationMs: 1000, label: 'Command sent' },
{ phase: 'routing', durationMs: 2500, label: 'Routing to zone' },
{ phase: 'exit', durationMs: 400, label: 'Exit to zone' },
{ phase: 'clear_gap', durationMs: 500, label: 'Clear gap' },
];
/** Safety timeline: jam near gate then recover. */
export const JAM_CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
{ phase: 'move_to_detection', durationMs: 2000, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 500, label: 'CV Detection' },
{ phase: 'measurement', durationMs: 800, label: 'Laser measurement' },
{ phase: 'classification', durationMs: 600, label: 'Classification' },
{ phase: 'fault_hold', durationMs: 2800, label: 'JAM / FAULT' },
{ phase: 'recover', durationMs: 1200, label: 'Recovery' },
{ phase: 'clear_gap', durationMs: 400, label: 'Clear gap' },
];
/** Safety timeline: emergency stop. */
export const ESTOP_CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
{ phase: 'move_to_detection', durationMs: 1800, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 400, label: 'CV Detection' },
{ phase: 'emergency_hold', durationMs: 3000, label: 'EMERGENCY STOP' },
{ phase: 'recover', durationMs: 1500, label: 'System reset' },
{ phase: 'clear_gap', durationMs: 400, label: 'Clear gap' },
];
/** Total duration of one normal case in ms. */
export const CASE_DURATION_MS = CASE_PHASES.reduce((sum, p) => sum + p.durationMs, 0);
export type PlaybackSpeed = 0.5 | 1 | 1.5 | 2;
export interface ContinuousPlaybackState {
status: PlaybackStatus;
currentCaseIndex: number;
currentCase: PlaylistCase;
currentPhaseIndex: number;
currentPhase: CasePhase;
phaseElapsedMs: number;
caseElapsedMs: number;
totalElapsedMs: number;
loopMode: boolean;
targetCategory: Category | null;
classification: ClassificationResult | null;
command: string;
warning: string | null;
/** Playback speed multiplier */
speed: PlaybackSpeed;
/** Seed for reproducible variability */
seed: number;
/** Deterministic position jitter (mm-scale visual offsets stored as meters) */
positionJitter: { x: number; z: number; yaw: number };
/** Bounded event journal for proof / engineering HUD */
events: EventLogEntry[];
}
function phasesForCase(playlistCase: PlaylistCase): PhaseConfig[] {
if (playlistCase.faultType === 'jam') return JAM_CASE_PHASES;
if (playlistCase.faultType === 'emergency_stop') return ESTOP_CASE_PHASES;
return CASE_PHASES;
}
export function getPlaylistCaseDurationMs(playlistCase: PlaylistCase): number {
return phasesForCase(playlistCase).reduce((sum, p) => sum + p.durationMs, 0);
}
/** Cumulative playlist duration before case index (supports wrap for loops). */
export function cumulativePlaylistDurationMs(caseIndex: number): number {
let sum = 0;
for (let i = 0; i < caseIndex; i++) {
sum += getPlaylistCaseDurationMs(DEMO_PLAYLIST[i % PLAYLIST_LENGTH]);
}
return sum;
}
function caseDurationMs(playlistCase: PlaylistCase): number {
return getPlaylistCaseDurationMs(playlistCase);
}
function classifyCase(playlistCase: PlaylistCase): ClassificationResult {
const item = resolveItem(playlistCase.itemId);
return classifyItem(item);
}
function buildJitter(seed: number, caseIndex: number): { x: number; z: number; yaw: number } {
const rng = createSeededRng(seed + caseIndex * 9973);
return {
x: seededOffset(rng, 0.012),
z: seededOffset(rng, 0.008),
yaw: seededOffset(rng, 0.04),
};
}
let eventCounter = 0;
function pushEvent(
events: EventLogEntry[],
timestampMs: number,
entry: Omit<EventLogEntry, 'id' | 'timestampMs'>,
): EventLogEntry[] {
eventCounter += 1;
const next: EventLogEntry = {
id: `pb-evt-${eventCounter}`,
timestampMs,
...entry,
};
return [next, ...events].slice(0, 40);
}
function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: number, events: EventLogEntry[], simTime: number) {
const classification = classifyCase(playlistCase);
const warnings = [
...(playlistCase.warning ? [playlistCase.warning] : []),
...classification.warnings,
];
return {
currentCase: playlistCase,
currentCaseIndex: caseIndex,
currentPhaseIndex: 0,
currentPhase: 'spawn' as CasePhase,
phaseElapsedMs: 0,
caseElapsedMs: 0,
targetCategory: classification.category,
classification,
command: 'IDLE',
warning: warnings[0] ?? null,
positionJitter: buildJitter(seed, caseIndex),
events: pushEvent(events, simTime, {
itemId: playlistCase.itemId,
type: 'system',
message: `Case start: ${playlistCase.title}`,
category: classification.category,
status: 'info',
}),
};
}
/** Create initial playback state. */
export function createPlaybackState(seed: number = DEFAULT_DEMO_SEED): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
const classification = classifyCase(firstCase);
return {
status: 'idle',
currentCaseIndex: 0,
currentCase: firstCase,
currentPhaseIndex: 0,
currentPhase: 'spawn',
phaseElapsedMs: 0,
caseElapsedMs: 0,
totalElapsedMs: 0,
loopMode: false,
targetCategory: null,
classification: null,
command: 'IDLE',
warning: null,
speed: 1,
seed,
positionJitter: { x: 0, z: 0, yaw: 0 },
events: [],
};
}
/** Start playback from the beginning. */
export function startPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
const fields = initCaseFields(firstCase, state.seed, 0, [], 0);
return {
...state,
status: 'running',
totalElapsedMs: 0,
speed: state.speed,
seed: state.seed,
loopMode: state.loopMode,
...fields,
};
}
export function pausePlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
if (state.status !== 'running') return state;
return { ...state, status: 'paused' };
}
export function resumePlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
if (state.status !== 'paused') return state;
return { ...state, status: 'running' };
}
export function stopPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
return createPlaybackState(state.seed);
}
export function toggleLoopMode(state: ContinuousPlaybackState): ContinuousPlaybackState {
return { ...state, loopMode: !state.loopMode };
}
export function setPlaybackSpeed(state: ContinuousPlaybackState, speed: PlaybackSpeed): ContinuousPlaybackState {
return { ...state, speed };
}
/** Jump to a playlist case index (keeps running/paused status). */
export function seekToCase(state: ContinuousPlaybackState, caseIndex: number): ContinuousPlaybackState {
const idx = ((caseIndex % PLAYLIST_LENGTH) + PLAYLIST_LENGTH) % PLAYLIST_LENGTH;
const nextCase = DEMO_PLAYLIST[idx];
const fields = initCaseFields(nextCase, state.seed, idx, state.events, state.totalElapsedMs);
const status = state.status === 'idle' || state.status === 'finished' ? 'running' : state.status;
return {
...state,
status,
...fields,
};
}
export function seekNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState {
return seekToCase(state, state.currentCaseIndex + 1);
}
export function seekPrevCase(state: ContinuousPlaybackState): ContinuousPlaybackState {
return seekToCase(state, state.currentCaseIndex - 1);
}
function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState {
const nextIndex = state.currentCaseIndex + 1;
if (nextIndex >= PLAYLIST_LENGTH) {
if (state.loopMode) {
const firstCase = DEMO_PLAYLIST[0];
const fields = initCaseFields(firstCase, state.seed, 0, state.events, state.totalElapsedMs);
return { ...state, ...fields };
}
return {
...state,
status: 'finished',
command: 'COMPLETE',
events: pushEvent(state.events, state.totalElapsedMs, {
type: 'system',
message: 'Playlist complete',
status: 'success',
}),
};
}
const nextCase = DEMO_PLAYLIST[nextIndex];
const fields = initCaseFields(nextCase, state.seed, nextIndex, state.events, state.totalElapsedMs);
return { ...state, ...fields };
}
function getCommandForPhase(phase: CasePhase, category: Category): string {
switch (phase) {
case 'spawn':
case 'move_to_detection':
return 'MOVING_TO_CAMERA';
case 'detection':
return 'DETECTING';
case 'measurement':
return 'MEASURING';
case 'classification':
return 'CLASSIFYING';
case 'command_sent':
case 'routing':
case 'exit':
return `ROUTE_TO_${category}`;
case 'fault_hold':
return 'FAULT';
case 'emergency_hold':
return 'EMERGENCY_STOP';
case 'recover':
return 'RECOVERING';
case 'clear_gap':
return 'RETURN_HOME';
default:
return 'IDLE';
}
}
function maybeLogPhaseTransition(
state: ContinuousPlaybackState,
phase: CasePhase,
category: Category,
): EventLogEntry[] {
let events = state.events;
if (phase === 'classification' && state.classification) {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'classification',
message: `${state.classification.label}: ${state.classification.reason}`,
category: state.classification.category,
command: 'CLASSIFY_RULE_BASED',
status: state.classification.warnings.length ? 'warning' : 'success',
});
}
if (phase === 'command_sent') {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'routing',
message: `Command ROUTE_TO_${category}`,
category,
command: `ROUTE_TO_${category}`,
status: 'success',
});
}
if (phase === 'fault_hold') {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'fault',
message: 'Jam detected at stop-gate — conveyor halted',
command: 'FAULT',
status: 'error',
});
}
if (phase === 'emergency_hold') {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'fault',
message: 'Emergency stop engaged — all motion frozen',
command: 'EMERGENCY_STOP',
status: 'error',
});
}
if (phase === 'recover') {
events = pushEvent(events, state.totalElapsedMs, {
type: 'system',
message: 'Recovery sequence started',
command: 'RECOVER',
status: 'warning',
});
}
return events;
}
/** Update playback state with elapsed wall-clock time (scaled by speed). */
export function updatePlayback(
state: ContinuousPlaybackState,
deltaMs: number,
): ContinuousPlaybackState {
if (state.status !== 'running') {
return state;
}
const scaledDelta = deltaMs * state.speed;
let newState = { ...state };
newState.phaseElapsedMs += scaledDelta;
newState.caseElapsedMs += scaledDelta;
newState.totalElapsedMs += scaledDelta;
const phases = phasesForCase(newState.currentCase);
const currentPhaseConfig = phases[newState.currentPhaseIndex];
if (newState.phaseElapsedMs >= currentPhaseConfig.durationMs) {
const nextPhaseIndex = newState.currentPhaseIndex + 1;
if (nextPhaseIndex >= phases.length) {
newState = advanceToNextCase(newState);
} else {
newState.currentPhaseIndex = nextPhaseIndex;
newState.currentPhase = phases[nextPhaseIndex].phase;
newState.phaseElapsedMs = 0;
if (newState.targetCategory) {
newState.events = maybeLogPhaseTransition(newState, newState.currentPhase, newState.targetCategory);
}
}
}
if (newState.status === 'running' && newState.targetCategory) {
newState.command = getCommandForPhase(newState.currentPhase, newState.targetCategory);
}
return newState;
}
export function getCasePhases(state: ContinuousPlaybackState): PhaseConfig[] {
return phasesForCase(state.currentCase);
}
export function getCaseDurationMs(state: ContinuousPlaybackState): number {
return caseDurationMs(state.currentCase);
}
export function getCaseProgress(state: ContinuousPlaybackState): number {
return Math.min(state.caseElapsedMs / getCaseDurationMs(state), 1);
}
export function getPhaseProgress(state: ContinuousPlaybackState): number {
const phases = getCasePhases(state);
const phaseConfig = phases[state.currentPhaseIndex];
return Math.min(state.phaseElapsedMs / phaseConfig.durationMs, 1);
}
export function getCurrentPhaseConfig(state: ContinuousPlaybackState): PhaseConfig {
return getCasePhases(state)[state.currentPhaseIndex];
}
export function isDetectionActive(state: ContinuousPlaybackState): boolean {
return state.currentPhase === 'detection' || state.currentPhase === 'measurement';
}
export function isRoutingActive(state: ContinuousPlaybackState): boolean {
return state.currentPhase === 'routing' || state.currentPhase === 'exit';
}
export function isFaultActive(state: ContinuousPlaybackState): boolean {
return state.currentPhase === 'fault_hold' || state.currentPhase === 'emergency_hold';
}
export function getTotalProgress(state: ContinuousPlaybackState): number {
const completedCases = state.currentCaseIndex;
const currentCaseProgress = getCaseProgress(state);
return (completedCases + currentCaseProgress) / PLAYLIST_LENGTH;
}
/** Assert playlist expectedCategory matches live classifier (for tests). */
export function assertPlaylistClassifierConsistency(): Array<{ id: string; expected: Category; actual: Category }> {
const mismatches: Array<{ id: string; expected: Category; actual: Category }> = [];
for (const c of DEMO_PLAYLIST) {
if (c.faultType) continue;
const result = classifyCase(c);
if (result.category !== c.expectedCategory) {
mismatches.push({ id: c.id, expected: c.expectedCategory, actual: result.category });
}
}
return mismatches;
}

Some files were not shown because too many files have changed in this diff Show More