From b749cef14eafd4294807be019b86ad1fe3f75e25 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 12:33:20 +0200 Subject: [PATCH] feat: add 3D digital twin visualization --- .cursor/rules/physics-engine-gate.mdc | 12 + .cursor/rules/three-d-digital-twin.mdc | 13 + .cursor/rules/webgl-performance-budget.mdc | 15 + README.md | 29 +- docs/DEMO_SCRIPT.md | 30 +- docs/JURY_QA.md | 20 +- docs/SUBMISSION_CHECKLIST.md | 16 +- docs/THREE_D_FEASIBILITY.md | 69 ++ package-lock.json | 696 +++++++++++++++++- package.json | 4 + src/components/EngineeringDetails.tsx | 10 +- src/components/ProductDemoSection.tsx | 75 +- src/components/ThreeD/Actuator3D.tsx | 52 ++ src/components/ThreeD/Conveyor3D.tsx | 32 + src/components/ThreeD/Item3D.tsx | 61 ++ src/components/ThreeD/RouteArrows3D.tsx | 73 ++ src/components/ThreeD/SceneLabels3D.tsx | 92 +++ src/components/ThreeD/SensorRig3D.tsx | 55 ++ src/components/ThreeD/SorterDigitalTwin.tsx | 171 +++++ src/components/ThreeD/SortingZones3D.tsx | 73 ++ .../ThreeD/ThreeCapabilityCheck.tsx | 92 +++ src/components/ThreeD/ThreeFallback.tsx | 24 + src/components/ThreeD/itemMotion.ts | 72 ++ src/components/ThreeD/useWebGL.ts | 28 + src/domain/classifier.ts | 2 +- src/domain/metrics.ts | 2 +- src/domain/pid.test.ts | 14 +- src/domain/pid.ts | 2 +- src/domain/simulation.ts | 2 +- src/styles.css | 102 +++ 30 files changed, 1897 insertions(+), 41 deletions(-) create mode 100644 .cursor/rules/physics-engine-gate.mdc create mode 100644 .cursor/rules/three-d-digital-twin.mdc create mode 100644 .cursor/rules/webgl-performance-budget.mdc create mode 100644 docs/THREE_D_FEASIBILITY.md create mode 100644 src/components/ThreeD/Actuator3D.tsx create mode 100644 src/components/ThreeD/Conveyor3D.tsx create mode 100644 src/components/ThreeD/Item3D.tsx create mode 100644 src/components/ThreeD/RouteArrows3D.tsx create mode 100644 src/components/ThreeD/SceneLabels3D.tsx create mode 100644 src/components/ThreeD/SensorRig3D.tsx create mode 100644 src/components/ThreeD/SorterDigitalTwin.tsx create mode 100644 src/components/ThreeD/SortingZones3D.tsx create mode 100644 src/components/ThreeD/ThreeCapabilityCheck.tsx create mode 100644 src/components/ThreeD/ThreeFallback.tsx create mode 100644 src/components/ThreeD/itemMotion.ts create mode 100644 src/components/ThreeD/useWebGL.ts diff --git a/.cursor/rules/physics-engine-gate.mdc b/.cursor/rules/physics-engine-gate.mdc new file mode 100644 index 0000000..48fbeff --- /dev/null +++ b/.cursor/rules/physics-engine-gate.mdc @@ -0,0 +1,12 @@ +--- +description: Physics engine only after feasibility; prefer predictable demo +alwaysApply: true +--- + +# Physics Engine Gate Rules + +- Physics engine подключать только после feasibility check. +- Если physics увеличивает bundle/лаги/нестабильность — использовать keyframe/state-machine animation. +- Physics не должна ломать управляемость демо. +- Для хакатона важнее предсказуемое демо, чем реалистичная физика. +- Fault/jam можно моделировать логикой, а не реальными столкновениями. diff --git a/.cursor/rules/three-d-digital-twin.mdc b/.cursor/rules/three-d-digital-twin.mdc new file mode 100644 index 0000000..7be1ad3 --- /dev/null +++ b/.cursor/rules/three-d-digital-twin.mdc @@ -0,0 +1,13 @@ +--- +description: 3D digital twin must prove physical routing, not decoration +alwaysApply: true +--- + +# 3D Digital Twin Rules + +- 3D должно доказывать физическую маршрутизацию, а не быть декором. +- Сцена должна показывать: A, конвейер, накопитель, camera/CV, laser, ultrasonic, stop-gate, actuator, B/C/D, roll-cages. +- 3D связано с simulation state. +- Категория B/C/D должна быть видна через цвет, route и команду. +- Не использовать тяжёлые модели/текстуры. +- Не добавлять 3D-эффекты без инженерного смысла. diff --git a/.cursor/rules/webgl-performance-budget.mdc b/.cursor/rules/webgl-performance-budget.mdc new file mode 100644 index 0000000..8e09dca --- /dev/null +++ b/.cursor/rules/webgl-performance-budget.mdc @@ -0,0 +1,15 @@ +--- +description: Lightweight WebGL budget and 2D fallback requirements +alwaysApply: true +--- + +# WebGL Performance Budget Rules + +- 3D-сцена должна быть lightweight. +- Использовать простые геометрии. +- Ограничить количество объектов. +- Не использовать дорогие shadows/postprocessing. +- Не использовать тяжёлые texture maps. +- Должен быть 2D fallback. +- На mobile должен быть simplified/fallback mode. +- Не должно быть horizontal scroll. diff --git a/README.md b/README.md index f9d1497..8307e9d 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,32 @@ UI — **Product Demo Landing Page**: - без горизонтального скролла на desktop / laptop / mobile; - mobile-first адаптив (одна колонка < 640px); +- **3D digital twin** (React Three Fiber) + 2D SVG fallback; - storyline Detection → Classification → Decision → Command → Routing; - карточки сценариев и критериев OZON; - Engineering Details свёрнуты по умолчанию. +## 3D Digital Twin + +Главная сцена — цифровая модель программно-аппаратного комплекса: + +A → подающий конвейер → накопитель → CV/laser/ultrasonic → stop-gate → actuator → B/C/D. + +- Стек: `three` + `@react-three/fiber` + `@react-three/drei`. +- **Physics engine не используется** — motion по state machine / keyframe (предсказуемое демо). +- Архитектура (`itemMotion.ts`) готова к подключению physics позже. +- Переключатель: **3D Digital Twin** / **2D fallback**. +- На mobile (<640px) по умолчанию 2D; 3D можно включить вручную. +- Если WebGL недоступен — автоматический 2D fallback. + +Что доказывает 3D: +- classification → `ROUTE_TO_*` → actuator motion → physical route; +- B зелёный прямой маршрут, C оранжевый roll-cage, D фиолетовый roll-cage; +- C priority при негабарите (даже если объект круглый); +- fault / emergency stop красной подсветкой и остановкой конвейера. + +Проверка WebGL: Engineering Details → **3D capability check** (FPS, WebGL status). + ## Стек - Vite, React, TypeScript @@ -38,7 +60,7 @@ http://127.0.0.1:3100/ ## Как устроен новый UI 1. **Hero** — что это за система, CTA «Запустить демо», цепочка Detection → Routing. -2. **Product Demo** — упрощённая сцена (`SorterScene variant="simple"`) + карточка результата + Start / Next / Reset. +2. **Product Demo** — 3D digital twin (или 2D fallback) + карточка результата + Start / Next / Reset. 3. **Storyline Stepper** — текущий этап цикла. 4. **Scenario Cards** — jury-кейсы карточками (кнопка «Показать»). 5. **Criteria Cards** — покрытие критериев OZON со ссылкой на сценарий. @@ -127,9 +149,10 @@ curl -I https://www.arhipovdan.ru/ Границы MVP: -- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм; +- min: width >= 10 мм, depth >= 10 мм, height >= 10 мм; - max: width <= 450 мм, depth <= 320 мм, height <= 320 мм; -- roundness threshold: 0.8. +- roundness threshold: 0.8 (K = r_in / r_out); +- conveyor target speed: 1.00 м/с (close_items: 0.75 м/с). ## Исполнительная часть diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index 0045271..4472bb1 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -6,22 +6,23 @@ Open https://arhipovdan.ru/ (или http://127.0.0.1:3100/). ## 30-Second Pitch -«Мы сделали инженерную симуляцию сортировочной ячейки OZON. Полный цикл: detection → classification → command → routing. Показываем happy path B/C/D, fault handling и timing. Главный экран — product demo, инженерные панели ниже.» +«Мы сделали инженерную симуляцию сортировочной ячейки OZON с 3D digital twin. Полный цикл: detection → classification → command → routing. Товар физически уходит в B/C/D. Physics engine не используем — motion по state machine, демо предсказуемо.» ## 3-Minute Demo 1. На первом экране укажите Hero: что это за система и цепочку Detection → Classification → Command → Routing. 2. Нажмите **Запустить демо**. -3. В блоке **Главное демо** покажите упрощённую сцену и карточку результата: - - товар; +3. В блоке **Главное демо** покажите **3D Digital Twin** и карточку результата: + - товар на конвейере; + - накопитель и stop-gate; - категория B/C/D; - - причина решения; - команда `ROUTE_TO_*`; - - целевая зона. -4. Нажимайте **Next step**, следите за **Этапами цикла**. -5. В **Сценариях** нажмите **Показать** на «Негабарит» — приоритет C. -6. Затем «Круглый объект» — зона D. -7. Затем «Застревание» или «Аварийная остановка» — FAULT / EMERGENCY_STOP, Reset. + - подсветка маршрута в зону. +4. Нажимайте **Next step**, следите за **Этапами цикла** и движением в 3D. +5. В **Сценариях** нажмите **Показать** на «Негабарит» — оранжевый route C, roll-cage C. +6. Затем «Круглый объект» — фиолетовый route D. +7. Затем «Застревание» или «Аварийная остановка» — красная подсветка, FAULT / EMERGENCY_STOP, Reset. +8. При необходимости переключите **2D fallback** — логика та же. ## 5-Minute Demo @@ -86,12 +87,19 @@ docker compose -p owl -f /opt/arhipovdan/app/docker-compose.server.yml ps document.documentElement.scrollWidth <= document.documentElement.clientWidth ``` +## 3D / WebGL + +- Desktop: по умолчанию 3D Digital Twin (если WebGL есть). +- Mobile: по умолчанию 2D; 3D можно включить вручную. +- Capability check: Engineering Details → блок **3D capability check** (WebGL, FPS). +- Physics engine: **не подключён** (state-machine animation). + ## What Each Block Proves - Hero — смысл проекта за 10 секунд. -- Product Demo scene — физический маршрут и команда. +- 3D digital twin — физическая маршрутизация A → CV → gate → B/C/D. - Proof card — почему выбрана категория B/C/D. - Storyline — текущий этап цикла. - Scenario cards — jury test cases без узкого скролл-списка. - Criteria cards — покрытие критериев OZON. -- Engineering Details — timeline, PID, event log, sensors. +- Engineering Details — timeline, PID, event log, sensors, 3D capability check. diff --git a/docs/JURY_QA.md b/docs/JURY_QA.md index fbea825..082435b 100644 --- a/docs/JURY_QA.md +++ b/docs/JURY_QA.md @@ -24,9 +24,9 @@ Open scenario **Аварийная остановка**. The system enters `EMER Low confidence is logged as a warning. The system still applies deterministic dimensions/roundness rules as a fallback and shows this on the proof card. -## 7. Why 2D, Not 3D? +## 7. Why 3D Digital Twin Without Physics Engine? -2D is enough for MVP validation: it shows geometry, timing, sensor positions, routes and state transitions without heavy rendering. +The main demo uses a lightweight React Three Fiber digital twin driven by the state machine (keyframe motion). A physics engine would add wasm weight and unpredictable collisions, which is worse for a live jury pitch. 2D SVG remains as fallback when WebGL is unavailable or on narrow mobile screens. ## 8. What Is Needed For A Physical Prototype? @@ -42,7 +42,7 @@ The proof card shows dimensions, roundness and reason. Tests cover key boundary ## 11. How Are Dimensions And Circular Section Handled? -Dimensions are checked first against min/max width, depth and height. If they pass, roundness is checked against threshold 0.8. +Dimensions are checked first against min/max: 10×10×10 mm to 450×320×320 mm. If they pass, roundness K = r_in / r_out is checked against threshold 0.8. Conveyor target speed is 1.00 m/s. ## 12. Why Does C Have Priority Over D? @@ -74,8 +74,20 @@ Scroll to **Engineering Details** or click **Инженерный режим**. ## 19. How to check mobile and no horizontal scroll? -Use widths 1920×1080, 1440×900 and 390×844. On mobile the layout is one column, buttons are large, the scene scales with `width: 100%`. In console: +Use widths 1920×1080, 1440×900 and 390×844. On mobile the layout is one column, buttons are large, default view is 2D fallback. In console: ```js document.documentElement.scrollWidth <= document.documentElement.clientWidth ``` + +## 20. How to show B / C / D / C-priority / fault in 3D? + +- Normal item → green route B. +- Oversized → orange route C into roll-cage C. +- Round object → purple route D into roll-cage D. +- Oversized + round → still C (dimensions priority), D route stays inactive. +- Jam / emergency → red overlay, conveyor stopped, Reset required. + +## 21. Where is the WebGL / FPS check? + +Open Engineering Details → **3D capability check**. diff --git a/docs/SUBMISSION_CHECKLIST.md b/docs/SUBMISSION_CHECKLIST.md index f96d05e..0dc3307 100644 --- a/docs/SUBMISSION_CHECKLIST.md +++ b/docs/SUBMISSION_CHECKLIST.md @@ -9,17 +9,25 @@ Before final submission: - [ ] `https://www.arhipovdan.ru/` opens. - [ ] `https://ai-shorts.ru/` still works and was not affected. - [ ] Product Demo page opens (Hero first, not engineering dashboard). -- [ ] Start demo / Next step / Reset work. +- [ ] 3D Digital Twin loads on desktop when WebGL is available. +- [ ] Toggle **3D Digital Twin** / **2D fallback** works. +- [ ] Mobile defaults to 2D fallback (or simplified 3D if enabled). +- [ ] Start demo / Next step / Reset move the item in 3D by state machine. +- [ ] Routes B / C / D are visible in 3D. +- [ ] C-priority works for oversized + round. +- [ ] Jam / emergency stop show red fault state. +- [ ] Physics engine is NOT required for the demo (state-machine motion). +- [ ] Engineering Details includes 3D capability check (WebGL / FPS). +- [ ] Min dimensions are 10×10×10 mm; conveyor target is 1.00 m/s. - [ ] Storyline Stepper updates with machine state. - [ ] Scenario cards open cases with **Показать**. - [ ] Criteria cards show OZON coverage and linked scenarios. - [ ] Engineering Details is collapsed by default and opens from CTA. -- [ ] Engineering Details shows full panels (state machine, sensors, PID, timeline, event log, criteria). - [ ] App is responsive on Desktop (1920×1080), Laptop (1440×900) and Mobile (390×844). - [ ] NO horizontal scroll on body / `#root` / product page. - [ ] Mobile: one column, buttons ≥ 44px, scene scales. - [ ] Scenarios checked: normal_flow, oversized_item, round_object, boundary_dimensions, close_items, low_confidence, jam, emergency_stop. -- [ ] Docs present: README, ARCHITECTURE, DEMO_SCRIPT, SCENARIOS, JURY_QA, UI_UX_REDESIGN_AUDIT, SUBMISSION_CHECKLIST. -- [ ] Cursor rules present in `.cursor/rules/`. +- [ ] Docs present: README, ARCHITECTURE, DEMO_SCRIPT, SCENARIOS, JURY_QA, UI_UX_REDESIGN_AUDIT, THREE_D_FEASIBILITY, SUBMISSION_CHECKLIST. +- [ ] Cursor rules present in `.cursor/rules/` (including 3D / physics / WebGL rules). - [ ] Branch ready for manual push to `origin/dan_branch`. - [ ] Nginx / SSL / neighboring projects were not modified. diff --git a/docs/THREE_D_FEASIBILITY.md b/docs/THREE_D_FEASIBILITY.md new file mode 100644 index 0000000..8d0a82d --- /dev/null +++ b/docs/THREE_D_FEASIBILITY.md @@ -0,0 +1,69 @@ +# 3D Digital Twin Feasibility + +## Выбранный стек + +- `three` +- `@react-three/fiber` +- `@react-three/drei` (OrbitControls, Html labels) +- **Без** `@react-three/rapier` на первом этапе + +## Почему + +- R3F хорошо ложится на текущий React UI. +- Простые box/cylinder геометрии достаточны для digital twin. +- Drei нужен только для камеры и коротких labels. +- Physics engine в браузере добавляет wasm, непредсказуемость и риск лагов на защите. + +## Physics engine + +**Не подключается.** + +Анимация: keyframe / state-machine interpolation по `machineState` + `elapsedInStateMs`. + +Архитектура готова к physics позже: +- позиции товара и actuator вычисляются в `itemMotion.ts`; +- статические меши сцены отделены от motion layer; +- можно заменить motion layer на kinematic/dynamic bodies без смены domain logic. + +## Риски + +| Риск | Митигация | +|------|-----------| +| Bundle size | lazy-load 3D, простые геометрии | +| Нет WebGL | 2D `SorterScene` fallback | +| Mobile FPS | simplified 3D или auto-2D при width < 640 | +| Непредсказуемая физика | не используем rapier | + +## Fallback + +1. WebGL unavailable → 2D. +2. User toggle «2D fallback». +3. Mobile narrow screen → default 2D (можно вручную включить 3D). + +Сообщение: «3D недоступен, включён 2D fallback. Логика симуляции та же.» + +## Performance budget + +- Без shadows / postprocessing / textures. +- < ~50 mesh-объектов. +- FPS overlay в capability check. +- Target: стабильный interactive FPS на desktop. + +## Integration point + +`ProductDemoSection`: toggle 3D Digital Twin / 2D fallback. +Props из `SimulationState`: item, machineState, scenario, classification, sensors, actuators, metrics, gate. + +## Definition of Done + +- [x] Build/test/Docker OK +- [x] 3D показывает A → conveyor → CV → gate → B/C/D +- [x] State machine двигает товар предсказуемо +- [x] ROUTE_TO_* виден цветом и стрелкой +- [x] 2D fallback работает +- [x] min dimensions 10×10×10, conveyor 1.00 m/s +- [x] Нет horizontal scroll + +## Decision log + +Physics (`@react-three/rapier`) **не установлен**: для хакатона важнее предсказуемый route, чем dynamic collisions. Motion layer — `itemMotion.ts` (`PHYSICS_ENGINE_ENABLED = false`). diff --git a/package-lock.json b/package-lock.json index fa008e6..d3d8f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "ozon-tech-sorter-simulation", "version": "0.1.0", "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", "@vitejs/plugin-react": "latest", "react": "latest", "react-dom": "latest", + "three": "^0.185.1", "typescript": "latest", "vite": "latest" }, @@ -18,9 +21,25 @@ "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.0", "vitest": "^4.1.9" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "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.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -59,6 +78,24 @@ "dev": true, "license": "MIT" }, + "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.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -86,6 +123,94 @@ "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.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "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.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -341,6 +466,12 @@ "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.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -369,6 +500,12 @@ "dev": true, "license": "MIT" }, + "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.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -386,11 +523,16 @@ "undici-types": "~8.3.0" } }, + "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.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -406,6 +548,59 @@ "@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.185.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.0.tgz", + "integrity": "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.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.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -554,6 +749,72 @@ "node": ">=12" } }, + "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/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/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/camera-controls": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz", + "integrity": "sha512-w5oULNpijgTRH0ARFJJ0R5ct1nUM3R3WP7/b8A6j9uTGpRfnsypc/RBMPQV8JQDPayUe37p/TZZY1PcUr4czOQ==", + "license": "MIT", + "engines": { + "node": ">=20.11.0", + "npm": ">=10.8.2" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -571,13 +832,53 @@ "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==", - "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", @@ -587,6 +888,12 @@ "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/es-module-lexer": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", @@ -631,6 +938,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -645,6 +958,77 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "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/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "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/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "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/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", @@ -894,6 +1278,16 @@ "url": "https://opencollective.com/parcel" } }, + "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/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -904,6 +1298,21 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -936,6 +1345,15 @@ "node": ">=12.20.0" } }, + "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/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -989,6 +1407,22 @@ "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/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/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1010,6 +1444,30 @@ "react": "^19.2.7" } }, + "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/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -1049,6 +1507,27 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "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/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1072,6 +1551,32 @@ "dev": true, "license": "MIT" }, + "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/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -1079,6 +1584,53 @@ "dev": true, "license": "MIT" }, + "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.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "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/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1122,6 +1674,36 @@ "node": ">=14.0.0" } }, + "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", @@ -1129,6 +1711,43 @@ "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/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1149,6 +1768,24 @@ "devOptional": true, "license": "MIT" }, + "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.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -1316,6 +1953,32 @@ } } }, + "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/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1332,6 +1995,35 @@ "engines": { "node": ">=8" } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "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/package.json b/package.json index f4632e1..4f2d952 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "test": "vitest run" }, "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", "@vitejs/plugin-react": "latest", "react": "latest", "react-dom": "latest", + "three": "^0.185.1", "typescript": "latest", "vite": "latest" }, @@ -20,6 +23,7 @@ "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.0", "vitest": "^4.1.9" } } diff --git a/src/components/EngineeringDetails.tsx b/src/components/EngineeringDetails.tsx index e4af842..74c20ac 100644 --- a/src/components/EngineeringDetails.tsx +++ b/src/components/EngineeringDetails.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useImperativeHandle, useState } from 'react'; +import { forwardRef, lazy, Suspense, useImperativeHandle, useState } from 'react'; import StateMachinePanel from './StateMachinePanel'; import TimelinePanel from './TimelinePanel'; import PidPanel from './PidPanel'; @@ -12,6 +12,8 @@ import { DEMO_STEPS } from '../data/demoSteps'; import { SCENARIOS } from '../data/scenarios'; import type { ScenarioId, SimulationState } from '../domain/types'; +const ThreeCapabilityCheck = lazy(() => import('./ThreeD/ThreeCapabilityCheck')); + export interface EngineeringDetailsHandle { open: () => void; } @@ -63,6 +65,12 @@ const EngineeringDetails = forwardRef( +
+ Loading 3D capability check…

}> + +
+
+
diff --git a/src/components/ProductDemoSection.tsx b/src/components/ProductDemoSection.tsx index dc6c80a..d192e42 100644 --- a/src/components/ProductDemoSection.tsx +++ b/src/components/ProductDemoSection.tsx @@ -1,7 +1,13 @@ -import SorterScene from './SorterScene'; +import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import CurrentProofCard from './CurrentProofCard'; +import ThreeFallback from './ThreeD/ThreeFallback'; +import { prefer3DByDefault, useWebGLSupport } from './ThreeD/useWebGL'; import type { SimulationState } from '../domain/types'; +const SorterDigitalTwin = lazy(() => import('./ThreeD/SorterDigitalTwin')); + +type ViewMode = '3d' | '2d'; + interface Props { simulation: SimulationState; demoStepTitle: string; @@ -19,6 +25,21 @@ export default function ProductDemoSection({ onReset, onOpenEngineering, }: Props) { + const webgl = useWebGLSupport(); + const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth)); + const [viewMode, setViewMode] = useState('3d'); + const [contextLost, setContextLost] = useState(false); + + useEffect(() => { + const onResize = () => setWidth(window.innerWidth); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + useEffect(() => { + setViewMode(prefer3DByDefault(width, webgl && !contextLost) ? '3d' : '2d'); + }, [webgl, width, contextLost]); + const item = simulation.currentItem; const category = item?.classification.category; const command = simulation.machineState.startsWith('ROUTE_TO_') @@ -27,6 +48,15 @@ export default function ProductDemoSection({ ? `ROUTE_TO_${item.classification.category}` : '—'; + const fallbackReason = useMemo(() => { + if (!webgl || contextLost) return 'webgl' as const; + if (width < 640) return 'mobile' as const; + return 'user' as const; + }, [webgl, contextLost, width]); + + const show3D = viewMode === '3d' && webgl && !contextLost; + const simplified = width < 900; + return (
@@ -34,7 +64,8 @@ export default function ProductDemoSection({

Live product demo

Главное демо

- Слева — упрощённая сцена конвейера. Справа — товар, категория, причина решения и команда маршрутизации. + 3D digital twin показывает физическую маршрутизацию: A → конвейер → CV → накопитель → gate → B/C/D. + Справа — товар, категория, причина и команда ROUTE_TO_*.

@@ -45,13 +76,47 @@ export default function ProductDemoSection({
+
+ + +
+

- Товар проходит камеру, классификатор и gate, затем уходит в зону B, C или D. + Товар проходит камеру, накопитель и gate, затем уходит в зону B, C или D.

-
- +
+ {show3D ? ( + Загрузка 3D digital twin…
}> + { + setContextLost(true); + setViewMode('2d'); + }} + /> + + ) : ( + + )}
diff --git a/src/components/ThreeD/Actuator3D.tsx b/src/components/ThreeD/Actuator3D.tsx new file mode 100644 index 0000000..ebaf600 --- /dev/null +++ b/src/components/ThreeD/Actuator3D.tsx @@ -0,0 +1,52 @@ +import type { SimulationState } from '../../domain/types'; +import { pusherOffset, TWIN_LAYOUT } from './itemMotion'; + +interface Props { + gate: SimulationState['gate']; + actuators: SimulationState['actuators']; + machineState: SimulationState['machineState']; +} + +export default function Actuator3D({ gate, actuators, machineState }: Props) { + const gateOpen = gate.open; + const pusherC = pusherOffset(actuators.pusherC); + const pusherD = pusherOffset(actuators.pusherD); + const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP'; + + return ( + + {/* Stop-gate */} + + + + + + {/* Pusher C → roll-cage C */} + + + + + + {/* Pusher D → roll-cage D */} + + + + + + ); +} diff --git a/src/components/ThreeD/Conveyor3D.tsx b/src/components/ThreeD/Conveyor3D.tsx new file mode 100644 index 0000000..7d34df5 --- /dev/null +++ b/src/components/ThreeD/Conveyor3D.tsx @@ -0,0 +1,32 @@ +import { TWIN_LAYOUT } from './itemMotion'; + +interface Props { + stopped: boolean; +} + +export default function Conveyor3D({ stopped }: Props) { + const color = stopped ? '#7f1d1d' : '#1e3a54'; + const beltColor = stopped ? '#991b1b' : '#334155'; + + return ( + + {/* Feed conveyor body */} + + + + + + {/* Belt surface */} + + + + + + {/* Accumulator / buffer at end of feed conveyor */} + + + + + + ); +} diff --git a/src/components/ThreeD/Item3D.tsx b/src/components/ThreeD/Item3D.tsx new file mode 100644 index 0000000..036680c --- /dev/null +++ b/src/components/ThreeD/Item3D.tsx @@ -0,0 +1,61 @@ +import type { SimulatedItem, SimulationState } from '../../domain/types'; +import { itemPosition3D, ROUTE_COLORS } from './itemMotion'; + +interface Props { + simulation: SimulationState; + currentItem?: SimulatedItem; +} + +export default function Item3D({ simulation, currentItem }: Props) { + if (!currentItem) { + return null; + } + + const [x, y, z] = itemPosition3D(simulation); + const category = currentItem.classification.category; + const color = ROUTE_COLORS[category] ?? '#38bdf8'; + const dims = currentItem.item.dimensionsMm; + const sx = Math.max(0.12, Math.min(0.45, dims.width / 1000)); + const sy = Math.max(0.1, Math.min(0.4, dims.height / 1000)); + const sz = Math.max(0.12, Math.min(0.45, dims.depth / 1000)); + const isRound = + currentItem.item.shape.includes('round') || + currentItem.item.shape.includes('cylinder') || + currentItem.item.roundness >= 0.8; + const detecting = simulation.machineState === 'DETECTING'; + const fault = + simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP'; + + return ( + + {isRound ? ( + + + + + ) : ( + + + + + )} + + {detecting ? ( + + + + + ) : null} + + ); +} diff --git a/src/components/ThreeD/RouteArrows3D.tsx b/src/components/ThreeD/RouteArrows3D.tsx new file mode 100644 index 0000000..389cfde --- /dev/null +++ b/src/components/ThreeD/RouteArrows3D.tsx @@ -0,0 +1,73 @@ +import type { Category, MachineState } from '../../domain/types'; +import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion'; + +interface Props { + category?: Category; + machineState: MachineState; +} + +function RouteBeam({ + start, + end, + color, + active, +}: { + start: [number, number, number]; + end: [number, number, number]; + color: string; + active: boolean; +}) { + const mid: [number, number, number] = [ + (start[0] + end[0]) / 2, + (start[1] + end[1]) / 2, + (start[2] + end[2]) / 2, + ]; + const dx = end[0] - start[0]; + const dz = end[2] - start[2]; + const length = Math.sqrt(dx * dx + dz * dz) || 0.1; + const angle = Math.atan2(dz, dx); + + return ( + + + + + ); +} + +export default function RouteArrows3D({ category, machineState }: Props) { + const routing = machineState.startsWith('ROUTE_TO_'); + const classifying = machineState === 'CLASSIFYING'; + const preview = classifying || routing; + const y = TWIN_LAYOUT.beltY + 0.05; + const from: [number, number, number] = [TWIN_LAYOUT.gateX, y, 0]; + + return ( + + + + + + ); +} diff --git a/src/components/ThreeD/SceneLabels3D.tsx b/src/components/ThreeD/SceneLabels3D.tsx new file mode 100644 index 0000000..3db048b --- /dev/null +++ b/src/components/ThreeD/SceneLabels3D.tsx @@ -0,0 +1,92 @@ +import { Html } from '@react-three/drei'; +import type { Category, MachineState, SimulatedItem } from '../../domain/types'; +import { TWIN_LAYOUT } from './itemMotion'; + +interface Props { + machineState: MachineState; + currentItem?: SimulatedItem; + simplified?: boolean; +} + +function Badge({ + position, + text, + color = '#e5f2ff', +}: { + position: [number, number, number]; + text: string; + color?: string; +}) { + return ( + +
+ {text} +
+ + ); +} + +export default function SceneLabels3D({ machineState, currentItem, simplified }: Props) { + const category = currentItem?.classification.category as Category | undefined; + const item = currentItem?.item; + const result = currentItem?.classification; + const command = machineState.startsWith('ROUTE_TO_') + ? machineState + : category + ? `ROUTE_TO_${category}` + : null; + + return ( + + + + {!simplified ? ( + <> + + + + ) : null} + + + + + + + {item && result && !simplified ? ( + + ) : null} + + {command ? ( + + ) : null} + + {machineState === 'DETECTING' ? ( + + ) : null} + + {machineState === 'FAULT' || machineState === 'EMERGENCY_STOP' ? ( + + ) : null} + + ); +} diff --git a/src/components/ThreeD/SensorRig3D.tsx b/src/components/ThreeD/SensorRig3D.tsx new file mode 100644 index 0000000..49acd50 --- /dev/null +++ b/src/components/ThreeD/SensorRig3D.tsx @@ -0,0 +1,55 @@ +import type { SimulationState } from '../../domain/types'; +import { TWIN_LAYOUT } from './itemMotion'; + +interface Props { + sensors: SimulationState['sensors']; + machineState: SimulationState['machineState']; +} + +function SensorPole({ + x, + active, + labelColor, + height = 0.9, +}: { + x: number; + active: boolean; + labelColor: string; + height?: number; +}) { + return ( + + + + + + + + + + + ); +} + +export default function SensorRig3D({ sensors, machineState }: Props) { + const detecting = machineState === 'DETECTING'; + + return ( + + + + + + {detecting ? ( + + + + + ) : null} + + ); +} diff --git a/src/components/ThreeD/SorterDigitalTwin.tsx b/src/components/ThreeD/SorterDigitalTwin.tsx new file mode 100644 index 0000000..040a797 --- /dev/null +++ b/src/components/ThreeD/SorterDigitalTwin.tsx @@ -0,0 +1,171 @@ +import { Canvas, useFrame } from '@react-three/fiber'; +import { Grid, OrbitControls } from '@react-three/drei'; +import { Suspense, useRef, useState } from 'react'; +import type { SimulationState } from '../../domain/types'; +import Conveyor3D from './Conveyor3D'; +import SensorRig3D from './SensorRig3D'; +import Actuator3D from './Actuator3D'; +import SortingZones3D from './SortingZones3D'; +import Item3D from './Item3D'; +import RouteArrows3D from './RouteArrows3D'; +import SceneLabels3D from './SceneLabels3D'; +import { PHYSICS_ENGINE_ENABLED } from './itemMotion'; + +export interface SorterDigitalTwinProps { + simulation: SimulationState; + simplified?: boolean; + showFps?: boolean; + onContextLost?: () => void; +} + +function FpsMeter({ onFps }: { onFps: (fps: number) => void }) { + const frames = useRef([]); + const last = useRef(performance.now()); + + useFrame(() => { + const now = performance.now(); + const dt = now - last.current; + last.current = now; + if (dt <= 0) return; + frames.current.push(1000 / dt); + if (frames.current.length > 45) frames.current.shift(); + const avg = frames.current.reduce((a, b) => a + b, 0) / frames.current.length; + onFps(Math.round(avg)); + }); + + return null; +} + +function TwinScene({ + simulation, + simplified, + onFps, +}: { + simulation: SimulationState; + simplified?: boolean; + onFps: (fps: number) => void; +}) { + const stopped = + simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP'; + const category = simulation.currentItem?.classification.category; + const activeRoute = + simulation.machineState === 'ROUTE_TO_B' + ? 'B' + : simulation.machineState === 'ROUTE_TO_C' + ? 'C' + : simulation.machineState === 'ROUTE_TO_D' + ? 'D' + : simulation.activeRoute ?? category; + + return ( + <> + + + + + + + + + + + + + + + + + + + + + {stopped ? ( + + + + + ) : null} + + + + + ); +} + +export default function SorterDigitalTwin({ + simulation, + simplified = false, + showFps = true, + onContextLost, +}: SorterDigitalTwinProps) { + const [fps, setFps] = useState(0); + const category = simulation.currentItem?.classification.category; + const command = simulation.machineState.startsWith('ROUTE_TO_') + ? simulation.machineState + : category + ? `ROUTE_TO_${category}` + : 'IDLE'; + + return ( +
+
+ {simulation.machineState} + {simulation.scenario.name} + {category ? Zone {category} : null} + {command} + {showFps ? FPS ~{fps} : null} + + Motion: {PHYSICS_ENGINE_ENABLED ? 'physics' : 'state-machine'} + +
+ +
+ { + const canvas = gl.domElement; + const handleLost = (event: Event) => { + event.preventDefault(); + // Ignore teardown when switching to 2D / unmounting the canvas. + if (!canvas.isConnected) return; + onContextLost?.(); + }; + canvas.addEventListener('webglcontextlost', handleLost, false); + }} + > + + + + +
+
+ ); +} diff --git a/src/components/ThreeD/SortingZones3D.tsx b/src/components/ThreeD/SortingZones3D.tsx new file mode 100644 index 0000000..599d424 --- /dev/null +++ b/src/components/ThreeD/SortingZones3D.tsx @@ -0,0 +1,73 @@ +import type { Category } from '../../domain/types'; +import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion'; + +interface Props { + activeCategory?: Category; + activeRoute?: Category; +} + +function ZoneBox({ + position, + size, + color, + active, +}: { + position: [number, number, number]; + size: [number, number, number]; + color: string; + active: boolean; +}) { + return ( + + + + + ); +} + +export default function SortingZones3D({ activeCategory, activeRoute }: Props) { + const active = activeRoute ?? activeCategory; + const cage = TWIN_LAYOUT.rollCageSize; + + return ( + + {/* Zone A feed */} + + + {/* Zone B main sorter — straight */} + + + {/* Roll-cage C 1200×800×800 mm */} + + + {/* Roll-cage D 1200×800×800 mm */} + + + ); +} diff --git a/src/components/ThreeD/ThreeCapabilityCheck.tsx b/src/components/ThreeD/ThreeCapabilityCheck.tsx new file mode 100644 index 0000000..4dfd02f --- /dev/null +++ b/src/components/ThreeD/ThreeCapabilityCheck.tsx @@ -0,0 +1,92 @@ +import { Canvas, useFrame } from '@react-three/fiber'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { detectWebGL, useWebGLSupport } from './useWebGL'; +import { PHYSICS_ENGINE_ENABLED } from './itemMotion'; + +function SpikeScene({ onFps }: { onFps: (fps: number) => void }) { + const frames = useRef([]); + const last = useRef(performance.now()); + + useFrame(() => { + const now = performance.now(); + const dt = now - last.current; + last.current = now; + if (dt <= 0) return; + const fps = 1000 / dt; + frames.current.push(fps); + if (frames.current.length > 60) { + frames.current.shift(); + } + const avg = frames.current.reduce((a, b) => a + b, 0) / frames.current.length; + onFps(Math.round(avg)); + }); + + const boxes = useMemo( + () => + Array.from({ length: 16 }, (_, index) => ({ + key: index, + position: [((index % 4) - 1.5) * 0.45, 0.2 + Math.floor(index / 4) * 0.35, -0.4] as [ + number, + number, + number, + ], + })), + [], + ); + + return ( + <> + + + + + + + + + + + {boxes.map((box) => ( + + + + + ))} + + ); +} + +/** + * Diagnostic utility: WebGL probe + lightweight scene FPS. + * Physics engine is intentionally not loaded (see PHYSICS_ENGINE_ENABLED). + */ +export default function ThreeCapabilityCheck() { + const webgl = useWebGLSupport(); + const [fps, setFps] = useState(0); + const [ready, setReady] = useState(false); + + useEffect(() => { + setReady(true); + }, []); + + return ( +
+
+ 3D capability check + WebGL: {webgl ? 'OK' : 'unavailable'} + Physics engine: {PHYSICS_ENGINE_ENABLED ? 'enabled' : 'disabled (state-machine motion)'} + Avg FPS: {webgl && ready ? fps : '—'} + Runtime detect: {detectWebGL() ? 'yes' : 'no'} +
+ {webgl && ready ? ( +
+ + + +
+ ) : ( +

WebGL недоступен — 3D digital twin будет в 2D fallback.

+ )} +
+ ); +} diff --git a/src/components/ThreeD/ThreeFallback.tsx b/src/components/ThreeD/ThreeFallback.tsx new file mode 100644 index 0000000..7cdbe37 --- /dev/null +++ b/src/components/ThreeD/ThreeFallback.tsx @@ -0,0 +1,24 @@ +import SorterScene from '../SorterScene'; +import type { SimulationState } from '../../domain/types'; + +interface Props { + simulation: SimulationState; + reason?: 'webgl' | 'user' | 'mobile'; +} + +const MESSAGES: Record, string> = { + webgl: '3D недоступен (WebGL), включён 2D fallback. Логика симуляции та же.', + user: 'Включён 2D fallback. Логика симуляции та же.', + mobile: 'На узком экране включён 2D fallback. Логика симуляции та же.', +}; + +export default function ThreeFallback({ simulation, reason = 'user' }: Props) { + return ( +
+

+ {MESSAGES[reason]} +

+ +
+ ); +} diff --git a/src/components/ThreeD/itemMotion.ts b/src/components/ThreeD/itemMotion.ts new file mode 100644 index 0000000..89e0a85 --- /dev/null +++ b/src/components/ThreeD/itemMotion.ts @@ -0,0 +1,72 @@ +import type { Category, MachineState, SimulationState } from '../../domain/types'; + +/** Compact digital-twin coordinates (1 unit ≈ 1 m, scaled layout). */ +export const TWIN_LAYOUT = { + beltY: 0.35, + startX: -4.2, + endX: 4.2, + cameraX: -1.2, + laserX: -0.2, + ultrasonicX: 1.0, + gateX: 1.6, + accumulatorX: 1.4, + zoneBX: 3.8, + zoneCZ: 2.4, + zoneDZ: -2.4, + rollCageSize: { x: 1.2, y: 0.8, z: 0.8 }, +} as const; + +export const ROUTE_COLORS: Record = { + B: '#4ade80', + C: '#f59e0b', + D: '#c084fc', +}; + +export function progressForState(state: MachineState, elapsedMs: number): number { + const ratios: Partial> = { + MOVING_TO_CAMERA: [0.06, 0.34, 1200], + DETECTING: [0.34, 0.36, 900], + MOVING_TO_GATE: [0.36, 0.64, 1300], + WAITING_AT_GATE: [0.64, 0.65, 800], + CLASSIFYING: [0.65, 0.66, 700], + ROUTE_TO_B: [0.66, 0.93, 1200], + ROUTE_TO_C: [0.66, 0.82, 1200], + ROUTE_TO_D: [0.66, 0.82, 1200], + RETURN_HOME: [0.93, 0.94, 700], + }; + + const segment = ratios[state]; + if (!segment) { + return state === 'IDLE' ? 0.04 : 0.65; + } + + const [from, to, duration] = segment; + return from + (to - from) * Math.min(elapsedMs / duration, 1); +} + +export function itemPosition3D(simulation: SimulationState): [number, number, number] { + const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs); + const x = TWIN_LAYOUT.startX + progress * (TWIN_LAYOUT.endX - TWIN_LAYOUT.startX); + const y = TWIN_LAYOUT.beltY + 0.12; + let z = 0; + + if (simulation.machineState === 'ROUTE_TO_C') { + z = Math.min(simulation.elapsedInStateMs / 1200, 1) * TWIN_LAYOUT.zoneCZ; + } else if (simulation.machineState === 'ROUTE_TO_D') { + z = -Math.min(simulation.elapsedInStateMs / 1200, 1) * Math.abs(TWIN_LAYOUT.zoneDZ); + } + + return [x, y, z]; +} + +export function pusherOffset(state: 'idle' | 'extended' | 'retracting'): number { + if (state === 'extended') return 0.55; + if (state === 'retracting') return 0.25; + return 0; +} + +/** + * Motion layer is state-machine driven (no physics engine). + * Future physics integration can replace this module with kinematic bodies. + */ +export const PHYSICS_ENGINE_ENABLED = false; diff --git a/src/components/ThreeD/useWebGL.ts b/src/components/ThreeD/useWebGL.ts new file mode 100644 index 0000000..26d370b --- /dev/null +++ b/src/components/ThreeD/useWebGL.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +export function detectWebGL(): boolean { + try { + const canvas = document.createElement('canvas'); + return Boolean( + canvas.getContext('webgl2') || + canvas.getContext('webgl') || + canvas.getContext('experimental-webgl'), + ); + } catch { + return false; + } +} + +export function useWebGLSupport(): boolean { + const [supported, setSupported] = useState(true); + + useEffect(() => { + setSupported(detectWebGL()); + }, []); + + return supported; +} + +export function prefer3DByDefault(width: number, webgl: boolean): boolean { + return webgl && width >= 640; +} diff --git a/src/domain/classifier.ts b/src/domain/classifier.ts index 6bd0dee..fbb68ee 100644 --- a/src/domain/classifier.ts +++ b/src/domain/classifier.ts @@ -16,7 +16,7 @@ export const CATEGORY_DEFINITIONS: Record = { }; export const DIMENSION_LIMITS = { - min: { width: 10, depth: 10, height: 2 }, + min: { width: 10, depth: 10, height: 10 }, max: { width: 450, depth: 320, height: 320 }, roundnessThreshold: 0.8, }; diff --git a/src/domain/metrics.ts b/src/domain/metrics.ts index 3500fda..c7cefce 100644 --- a/src/domain/metrics.ts +++ b/src/domain/metrics.ts @@ -12,7 +12,7 @@ export function createMetrics(): Metrics { queueLength: 0, queueDelayMs: 0, conveyorSpeedMps: 0, - pidTargetSpeedMps: 0.42, + pidTargetSpeedMps: 1, pidActualSpeedMps: 0, }; } diff --git a/src/domain/pid.test.ts b/src/domain/pid.test.ts index 30924ea..25b1eaf 100644 --- a/src/domain/pid.test.ts +++ b/src/domain/pid.test.ts @@ -3,21 +3,21 @@ import { createPidState, updatePid } from './pid'; describe('PID conveyor speed imitation', () => { it('moves actual speed toward target in normal mode', () => { - let pid = createPidState(0.42); + let pid = createPidState(1); for (let index = 0; index < 20; index += 1) { - pid = updatePid(pid, 0.42, 250); + pid = updatePid(pid, 1, 250); } - expect(pid.actualSpeedMps).toBeGreaterThan(0.3); - expect(Math.abs(pid.pidError)).toBeLessThan(0.15); + expect(pid.actualSpeedMps).toBeGreaterThan(0.7); + expect(Math.abs(pid.pidError)).toBeLessThan(0.35); }); it('decays actual speed toward zero when target is stopped', () => { - let pid = createPidState(0.42); + let pid = createPidState(1); for (let index = 0; index < 20; index += 1) { - pid = updatePid(pid, 0.42, 250); + pid = updatePid(pid, 1, 250); } const runningSpeed = pid.actualSpeedMps; @@ -26,6 +26,6 @@ describe('PID conveyor speed imitation', () => { } expect(pid.actualSpeedMps).toBeLessThan(runningSpeed); - expect(pid.actualSpeedMps).toBeLessThan(0.05); + expect(pid.actualSpeedMps).toBeLessThan(0.12); }); }); diff --git a/src/domain/pid.ts b/src/domain/pid.ts index 1ab83fc..4a4a94e 100644 --- a/src/domain/pid.ts +++ b/src/domain/pid.ts @@ -2,7 +2,7 @@ import type { PidState } from './types'; const PID_HISTORY_LIMIT = 40; -export function createPidState(targetSpeedMps = 0.42): PidState { +export function createPidState(targetSpeedMps = 1): PidState { return { targetSpeedMps, actualSpeedMps: 0, diff --git a/src/domain/simulation.ts b/src/domain/simulation.ts index e25dfef..80e5b60 100644 --- a/src/domain/simulation.ts +++ b/src/domain/simulation.ts @@ -223,7 +223,7 @@ function targetSpeedFor(state: SimulationState): number { if (!state.running) { return 0; } - return state.scenario.id === 'close_items' ? 0.32 : 0.42; + return state.scenario.id === 'close_items' ? 0.75 : 1; } function deriveSensorsAndActuators(state: SimulationState): SimulationState { diff --git a/src/styles.css b/src/styles.css index 379216e..ad471cf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -488,6 +488,108 @@ button:disabled { box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22); } +.demo-scene-3d { + min-height: 420px; +} + +.view-mode-toggle { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 16px; +} + +.digital-twin-wrap { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + min-width: 0; +} + +.digital-twin-canvas { + width: 100%; + min-width: 0; + height: min(52vh, 520px); + min-height: 360px; + border-radius: 14px; + overflow: hidden; + background: #050910; + border: 1px solid #1e3a54; +} + +.digital-twin-simplified .digital-twin-canvas { + height: min(46vh, 420px); + min-height: 300px; +} + +.digital-twin-canvas canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.digital-twin-hud { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.twin-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border: 1px solid #284762; + border-radius: 999px; + background: #091522; + color: #c8d7e8; + font-size: 12px; + font-weight: 700; +} + +.muted-chip { + color: var(--muted); +} + +.three-fallback-note, +.three-loading { + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid rgba(56, 189, 248, 0.28); + border-radius: 12px; + background: rgba(56, 189, 248, 0.08); + color: #bfe9ff; + font-size: 14px; +} + +.three-capability-check { + display: grid; + gap: 12px; +} + +.three-capability-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 13px; + color: var(--muted); +} + +.three-capability-meta strong { + color: var(--text); + width: 100%; +} + +.three-capability-canvas { + width: 100%; + height: 220px; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--line); + background: #050910; +} + .demo-result-column { display: flex; flex-direction: column;