From 07d95792e30dfa6054c33b5c5a3c77e87bc4ab7d Mon Sep 17 00:00:00 2001 From: robot Date: Tue, 19 May 2026 00:58:23 +0200 Subject: [PATCH] Initial commit: RealSense D435 API server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in replacement for the Nuwa-HP60C camera container, exposing RGB + aligned depth + intrinsics via REST/WebSocket so the existing pestello-pose-detector pipeline can consume frames without changes. Key adaptations for D4xx on USB 2.0 (this robot has no USB 3 port exposed externally — see STATUS.md): - pyrealsense2 built from source; the librealsense v2.55.1 install rule doesn't ship the main .so, so we copy it from the build dir and add an __init__.py that re-exports the submodule. - Color stream uses YUYV format on USB 2.x (BGR8/RGB8 time out on this bus); converted to BGR via cv2.cvtColor in capture(). - rs.align is skipped on USB 2 (incompatible with YUYV); color is cv2-resized to depth shape for approximate per-pixel alignment. - Driver auto-detects usb_type_descriptor and falls back to the full-resolution BGR8 + rs.align pipeline when on USB 3. Tested: /api/{health,status,discover,rgb,depth,depth/color,capture, intrinsics} all return 200 with valid payloads at 640x480. --- .dockerignore | 10 + .env.example | 26 + .gitignore | 38 + .python-version | 1 + Dockerfile | 117 + README.md | 168 + STATUS.md | 79 + docker-compose.yml | 59 + pyproject.toml | 29 + scripts/entrypoint.sh | 15 + src/backend/__init__.py | 0 src/backend/api/__init__.py | 0 src/backend/api/dependencies.py | 9 + src/backend/api/routes.py | 537 +++ src/backend/api/websocket.py | 94 + src/backend/config.py | 51 + src/backend/main.py | 147 + src/backend/models/__init__.py | 0 src/backend/models/api.py | 54 + src/backend/models/nats.py | 26 + src/backend/nats_client/__init__.py | 0 src/backend/nats_client/connection.py | 58 + src/backend/nats_client/discovery.py | 52 + src/backend/nats_client/handlers.py | 66 + src/backend/nats_client/subjects.py | 18 + src/backend/services/__init__.py | 0 src/backend/services/camera.py | 413 +++ src/backend/services/detector.py | 92 + src/backend/services/image_saver.py | 105 + src/frontend/react/.gitignore | 24 + src/frontend/react/README.md | 16 + src/frontend/react/eslint.config.js | 29 + src/frontend/react/index.html | 13 + src/frontend/react/package-lock.json | 3232 +++++++++++++++++ src/frontend/react/package.json | 31 + src/frontend/react/public/favicon.svg | 1 + src/frontend/react/public/icons.svg | 24 + src/frontend/react/src/App.jsx | 117 + .../react/src/components/CalibrationPanel.jsx | 169 + .../react/src/components/CameraControls.jsx | 202 ++ .../react/src/components/DepthView.jsx | 42 + src/frontend/react/src/components/Header.jsx | 36 + .../react/src/components/PointCloudView.jsx | 134 + .../react/src/components/SplitView.jsx | 33 + .../react/src/components/StatusBar.jsx | 28 + .../react/src/components/StreamView.jsx | 187 + src/frontend/react/src/hooks/useApi.js | 41 + src/frontend/react/src/hooks/useWebSocket.js | 88 + src/frontend/react/src/hooks/useZoomPan.js | 51 + src/frontend/react/src/index.css | 40 + src/frontend/react/src/main.jsx | 10 + src/frontend/react/src/store.js | 41 + src/frontend/react/vite.config.js | 16 + tests/__init__.py | 0 tests/conftest.py | 6 + tests/test_api.py | 14 + tests/test_models.py | 25 + tests/test_services.py | 73 + 58 files changed, 6987 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 STATUS.md create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 scripts/entrypoint.sh create mode 100644 src/backend/__init__.py create mode 100644 src/backend/api/__init__.py create mode 100644 src/backend/api/dependencies.py create mode 100644 src/backend/api/routes.py create mode 100644 src/backend/api/websocket.py create mode 100644 src/backend/config.py create mode 100644 src/backend/main.py create mode 100644 src/backend/models/__init__.py create mode 100644 src/backend/models/api.py create mode 100644 src/backend/models/nats.py create mode 100644 src/backend/nats_client/__init__.py create mode 100644 src/backend/nats_client/connection.py create mode 100644 src/backend/nats_client/discovery.py create mode 100644 src/backend/nats_client/handlers.py create mode 100644 src/backend/nats_client/subjects.py create mode 100644 src/backend/services/__init__.py create mode 100644 src/backend/services/camera.py create mode 100644 src/backend/services/detector.py create mode 100644 src/backend/services/image_saver.py create mode 100644 src/frontend/react/.gitignore create mode 100644 src/frontend/react/README.md create mode 100644 src/frontend/react/eslint.config.js create mode 100644 src/frontend/react/index.html create mode 100644 src/frontend/react/package-lock.json create mode 100644 src/frontend/react/package.json create mode 100644 src/frontend/react/public/favicon.svg create mode 100644 src/frontend/react/public/icons.svg create mode 100644 src/frontend/react/src/App.jsx create mode 100644 src/frontend/react/src/components/CalibrationPanel.jsx create mode 100644 src/frontend/react/src/components/CameraControls.jsx create mode 100644 src/frontend/react/src/components/DepthView.jsx create mode 100644 src/frontend/react/src/components/Header.jsx create mode 100644 src/frontend/react/src/components/PointCloudView.jsx create mode 100644 src/frontend/react/src/components/SplitView.jsx create mode 100644 src/frontend/react/src/components/StatusBar.jsx create mode 100644 src/frontend/react/src/components/StreamView.jsx create mode 100644 src/frontend/react/src/hooks/useApi.js create mode 100644 src/frontend/react/src/hooks/useWebSocket.js create mode 100644 src/frontend/react/src/hooks/useZoomPan.js create mode 100644 src/frontend/react/src/index.css create mode 100644 src/frontend/react/src/main.jsx create mode 100644 src/frontend/react/src/store.js create mode 100644 src/frontend/react/vite.config.js create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_models.py create mode 100644 tests/test_services.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b862d3a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git/ +.gitignore +.claude/ +.omc/ +.pytest_cache/ +__pycache__/ +tests/ +docs/ +*.md +src/libascam_bridge.so diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b588793 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Server +API_HOST=0.0.0.0 +API_PORT=8878 + +# NATS +NATS_URL=nats://nats:4222 +NATS_TOKEN= + +# Service identity +SERVICE_NAME=realsense-d435 +SERVICE_DOMAIN=vision + +# Security — set to a non-empty string to require X-Api-Key on every REST call +API_KEY= + +# RealSense stream +CAMERA_WIDTH=640 +CAMERA_HEIGHT=480 +CAMERA_FPS=30 + +# Auto-open the first discovered camera at container startup (1=on, 0=off). +# With auto-open, the service is fully self-service: no /api/open call needed. +CAMERA_AUTO_OPEN=1 + +# CORS — wildcard is OK for a LAN-only service. Tighten if exposed publicly. +CORS_ORIGINS=* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00cf0c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ + +# uv +.venv/ + +# Environment +.env +!.env.example + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Node +node_modules/ +src/frontend/react/dist/ + +# Docker +*.log + +# Compiled bridge (rebuilt from source) +src/backend/bridge/libascam_bridge.so + +# Claude / OMC +.claude/ +.omc/ +.pytest_cache/ +images/* +models/* diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7b1c5bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,117 @@ +# Multi-stage build for the RealSense D4xx API server on aarch64 (Jetson) +# and x86_64. The Intel apt repo ships librealsense2 runtime for arm64 +# but no pyrealsense2 wheel, so the Python bindings are built from source +# in a dedicated builder stage and copied into the runtime image. + +# --------------------------------------------------------------------------- +# Stage 1: build pyrealsense2 (Python bindings) from source +# --------------------------------------------------------------------------- +FROM ubuntu:22.04 AS librealsense-builder +ENV DEBIAN_FRONTEND=noninteractive +ARG RS_VERSION=v2.55.1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git cmake build-essential pkg-config ca-certificates \ + libssl-dev libusb-1.0-0-dev libudev-dev \ + python3 python3-dev python3-pip \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +RUN git clone --depth 1 --branch ${RS_VERSION} https://github.com/IntelRealSense/librealsense.git + +WORKDIR /src/librealsense/build +RUN cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_GRAPHICAL_EXAMPLES=OFF \ + -DBUILD_TOOLS=OFF \ + -DBUILD_UNIT_TESTS=OFF \ + -DBUILD_NETWORK_DEVICE=OFF \ + -DBUILD_PYTHON_BINDINGS=ON \ + -DPYTHON_EXECUTABLE=/usr/bin/python3 \ + -DCMAKE_INSTALL_PREFIX=/opt/install \ + && make -j$(nproc) \ + && make install + +# librealsense@v2.55.1 has a buggy install rule that doesn't install the +# main pyrealsense2.so — only pyrsutils.so. We pick the main .so straight +# from the build dir to work around it. +RUN mkdir -p /opt/pyrealsense2 \ + && cp -r /usr/lib/python3/dist-packages/pyrealsense2/. /opt/pyrealsense2/ \ + && cp /src/librealsense/build/Release/pyrealsense2.cpython-310-aarch64-linux-gnu.so /opt/pyrealsense2/ \ + && ls /opt/pyrealsense2/pyrealsense2.cpython-310-aarch64-linux-gnu.so > /dev/null \ + && printf 'from .pyrealsense2 import *\n' > /opt/pyrealsense2/__init__.py \ + && echo "=== /opt/pyrealsense2 contents ===" \ + && ls -la /opt/pyrealsense2 + +# --------------------------------------------------------------------------- +# Stage 2: build React frontend +# --------------------------------------------------------------------------- +FROM node:20-alpine AS frontend-build +WORKDIR /app +COPY src/frontend/react/package*.json ./ +RUN npm ci +COPY src/frontend/react/ . +RUN npm run build + +# --------------------------------------------------------------------------- +# Stage 3: runtime image +# --------------------------------------------------------------------------- +FROM ubuntu:22.04 AS runtime +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg \ + python3 python3-pip python3-venv \ + libusb-1.0-0 libudev1 \ + libgl1 libglib2.0-0 libsm6 libxext6 libxrender1 \ + usbutils \ + && rm -rf /var/lib/apt/lists/* + +# Intel RealSense apt repo for the librealsense2 native runtime (pinned to v2.55.1). +# Intel rotated the signing key in 2025: the legacy librealsense.pgp file on +# their server is the old 2018 key (C8B3A55A...), but the current `jammy` +# Packages index is signed by the new key FB0B24895113F120. We fetch the new +# key from Ubuntu's keyserver and dearmor it into the trusted keyring. +RUN mkdir -p /etc/apt/keyrings \ + && curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xFB0B24895113F120" \ + | gpg --dearmor -o /etc/apt/keyrings/librealsense.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/librealsense.gpg] https://librealsense.intel.com/Debian/apt-repo jammy main" \ + > /etc/apt/sources.list.d/librealsense.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + librealsense2=2.55.1-0~realsense.3337 \ + librealsense2-udev-rules=2.55.1-0~realsense.3337 \ + && rm -rf /var/lib/apt/lists/* + +# Python bindings (built in stage 1). System Python 3.10 looks in +# /usr/lib/python3/dist-packages, so install there. +COPY --from=librealsense-builder /opt/pyrealsense2 /usr/lib/python3/dist-packages/pyrealsense2 + +WORKDIR /app + +# Python dependencies — keep this layer cacheable: install before copying source. +RUN pip3 install --no-cache-dir \ + "fastapi>=0.115" \ + "uvicorn[standard]>=0.34" \ + "pydantic>=2.0" \ + "pydantic-settings>=2.0" \ + "python-dotenv>=1.0" \ + "nats-py>=2.9" \ + "numpy>=1.24,<2" \ + "opencv-python-headless>=4.8" + +# Application source +COPY src/backend/ src/backend/ +COPY --from=frontend-build /app/dist src/frontend/react/dist/ + +# Entrypoint +COPY scripts/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Optional .env (compose can also inject via env_file) +COPY .env* ./ + +EXPOSE 8878 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a244c1 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# Intel RealSense D435 — API Server + +Server FastAPI per la depth camera Intel RealSense D4xx (testato su D435) +con accesso remoto via REST/WebSocket. Esecuzione tramite Docker. Mantiene +la stessa shape API del servizio Nuwa HP60C (envelope `ApiResponse`, +endpoint `/api/...`, header `X-Api-Key`, WebSocket `/ws`) così che i client +e il frontend React esistenti funzionano senza modifiche. + +## Caratteristiche + +- **RGB**: configurabile (default 640x480) BGR8, allineato al depth +- **Depth**: 640x480 uint16 in millimetri (allineato al color via `rs.align`) +- **Intrinsics**: parametri intrinseci color + depth e estrinseci depth→color +- **Discovery**: enumerazione devices via `pyrealsense2` +- **API Server**: REST + WebSocket con envelope `ApiResponse` standard +- **Sicurezza**: API Key opzionale (`X-Api-Key`) su tutti gli endpoint REST +- **NATS**: messaging asincrono opzionale (profilo `nats`) +- **Frontend React**: viewer RGB, depth e 3D point cloud (riusato dal Nuwa) +- **Swagger**: documentazione interattiva API a `/apidocs` + +## Hardware + +| Parametro | Valore | +|-----------|--------| +| Camera | Intel RealSense D435 | +| Serial number (questo robot) | `933322070696` | +| Firmware | `05.16.00.01` | +| Range depth | 0.1m – 10m | +| Interfaccia | USB 3.0 | +| SDK | librealsense 2.55.1 + pyrealsense2 (built from source) | +| Host | Jetson Xavier NX, Ubuntu 20.04 (JetPack 5.1.x) | + +## Configurazione + +Crea `.env` (vedi `.env.example`): + +```env +API_PORT=8878 +API_KEY=cambia-questa-chiave-segreta +CAMERA_WIDTH=640 +CAMERA_HEIGHT=480 +CAMERA_FPS=30 +CAMERA_AUTO_OPEN=1 # apre la camera al boot, niente /api/open manuale +CORS_ORIGINS=* # LAN-only OK +``` + +## Accesso dalla LAN + +Il container gira sul robot e binda la porta `8878` su tutte le interfacce +(`0.0.0.0`). Dal vostro PC sulla stessa LAN raggiungete il servizio via: + +``` +http://:8878/api/health # health check +http://:8878/apidocs # Swagger UI +http://:8878/gui # frontend React +ws://:8878/ws # stream WebSocket +``` + +Il robot ha Avahi attivo, quindi `ubuntu.local:8878` funziona se mDNS +risolve sul vostro PC (`avahi-resolve -n ubuntu.local`). Altrimenti +usate l'IP diretto (`ip a` sul robot per scoprirlo). + +> Lo streaming **non richiede internet** — solo la build (~25 min al primo +> avvio) deve raggiungere registry Docker/apt/PyPI/npm via il tinyproxy +> locale del robot. + +## Build & avvio + +```bash +# Backend (porta 8878) +docker compose up --build -d + +# Backend + NATS +docker compose --profile nats up --build -d +``` + +> Il primo build compila i bindings Python di `pyrealsense2` da source — +> ~20–30 min su Jetson. I build successivi sono cached. + +Il container: +- non fa unbind UVC (a differenza del Nuwa) — RealSense usa direttamente uvcvideo +- monta `/dev/bus/usb` in privileged mode per accesso diretto USB +- riparte automaticamente (`restart: unless-stopped`) + +## Endpoints + +Identici al Nuwa per drop-in compatibility: + +| Endpoint | Metodo | Descrizione | +|----------|--------|-------------| +| `/api/health` | GET | Health check (no auth) | +| `/api/status` | GET | Stato SDK/camera e calibrazione | +| `/api/discover` | GET | Rileva camere RealSense su USB | +| `/api/open` | POST | Apre camera per indice (`device_index`) | +| `/api/close` | POST | Chiude la camera | +| `/api/capture` | GET | Frame RGB+depth (JSON, base64) | +| `/api/rgb` | GET | Frame RGB come JPEG | +| `/api/depth` | GET | Frame depth come PNG 16-bit (mm) | +| `/api/depth/color` | GET | Depth colormap JET come JPEG | +| `/api/intrinsics` | GET | Calibrazione completa | +| `/api/pointcloud` | GET | Point cloud (subsampling `step`) | +| `/api/pointcloud/roi` | GET | Point cloud su ROI pixel-space | +| `/api/measure` | GET | Backprojection 3D di un pixel | +| `/api/snapshot` | POST | Salva snapshot RGB | +| `/api/recording/{start,stop,status}` | POST/GET | Registrazione continua | +| `/ws` | WS | Streaming WebSocket (text msgs: `start`, `stop`, `close`) | + +#### Payload calibrazione + +`/api/intrinsics` ritorna i campi `ir` e `rgb` per compatibilità con il +frontend Nuwa, ma in questo servizio: + +- `ir` = intrinseci del **sensore depth** (post-rectification dello stereo IR) +- `rgb` = intrinseci del **sensore color** +- `extrinsics_ir_to_rgb` = trasformazione **depth → color** + +```json +{ + "ir": { "fx": ..., "fy": ..., "cx": ..., "cy": ..., + "distortion": { "k1":..., "k2":..., "k3":..., "p1":..., "p2":... } }, + "rgb": { "fx": ..., "fy": ..., "cx": ..., "cy": ..., + "distortion": { ... } }, + "extrinsics_ir_to_rgb": { + "rotation": [r00, r01, r02, r10, r11, r12, r20, r21, r22], + "translation": [tx, ty, tz] + } +} +``` + +- Rotation row-major 3x3 +- Translation in **millimetri** (la D4xx ritorna `extrinsics.translation` in + metri; convertito qui per allinearsi alla convenzione Nuwa) +- D4xx pubblica tipicamente coefficienti di distorsione tutti zero (immagini + già rettificate dall'ASIC) + +### Esempi curl + +```bash +# Discover +curl -H "X-Api-Key: $API_KEY" http://localhost:8878/api/discover + +# Apri (D435 in genere index 0) +curl -X POST -H "X-Api-Key: $API_KEY" \ + "http://localhost:8878/api/open?device_index=0" + +# Frame RGB +curl -H "X-Api-Key: $API_KEY" http://localhost:8878/api/rgb -o rgb.jpg + +# Frame depth (PNG 16-bit, valori in mm) +curl -H "X-Api-Key: $API_KEY" http://localhost:8878/api/depth -o depth.png + +# Intrinsics +curl -H "X-Api-Key: $API_KEY" http://localhost:8878/api/intrinsics | jq + +# Close +curl -X POST -H "X-Api-Key: $API_KEY" http://localhost:8878/api/close +``` + +## Note + +- `librealsense2` runtime nel container è installato dal repo apt ufficiale + Intel (pinned a `2.55.1-0~realsense.3337`). I bindings Python sono + buildati da `IntelRealSense/librealsense@v2.55.1`. +- Il frontend React originale (`/gui`) referenzia `ir` e `rgb` come label di + display — su questo servizio quei nomi mappano depth/color (vedi sopra). +- L'autostart è gestito da Docker (`restart: unless-stopped`). Per partire + automaticamente al boot del robot serve `systemctl enable docker`. +- Per disabilitare l'auth API setta `API_KEY=` (vuoto). Sconsigliato in produzione. diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..b20e4f1 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,79 @@ +# Stato del progetto RealSense-D435 — 2026-05-19 + +## TL;DR + +- ✅ Driver completo e funzionante su questo robot (Jetson Xavier NX, D435 su USB 2.1) +- ✅ RGB 640x480 + Depth 640x480 simultanei via REST/WebSocket +- ✅ Intrinsics, point cloud, measure, snapshot, recording, WebSocket stream — tutti operativi +- ⚠️ Framerate effettivo ~4 fps (limitato dalla banda USB 2.0). Su porta USB 3 il driver riprende automaticamente 640x480@30. + +## Cosa è stato risolto in questa sessione + +1. **Bug install di librealsense v2.55.1**: il `.so` principale di `pyrealsense2` non viene installato dal CMake — solo `pyrsutils.so`. Workaround nel `Dockerfile`: si copia il `.so` direttamente da `/src/librealsense/build/Release/` e si aggiunge un `__init__.py` che riesporta il submodule (`from .pyrealsense2 import *`). +2. **D4xx su USB 2 — formato color**: BGR8/RGB8 vanno in timeout su USB 2 anche da soli (firmware D4xx). Solo **YUYV** sostiene il transfer su USB 2. Il driver ora rileva `usb_type_descriptor` e su USB 2.x configura color = YUYV, convertendo in BGR via `cv2.cvtColor` in `capture()`. +3. **`rs.align` incompatibile con YUYV**: il blocco di processing fallisce con "Error occurred during execution of the processing block". Su USB 2 saltiamo l'alignment hardware e facciamo un resize numpy del color per matchare la shape del depth (approximate alignment, OK per use case standard). +4. **Risoluzione massima USB 2**: 640x480@15 (configurato) → ~4 fps reali. Testate tutte le combinazioni; 848x480 dà "Couldn't resolve requests" anche in YUYV. + +## Hardware setup (constatato) + +``` +Jetson Xavier NX +├─ Bus 1 (USB 2.0, 480M) +│ ├─ Hub interno → Inspire Hands (cdc_acm /dev/ttyACM0) + ch341 +│ └─ Hub esterno (collegato a porta USB-C frontale) +│ ├─ Tastiera, mouse, ax88179 (Ethernet) +│ └─ D435 ← qui +└─ Bus 2 (USB 3.0, 10000M) — VUOTO, ma probabilmente non esposto esternamente +``` + +- D435 serial: `933322070696`, fw `05.16.00.01`, USB `8086:0b07`, `usb_type = 2.1` +- La porta USB-C frontale del robot H1 è cablata **USB 2 hardware** (confermato indirettamente: l'ax88179 sullo stesso hub gira a 480M) +- Le Inspire Hands sono su hub interno separato — non vanno toccate + +## Endpoints (verificati) + +| Endpoint | HTTP | Note | +|---|---|---| +| `/api/health` | 200 | sdk_initialized=true, camera_open=true | +| `/api/status` | 200 | include `color_available`, `usb_type` | +| `/api/discover` | 200 | rileva D435 serial `933322070696` | +| `/api/rgb` | 200 | JPEG 640x480 | +| `/api/depth` | 200 | PNG 16-bit 640x480 | +| `/api/depth/color` | 200 | JPEG colormap JET 640x480 | +| `/api/capture` | 200 | JSON con tutte e tre base64 | +| `/api/intrinsics` | 200 | ir + rgb + extrinsics_ir_to_rgb | +| `/api/pointcloud`, `/api/pointcloud/roi`, `/api/measure`, `/api/snapshot` | 200 | da testare in scenario reale | + +## File modificati in questa sessione + +- `Dockerfile`: fix install pyrealsense2 (`.so` + `__init__.py`) +- `src/backend/services/camera.py`: + - rileva `usb_type_descriptor` in `open()` + - profilo USB 2 (YUYV 640x480@15 color + Z16 640x480@15 depth) + - conversione YUYV → BGR in `capture()` via `cv2.cvtColor` (manuale, dopo aver ricostruito lo shape (H,W,2)) + - skip `rs.align` su USB 2; resize numpy del color per matchare il depth + - nuovi metodi `is_color_available()`, `usb_type()` per diagnostica +- `src/backend/api/routes.py`: `/api/status` espone `color_available` + `usb_type` + +## Da fare prossima volta + +- Testare `/api/pointcloud` e `/api/measure` in un ambiente reale e verificare che l'approximate alignment (resize) sia accettabile per il caso d'uso. Se no, implementare alignment proprio con `cv2.remap` usando intrinsics + extrinsics. +- Decidere se esporre il flag `usb_type` nel frontend React per warning utente +- Eventualmente: profilo configurabile via `.env` per chi vuole controllare manualmente + +## Modifiche al setup ambiente (persistono nei reboot) + +1. `/etc/systemd/system/docker.service.d/http-proxy.conf` (proxy daemon) +2. `unitree` nel gruppo `docker` +3. `~/.docker/config.json` con proxy CLI + cli-plugins path +4. `~/.docker/cli-plugins/docker-compose` (plugin v2) + +## Avvio rapido + +```bash +cd ~/Robot_2026/RealSense-D435 +sg docker -c "docker compose up -d" # già buildato +curl -s http://localhost:8878/api/health # check +curl -s http://localhost:8878/api/rgb -o rgb.jpg +curl -s http://localhost:8878/api/depth -o depth.png +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..79aba54 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + + # -- NATS Server ---------------------------- + nats: + image: nats:latest + profiles: ["nats"] + ports: + - "4222:4222" + - "8222:8222" + command: > + --auth ${NATS_TOKEN:-realsense-dev-token} + --http_port 8222 + restart: unless-stopped + healthcheck: + test: ["CMD", "nats-server", "--help"] + interval: 5s + timeout: 3s + retries: 3 + start_period: 5s + + # -- Backend -------------------------------- + backend: + build: + context: . + dockerfile: Dockerfile + # network: host so that build-time apt/git/pip can reach the + # tinyproxy running on the host at localhost:8888. + network: host + privileged: true + ports: + - "${API_PORT:-8878}:8878" + devices: + - "/dev/bus/usb:/dev/bus/usb" + volumes: + - ./images:/app/images + - ./models:/app/models:ro + env_file: .env + depends_on: + nats: + condition: service_healthy + required: false + restart: unless-stopped + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8878/api/health')"] + interval: 10s + timeout: 5s + retries: 3 + + # -- Frontend React Dev (hot reload) -------- + frontend-dev: + image: node:20-alpine + working_dir: /app + volumes: + - ./src/frontend/react:/app + - /app/node_modules + command: sh -c "npm ci && npm run dev -- --host 0.0.0.0" + ports: + - "5173:5173" + profiles: [dev] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2ce5296 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "realsense-d435" +version = "1.0.0" +description = "REST API + WebSocket server for Intel RealSense D4xx depth camera (pyrealsense2)" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.34", + "pydantic>=2.0", + "pydantic-settings>=2.0", + "python-dotenv>=1.0", + "nats-py>=2.9", + "numpy>=1.24,<2", + "opencv-python-headless>=4.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "httpx>=0.27", +] +yolo = [ + "onnxruntime>=1.16", +] + +[project.scripts] +start = "uvicorn src.backend.main:app --host 0.0.0.0 --port 8878" +dev = "uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8878" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..ccd86ff --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# RealSense D4xx uses uvcvideo via the librealsense2 backend, so no driver +# unbind is needed — unlike the Nuwa HP60C entrypoint. +# +# librealsense expects the udev rules to be present on the host. The rules +# package is installed inside the image too (librealsense2-udev-rules) but +# udev itself runs on the host; if the camera is not visible to lsusb the +# host udev rules are likely missing or stale. + +set -e + +PORT=${API_PORT:-8878} + +echo "[entrypoint] Starting RealSense D4xx API server on port ${PORT}..." +exec python3 -m uvicorn src.backend.main:app --host 0.0.0.0 --port "${PORT}" diff --git a/src/backend/__init__.py b/src/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/__init__.py b/src/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/dependencies.py b/src/backend/api/dependencies.py new file mode 100644 index 0000000..7624306 --- /dev/null +++ b/src/backend/api/dependencies.py @@ -0,0 +1,9 @@ +from fastapi import Header, HTTPException +from src.backend.config import settings + + +async def verify_api_key(x_api_key: str | None = Header(None)): + if not settings.api_key: + return + if x_api_key != settings.api_key: + raise HTTPException(status_code=403, detail="API key non valida") diff --git a/src/backend/api/routes.py b/src/backend/api/routes.py new file mode 100644 index 0000000..dd631d6 --- /dev/null +++ b/src/backend/api/routes.py @@ -0,0 +1,537 @@ +"""REST API routes for the RealSense D435 camera server.""" + +import base64 +import logging + +import cv2 +import numpy as np +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response, JSONResponse + +from src.backend.api.dependencies import verify_api_key +from src.backend.models.api import ok, fail +from src.backend.services.camera import RealSenseD435, CameraFrame +from src.backend.services.image_saver import ImageSaver + +logger = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(verify_api_key)]) + +_camera: RealSenseD435 | None = None +_saver: ImageSaver | None = None +_frame_count = 0 + + +def set_camera(camera: RealSenseD435) -> None: + global _camera + _camera = camera + + +def set_image_saver(saver: ImageSaver) -> None: + global _saver + _saver = saver + + +# --------------------------------------------------------------------------- +# Image encoding helpers +# --------------------------------------------------------------------------- + +def _encode_rgb_jpeg(rgb: np.ndarray, quality: int = 85) -> bytes: + _, buf = cv2.imencode(".jpg", rgb, [cv2.IMWRITE_JPEG_QUALITY, quality]) + return buf.tobytes() + + +def _encode_depth_png(depth: np.ndarray) -> bytes: + _, buf = cv2.imencode(".png", depth) + return buf.tobytes() + + +def _depth_colormap(depth: np.ndarray) -> np.ndarray: + valid = depth[depth > 0] + if len(valid) == 0: + return np.zeros((*depth.shape, 3), dtype=np.uint8) + p5, p95 = np.percentile(valid, [5, 95]) + vis = np.zeros_like(depth, dtype=np.uint8) + mask = depth > 0 + vis[mask] = np.clip( + (depth[mask].astype(float) - p5) / max(p95 - p5, 1) * 255, 0, 255 + ).astype(np.uint8) + return cv2.applyColorMap(vis, cv2.COLORMAP_JET) + + +def _frame_to_dict(frame: CameraFrame, include_images: bool = True) -> dict: + global _frame_count + _frame_count += 1 + + result = { + "frame_id": _frame_count, + "timestamp": frame.timestamp, + "rgb_shape": list(frame.rgb.shape), + "depth_shape": list(frame.depth.shape), + "depth_min_mm": int(frame.depth[frame.depth > 0].min()) if (frame.depth > 0).any() else 0, + "depth_max_mm": int(frame.depth.max()), + "depth_valid_pixels": int((frame.depth > 0).sum()), + } + + if include_images: + result["rgb_jpeg_b64"] = base64.b64encode(_encode_rgb_jpeg(frame.rgb)).decode() + result["depth_png_b64"] = base64.b64encode(_encode_depth_png(frame.depth)).decode() + colormap = _depth_colormap(frame.depth) + result["depth_color_b64"] = base64.b64encode(_encode_rgb_jpeg(colormap, 90)).decode() + + return result + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +# /health is mounted as public (no API key) in main.py + + +def _calibration_payload() -> dict | None: + """Full calibration (intrinsics + distortion + extrinsics) or None.""" + if not _camera or not _camera.intrinsics: + return None + i = _camera.intrinsics + e = _camera.extrinsics + dir_ = _camera.distortion_ir + drgb = _camera.distortion_rgb + + def _d(d): + return None if d is None else {"k1": d.k1, "k2": d.k2, "k3": d.k3, "p1": d.p1, "p2": d.p2} + + return { + "ir": { + "fx": i.fx_ir, "fy": i.fy_ir, "cx": i.cx_ir, "cy": i.cy_ir, + "distortion": _d(dir_), + }, + "rgb": { + "fx": i.fx_rgb, "fy": i.fy_rgb, "cx": i.cx_rgb, "cy": i.cy_rgb, + "distortion": _d(drgb), + }, + "extrinsics_ir_to_rgb": None if e is None else { + "rotation": list(e.rotation), + "translation": list(e.translation), + }, + } + + +@router.get("/status") +async def status(): + """Camera status and intrinsics.""" + return ok({ + "sdk_initialized": _camera.is_initialized() if _camera else False, + "camera_open": _camera.is_open() if _camera else False, + "color_available": _camera.is_color_available() if (_camera and _camera.is_open()) else False, + "usb_type": _camera.usb_type() if (_camera and _camera.is_open()) else None, + "frame_count": _frame_count, + "resolution": {"width": 640, "height": 480}, + "intrinsics": _calibration_payload(), + }) + + +@router.get("/discover") +async def discover(timeout_ms: int = Query(default=5000, ge=1000, le=30000)): + """Discover available RealSense cameras on USB bus.""" + if not _camera or not _camera.is_initialized(): + return fail("SDK_NOT_INITIALIZED", "SDK not initialized") + + devices = _camera.discover(timeout_ms=timeout_ms) + return ok({ + "cameras": [ + { + "index": d.index, + "vid": f"{d.vid:04x}", + "pid": f"{d.pid:04x}", + "serial": d.serial, + "bus": d.bus, + "device": d.device, + } + for d in devices + ], + "count": len(devices), + }) + + +@router.post("/open") +async def open_camera(device_index: int = Query(default=0, ge=0)): + """Open a discovered camera by index and start streaming. + + Idempotent: if a camera is already open, returns the current open state + instead of failing — the server only supports one open camera at a time. + """ + if not _camera or not _camera.is_initialized(): + return fail("SDK_NOT_INITIALIZED", "SDK not initialized") + + if _camera.is_open(): + return ok({ + "status": "open", + "device_index": _camera.device_index, + "intrinsics": _calibration_payload(), + "already_open": True, + }) + + if _camera.open(device_index): + return ok({ + "status": "open", + "device_index": device_index, + "intrinsics": _calibration_payload(), + "already_open": False, + }) + else: + return fail("OPEN_FAILED", "Failed to open camera") + + +@router.post("/close") +async def close_camera(): + """Close the active camera and stop streaming.""" + if not _camera or not _camera.is_open(): + return fail("CAMERA_NOT_OPEN", "No camera is open") + + _camera.close() + return ok({"status": "closed"}) + + +@router.get("/capture") +async def capture(): + """Capture a single RGB+depth frame. Returns JSON with base64-encoded images.""" + if not _camera or not _camera.is_open(): + return fail("CAMERA_NOT_OPEN", "Camera not open") + + frame = _camera.capture() + if frame is None: + return fail("CAPTURE_TIMEOUT", "Frame capture timeout") + + return JSONResponse(ok(_frame_to_dict(frame))) + + +@router.get("/rgb") +async def get_rgb(): + """Get RGB frame as JPEG image.""" + if not _camera or not _camera.is_open(): + raise HTTPException(503, "Camera not open") + + rgb = _camera.get_rgb() + if rgb is None: + raise HTTPException(504, "No RGB frame") + + return Response(content=_encode_rgb_jpeg(rgb), media_type="image/jpeg") + + +@router.get("/depth") +async def get_depth(): + """Get depth frame as 16-bit PNG (values in mm).""" + if not _camera or not _camera.is_open(): + raise HTTPException(503, "Camera not open") + + depth = _camera.get_depth() + if depth is None: + raise HTTPException(504, "No depth frame") + + return Response(content=_encode_depth_png(depth), media_type="image/png") + + +@router.get("/depth/color") +async def get_depth_colormap(): + """Get depth frame as JET colormap JPEG.""" + if not _camera or not _camera.is_open(): + raise HTTPException(503, "Camera not open") + + depth = _camera.get_depth() + if depth is None: + raise HTTPException(504, "No depth frame") + + colormap = _depth_colormap(depth) + return Response(content=_encode_rgb_jpeg(colormap, 90), media_type="image/jpeg") + + +@router.get("/pointcloud", summary="3D Point cloud", tags=["capture"]) +async def get_pointcloud( + step: int = Query(default=4, ge=1, le=16), + max_depth_mm: int = Query(default=4000, ge=100, le=10000), +): + """Get 3D point cloud as JSON array of [x,y,z,r,g,b] points. + + Points are computed server-side using depth + RGB + intrinsics. + step: subsampling factor (4 = every 4th pixel, faster) + max_depth_mm: discard points beyond this depth + """ + if not _camera or not _camera.is_open(): + return fail("NO_CAMERA", "Camera not open") + + frame = _camera.capture() + if frame is None: + return fail("TIMEOUT", "Frame capture timeout") + + depth = frame.depth + rgb = frame.rgb # BGR format + + # Use intrinsics if available, otherwise use typical HP60C defaults + if _camera.intrinsics: + fx = _camera.intrinsics.fx_ir + fy = _camera.intrinsics.fy_ir + cx = _camera.intrinsics.cx_ir + cy = _camera.intrinsics.cy_ir + else: + # Default intrinsics for 640x480 HP60C + fx, fy = 445.0, 445.0 + cx, cy = 320.0, 240.0 + + h, w = depth.shape + points = [] + + for y in range(0, h, step): + for x in range(0, w, step): + z_val = float(depth[y, x]) + if z_val <= 0 or z_val > max_depth_mm: + continue + z_m = z_val / 1000.0 + x_m = (x - cx) * z_m / fx + y_m = (y - cy) * z_m / fy + b_val, g_val, r_val = rgb[y, x] # BGR to RGB + points.append([ + round(x_m, 4), round(-y_m, 4), round(-z_m, 4), + int(r_val), int(g_val), int(b_val) + ]) + + return ok({ + "points": points, + "count": len(points), + "step": step, + "intrinsics": { + "fx": fx, "fy": fy, "cx": cx, "cy": cy + } + }) + + +@router.get("/pointcloud/roi", summary="3D Point cloud within ROI", tags=["capture"]) +async def get_pointcloud_roi( + u_min: int = Query(..., ge=0, description="ROI left pixel (inclusive)"), + v_min: int = Query(..., ge=0, description="ROI top pixel (inclusive)"), + u_max: int = Query(..., ge=1, description="ROI right pixel (exclusive)"), + v_max: int = Query(..., ge=1, description="ROI bottom pixel (exclusive)"), + step: int = Query(default=1, ge=1, le=16), + max_depth_mm: int = Query(default=4000, ge=100, le=10000), +): + """Get 3D point cloud restricted to a pixel-space ROI. + + Coordinates are RAW image pixels (no flip applied) in the depth/RGB + frame (typically 640x480). Points are returned as [x, -y, -z, r, g, b] + — same convention as /pointcloud so the frontend renderer is compatible. + """ + if not _camera or not _camera.is_open(): + return fail("NO_CAMERA", "Camera not open") + + frame = _camera.capture() + if frame is None: + return fail("TIMEOUT", "Frame capture timeout") + + depth = frame.depth + rgb = frame.rgb # BGR format + h, w = depth.shape + + u0 = max(0, min(w, u_min)) + v0 = max(0, min(h, v_min)) + u1 = max(0, min(w, u_max)) + v1 = max(0, min(h, v_max)) + if u1 <= u0 or v1 <= v0: + return fail("INVALID_ROI", f"Empty ROI ({u0},{v0})-({u1},{v1}) in {w}x{h}") + + if _camera.intrinsics: + fx = _camera.intrinsics.fx_ir + fy = _camera.intrinsics.fy_ir + cx = _camera.intrinsics.cx_ir + cy = _camera.intrinsics.cy_ir + else: + fx, fy = 445.0, 445.0 + cx, cy = 320.0, 240.0 + + # Vectorized: restrict both arrays to the ROI and filter by depth mask. + roi_depth = depth[v0:v1:step, u0:u1:step].astype(np.float32) + roi_rgb = rgb[v0:v1:step, u0:u1:step] + mask = (roi_depth > 0) & (roi_depth <= max_depth_mm) + + if not mask.any(): + return ok({ + "points": [], + "count": 0, + "step": step, + "roi": {"u_min": u0, "v_min": v0, "u_max": u1, "v_max": v1}, + "intrinsics": {"fx": fx, "fy": fy, "cx": cx, "cy": cy}, + }) + + ys_local, xs_local = np.where(mask) + zs_mm = roi_depth[ys_local, xs_local] + # Absolute pixel coordinates within the full image (needed for fx/cx back-projection). + xs_abs = u0 + xs_local * step + ys_abs = v0 + ys_local * step + zs_m = zs_mm / 1000.0 + xs_m = (xs_abs - cx) * zs_m / fx + ys_m = (ys_abs - cy) * zs_m / fy + bgr = roi_rgb[ys_local, xs_local] # (N, 3) BGR + + # Match /pointcloud convention: [x, -y, -z, r, g, b] + points = np.stack([ + np.round(xs_m, 4), + np.round(-ys_m, 4), + np.round(-zs_m, 4), + bgr[:, 2].astype(np.float32), + bgr[:, 1].astype(np.float32), + bgr[:, 0].astype(np.float32), + ], axis=1).tolist() + + return ok({ + "points": points, + "count": len(points), + "step": step, + "roi": {"u_min": u0, "v_min": v0, "u_max": u1, "v_max": v1}, + "intrinsics": {"fx": fx, "fy": fy, "cx": cx, "cy": cy}, + }) + + +@router.get("/measure", summary="3D measurement at pixel", tags=["capture"]) +async def measure_point( + u: int = Query(..., ge=0, description="Pixel X in raw image coords (0..W-1)"), + v: int = Query(..., ge=0, description="Pixel Y in raw image coords (0..H-1)"), + radius: int = Query(default=6, ge=0, le=50, description="Search radius (px) for nearest valid depth"), +): + """Find nearest valid-depth pixel around (u, v) and back-project to 3D. + + Useful for testing RGB/depth alignment: click on RGB at a pixel, get the + closest point-cloud point within `radius`. Coordinates are RAW image pixels + (no flip applied) in the depth/RGB frame (typically 640x480). + """ + if not _camera or not _camera.is_open(): + return fail("CAMERA_NOT_OPEN", "Camera not open") + + frame = _camera.capture() + if frame is None: + return fail("CAPTURE_TIMEOUT", "Frame capture timeout") + + depth = frame.depth + h, w = depth.shape + if u >= w or v >= h: + return fail("OUT_OF_RANGE", f"Pixel ({u},{v}) outside image ({w}x{h})") + + u0 = max(0, u - radius) + u1 = min(w, u + radius + 1) + v0 = max(0, v - radius) + v1 = min(h, v + radius + 1) + window = depth[v0:v1, u0:u1] + ys, xs = np.where(window > 0) + if len(xs) == 0: + return fail("NO_DEPTH", f"No valid depth within radius {radius}") + + abs_xs = xs + u0 + abs_ys = ys + v0 + d2 = (abs_xs - u) ** 2 + (abs_ys - v) ** 2 + idx = int(np.argmin(d2)) + u_found = int(abs_xs[idx]) + v_found = int(abs_ys[idx]) + z_mm = int(depth[v_found, u_found]) + + if _camera.intrinsics: + fx = _camera.intrinsics.fx_ir + fy = _camera.intrinsics.fy_ir + cx = _camera.intrinsics.cx_ir + cy = _camera.intrinsics.cy_ir + else: + fx, fy = 445.0, 445.0 + cx, cy = 320.0, 240.0 + + z_m = z_mm / 1000.0 + x_m = (u_found - cx) * z_m / fx + y_m = (v_found - cy) * z_m / fy + dist_mm = float(np.sqrt(x_m * x_m + y_m * y_m + z_m * z_m) * 1000.0) + + return ok({ + "u": u, + "v": v, + "u_found": u_found, + "v_found": v_found, + "pixel_offset": round(float(np.sqrt(float(d2[idx]))), 2), + "radius": radius, + "depth_mm": z_mm, + "distance_mm": round(dist_mm, 1), + "point_m": { + "x": round(x_m, 4), + "y": round(y_m, 4), + "z": round(z_m, 4), + }, + }) + + +@router.post("/snapshot") +async def snapshot( + flip_h: bool = Query(default=False), + flip_v: bool = Query(default=False), +): + """Capture a single RGB frame and save it as JPG in images_dir.""" + if not _camera or not _camera.is_open(): + return fail("CAMERA_NOT_OPEN", "Camera not open") + if _saver is None: + return fail("SAVER_NOT_READY", "Image saver not initialized") + + frame = _camera.capture() + if frame is None: + return fail("CAPTURE_TIMEOUT", "Frame capture timeout") + + filename = _saver.save_snapshot(frame.rgb, flip_h, flip_v) + return ok({"filename": filename}) + + +@router.post("/recording/start") +async def recording_start( + flip_h: bool = Query(default=False), + flip_v: bool = Query(default=False), + interval: int = Query(default=1, ge=1, le=600), +): + """Start continuous frame recording (applies to WebSocket stream loop). + + interval: save 1 frame every N streamed frames (1 = every frame). + """ + if _saver is None: + return fail("SAVER_NOT_READY", "Image saver not initialized") + if _saver.is_recording: + return fail("ALREADY_RECORDING", "Recording already in progress") + + status = _saver.start_recording(flip_h, flip_v, interval) + return ok(status) + + +@router.post("/recording/stop") +async def recording_stop(): + """Stop continuous frame recording.""" + if _saver is None: + return fail("SAVER_NOT_READY", "Image saver not initialized") + if not _saver.is_recording: + return fail("NOT_RECORDING", "No recording in progress") + + status = _saver.stop_recording() + return ok(status) + + +@router.get("/recording/status") +async def recording_status(): + """Current recording state.""" + if _saver is None: + return fail("SAVER_NOT_READY", "Image saver not initialized") + return ok(_saver.status()) + + +@router.get("/intrinsics") +async def get_intrinsics(): + """Get camera intrinsic parameters.""" + if not _camera: + return fail("INTRINSICS_NOT_AVAILABLE", "Camera driver not ready") + if not _camera.is_open(): + return fail("INTRINSICS_NOT_AVAILABLE", "Camera not open") + if not _camera.intrinsics: + return fail( + "INTRINSICS_NOT_AVAILABLE", + "Camera open but SDK did not return calibration (AS_SDK_GetCamParameter). " + "Check docker logs for 'GetCamParameter ... failed'.", + ) + + return ok(_calibration_payload()) diff --git a/src/backend/api/websocket.py b/src/backend/api/websocket.py new file mode 100644 index 0000000..9318924 --- /dev/null +++ b/src/backend/api/websocket.py @@ -0,0 +1,94 @@ +import asyncio +import logging + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from src.backend.api.routes import _frame_to_dict +from src.backend.services.image_saver import ImageSaver + +try: + from src.backend.services.detector import Detector +except Exception: + Detector = None # onnxruntime is optional; YOLO features stay disabled + +logger = logging.getLogger(__name__) + +ws_router = APIRouter() + +_camera = None +_saver: ImageSaver | None = None +_detector = None # Detector | None when onnxruntime is installed + + +def set_camera(camera): + global _camera + _camera = camera + + +def set_image_saver(saver: ImageSaver) -> None: + global _saver + _saver = saver + + +def set_detector(detector) -> None: + global _detector + _detector = detector + + +@ws_router.websocket("/ws") +async def websocket_stream(ws: WebSocket): + """WebSocket stream of RGB+depth frames. + + Client → server text messages: + 'start' — begin streaming + 'stop' — pause streaming + 'close' — disconnect + 'predict_on' — enable YOLO inference per frame (adds 'detections' to JSON) + 'predict_off' — disable YOLO inference + 'conf:0.55' — set YOLO confidence threshold (0.0–1.0) + """ + await ws.accept() + logger.info("WebSocket client connected") + streaming = False + predicting = False + + try: + while True: + if streaming and _camera and _camera.is_open(): + frame = _camera.capture(timeout_ms=500) + if frame: + data = _frame_to_dict(frame) + if predicting and _detector is not None: + data["detections"] = await asyncio.to_thread(_detector.detect, frame.rgb) + await ws.send_json(data) + if _saver and _saver.is_recording: + _saver.save_rec_frame(frame.rgb) + await asyncio.sleep(0.033) + + try: + msg = await asyncio.wait_for(ws.receive_text(), timeout=0.01) + if msg == "start": + streaming = True + elif msg == "stop": + streaming = False + elif msg == "close": + break + elif msg == "predict_on": + predicting = _detector is not None + if not predicting: + logger.warning("predict_on requested but detector not loaded") + elif msg == "predict_off": + predicting = False + elif msg.startswith("conf:") and _detector is not None: + try: + _detector.set_conf(float(msg.split(":", 1)[1])) + except ValueError: + logger.warning(f"Invalid conf value: {msg!r}") + except asyncio.TimeoutError: + pass + + if not streaming: + await asyncio.sleep(0.1) + + except WebSocketDisconnect: + logger.info("WebSocket client disconnected") diff --git a/src/backend/config.py b/src/backend/config.py new file mode 100644 index 0000000..515f7ed --- /dev/null +++ b/src/backend/config.py @@ -0,0 +1,51 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Server + api_host: str = "0.0.0.0" + api_port: int = 8878 + + # NATS + nats_url: str = "nats://nats:4222" + nats_token: str = "" + + # Service identity + service_name: str = "realsense-d435" + service_domain: str = "vision" + + # Security + api_key: str = "" + + # Storage + images_dir: str = "/app/images" + + # RealSense stream config + camera_width: int = 640 + camera_height: int = 480 + camera_fps: int = 30 + camera_auto_open: bool = True + + # YOLO detector (optional — disabled if model file or onnxruntime missing) + yolo_onnx_path: str = "/app/models/best.onnx" + yolo_classes: str = "" + yolo_conf_default: float = 0.5 + + @property + def yolo_class_list(self) -> list[str]: + return [s.strip() for s in self.yolo_classes.split(",") if s.strip()] + + # CORS (comma-separated string, parsed into list) + cors_origins: str = "http://localhost:5173,http://localhost,http://localhost:80" + + @property + def cors_origins_list(self) -> list[str]: + return [s.strip() for s in self.cors_origins.split(",")] + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + } + + +settings = Settings() diff --git a/src/backend/main.py b/src/backend/main.py new file mode 100644 index 0000000..eafab26 --- /dev/null +++ b/src/backend/main.py @@ -0,0 +1,147 @@ +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from src.backend.config import settings +from src.backend.api.routes import ( + router, + set_camera as set_routes_camera, + set_image_saver as set_routes_saver, +) +from src.backend.api.websocket import ( + ws_router, + set_camera as set_ws_camera, + set_image_saver as set_ws_saver, + set_detector as set_ws_detector, +) +from src.backend.nats_client.connection import nats_manager +from src.backend.nats_client.handlers import set_camera as set_nats_camera +from src.backend.services.camera import RealSenseD435 +from src.backend.services.image_saver import ImageSaver + +try: + from src.backend.services.detector import Detector +except Exception: + Detector = None # YOLO/onnxruntime optional + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + camera = RealSenseD435( + width=settings.camera_width, + height=settings.camera_height, + fps=settings.camera_fps, + ) + sdk_ok = camera.init() + if sdk_ok: + logger.info("SDK initialized") + else: + logger.error( + "SDK init failed — API will stay up so /api/health can be queried, " + "but camera endpoints will return SDK_NOT_INITIALIZED" + ) + + if sdk_ok and settings.camera_auto_open: + try: + devices = camera.discover() + if devices: + if camera.open(0): + logger.info( + f"Auto-opened camera 0 (serial={devices[0].serial})" + ) + else: + logger.warning("Auto-open requested but open(0) failed") + else: + logger.warning("Auto-open requested but no RealSense devices found") + except Exception as exc: + logger.warning(f"Auto-open failed: {exc}") + + # Inject camera into all layers + set_routes_camera(camera) + set_ws_camera(camera) + set_nats_camera(camera) + + # Image saver (snapshot + continuous recording to images_dir) + saver = ImageSaver(Path(settings.images_dir)) + set_routes_saver(saver) + set_ws_saver(saver) + logger.info(f"Image saver ready: {settings.images_dir}") + + # YOLO detector (optional — disabled if onnxruntime or model file is missing) + if Detector is not None: + try: + detector = Detector( + settings.yolo_onnx_path, + settings.yolo_class_list, + settings.yolo_conf_default, + ) + set_ws_detector(detector) + except FileNotFoundError as exc: + logger.warning(f"YOLO detector disabled (model not found): {exc}") + except Exception as exc: + logger.warning(f"YOLO detector disabled: {exc}") + else: + logger.info("YOLO detector disabled (onnxruntime not installed)") + + # Connect NATS (non-blocking, graceful if unavailable) + await nats_manager.connect() + + yield + + # Shutdown + await nats_manager.disconnect() + camera.close() + camera.deinit() + logger.info("SDK deinitialized") + + +app = FastAPI( + title=settings.service_name, + description="REST API + WebSocket for Intel RealSense D4xx depth camera (pyrealsense2)", + version="1.0.0", + docs_url="/apidocs", + redoc_url="/redoc", + openapi_url="/openapi.json", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(router, prefix="/api") +app.include_router(ws_router) + +# Serve React frontend at /gui (built files in src/frontend/react/dist) +_GUI_DIR = Path(__file__).parent.parent / "frontend" / "react" / "dist" +if _GUI_DIR.is_dir(): + app.mount("/gui", StaticFiles(directory=str(_GUI_DIR), html=True), name="gui") + logger.info(f"React GUI mounted at /gui from {_GUI_DIR}") + + +# Public health endpoint (no API key required) for Docker healthchecks +@app.get("/api/health", summary="Health check", tags=["system"]) +async def health(): + """Service health check — no API key required.""" + from src.backend.models.api import ok + from src.backend.nats_client.connection import nats_manager + from src.backend.api.routes import _camera + + return ok({ + "status": "ok", + "sdk_initialized": _camera.is_initialized() if _camera else False, + "camera_open": _camera.is_open() if _camera else False, + "nats_connected": nats_manager.is_connected, + }) diff --git a/src/backend/models/__init__.py b/src/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/models/api.py b/src/backend/models/api.py new file mode 100644 index 0000000..129f654 --- /dev/null +++ b/src/backend/models/api.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel +from typing import Any + + +class ApiError(BaseModel): + code: str + message: str + + +class ApiResponse(BaseModel): + success: bool + data: Any | None = None + error: ApiError | None = None + + +def ok(data: Any) -> dict: + return {"success": True, "data": data, "error": None} + + +def fail(code: str, message: str) -> dict: + return {"success": False, "data": None, "error": {"code": code, "message": message}} + + +class DeviceInfo(BaseModel): + index: int + vid: str + pid: str + serial: str + bus: int + device: int + + +class IntrinsicsInfo(BaseModel): + fx: float + fy: float + cx: float + cy: float + + +class CameraIntrinsicsInfo(BaseModel): + ir: IntrinsicsInfo + rgb: IntrinsicsInfo + + +class FrameInfo(BaseModel): + frame_id: int + timestamp: float + rgb_shape: list[int] + depth_shape: list[int] + depth_min_mm: int + depth_max_mm: int + depth_valid_pixels: int + rgb_jpeg_b64: str | None = None + depth_png_b64: str | None = None diff --git a/src/backend/models/nats.py b/src/backend/models/nats.py new file mode 100644 index 0000000..81ace32 --- /dev/null +++ b/src/backend/models/nats.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel + + +class NatsResponse(BaseModel): + success: bool + data: dict | None = None + error: dict | None = None + + +class SnapshotRequest(BaseModel): + camera_id: str = "default" + include_depth: bool = True + + +class SnapshotResponse(BaseModel): + rgb_jpeg_b64: str + depth_png_b64: str | None = None + timestamp: float + width: int + height: int + + +class CameraStatusMessage(BaseModel): + camera_open: bool + sdk_initialized: bool + frame_count: int diff --git a/src/backend/nats_client/__init__.py b/src/backend/nats_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/nats_client/connection.py b/src/backend/nats_client/connection.py new file mode 100644 index 0000000..f341978 --- /dev/null +++ b/src/backend/nats_client/connection.py @@ -0,0 +1,58 @@ +import logging + +import nats +from nats.aio.client import Client +from src.backend.config import settings + +logger = logging.getLogger(__name__) + + +class NatsManager: + def __init__(self): + self._nc: Client | None = None + + @property + def is_connected(self) -> bool: + return self._nc is not None and self._nc.is_connected + + @property + def client(self) -> Client: + if not self.is_connected: + raise RuntimeError("NATS non connesso") + return self._nc + + async def connect(self): + if not settings.nats_url: + logger.info("NATS URL not configured, skipping connection") + return + try: + self._nc = await nats.connect( + settings.nats_url, + token=settings.nats_token if settings.nats_token else None, + name=settings.service_name, + reconnect_time_wait=2, + max_reconnect_attempts=-1, + ) + logger.info(f"Connected to NATS at {settings.nats_url}") + await self._register_handlers() + await self._publish_discovery() + except Exception as e: + logger.warning(f"NATS connection failed: {e}. Running without NATS.") + self._nc = None + + async def disconnect(self): + if self.is_connected: + await self._nc.drain() + self._nc = None + logger.info("Disconnected from NATS") + + async def _register_handlers(self): + from src.backend.nats_client.handlers import register_all + await register_all(self._nc) + + async def _publish_discovery(self): + from src.backend.nats_client.discovery import publish_discovery + await publish_discovery(self._nc) + + +nats_manager = NatsManager() diff --git a/src/backend/nats_client/discovery.py b/src/backend/nats_client/discovery.py new file mode 100644 index 0000000..1bd6004 --- /dev/null +++ b/src/backend/nats_client/discovery.py @@ -0,0 +1,52 @@ +import json +from nats.aio.client import Client +from src.backend.config import settings +from src.backend.nats_client.subjects import Subjects + +CAPABILITIES = { + "service": settings.service_name, + "domain": settings.service_domain, + "version": "1.0.0", + "description": "Intel RealSense D4xx stereo depth camera server", + "endpoints": { + "rest": { + "health": {"method": "GET", "path": "/api/health"}, + "discover": {"method": "GET", "path": "/api/discover"}, + "open": {"method": "POST", "path": "/api/open"}, + "close": {"method": "POST", "path": "/api/close"}, + "capture": {"method": "GET", "path": "/api/capture"}, + "rgb": {"method": "GET", "path": "/api/rgb"}, + "depth": {"method": "GET", "path": "/api/depth"}, + }, + }, + "nats": { + "actions": [ + { + "subject": Subjects.action("snapshot"), + "pattern": "request/reply", + "description": "Capture RGB+depth frame", + "request_schema": {"include_depth": "boolean"}, + "response_schema": {"rgb_jpeg_b64": "string", "depth_png_b64": "string", "timestamp": "number"}, + }, + { + "subject": Subjects.action("status"), + "pattern": "request/reply", + "description": "Get camera status", + "response_schema": {"camera_open": "boolean", "sdk_initialized": "boolean"}, + }, + ], + }, +} + + +async def publish_discovery(nc: Client): + await nc.publish( + Subjects.DISCOVERY_ANNOUNCE, + json.dumps(CAPABILITIES).encode(), + ) + + async def handle_discovery_request(msg): + await msg.respond(json.dumps(CAPABILITIES).encode()) + + await nc.subscribe(Subjects.DISCOVERY_REQUEST, cb=handle_discovery_request) + await nc.subscribe(Subjects.SERVICE_DISCOVERY, cb=handle_discovery_request) diff --git a/src/backend/nats_client/handlers.py b/src/backend/nats_client/handlers.py new file mode 100644 index 0000000..7cbbdb5 --- /dev/null +++ b/src/backend/nats_client/handlers.py @@ -0,0 +1,66 @@ +import base64 +import json +import logging + +from nats.aio.client import Client +from src.backend.nats_client.subjects import Subjects + +logger = logging.getLogger(__name__) + +_camera = None + + +def set_camera(camera): + global _camera + _camera = camera + + +def _nats_response(success: bool, data=None, error=None) -> bytes: + return json.dumps({ + "success": success, + "data": data, + "error": error, + }).encode() + + +async def register_all(nc: Client): + async def handle_snapshot(msg): + try: + if not _camera or not _camera.is_open(): + await msg.respond(_nats_response( + False, error={"code": "NO_CAMERA", "message": "Camera not open"} + )) + return + + import cv2 + frame = _camera.capture() + if frame is None: + await msg.respond(_nats_response( + False, error={"code": "TIMEOUT", "message": "Frame capture timeout"} + )) + return + + _, rgb_buf = cv2.imencode(".jpg", frame.rgb, [cv2.IMWRITE_JPEG_QUALITY, 85]) + _, depth_buf = cv2.imencode(".png", frame.depth) + + await msg.respond(_nats_response(True, data={ + "rgb_jpeg_b64": base64.b64encode(rgb_buf.tobytes()).decode(), + "depth_png_b64": base64.b64encode(depth_buf.tobytes()).decode(), + "timestamp": frame.timestamp, + "width": frame.rgb.shape[1], + "height": frame.rgb.shape[0], + })) + except Exception as e: + logger.error(f"Snapshot handler error: {e}") + await msg.respond(_nats_response( + False, error={"code": "PROCESSING_ERROR", "message": str(e)} + )) + + async def handle_status(msg): + await msg.respond(_nats_response(True, data={ + "camera_open": _camera.is_open() if _camera else False, + "sdk_initialized": _camera.is_initialized() if _camera else False, + })) + + await nc.subscribe(Subjects.action("snapshot"), cb=handle_snapshot) + await nc.subscribe(Subjects.action("status"), cb=handle_status) diff --git a/src/backend/nats_client/subjects.py b/src/backend/nats_client/subjects.py new file mode 100644 index 0000000..cf0dbbd --- /dev/null +++ b/src/backend/nats_client/subjects.py @@ -0,0 +1,18 @@ +from src.backend.config import settings + +DOMAIN = settings.service_domain +SERVICE = settings.service_name + + +class Subjects: + DISCOVERY_REQUEST = "_discovery.request" + DISCOVERY_ANNOUNCE = "_discovery.announce" + SERVICE_DISCOVERY = f"_discovery.{SERVICE}" + + @staticmethod + def action(action: str) -> str: + return f"{DOMAIN}.{SERVICE}.{action}" + + @staticmethod + def action_reply(action: str) -> str: + return f"{DOMAIN}.{SERVICE}.{action}.reply" diff --git a/src/backend/services/__init__.py b/src/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/services/camera.py b/src/backend/services/camera.py new file mode 100644 index 0000000..b0db4f9 --- /dev/null +++ b/src/backend/services/camera.py @@ -0,0 +1,413 @@ +"""Intel RealSense D4xx depth camera driver via pyrealsense2. + +Provides RGB (BGR8) and aligned Depth (uint16 mm) frames plus +intrinsics/extrinsics through the same public surface as the +Nuwa HP60C driver, so the routes/websocket/frontend stay unchanged. + +In this driver the "ir_*" intrinsic fields hold the **depth** sensor +intrinsics (the D4xx depth stream is rectified from the IR stereo pair), +and "rgb_*" hold the **color** sensor intrinsics. After alignment both +streams share the color intrinsics, but we still expose the raw depth +intrinsics so client code that wants to back-project the unaligned depth +can do so. + +Usage: + cam = RealSenseD435() + cam.init() + devs = cam.discover() + cam.open(0) + frame = cam.capture() + cam.close() + cam.deinit() +""" + +import logging +import time +from dataclasses import dataclass +from typing import Optional + +import cv2 +import numpy as np +import pyrealsense2 as rs + +logger = logging.getLogger(__name__) + + +@dataclass +class CameraFrame: + """Container for a single capture: aligned color + depth + timestamp.""" + rgb: np.ndarray # (H, W, 3) uint8 BGR + depth: np.ndarray # (H, W) uint16 millimeters + timestamp: float + + +@dataclass +class CameraIntrinsics: + """Per-sensor intrinsics. `*_ir` = depth, `*_rgb` = color.""" + fx_ir: float + fy_ir: float + cx_ir: float + cy_ir: float + fx_rgb: float + fy_rgb: float + cx_rgb: float + cy_rgb: float + + +@dataclass +class CameraExtrinsics: + """Rigid transform depth → color. Row-major 3x3 rotation, translation in mm.""" + rotation: tuple + translation: tuple + + +@dataclass +class Distortion: + """Brown-Conrady distortion (k1, k2, k3, p1, p2).""" + k1: float + k2: float + k3: float + p1: float + p2: float + + +@dataclass +class DiscoveredDevice: + index: int + vid: int + pid: int + serial: str + bus: int + device: int + + +class RealSenseD435: + """Driver for Intel RealSense D4xx (tested on D435) via pyrealsense2. + + Lifecycle: init() → discover() → open(index) → capture/get_rgb/get_depth → close() → deinit() + """ + + def __init__( + self, + width: int = 640, + height: int = 480, + fps: int = 30, + frame_cache_ms: float = 100.0, + ): + self._width = width + self._height = height + self._fps = fps + self._frame_cache_ms = frame_cache_ms + + self._ctx: Optional[rs.context] = None + self._pipeline: Optional[rs.pipeline] = None + self._profile = None + self._align: Optional[rs.align] = None + self._depth_scale: float = 0.001 + + self._initialized = False + self._open = False + self._discovered: list[DiscoveredDevice] = [] + self._last_frame: Optional[CameraFrame] = None + self._color_available: bool = True + self._color_format = rs.format.bgr8 + self._usb_type: str = "unknown" + + self.device_index: Optional[int] = None + self.intrinsics: Optional[CameraIntrinsics] = None + self.extrinsics: Optional[CameraExtrinsics] = None + self.distortion_ir: Optional[Distortion] = None + self.distortion_rgb: Optional[Distortion] = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + def is_initialized(self) -> bool: + return self._initialized + + def is_open(self) -> bool: + return self._open + + def is_color_available(self) -> bool: + """False when the pipeline runs depth-only (USB 2.x fallback).""" + return self._color_available + + def usb_type(self) -> str: + return self._usb_type + + def init(self) -> bool: + try: + self._ctx = rs.context() + self._initialized = True + logger.info("RealSense SDK initialized") + return True + except Exception as e: + logger.error(f"RealSense init failed: {e}") + return False + + def deinit(self) -> None: + if self._open: + self.close() + self._ctx = None + self._initialized = False + logger.info("RealSense SDK deinitialized") + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + def discover(self, timeout_ms: int = 5000) -> list[DiscoveredDevice]: + """Enumerate RealSense devices on USB. `timeout_ms` kept for API parity.""" + if not self._initialized: + raise RuntimeError("SDK not initialized, call init() first") + + devices = self._ctx.query_devices() + self._discovered = [] + for i, dev in enumerate(devices): + try: + serial = dev.get_info(rs.camera_info.serial_number) + try: + pid_hex = dev.get_info(rs.camera_info.product_id) + pid = int(pid_hex, 16) if pid_hex else 0 + except Exception: + pid = 0 + bus = 0 + addr = 0 + try: + phys = dev.get_info(rs.camera_info.physical_port) + parts = phys.rsplit("/", 1)[-1].split("-") + if len(parts) >= 2: + try: + bus = int(parts[0]) + except ValueError: + pass + except Exception: + pass + self._discovered.append(DiscoveredDevice( + index=i, vid=0x8086, pid=pid, serial=serial, bus=bus, device=addr, + )) + except Exception as e: + logger.warning(f"Failed to read device {i} info: {e}") + + logger.info(f"Discovered {len(self._discovered)} RealSense device(s)") + return self._discovered + + # ------------------------------------------------------------------ + # Stream open/close + # ------------------------------------------------------------------ + def open(self, device_index: int = 0) -> bool: + if not self._initialized: + logger.error("Cannot open: SDK not initialized") + return False + if self._open: + logger.warning("Camera already open") + return True + + if not self._discovered: + self.discover() + if device_index < 0 or device_index >= len(self._discovered): + logger.error( + f"Invalid device_index {device_index} ({len(self._discovered)} devices found)" + ) + return False + + serial = self._discovered[device_index].serial + + # Probe the USB type via the SDK. D4xx firmware can't sustain both + # depth and color streams simultaneously on USB 2.x — wait_for_frames + # times out indefinitely. We fall back to depth-only there. + usb_type = "unknown" + for dev in self._ctx.query_devices(): + try: + if dev.get_info(rs.camera_info.serial_number) == serial: + if dev.supports(rs.camera_info.usb_type_descriptor): + usb_type = dev.get_info(rs.camera_info.usb_type_descriptor) + break + except Exception: + continue + self._usb_type = usb_type + + is_usb2 = usb_type.startswith("2.") + if is_usb2: + # On USB 2.x the D4xx firmware only sustains the *native* YUYV + # color format — BGR8/RGB8 trigger a wait_for_frames timeout. We + # convert YUYV → BGR in capture() so callers stay format-agnostic. + # 640x480@15 is the highest tested combination that holds without + # dropping frames on this Jetson; actual delivery rate is ~4 fps + # due to USB 2 bandwidth, but wait_for_frames stays under 300 ms. + color_w, color_h, color_fps = 640, 480, 15 + depth_w, depth_h, depth_fps = 640, 480, 15 + color_fmt = rs.format.yuyv + logger.warning( + f"D4xx on USB {usb_type} — using USB2 profile " + f"(color YUYV {color_w}x{color_h}@{color_fps}, " + f"depth {depth_w}x{depth_h}@{depth_fps}, ~4 fps actual). " + f"Move to a USB 3 port for full framerate." + ) + else: + color_w, color_h, color_fps = self._width, self._height, self._fps + depth_w, depth_h, depth_fps = self._width, self._height, self._fps + color_fmt = rs.format.bgr8 + + try: + self._pipeline = rs.pipeline(self._ctx) + config = rs.config() + config.enable_device(serial) + config.enable_stream( + rs.stream.depth, depth_w, depth_h, rs.format.z16, depth_fps + ) + config.enable_stream( + rs.stream.color, color_w, color_h, color_fmt, color_fps + ) + self._profile = self._pipeline.start(config) + self._color_available = True + self._color_format = color_fmt + # rs.align fails when the color stream is YUYV (the only color + # format the D4xx can sustain on USB 2). Skip hardware alignment + # there; we fall back to a numpy resize in capture() so the rgb + # and depth arrays share the same shape for downstream consumers. + self._align = None if is_usb2 else rs.align(rs.stream.color) + + depth_sensor = self._profile.get_device().first_depth_sensor() + self._depth_scale = depth_sensor.get_depth_scale() + + self._read_calibration() + + self.device_index = device_index + self._open = True + logger.info( + f"RealSense D4xx opened: serial={serial} usb={usb_type} " + f"color={color_w}x{color_h}@{color_fps}({color_fmt}) " + f"depth={depth_w}x{depth_h}@{depth_fps} depth_scale={self._depth_scale}" + ) + return True + except Exception as e: + logger.error(f"Open failed: {e}") + self._teardown_pipeline() + return False + + def close(self) -> None: + if not self._open: + return + self._teardown_pipeline() + self.device_index = None + self._last_frame = None + logger.info("RealSense camera closed") + + def _teardown_pipeline(self) -> None: + if self._pipeline is not None: + try: + self._pipeline.stop() + except Exception as e: + logger.warning(f"pipeline.stop failed: {e}") + self._pipeline = None + self._profile = None + self._align = None + self._open = False + + # ------------------------------------------------------------------ + # Calibration + # ------------------------------------------------------------------ + def _read_calibration(self) -> None: + color_stream = self._profile.get_stream(rs.stream.color).as_video_stream_profile() + depth_stream = self._profile.get_stream(rs.stream.depth).as_video_stream_profile() + ci = color_stream.get_intrinsics() + di = depth_stream.get_intrinsics() + + self.intrinsics = CameraIntrinsics( + fx_ir=di.fx, fy_ir=di.fy, cx_ir=di.ppx, cy_ir=di.ppy, + fx_rgb=ci.fx, fy_rgb=ci.fy, cx_rgb=ci.ppx, cy_rgb=ci.ppy, + ) + + try: + ext = depth_stream.get_extrinsics_to(color_stream) + r = list(ext.rotation) + rotation = ( + r[0], r[3], r[6], + r[1], r[4], r[7], + r[2], r[5], r[8], + ) + t = list(ext.translation) + translation = (t[0] * 1000.0, t[1] * 1000.0, t[2] * 1000.0) + self.extrinsics = CameraExtrinsics(rotation=rotation, translation=translation) + except Exception as e: + logger.warning(f"Extrinsics read failed: {e}") + self.extrinsics = None + + # rs intrinsics.coeffs order is [k1, k2, p1, p2, k3]. + def _to_distortion(coeffs) -> Distortion: + if not coeffs or len(coeffs) < 5: + return Distortion(0.0, 0.0, 0.0, 0.0, 0.0) + return Distortion( + k1=coeffs[0], k2=coeffs[1], k3=coeffs[4], p1=coeffs[2], p2=coeffs[3], + ) + + self.distortion_ir = _to_distortion(list(di.coeffs)) + self.distortion_rgb = _to_distortion(list(ci.coeffs)) + + # ------------------------------------------------------------------ + # Frame capture + # ------------------------------------------------------------------ + def capture(self, timeout_ms: int = 5000) -> Optional[CameraFrame]: + if not self._open: + return None + try: + frames = self._pipeline.wait_for_frames(timeout_ms) + except Exception as e: + logger.warning(f"wait_for_frames failed: {e}") + return None + + if self._align is not None: + frames = self._align.process(frames) + + color_frame = frames.get_color_frame() + depth_frame = frames.get_depth_frame() + if not color_frame or not depth_frame: + return None + + if self._color_format == rs.format.yuyv: + # SDK exposes YUYV as a 1-channel view of the packed buffer; + # rebuild it as (H, W, 2) uint8 so cv2 can decode it to BGR. + h = color_frame.get_height() + w = color_frame.get_width() + buf = np.frombuffer(color_frame.get_data(), dtype=np.uint8) + rgb = cv2.cvtColor(buf.reshape(h, w, 2), cv2.COLOR_YUV2BGR_YUYV) + else: + rgb = np.asanyarray(color_frame.get_data()) + + depth_raw = np.asanyarray(depth_frame.get_data()) + # On USB 2 (no hardware alignment) the color and depth streams have + # different native resolutions. Resize color to depth shape so + # downstream endpoints (/pointcloud, /measure) that index rgb[y, x] + # and depth[y, x] in lockstep continue to work. This is an + # *approximate* alignment — depth↔color extrinsics are ignored. + if rgb.shape[:2] != depth_raw.shape[:2]: + rgb = cv2.resize( + rgb, (depth_raw.shape[1], depth_raw.shape[0]), + interpolation=cv2.INTER_LINEAR, + ) + + # depth_scale ~ 0.001 m/unit on D4xx → raw uint16 is already millimeters. + if abs(self._depth_scale - 0.001) < 1e-6: + depth_mm = depth_raw + else: + depth_mm = (depth_raw.astype(np.float32) * self._depth_scale * 1000.0).astype(np.uint16) + + frame = CameraFrame(rgb=rgb, depth=depth_mm, timestamp=time.time()) + self._last_frame = frame + return frame + + def _cached_or_capture(self) -> Optional[CameraFrame]: + if ( + self._last_frame is not None + and (time.time() - self._last_frame.timestamp) * 1000.0 < self._frame_cache_ms + ): + return self._last_frame + return self.capture() + + def get_rgb(self) -> Optional[np.ndarray]: + f = self._cached_or_capture() + return f.rgb if f else None + + def get_depth(self) -> Optional[np.ndarray]: + f = self._cached_or_capture() + return f.depth if f else None diff --git a/src/backend/services/detector.py b/src/backend/services/detector.py new file mode 100644 index 0000000..15c5abe --- /dev/null +++ b/src/backend/services/detector.py @@ -0,0 +1,92 @@ +"""ONNX YOLO detector with letterbox preprocessing. + +Assumes the exported ONNX model has end-to-end NMS embedded: +output shape (1, max_det, 6) where the 6 columns are +[x1, y1, x2, y2, conf, cls] in letterboxed input space. +""" + +import logging +from pathlib import Path + +import cv2 +import numpy as np +import onnxruntime as ort + +logger = logging.getLogger(__name__) + + +def _letterbox(img: np.ndarray, new_size: int = 640, color: tuple = (114, 114, 114)): + """Resize keeping aspect ratio and pad to (new_size, new_size).""" + h, w = img.shape[:2] + r = min(new_size / h, new_size / w) + new_w, new_h = int(round(w * r)), int(round(h * r)) + pad_w = (new_size - new_w) / 2 + pad_h = (new_size - new_h) / 2 + if (w, h) != (new_w, new_h): + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) + top, bottom = int(round(pad_h - 0.1)), int(round(pad_h + 0.1)) + left, right = int(round(pad_w - 0.1)), int(round(pad_w + 0.1)) + img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) + return img, r, left, top + + +class Detector: + def __init__(self, onnx_path: str | Path, class_names: list[str], conf_threshold: float = 0.5): + path = Path(onnx_path) + if not path.is_file(): + raise FileNotFoundError(f"ONNX model not found: {path}") + + opts = ort.SessionOptions() + opts.intra_op_num_threads = 4 + opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + self.session = ort.InferenceSession(str(path), opts, providers=["CPUExecutionProvider"]) + + inp = self.session.get_inputs()[0] + self.input_name = inp.name + self.imgsz = int(inp.shape[-1]) + self.class_names = class_names + self.conf_threshold = conf_threshold + + # Warmup + dummy = np.zeros((self.imgsz, self.imgsz, 3), dtype=np.uint8) + self.detect(dummy) + logger.info(f"Detector ready: model={path.name} imgsz={self.imgsz} classes={class_names}") + + def set_conf(self, value: float) -> None: + self.conf_threshold = float(np.clip(value, 0.0, 1.0)) + + def detect(self, bgr: np.ndarray) -> list[dict]: + h, w = bgr.shape[:2] + padded, scale, pad_x, pad_y = _letterbox(bgr, self.imgsz) + rgb = cv2.cvtColor(padded, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 + tensor = np.expand_dims(rgb.transpose(2, 0, 1), 0) + + outputs = self.session.run(None, {self.input_name: tensor}) + dets = outputs[0][0] # (max_det, 6) + + mask = dets[:, 4] >= self.conf_threshold + dets = dets[mask] + if len(dets) == 0: + return [] + + best = int(np.argmax(dets[:, 4])) + dets = dets[best:best + 1] + + boxes = dets[:, :4].copy() + boxes[:, [0, 2]] = (boxes[:, [0, 2]] - pad_x) / scale + boxes[:, [1, 3]] = (boxes[:, [1, 3]] - pad_y) / scale + boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, w - 1) + boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, h - 1) + + results = [] + for box, det in zip(boxes, dets): + cls_idx = int(det[5]) + label = self.class_names[cls_idx] if 0 <= cls_idx < len(self.class_names) else str(cls_idx) + results.append({ + "bbox": [round(float(box[0]), 1), round(float(box[1]), 1), + round(float(box[2]), 1), round(float(box[3]), 1)], + "conf": round(float(det[4]), 3), + "cls": cls_idx, + "label": label, + }) + return results diff --git a/src/backend/services/image_saver.py b/src/backend/services/image_saver.py new file mode 100644 index 0000000..3d03b9c --- /dev/null +++ b/src/backend/services/image_saver.py @@ -0,0 +1,105 @@ +import logging +from datetime import datetime +from pathlib import Path +from threading import Lock + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + + +def _flip_code(flip_h: bool, flip_v: bool) -> int | None: + if flip_h and flip_v: + return -1 + if flip_h: + return 1 + if flip_v: + return 0 + return None + + +def _timestamp() -> str: + now = datetime.now() + return now.strftime("%Y%m%d_%H%M%S_") + f"{now.microsecond // 1000:03d}" + + +class ImageSaver: + def __init__(self, images_dir: Path, jpeg_quality: int = 85): + self._dir = images_dir + self._dir.mkdir(parents=True, exist_ok=True) + self._quality = jpeg_quality + self._lock = Lock() + self._recording = False + self._rec_flip_h = False + self._rec_flip_v = False + self._rec_started_at: str | None = None + self._rec_count = 0 + self._rec_interval = 1 + self._rec_seen = 0 + + @property + def is_recording(self) -> bool: + return self._recording + + def _encode_jpeg(self, rgb: np.ndarray, flip_h: bool, flip_v: bool) -> bytes: + img = rgb + code = _flip_code(flip_h, flip_v) + if code is not None: + img = cv2.flip(img, code) + _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, self._quality]) + return buf.tobytes() + + def save_snapshot(self, rgb: np.ndarray, flip_h: bool, flip_v: bool) -> str: + data = self._encode_jpeg(rgb, flip_h, flip_v) + filename = f"snap_{_timestamp()}.jpg" + (self._dir / filename).write_bytes(data) + logger.info(f"Snapshot saved: {filename}") + return filename + + def start_recording(self, flip_h: bool, flip_v: bool, interval: int = 1) -> dict: + if interval < 1: + interval = 1 + with self._lock: + if self._recording: + raise RuntimeError("already recording") + self._recording = True + self._rec_flip_h = flip_h + self._rec_flip_v = flip_v + self._rec_started_at = datetime.now().isoformat(timespec="seconds") + self._rec_count = 0 + self._rec_seen = 0 + self._rec_interval = interval + logger.info( + f"Recording started (flip_h={flip_h}, flip_v={flip_v}, interval={interval})" + ) + return self.status() + + def stop_recording(self) -> dict: + with self._lock: + snapshot = self.status() + self._recording = False + logger.info(f"Recording stopped: {snapshot['frames_written']} frames") + return snapshot + + def save_rec_frame(self, rgb: np.ndarray) -> None: + if not self._recording: + return + self._rec_seen += 1 + if (self._rec_seen - 1) % self._rec_interval != 0: + return + data = self._encode_jpeg(rgb, self._rec_flip_h, self._rec_flip_v) + filename = f"rec_{_timestamp()}.jpg" + (self._dir / filename).write_bytes(data) + self._rec_count += 1 + + def status(self) -> dict: + return { + "recording": self._recording, + "frames_written": self._rec_count, + "frames_seen": self._rec_seen, + "interval": self._rec_interval, + "started_at": self._rec_started_at, + "flip_h": self._rec_flip_h, + "flip_v": self._rec_flip_v, + } diff --git a/src/frontend/react/.gitignore b/src/frontend/react/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/src/frontend/react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/src/frontend/react/README.md b/src/frontend/react/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/src/frontend/react/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/src/frontend/react/eslint.config.js b/src/frontend/react/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/src/frontend/react/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/src/frontend/react/index.html b/src/frontend/react/index.html new file mode 100644 index 0000000..03d9388 --- /dev/null +++ b/src/frontend/react/index.html @@ -0,0 +1,13 @@ + + + + + + + react + + +
+ + + diff --git a/src/frontend/react/package-lock.json b/src/frontend/react/package-lock.json new file mode 100644 index 0000000..e8d3223 --- /dev/null +++ b/src/frontend/react/package-lock.json @@ -0,0 +1,3232 @@ +{ + "name": "react", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react", + "version": "0.0.0", + "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "three": "^0.183.2", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.330", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.330.tgz", + "integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/src/frontend/react/package.json b/src/frontend/react/package.json new file mode 100644 index 0000000..d7031ac --- /dev/null +++ b/src/frontend/react/package.json @@ -0,0 +1,31 @@ +{ + "name": "react", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "three": "^0.183.2", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.1" + } +} diff --git a/src/frontend/react/public/favicon.svg b/src/frontend/react/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/src/frontend/react/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/react/public/icons.svg b/src/frontend/react/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/src/frontend/react/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/react/src/App.jsx b/src/frontend/react/src/App.jsx new file mode 100644 index 0000000..dfb85bd --- /dev/null +++ b/src/frontend/react/src/App.jsx @@ -0,0 +1,117 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { useStore } from './store' +import { useWebSocket } from './hooks/useWebSocket' +import { useApi } from './hooks/useApi' +import { Header } from './components/Header' +import { CameraControls } from './components/CameraControls' +import { StreamView } from './components/StreamView' +import { DepthView } from './components/DepthView' +import { PointCloudView } from './components/PointCloudView' +import { SplitView } from './components/SplitView' +import { StatusBar } from './components/StatusBar' +import { CalibrationPanel } from './components/CalibrationPanel' + +export default function App() { + const [viewMode, setViewMode] = useState('rgb') + const [showCalib, setShowCalib] = useState(false) + const { + setCurrentFrame, setFps, setStreaming, flipH, flipV, toggleFlipH, toggleFlipV, + setConnected, setCameraOpen, setIntrinsics, setDevices, setSelectedDevice, + } = useStore() + const frame = useStore((s) => s.currentFrame) + const fpsCounter = useRef({ count: 0, lastTime: Date.now() }) + const api = useApi() + + const onFrame = useCallback((data) => { + setCurrentFrame(data) + fpsCounter.current.count++ + const now = Date.now() + if (now - fpsCounter.current.lastTime >= 1000) { + setFps(fpsCounter.current.count) + fpsCounter.current.count = 0 + fpsCounter.current.lastTime = now + } + }, [setCurrentFrame, setFps]) + + const { start, stop, setPredict, setConf } = useWebSocket(onFrame) + + const handleStart = () => { start(); setStreaming(true) } + const handleStop = () => { stop(); setStreaming(false) } + + // Sync GUI state with server on mount: if a camera is already open, reflect it + // and auto-start the WebSocket stream so the user sees the live image. + const syncedRef = useRef(false) + useEffect(() => { + if (syncedRef.current) return + syncedRef.current = true + ;(async () => { + const res = await api.status() + if (!res?.success) return + const { sdk_initialized, camera_open, intrinsics: sIntr } = res.data || {} + if (sdk_initialized) setConnected(true) + if (camera_open) { + setCameraOpen(true) + if (sIntr) setIntrinsics(sIntr) + const disc = await api.discover() + if (disc?.success) { + setDevices(disc.data.cameras) + if (disc.data.cameras.length > 0) { + setSelectedDevice(disc.data.cameras[0].index) + } + } + start() + setStreaming(true) + } + })() + }, [api, setConnected, setCameraOpen, setIntrinsics, setDevices, setSelectedDevice, setStreaming, start]) + + const tabStyle = (active) => ({ + padding: '6px 12px', + background: active ? 'var(--accent)' : 'var(--bg-card)', + color: 'var(--text)', + border: 'none', + borderRadius: 'var(--radius)', + cursor: 'pointer', + fontSize: '12px', + }) + + return ( +
+
+
+ +
+ + + + +
+ + + +
+
+
+ {viewMode === 'rgb' && } + {viewMode === 'depth' && } + {viewMode === '3d' && } + {viewMode === 'split' && } +
+
+ + {showCalib && setShowCalib(false)} />} +
+ ) +} diff --git a/src/frontend/react/src/components/CalibrationPanel.jsx b/src/frontend/react/src/components/CalibrationPanel.jsx new file mode 100644 index 0000000..35de2c5 --- /dev/null +++ b/src/frontend/react/src/components/CalibrationPanel.jsx @@ -0,0 +1,169 @@ +import { useStore } from '../store' + +const fmt = (v, digits = 4) => + v === null || v === undefined || Number.isNaN(v) ? '—' : Number(v).toFixed(digits) + +function Row({ label, value, mono = true }) { + return ( +
+ {label} + {value} +
+ ) +} + +function Section({ title, children }) { + return ( +
+
{title}
+ {children} +
+ ) +} + +function Sensor({ label, data }) { + if (!data) return null + const d = data.distortion + return ( +
+ + + + + {d ? ( + <> + + + + + + + ) : ( + + )} +
+ ) +} + +function Extrinsics({ data }) { + if (!data) return null + const R = data.rotation + const T = data.translation + const cell = { + flex: 1, textAlign: 'right', + fontFamily: 'var(--font-mono)', fontSize: '11px', + padding: '2px 6px', color: 'var(--text)', + } + return ( +
+
+ Rotation (row-major) +
+
+ {[0, 1, 2].map((r) => ( +
+ {[0, 1, 2].map((c) => ( + {fmt(R[r * 3 + c], 5)} + ))} +
+ ))} +
+
+ Translation (mm) +
+ + + +
+ ) +} + +export function CalibrationPanel({ onClose }) { + const intrinsics = useStore((s) => s.intrinsics) + + const copyJson = () => { + navigator.clipboard?.writeText(JSON.stringify(intrinsics, null, 2)) + } + + return ( +
+
e.stopPropagation()} + style={{ + background: 'var(--bg-panel)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius)', + padding: '20px', + width: '420px', + maxHeight: '85vh', + overflowY: 'auto', + boxShadow: '0 10px 40px rgba(0,0,0,0.5)', + }} + > +
+

+ Camera Calibration +

+
+ + +
+
+ + {!intrinsics ? ( +
+ Calibration non disponibile. Apri la camera per caricare i parametri. +
+ ) : ( + <> + + + + + )} +
+
+ ) +} diff --git a/src/frontend/react/src/components/CameraControls.jsx b/src/frontend/react/src/components/CameraControls.jsx new file mode 100644 index 0000000..ba8c602 --- /dev/null +++ b/src/frontend/react/src/components/CameraControls.jsx @@ -0,0 +1,202 @@ +import { useState } from 'react' +import { useStore } from '../store' +import { useApi } from '../hooks/useApi' + +export function CameraControls({ onStartStream, onStopStream, onSetPredict, onSetConf }) { + const { + devices, setDevices, selectedDevice, setSelectedDevice, + cameraOpen, setCameraOpen, setConnected, setIntrinsics, + flipH, flipV, recording, setRecording, + recInterval, setRecInterval, + predicting, setPredicting, + confThreshold, setConfThreshold, + } = useStore() + const api = useApi() + const [loading, setLoading] = useState(false) + + const handleSnapshot = async () => { + const res = await api.snapshot(flipH, flipV) + if (res.success) { + console.log('Snapshot saved:', res.data.filename) + } else { + alert(`Snapshot failed: ${res.error?.message ?? 'unknown error'}`) + } + } + + const handleRecToggle = async () => { + if (!recording) { + const res = await api.startRec(flipH, flipV, recInterval) + if (res.success) setRecording(true) + else alert(`REC start failed: ${res.error?.message ?? 'unknown error'}`) + } else { + const res = await api.stopRec() + if (res.success) { + setRecording(false) + console.log(`REC stopped: ${res.data.frames_written} frames`) + } else { + alert(`REC stop failed: ${res.error?.message ?? 'unknown error'}`) + } + } + } + + const handleDiscover = async () => { + setLoading(true) + const res = await api.discover() + if (res.success) { + setDevices(res.data.cameras) + setConnected(true) + } + setLoading(false) + } + + const handleOpen = async () => { + setLoading(true) + const res = await api.openCamera(selectedDevice) + if (res.success) { + setCameraOpen(true) + if (res.data.intrinsics) setIntrinsics(res.data.intrinsics) + if (typeof res.data.device_index === 'number') { + setSelectedDevice(res.data.device_index) + } + } else { + alert(`Open failed: ${res.error?.message ?? 'unknown error'}`) + } + setLoading(false) + } + + const handleClose = async () => { + onStopStream() + const res = await api.closeCamera() + if (res.success) setCameraOpen(false) + } + + const handlePredictToggle = () => { + const next = !predicting + setPredicting(next) + onSetPredict?.(next) + } + + const handleConfChange = (e) => { + const v = Number(e.target.value) + setConfThreshold(v) + onSetConf?.(v) + } + + const btnStyle = (variant = 'default') => ({ + padding: '10px 20px', + borderRadius: 'var(--radius)', + border: 'none', + cursor: loading ? 'wait' : 'pointer', + fontSize: '14px', + fontWeight: 600, + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + background: variant === 'accent' ? 'var(--accent)' : + variant === 'danger' ? 'var(--danger)' : + variant === 'success' ? '#22c55e' : + variant === 'open' ? '#3b82f6' : 'var(--bg-card)', + color: '#fff', + }) + + return ( +
+ + + {devices.length > 0 && ( + + )} + + {!cameraOpen ? ( + + ) : ( + <> + + + + + + + + + + )} +
+ ) +} diff --git a/src/frontend/react/src/components/DepthView.jsx b/src/frontend/react/src/components/DepthView.jsx new file mode 100644 index 0000000..09f2cc6 --- /dev/null +++ b/src/frontend/react/src/components/DepthView.jsx @@ -0,0 +1,42 @@ +import { useRef, useEffect } from 'react' +import { useZoomPan } from '../hooks/useZoomPan' + +export function DepthView({ frame, flipH = false, flipV = false }) { + const canvasRef = useRef(null) + const { zoom, pan, handlers } = useZoomPan() + + useEffect(() => { + if (!frame || !canvasRef.current) return + const b64 = frame.depth_color_b64 || frame.depth_png_b64 + if (!b64) return + const canvas = canvasRef.current + const ctx = canvas.getContext('2d') + const mime = frame.depth_color_b64 ? 'jpeg' : 'png' + const img = new Image() + img.onload = () => { + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + } + img.src = `data:image/${mime};base64,${b64}` + }, [frame]) + + return ( +
1 ? 'grab' : 'default', touchAction: 'none', + }} + > + +
+ ) +} diff --git a/src/frontend/react/src/components/Header.jsx b/src/frontend/react/src/components/Header.jsx new file mode 100644 index 0000000..5914b4e --- /dev/null +++ b/src/frontend/react/src/components/Header.jsx @@ -0,0 +1,36 @@ +import { useStore } from '../store' + +export function Header() { + const { connected, cameraOpen, streaming, natsConnected } = useStore() + + return ( +
+

Nuwa HP60C

+
+ + + + +
+
+ ) +} + +function Indicator({ label, active }) { + return ( + + + {label} + + ) +} diff --git a/src/frontend/react/src/components/PointCloudView.jsx b/src/frontend/react/src/components/PointCloudView.jsx new file mode 100644 index 0000000..75e657a --- /dev/null +++ b/src/frontend/react/src/components/PointCloudView.jsx @@ -0,0 +1,134 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import { Canvas } from '@react-three/fiber' +import { OrbitControls } from '@react-three/drei' +import * as THREE from 'three' +import { apiFetch } from '../hooks/useApi' +import { useStore } from '../store' + +function MeasurementMarker({ measurement }) { + if (!measurement || measurement.error || !measurement.point_m) return null + const { x, y, z } = measurement.point_m + // /pointcloud stores points as [x, -y, -z] — match that convention here. + return ( + + + + + + + + + + + ) +} + +function PointCloud({ points }) { + const geometry = useRef(new THREE.BufferGeometry()) + + useEffect(() => { + if (!points || points.length === 0) return + + const pos = new Float32Array(points.length * 3) + const col = new Float32Array(points.length * 3) + + for (let i = 0; i < points.length; i++) { + const p = points[i] + pos[i * 3] = p[0] + pos[i * 3 + 1] = p[1] + pos[i * 3 + 2] = p[2] + col[i * 3] = p[3] / 255 + col[i * 3 + 1] = p[4] / 255 + col[i * 3 + 2] = p[5] / 255 + } + + geometry.current.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)) + geometry.current.setAttribute('color', new THREE.Float32BufferAttribute(col, 3)) + geometry.current.computeBoundingSphere() + }, [points]) + + if (!points || points.length === 0) return null + + return ( + + + + + ) +} + +export function PointCloudView({ flipH = false, flipV = false }) { + const [points, setPoints] = useState(null) + const [loading, setLoading] = useState(false) + const [autoRefresh, setAutoRefresh] = useState(false) + const intervalRef = useRef(null) + const measurement = useStore((s) => s.measurement) + + const fetchPoints = useCallback(async () => { + setLoading(true) + try { + const res = await apiFetch('/pointcloud?step=4&max_depth_mm=4000') + if (res.success) { + setPoints(res.data.points) + } + } catch (e) { + console.error('Point cloud fetch error:', e) + } + setLoading(false) + }, []) + + useEffect(() => { + fetchPoints() + }, [fetchPoints]) + + useEffect(() => { + if (autoRefresh) { + intervalRef.current = setInterval(fetchPoints, 500) + } else { + clearInterval(intervalRef.current) + } + return () => clearInterval(intervalRef.current) + }, [autoRefresh, fetchPoints]) + + return ( +
+
+ + + {points && ( + + {points.length.toLocaleString()} pts + + )} +
+ + + + + + + + +
+ ) +} diff --git a/src/frontend/react/src/components/SplitView.jsx b/src/frontend/react/src/components/SplitView.jsx new file mode 100644 index 0000000..da250fc --- /dev/null +++ b/src/frontend/react/src/components/SplitView.jsx @@ -0,0 +1,33 @@ +import { StreamView } from './StreamView' +import { PointCloudView } from './PointCloudView' + +export function SplitView({ frame, flipH = false, flipV = false }) { + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +function Label({ children }) { + return ( +
{children}
+ ) +} diff --git a/src/frontend/react/src/components/StatusBar.jsx b/src/frontend/react/src/components/StatusBar.jsx new file mode 100644 index 0000000..9690d35 --- /dev/null +++ b/src/frontend/react/src/components/StatusBar.jsx @@ -0,0 +1,28 @@ +import { useStore } from '../store' + +export function StatusBar() { + const { fps, frameCount, currentFrame } = useStore() + + return ( +
+ FPS: {fps} + Frames: {frameCount} + {currentFrame && ( + <> + Depth: {currentFrame.depth_min_mm}-{currentFrame.depth_max_mm}mm + Valid px: {currentFrame.depth_valid_pixels} + + )} +
+ ) +} diff --git a/src/frontend/react/src/components/StreamView.jsx b/src/frontend/react/src/components/StreamView.jsx new file mode 100644 index 0000000..7f83c21 --- /dev/null +++ b/src/frontend/react/src/components/StreamView.jsx @@ -0,0 +1,187 @@ +import { useRef, useEffect, useState, useCallback } from 'react' +import { useZoomPan } from '../hooks/useZoomPan' +import { apiFetch } from '../hooks/useApi' +import { useStore } from '../store' + +const MEASURE_RADIUS = 6 + +function drawDetections(ctx, detections) { + ctx.lineWidth = 2 + ctx.strokeStyle = '#22c55e' + ctx.fillStyle = '#22c55e' + ctx.font = '14px sans-serif' + ctx.textBaseline = 'top' + for (const d of detections) { + const [x1, y1, x2, y2] = d.bbox + const w = x2 - x1 + const h = y2 - y1 + ctx.strokeRect(x1, y1, w, h) + const label = `${d.label} ${(d.conf * 100).toFixed(0)}%` + const padding = 3 + const textW = ctx.measureText(label).width + const tagH = 16 + const tagY = y1 - tagH >= 0 ? y1 - tagH : y1 + ctx.fillRect(x1, tagY, textW + padding * 2, tagH) + ctx.fillStyle = '#000' + ctx.fillText(label, x1 + padding, tagY + 1) + ctx.fillStyle = '#22c55e' + } +} + +export function StreamView({ frame, type = 'rgb', flipH = false, flipV = false }) { + const canvasRef = useRef(null) + const imgSizeRef = useRef({ w: 0, h: 0 }) + const [pending, setPending] = useState(false) + const measurement = useStore((s) => s.measurement) + const setMeasurement = useStore((s) => s.setMeasurement) + + const canMeasure = type === 'rgb' + + const handleTap = useCallback(async (e) => { + if (!canMeasure || !canvasRef.current) return + const canvas = canvasRef.current + const cw = canvas.width + const ch = canvas.height + if (!cw || !ch) return + const rect = canvas.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return + + // objectFit: contain letterboxes the canvas content inside the CSS rect. + // Compute the actual drawn content rect so clicks map to image pixels. + const scale = Math.min(rect.width / cw, rect.height / ch) + const dw = cw * scale + const dh = ch * scale + const ox = rect.left + (rect.width - dw) / 2 + const oy = rect.top + (rect.height - dh) / 2 + + let fx = (e.clientX - ox) / dw + let fy = (e.clientY - oy) / dh + if (fx < 0 || fx > 1 || fy < 0 || fy > 1) return // click in letterbox + + // CSS flips invert the visual axis; undo to recover raw image coords. + if (flipH) fx = 1 - fx + if (flipV) fy = 1 - fy + const u = Math.max(0, Math.min(cw - 1, Math.round(fx * cw))) + const v = Math.max(0, Math.min(ch - 1, Math.round(fy * ch))) + + setPending(true) + try { + const res = await apiFetch(`/measure?u=${u}&v=${v}&radius=${MEASURE_RADIUS}`) + if (res.success) { + setMeasurement({ ...res.data, ts: Date.now() }) + } else { + setMeasurement({ error: res.error || 'ERR', u, v, ts: Date.now() }) + } + } catch (err) { + setMeasurement({ error: String(err), u, v, ts: Date.now() }) + } + setPending(false) + }, [canMeasure, flipH, flipV, setMeasurement]) + + const { zoom, pan, handlers } = useZoomPan({ onTap: canMeasure ? handleTap : undefined }) + + useEffect(() => { + if (!frame || !canvasRef.current) return + const canvas = canvasRef.current + const ctx = canvas.getContext('2d') + const b64 = type === 'rgb' ? frame.rgb_jpeg_b64 : frame.depth_png_b64 + if (!b64) return + const img = new Image() + img.onload = () => { + canvas.width = img.width + canvas.height = img.height + imgSizeRef.current = { w: img.width, h: img.height } + ctx.drawImage(img, 0, 0) + if (type === 'rgb' && frame.detections?.length) { + drawDetections(ctx, frame.detections) + } + if (canMeasure && measurement && !measurement.error && measurement.u_found !== undefined) { + drawMarker(ctx, measurement) + } + } + img.src = `data:image/${type === 'rgb' ? 'jpeg' : 'png'};base64,${b64}` + }, [frame, type, canMeasure, measurement]) + + const label = (() => { + if (pending) return 'Measuring...' + if (!canMeasure || !measurement) return null + if (measurement.error) return `(${measurement.u},${measurement.v}) — ${measurement.error}` + const { u, v, u_found, v_found, depth_mm, distance_mm, point_m, pixel_offset } = measurement + const offsetLbl = pixel_offset > 0 ? ` (Δ${pixel_offset}px)` : '' + return ( + <> +
pixel clicked: ({u}, {v}) → found: ({u_found}, {v_found}){offsetLbl}
+
depth Z: {depth_mm} mm · distance: {distance_mm} mm
+
3D (m): x={point_m.x} y={point_m.y} z={point_m.z}
+ + ) + })() + + return ( +
1 ? 'grab' : 'default'), + touchAction: 'none', position: 'relative', + }} + > + + {canMeasure && label && ( +
+ {label} + {measurement && !measurement.error && ( + + )} +
+ )} +
+ ) +} + +function drawMarker(ctx, m) { + const { u, v, u_found, v_found } = m + ctx.save() + ctx.strokeStyle = 'rgba(255,255,255,0.95)' + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(u - 6, v); ctx.lineTo(u + 6, v) + ctx.moveTo(u, v - 6); ctx.lineTo(u, v + 6) + ctx.stroke() + ctx.strokeStyle = '#ffcc00' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(u_found, v_found, 5, 0, Math.PI * 2) + ctx.stroke() + ctx.fillStyle = 'rgba(255,204,0,0.35)' + ctx.fill() + if (u_found !== u || v_found !== v) { + ctx.strokeStyle = 'rgba(255,204,0,0.7)' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(u, v); ctx.lineTo(u_found, v_found) + ctx.stroke() + } + ctx.restore() +} diff --git a/src/frontend/react/src/hooks/useApi.js b/src/frontend/react/src/hooks/useApi.js new file mode 100644 index 0000000..2a65b73 --- /dev/null +++ b/src/frontend/react/src/hooks/useApi.js @@ -0,0 +1,41 @@ +const API_URL = import.meta.env.VITE_API_URL || '/api' +const API_KEY = import.meta.env.VITE_API_KEY || '' + +export async function apiFetch(path, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + } + if (API_KEY) headers['X-Api-Key'] = API_KEY + + const res = await fetch(`${API_URL}${path}`, { + ...options, + headers, + }) + return res.json() +} + +export function useApi() { + const discover = () => apiFetch('/discover') + const openCamera = (index = 0) => apiFetch(`/open?device_index=${index}`, { method: 'POST' }) + const closeCamera = () => apiFetch('/close', { method: 'POST' }) + const health = () => apiFetch('/health') + const status = () => apiFetch('/status') + const capture = () => apiFetch('/capture') + const intrinsics = () => apiFetch('/intrinsics') + const snapshot = (flipH, flipV) => + apiFetch(`/snapshot?flip_h=${flipH}&flip_v=${flipV}`, { method: 'POST' }) + const startRec = (flipH, flipV, interval = 1) => + apiFetch( + `/recording/start?flip_h=${flipH}&flip_v=${flipV}&interval=${interval}`, + { method: 'POST' }, + ) + const stopRec = () => apiFetch('/recording/stop', { method: 'POST' }) + const recStatus = () => apiFetch('/recording/status') + const measure = (u, v, radius = 6) => + apiFetch(`/measure?u=${u}&v=${v}&radius=${radius}`) + return { + discover, openCamera, closeCamera, health, status, capture, intrinsics, + snapshot, startRec, stopRec, recStatus, measure, + } +} diff --git a/src/frontend/react/src/hooks/useWebSocket.js b/src/frontend/react/src/hooks/useWebSocket.js new file mode 100644 index 0000000..aa41942 --- /dev/null +++ b/src/frontend/react/src/hooks/useWebSocket.js @@ -0,0 +1,88 @@ +import { useRef, useCallback, useEffect } from 'react' + +function getWsUrl() { + if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${proto}//${window.location.host}/ws` +} + +export function useWebSocket(onFrame) { + const wsRef = useRef(null) + const streamingRef = useRef(false) + const pendingStart = useRef(false) + const predictRef = useRef(false) + const confRef = useRef(null) + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return + if (wsRef.current?.readyState === WebSocket.CONNECTING) return + const ws = new WebSocket(getWsUrl()) + ws.onopen = () => { + if (predictRef.current) ws.send('predict_on') + if (confRef.current != null) ws.send(`conf:${confRef.current.toFixed(2)}`) + if (pendingStart.current) { + ws.send('start') + pendingStart.current = false + } + } + ws.onmessage = (e) => { + const data = JSON.parse(e.data) + onFrame(data) + } + ws.onclose = () => { wsRef.current = null } + ws.onerror = (e) => { console.error('WebSocket error:', e) } + wsRef.current = ws + }, [onFrame]) + + const start = useCallback(() => { + if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) { + pendingStart.current = true + connect() + } else if (wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send('start') + } else { + pendingStart.current = true + } + streamingRef.current = true + }, [connect]) + + const stop = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send('stop') + } + streamingRef.current = false + }, []) + + const sendCommand = useCallback((cmd) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(cmd) + return true + } + return false + }, []) + + const setPredict = useCallback((on) => { + predictRef.current = on + sendCommand(on ? 'predict_on' : 'predict_off') + }, [sendCommand]) + + const setConf = useCallback((value) => { + confRef.current = value + sendCommand(`conf:${value.toFixed(2)}`) + }, [sendCommand]) + + const disconnect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send('close') + } + wsRef.current?.close() + wsRef.current = null + streamingRef.current = false + }, []) + + useEffect(() => { + return () => { wsRef.current?.close() } + }, []) + + return { connect, start, stop, disconnect, setPredict, setConf, isStreaming: streamingRef } +} diff --git a/src/frontend/react/src/hooks/useZoomPan.js b/src/frontend/react/src/hooks/useZoomPan.js new file mode 100644 index 0000000..01b80a0 --- /dev/null +++ b/src/frontend/react/src/hooks/useZoomPan.js @@ -0,0 +1,51 @@ +import { useState, useRef, useCallback } from 'react' + +const TAP_THRESHOLD_PX = 4 + +export function useZoomPan({ onTap } = {}) { + const [zoom, setZoom] = useState(1) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const dragging = useRef(false) + const lastPos = useRef({ x: 0, y: 0 }) + const downPos = useRef(null) + + const onWheel = useCallback((e) => { + e.preventDefault() + setZoom((z) => Math.min(10, Math.max(0.5, z * (e.deltaY < 0 ? 1.15 : 0.87)))) + }, []) + + const onPointerDown = useCallback((e) => { + dragging.current = true + lastPos.current = { x: e.clientX, y: e.clientY } + downPos.current = { x: e.clientX, y: e.clientY } + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const onPointerMove = useCallback((e) => { + if (!dragging.current) return + setPan((p) => ({ + x: p.x + e.clientX - lastPos.current.x, + y: p.y + e.clientY - lastPos.current.y, + })) + lastPos.current = { x: e.clientX, y: e.clientY } + }, []) + + const onPointerUp = useCallback((e) => { + dragging.current = false + if (onTap && downPos.current) { + const dx = e.clientX - downPos.current.x + const dy = e.clientY - downPos.current.y + if (Math.hypot(dx, dy) < TAP_THRESHOLD_PX) onTap(e) + } + downPos.current = null + }, [onTap]) + + const onDoubleClick = useCallback(() => { + setZoom(1) + setPan({ x: 0, y: 0 }) + }, []) + + const handlers = { onWheel, onPointerDown, onPointerMove, onPointerUp, onDoubleClick } + + return { zoom, pan, handlers } +} diff --git a/src/frontend/react/src/index.css b/src/frontend/react/src/index.css new file mode 100644 index 0000000..94e7b0f --- /dev/null +++ b/src/frontend/react/src/index.css @@ -0,0 +1,40 @@ +:root { + --bg-dark: #0c0e14; + --bg-panel: #141722; + --bg-card: #1a1e2e; + --bg-input: #0f1219; + --border: #252a3a; + --accent: #3b82f6; + --accent-hover: #2563eb; + --success: #10b981; + --danger: #ef4444; + --warning: #f59e0b; + --text: #e2e8f0; + --text-muted: #64748b; + --text-dim: #475569; + --radius: 8px; + --font-ui: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body, #root { + width: 100%; + height: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-ui); + background: var(--bg-dark); + color: var(--text); + font-size: 13px; +} + +.app { + display: grid; + grid-template-rows: 44px 1fr 32px; + height: 100dvh; + overflow: hidden; +} diff --git a/src/frontend/react/src/main.jsx b/src/frontend/react/src/main.jsx new file mode 100644 index 0000000..5cc5991 --- /dev/null +++ b/src/frontend/react/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/src/frontend/react/src/store.js b/src/frontend/react/src/store.js new file mode 100644 index 0000000..ab32278 --- /dev/null +++ b/src/frontend/react/src/store.js @@ -0,0 +1,41 @@ +import { create } from 'zustand' + +export const useStore = create((set) => ({ + connected: false, + cameraOpen: false, + streaming: false, + natsConnected: false, + devices: [], + selectedDevice: 0, + currentFrame: null, + fps: 0, + frameCount: 0, + intrinsics: null, + flipH: false, + flipV: false, + recording: false, + recFrameCount: 0, + recInterval: 1, + predicting: false, + confThreshold: 0.5, + measurement: null, + setConnected: (v) => set({ connected: v }), + setCameraOpen: (v) => set({ cameraOpen: v }), + setStreaming: (v) => set({ streaming: v }), + setNatsConnected: (v) => set({ natsConnected: v }), + setDevices: (d) => set({ devices: d }), + setSelectedDevice: (i) => set({ selectedDevice: i }), + setCurrentFrame: (f) => set((s) => ({ currentFrame: f, frameCount: s.frameCount + 1 })), + setFps: (v) => set({ fps: v }), + setIntrinsics: (i) => set({ intrinsics: i }), + setFlipH: (v) => set({ flipH: v }), + setFlipV: (v) => set({ flipV: v }), + toggleFlipH: () => set((s) => ({ flipH: !s.flipH })), + toggleFlipV: () => set((s) => ({ flipV: !s.flipV })), + setRecording: (v) => set({ recording: v }), + setRecFrameCount: (n) => set({ recFrameCount: n }), + setRecInterval: (n) => set({ recInterval: n }), + setPredicting: (v) => set({ predicting: v }), + setConfThreshold: (v) => set({ confThreshold: v }), + setMeasurement: (m) => set({ measurement: m }), +})) diff --git a/src/frontend/react/vite.config.js b/src/frontend/react/vite.config.js new file mode 100644 index 0000000..1fbf760 --- /dev/null +++ b/src/frontend/react/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + base: '/gui/', + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8877', + '/ws': { + target: 'ws://localhost:8877', + ws: true, + }, + }, + }, +}) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..af7e479 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def anyio_backend(): + return "asyncio" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b93eb81 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,14 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from src.backend.config import settings + + +@pytest.mark.anyio +async def test_health_without_api_key(): + """Request without API key should fail with 403 when key is configured.""" + from src.backend.main import app + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + resp = await ac.get("/api/health") + if settings.api_key: + assert resp.status_code in (403, 422) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..cdb2b35 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,25 @@ +from src.backend.models.api import ok, fail, ApiResponse, ApiError + + +class TestApiEnvelope: + def test_ok_response(self): + result = ok({"status": "running"}) + assert result["success"] is True + assert result["data"]["status"] == "running" + assert result["error"] is None + + def test_fail_response(self): + result = fail("TIMEOUT", "Frame capture timeout") + assert result["success"] is False + assert result["data"] is None + assert result["error"]["code"] == "TIMEOUT" + assert result["error"]["message"] == "Frame capture timeout" + + def test_api_response_model(self): + resp = ApiResponse(success=True, data={"key": "value"}) + assert resp.success is True + assert resp.data == {"key": "value"} + + def test_api_error_model(self): + err = ApiError(code="NOT_FOUND", message="Resource not found") + assert err.code == "NOT_FOUND" diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..76a3a37 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,73 @@ +"""Smoke tests for the RealSense D435 service layer. + +These tests exercise the dataclasses + the driver in its un-initialized +state. Tests that need real hardware are skipped at runtime. +""" +import pytest +import numpy as np + + +def _camera_module(): + try: + return __import__("src.backend.services.camera", fromlist=["*"]) + except ImportError as exc: + pytest.skip(f"pyrealsense2 not importable: {exc}") + + +class TestCameraIntrinsics: + def test_creation(self): + mod = _camera_module() + i = mod.CameraIntrinsics( + fx_ir=383.0, fy_ir=383.0, cx_ir=320.0, cy_ir=240.0, + fx_rgb=610.0, fy_rgb=610.0, cx_rgb=320.0, cy_rgb=240.0, + ) + assert i.fx_ir == 383.0 + assert i.fx_rgb == 610.0 + + +class TestCameraFrame: + def test_creation(self): + mod = _camera_module() + rgb = np.zeros((480, 640, 3), dtype=np.uint8) + depth = np.zeros((480, 640), dtype=np.uint16) + f = mod.CameraFrame(rgb=rgb, depth=depth, timestamp=1234.5) + assert f.rgb.shape == (480, 640, 3) + assert f.depth.shape == (480, 640) + assert f.timestamp == 1234.5 + + +class TestDiscoveredDevice: + def test_creation(self): + mod = _camera_module() + d = mod.DiscoveredDevice(index=0, vid=0x8086, pid=0x0B07, serial="ABC123", bus=1, device=5) + assert d.vid == 0x8086 + assert d.serial == "ABC123" + assert d.index == 0 + + +class TestRealSenseD435: + def test_defaults(self): + mod = _camera_module() + cam = mod.RealSenseD435() + assert not cam.is_open() + assert not cam.is_initialized() + assert cam.intrinsics is None + + def test_capture_when_closed(self): + mod = _camera_module() + cam = mod.RealSenseD435() + assert cam.capture() is None + assert cam.get_rgb() is None + assert cam.get_depth() is None + + def test_discover_requires_init(self): + mod = _camera_module() + cam = mod.RealSenseD435() + with pytest.raises(RuntimeError, match="not initialized"): + cam.discover() + + def test_open_requires_init(self): + mod = _camera_module() + cam = mod.RealSenseD435() + # open() returns False rather than raising when not initialized. + assert cam.open(0) is False