feat: add 3D digital twin visualization
This commit is contained in:
12
.cursor/rules/physics-engine-gate.mdc
Normal file
12
.cursor/rules/physics-engine-gate.mdc
Normal file
@@ -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 можно моделировать логикой, а не реальными столкновениями.
|
||||
13
.cursor/rules/three-d-digital-twin.mdc
Normal file
13
.cursor/rules/three-d-digital-twin.mdc
Normal file
@@ -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-эффекты без инженерного смысла.
|
||||
15
.cursor/rules/webgl-performance-budget.mdc
Normal file
15
.cursor/rules/webgl-performance-budget.mdc
Normal file
@@ -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.
|
||||
29
README.md
29
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 м/с).
|
||||
|
||||
## Исполнительная часть
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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.
|
||||
|
||||
69
docs/THREE_D_FEASIBILITY.md
Normal file
69
docs/THREE_D_FEASIBILITY.md
Normal file
@@ -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`).
|
||||
696
package-lock.json
generated
696
package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EngineeringDetailsHandle, Props>(
|
||||
<SorterScene simulation={simulation} variant="full" />
|
||||
</div>
|
||||
|
||||
<div className="eng-panel eng-panel-wide">
|
||||
<Suspense fallback={<p className="three-loading">Loading 3D capability check…</p>}>
|
||||
<ThreeCapabilityCheck />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="engineering-grid">
|
||||
<div className="eng-panel">
|
||||
<StateMachinePanel currentState={simulation.machineState} />
|
||||
|
||||
@@ -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<ViewMode>('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 (
|
||||
<section className="product-demo-section" id="demo" aria-labelledby="demo-title">
|
||||
<div className="demo-header">
|
||||
@@ -34,7 +64,8 @@ export default function ProductDemoSection({
|
||||
<p className="section-eyebrow">Live product demo</p>
|
||||
<h2 id="demo-title">Главное демо</h2>
|
||||
<p className="demo-lead">
|
||||
Слева — упрощённая сцена конвейера. Справа — товар, категория, причина решения и команда маршрутизации.
|
||||
3D digital twin показывает физическую маршрутизацию: A → конвейер → CV → накопитель → gate → B/C/D.
|
||||
Справа — товар, категория, причина и команда ROUTE_TO_*.
|
||||
</p>
|
||||
</div>
|
||||
<div className="demo-status">
|
||||
@@ -45,13 +76,47 @@ export default function ProductDemoSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="view-mode-toggle" role="group" aria-label="Режим сцены">
|
||||
<button
|
||||
type="button"
|
||||
className={show3D ? 'btn-primary' : 'btn-secondary'}
|
||||
onClick={() => {
|
||||
setContextLost(false);
|
||||
setViewMode('3d');
|
||||
}}
|
||||
disabled={!webgl}
|
||||
>
|
||||
3D Digital Twin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={!show3D ? 'btn-primary' : 'btn-secondary'}
|
||||
onClick={() => setViewMode('2d')}
|
||||
>
|
||||
2D fallback
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="demo-layout">
|
||||
<div className="demo-scene-column">
|
||||
<p className="demo-mobile-hint">
|
||||
Товар проходит камеру, классификатор и gate, затем уходит в зону B, C или D.
|
||||
Товар проходит камеру, накопитель и gate, затем уходит в зону B, C или D.
|
||||
</p>
|
||||
<div className="demo-scene-container">
|
||||
<SorterScene simulation={simulation} variant="simple" />
|
||||
<div className="demo-scene-container demo-scene-3d">
|
||||
{show3D ? (
|
||||
<Suspense fallback={<div className="three-loading">Загрузка 3D digital twin…</div>}>
|
||||
<SorterDigitalTwin
|
||||
simulation={simulation}
|
||||
simplified={simplified}
|
||||
onContextLost={() => {
|
||||
setContextLost(true);
|
||||
setViewMode('2d');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<ThreeFallback simulation={simulation} reason={fallbackReason} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
52
src/components/ThreeD/Actuator3D.tsx
Normal file
52
src/components/ThreeD/Actuator3D.tsx
Normal file
@@ -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 (
|
||||
<group>
|
||||
{/* Stop-gate */}
|
||||
<mesh
|
||||
position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY + 0.2, 0]}
|
||||
rotation={[0, 0, gateOpen ? -0.7 : 0]}
|
||||
>
|
||||
<boxGeometry args={[0.06, 0.55, 0.62]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
|
||||
emissive={fault ? '#fb3d4e' : '#000000'}
|
||||
emissiveIntensity={fault ? 0.4 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Pusher C → roll-cage C */}
|
||||
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, pusherC]}>
|
||||
<boxGeometry args={[0.35, 0.18, 0.18]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherC === 'extended' ? '#f59e0b' : '#78350f'}
|
||||
emissive={actuators.pusherC === 'extended' ? '#f59e0b' : '#000000'}
|
||||
emissiveIntensity={actuators.pusherC === 'extended' ? 0.35 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Pusher D → roll-cage D */}
|
||||
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, -pusherD]}>
|
||||
<boxGeometry args={[0.35, 0.18, 0.18]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherD === 'extended' ? '#c084fc' : '#4c1d95'}
|
||||
emissive={actuators.pusherD === 'extended' ? '#c084fc' : '#000000'}
|
||||
emissiveIntensity={actuators.pusherD === 'extended' ? 0.35 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
32
src/components/ThreeD/Conveyor3D.tsx
Normal file
32
src/components/ThreeD/Conveyor3D.tsx
Normal file
@@ -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 (
|
||||
<group>
|
||||
{/* Feed conveyor body */}
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY - 0.12, 0]} receiveShadow={false} castShadow={false}>
|
||||
<boxGeometry args={[8.6, 0.18, 0.55]} />
|
||||
<meshStandardMaterial color={color} roughness={0.85} metalness={0.15} />
|
||||
</mesh>
|
||||
|
||||
{/* Belt surface */}
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY - 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[8.4, 0.48]} />
|
||||
<meshStandardMaterial color={beltColor} roughness={0.9} />
|
||||
</mesh>
|
||||
|
||||
{/* Accumulator / buffer at end of feed conveyor */}
|
||||
<mesh position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY - 0.05, 0]}>
|
||||
<boxGeometry args={[0.9, 0.28, 0.7]} />
|
||||
<meshStandardMaterial color={stopped ? '#7f1d1d' : '#0f766e'} roughness={0.7} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
61
src/components/ThreeD/Item3D.tsx
Normal file
61
src/components/ThreeD/Item3D.tsx
Normal file
@@ -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 (
|
||||
<group position={[x, y, z]}>
|
||||
{isRound ? (
|
||||
<mesh>
|
||||
<cylinderGeometry args={[Math.min(sx, sz) / 2, Math.min(sx, sz) / 2, sy, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : color}
|
||||
emissive={fault ? '#fb3d4e' : color}
|
||||
emissiveIntensity={0.2}
|
||||
roughness={0.45}
|
||||
/>
|
||||
</mesh>
|
||||
) : (
|
||||
<mesh>
|
||||
<boxGeometry args={[sx, sy, sz]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : color}
|
||||
emissive={fault ? '#fb3d4e' : color}
|
||||
emissiveIntensity={0.2}
|
||||
roughness={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{detecting ? (
|
||||
<mesh>
|
||||
<boxGeometry args={[sx + 0.08, sy + 0.08, sz + 0.08]} />
|
||||
<meshStandardMaterial color="#38bdf8" wireframe transparent opacity={0.8} />
|
||||
</mesh>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
73
src/components/ThreeD/RouteArrows3D.tsx
Normal file
73
src/components/ThreeD/RouteArrows3D.tsx
Normal file
@@ -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 (
|
||||
<mesh position={mid} rotation={[0, -angle, 0]}>
|
||||
<boxGeometry args={[length, 0.04, active ? 0.12 : 0.06]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={active ? 0.95 : 0.25}
|
||||
emissive={color}
|
||||
emissiveIntensity={active ? 0.5 : 0.05}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<group>
|
||||
<RouteBeam
|
||||
start={from}
|
||||
end={[TWIN_LAYOUT.zoneBX, y, 0]}
|
||||
color={ROUTE_COLORS.B}
|
||||
active={preview && category === 'B'}
|
||||
/>
|
||||
<RouteBeam
|
||||
start={from}
|
||||
end={[TWIN_LAYOUT.gateX + 0.4, y, TWIN_LAYOUT.zoneCZ]}
|
||||
color={ROUTE_COLORS.C}
|
||||
active={preview && category === 'C'}
|
||||
/>
|
||||
<RouteBeam
|
||||
start={from}
|
||||
end={[TWIN_LAYOUT.gateX + 0.4, y, TWIN_LAYOUT.zoneDZ]}
|
||||
color={ROUTE_COLORS.D}
|
||||
active={preview && category === 'D'}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
92
src/components/ThreeD/SceneLabels3D.tsx
Normal file
92
src/components/ThreeD/SceneLabels3D.tsx
Normal file
@@ -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 (
|
||||
<Html position={position} center distanceFactor={10} style={{ pointerEvents: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(5, 9, 16, 0.82)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.35)',
|
||||
color,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<group>
|
||||
<Badge position={[TWIN_LAYOUT.startX, 0.7, 0]} text="A feed" color="#7dd3fc" />
|
||||
<Badge position={[TWIN_LAYOUT.cameraX, 1.15, -0.55]} text="Camera / CV" color="#38bdf8" />
|
||||
{!simplified ? (
|
||||
<>
|
||||
<Badge position={[TWIN_LAYOUT.laserX, 1.15, -0.55]} text="Laser" color="#22d3ee" />
|
||||
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.0, -0.55]} text="Ultrasonic" color="#67e8f9" />
|
||||
</>
|
||||
) : null}
|
||||
<Badge position={[TWIN_LAYOUT.accumulatorX, 0.85, 0.55]} text="Накопитель" color="#5eead4" />
|
||||
<Badge position={[TWIN_LAYOUT.gateX, 1.0, 0]} text="Stop-gate" color="#fda4af" />
|
||||
<Badge position={[TWIN_LAYOUT.zoneBX, 0.9, 0]} text="B" color="#4ade80" />
|
||||
<Badge position={[TWIN_LAYOUT.gateX + 0.4, 1.0, TWIN_LAYOUT.zoneCZ]} text="C roll-cage" color="#f59e0b" />
|
||||
<Badge position={[TWIN_LAYOUT.gateX + 0.4, 1.0, TWIN_LAYOUT.zoneDZ]} text="D roll-cage" color="#c084fc" />
|
||||
|
||||
{item && result && !simplified ? (
|
||||
<Badge
|
||||
position={[0, 1.6, 0]}
|
||||
text={`${item.dimensionsMm.width}×${item.dimensionsMm.depth}×${item.dimensionsMm.height} · K=${item.roundness.toFixed(2)} · ${result.dimensionsPass ? 'DIM PASS' : 'DIM FAIL'} · ${category}`}
|
||||
color={category === 'B' ? '#4ade80' : category === 'C' ? '#f59e0b' : '#c084fc'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{command ? (
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 1.2, 1.35, 0]}
|
||||
text={command}
|
||||
color={category === 'B' ? '#4ade80' : category === 'C' ? '#f59e0b' : '#c084fc'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{machineState === 'DETECTING' ? (
|
||||
<Badge position={[TWIN_LAYOUT.cameraX, 1.45, 0]} text="CV detection" color="#38bdf8" />
|
||||
) : null}
|
||||
|
||||
{machineState === 'FAULT' || machineState === 'EMERGENCY_STOP' ? (
|
||||
<Badge position={[0, 1.8, 0]} text={machineState} color="#fb3d4e" />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
55
src/components/ThreeD/SensorRig3D.tsx
Normal file
55
src/components/ThreeD/SensorRig3D.tsx
Normal file
@@ -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 (
|
||||
<group position={[x, 0, -0.55]}>
|
||||
<mesh position={[0, height / 2, 0]}>
|
||||
<boxGeometry args={[0.08, height, 0.08]} />
|
||||
<meshStandardMaterial color="#475569" />
|
||||
</mesh>
|
||||
<mesh position={[0, height, 0.2]}>
|
||||
<boxGeometry args={[0.28, 0.16, 0.22]} />
|
||||
<meshStandardMaterial
|
||||
color={active ? labelColor : '#1e293b'}
|
||||
emissive={active ? labelColor : '#000000'}
|
||||
emissiveIntensity={active ? 0.55 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SensorRig3D({ sensors, machineState }: Props) {
|
||||
const detecting = machineState === 'DETECTING';
|
||||
|
||||
return (
|
||||
<group>
|
||||
<SensorPole x={TWIN_LAYOUT.cameraX} active={sensors.camera.active || detecting} labelColor="#38bdf8" />
|
||||
<SensorPole x={TWIN_LAYOUT.laserX} active={sensors.laser.active} labelColor="#22d3ee" />
|
||||
<SensorPole x={TWIN_LAYOUT.ultrasonicX} active={sensors.ultrasound.active} labelColor="#67e8f9" height={0.75} />
|
||||
|
||||
{detecting ? (
|
||||
<mesh position={[TWIN_LAYOUT.cameraX, TWIN_LAYOUT.beltY + 0.35, 0]}>
|
||||
<boxGeometry args={[0.7, 0.02, 0.55]} />
|
||||
<meshStandardMaterial color="#38bdf8" transparent opacity={0.35} emissive="#38bdf8" emissiveIntensity={0.4} />
|
||||
</mesh>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
171
src/components/ThreeD/SorterDigitalTwin.tsx
Normal file
171
src/components/ThreeD/SorterDigitalTwin.tsx
Normal file
@@ -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<number[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<color attach="background" args={['#050910']} />
|
||||
<ambientLight intensity={0.55} />
|
||||
<directionalLight position={[5, 8, 4]} intensity={0.9} />
|
||||
<directionalLight position={[-4, 4, -2]} intensity={0.25} />
|
||||
|
||||
<Grid
|
||||
args={[16, 16]}
|
||||
cellSize={0.5}
|
||||
cellThickness={0.6}
|
||||
cellColor="#1e3a54"
|
||||
sectionSize={2}
|
||||
sectionThickness={1}
|
||||
sectionColor="#284762"
|
||||
fadeDistance={18}
|
||||
infiniteGrid={false}
|
||||
position={[0, 0.001, 0]}
|
||||
/>
|
||||
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||
<planeGeometry args={[14, 10]} />
|
||||
<meshStandardMaterial color="#07111d" />
|
||||
</mesh>
|
||||
|
||||
<Conveyor3D stopped={stopped} />
|
||||
<SensorRig3D sensors={simulation.sensors} machineState={simulation.machineState} />
|
||||
<Actuator3D
|
||||
gate={simulation.gate}
|
||||
actuators={simulation.actuators}
|
||||
machineState={simulation.machineState}
|
||||
/>
|
||||
<SortingZones3D activeCategory={category} activeRoute={activeRoute} />
|
||||
<RouteArrows3D category={category} machineState={simulation.machineState} />
|
||||
<Item3D simulation={simulation} currentItem={simulation.currentItem} />
|
||||
<SceneLabels3D
|
||||
machineState={simulation.machineState}
|
||||
currentItem={simulation.currentItem}
|
||||
simplified={simplified}
|
||||
/>
|
||||
|
||||
{stopped ? (
|
||||
<mesh position={[0, 1.2, 0]}>
|
||||
<planeGeometry args={[6, 1.2]} />
|
||||
<meshStandardMaterial color="#7f1d1d" transparent opacity={0.35} />
|
||||
</mesh>
|
||||
) : null}
|
||||
|
||||
<OrbitControls
|
||||
enablePan={!simplified}
|
||||
enableZoom
|
||||
maxPolarAngle={Math.PI / 2.1}
|
||||
minDistance={4}
|
||||
maxDistance={14}
|
||||
target={[0.4, 0.3, 0]}
|
||||
/>
|
||||
<FpsMeter onFps={onFps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`digital-twin-wrap ${simplified ? 'digital-twin-simplified' : ''}`}>
|
||||
<div className="digital-twin-hud" aria-live="polite">
|
||||
<span className="twin-chip">{simulation.machineState}</span>
|
||||
<span className="twin-chip">{simulation.scenario.name}</span>
|
||||
{category ? <span className={`twin-chip category-${category}`}>Zone {category}</span> : null}
|
||||
<span className="twin-chip">{command}</span>
|
||||
{showFps ? <span className="twin-chip">FPS ~{fps}</span> : null}
|
||||
<span className="twin-chip muted-chip">
|
||||
Motion: {PHYSICS_ENGINE_ENABLED ? 'physics' : 'state-machine'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="digital-twin-canvas">
|
||||
<Canvas
|
||||
camera={{ position: simplified ? [5.5, 4.2, 5.5] : [6.2, 4.8, 6.2], fov: 42 }}
|
||||
dpr={simplified ? [1, 1.25] : [1, 1.75]}
|
||||
gl={{ antialias: !simplified, powerPreference: 'high-performance' }}
|
||||
onCreated={({ gl }) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<TwinScene simulation={simulation} simplified={simplified} onFps={setFps} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/ThreeD/SortingZones3D.tsx
Normal file
73
src/components/ThreeD/SortingZones3D.tsx
Normal file
@@ -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 (
|
||||
<mesh position={position}>
|
||||
<boxGeometry args={size} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={active ? 0.45 : 0.18}
|
||||
emissive={color}
|
||||
emissiveIntensity={active ? 0.35 : 0.05}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
|
||||
const active = activeRoute ?? activeCategory;
|
||||
const cage = TWIN_LAYOUT.rollCageSize;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Zone A feed */}
|
||||
<ZoneBox
|
||||
position={[TWIN_LAYOUT.startX, 0.2, 0]}
|
||||
size={[0.9, 0.4, 0.9]}
|
||||
color="#38bdf8"
|
||||
active={!active}
|
||||
/>
|
||||
|
||||
{/* Zone B main sorter — straight */}
|
||||
<ZoneBox
|
||||
position={[TWIN_LAYOUT.zoneBX, 0.25, 0]}
|
||||
size={[1.1, 0.5, 1.1]}
|
||||
color={ROUTE_COLORS.B}
|
||||
active={active === 'B'}
|
||||
/>
|
||||
|
||||
{/* Roll-cage C 1200×800×800 mm */}
|
||||
<ZoneBox
|
||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneCZ]}
|
||||
size={[cage.x, cage.y, cage.z]}
|
||||
color={ROUTE_COLORS.C}
|
||||
active={active === 'C'}
|
||||
/>
|
||||
|
||||
{/* Roll-cage D 1200×800×800 mm */}
|
||||
<ZoneBox
|
||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneDZ]}
|
||||
size={[cage.x, cage.y, cage.z]}
|
||||
color={ROUTE_COLORS.D}
|
||||
active={active === 'D'}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
92
src/components/ThreeD/ThreeCapabilityCheck.tsx
Normal file
92
src/components/ThreeD/ThreeCapabilityCheck.tsx
Normal file
@@ -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<number[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<ambientLight intensity={0.55} />
|
||||
<directionalLight position={[4, 6, 2]} intensity={0.85} />
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||
<planeGeometry args={[6, 4]} />
|
||||
<meshStandardMaterial color="#0b1725" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.15, 0]}>
|
||||
<boxGeometry args={[3.5, 0.2, 0.5]} />
|
||||
<meshStandardMaterial color="#1e3a54" />
|
||||
</mesh>
|
||||
{boxes.map((box) => (
|
||||
<mesh key={box.key} position={box.position}>
|
||||
<boxGeometry args={[0.28, 0.28, 0.28]} />
|
||||
<meshStandardMaterial color={box.key % 3 === 0 ? '#4ade80' : box.key % 3 === 1 ? '#f59e0b' : '#c084fc'} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="three-capability-check">
|
||||
<div className="three-capability-meta">
|
||||
<strong>3D capability check</strong>
|
||||
<span>WebGL: {webgl ? 'OK' : 'unavailable'}</span>
|
||||
<span>Physics engine: {PHYSICS_ENGINE_ENABLED ? 'enabled' : 'disabled (state-machine motion)'}</span>
|
||||
<span>Avg FPS: {webgl && ready ? fps : '—'}</span>
|
||||
<span>Runtime detect: {detectWebGL() ? 'yes' : 'no'}</span>
|
||||
</div>
|
||||
{webgl && ready ? (
|
||||
<div className="three-capability-canvas">
|
||||
<Canvas camera={{ position: [2.5, 2.2, 3.2], fov: 45 }} dpr={[1, 1.5]}>
|
||||
<SpikeScene onFps={setFps} />
|
||||
</Canvas>
|
||||
</div>
|
||||
) : (
|
||||
<p className="three-fallback-note">WebGL недоступен — 3D digital twin будет в 2D fallback.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ThreeD/ThreeFallback.tsx
Normal file
24
src/components/ThreeD/ThreeFallback.tsx
Normal file
@@ -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<NonNullable<Props['reason']>, string> = {
|
||||
webgl: '3D недоступен (WebGL), включён 2D fallback. Логика симуляции та же.',
|
||||
user: 'Включён 2D fallback. Логика симуляции та же.',
|
||||
mobile: 'На узком экране включён 2D fallback. Логика симуляции та же.',
|
||||
};
|
||||
|
||||
export default function ThreeFallback({ simulation, reason = 'user' }: Props) {
|
||||
return (
|
||||
<div className="three-fallback">
|
||||
<p className="three-fallback-note" role="status">
|
||||
{MESSAGES[reason]}
|
||||
</p>
|
||||
<SorterScene simulation={simulation} variant="simple" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/ThreeD/itemMotion.ts
Normal file
72
src/components/ThreeD/itemMotion.ts
Normal file
@@ -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<Category, string> = {
|
||||
B: '#4ade80',
|
||||
C: '#f59e0b',
|
||||
D: '#c084fc',
|
||||
};
|
||||
|
||||
export function progressForState(state: MachineState, elapsedMs: number): number {
|
||||
const ratios: Partial<Record<MachineState, [number, number, number]>> = {
|
||||
MOVING_TO_CAMERA: [0.06, 0.34, 1200],
|
||||
DETECTING: [0.34, 0.36, 900],
|
||||
MOVING_TO_GATE: [0.36, 0.64, 1300],
|
||||
WAITING_AT_GATE: [0.64, 0.65, 800],
|
||||
CLASSIFYING: [0.65, 0.66, 700],
|
||||
ROUTE_TO_B: [0.66, 0.93, 1200],
|
||||
ROUTE_TO_C: [0.66, 0.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;
|
||||
28
src/components/ThreeD/useWebGL.ts
Normal file
28
src/components/ThreeD/useWebGL.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function detectWebGL(): boolean {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return Boolean(
|
||||
canvas.getContext('webgl2') ||
|
||||
canvas.getContext('webgl') ||
|
||||
canvas.getContext('experimental-webgl'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebGLSupport(): boolean {
|
||||
const [supported, setSupported] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(detectWebGL());
|
||||
}, []);
|
||||
|
||||
return supported;
|
||||
}
|
||||
|
||||
export function prefer3DByDefault(width: number, webgl: boolean): boolean {
|
||||
return webgl && width >= 640;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const CATEGORY_DEFINITIONS: Record<Category, CategoryDefinition> = {
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export function createMetrics(): Metrics {
|
||||
queueLength: 0,
|
||||
queueDelayMs: 0,
|
||||
conveyorSpeedMps: 0,
|
||||
pidTargetSpeedMps: 0.42,
|
||||
pidTargetSpeedMps: 1,
|
||||
pidActualSpeedMps: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
102
src/styles.css
102
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;
|
||||
|
||||
Reference in New Issue
Block a user