Compare commits
53 Commits
drho1y-mvp
...
dan_branch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e83be26e | ||
|
|
3777fecacf | ||
|
|
bb76963902 | ||
|
|
496162a3db | ||
|
|
6e3b6ae3f0 | ||
|
|
30a9f7efb3 | ||
|
|
dce7faee24 | ||
|
|
1586e9d2ab | ||
|
|
13ce16bedc | ||
|
|
4413f01ce4 | ||
|
|
c0dedfcdfb | ||
|
|
30103ccefc | ||
|
|
014a46077d | ||
|
|
68327635ce | ||
|
|
0a1be376eb | ||
|
|
acc59ec912 | ||
|
|
40e9b18e8d | ||
|
|
394c513037 | ||
|
|
5cc27c6fc2 | ||
|
|
060971da75 | ||
|
|
982f85dae1 | ||
|
|
4fcce5bff9 | ||
|
|
16e7930304 | ||
|
|
b5985f33bb | ||
|
|
985f7c327d | ||
|
|
e89c728dd2 | ||
|
|
8e9ae4f62b | ||
|
|
1528cfce10 | ||
|
|
39dc11c773 | ||
|
|
a078d296e3 | ||
|
|
717a992b98 | ||
|
|
3aad908aaf | ||
|
|
fa770f9da9 | ||
|
|
35138c824c | ||
|
|
d9bf861960 | ||
|
|
ae2f38d6cf | ||
|
|
05029e6f51 | ||
|
|
600699f155 | ||
|
|
4ffafa699a | ||
|
|
f9eb0819c0 | ||
|
|
29035dad5f | ||
|
|
ad2898200b | ||
|
|
3e1f10a01a | ||
|
|
a700a146a3 | ||
|
|
17c37c2580 | ||
|
|
e0a655db4f | ||
|
|
b749cef14e | ||
|
|
a7025ca50c | ||
|
|
cb38cd0002 | ||
|
|
2d50cdb454 | ||
|
|
f3e2d16059 | ||
|
|
77069748ee | ||
|
|
9395d25332 |
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
38
.github/workflows/ci.yml
vendored
Normal 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
22
.gitignore
vendored
Normal 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
2
3d_models/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.FCBak
|
||||
/export
|
||||
BIN
3d_models/conveer.FCStd
Normal file
BIN
3d_models/conveer.FCStd
Normal file
Binary file not shown.
19
Dockerfile
Normal file
19
Dockerfile
Normal 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;"]
|
||||
213
README.md
213
README.md
@@ -0,0 +1,213 @@
|
||||
# OZON Sorter Digital Twin
|
||||
|
||||
Complete Track 3 Ozon Tech solution in one repository: a web digital twin of the sorting line, plus a real RealSense/OpenCV CV prototype.
|
||||
|
||||
**Production:** https://arhipovdan.ru
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This repository delivers a continuous web simulation of an Ozon conveyor sorter: CAD conveyor, product models, camera/measurement stage, B/C/D classification, diverters, and Rapier physics. The public demo runs on https://arhipovdan.ru with routes `/` (simulation) and `/documentation` (engineering status).
|
||||
|
||||
Separately, `cv/` contains a **working hardware prototype** that reads Intel RealSense D415 depth, measures parcels with OpenCV, classifies B/C/D, and can publish results over MQTT. It is **not** wired into the live website.
|
||||
|
||||
Both paths share the same Track 3 classification domain (exclusive 10×10×10 … 450×320×320 mm, roundness K > 0.8). The web twin uses a digital sensor simulation; the CV folder uses real depth frames.
|
||||
|
||||
`main` is the canonical complete solution. Developers do not need other branches to run the web app or inspect/run the CV prototype.
|
||||
|
||||
Large submission artifacts (presentation, video, optional CAD/model mirrors) belong in team cloud storage; runtime assets required by deploy stay in Git.
|
||||
|
||||
## 2. Submission components
|
||||
|
||||
| Component | Location | Notes |
|
||||
|---|---|---|
|
||||
| Web digital twin | `src/`, `public/` | Production-integrated |
|
||||
| Real CV prototype | `cv/` | WORKING_PROTOTYPE, not live-integrated |
|
||||
| Author CAD | `3d_models/conveer.FCStd` | FreeCAD source |
|
||||
| Official materials | `input_info/`, `official_sources/` | PDFs/ZIPs cited by docs/code |
|
||||
| Production domain | https://arhipovdan.ru | Docker + nginx |
|
||||
| Presentation / video | cloud (owner) | Links TBD — see §17 |
|
||||
|
||||
## 3. Production demo
|
||||
|
||||
- **URL:** https://arhipovdan.ru
|
||||
- **`/`** — continuous digital-twin simulation
|
||||
- **`/documentation`** — canonical engineering status
|
||||
- Unknown routes redirect to `/`
|
||||
|
||||
Device behavior on current baseline:
|
||||
|
||||
- **Desktop:** interactive WebGL 3D
|
||||
- **Mobile:** lightweight **2D** fallback (not full WebGL 3D)
|
||||
|
||||
Build identity: `/version.json`.
|
||||
|
||||
## 4. Web capabilities
|
||||
|
||||
- Three-module CAD conveyor (clean → camera → sorter)
|
||||
- Camera / measurement simulation and Track 3 classifier
|
||||
- B / C / D routing with CAD diverters (−45° / +45°)
|
||||
- Rapier product physics (contact sorting **not** fully validated)
|
||||
- Continuous playback HUD + documentation page
|
||||
|
||||
## 5. Real CV prototype
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Path | `cv/` |
|
||||
| Origin | `drho1y-mvp_1` / `vision_classifier/` |
|
||||
| Technology | RealSense D415 + OpenCV (depth segmentation + metrics) |
|
||||
| Status | WORKING_PROTOTYPE |
|
||||
| Integration | **Not** connected to production web runtime |
|
||||
|
||||
See **[cv/README.md](cv/README.md)** for install, demo, live camera, and MQTT.
|
||||
|
||||
## 6. Architecture
|
||||
|
||||
```
|
||||
Real device path:
|
||||
RealSense D415 → depth preprocess → segmentation → measurement
|
||||
→ B/C/D → optional MQTT / hardware
|
||||
|
||||
Web path:
|
||||
Digital product → simulated sensor → classifier
|
||||
→ physical digital twin → B/C/D receiver visualization
|
||||
```
|
||||
|
||||
Shared: B/C/D semantics and official dimension/roundness rules.
|
||||
Not shared today: live camera frames into the website.
|
||||
|
||||
## 7. Repository structure
|
||||
|
||||
```
|
||||
.github/ CI (build, unit, e2e)
|
||||
3d_models/ Author CAD (conveer.FCStd)
|
||||
cv/ Real CV prototype (Python)
|
||||
docs/ Engineering notes
|
||||
e2e/ Playwright smoke/routes
|
||||
input_info/ Official Ozon input packs
|
||||
official_sources/ Classifier bounds PDF
|
||||
public/ Runtime static assets (GLB/STL/draco)
|
||||
src/ React/Three web twin
|
||||
Dockerfile Web production image
|
||||
docker-compose.server.yml
|
||||
nginx.conf
|
||||
package.json / lock
|
||||
vite / vitest / playwright / tsconfig
|
||||
README.md
|
||||
```
|
||||
|
||||
No other top-level product directories are required to run or understand the solution.
|
||||
|
||||
## 8. Requirements
|
||||
|
||||
**Web:** Node.js 20+, npm (`package-lock.json`).
|
||||
**CV:** Python 3.10+, ffmpeg, V4L2; RealSense D415 for live mode (`cv/requirements.txt`).
|
||||
**Hardware (CV live / MQTT):** D415 USB3; optional MQTT broker + servo/motor controllers on site network.
|
||||
|
||||
## 9. Web quick start
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## 10. CV quick start
|
||||
|
||||
```bash
|
||||
cd cv
|
||||
./demo.sh # venv + deps; HUD on :8080 (needs D415 for live view)
|
||||
# without camera:
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/python test_classify.py
|
||||
.venv/bin/python test_geometry.py
|
||||
# live pipeline (hardware):
|
||||
./run.sh --preview --no-mqtt --no-motor
|
||||
```
|
||||
|
||||
`npm install` does **not** install CV dependencies.
|
||||
|
||||
## 11. Configuration
|
||||
|
||||
**Web (build-time, optional):** `VITE_BUILD_COMMIT`, `VITE_BUILD_BRANCH`, `VITE_BUILD_RELEASE` → `/version.json`. No runtime secrets.
|
||||
|
||||
**CV:** copy `cv/config.example.yaml` → `cv/config.yaml` (gitignored). MQTT/motor/routing **disabled by default**. Never commit real passwords or broker credentials.
|
||||
|
||||
## 12. Classification rules
|
||||
|
||||
Verified in web (`src/domain/classifier.ts`) and CV (`cv/classify.py`):
|
||||
|
||||
- dimensions strictly **> 10×10×10 mm** and **< 450×320×320 mm**
|
||||
- circular when **K > 0.8** (web) / `circle_ratio ≥ 0.8` (CV)
|
||||
- order: dimensions fail → **C**; else circular → **D**; else **B**
|
||||
|
||||
Official citation: `official_sources/doc-1783095831.pdf` (present; not re-parsed on every doc pass). Missing extracted brief PDF is not claimed.
|
||||
|
||||
## 13. Physics (accepted `main`)
|
||||
|
||||
From current source (not superseded experimental branches):
|
||||
|
||||
- belt speed **1.0 m/s** (`CONVEYOR_SPEED_MPS`)
|
||||
- fixed timestep **1/60 s** (`PHYSICS_TIMESTEP_SEC`)
|
||||
- CCD for light/thin product bodies
|
||||
- diverters LEFT **−45°**, RIGHT **+45°**
|
||||
- full contact-only junction sorting through CAD: **not fully validated**
|
||||
|
||||
## 14. CAD and assets
|
||||
|
||||
| Asset | Path |
|
||||
|---|---|
|
||||
| Author CAD | `3d_models/conveer.FCStd` |
|
||||
| Runtime GLB | `public/models/sorter/conveyor-clean.glb` |
|
||||
| Products | `public/models/*.stl` |
|
||||
|
||||
Keep runtime assets in Git for deploy. Mirror large CAD/models/presentation/video to cloud for submission.
|
||||
|
||||
SHA-256 (frozen):
|
||||
|
||||
```
|
||||
3d_models/conveer.FCStd
|
||||
90c1844a4ca05e26def783d6130fc4b993430dde14307534ef8fbb21c9fac2e6
|
||||
|
||||
public/models/sorter/conveyor-clean.glb
|
||||
1dc7a8d7891bfe756e277ad5368df74cb73410156b2fe0f92845afb8a56f285a
|
||||
```
|
||||
|
||||
## 15. Testing
|
||||
|
||||
```bash
|
||||
npm test -- --run
|
||||
# current release result: 196/196
|
||||
npm run build
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3101 npm run test:e2e -- e2e/routes.spec.ts e2e/smoke.spec.ts
|
||||
|
||||
cd cv && .venv/bin/python test_classify.py && .venv/bin/python test_geometry.py
|
||||
```
|
||||
|
||||
## 16. Deployment
|
||||
|
||||
Nginx terminates TLS for `arhipovdan.ru` and proxies to Docker `owl-web-1` (`docker-compose.server.yml` + `Dockerfile`) on `127.0.0.1:3100`. Deploy from `main` with build-args for `/version.json`. CV is **not** part of the web container.
|
||||
|
||||
## 17. Submission materials
|
||||
|
||||
| Material | Status |
|
||||
|---|---|
|
||||
| Presentation URL | REQUIRED_FROM_OWNER |
|
||||
| Video demo URL | REQUIRED_FROM_OWNER |
|
||||
| Cloud folder URL | REQUIRED_FROM_OWNER |
|
||||
|
||||
Do not invent links. Runtime site assets remain in Git even when mirrored to cloud.
|
||||
|
||||
## 18. Known limitations
|
||||
|
||||
- Mobile uses 2D lite fallback on current baseline
|
||||
- CV is a prototype and is not live-integrated into arhipovdan.ru
|
||||
- Simulation physics is engineering-derived; hardware calibration still required
|
||||
- Contact routing through CAD diverters not fully validated
|
||||
- Large presentation/video must be uploaded to cloud by owner
|
||||
|
||||
## 19. Branch history policy
|
||||
|
||||
**`main` is the canonical complete solution** (web + cleaned CV under `cv/`). Historical branches (`dan_branch`, `drho1y-mvp_1`, …) may remain for audit but are not required to run the product.
|
||||
|
||||
11
cv/.gitignore
vendored
Normal file
11
cv/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
debug_frames/
|
||||
logs/
|
||||
*.jpg
|
||||
*.png
|
||||
.pytest_cache/
|
||||
config.yaml
|
||||
.env
|
||||
.env.*
|
||||
23
cv/Dockerfile
Normal file
23
cv/Dockerfile
Normal 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"]
|
||||
176
cv/README.md
Normal file
176
cv/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# 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**
|
||||
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
139
cv/align_color.py
Normal 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
188
cv/calibrate.py
Normal 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.5–1.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
373
cv/camera.py
Normal 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.5–1.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.5–1.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()
|
||||
147
cv/classify.py
Normal file
147
cv/classify.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Классификация строго по правилам ТЗ трека 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
|
||||
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
151
cv/collect_log.py
Normal 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
90
cv/config.example.yaml
Normal 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
873
cv/demo.py
Executable 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.5–2 с: глянцевые/тёмные предметы (мышка) дают
|
||||
# кратковременные выпадения 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
19
cv/demo.sh
Executable 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
302
cv/demo_hud.py
Normal 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
25
cv/docker-compose.yml
Normal 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
47
cv/journal.py
Normal 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
330
cv/main.py
Executable 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
719
cv/measure.py
Normal 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:
|
||||
"""
|
||||
Поперечные срезы. Из логов: у круга часто 1–2 среза ≥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
172
cv/mqtt_bridge.py
Normal 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
6
cv/requirements.txt
Normal 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
17
cv/run.sh
Executable 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 "$@"
|
||||
306
cv/stabilize.py
Normal file
306
cv/stabilize.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Стабильный консенсус по кадрам.
|
||||
|
||||
Из логов RealSense: круг даёт sec≈0.82–0.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 и ≥ половины окна сильные)
|
||||
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)
|
||||
86
cv/test_classify.py
Executable file
86
cv/test_classify.py
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/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_border_circle():
|
||||
r = classify_from_dims(100, 50, 50, circle_ratio=0.8)
|
||||
assert r.category == Category.NEED_PACK
|
||||
r2 = classify_from_dims(100, 50, 50, circle_ratio=0.799)
|
||||
assert r2.category == Category.SUITABLE
|
||||
|
||||
|
||||
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_border_circle()
|
||||
test_border_dims_strict()
|
||||
test_dims_order_independent()
|
||||
print("OK: all classification tests passed")
|
||||
384
cv/test_geometry.py
Normal file
384
cv/test_geometry.py
Normal 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
279
cv/tracker.py
Normal 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, # ~15–20 с помнить «уже учтён»
|
||||
) -> 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
|
||||
6
docker-compose.server.yml
Normal file
6
docker-compose.server.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:3100:80"
|
||||
restart: unless-stopped
|
||||
83
docs/ENGINEERING.md
Normal file
83
docs/ENGINEERING.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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 | same |
|
||||
| `CONVEYOR_CAD_URL` | `/models/sorter/conveyor-clean.glb` | `ConveyorCadModel.tsx` |
|
||||
|
||||
## 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 README | PASS (assets present) |
|
||||
| Real measurement CV | Track 3 camera intent in briefs | `cv/` RealSense+OpenCV | `cv/README.md`, `test_classify.py` | PARTIAL (prototype, not live web) |
|
||||
| Physical industrial line | scoring/workspace PDFs | web physics + optional MQTT CV | code; contact not fully validated | PARTIAL |
|
||||
| Presentation / video | platform rules | cloud links | README §17 | NOT_VERIFIED (links missing) |
|
||||
|
||||
## 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.
|
||||
BIN
docs/engineering/work-area-layout-source.png
Normal file
BIN
docs/engineering/work-area-layout-source.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
55
e2e/helpers.ts
Normal file
55
e2e/helpers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Shared e2e helpers — Stage 2B autostart means / opens already running. */
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/** Close the Demo Complete overlay if it is covering the controls. */
|
||||
export async function dismissFinished(page: Page) {
|
||||
const finished = page.getByTestId('demo-finished');
|
||||
if (await finished.isVisible().catch(() => false)) {
|
||||
await finished.getByRole('button', { name: /Replay/i }).click({ force: true });
|
||||
await expect(finished).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure playback is running (noop if already on Pause button). */
|
||||
export async function ensureRunning(page: Page) {
|
||||
await dismissFinished(page);
|
||||
const pause = page.getByTestId('demo-pause');
|
||||
if (await pause.isVisible().catch(() => false)) return;
|
||||
const play = page.getByTestId('demo-play');
|
||||
await expect(play.or(pause)).toBeVisible({ timeout: 15_000 });
|
||||
if (await play.isVisible().catch(() => false)) {
|
||||
await play.click({ force: true });
|
||||
}
|
||||
await expect(page.getByTestId('demo-pause')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function ensurePaused(page: Page) {
|
||||
await dismissFinished(page);
|
||||
const play = page.getByTestId('demo-play');
|
||||
if (await play.isVisible().catch(() => false)) return;
|
||||
await page.getByTestId('demo-pause').click({ force: true });
|
||||
await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Assert either Play or Pause control is present (autostart-safe). */
|
||||
export async function expectPlaybackControl(page: Page) {
|
||||
await expect(
|
||||
page.getByTestId('demo-play').or(page.getByTestId('demo-pause')),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open debug demo, pause, then seek to a case WHILE paused (seek preserves
|
||||
* paused status — unlike seek from idle which auto-starts).
|
||||
*/
|
||||
export async function openPausedCase(page: Page, caseIndex: number, speed: '0.5' | '1' | '1.5' | '2' = '1') {
|
||||
await page.goto('/?debug=1');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByTestId('demo-hud')).toBeVisible();
|
||||
await ensurePaused(page);
|
||||
await page.getByTestId(`demo-speed-${speed}`).click();
|
||||
await page.getByTestId(`demo-case-${caseIndex}`).click();
|
||||
await dismissFinished(page);
|
||||
await ensurePaused(page);
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText(`${caseIndex + 1}/12`);
|
||||
}
|
||||
33
e2e/routes.spec.ts
Normal file
33
e2e/routes.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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('DATA_ACQUISITION_PACK_READY');
|
||||
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
31
e2e/smoke.spec.ts
Normal 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
14
index.html
Normal 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>
|
||||
BIN
input_info/doc-1782987706.zip
Normal file
BIN
input_info/doc-1782987706.zip
Normal file
Binary file not shown.
BIN
input_info/doc-1782987733.zip
Normal file
BIN
input_info/doc-1782987733.zip
Normal file
Binary file not shown.
BIN
input_info/doc-1783009063.pdf
Normal file
BIN
input_info/doc-1783009063.pdf
Normal file
Binary file not shown.
BIN
input_info/doc-1783009942.pdf
Normal file
BIN
input_info/doc-1783009942.pdf
Normal file
Binary file not shown.
28546
input_info/doc-1783011400.pdf
Normal file
28546
input_info/doc-1783011400.pdf
Normal file
File diff suppressed because one or more lines are too long
BIN
input_info/doc-1783011771.zip
Normal file
BIN
input_info/doc-1783011771.zip
Normal file
Binary file not shown.
BIN
input_info/ozone-tech_owl_prime_170-main.zip
Normal file
BIN
input_info/ozone-tech_owl_prime_170-main.zip
Normal file
Binary file not shown.
56
nginx.conf
Normal file
56
nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
145855
official_sources/doc-1783095831.pdf
Normal file
145855
official_sources/doc-1783095831.pdf
Normal file
File diff suppressed because one or more lines are too long
2227
package-lock.json
generated
Normal file
2227
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
42
playwright.config.ts
Normal 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,
|
||||
});
|
||||
31
public/draco/README.md
Normal file
31
public/draco/README.md
Normal 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)
|
||||
34
public/draco/draco_decoder.js
Normal file
34
public/draco/draco_decoder.js
Normal file
File diff suppressed because one or more lines are too long
BIN
public/draco/draco_decoder.wasm
Normal file
BIN
public/draco/draco_decoder.wasm
Normal file
Binary file not shown.
117
public/draco/draco_wasm_wrapper.js
Normal file
117
public/draco/draco_wasm_wrapper.js
Normal 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);
|
||||
33
public/draco/gltf/draco_decoder.js
Normal file
33
public/draco/gltf/draco_decoder.js
Normal file
File diff suppressed because one or more lines are too long
BIN
public/draco/gltf/draco_decoder.wasm
Normal file
BIN
public/draco/gltf/draco_decoder.wasm
Normal file
Binary file not shown.
116
public/draco/gltf/draco_wasm_wrapper.js
Normal file
116
public/draco/gltf/draco_wasm_wrapper.js
Normal 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
BIN
public/models/bottle.stl
Normal file
Binary file not shown.
BIN
public/models/box-300.stl
Normal file
BIN
public/models/box-300.stl
Normal file
Binary file not shown.
BIN
public/models/box-400.stl
Normal file
BIN
public/models/box-400.stl
Normal file
Binary file not shown.
BIN
public/models/cylinder.stl
Normal file
BIN
public/models/cylinder.stl
Normal file
Binary file not shown.
BIN
public/models/detergent.stl
Normal file
BIN
public/models/detergent.stl
Normal file
Binary file not shown.
BIN
public/models/lunchbox.stl
Normal file
BIN
public/models/lunchbox.stl
Normal file
Binary file not shown.
BIN
public/models/pen.stl
Normal file
BIN
public/models/pen.stl
Normal file
Binary file not shown.
BIN
public/models/plate.stl
Normal file
BIN
public/models/plate.stl
Normal file
Binary file not shown.
BIN
public/models/pouf.stl
Normal file
BIN
public/models/pouf.stl
Normal file
Binary file not shown.
BIN
public/models/sorter/conveyor-clean.glb
Normal file
BIN
public/models/sorter/conveyor-clean.glb
Normal file
Binary file not shown.
131
src/App.tsx
Normal file
131
src/App.tsx
Normal 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
35
src/components/AppNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/BuildIdentityBadge.tsx
Normal file
51
src/components/BuildIdentityBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
src/components/CVInspectionOverlay.tsx
Normal file
227
src/components/CVInspectionOverlay.tsx
Normal 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"> >{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>
|
||||
);
|
||||
}
|
||||
302
src/components/SorterScene.tsx
Normal file
302
src/components/SorterScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1392
src/components/ThreeD/ConveyorCadModel.tsx
Normal file
1392
src/components/ThreeD/ConveyorCadModel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
143
src/components/ThreeD/PerfCollector.tsx
Normal file
143
src/components/ThreeD/PerfCollector.tsx
Normal 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} />;
|
||||
}
|
||||
128
src/components/ThreeD/PerfOverlay.tsx
Normal file
128
src/components/ThreeD/PerfOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
src/components/ThreeD/PhysicalPlaybackItem.tsx
Normal file
229
src/components/ThreeD/PhysicalPlaybackItem.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
296
src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx
Normal file
296
src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
22
src/components/ThreeD/PostProcessingSpike.tsx
Normal file
22
src/components/ThreeD/PostProcessingSpike.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
src/components/ThreeD/RealItemModel.tsx
Normal file
193
src/components/ThreeD/RealItemModel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
src/components/ThreeD/RealModelVerification.tsx
Normal file
168
src/components/ThreeD/RealModelVerification.tsx
Normal 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;
|
||||
}
|
||||
154
src/components/ThreeD/RealSenseD435i.tsx
Normal file
154
src/components/ThreeD/RealSenseD435i.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Stage 2B §9 — Intel RealSense Depth Camera D435i (SPEC_DERIVED).
|
||||
*
|
||||
* Built from the official Intel datasheet dimensions (90 × 25 × 25 mm,
|
||||
* 50 mm stereo baseline, depth FOV 87°×58°) and the reference photo:
|
||||
* horizontal anodized-aluminium bar, full-width front glass, left/right IR
|
||||
* imagers, center RGB module, IR texture projector, rear USB-C, tripod boss.
|
||||
* SPEC_DERIVED — not an official Intel CAD file; provenance documented in
|
||||
* docs/stage2_real_sorter/camera-realsense-spec.md.
|
||||
*
|
||||
* Mount: overhead bar across the belt (Z), front glass facing DOWN (-Y),
|
||||
* optical center 1.35 m (0.65 m above belt top 0.7 m) — clears the 500 mm
|
||||
* oversized item by 112 mm. Laser triangulation module separately at 1.15 m
|
||||
* (project doc height), so camera and laser heights are NOT conflated.
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { SCAN_START_X, SCAN_END_X } from '../../domain/measurementZone';
|
||||
import { ZONES, CONVEYOR_WIDTH_M, BELT_TOP_Y } from '../../domain/physicalLayout';
|
||||
|
||||
/** Official datasheet dimensions (m). */
|
||||
export const D435I = {
|
||||
width: 0.09, // 90 mm along the bar (Z when mounted across the belt)
|
||||
height: 0.025, // 25 mm
|
||||
depth: 0.025, // 25 mm
|
||||
baseline: 0.05,
|
||||
fovH: 87, // deg, along the baseline
|
||||
fovV: 58, // deg
|
||||
opticalCenterY: 1.35,
|
||||
} as const;
|
||||
|
||||
const HOUSING = '#222528';
|
||||
const HOUSING_EDGE = '#31363b';
|
||||
const GLASS = '#0c1116';
|
||||
const LENS_RIM = '#3c4249';
|
||||
const LENS_INNER = '#05070a';
|
||||
|
||||
/** One sensor window on the glass face (rim + recessed lens), facing DOWN (-Y). */
|
||||
function SensorWindow({ z, radius }: { z: number; radius: number }) {
|
||||
const y = -D435I.height / 2 - 0.0004;
|
||||
return (
|
||||
<group position={[0, 0, z]}>
|
||||
<mesh position={[0, y, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[radius, 0.0016, 10, 24]} />
|
||||
<meshStandardMaterial color={LENS_RIM} metalness={0.8} roughness={0.35} />
|
||||
</mesh>
|
||||
<mesh position={[0, y + 0.0008, 0]}>
|
||||
<cylinderGeometry args={[radius * 0.72, radius * 0.72, 0.0016, 20]} />
|
||||
<meshStandardMaterial color={LENS_INNER} metalness={0.4} roughness={0.15} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export const RealSenseD435i = memo(function RealSenseD435i({
|
||||
castShadow = false,
|
||||
}: {
|
||||
castShadow?: boolean;
|
||||
}) {
|
||||
const w = D435I.width;
|
||||
return (
|
||||
// Bar runs across the belt (Z); front glass face looks DOWN (-Y) at the belt.
|
||||
<group>
|
||||
{/* Main housing bar with chamfered edge rails */}
|
||||
<mesh castShadow={castShadow}>
|
||||
<boxGeometry args={[D435I.depth - 0.004, D435I.height - 0.004, w]} />
|
||||
<meshStandardMaterial color={HOUSING} metalness={0.7} roughness={0.42} />
|
||||
</mesh>
|
||||
{/* side edge rails (rounded anodized look) */}
|
||||
{[-1, 1].map((s) => (
|
||||
<mesh key={s} position={[s * (D435I.depth / 2 - 0.002), 0, 0]}>
|
||||
<boxGeometry args={[0.004, D435I.height, w - 0.006]} />
|
||||
<meshStandardMaterial color={HOUSING_EDGE} metalness={0.75} roughness={0.35} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Full-width front glass on the downward face */}
|
||||
<mesh position={[0, -D435I.height / 2 - 0.0006, 0]}>
|
||||
<boxGeometry args={[D435I.depth - 0.007, 0.0012, w - 0.008]} />
|
||||
<meshPhysicalMaterial
|
||||
color={GLASS}
|
||||
metalness={0.1}
|
||||
roughness={0.08}
|
||||
transparent
|
||||
opacity={0.82}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Sensors along the bar: left imager / RGB / IR projector / right imager */}
|
||||
<SensorWindow z={D435I.baseline / 2} radius={0.0065} />
|
||||
<SensorWindow z={-D435I.baseline / 2} radius={0.0065} />
|
||||
<SensorWindow z={0.012} radius={0.0042} />
|
||||
<SensorWindow z={-0.011} radius={0.0052} />
|
||||
{/* USB-C port on the right end (rear) */}
|
||||
<mesh position={[D435I.depth / 2 - 0.001, 0.002, w / 2 - 0.008]}>
|
||||
<boxGeometry args={[0.004, 0.006, 0.009]} />
|
||||
<meshStandardMaterial color="#0b0d0f" metalness={0.3} roughness={0.6} />
|
||||
</mesh>
|
||||
{/* Tripod boss on top (mount point) */}
|
||||
<mesh position={[0, D435I.height / 2 + 0.003, 0]}>
|
||||
<boxGeometry args={[0.012, 0.006, 0.02]} />
|
||||
<meshStandardMaterial color={HOUSING_EDGE} metalness={0.8} roughness={0.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Debug-only measurement frustum (§9.4): optical axis, FOV pyramid,
|
||||
* scan-zone rectangle on the belt, entry/exit markers.
|
||||
*/
|
||||
export const RealSenseFrustumDebug = memo(function RealSenseFrustumDebug() {
|
||||
const h = D435I.opticalCenterY - BELT_TOP_Y;
|
||||
const halfAlong = Math.tan((D435I.fovV / 2) * (Math.PI / 180)) * h; // along belt X
|
||||
const halfAcross = Math.tan((D435I.fovH / 2) * (Math.PI / 180)) * h; // across belt Z
|
||||
const cx = ZONES.CAMERA.x;
|
||||
const top: [number, number, number] = [cx, D435I.opticalCenterY - D435I.height / 2, 0];
|
||||
const y = BELT_TOP_Y;
|
||||
const corners: [number, number, number][] = [
|
||||
[cx - halfAlong, y, -halfAcross],
|
||||
[cx + halfAlong, y, -halfAcross],
|
||||
[cx + halfAlong, y, halfAcross],
|
||||
[cx - halfAlong, y, halfAcross],
|
||||
];
|
||||
return (
|
||||
<group>
|
||||
{/* optical axis */}
|
||||
<lineSegments>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[new Float32Array([
|
||||
...top, cx, y, 0,
|
||||
// FOV edges
|
||||
...corners.flatMap((c) => [...top, ...c]),
|
||||
// FOV footprint rectangle
|
||||
...corners.flatMap((c, i) => [...c, ...corners[(i + 1) % 4]]),
|
||||
]), 3]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color="#38bdf8" transparent opacity={0.55} />
|
||||
</lineSegments>
|
||||
{/* scan zone rectangle on the belt */}
|
||||
<mesh position={[(SCAN_START_X + SCAN_END_X) / 2, y + 0.003, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[SCAN_END_X - SCAN_START_X, CONVEYOR_WIDTH_M]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
{/* entry / exit markers */}
|
||||
{[SCAN_START_X, SCAN_END_X].map((x) => (
|
||||
<mesh key={x} position={[x, y + 0.004, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[0.015, CONVEYOR_WIDTH_M]} />
|
||||
<meshBasicMaterial color={x === SCAN_START_X ? '#22c55e' : '#ef4444'} transparent opacity={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
});
|
||||
185
src/components/ThreeD/RollCageMesh.tsx
Normal file
185
src/components/ThreeD/RollCageMesh.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1507
src/components/ThreeD/SorterDigitalTwinContinuous.tsx
Normal file
1507
src/components/ThreeD/SorterDigitalTwinContinuous.tsx
Normal file
File diff suppressed because it is too large
Load Diff
181
src/components/ThreeD/SorterPhysics.tsx
Normal file
181
src/components/ThreeD/SorterPhysics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/components/ThreeD/ThreeErrorBoundary.tsx
Normal file
143
src/components/ThreeD/ThreeErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/components/ThreeD/useWebGL.ts
Normal file
28
src/components/ThreeD/useWebGL.ts
Normal 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
111
src/data/items.ts
Normal 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;
|
||||
}
|
||||
149
src/data/modelAssets.test.ts
Normal file
149
src/data/modelAssets.test.ts
Normal 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
449
src/data/modelAssets.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
182
src/data/productionStatusSummary.ts
Normal file
182
src/data/productionStatusSummary.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Canonical summary data for `/documentation`.
|
||||
* Authority: official sources present in-repo → production code → unit tests.
|
||||
* Unverified / planned items are labeled explicitly — never implied complete.
|
||||
*/
|
||||
|
||||
/** Kept for e2e (`docs-production-status`) and acquisition-pack provenance. */
|
||||
export const PRODUCTION_STATUS = {
|
||||
acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY',
|
||||
webTwinStatus: 'BASELINE_PRESERVED',
|
||||
unitTests: '196/196',
|
||||
productionBuild: 'PASS',
|
||||
contactPhysics: 'NOT_FULLY_VALIDATED',
|
||||
officialCompliance: 'PARTIAL_SOURCES_PRESENT',
|
||||
} 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: 'CURRENT_IMPLEMENTATION_VERIFIED_IN_CODE' 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',
|
||||
},
|
||||
checkOrder: 'dimensions→C, else circular→D, else B' 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',
|
||||
],
|
||||
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',
|
||||
'Dynamic rigid bodies for divert segment with fixed timestep',
|
||||
'Per-SKU collider, damping, and friction profiles',
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const VALIDATION_BOARD = [
|
||||
{ item: 'Unit tests', status: '196/196 PASS' },
|
||||
{ item: 'Production build', status: 'PASS' },
|
||||
{ item: 'Active routes / + /documentation', status: 'PASS' },
|
||||
{ item: 'conveyor-clean.glb checksum', status: 'PASS' },
|
||||
{ item: 'Author FCStd checksum', status: 'PASS' },
|
||||
{ item: 'Frozen diverter angles / 0.50 s', status: 'PASS' },
|
||||
{ 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; not re-parsed this pass',
|
||||
},
|
||||
{
|
||||
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 CURRENT_LIMITATIONS = [
|
||||
'Full physical contact sorting through CAD diverters is not fully validated.',
|
||||
'Belt surface-velocity drive at exactly 1 m/s is planned, not complete.',
|
||||
'Per-SKU physical parameters still require profiling/calibration.',
|
||||
'Author CAD horn / transmission incomplete in active GLB.',
|
||||
'Official compliance is partial: missing extracted task PDF; scoring PDF not re-parsed this pass.',
|
||||
'Repository retains only the web twin, author CAD, official sources, and active tests.',
|
||||
] as const;
|
||||
|
||||
/** @deprecated alias — historical Gate wording retained for acquisition-pack note */
|
||||
export const DATA_ACQUISITION_SEQUENCE = ['MEASURE', 'VERIFY', 'FREEZE', 'DESIGN'] as const;
|
||||
29
src/data/resolveItem.ts
Normal file
29
src/data/resolveItem.ts
Normal 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
93
src/data/scenarios.ts
Normal 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];
|
||||
}
|
||||
45
src/domain/cadAssemblyParams.ts
Normal file
45
src/domain/cadAssemblyParams.ts
Normal 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;
|
||||
168
src/domain/cinematicCamera.test.ts
Normal file
168
src/domain/cinematicCamera.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
310
src/domain/cinematicCamera.ts
Normal file
310
src/domain/cinematicCamera.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
217
src/domain/classifier.test.ts
Normal file
217
src/domain/classifier.test.ts
Normal 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('21–22. 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
81
src/domain/classifier.ts
Normal 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.5–7).
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
147
src/domain/continuousMeasurement.test.ts
Normal file
147
src/domain/continuousMeasurement.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Stage 2B §10–12, §25 — continuous measurement: the item NEVER stops under
|
||||
* the camera; scan progress is position-based; classification completes
|
||||
* before mechanism contact; physics handoff does not pre-position the item
|
||||
* on the chute before the paddle touches it.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { CASE_PHASES, createPlaybackState, startPlayback, updatePlayback } from './continuousPlayback';
|
||||
import { getPhysicalItemPose, getDropHandoffTimeMs, getRoutingStartMs } from './physicalItemMotion';
|
||||
import { deriveSorterVisualState } from './sorterVisualState';
|
||||
import {
|
||||
SCAN_START_X, SCAN_END_X, CLASSIFICATION_DEADLINE_X, MECHANISM_CONTACT_X,
|
||||
getScanProgress, getScanWindow,
|
||||
} from './measurementZone';
|
||||
import { ZONES, CONVEYOR_SPEED_MPS, BELT_TOP_Y, 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);
|
||||
});
|
||||
});
|
||||
132
src/domain/continuousPlayback.test.ts
Normal file
132
src/domain/continuousPlayback.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
471
src/domain/continuousPlayback.ts
Normal file
471
src/domain/continuousPlayback.ts
Normal 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;
|
||||
}
|
||||
92
src/domain/conveyorNetwork.test.ts
Normal file
92
src/domain/conveyorNetwork.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SURFACES,
|
||||
getSurface,
|
||||
isWithinBounds,
|
||||
surfaceLength,
|
||||
pathForCategory,
|
||||
type SurfaceName,
|
||||
} from './conveyorNetwork';
|
||||
|
||||
const ALL: SurfaceName[] = [
|
||||
'main_belt',
|
||||
'inspection_station',
|
||||
'routing_junction',
|
||||
'b_transfer',
|
||||
'chute_b',
|
||||
'b_bin_floor',
|
||||
'chute_c',
|
||||
'chute_d',
|
||||
'c_cage_floor',
|
||||
'd_cage_floor',
|
||||
];
|
||||
|
||||
describe('conveyorNetwork', () => {
|
||||
it('defines every required surface', () => {
|
||||
for (const name of ALL) {
|
||||
expect(SURFACES[name]).toBeDefined();
|
||||
expect(getSurface(name).name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('every surface has finite coordinates and bounds', () => {
|
||||
for (const name of ALL) {
|
||||
const s = SURFACES[name];
|
||||
for (const v of [...s.start, ...s.end, s.surfaceY, s.width, s.speedMps]) {
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
}
|
||||
const b = s.bounds;
|
||||
for (const v of [b.minX, b.maxX, b.minZ, b.maxZ]) {
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
}
|
||||
expect(b.maxX).toBeGreaterThanOrEqual(b.minX);
|
||||
expect(b.maxZ).toBeGreaterThanOrEqual(b.minZ);
|
||||
}
|
||||
});
|
||||
|
||||
it('main belt runs at 1 m/s and cages/junctions are static', () => {
|
||||
expect(SURFACES.main_belt.speedMps).toBe(1);
|
||||
expect(SURFACES.routing_junction.speedMps).toBe(1);
|
||||
expect(SURFACES.c_cage_floor.speedMps).toBe(0);
|
||||
expect(SURFACES.d_cage_floor.speedMps).toBe(0);
|
||||
expect(SURFACES.inspection_station.speedMps).toBe(0);
|
||||
});
|
||||
|
||||
it('chutes move slower than the belt but are not static', () => {
|
||||
expect(SURFACES.chute_c.speedMps).toBeGreaterThan(0);
|
||||
expect(SURFACES.chute_c.speedMps).toBeLessThan(SURFACES.main_belt.speedMps);
|
||||
});
|
||||
|
||||
it('provides an ordered path per category', () => {
|
||||
expect(pathForCategory('B')).toEqual(['main_belt', 'inspection_station', 'routing_junction', 'b_transfer', 'chute_b', 'b_bin_floor']);
|
||||
expect(pathForCategory('C')).toEqual(['main_belt', 'inspection_station', 'routing_junction', 'chute_c', 'c_cage_floor']);
|
||||
expect(pathForCategory('D')).toEqual(['main_belt', 'inspection_station', 'routing_junction', 'chute_d', 'd_cage_floor']);
|
||||
});
|
||||
|
||||
it('B bin floor and cages are on the ground (not floating)', () => {
|
||||
expect(SURFACES.b_bin_floor.surfaceY).toBeLessThan(0.2);
|
||||
expect(SURFACES.c_cage_floor.surfaceY).toBeLessThan(0.2);
|
||||
expect(SURFACES.d_cage_floor.surfaceY).toBeLessThan(0.2);
|
||||
});
|
||||
|
||||
it('bounds check works', () => {
|
||||
const c = SURFACES.c_cage_floor.bounds;
|
||||
const cx = (c.minX + c.maxX) / 2;
|
||||
const cz = (c.minZ + c.maxZ) / 2;
|
||||
expect(isWithinBounds('c_cage_floor', cx, cz)).toBe(true);
|
||||
expect(isWithinBounds('c_cage_floor', c.maxX + 1, cz)).toBe(false);
|
||||
});
|
||||
|
||||
it('every surface has a kind', () => {
|
||||
for (const name of ALL) {
|
||||
expect(SURFACES[name].kind).toBeTruthy();
|
||||
}
|
||||
expect(SURFACES.b_bin_floor.kind).toBe('bin_floor');
|
||||
expect(SURFACES.b_transfer.kind).toBe('conveyor');
|
||||
});
|
||||
|
||||
it('chutes have real length (item slides, does not teleport)', () => {
|
||||
expect(surfaceLength('chute_c')).toBeGreaterThan(0.5);
|
||||
expect(surfaceLength('chute_d')).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user