Реструктуризация проекта и генератор синтетических датасетов эхолота.
Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,6 +41,14 @@ bearer/
|
||||
*.temp
|
||||
__pycache__/
|
||||
*.pyc
|
||||
backend/.venv/
|
||||
backend/wheels/
|
||||
data/
|
||||
frontend/web/node_modules/
|
||||
frontend/web/dist/
|
||||
|
||||
# Generated PointNet sonar dataset
|
||||
sonar_dataset/
|
||||
|
||||
# OS/editor files
|
||||
.DS_Store
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Архитектура DotsToSurface
|
||||
|
||||
Этот документ описывает **логические слои** проекта и соответствие **физических папок**.
|
||||
|
||||
## Три слоя
|
||||
|
||||
| Слой | Папки | Ответственность |
|
||||
|------|-------|-----------------|
|
||||
| **Engine** (C++) | `src/engine/` | Обработка облака точек: preprocess, PCL, реконструкция, метрики, CLI |
|
||||
| **Backend** (HTTP) | `backend/` | REST API: пресеты, validate, wizard, вызов CLI, отдача статики Vue |
|
||||
| **Frontend (Web)** | `frontend/web/` | Dashboard в браузере, Pinia, Three.js viewer |
|
||||
| **Frontend (Desktop)** | `src/desktop/` | Qt Widgets + QML dashboard, OpenGL viewer |
|
||||
|
||||
**Важно:** Python и Vue **не выполняют** пайплайн сами. Backend вызывает бинарник `DotsToSurface --cli` через `subprocess` ([`backend/main.py`](backend/main.py)).
|
||||
|
||||
## Карта репозитория
|
||||
|
||||
```
|
||||
DotsToSurface/
|
||||
├── src/
|
||||
│ ├── main.cpp # Entry: --cli или Qt GUI
|
||||
│ ├── engine/ # Engine — C++ пайплайн
|
||||
│ │ ├── core/
|
||||
│ │ ├── strategies/
|
||||
│ │ ├── adapters/
|
||||
│ │ ├── algorithms/
|
||||
│ │ ├── cli/
|
||||
│ │ ├── factories/
|
||||
│ │ └── tests/
|
||||
│ └── desktop/ # Frontend (Desktop) — Qt/QML
|
||||
│ ├── mainwindow.cpp
|
||||
│ ├── glview.cpp
|
||||
│ └── qml/
|
||||
├── backend/ # Backend — FastAPI
|
||||
├── frontend/
|
||||
│ └── web/ # Frontend (Web) — Vue 3
|
||||
├── docker/ # Infra — сборка и runtime-образ
|
||||
├── presets/ # Shared — JSON-пресеты
|
||||
└── DotsToSurface.pro
|
||||
```
|
||||
|
||||
Артефакты сборки **не в git**: `build/`, `frontend/web/dist/`, `frontend/web/node_modules/`.
|
||||
|
||||
## Потоки данных
|
||||
|
||||
### Web (Docker или локально)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[Browser Vue3] -->|HTTP /api/*| FastAPI[backend/main.py]
|
||||
FastAPI -->|subprocess| CLI[DotsToSurface --cli]
|
||||
CLI --> Engine[PipelineExecutor]
|
||||
Engine --> JSON[result.json]
|
||||
JSON --> FastAPI
|
||||
FastAPI --> Browser
|
||||
```
|
||||
|
||||
### Desktop (Qt)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
QML[Qt QML Dashboard] --> MainWindow[MainWindow C++]
|
||||
MainWindow --> Engine[PipelineExecutor in-process]
|
||||
Engine --> GLView[OpenGL viewer]
|
||||
```
|
||||
|
||||
## Что работает без C++ бинарника
|
||||
|
||||
| Endpoint / функция | Без `DotsToSurface` |
|
||||
|--------------------|---------------------|
|
||||
| `GET /api/catalog`, `/api/presets`, `/api/builtin-presets` | Да |
|
||||
| `POST /api/validate-config`, `/api/wizard` | Да |
|
||||
| `GET /api/demo` | Да (точки генерирует Python) |
|
||||
| `POST /api/run` | **Нет** — нужен CLI |
|
||||
|
||||
## Сценарии разработки
|
||||
|
||||
### Web-версия целиком (рекомендуется)
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up --build
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
### Frontend отдельно
|
||||
|
||||
```bash
|
||||
cd frontend/web
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:5173, /api проксируется на :8080
|
||||
```
|
||||
|
||||
### Engine + Desktop локально
|
||||
|
||||
```bash
|
||||
qmake DotsToSurface.pro 'DEFINES+=PCL_ENABLED'
|
||||
make -j$(nproc)
|
||||
./DotsToSurface
|
||||
```
|
||||
|
||||
## Shared и известный техдолг
|
||||
|
||||
- **`presets/`** — JSON-шаблоны; читаются Qt и Backend.
|
||||
- **`pipelineUiCatalog`** — дублирован в Qt и Vue:
|
||||
- [`src/desktop/qml/shared/PipelineUiCatalog.js`](src/desktop/qml/shared/PipelineUiCatalog.js)
|
||||
- [`frontend/web/src/catalog/pipelineUiCatalog.js`](frontend/web/src/catalog/pipelineUiCatalog.js)
|
||||
|
||||
## Указатели по папкам
|
||||
|
||||
- [backend/README.md](backend/README.md)
|
||||
- [frontend/web/README.md](frontend/web/README.md)
|
||||
- [src/README.md](src/README.md)
|
||||
- [docker/README.md](docker/README.md)
|
||||
@@ -1,104 +0,0 @@
|
||||
QT += core gui widgets opengl quick quickwidgets qml
|
||||
|
||||
CONFIG += c++14
|
||||
TEMPLATE = app
|
||||
TARGET = DotsToSirface
|
||||
|
||||
win32:LIBS += -lopengl32
|
||||
unix:LIBS += -lGL
|
||||
|
||||
contains(DEFINES, PCL_ENABLED) {
|
||||
message(PCL support enabled)
|
||||
win32 {
|
||||
isEmpty(PCL_ROOT) {
|
||||
PCL_ROOT = C:/PCL
|
||||
}
|
||||
INCLUDEPATH += $$PCL_ROOT/include $$PCL_ROOT/include/pcl-1.12
|
||||
LIBS += -L$$PCL_ROOT/lib \
|
||||
-lpcl_common \
|
||||
-lpcl_io \
|
||||
-lpcl_filters \
|
||||
-lpcl_features \
|
||||
-lpcl_kdtree \
|
||||
-lpcl_search \
|
||||
-lpcl_surface
|
||||
}
|
||||
|
||||
unix {
|
||||
CONFIG += link_pkgconfig
|
||||
PKGCONFIG += \
|
||||
pcl_common \
|
||||
pcl_io \
|
||||
pcl_filters \
|
||||
pcl_features \
|
||||
pcl_kdtree \
|
||||
pcl_search \
|
||||
pcl_surface
|
||||
}
|
||||
}
|
||||
|
||||
SOURCES += \
|
||||
src/main.cpp \
|
||||
src/cli/pipeline_cli_runner.cpp \
|
||||
src/algorithms/preprocess/preprocess_algorithms.cpp \
|
||||
src/algorithms/reconstruction/surface_reconstruction.cpp \
|
||||
src/adapters/reconstruction/reconstruction_adapter.cpp \
|
||||
src/adapters/pcl/pcl_point_cloud_adapter.cpp \
|
||||
src/adapters/registration/registration_adapter.cpp \
|
||||
src/adapters/ros2/pointcloud2_adapter.cpp \
|
||||
src/adapters/ros2/tf2_adapter.cpp \
|
||||
src/adapters/sources/file_point_cloud_source.cpp \
|
||||
src/adapters/sources/ply_point_cloud_loader.cpp \
|
||||
src/adapters/sources/ros2_point_cloud_source.cpp \
|
||||
src/core/pipeline_config.cpp \
|
||||
src/core/pipeline_config_validation.cpp \
|
||||
src/core/hole_aware_score.cpp \
|
||||
src/core/pipeline_stage_defaults.cpp \
|
||||
src/core/tuning_service.cpp \
|
||||
src/core/pipeline_executor.cpp \
|
||||
src/core/pipeline_plugin_registry.cpp \
|
||||
src/factories/pipeline/desktop_pipeline_factory.cpp \
|
||||
src/strategies/registration_icp_stage.cpp \
|
||||
src/strategies/preprocess_basic_stages.cpp \
|
||||
src/strategies/preprocess_pcl_stages.cpp \
|
||||
src/strategies/transform_tf_stage.cpp \
|
||||
src/tests/pipeline_smoke_tests.cpp \
|
||||
src/ui/mainwindow.cpp \
|
||||
src/ui/glview.cpp
|
||||
|
||||
HEADERS += \
|
||||
src/cli/pipeline_cli_runner.h \
|
||||
src/algorithms/preprocess/preprocess_algorithms.h \
|
||||
src/algorithms/reconstruction/surface_reconstruction.h \
|
||||
src/adapters/reconstruction/reconstruction_adapter.h \
|
||||
src/adapters/pcl/pcl_point_cloud_adapter.h \
|
||||
src/adapters/registration/registration_adapter.h \
|
||||
src/adapters/ros2/pointcloud2_adapter.h \
|
||||
src/adapters/ros2/tf2_adapter.h \
|
||||
src/adapters/sources/file_point_cloud_source.h \
|
||||
src/adapters/sources/ply_point_cloud_loader.h \
|
||||
src/adapters/sources/ros2_point_cloud_source.h \
|
||||
src/core/data_source.h \
|
||||
src/core/point_cloud_types.h \
|
||||
src/core/pipeline_config.h \
|
||||
src/core/pipeline_config_validation.h \
|
||||
src/core/hole_aware_score.h \
|
||||
src/core/pipeline_stage_defaults.h \
|
||||
src/core/tuning_service.h \
|
||||
src/core/pipeline_stage.h \
|
||||
src/core/pipeline_executor.h \
|
||||
src/core/pipeline_plugin_registry.h \
|
||||
src/factories/pipeline/desktop_pipeline_factory.h \
|
||||
src/strategies/preprocess_basic_stages.h \
|
||||
src/strategies/preprocess_pcl_stages.h \
|
||||
src/strategies/registration_icp_stage.h \
|
||||
src/strategies/reconstruction_pcl_greedy_stage.h \
|
||||
src/strategies/reconstruction_pcl_poisson_stage.h \
|
||||
src/strategies/reconstruction_surface_stage.h \
|
||||
src/strategies/transform_tf_stage.h \
|
||||
src/tests/pipeline_smoke_tests.h \
|
||||
src/ui/mainwindow.h \
|
||||
src/ui/glview.h
|
||||
|
||||
RESOURCES += \
|
||||
src/ui/qml/ui_qml.qrc
|
||||
@@ -1,92 +0,0 @@
|
||||
QT += core
|
||||
|
||||
CONFIG += console c++14
|
||||
CONFIG -= app_bundle
|
||||
TEMPLATE = app
|
||||
TARGET = DotsToSirfaceTests
|
||||
|
||||
contains(DEFINES, PCL_ENABLED) {
|
||||
message(PCL support enabled)
|
||||
win32 {
|
||||
isEmpty(PCL_ROOT) {
|
||||
PCL_ROOT = C:/PCL
|
||||
}
|
||||
INCLUDEPATH += $$PCL_ROOT/include $$PCL_ROOT/include/pcl-1.12
|
||||
LIBS += -L$$PCL_ROOT/lib \
|
||||
-lpcl_common \
|
||||
-lpcl_io \
|
||||
-lpcl_filters \
|
||||
-lpcl_features \
|
||||
-lpcl_kdtree \
|
||||
-lpcl_search \
|
||||
-lpcl_surface
|
||||
}
|
||||
|
||||
unix {
|
||||
CONFIG += link_pkgconfig
|
||||
PKGCONFIG += \
|
||||
pcl_common \
|
||||
pcl_io \
|
||||
pcl_filters \
|
||||
pcl_features \
|
||||
pcl_kdtree \
|
||||
pcl_search \
|
||||
pcl_surface
|
||||
}
|
||||
}
|
||||
|
||||
SOURCES += \
|
||||
src/tests/test_runner_main.cpp \
|
||||
src/tests/pipeline_smoke_tests.cpp \
|
||||
src/algorithms/preprocess/preprocess_algorithms.cpp \
|
||||
src/algorithms/reconstruction/surface_reconstruction.cpp \
|
||||
src/adapters/reconstruction/reconstruction_adapter.cpp \
|
||||
src/adapters/pcl/pcl_point_cloud_adapter.cpp \
|
||||
src/adapters/registration/registration_adapter.cpp \
|
||||
src/adapters/ros2/pointcloud2_adapter.cpp \
|
||||
src/adapters/ros2/tf2_adapter.cpp \
|
||||
src/adapters/sources/file_point_cloud_source.cpp \
|
||||
src/adapters/sources/ply_point_cloud_loader.cpp \
|
||||
src/adapters/sources/ros2_point_cloud_source.cpp \
|
||||
src/core/pipeline_config.cpp \
|
||||
src/core/pipeline_config_validation.cpp \
|
||||
src/core/hole_aware_score.cpp \
|
||||
src/core/pipeline_stage_defaults.cpp \
|
||||
src/core/tuning_service.cpp \
|
||||
src/core/pipeline_executor.cpp \
|
||||
src/core/pipeline_plugin_registry.cpp \
|
||||
src/factories/pipeline/desktop_pipeline_factory.cpp \
|
||||
src/strategies/preprocess_basic_stages.cpp \
|
||||
src/strategies/preprocess_pcl_stages.cpp \
|
||||
src/strategies/registration_icp_stage.cpp \
|
||||
src/strategies/transform_tf_stage.cpp
|
||||
|
||||
HEADERS += \
|
||||
src/tests/pipeline_smoke_tests.h \
|
||||
src/algorithms/preprocess/preprocess_algorithms.h \
|
||||
src/algorithms/reconstruction/surface_reconstruction.h \
|
||||
src/adapters/reconstruction/reconstruction_adapter.h \
|
||||
src/adapters/pcl/pcl_point_cloud_adapter.h \
|
||||
src/adapters/registration/registration_adapter.h \
|
||||
src/adapters/ros2/pointcloud2_adapter.h \
|
||||
src/adapters/ros2/tf2_adapter.h \
|
||||
src/adapters/sources/file_point_cloud_source.h \
|
||||
src/adapters/sources/ply_point_cloud_loader.h \
|
||||
src/adapters/sources/ros2_point_cloud_source.h \
|
||||
src/core/data_source.h \
|
||||
src/core/point_cloud_types.h \
|
||||
src/core/pipeline_config.h \
|
||||
src/core/pipeline_config_validation.h \
|
||||
src/core/hole_aware_score.h \
|
||||
src/core/pipeline_stage_defaults.h \
|
||||
src/core/tuning_service.h \
|
||||
src/core/pipeline_stage.h \
|
||||
src/core/pipeline_executor.h \
|
||||
src/core/pipeline_plugin_registry.h \
|
||||
src/factories/pipeline/desktop_pipeline_factory.h \
|
||||
src/strategies/preprocess_basic_stages.h \
|
||||
src/strategies/preprocess_pcl_stages.h \
|
||||
src/strategies/reconstruction_surface_stage.h \
|
||||
src/strategies/reconstruction_pcl_greedy_stage.h \
|
||||
src/strategies/registration_icp_stage.h \
|
||||
src/strategies/transform_tf_stage.h
|
||||
@@ -0,0 +1,104 @@
|
||||
QT += core gui widgets opengl quick quickwidgets qml
|
||||
|
||||
CONFIG += c++14
|
||||
TEMPLATE = app
|
||||
TARGET = DotsToSurface
|
||||
|
||||
win32:LIBS += -lopengl32
|
||||
unix:LIBS += -lGL
|
||||
|
||||
contains(DEFINES, PCL_ENABLED) {
|
||||
message(PCL support enabled)
|
||||
win32 {
|
||||
isEmpty(PCL_ROOT) {
|
||||
PCL_ROOT = C:/PCL
|
||||
}
|
||||
INCLUDEPATH += $$PCL_ROOT/include $$PCL_ROOT/include/pcl-1.12
|
||||
LIBS += -L$$PCL_ROOT/lib \
|
||||
-lpcl_common \
|
||||
-lpcl_io \
|
||||
-lpcl_filters \
|
||||
-lpcl_features \
|
||||
-lpcl_kdtree \
|
||||
-lpcl_search \
|
||||
-lpcl_surface
|
||||
}
|
||||
|
||||
unix {
|
||||
CONFIG += link_pkgconfig
|
||||
PKGCONFIG += \
|
||||
pcl_common \
|
||||
pcl_io \
|
||||
pcl_filters \
|
||||
pcl_features \
|
||||
pcl_kdtree \
|
||||
pcl_search \
|
||||
pcl_surface
|
||||
}
|
||||
}
|
||||
|
||||
SOURCES += \
|
||||
src/main.cpp \
|
||||
src/engine/cli/pipeline_cli_runner.cpp \
|
||||
src/engine/algorithms/preprocess/preprocess_algorithms.cpp \
|
||||
src/engine/algorithms/reconstruction/surface_reconstruction.cpp \
|
||||
src/engine/adapters/reconstruction/reconstruction_adapter.cpp \
|
||||
src/engine/adapters/pcl/pcl_point_cloud_adapter.cpp \
|
||||
src/engine/adapters/registration/registration_adapter.cpp \
|
||||
src/engine/adapters/ros2/pointcloud2_adapter.cpp \
|
||||
src/engine/adapters/ros2/tf2_adapter.cpp \
|
||||
src/engine/adapters/sources/file_point_cloud_source.cpp \
|
||||
src/engine/adapters/sources/ply_point_cloud_loader.cpp \
|
||||
src/engine/adapters/sources/ros2_point_cloud_source.cpp \
|
||||
src/engine/core/pipeline_config.cpp \
|
||||
src/engine/core/pipeline_config_validation.cpp \
|
||||
src/engine/core/hole_aware_score.cpp \
|
||||
src/engine/core/pipeline_stage_defaults.cpp \
|
||||
src/engine/core/tuning_service.cpp \
|
||||
src/engine/core/pipeline_executor.cpp \
|
||||
src/engine/core/pipeline_plugin_registry.cpp \
|
||||
src/engine/factories/pipeline/desktop_pipeline_factory.cpp \
|
||||
src/engine/strategies/registration_icp_stage.cpp \
|
||||
src/engine/strategies/preprocess_basic_stages.cpp \
|
||||
src/engine/strategies/preprocess_pcl_stages.cpp \
|
||||
src/engine/strategies/transform_tf_stage.cpp \
|
||||
src/engine/tests/pipeline_smoke_tests.cpp \
|
||||
src/desktop/mainwindow.cpp \
|
||||
src/desktop/glview.cpp
|
||||
|
||||
HEADERS += \
|
||||
src/engine/cli/pipeline_cli_runner.h \
|
||||
src/engine/algorithms/preprocess/preprocess_algorithms.h \
|
||||
src/engine/algorithms/reconstruction/surface_reconstruction.h \
|
||||
src/engine/adapters/reconstruction/reconstruction_adapter.h \
|
||||
src/engine/adapters/pcl/pcl_point_cloud_adapter.h \
|
||||
src/engine/adapters/registration/registration_adapter.h \
|
||||
src/engine/adapters/ros2/pointcloud2_adapter.h \
|
||||
src/engine/adapters/ros2/tf2_adapter.h \
|
||||
src/engine/adapters/sources/file_point_cloud_source.h \
|
||||
src/engine/adapters/sources/ply_point_cloud_loader.h \
|
||||
src/engine/adapters/sources/ros2_point_cloud_source.h \
|
||||
src/engine/core/data_source.h \
|
||||
src/engine/core/point_cloud_types.h \
|
||||
src/engine/core/pipeline_config.h \
|
||||
src/engine/core/pipeline_config_validation.h \
|
||||
src/engine/core/hole_aware_score.h \
|
||||
src/engine/core/pipeline_stage_defaults.h \
|
||||
src/engine/core/tuning_service.h \
|
||||
src/engine/core/pipeline_stage.h \
|
||||
src/engine/core/pipeline_executor.h \
|
||||
src/engine/core/pipeline_plugin_registry.h \
|
||||
src/engine/factories/pipeline/desktop_pipeline_factory.h \
|
||||
src/engine/strategies/preprocess_basic_stages.h \
|
||||
src/engine/strategies/preprocess_pcl_stages.h \
|
||||
src/engine/strategies/registration_icp_stage.h \
|
||||
src/engine/strategies/reconstruction_pcl_greedy_stage.h \
|
||||
src/engine/strategies/reconstruction_pcl_poisson_stage.h \
|
||||
src/engine/strategies/reconstruction_surface_stage.h \
|
||||
src/engine/strategies/transform_tf_stage.h \
|
||||
src/engine/tests/pipeline_smoke_tests.h \
|
||||
src/desktop/mainwindow.h \
|
||||
src/desktop/glview.h
|
||||
|
||||
RESOURCES += \
|
||||
src/desktop/qml/ui_qml.qrc
|
||||
@@ -0,0 +1,92 @@
|
||||
QT += core
|
||||
|
||||
CONFIG += console c++14
|
||||
CONFIG -= app_bundle
|
||||
TEMPLATE = app
|
||||
TARGET = DotsToSurfaceTests
|
||||
|
||||
contains(DEFINES, PCL_ENABLED) {
|
||||
message(PCL support enabled)
|
||||
win32 {
|
||||
isEmpty(PCL_ROOT) {
|
||||
PCL_ROOT = C:/PCL
|
||||
}
|
||||
INCLUDEPATH += $$PCL_ROOT/include $$PCL_ROOT/include/pcl-1.12
|
||||
LIBS += -L$$PCL_ROOT/lib \
|
||||
-lpcl_common \
|
||||
-lpcl_io \
|
||||
-lpcl_filters \
|
||||
-lpcl_features \
|
||||
-lpcl_kdtree \
|
||||
-lpcl_search \
|
||||
-lpcl_surface
|
||||
}
|
||||
|
||||
unix {
|
||||
CONFIG += link_pkgconfig
|
||||
PKGCONFIG += \
|
||||
pcl_common \
|
||||
pcl_io \
|
||||
pcl_filters \
|
||||
pcl_features \
|
||||
pcl_kdtree \
|
||||
pcl_search \
|
||||
pcl_surface
|
||||
}
|
||||
}
|
||||
|
||||
SOURCES += \
|
||||
src/engine/tests/test_runner_main.cpp \
|
||||
src/engine/tests/pipeline_smoke_tests.cpp \
|
||||
src/engine/algorithms/preprocess/preprocess_algorithms.cpp \
|
||||
src/engine/algorithms/reconstruction/surface_reconstruction.cpp \
|
||||
src/engine/adapters/reconstruction/reconstruction_adapter.cpp \
|
||||
src/engine/adapters/pcl/pcl_point_cloud_adapter.cpp \
|
||||
src/engine/adapters/registration/registration_adapter.cpp \
|
||||
src/engine/adapters/ros2/pointcloud2_adapter.cpp \
|
||||
src/engine/adapters/ros2/tf2_adapter.cpp \
|
||||
src/engine/adapters/sources/file_point_cloud_source.cpp \
|
||||
src/engine/adapters/sources/ply_point_cloud_loader.cpp \
|
||||
src/engine/adapters/sources/ros2_point_cloud_source.cpp \
|
||||
src/engine/core/pipeline_config.cpp \
|
||||
src/engine/core/pipeline_config_validation.cpp \
|
||||
src/engine/core/hole_aware_score.cpp \
|
||||
src/engine/core/pipeline_stage_defaults.cpp \
|
||||
src/engine/core/tuning_service.cpp \
|
||||
src/engine/core/pipeline_executor.cpp \
|
||||
src/engine/core/pipeline_plugin_registry.cpp \
|
||||
src/engine/factories/pipeline/desktop_pipeline_factory.cpp \
|
||||
src/engine/strategies/preprocess_basic_stages.cpp \
|
||||
src/engine/strategies/preprocess_pcl_stages.cpp \
|
||||
src/engine/strategies/registration_icp_stage.cpp \
|
||||
src/engine/strategies/transform_tf_stage.cpp
|
||||
|
||||
HEADERS += \
|
||||
src/engine/tests/pipeline_smoke_tests.h \
|
||||
src/engine/algorithms/preprocess/preprocess_algorithms.h \
|
||||
src/engine/algorithms/reconstruction/surface_reconstruction.h \
|
||||
src/engine/adapters/reconstruction/reconstruction_adapter.h \
|
||||
src/engine/adapters/pcl/pcl_point_cloud_adapter.h \
|
||||
src/engine/adapters/registration/registration_adapter.h \
|
||||
src/engine/adapters/ros2/pointcloud2_adapter.h \
|
||||
src/engine/adapters/ros2/tf2_adapter.h \
|
||||
src/engine/adapters/sources/file_point_cloud_source.h \
|
||||
src/engine/adapters/sources/ply_point_cloud_loader.h \
|
||||
src/engine/adapters/sources/ros2_point_cloud_source.h \
|
||||
src/engine/core/data_source.h \
|
||||
src/engine/core/point_cloud_types.h \
|
||||
src/engine/core/pipeline_config.h \
|
||||
src/engine/core/pipeline_config_validation.h \
|
||||
src/engine/core/hole_aware_score.h \
|
||||
src/engine/core/pipeline_stage_defaults.h \
|
||||
src/engine/core/tuning_service.h \
|
||||
src/engine/core/pipeline_stage.h \
|
||||
src/engine/core/pipeline_executor.h \
|
||||
src/engine/core/pipeline_plugin_registry.h \
|
||||
src/engine/factories/pipeline/desktop_pipeline_factory.h \
|
||||
src/engine/strategies/preprocess_basic_stages.h \
|
||||
src/engine/strategies/preprocess_pcl_stages.h \
|
||||
src/engine/strategies/reconstruction_surface_stage.h \
|
||||
src/engine/strategies/reconstruction_pcl_greedy_stage.h \
|
||||
src/engine/strategies/registration_icp_stage.h \
|
||||
src/engine/strategies/transform_tf_stage.h
|
||||
@@ -1,4 +1,19 @@
|
||||
# DotsToSirface (Qt 5.11)
|
||||
# DotsToSurface (Qt 5.11)
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
| Слой | Папка | Назначение |
|
||||
|------|-------|------------|
|
||||
| **Engine** | `src/engine/` | C++ пайплайн, PCL, CLI `--cli` |
|
||||
| **Backend** | `backend/` | FastAPI — HTTP-обёртка над CLI |
|
||||
| **Frontend (Web)** | `frontend/web/` | Vue 3 dashboard + Three.js |
|
||||
| **Frontend (Desktop)** | `src/desktop/` | Qt QML dashboard |
|
||||
|
||||
Подробнее: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
|
||||
Сборка Web-версии полностью воспроизводится в Docker (`docker compose up --build`). В git — исходники; артефакты (`build/`, `frontend/web/dist/`) не коммитятся.
|
||||
|
||||
---
|
||||
|
||||
Демонстрационная программа на Qt 5.11 C++, которая:
|
||||
- принимает массив 3D-точек `QVector<Point3f>`;
|
||||
@@ -7,7 +22,7 @@
|
||||
|
||||
## Вход/выход API
|
||||
|
||||
`src/algorithms/reconstruction/surface_reconstruction.h`:
|
||||
`src/engine/algorithms/reconstruction/surface_reconstruction.h`:
|
||||
|
||||
- `struct Point3f { float x, y, z; };`
|
||||
- `struct Triangle { int i0, i1, i2; };`
|
||||
@@ -38,7 +53,7 @@
|
||||
## Этап 1 (backend-first, без CLI)
|
||||
|
||||
- Парсинг и применение `defaults` параметров стадий вынесены из UI в `core`:
|
||||
`src/core/pipeline_stage_defaults.h` и `src/core/pipeline_stage_defaults.cpp`.
|
||||
`src/engine/core/pipeline_stage_defaults.h` и `src/engine/core/pipeline_stage_defaults.cpp`.
|
||||
- `MainWindow` больше не содержит backend-логику разбора параметров, а вызывает
|
||||
`core::applyStageDefaultsToConfig(...)`.
|
||||
- Это позволяет использовать один и тот же backend API из GUI сейчас и из будущего
|
||||
@@ -78,10 +93,10 @@
|
||||
Рабочий поток для этого проекта на Windows: собирать и запускать через WSL.
|
||||
|
||||
```bash
|
||||
cd /mnt/d/yakupov/Projects/DotsToSirface/build-wsl
|
||||
qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED'
|
||||
cd /mnt/d/yakupov/Projects/DotsToSurface/build-wsl
|
||||
qmake ../DotsToSurface.pro 'DEFINES+=PCL_ENABLED'
|
||||
make -j4
|
||||
LIBGL_ALWAYS_SOFTWARE=1 QT_QPA_PLATFORM=xcb ./DotsToSirface
|
||||
LIBGL_ALWAYS_SOFTWARE=1 QT_QPA_PLATFORM=xcb ./DotsToSurface
|
||||
```
|
||||
|
||||
Примечания:
|
||||
@@ -92,22 +107,22 @@ LIBGL_ALWAYS_SOFTWARE=1 QT_QPA_PLATFORM=xcb ./DotsToSirface
|
||||
Перезапуск после правок:
|
||||
|
||||
```bash
|
||||
pkill -f '^./DotsToSirface$' || true
|
||||
cd /mnt/d/yakupov/Projects/DotsToSirface/build-wsl
|
||||
qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED'
|
||||
pkill -f '^./DotsToSurface$' || true
|
||||
cd /mnt/d/yakupov/Projects/DotsToSurface/build-wsl
|
||||
qmake ../DotsToSurface.pro 'DEFINES+=PCL_ENABLED'
|
||||
make -j4
|
||||
LIBGL_ALWAYS_SOFTWARE=1 QT_QPA_PLATFORM=xcb ./DotsToSirface
|
||||
LIBGL_ALWAYS_SOFTWARE=1 QT_QPA_PLATFORM=xcb ./DotsToSurface
|
||||
```
|
||||
|
||||
## Smoke tests (отдельный runner)
|
||||
|
||||
Для быстрой проверки пайплайна без GUI добавлен отдельный консольный таргет:
|
||||
`DotsToSirfaceTests.pro`.
|
||||
`DotsToSurfaceTests.pro`.
|
||||
|
||||
```bash
|
||||
qmake DotsToSirfaceTests.pro
|
||||
qmake DotsToSurfaceTests.pro
|
||||
make
|
||||
./release/DotsToSirfaceTests.exe
|
||||
./release/DotsToSurfaceTests.exe
|
||||
```
|
||||
|
||||
При успешном запуске runner печатает `Smoke tests passed.` и завершаетcя с кодом `0`.
|
||||
@@ -119,10 +134,10 @@ make
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
cd /mnt/d/yakupov/Projects/DotsToSirface/build-wsl
|
||||
qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED'
|
||||
cd /mnt/d/yakupov/Projects/DotsToSurface/build-wsl
|
||||
qmake ../DotsToSurface.pro 'DEFINES+=PCL_ENABLED'
|
||||
make -j4
|
||||
./DotsToSirface --cli \
|
||||
./DotsToSurface --cli \
|
||||
--input /mnt/d/path/to/cloud.xyz \
|
||||
--profile desktop_debug \
|
||||
--preprocess pcl_remove_nan,pcl_voxel_grid,pcl_statistical_outlier \
|
||||
@@ -155,7 +170,7 @@ CLI печатает краткую сводку по метрикам (`input`,
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
./DotsToSirface --cli --input /mnt/d/path/to/cloud.xyz --config-json /mnt/d/path/to/pipeline_config.json
|
||||
./DotsToSurface --cli --input /mnt/d/path/to/cloud.xyz --config-json /mnt/d/path/to/pipeline_config.json
|
||||
```
|
||||
|
||||
Если одновременно переданы `--config-json` и обычные CLI-флаги (`--preprocess`, `--reconstruction`, `--stage-default`), флаги командной строки имеют приоритет.
|
||||
@@ -193,7 +208,7 @@ CLI печатает краткую сводку по метрикам (`input`,
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
./DotsToSirface --cli \
|
||||
./DotsToSurface --cli \
|
||||
--input /mnt/d/path/to/cloud.xyz \
|
||||
--autotune-config /mnt/d/path/to/autotune_config.json
|
||||
```
|
||||
@@ -208,13 +223,12 @@ CLI печатает краткую сводку по метрикам (`input`,
|
||||
|
||||
Для запуска пайплайна с PCL в изолированной среде (без конфликтов с `libpq` на хосте) добавлены:
|
||||
|
||||
- `docker/Dockerfile` — сборка `DotsToSirface` с `PCL_ENABLED` + Vue 3 dashboard;
|
||||
- `docker/Dockerfile` — сборка `DotsToSurface` с `PCL_ENABLED` + Vue 3 dashboard;
|
||||
- `docker/docker-compose.yml`;
|
||||
- `api/main.py` — HTTP API (`/api/run`, `/api/presets`, `/api/validate-config`, …);
|
||||
- `web-vue/` — Vue 3 + Pinia + Three.js (полный dashboard, паритет с Qt);
|
||||
- `web/` — legacy MVP (fallback, если Vue `dist` не собран).
|
||||
- `backend/main.py` — HTTP API (`/api/run`, `/api/presets`, `/api/validate-config`, …);
|
||||
- `frontend/web/` — Vue 3 + Pinia + Three.js (полный dashboard, паритет с Qt);
|
||||
|
||||
**Qt desktop (`./DotsToSirface`) сохраняется** как локальный fallback без Docker.
|
||||
**Qt desktop (`./DotsToSurface`) сохраняется** как локальный fallback без Docker.
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
@@ -228,7 +242,7 @@ docker compose up --build
|
||||
### Локальная разработка frontend
|
||||
|
||||
```bash
|
||||
cd web-vue
|
||||
cd frontend/web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,36 @@
|
||||
# Backend (HTTP)
|
||||
|
||||
**Слой:** Backend — оркестрация и REST API. Вычисления выполняет C++ Engine через CLI.
|
||||
|
||||
## Роль
|
||||
|
||||
- Приём файлов и конфигурации пайплайна
|
||||
- Пресеты, validate, wizard, demo-облака (Python)
|
||||
- Запуск `DotsToSurface --cli` и возврат JSON
|
||||
- Отдача собранного Vue frontend (`frontend/web/dist`)
|
||||
|
||||
## Entry point
|
||||
|
||||
- [`main.py`](main.py) — FastAPI
|
||||
- Запуск: `uvicorn main:app --host 0.0.0.0 --port 8080`
|
||||
|
||||
## Зависимости
|
||||
|
||||
- **`DOTSTOSURFACE_BIN`** — путь к C++ бинарнику
|
||||
- Без бинарника `/api/run` не работает
|
||||
|
||||
## Запуск приложения
|
||||
|
||||
Полный Web-стек (frontend + API + engine) — **в Docker**, см. [../docker/README.md](../docker/README.md).
|
||||
|
||||
Локальный `uvicorn` ниже — только для разработки backend без пересборки образа:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
export DOTSTOSURFACE_BIN=/path/to/DotsToSurface
|
||||
uvicorn main:app --reload --port 8080
|
||||
```
|
||||
|
||||
См. также: [../ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||
@@ -0,0 +1,785 @@
|
||||
"""Batch synthetic sonar dataset generator for PointNet semantic segmentation.
|
||||
|
||||
Produces paired Area_X_scene_XXXX.npy + .obj files under sonar_dataset/.
|
||||
Target class 1 = user-provided object (from .obj mesh vertices); class 0 = seafloor / clutter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from scene_generator import (
|
||||
apply_transform,
|
||||
export_npy_float64,
|
||||
export_obj,
|
||||
generate_box,
|
||||
generate_pipe,
|
||||
generate_sphere,
|
||||
generate_torus,
|
||||
parse_obj_points,
|
||||
points_to_pointnet_rows,
|
||||
)
|
||||
|
||||
# Full dataset layout (train / val / test).
|
||||
AREA_LAYOUT: list[tuple[int, int]] = [
|
||||
(1, 75),
|
||||
(2, 75),
|
||||
(3, 75),
|
||||
(4, 75),
|
||||
(5, 100),
|
||||
(6, 100),
|
||||
]
|
||||
TOTAL_FULL_SCENES = sum(n for _, n in AREA_LAYOUT) # 500
|
||||
|
||||
VISIBILITY_TIERS = ("nearly_hidden", "partial", "visible")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Area naming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scene_index_to_area_name(index: int) -> tuple[int, int, str]:
|
||||
"""Map 0-based global index → (area, scene_number_1based, stem).
|
||||
|
||||
Scene numbers restart at 0001 within each Area.
|
||||
"""
|
||||
if index < 0:
|
||||
raise ValueError("scene index must be >= 0")
|
||||
remaining = index
|
||||
for area, count in AREA_LAYOUT:
|
||||
if remaining < count:
|
||||
scene_no = remaining + 1
|
||||
stem = f"Area_{area}_scene_{scene_no:04d}"
|
||||
return area, scene_no, stem
|
||||
remaining -= count
|
||||
scene_no = AREA_LAYOUT[-1][1] + remaining + 1
|
||||
stem = f"Area_6_scene_{scene_no:04d}"
|
||||
return 6, scene_no, stem
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Target object from user .obj
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_object_points(points: list[list[float]]) -> list[list[float]]:
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
zs = [p[2] for p in points]
|
||||
cx = (min(xs) + max(xs)) * 0.5
|
||||
cy = (min(ys) + max(ys)) * 0.5
|
||||
cz = (min(zs) + max(zs)) * 0.5
|
||||
span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1e-6)
|
||||
scale = 1.0 / span
|
||||
return [[(p[0] - cx) * scale, (p[1] - cy) * scale, (p[2] - cz) * scale] for p in points]
|
||||
|
||||
|
||||
def load_object_points_from_obj_text(text: str) -> list[list[float]]:
|
||||
"""Parse OBJ vertices and normalize to unit local frame (centered, max span ≈ 1)."""
|
||||
points = parse_obj_points(text)
|
||||
if len(points) < 3:
|
||||
raise ValueError("OBJ model must contain at least 3 vertices.")
|
||||
return normalize_object_points(points)
|
||||
|
||||
|
||||
def resample_object_points(
|
||||
template: list[list[float]],
|
||||
count: int,
|
||||
*,
|
||||
noise: float = 0.0,
|
||||
seed: int = 1,
|
||||
) -> list[list[float]]:
|
||||
"""Subsample (or sample with replacement) template points to the requested count."""
|
||||
if not template:
|
||||
raise ValueError("Object template is empty.")
|
||||
rng = random.Random(int(seed))
|
||||
count = max(1, int(count))
|
||||
out: list[list[float]] = []
|
||||
n = len(template)
|
||||
for _ in range(count):
|
||||
src = template[rng.randrange(n)]
|
||||
if noise > 0:
|
||||
out.append(
|
||||
[
|
||||
src[0] + rng.uniform(-noise, noise),
|
||||
src[1] + rng.uniform(-noise, noise),
|
||||
src[2] + rng.uniform(-noise, noise),
|
||||
]
|
||||
)
|
||||
else:
|
||||
out.append([src[0], src[1], src[2]])
|
||||
return out
|
||||
|
||||
|
||||
def object_half_extent_z(points: list[list[float]]) -> float:
|
||||
if not points:
|
||||
return 0.35
|
||||
zs = [p[2] for p in points]
|
||||
return max(0.05, (max(zs) - min(zs)) * 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seafloor / clutter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seafloor_height(
|
||||
x: float,
|
||||
y: float,
|
||||
*,
|
||||
base_z: float,
|
||||
amplitude: float,
|
||||
frequency: float,
|
||||
hills: list[tuple[float, float, float, float]],
|
||||
valleys: list[tuple[float, float, float, float]],
|
||||
bumps: list[tuple[float, float, float, float]],
|
||||
) -> float:
|
||||
z = base_z
|
||||
z += amplitude * math.sin(frequency * x) * math.cos(frequency * 0.7 * y)
|
||||
z += 0.35 * amplitude * math.sin(frequency * 1.7 * y + 0.4)
|
||||
for cx, cy, height, radius in hills:
|
||||
d2 = (x - cx) ** 2 + (y - cy) ** 2
|
||||
if d2 < radius * radius * 4:
|
||||
z += height * math.exp(-d2 / max(radius * radius, 1e-6))
|
||||
for cx, cy, depth, radius in valleys:
|
||||
d2 = (x - cx) ** 2 + (y - cy) ** 2
|
||||
if d2 < radius * radius * 4:
|
||||
z -= depth * math.exp(-d2 / max(radius * radius, 1e-6))
|
||||
for cx, cy, height, radius in bumps:
|
||||
d2 = (x - cx) ** 2 + (y - cy) ** 2
|
||||
if d2 < radius * radius * 4:
|
||||
z += height * math.exp(-d2 / max(radius * radius * 0.5, 1e-6))
|
||||
return z
|
||||
|
||||
|
||||
def _generate_seafloor(
|
||||
rng: random.Random,
|
||||
*,
|
||||
beam_count: int = 45,
|
||||
length_count: int | None = None,
|
||||
) -> tuple[list[list[float]], dict[str, Any]]:
|
||||
"""Sample seafloor as a square relief grid.
|
||||
|
||||
beam_count controls width resolution (X axis).
|
||||
length_count controls length resolution (Y axis).
|
||||
"""
|
||||
beams = max(1, int(beam_count))
|
||||
length_points = beams if length_count is None else max(1, int(length_count))
|
||||
size_x = rng.uniform(8.0, 16.0)
|
||||
size_y = rng.uniform(8.0, 16.0)
|
||||
base_z = rng.uniform(-1.2, -0.2)
|
||||
amplitude = rng.uniform(0.05, 0.35)
|
||||
frequency = rng.uniform(0.4, 2.2)
|
||||
noise = rng.uniform(0.005, 0.04)
|
||||
|
||||
hills = [
|
||||
(
|
||||
rng.uniform(-size_x * 0.4, size_x * 0.4),
|
||||
rng.uniform(-size_y * 0.4, size_y * 0.4),
|
||||
rng.uniform(0.15, 0.7),
|
||||
rng.uniform(0.6, 2.2),
|
||||
)
|
||||
for _ in range(rng.randint(1, 4))
|
||||
]
|
||||
valleys = [
|
||||
(
|
||||
rng.uniform(-size_x * 0.4, size_x * 0.4),
|
||||
rng.uniform(-size_y * 0.4, size_y * 0.4),
|
||||
rng.uniform(0.1, 0.55),
|
||||
rng.uniform(0.5, 2.0),
|
||||
)
|
||||
for _ in range(rng.randint(1, 3))
|
||||
]
|
||||
bumps = [
|
||||
(
|
||||
rng.uniform(-size_x * 0.45, size_x * 0.45),
|
||||
rng.uniform(-size_y * 0.45, size_y * 0.45),
|
||||
rng.uniform(0.03, 0.18),
|
||||
rng.uniform(0.15, 0.55),
|
||||
)
|
||||
for _ in range(rng.randint(3, 12))
|
||||
]
|
||||
|
||||
meta = {
|
||||
"sizeX": size_x,
|
||||
"sizeY": size_y,
|
||||
"baseZ": base_z,
|
||||
"amplitude": amplitude,
|
||||
"frequency": frequency,
|
||||
"hills": hills,
|
||||
"valleys": valleys,
|
||||
"bumps": bumps,
|
||||
"beamCount": beams,
|
||||
"lengthCount": length_points,
|
||||
"gridWidthPoints": beams,
|
||||
"gridLengthPoints": length_points,
|
||||
}
|
||||
|
||||
half_x = size_x * 0.5
|
||||
half_y = size_y * 0.5
|
||||
points: list[list[float]] = []
|
||||
for yi in range(length_points):
|
||||
y = -half_y if length_points == 1 else (-half_y + size_y * yi / (length_points - 1))
|
||||
for xi in range(beams):
|
||||
x = -half_x if beams == 1 else (-half_x + size_x * xi / (beams - 1))
|
||||
x += rng.uniform(-noise * 2, noise * 2)
|
||||
yj = y + rng.uniform(-noise * 2, noise * 2)
|
||||
z = _seafloor_height(
|
||||
x,
|
||||
yj,
|
||||
base_z=base_z,
|
||||
amplitude=amplitude,
|
||||
frequency=frequency,
|
||||
hills=hills,
|
||||
valleys=valleys,
|
||||
bumps=bumps,
|
||||
)
|
||||
z += rng.uniform(-noise, noise)
|
||||
points.append([x, yj, z])
|
||||
|
||||
# Local noise clusters (false sonar clutter blobs)
|
||||
for _ in range(rng.randint(1, 5)):
|
||||
cx = rng.uniform(-half_x * 0.8, half_x * 0.8)
|
||||
cy = rng.uniform(-half_y * 0.8, half_y * 0.8)
|
||||
cz = _seafloor_height(
|
||||
cx,
|
||||
cy,
|
||||
base_z=base_z,
|
||||
amplitude=amplitude,
|
||||
frequency=frequency,
|
||||
hills=hills,
|
||||
valleys=valleys,
|
||||
bumps=bumps,
|
||||
) + rng.uniform(0.0, 0.25)
|
||||
n_blob = rng.randint(40, 280)
|
||||
spread = rng.uniform(0.15, 0.7)
|
||||
for _ in range(n_blob):
|
||||
points.append(
|
||||
[
|
||||
cx + rng.gauss(0, spread),
|
||||
cy + rng.gauss(0, spread),
|
||||
cz + rng.gauss(0, spread * 0.35),
|
||||
]
|
||||
)
|
||||
|
||||
meta["pingCount"] = length_points
|
||||
meta["swathBeams"] = beams
|
||||
return points, meta
|
||||
|
||||
|
||||
def _height_at(x: float, y: float, meta: dict[str, Any]) -> float:
|
||||
return _seafloor_height(
|
||||
x,
|
||||
y,
|
||||
base_z=float(meta["baseZ"]),
|
||||
amplitude=float(meta["amplitude"]),
|
||||
frequency=float(meta["frequency"]),
|
||||
hills=meta["hills"],
|
||||
valleys=meta["valleys"],
|
||||
bumps=meta["bumps"],
|
||||
)
|
||||
|
||||
|
||||
def _generate_false_objects(rng: random.Random, meta: dict[str, Any]) -> list[list[float]]:
|
||||
n_objects = rng.randint(0, 6)
|
||||
points: list[list[float]] = []
|
||||
half_x = float(meta["sizeX"]) * 0.5
|
||||
half_y = float(meta["sizeY"]) * 0.5
|
||||
|
||||
for i in range(n_objects):
|
||||
kind = rng.choice(["sphere", "box", "torus", "pipe"])
|
||||
count = rng.randint(80, 900)
|
||||
noise = rng.uniform(0.005, 0.03)
|
||||
seed = rng.randint(0, 10_000_000)
|
||||
if kind == "sphere":
|
||||
local = generate_sphere(
|
||||
{"radius": rng.uniform(0.08, 0.55), "count": count, "noise": noise, "seed": seed}
|
||||
)
|
||||
elif kind == "box":
|
||||
local = generate_box(
|
||||
{
|
||||
"sizeX": rng.uniform(0.15, 1.2),
|
||||
"sizeY": rng.uniform(0.15, 1.0),
|
||||
"sizeZ": rng.uniform(0.08, 0.6),
|
||||
"count": count,
|
||||
"noise": noise,
|
||||
"seed": seed,
|
||||
}
|
||||
)
|
||||
elif kind == "torus":
|
||||
major = rng.uniform(0.15, 0.6)
|
||||
local = generate_torus(
|
||||
{
|
||||
"majorR": major,
|
||||
"minorR": rng.uniform(0.03, major * 0.4),
|
||||
"count": count,
|
||||
"noise": noise,
|
||||
"seed": seed,
|
||||
}
|
||||
)
|
||||
else:
|
||||
local = generate_pipe(
|
||||
{
|
||||
"length": rng.uniform(0.4, 2.5),
|
||||
"radius": rng.uniform(0.04, 0.2),
|
||||
"axis": rng.choice(["x", "y", "z"]),
|
||||
"count": count,
|
||||
"noise": noise,
|
||||
"seed": seed,
|
||||
}
|
||||
)
|
||||
|
||||
tx = rng.uniform(-half_x * 0.75, half_x * 0.75)
|
||||
ty = rng.uniform(-half_y * 0.75, half_y * 0.75)
|
||||
floor_z = _height_at(tx, ty, meta)
|
||||
# Rest on / slightly into seafloor
|
||||
tz = floor_z + rng.uniform(-0.05, 0.35)
|
||||
transform = {
|
||||
"x": tx,
|
||||
"y": ty,
|
||||
"z": tz,
|
||||
"rx": rng.uniform(-0.4, 0.4),
|
||||
"ry": rng.uniform(-0.4, 0.4),
|
||||
"rz": rng.uniform(0, 2 * math.pi),
|
||||
}
|
||||
world = apply_transform(local, transform)
|
||||
# Drop points buried deep under seafloor
|
||||
for p in world:
|
||||
if p[2] >= _height_at(p[0], p[1], meta) - 0.02:
|
||||
points.append(p)
|
||||
return points
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Balance plan + single scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def plan_scene_labels(count: int, seed: int) -> list[str]:
|
||||
"""Return visibility label per scene: absent | nearly_hidden | partial | visible.
|
||||
|
||||
~50% absent; among present scenes, roughly equal nearly_hidden/partial/visible.
|
||||
"""
|
||||
count = max(0, int(count))
|
||||
rng = random.Random(int(seed) ^ 0xA5A5_5A5A)
|
||||
n_with = (count + 1) // 2 # ceil → ~50% with object
|
||||
n_without = count - n_with
|
||||
labels: list[str] = ["absent"] * n_without
|
||||
for i in range(n_with):
|
||||
labels.append(VISIBILITY_TIERS[i % 3])
|
||||
rng.shuffle(labels)
|
||||
return labels
|
||||
|
||||
|
||||
def _place_object(
|
||||
rng: random.Random,
|
||||
meta: dict[str, Any],
|
||||
visibility: str,
|
||||
object_template: list[list[float]],
|
||||
object_scale: float = 1.0,
|
||||
) -> tuple[list[list[float]], dict[str, Any]]:
|
||||
"""Sample, transform, and bury target object; return surviving world points + info."""
|
||||
base_scale = max(0.01, float(object_scale))
|
||||
if visibility == "nearly_hidden":
|
||||
count = rng.randint(80, 600)
|
||||
burial = rng.uniform(0.35, 0.75)
|
||||
scale = base_scale * rng.uniform(0.7, 1.15)
|
||||
elif visibility == "partial":
|
||||
count = rng.randint(400, 2500)
|
||||
burial = rng.uniform(0.12, 0.4)
|
||||
scale = base_scale * rng.uniform(0.8, 1.3)
|
||||
else: # visible
|
||||
count = rng.randint(1500, 8000)
|
||||
burial = rng.uniform(-0.05, 0.15)
|
||||
scale = base_scale * rng.uniform(0.85, 1.4)
|
||||
|
||||
noise = rng.uniform(0.004, 0.025)
|
||||
local = resample_object_points(
|
||||
object_template,
|
||||
count,
|
||||
noise=noise,
|
||||
seed=rng.randint(0, 10_000_000),
|
||||
)
|
||||
# Apply world scale to unit-normalized template
|
||||
local = [[p[0] * scale, p[1] * scale, p[2] * scale] for p in local]
|
||||
|
||||
half_x = float(meta["sizeX"]) * 0.35
|
||||
half_y = float(meta["sizeY"]) * 0.35
|
||||
tx = rng.uniform(-half_x, half_x)
|
||||
ty = rng.uniform(-half_y, half_y)
|
||||
floor_z = _height_at(tx, ty, meta)
|
||||
|
||||
half_h = object_half_extent_z(local)
|
||||
tz = floor_z + half_h * (1.0 - 2.0 * burial)
|
||||
|
||||
transform = {
|
||||
"x": tx,
|
||||
"y": ty,
|
||||
"z": tz,
|
||||
"rx": rng.uniform(-0.25, 0.25),
|
||||
"ry": rng.uniform(-0.2, 0.2),
|
||||
"rz": rng.uniform(0, 2 * math.pi),
|
||||
}
|
||||
world = apply_transform(local, transform)
|
||||
|
||||
kept: list[list[float]] = []
|
||||
for p in world:
|
||||
surface = _height_at(p[0], p[1], meta)
|
||||
eps = 0.01 if visibility != "nearly_hidden" else -0.02
|
||||
if p[2] >= surface + eps:
|
||||
kept.append(p)
|
||||
|
||||
if visibility == "nearly_hidden" and len(kept) < 15 and world:
|
||||
ranked = sorted(world, key=lambda p: p[2] - _height_at(p[0], p[1], meta), reverse=True)
|
||||
kept = ranked[: max(15, min(40, len(ranked) // 8))]
|
||||
|
||||
info = {
|
||||
"visibility": visibility,
|
||||
"transform": transform,
|
||||
"requestedCount": count,
|
||||
"keptCount": len(kept),
|
||||
"scale": scale,
|
||||
"objectScale": base_scale,
|
||||
"burial": burial,
|
||||
"classLabel": "object",
|
||||
"classId": 1,
|
||||
}
|
||||
return kept, info
|
||||
|
||||
|
||||
def generate_sonar_scene(
|
||||
*,
|
||||
seed: int,
|
||||
visibility: str = "absent",
|
||||
object_points: list[list[float]] | None = None,
|
||||
object_scale: float = 1.0,
|
||||
beam_count: int = 45,
|
||||
length_count: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build one unique sonar scene. visibility in absent|nearly_hidden|partial|visible."""
|
||||
rng = random.Random(int(seed))
|
||||
if visibility not in ("absent",) + VISIBILITY_TIERS:
|
||||
raise ValueError(f"Unknown visibility: {visibility}")
|
||||
if visibility != "absent" and not object_points:
|
||||
raise ValueError("object_points required when visibility is not absent.")
|
||||
|
||||
floor_pts, meta = _generate_seafloor(rng, beam_count=beam_count, length_count=length_count)
|
||||
clutter = _generate_false_objects(rng, meta)
|
||||
|
||||
jitter = rng.uniform(0.0, 0.015)
|
||||
background = floor_pts + clutter
|
||||
if jitter > 0:
|
||||
background = [
|
||||
[
|
||||
p[0] + rng.uniform(-jitter, jitter),
|
||||
p[1] + rng.uniform(-jitter, jitter),
|
||||
p[2] + rng.uniform(-jitter, jitter),
|
||||
]
|
||||
for p in background
|
||||
]
|
||||
|
||||
drop = rng.uniform(0.0, 0.12)
|
||||
if drop > 0:
|
||||
background = [p for p in background if rng.random() >= drop]
|
||||
|
||||
object_pts: list[list[float]] = []
|
||||
object_info: dict[str, Any] | None = None
|
||||
if visibility != "absent":
|
||||
object_pts, object_info = _place_object(
|
||||
rng,
|
||||
meta,
|
||||
visibility,
|
||||
object_points,
|
||||
object_scale=object_scale,
|
||||
)
|
||||
|
||||
# class 0 = background, class 1 = object
|
||||
rows = points_to_pointnet_rows(background, 0.0)
|
||||
rows.extend(points_to_pointnet_rows(object_pts, 1.0))
|
||||
rng.shuffle(rows)
|
||||
|
||||
xyz = [[r[0], r[1], r[2]] for r in rows]
|
||||
return {
|
||||
"seed": int(seed),
|
||||
"visibility": visibility,
|
||||
"hasObject": visibility != "absent",
|
||||
"object": object_info,
|
||||
"pointCount": len(rows),
|
||||
"objectPointCount": len(object_pts),
|
||||
"backgroundPointCount": len(background),
|
||||
"rows": rows,
|
||||
"points": xyz,
|
||||
"meta": {
|
||||
"sizeX": meta["sizeX"],
|
||||
"sizeY": meta["sizeY"],
|
||||
"beamCount": meta.get("beamCount", beam_count),
|
||||
"lengthCount": meta.get("lengthCount", length_count if length_count is not None else beam_count),
|
||||
"gridWidthPoints": meta.get("gridWidthPoints", beam_count),
|
||||
"gridLengthPoints": meta.get(
|
||||
"gridLengthPoints",
|
||||
length_count if length_count is not None else beam_count,
|
||||
),
|
||||
"pingCount": meta.get("pingCount"),
|
||||
"swathBeams": meta.get("swathBeams"),
|
||||
"floorFeatures": {
|
||||
"hills": len(meta["hills"]),
|
||||
"valleys": len(meta["valleys"]),
|
||||
"bumps": len(meta["bumps"]),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch write / preview load
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_output_dir(output_dir: str | Path = "sonar_dataset") -> Path:
|
||||
out = Path(output_dir)
|
||||
if not out.is_absolute():
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
out = project_root / out
|
||||
return out
|
||||
|
||||
|
||||
def _downsample_points(points: list[list[float]], max_points: int) -> list[list[float]]:
|
||||
max_points = max(100, int(max_points))
|
||||
if len(points) <= max_points:
|
||||
return points
|
||||
step = max(1, len(points) // max_points)
|
||||
return points[::step][:max_points]
|
||||
|
||||
|
||||
def load_npy_float64_rows(path: Path) -> list[list[float]]:
|
||||
"""Read float64 little-endian .npy array written by export_npy_float64."""
|
||||
import re
|
||||
import struct
|
||||
|
||||
data = path.read_bytes()
|
||||
if data[:6] != b"\x93NUMPY":
|
||||
raise ValueError(f"Not a NumPy .npy file: {path.name}")
|
||||
major = data[6]
|
||||
if major == 1:
|
||||
hlen = struct.unpack_from("<H", data, 8)[0]
|
||||
header = data[10 : 10 + hlen].decode("latin1")
|
||||
offset = 10 + hlen
|
||||
elif major == 2:
|
||||
hlen = struct.unpack_from("<I", data, 8)[0]
|
||||
header = data[12 : 12 + hlen].decode("latin1")
|
||||
offset = 12 + hlen
|
||||
else:
|
||||
raise ValueError(f"Unsupported .npy version: {major}")
|
||||
match = re.search(r"shape'\s*:\s*\((\d+)\s*,\s*(\d+)\)", header)
|
||||
if not match:
|
||||
match = re.search(r"shape':\s*\((\d+),\s*(\d+)\)", header)
|
||||
if not match:
|
||||
raise ValueError(f"Cannot parse .npy shape from {path.name}")
|
||||
n_rows, n_cols = int(match.group(1)), int(match.group(2))
|
||||
expected = n_rows * n_cols * 8
|
||||
body = data[offset : offset + expected]
|
||||
if len(body) < expected:
|
||||
raise ValueError(f"Truncated .npy payload in {path.name}")
|
||||
flat = struct.unpack("<" + "d" * (n_rows * n_cols), body)
|
||||
return [list(flat[i * n_cols : (i + 1) * n_cols]) for i in range(n_rows)]
|
||||
|
||||
|
||||
def _class_counts_from_labeled(points: list[list[float]]) -> dict[str, int]:
|
||||
"""Count classes from rows shaped [x, y, z, class]."""
|
||||
counts: dict[str, int] = {}
|
||||
for row in points:
|
||||
key = str(int(round(float(row[3] if len(row) > 3 else 0))))
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _class_counts(rows: list[list[float]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
key = str(int(round(float(row[6] if len(row) > 6 else 0))))
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def load_scene_preview(
|
||||
*,
|
||||
stem: str,
|
||||
output_dir: str | Path = "sonar_dataset",
|
||||
max_points: int = 25000,
|
||||
) -> dict[str, Any]:
|
||||
"""Load labeled points for a written scene (prefer .npy) for the 3D viewer.
|
||||
|
||||
Each preview point is [x, y, z, class].
|
||||
"""
|
||||
safe = "".join(ch if ch.isalnum() or ch in "_-" else "" for ch in (stem or ""))
|
||||
if not safe or safe != stem:
|
||||
raise ValueError("Invalid scene stem.")
|
||||
out = resolve_output_dir(output_dir)
|
||||
npy_path = out / f"{safe}.npy"
|
||||
obj_path = out / f"{safe}.obj"
|
||||
|
||||
labeled: list[list[float]]
|
||||
if npy_path.is_file():
|
||||
rows = load_npy_float64_rows(npy_path)
|
||||
labeled = [[float(r[0]), float(r[1]), float(r[2]), float(r[6])] for r in rows]
|
||||
elif obj_path.is_file():
|
||||
text = obj_path.read_text(encoding="utf-8", errors="ignore")
|
||||
points = parse_obj_points(text)
|
||||
labeled = [[p[0], p[1], p[2], 0.0] for p in points]
|
||||
else:
|
||||
raise FileNotFoundError(f"Scene not found: {safe}.npy / {safe}.obj")
|
||||
|
||||
full_counts = _class_counts_from_labeled(labeled)
|
||||
preview = _downsample_points(labeled, max_points)
|
||||
return {
|
||||
"stem": safe,
|
||||
"outputDir": str(out),
|
||||
"pointCount": len(labeled),
|
||||
"previewCount": len(preview),
|
||||
"points": preview,
|
||||
"classCounts": full_counts,
|
||||
"classLabels": {"0": "background", "1": "object"},
|
||||
"obj": str(obj_path) if obj_path.is_file() else None,
|
||||
"npy": str(npy_path) if npy_path.is_file() else None,
|
||||
}
|
||||
|
||||
|
||||
def write_scene_files(
|
||||
scene: dict[str, Any],
|
||||
output_dir: Path,
|
||||
stem: str,
|
||||
) -> dict[str, str]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
npy_path = output_dir / f"{stem}.npy"
|
||||
obj_path = output_dir / f"{stem}.obj"
|
||||
npy_path.write_bytes(export_npy_float64(scene["rows"]))
|
||||
obj_path.write_text(export_obj(scene["points"], object_name=stem), encoding="utf-8")
|
||||
return {"npy": str(npy_path), "obj": str(obj_path), "stem": stem}
|
||||
|
||||
|
||||
def generate_dataset(
|
||||
*,
|
||||
count: int = 5,
|
||||
seed: int = 42,
|
||||
output_dir: str | Path = "sonar_dataset",
|
||||
object_points: list[list[float]],
|
||||
object_name: str | None = None,
|
||||
object_scale: float = 1.0,
|
||||
beam_count: int = 45,
|
||||
length_count: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate `count` unique scenes into output_dir with Area_X naming.
|
||||
|
||||
object_points: normalized template vertices from user .obj (class 1 = object).
|
||||
object_scale: relative size multiplier vs unit-normalized mesh (1.0 = default).
|
||||
beam_count: number of width points for seafloor grid (X axis).
|
||||
length_count: number of length points for seafloor grid (Y axis). Defaults to beam_count.
|
||||
"""
|
||||
count = int(count)
|
||||
if count < 1:
|
||||
raise ValueError("count must be >= 1")
|
||||
if count > 5000:
|
||||
raise ValueError("count must be <= 5000")
|
||||
if not object_points or len(object_points) < 3:
|
||||
raise ValueError("A valid .obj model with at least 3 vertices is required.")
|
||||
object_scale = float(object_scale)
|
||||
if object_scale <= 0:
|
||||
raise ValueError("object_scale must be > 0")
|
||||
if object_scale > 100:
|
||||
raise ValueError("object_scale must be <= 100")
|
||||
beam_count = int(beam_count)
|
||||
if beam_count < 1:
|
||||
raise ValueError("beam_count (Кол-во лучей) must be >= 1")
|
||||
if beam_count > 1024:
|
||||
raise ValueError("beam_count (Кол-во лучей) must be <= 1024")
|
||||
if length_count is None:
|
||||
length_count = beam_count
|
||||
length_count = int(length_count)
|
||||
if length_count < 1:
|
||||
raise ValueError("length_count (Длина) must be >= 1")
|
||||
if length_count > 1024:
|
||||
raise ValueError("length_count (Длина) must be <= 1024")
|
||||
|
||||
out = resolve_output_dir(output_dir)
|
||||
template = normalize_object_points(object_points)
|
||||
|
||||
labels = plan_scene_labels(count, seed)
|
||||
written: list[dict[str, Any]] = []
|
||||
stats = {
|
||||
"total": count,
|
||||
"withObject": 0,
|
||||
"withoutObject": 0,
|
||||
"nearly_hidden": 0,
|
||||
"partial": 0,
|
||||
"visible": 0,
|
||||
"absent": 0,
|
||||
}
|
||||
|
||||
preview_points: list[list[float]] | None = None
|
||||
preview_stem: str | None = None
|
||||
preview_has_object = False
|
||||
|
||||
for i in range(count):
|
||||
visibility = labels[i]
|
||||
scene_seed = int(seed) + i * 10007 + 17
|
||||
scene = generate_sonar_scene(
|
||||
seed=scene_seed,
|
||||
visibility=visibility,
|
||||
object_points=template,
|
||||
object_scale=object_scale,
|
||||
beam_count=beam_count,
|
||||
length_count=length_count,
|
||||
)
|
||||
area, scene_no, stem = scene_index_to_area_name(i)
|
||||
paths = write_scene_files(scene, out, stem)
|
||||
|
||||
entry = {
|
||||
"index": i,
|
||||
"area": area,
|
||||
"scene": scene_no,
|
||||
"stem": stem,
|
||||
"visibility": visibility,
|
||||
"hasObject": scene["hasObject"],
|
||||
"pointCount": scene["pointCount"],
|
||||
"objectPointCount": scene["objectPointCount"],
|
||||
"files": paths,
|
||||
}
|
||||
written.append(entry)
|
||||
|
||||
stats[visibility] = stats.get(visibility, 0) + 1
|
||||
if scene["hasObject"]:
|
||||
stats["withObject"] += 1
|
||||
else:
|
||||
stats["withoutObject"] += 1
|
||||
|
||||
if preview_points is None or (scene["hasObject"] and not preview_has_object):
|
||||
preview_points = [[r[0], r[1], r[2], r[6]] for r in scene["rows"]]
|
||||
preview_stem = stem
|
||||
preview_has_object = bool(scene["hasObject"])
|
||||
|
||||
preview: dict[str, Any] | None = None
|
||||
if preview_points is not None:
|
||||
pts = _downsample_points(preview_points, 25000)
|
||||
preview = {
|
||||
"stem": preview_stem,
|
||||
"points": pts,
|
||||
"pointCount": len(preview_points),
|
||||
"classCounts": _class_counts_from_labeled(preview_points),
|
||||
"classLabels": {"0": "background", "1": "object"},
|
||||
}
|
||||
|
||||
return {
|
||||
"outputDir": str(out),
|
||||
"count": count,
|
||||
"seed": int(seed),
|
||||
"beamCount": beam_count,
|
||||
"lengthCount": length_count,
|
||||
"objectName": object_name,
|
||||
"objectScale": object_scale,
|
||||
"objectVertexCount": len(template),
|
||||
"classLabels": {"0": "background", "1": "object"},
|
||||
"stats": stats,
|
||||
"written": written,
|
||||
"preview": preview,
|
||||
}
|
||||
+271
-20
@@ -18,19 +18,32 @@ from pydantic import BaseModel
|
||||
from builtin_presets import BUILTIN_PRESETS, get_builtin_preset
|
||||
from demo_generator import DEMO_SURFACE_TYPES, demo_payload
|
||||
from pipeline_insights import compute_insights
|
||||
from dataset_generator import generate_dataset, load_object_points_from_obj_text, load_scene_preview
|
||||
from scene_generator import (
|
||||
catalog_payload as generator_catalog_payload,
|
||||
export_npy_float64,
|
||||
export_obj,
|
||||
export_ply,
|
||||
export_xyz,
|
||||
generate_layer,
|
||||
layers_to_pointnet_rows,
|
||||
merge_layers_world,
|
||||
parse_obj_points,
|
||||
points_to_pointnet_rows,
|
||||
resolve_intersections,
|
||||
)
|
||||
from stage_meta import STAGE_META, STAGE_META_BY_ID, catalog_payload, defaults_for_stage
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parent.parent
|
||||
WEB_DIST = APP_ROOT / "web-vue" / "dist"
|
||||
WEB_LEGACY = APP_ROOT / "web"
|
||||
WEB_DIST = APP_ROOT / "frontend" / "web" / "dist"
|
||||
PRESETS_DIRS = [APP_ROOT / "presets", APP_ROOT / "docker"]
|
||||
USER_PRESETS_DIR = Path(os.environ.get("USER_PRESETS_DIR", "/app/data/user-presets"))
|
||||
DEFAULT_PIPELINE_CONFIG = Path(
|
||||
os.environ.get("PIPELINE_CONFIG", APP_ROOT / "docker" / "default_pipeline.json")
|
||||
)
|
||||
DOTSTOSIRFACE_BIN = Path(os.environ.get("DOTSTOSIRFACE_BIN", "/usr/local/bin/DotsToSirface"))
|
||||
DOTSTOSURFACE_BIN = Path(os.environ.get("DOTSTOSURFACE_BIN", "/usr/local/bin/DotsToSurface"))
|
||||
|
||||
app = FastAPI(title="DotsToSirface Web API", version="2.0.0")
|
||||
app = FastAPI(title="DotsToSurface Web API", version="2.0.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -64,6 +77,52 @@ class UserPresetBody(BaseModel):
|
||||
stages: list[dict[str, Any]]
|
||||
|
||||
|
||||
class GeneratorLayerBody(BaseModel):
|
||||
kind: str
|
||||
type: str
|
||||
params: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class GeneratorLayerItem(BaseModel):
|
||||
id: str | None = None
|
||||
kind: str
|
||||
type: str
|
||||
name: str | None = None
|
||||
params: dict[str, Any] | None = None
|
||||
transform: dict[str, Any] | None = None
|
||||
points: list[list[float]] | None = None
|
||||
label: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class GeneratorResolveBody(BaseModel):
|
||||
layers: list[GeneratorLayerItem]
|
||||
eps: float = 0.01
|
||||
clipSurfaceInsideObjects: bool = True
|
||||
clipObjectsVsObjects: bool = True
|
||||
|
||||
|
||||
class GeneratorExportBody(BaseModel):
|
||||
points: list[list[float]] | None = None
|
||||
layers: list[GeneratorLayerItem] | None = None
|
||||
format: str = "xyz"
|
||||
filename: str | None = None
|
||||
# For points-only .npy export when layers are not provided (1=pipe, 0=other).
|
||||
classLabel: float | None = None
|
||||
|
||||
|
||||
class DatasetGenerateBody(BaseModel):
|
||||
count: int = 5
|
||||
seed: int = 42
|
||||
outputDir: str = "sonar_dataset"
|
||||
|
||||
|
||||
class DatasetPreviewBody(BaseModel):
|
||||
stem: str
|
||||
outputDir: str = "sonar_dataset"
|
||||
maxPoints: int = 25000
|
||||
|
||||
|
||||
def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
|
||||
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
|
||||
return preset
|
||||
@@ -181,7 +240,7 @@ def run_pipeline_command(input_path: Path, config: dict[str, Any], output_path:
|
||||
config_path = output_path.parent / "pipeline_config.json"
|
||||
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
||||
command = [
|
||||
str(DOTSTOSIRFACE_BIN),
|
||||
str(DOTSTOSURFACE_BIN),
|
||||
"--cli",
|
||||
"--input",
|
||||
str(input_path),
|
||||
@@ -220,8 +279,8 @@ def enrich_result(result: dict[str, Any], config: dict[str, Any], stdout: str, w
|
||||
def health() -> dict[str, str]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"binary": str(DOTSTOSIRFACE_BIN),
|
||||
"binaryExists": str(DOTSTOSIRFACE_BIN.is_file()),
|
||||
"binary": str(DOTSTOSURFACE_BIN),
|
||||
"binaryExists": str(DOTSTOSURFACE_BIN.is_file()),
|
||||
"webDist": str(WEB_DIST.is_dir()),
|
||||
}
|
||||
|
||||
@@ -329,6 +388,159 @@ def demo_types() -> list[str]:
|
||||
return DEMO_SURFACE_TYPES
|
||||
|
||||
|
||||
@app.get("/api/generator/catalog")
|
||||
def generator_catalog() -> dict[str, Any]:
|
||||
return generator_catalog_payload()
|
||||
|
||||
|
||||
@app.post("/api/generator/layer")
|
||||
def generator_layer(body: GeneratorLayerBody) -> dict[str, Any]:
|
||||
try:
|
||||
return generate_layer(body.kind, body.type, body.params)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/generator/resolve-intersections")
|
||||
def generator_resolve_intersections(body: GeneratorResolveBody) -> dict[str, Any]:
|
||||
try:
|
||||
layers = [item.model_dump() for item in body.layers]
|
||||
resolved = resolve_intersections(
|
||||
layers,
|
||||
eps=body.eps,
|
||||
clip_surface_inside_objects=body.clipSurfaceInsideObjects,
|
||||
clip_objects_vs_objects=body.clipObjectsVsObjects,
|
||||
)
|
||||
return {
|
||||
"layers": resolved,
|
||||
"removedTotal": sum(int(layer.get("removedCount", 0)) for layer in resolved),
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/dataset/generate")
|
||||
async def dataset_generate(
|
||||
count: int = Form(5),
|
||||
seed: int = Form(42),
|
||||
outputDir: str = Form("sonar_dataset"),
|
||||
objectScale: float = Form(1.0),
|
||||
beamCount: int = Form(45),
|
||||
lengthCount: int | None = Form(None),
|
||||
model: UploadFile = File(...),
|
||||
) -> dict[str, Any]:
|
||||
filename = (model.filename or "").strip()
|
||||
if not filename.lower().endswith(".obj"):
|
||||
raise HTTPException(status_code=400, detail="Upload a .obj 3D model file.")
|
||||
try:
|
||||
raw = await model.read()
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
object_points = load_object_points_from_obj_text(text)
|
||||
return generate_dataset(
|
||||
count=count,
|
||||
seed=seed,
|
||||
output_dir=outputDir or "sonar_dataset",
|
||||
object_points=object_points,
|
||||
object_name=filename,
|
||||
object_scale=objectScale,
|
||||
beam_count=beamCount,
|
||||
length_count=lengthCount,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to write dataset: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/api/dataset/preview")
|
||||
def dataset_preview(body: DatasetPreviewBody) -> dict[str, Any]:
|
||||
try:
|
||||
return load_scene_preview(
|
||||
stem=body.stem,
|
||||
output_dir=body.outputDir or "sonar_dataset",
|
||||
max_points=body.maxPoints,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load scene: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/api/generator/export")
|
||||
def generator_export(body: GeneratorExportBody) -> Response:
|
||||
from urllib.parse import quote
|
||||
|
||||
fmt = (body.format or "xyz").lower().lstrip(".")
|
||||
if fmt not in ("xyz", "ply", "obj", "npy"):
|
||||
raise HTTPException(status_code=400, detail="Supported formats: xyz, ply, obj, npy")
|
||||
|
||||
filename = body.filename
|
||||
content: bytes
|
||||
media: str
|
||||
ext: str
|
||||
|
||||
if fmt == "npy":
|
||||
try:
|
||||
if body.layers:
|
||||
rows = layers_to_pointnet_rows([item.model_dump() for item in body.layers])
|
||||
elif body.points is not None:
|
||||
label = 0.0 if body.classLabel is None else float(body.classLabel)
|
||||
rows = points_to_pointnet_rows(body.points, label)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Provide points or layers to export.")
|
||||
content = export_npy_float64(rows)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
media = "application/octet-stream"
|
||||
ext = "npy"
|
||||
else:
|
||||
points = body.points
|
||||
if points is None and body.layers:
|
||||
try:
|
||||
points = merge_layers_world([item.model_dump() for item in body.layers])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if points is None:
|
||||
raise HTTPException(status_code=400, detail="Provide points or layers to export.")
|
||||
|
||||
if fmt == "ply":
|
||||
text = export_ply(points)
|
||||
media = "application/octet-stream"
|
||||
ext = "ply"
|
||||
elif fmt == "obj":
|
||||
stem = Path(body.filename or "cloud").stem or "cloud"
|
||||
text = export_obj(points, object_name=stem)
|
||||
media = "text/plain; charset=utf-8"
|
||||
ext = "obj"
|
||||
else:
|
||||
text = export_xyz(points)
|
||||
media = "text/plain; charset=utf-8"
|
||||
ext = "xyz"
|
||||
content = text.encode("utf-8")
|
||||
|
||||
out_name = filename or f"cloud.{ext}"
|
||||
if not out_name.lower().endswith(f".{ext}"):
|
||||
out_name = f"{out_name}.{ext}"
|
||||
|
||||
ascii_name = "".join(ch if 32 <= ord(ch) < 127 and ch not in '\\/"' else "_" for ch in out_name)
|
||||
if not ascii_name.lower().endswith(f".{ext}"):
|
||||
ascii_name = f"cloud.{ext}"
|
||||
disposition = (
|
||||
f"attachment; filename=\"{ascii_name}\"; "
|
||||
f"filename*=UTF-8''{quote(out_name)}"
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media,
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/user-presets")
|
||||
def get_user_presets() -> list[dict[str, Any]]:
|
||||
return load_user_presets()
|
||||
@@ -363,11 +575,11 @@ async def run_pipeline(
|
||||
demo_surface: str | None = Form(default=None),
|
||||
geometry_format: str = Form(default="json"),
|
||||
) -> dict[str, Any]:
|
||||
if not DOTSTOSIRFACE_BIN.is_file():
|
||||
raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSIRFACE_BIN}")
|
||||
if not DOTSTOSURFACE_BIN.is_file():
|
||||
raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSURFACE_BIN}")
|
||||
|
||||
work_id = uuid.uuid4().hex
|
||||
work_dir = Path(tempfile.gettempdir()) / "dotstosirface" / work_id
|
||||
work_dir = Path(tempfile.gettempdir()) / "dottosurface" / work_id
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = work_dir / "result.json"
|
||||
|
||||
@@ -378,9 +590,24 @@ async def run_pipeline(
|
||||
lines = [f"{p[0]} {p[1]} {p[2]}" for p in demo["points"]]
|
||||
input_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
elif file is not None:
|
||||
suffix = Path(file.filename or "cloud.ply").suffix or ".ply"
|
||||
input_path = work_dir / f"input{suffix}"
|
||||
input_path.write_bytes(await file.read())
|
||||
suffix = Path(file.filename or "cloud.ply").suffix.lower() or ".ply"
|
||||
raw = await file.read()
|
||||
if suffix == ".obj":
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
obj_points = parse_obj_points(text)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid OBJ: {exc}") from exc
|
||||
if not obj_points:
|
||||
raise HTTPException(status_code=400, detail="OBJ has no vertices (v x y z).")
|
||||
input_path = work_dir / "input.xyz"
|
||||
input_path.write_text(
|
||||
"\n".join(f"{p[0]} {p[1]} {p[2]}" for p in obj_points) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
input_path = work_dir / f"input{suffix}"
|
||||
input_path.write_bytes(raw)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Provide file or demo_surface.")
|
||||
|
||||
@@ -438,21 +665,45 @@ async def run_pipeline(
|
||||
|
||||
@app.get("/api/geometry/{work_id}")
|
||||
def get_geometry(work_id: str) -> Response:
|
||||
bin_path = Path(tempfile.gettempdir()) / "dotstosirface" / work_id / "geometry.bin"
|
||||
bin_path = Path(tempfile.gettempdir()) / "dottosurface" / work_id / "geometry.bin"
|
||||
if not bin_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Geometry not found.")
|
||||
return Response(content=bin_path.read_bytes(), media_type="application/octet-stream")
|
||||
|
||||
|
||||
@app.get("/airplane_reference.png")
|
||||
def airplane_reference_image() -> FileResponse:
|
||||
candidates = [
|
||||
WEB_DIST / "airplane_reference.png",
|
||||
APP_ROOT / "assets" / "airplane_reference.png",
|
||||
APP_ROOT / "frontend" / "web" / "public" / "airplane_reference.png",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
return FileResponse(path, media_type="image/png")
|
||||
raise HTTPException(status_code=404, detail="Airplane reference image not found.")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
if (WEB_DIST / "index.html").is_file():
|
||||
return FileResponse(WEB_DIST / "index.html")
|
||||
return FileResponse(WEB_LEGACY / "index.html")
|
||||
index_path = WEB_DIST / "index.html"
|
||||
if not index_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Frontend not built. Run: cd frontend/web && npm install && npm run build",
|
||||
)
|
||||
return FileResponse(index_path)
|
||||
|
||||
|
||||
@app.get("/generator")
|
||||
def generator_spa() -> FileResponse:
|
||||
return index()
|
||||
|
||||
|
||||
@app.get("/dataset")
|
||||
def dataset_spa() -> FileResponse:
|
||||
return index()
|
||||
|
||||
|
||||
if (WEB_DIST / "assets").is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
if WEB_LEGACY.is_dir() and not (WEB_DIST / "index.html").is_file():
|
||||
app.mount("/static", StaticFiles(directory=WEB_LEGACY), name="static")
|
||||
@@ -57,7 +57,7 @@ def compute_insights(
|
||||
recommendation = warnings[0]
|
||||
else:
|
||||
chain_health = "OK"
|
||||
recommendation = "Pipeline looks balanced. Use Compare snapshots for A/B tuning."
|
||||
recommendation = "Пайплайн сбалансирован. Используйте «Сохр. конф.» для A/B-сравнения."
|
||||
|
||||
return {
|
||||
"chainHealth": chain_health,
|
||||
@@ -0,0 +1,674 @@
|
||||
"""Parametric scene generator: objects, terrain surfaces, intersection clipping, export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog / default params
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LAYER_CATALOG: list[dict[str, Any]] = [
|
||||
{
|
||||
"kind": "object",
|
||||
"type": "pipe",
|
||||
"label": "Труба",
|
||||
"params": [
|
||||
{"key": "length", "label": "Длина", "type": "number", "default": 2.6, "min": 0.2, "max": 20, "step": 0.1},
|
||||
{"key": "radius", "label": "Радиус", "type": "number", "default": 0.22, "min": 0.02, "max": 5, "step": 0.01},
|
||||
{"key": "axis", "label": "Ось", "type": "select", "default": "y", "options": ["x", "y", "z"]},
|
||||
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
|
||||
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
|
||||
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "object",
|
||||
"type": "sphere",
|
||||
"label": "Сфера",
|
||||
"params": [
|
||||
{"key": "radius", "label": "Радиус", "type": "number", "default": 0.5, "min": 0.05, "max": 10, "step": 0.05},
|
||||
{"key": "count", "label": "Точек", "type": "number", "default": 2000, "min": 100, "max": 100000, "step": 100},
|
||||
{"key": "noise", "label": "Шум", "type": "number", "default": 0.02, "min": 0, "max": 0.5, "step": 0.005},
|
||||
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "object",
|
||||
"type": "box",
|
||||
"label": "Параллелепипед",
|
||||
"params": [
|
||||
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 1.0, "min": 0.1, "max": 20, "step": 0.1},
|
||||
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 0.6, "min": 0.1, "max": 20, "step": 0.1},
|
||||
{"key": "sizeZ", "label": "Размер Z", "type": "number", "default": 0.4, "min": 0.1, "max": 20, "step": 0.1},
|
||||
{"key": "count", "label": "Точек", "type": "number", "default": 2000, "min": 100, "max": 100000, "step": 100},
|
||||
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
|
||||
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "object",
|
||||
"type": "torus",
|
||||
"label": "Тор",
|
||||
"params": [
|
||||
{"key": "majorR", "label": "Большой R", "type": "number", "default": 1.0, "min": 0.1, "max": 10, "step": 0.05},
|
||||
{"key": "minorR", "label": "Малый R", "type": "number", "default": 0.35, "min": 0.02, "max": 5, "step": 0.01},
|
||||
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
|
||||
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
|
||||
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "surface",
|
||||
"type": "ocean_floor",
|
||||
"label": "Дно океана",
|
||||
"params": [
|
||||
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
|
||||
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 3.0, "min": 0.5, "max": 50, "step": 0.1},
|
||||
{"key": "amplitude", "label": "Амплитуда", "type": "number", "default": 0.12, "min": 0, "max": 2, "step": 0.01},
|
||||
{"key": "frequency", "label": "Частота", "type": "number", "default": 2.2, "min": 0.1, "max": 20, "step": 0.1},
|
||||
{"key": "channel", "label": "Канал", "type": "number", "default": 0.08, "min": 0, "max": 1, "step": 0.01},
|
||||
{"key": "baseZ", "label": "База Z", "type": "number", "default": -0.45, "min": -20, "max": 20, "step": 0.05},
|
||||
{"key": "count", "label": "Точек", "type": "number", "default": 4000, "min": 100, "max": 100000, "step": 100},
|
||||
{"key": "noise", "label": "Шум", "type": "number", "default": 0.02, "min": 0, "max": 0.5, "step": 0.005},
|
||||
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "surface",
|
||||
"type": "wave",
|
||||
"label": "Волна",
|
||||
"params": [
|
||||
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 2.4, "min": 0.5, "max": 50, "step": 0.1},
|
||||
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 2.4, "min": 0.5, "max": 50, "step": 0.1},
|
||||
{"key": "amplitude", "label": "Амплитуда", "type": "number", "default": 0.35, "min": 0, "max": 5, "step": 0.05},
|
||||
{"key": "frequency", "label": "Частота", "type": "number", "default": 2.5, "min": 0.1, "max": 20, "step": 0.1},
|
||||
{"key": "count", "label": "Точек", "type": "number", "default": 3000, "min": 100, "max": 100000, "step": 100},
|
||||
{"key": "noise", "label": "Шум", "type": "number", "default": 0.015, "min": 0, "max": 0.5, "step": 0.005},
|
||||
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "surface",
|
||||
"type": "flat",
|
||||
"label": "Плоскость",
|
||||
"params": [
|
||||
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
|
||||
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
|
||||
{"key": "z", "label": "Высота Z", "type": "number", "default": -0.5, "min": -20, "max": 20, "step": 0.05},
|
||||
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
|
||||
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
|
||||
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_CATALOG_BY_KEY = {(item["kind"], item["type"]): item for item in LAYER_CATALOG}
|
||||
|
||||
LAYER_COLORS = {
|
||||
("object", "pipe"): "#f59e0b",
|
||||
("object", "sphere"): "#38bdf8",
|
||||
("object", "box"): "#a78bfa",
|
||||
("object", "torus"): "#34d399",
|
||||
("surface", "ocean_floor"): "#64748b",
|
||||
("surface", "wave"): "#94a3b8",
|
||||
("surface", "flat"): "#78716c",
|
||||
}
|
||||
|
||||
|
||||
def catalog_payload() -> dict[str, Any]:
|
||||
return {"layers": LAYER_CATALOG, "colors": {f"{k[0]}:{k[1]}": v for k, v in LAYER_COLORS.items()}}
|
||||
|
||||
|
||||
def default_params(kind: str, type_name: str) -> dict[str, Any]:
|
||||
entry = _CATALOG_BY_KEY.get((kind, type_name))
|
||||
if entry is None:
|
||||
raise ValueError(f"Unknown layer type: {kind}/{type_name}")
|
||||
return {p["key"]: p["default"] for p in entry["params"]}
|
||||
|
||||
|
||||
def merge_params(kind: str, type_name: str, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
merged = default_params(kind, type_name)
|
||||
if params:
|
||||
for key, value in params.items():
|
||||
if key in merged:
|
||||
merged[key] = value
|
||||
# Coerce numeric fields
|
||||
entry = _CATALOG_BY_KEY[(kind, type_name)]
|
||||
for p in entry["params"]:
|
||||
key = p["key"]
|
||||
if p["type"] == "number" and key in merged:
|
||||
try:
|
||||
merged[key] = float(merged[key])
|
||||
if key in ("count", "seed"):
|
||||
merged[key] = int(merged[key])
|
||||
except (TypeError, ValueError):
|
||||
merged[key] = p["default"]
|
||||
if p["type"] == "select" and key in merged:
|
||||
options = p.get("options") or []
|
||||
if merged[key] not in options:
|
||||
merged[key] = p["default"]
|
||||
return merged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _jitter(rng: random.Random, noise: float) -> float:
|
||||
if noise <= 0:
|
||||
return 0.0
|
||||
return rng.uniform(-noise, noise)
|
||||
|
||||
|
||||
def generate_pipe(params: dict[str, Any]) -> list[list[float]]:
|
||||
rng = random.Random(int(params["seed"]))
|
||||
count = max(1, int(params["count"]))
|
||||
length = float(params["length"])
|
||||
radius = float(params["radius"])
|
||||
noise = float(params["noise"])
|
||||
axis = params.get("axis", "y")
|
||||
points: list[list[float]] = []
|
||||
half = length * 0.5
|
||||
for _ in range(count):
|
||||
angle = rng.random() * 2.0 * math.pi
|
||||
t = rng.uniform(-half, half)
|
||||
radial = radius + _jitter(rng, noise)
|
||||
cx = radial * math.cos(angle)
|
||||
cy = radial * math.sin(angle)
|
||||
if axis == "x":
|
||||
points.append([t, cx, cy])
|
||||
elif axis == "z":
|
||||
points.append([cx, cy, t])
|
||||
else:
|
||||
points.append([cx, t, cy])
|
||||
return points
|
||||
|
||||
|
||||
def generate_sphere(params: dict[str, Any]) -> list[list[float]]:
|
||||
rng = random.Random(int(params["seed"]))
|
||||
count = max(1, int(params["count"]))
|
||||
radius = float(params["radius"])
|
||||
noise = float(params["noise"])
|
||||
points: list[list[float]] = []
|
||||
for _ in range(count):
|
||||
u = rng.uniform(-1.0, 1.0)
|
||||
theta = rng.random() * 2.0 * math.pi
|
||||
r = radius + _jitter(rng, noise)
|
||||
s = math.sqrt(max(0.0, 1.0 - u * u))
|
||||
points.append([r * s * math.cos(theta), r * s * math.sin(theta), r * u])
|
||||
return points
|
||||
|
||||
|
||||
def generate_box(params: dict[str, Any]) -> list[list[float]]:
|
||||
"""Sample points on the box surface."""
|
||||
rng = random.Random(int(params["seed"]))
|
||||
count = max(1, int(params["count"]))
|
||||
sx = float(params["sizeX"]) * 0.5
|
||||
sy = float(params["sizeY"]) * 0.5
|
||||
sz = float(params["sizeZ"]) * 0.5
|
||||
noise = float(params["noise"])
|
||||
faces = [
|
||||
("x", sx, sy, sz),
|
||||
("x", -sx, sy, sz),
|
||||
("y", sy, sx, sz),
|
||||
("y", -sy, sx, sz),
|
||||
("z", sz, sx, sy),
|
||||
("z", -sz, sx, sy),
|
||||
]
|
||||
areas = [abs(a[2]) * abs(a[3]) * 4.0 for a in faces]
|
||||
total = sum(areas) or 1.0
|
||||
points: list[list[float]] = []
|
||||
for _ in range(count):
|
||||
pick = rng.random() * total
|
||||
acc = 0.0
|
||||
face = faces[0]
|
||||
for f, area in zip(faces, areas):
|
||||
acc += area
|
||||
if pick <= acc:
|
||||
face = f
|
||||
break
|
||||
axis, fixed, u_max, v_max = face
|
||||
u = rng.uniform(-u_max, u_max)
|
||||
v = rng.uniform(-v_max, v_max)
|
||||
jx, jy, jz = _jitter(rng, noise), _jitter(rng, noise), _jitter(rng, noise)
|
||||
if axis == "x":
|
||||
points.append([fixed + jx, u + jy, v + jz])
|
||||
elif axis == "y":
|
||||
points.append([u + jx, fixed + jy, v + jz])
|
||||
else:
|
||||
points.append([u + jx, v + jy, fixed + jz])
|
||||
return points
|
||||
|
||||
|
||||
def generate_torus(params: dict[str, Any]) -> list[list[float]]:
|
||||
rng = random.Random(int(params["seed"]))
|
||||
count = max(1, int(params["count"]))
|
||||
major_r = float(params["majorR"])
|
||||
minor_r = float(params["minorR"])
|
||||
noise = float(params["noise"])
|
||||
points: list[list[float]] = []
|
||||
for _ in range(count):
|
||||
u = rng.random() * 2.0 * math.pi
|
||||
v = rng.random() * 2.0 * math.pi
|
||||
radial = minor_r + _jitter(rng, noise)
|
||||
x = (major_r + radial * math.cos(v)) * math.cos(u)
|
||||
y = (major_r + radial * math.cos(v)) * math.sin(u)
|
||||
z = radial * math.sin(v)
|
||||
points.append([x, y, z])
|
||||
return points
|
||||
|
||||
|
||||
def height_ocean_floor(x: float, y: float, params: dict[str, Any]) -> float:
|
||||
amplitude = float(params["amplitude"])
|
||||
frequency = float(params["frequency"])
|
||||
channel = float(params["channel"])
|
||||
base_z = float(params["baseZ"])
|
||||
waviness = amplitude * math.cos(frequency * y)
|
||||
channel_term = channel * x * x
|
||||
return base_z + channel_term + waviness
|
||||
|
||||
|
||||
def height_wave(x: float, y: float, params: dict[str, Any]) -> float:
|
||||
amplitude = float(params["amplitude"])
|
||||
frequency = float(params["frequency"])
|
||||
return amplitude * math.sin(frequency * x) * math.cos(frequency * y)
|
||||
|
||||
|
||||
def height_flat(_x: float, _y: float, params: dict[str, Any]) -> float:
|
||||
return float(params["z"])
|
||||
|
||||
|
||||
def surface_height_fn(type_name: str):
|
||||
if type_name == "ocean_floor":
|
||||
return height_ocean_floor
|
||||
if type_name == "wave":
|
||||
return height_wave
|
||||
if type_name == "flat":
|
||||
return height_flat
|
||||
raise ValueError(f"Unknown surface type: {type_name}")
|
||||
|
||||
|
||||
def generate_surface(type_name: str, params: dict[str, Any]) -> list[list[float]]:
|
||||
rng = random.Random(int(params["seed"]))
|
||||
count = max(1, int(params["count"]))
|
||||
size_x = float(params.get("sizeX", 2.0))
|
||||
size_y = float(params.get("sizeY", 2.0))
|
||||
noise = float(params["noise"])
|
||||
height_fn = surface_height_fn(type_name)
|
||||
half_x = size_x * 0.5
|
||||
half_y = size_y * 0.5
|
||||
points: list[list[float]] = []
|
||||
for _ in range(count):
|
||||
x = rng.uniform(-half_x, half_x)
|
||||
y = rng.uniform(-half_y, half_y)
|
||||
z = height_fn(x, y, params) + _jitter(rng, noise)
|
||||
points.append([x, y, z])
|
||||
return points
|
||||
|
||||
|
||||
_GENERATORS = {
|
||||
("object", "pipe"): generate_pipe,
|
||||
("object", "sphere"): generate_sphere,
|
||||
("object", "box"): generate_box,
|
||||
("object", "torus"): generate_torus,
|
||||
}
|
||||
|
||||
|
||||
def generate_layer(kind: str, type_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if (kind, type_name) not in _CATALOG_BY_KEY:
|
||||
raise ValueError(f"Unknown layer type: {kind}/{type_name}")
|
||||
merged = merge_params(kind, type_name, params)
|
||||
if kind == "surface":
|
||||
points = generate_surface(type_name, merged)
|
||||
else:
|
||||
points = _GENERATORS[(kind, type_name)](merged)
|
||||
entry = _CATALOG_BY_KEY[(kind, type_name)]
|
||||
return {
|
||||
"kind": kind,
|
||||
"type": type_name,
|
||||
"label": entry["label"],
|
||||
"params": merged,
|
||||
"pointCount": len(points),
|
||||
"points": points,
|
||||
"color": LAYER_COLORS.get((kind, type_name), "#7dd3fc"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transforms & intersections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_transform(transform: dict[str, Any] | None) -> dict[str, float]:
|
||||
t = transform or {}
|
||||
return {
|
||||
"x": float(t.get("x", 0.0) or 0.0),
|
||||
"y": float(t.get("y", 0.0) or 0.0),
|
||||
"z": float(t.get("z", 0.0) or 0.0),
|
||||
"rx": float(t.get("rx", 0.0) or 0.0),
|
||||
"ry": float(t.get("ry", 0.0) or 0.0),
|
||||
"rz": float(t.get("rz", 0.0) or 0.0),
|
||||
}
|
||||
|
||||
|
||||
def _rotate_xyz(x: float, y: float, z: float, rx: float, ry: float, rz: float) -> tuple[float, float, float]:
|
||||
"""Euler XYZ (same as Three.js Object3D.rotation default order)."""
|
||||
cx, sx = math.cos(rx), math.sin(rx)
|
||||
cy, sy = math.cos(ry), math.sin(ry)
|
||||
cz, sz = math.cos(rz), math.sin(rz)
|
||||
y, z = y * cx - z * sx, y * sx + z * cx
|
||||
x, z = x * cy + z * sy, -x * sy + z * cy
|
||||
x, y = x * cz - y * sz, x * sz + y * cz
|
||||
return x, y, z
|
||||
|
||||
|
||||
def _rotate_xyz_inverse(x: float, y: float, z: float, rx: float, ry: float, rz: float) -> tuple[float, float, float]:
|
||||
cx, sx = math.cos(rx), math.sin(rx)
|
||||
cy, sy = math.cos(ry), math.sin(ry)
|
||||
cz, sz = math.cos(rz), math.sin(rz)
|
||||
x, y = x * cz + y * sz, -x * sz + y * cz
|
||||
x, z = x * cy - z * sy, x * sy + z * cy
|
||||
y, z = y * cx + z * sx, -y * sx + z * cx
|
||||
return x, y, z
|
||||
|
||||
|
||||
def apply_transform(points: list[list[float]], transform: dict[str, Any] | None) -> list[list[float]]:
|
||||
t = normalize_transform(transform)
|
||||
out: list[list[float]] = []
|
||||
for p in points:
|
||||
x, y, z = _rotate_xyz(p[0], p[1], p[2], t["rx"], t["ry"], t["rz"])
|
||||
out.append([x + t["x"], y + t["y"], z + t["z"]])
|
||||
return out
|
||||
|
||||
|
||||
def _world_to_local(point: list[float], transform: dict[str, Any] | None) -> tuple[float, float, float]:
|
||||
t = normalize_transform(transform)
|
||||
x = point[0] - t["x"]
|
||||
y = point[1] - t["y"]
|
||||
z = point[2] - t["z"]
|
||||
return _rotate_xyz_inverse(x, y, z, t["rx"], t["ry"], t["rz"])
|
||||
|
||||
|
||||
def object_sdf(type_name: str, params: dict[str, Any], local: tuple[float, float, float]) -> float:
|
||||
"""Signed distance: negative = inside."""
|
||||
if type_name == "imported":
|
||||
# No analytic SDF for imported clouds — skip solid clipping.
|
||||
return 1.0
|
||||
x, y, z = local
|
||||
if type_name == "sphere":
|
||||
return math.sqrt(x * x + y * y + z * z) - float(params["radius"])
|
||||
if type_name == "pipe":
|
||||
radius = float(params["radius"])
|
||||
half = float(params["length"]) * 0.5
|
||||
axis = params.get("axis", "y")
|
||||
if axis == "x":
|
||||
radial = math.sqrt(y * y + z * z) - radius
|
||||
axial = abs(x) - half
|
||||
elif axis == "z":
|
||||
radial = math.sqrt(x * x + y * y) - radius
|
||||
axial = abs(z) - half
|
||||
else:
|
||||
radial = math.sqrt(x * x + z * z) - radius
|
||||
axial = abs(y) - half
|
||||
# Approximate solid cylinder: inside if radial < 0 and axial < 0
|
||||
outside = max(radial, axial)
|
||||
if radial < 0 and axial < 0:
|
||||
return max(radial, axial)
|
||||
if axial > 0 and radial < 0:
|
||||
return axial
|
||||
if radial > 0 and axial < 0:
|
||||
return radial
|
||||
return math.sqrt(max(radial, 0) ** 2 + max(axial, 0) ** 2) if outside > 0 else outside
|
||||
if type_name == "box":
|
||||
hx = float(params["sizeX"]) * 0.5
|
||||
hy = float(params["sizeY"]) * 0.5
|
||||
hz = float(params["sizeZ"]) * 0.5
|
||||
qx = abs(x) - hx
|
||||
qy = abs(y) - hy
|
||||
qz = abs(z) - hz
|
||||
outside = math.sqrt(max(qx, 0) ** 2 + max(qy, 0) ** 2 + max(qz, 0) ** 2)
|
||||
inside = min(max(qx, qy, qz), 0.0)
|
||||
return outside + inside
|
||||
if type_name == "torus":
|
||||
major_r = float(params["majorR"])
|
||||
minor_r = float(params["minorR"])
|
||||
q = math.sqrt(x * x + y * y) - major_r
|
||||
return math.sqrt(q * q + z * z) - minor_r
|
||||
return 1.0
|
||||
|
||||
|
||||
def point_below_surface(
|
||||
world_pt: list[float],
|
||||
surf_type: str,
|
||||
surf_params: dict[str, Any],
|
||||
surf_transform: dict[str, Any] | None,
|
||||
eps: float,
|
||||
) -> bool:
|
||||
"""True if world point is below the heightfield in the surface local frame."""
|
||||
lx, ly, lz = _world_to_local(world_pt, surf_transform)
|
||||
size_x = float(surf_params.get("sizeX", 1e9))
|
||||
size_y = float(surf_params.get("sizeY", 1e9))
|
||||
if abs(lx) > size_x * 0.5 + eps or abs(ly) > size_y * 0.5 + eps:
|
||||
return False
|
||||
h = surface_height_fn(surf_type)(lx, ly, surf_params)
|
||||
return lz < h + eps
|
||||
|
||||
|
||||
def resolve_intersections(
|
||||
layers: list[dict[str, Any]],
|
||||
*,
|
||||
eps: float = 0.01,
|
||||
clip_surface_inside_objects: bool = True,
|
||||
clip_objects_vs_objects: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return layers with points updated (local coords preserved via inverse transform)."""
|
||||
prepared: list[dict[str, Any]] = []
|
||||
for layer in layers:
|
||||
kind = layer["kind"]
|
||||
type_name = layer["type"]
|
||||
transform = normalize_transform(layer.get("transform"))
|
||||
local_points = layer.get("points")
|
||||
if type_name == "imported":
|
||||
params = dict(layer.get("params") or {})
|
||||
if not local_points:
|
||||
raise ValueError("Imported layer has no points.")
|
||||
label = layer.get("label") or "OBJ"
|
||||
color = layer.get("color") or "#f472b6"
|
||||
else:
|
||||
params = merge_params(kind, type_name, layer.get("params"))
|
||||
if not local_points:
|
||||
generated = generate_layer(kind, type_name, params)
|
||||
local_points = generated["points"]
|
||||
label = layer.get("label") or _CATALOG_BY_KEY[(kind, type_name)]["label"]
|
||||
color = layer.get("color") or LAYER_COLORS.get((kind, type_name), "#7dd3fc")
|
||||
world = apply_transform(local_points, transform)
|
||||
prepared.append({
|
||||
"id": layer.get("id"),
|
||||
"kind": kind,
|
||||
"type": type_name,
|
||||
"params": params,
|
||||
"transform": transform,
|
||||
"local_points": local_points,
|
||||
"world_points": world,
|
||||
"color": color,
|
||||
"label": label,
|
||||
})
|
||||
|
||||
surfaces = [p for p in prepared if p["kind"] == "surface"]
|
||||
objects = [p for p in prepared if p["kind"] == "object"]
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for layer in prepared:
|
||||
keep_local: list[list[float]] = []
|
||||
keep_world: list[list[float]] = []
|
||||
for local_pt, world_pt in zip(layer["local_points"], layer["world_points"]):
|
||||
drop = False
|
||||
|
||||
if layer["kind"] == "object":
|
||||
for surf in surfaces:
|
||||
if point_below_surface(
|
||||
world_pt, surf["type"], surf["params"], surf["transform"], eps
|
||||
):
|
||||
drop = True
|
||||
break
|
||||
if not drop and clip_objects_vs_objects:
|
||||
for other in objects:
|
||||
if other is layer:
|
||||
continue
|
||||
local_in_other = _world_to_local(world_pt, other["transform"])
|
||||
if object_sdf(other["type"], other["params"], local_in_other) < -eps:
|
||||
drop = True
|
||||
break
|
||||
|
||||
elif layer["kind"] == "surface" and clip_surface_inside_objects:
|
||||
for obj in objects:
|
||||
local_in_obj = _world_to_local(world_pt, obj["transform"])
|
||||
if object_sdf(obj["type"], obj["params"], local_in_obj) < -eps:
|
||||
drop = True
|
||||
break
|
||||
|
||||
if not drop:
|
||||
keep_local.append([local_pt[0], local_pt[1], local_pt[2]])
|
||||
keep_world.append(world_pt)
|
||||
|
||||
result.append({
|
||||
"id": layer["id"],
|
||||
"kind": layer["kind"],
|
||||
"type": layer["type"],
|
||||
"label": layer["label"],
|
||||
"params": layer["params"],
|
||||
"transform": layer["transform"],
|
||||
"color": layer["color"],
|
||||
"pointCount": len(keep_local),
|
||||
"points": keep_local,
|
||||
"removedCount": len(layer["local_points"]) - len(keep_local),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def merge_layers_world(layers: list[dict[str, Any]]) -> list[list[float]]:
|
||||
merged: list[list[float]] = []
|
||||
for layer in layers:
|
||||
kind = layer["kind"]
|
||||
type_name = layer["type"]
|
||||
transform = normalize_transform(layer.get("transform"))
|
||||
points = layer.get("points")
|
||||
if not points:
|
||||
if type_name == "imported":
|
||||
continue
|
||||
params = merge_params(kind, type_name, layer.get("params"))
|
||||
points = generate_layer(kind, type_name, params)["points"]
|
||||
merged.extend(apply_transform(points, transform))
|
||||
return merged
|
||||
|
||||
|
||||
def layer_semantic_class(layer: dict[str, Any]) -> float:
|
||||
"""Binary PointNet label: 1 = pipe, 0 = everything else."""
|
||||
type_name = str(layer.get("type") or "").lower()
|
||||
if type_name == "pipe":
|
||||
return 1.0
|
||||
# Optional name hint for renamed imported clouds
|
||||
name = str(layer.get("name") or layer.get("label") or "").lower()
|
||||
if "pipe" in name or "труб" in name:
|
||||
return 1.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def points_to_pointnet_rows(points: list[list[float]], class_label: float) -> list[list[float]]:
|
||||
"""XYZRGB+class rows; RGB forced to 0; float values (stored as float64 in .npy)."""
|
||||
c = float(class_label)
|
||||
rows: list[list[float]] = []
|
||||
for p in points:
|
||||
rows.append([float(p[0]), float(p[1]), float(p[2]), 0.0, 0.0, 0.0, c])
|
||||
return rows
|
||||
|
||||
|
||||
def layers_to_pointnet_rows(layers: list[dict[str, Any]]) -> list[list[float]]:
|
||||
rows: list[list[float]] = []
|
||||
for layer in layers:
|
||||
kind = layer.get("kind") or "object"
|
||||
type_name = layer.get("type") or "imported"
|
||||
transform = normalize_transform(layer.get("transform"))
|
||||
points = layer.get("points")
|
||||
if not points:
|
||||
if type_name == "imported":
|
||||
continue
|
||||
params = merge_params(kind, type_name, layer.get("params"))
|
||||
points = generate_layer(kind, type_name, params)["points"]
|
||||
world = apply_transform(points, transform)
|
||||
rows.extend(points_to_pointnet_rows(world, layer_semantic_class(layer)))
|
||||
return rows
|
||||
|
||||
|
||||
def export_npy_float64(rows: list[list[float]]) -> bytes:
|
||||
"""Write NumPy .npy v1.0 binary array shape (N, C) dtype float64 little-endian."""
|
||||
import struct
|
||||
|
||||
n = len(rows)
|
||||
cols = len(rows[0]) if n else 7
|
||||
if n and any(len(r) != cols for r in rows):
|
||||
raise ValueError("All rows must have the same length for .npy export.")
|
||||
|
||||
header = "{'descr': '<f8', 'fortran_order': False, 'shape': (%d, %d), }" % (n, cols)
|
||||
# Pad so magic(6)+ver(2)+hlen(2)+header is multiple of 64.
|
||||
preamble = 10
|
||||
pad = 64 - ((preamble + len(header) + 1) % 64)
|
||||
if pad == 64:
|
||||
pad = 0
|
||||
header_padded = (header + (" " * pad) + "\n").encode("latin1")
|
||||
|
||||
out = bytearray()
|
||||
out += b"\x93NUMPY"
|
||||
out += struct.pack("<BB", 1, 0)
|
||||
out += struct.pack("<H", len(header_padded))
|
||||
out += header_padded
|
||||
for row in rows:
|
||||
for value in row:
|
||||
out += struct.pack("<d", float(value))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def export_xyz(points: list[list[float]]) -> str:
|
||||
return "\n".join(f"{p[0]:.8f} {p[1]:.8f} {p[2]:.8f}" for p in points) + ("\n" if points else "")
|
||||
|
||||
|
||||
def export_ply(points: list[list[float]]) -> str:
|
||||
header = (
|
||||
"ply\n"
|
||||
"format ascii 1.0\n"
|
||||
f"element vertex {len(points)}\n"
|
||||
"property float x\n"
|
||||
"property float y\n"
|
||||
"property float z\n"
|
||||
"end_header\n"
|
||||
)
|
||||
body = "\n".join(f"{p[0]:.8f} {p[1]:.8f} {p[2]:.8f}" for p in points)
|
||||
return header + body + ("\n" if points else "")
|
||||
|
||||
|
||||
def export_obj(points: list[list[float]], object_name: str = "cloud") -> str:
|
||||
safe_name = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "cloud")) or "cloud"
|
||||
lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"]
|
||||
for p in points:
|
||||
lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def parse_obj_points(text: str) -> list[list[float]]:
|
||||
"""Extract vertex positions from Wavefront OBJ (ignores faces/materials)."""
|
||||
points: list[list[float]] = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.lower().startswith("v "):
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
try:
|
||||
points.append([float(parts[1]), float(parts[2]), float(parts[3])])
|
||||
except ValueError:
|
||||
continue
|
||||
return points
|
||||
+9
-8
@@ -1,10 +1,10 @@
|
||||
FROM node:20-bookworm-slim AS web-builder
|
||||
|
||||
WORKDIR /web
|
||||
COPY web-vue/package.json ./
|
||||
COPY frontend/web/package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY web-vue/ ./
|
||||
COPY frontend/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM ubuntu:24.04 AS builder
|
||||
@@ -25,13 +25,13 @@ WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p build && cd build \
|
||||
&& qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED' \
|
||||
&& qmake ../DotsToSurface.pro 'DEFINES+=PCL_ENABLED' \
|
||||
&& make -j"$(nproc)"
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV DOTSTOSIRFACE_BIN=/usr/local/bin/DotsToSirface
|
||||
ENV DOTSTOSURFACE_BIN=/usr/local/bin/DotsToSurface
|
||||
ENV PIPELINE_CONFIG=/app/docker/default_pipeline.json
|
||||
ENV USER_PRESETS_DIR=/app/data/user-presets
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
@@ -58,13 +58,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /src/build/DotsToSirface /usr/local/bin/DotsToSirface
|
||||
COPY --from=web-builder /web/dist /app/web-vue/dist
|
||||
COPY api /app/api
|
||||
COPY --from=builder /src/build/DotsToSurface /usr/local/bin/DotsToSurface
|
||||
COPY --from=web-builder /web/dist /app/frontend/web/dist
|
||||
COPY backend /app/backend
|
||||
COPY docker /app/docker
|
||||
COPY presets /app/presets
|
||||
COPY assets /app/assets
|
||||
|
||||
WORKDIR /app/api
|
||||
WORKDIR /app/backend
|
||||
RUN python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Docker
|
||||
|
||||
**Infra** — сборка и запуск Web-стека (Frontend + Backend + Engine).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Запуск **только в контейнере** (на хосте ставить Python/npm для работы приложения не нужно).
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up --build
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
Или из корня репозитория:
|
||||
|
||||
```bash
|
||||
./scripts/run-docker.sh # с логами в терминале
|
||||
./scripts/run-docker.sh -d # в фоне
|
||||
```
|
||||
|
||||
Если порт **8080** занят, в `docker-compose.yml` измените маппинг, например `"8081:8080"`.
|
||||
|
||||
Перед первым запуском должен быть запущен Docker Engine (`docker ps` без ошибки).
|
||||
|
||||
## Multi-stage сборка
|
||||
|
||||
| Stage | Результат |
|
||||
|-------|-----------|
|
||||
| `web-builder` | `frontend/web/dist` |
|
||||
| `builder` | `DotsToSurface` binary |
|
||||
| runtime | API + binary + dist |
|
||||
|
||||
## Runtime-контейнер
|
||||
|
||||
| Путь | Содержимое |
|
||||
|------|------------|
|
||||
| `/usr/local/bin/DotsToSurface` | C++ Engine (CLI) |
|
||||
| `/app/frontend/web/dist` | Vue frontend |
|
||||
| `/app/backend` | FastAPI |
|
||||
|
||||
**CMD:** `uvicorn main:app --host 0.0.0.0 --port 8080` (WORKDIR `/app/backend`)
|
||||
|
||||
См. также: [../ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||
@@ -1,17 +1,17 @@
|
||||
services:
|
||||
dotstosirface-web:
|
||||
dottosurface-web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DOTSTOSIRFACE_BIN: /usr/local/bin/DotsToSirface
|
||||
DOTSTOSURFACE_BIN: /usr/local/bin/DotsToSurface
|
||||
PIPELINE_CONFIG: /app/docker/default_pipeline.json
|
||||
USER_PRESETS_DIR: /app/data/user-presets
|
||||
volumes:
|
||||
- ../presets:/app/presets:ro
|
||||
- dotstosirface-user-presets:/app/data/user-presets
|
||||
- dottosurface-user-presets:/app/data/user-presets
|
||||
|
||||
volumes:
|
||||
dotstosirface-user-presets:
|
||||
dottosurface-user-presets:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Frontend (Web)
|
||||
|
||||
**Слой:** Frontend — браузерный dashboard. Обработка точек — в C++ Engine через Backend.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run build # → dist/ (обязательно перед запуском Backend без Docker)
|
||||
```
|
||||
|
||||
API: [`src/api/client.js`](src/api/client.js). В dev Vite проксирует `/api` на `:8080` ([`vite.config.js`](vite.config.js)).
|
||||
|
||||
См. также: [../../ARCHITECTURE.md](../../ARCHITECTURE.md)
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DotsToSirface</title>
|
||||
<title>DotsToSurface</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "dotstosirface-web",
|
||||
"name": "dottosurface-web",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dotstosirface-web",
|
||||
"name": "dottosurface-web",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^11.3.0",
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "dotstosirface-web",
|
||||
"name": "dottosurface-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { api } from "@/api/client";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
const route = useRoute();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const healthText = ref("Checking API...");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const health = await api.health();
|
||||
healthText.value = health.binaryExists === "True" || health.binaryExists === true
|
||||
? "API online, pipeline binary ready"
|
||||
: "API online, binary missing";
|
||||
} catch (error) {
|
||||
healthText.value = `API error: ${error.message}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-root">
|
||||
<header class="header">
|
||||
<div class="header-brand">
|
||||
<h1>DotsToSurface</h1>
|
||||
<nav class="nav-tabs" aria-label="Разделы">
|
||||
<RouterLink
|
||||
class="nav-tab"
|
||||
:class="{ active: route.path === '/' }"
|
||||
to="/"
|
||||
>
|
||||
Пайплайн
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="nav-tab"
|
||||
:class="{ active: route.path.startsWith('/generator') }"
|
||||
to="/generator"
|
||||
>
|
||||
Генератор
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="nav-tab"
|
||||
:class="{ active: route.path.startsWith('/dataset') }"
|
||||
to="/dataset"
|
||||
>
|
||||
Генератор Датасета
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="theme-toggle" @click="toggleTheme">
|
||||
{{ theme === "light" ? "Тёмная тема" : "Светлая тема" }}
|
||||
</button>
|
||||
<div class="health">{{ healthText }}</div>
|
||||
</div>
|
||||
</header>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
background: var(--header-bg);
|
||||
gap: 16px;
|
||||
}
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.header h1 { margin: 0; font-size: 20px; white-space: nowrap; }
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-tab {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--muted-text);
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.nav-tab:hover {
|
||||
color: var(--control-text);
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.nav-tab.active {
|
||||
color: var(--control-text);
|
||||
border-color: var(--chain-selected-border);
|
||||
background: var(--chain-enabled-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
|
||||
.theme-toggle { font-size: 13px; padding: 6px 10px; }
|
||||
.health {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--health-bg);
|
||||
color: var(--health-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
const API_BASE = "";
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(formatApiError(payload.detail, `Request failed: ${path}`));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestBlob(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, options);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(formatApiError(payload.detail, `Request failed: ${path}`));
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get("Content-Disposition") || "";
|
||||
const utfMatch = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(disposition);
|
||||
const plainMatch = /filename="?([^";]+)"?/i.exec(disposition);
|
||||
let filename = "cloud.xyz";
|
||||
if (utfMatch?.[1]) {
|
||||
try {
|
||||
filename = decodeURIComponent(utfMatch[1]);
|
||||
} catch {
|
||||
filename = utfMatch[1];
|
||||
}
|
||||
} else if (plainMatch?.[1]) {
|
||||
filename = plainMatch[1];
|
||||
}
|
||||
return { blob, filename };
|
||||
}
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename || "cloud.xyz";
|
||||
link.rel = "noopener";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
}
|
||||
|
||||
function exportFilename(name, format) {
|
||||
const allowed = new Set(["xyz", "ply", "obj", "npy"]);
|
||||
const ext = allowed.has(format) ? format : "xyz";
|
||||
const base = String(name || "cloud")
|
||||
.trim()
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^\w.\-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.toLowerCase() || "cloud";
|
||||
return base.toLowerCase().endsWith(`.${ext}`) ? base : `${base}.${ext}`;
|
||||
}
|
||||
|
||||
function formatApiError(detail, fallback) {
|
||||
if (!detail) return fallback;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((item) => item.msg || JSON.stringify(item)).join("; ");
|
||||
}
|
||||
return String(detail);
|
||||
}
|
||||
|
||||
function pointsToXyz(points) {
|
||||
return points.map((p) => `${p[0]} ${p[1]} ${p[2]}`).join("\n") + (points.length ? "\n" : "");
|
||||
}
|
||||
|
||||
function pointsToPly(points) {
|
||||
const header = [
|
||||
"ply",
|
||||
"format ascii 1.0",
|
||||
`element vertex ${points.length}`,
|
||||
"property float x",
|
||||
"property float y",
|
||||
"property float z",
|
||||
"end_header",
|
||||
].join("\n");
|
||||
const body = points.map((p) => `${p[0]} ${p[1]} ${p[2]}`).join("\n");
|
||||
return `${header}\n${body}${points.length ? "\n" : ""}`;
|
||||
}
|
||||
|
||||
function pointsToObj(points, objectName = "cloud") {
|
||||
const safe = String(objectName || "cloud").replace(/[^\w\-]+/g, "_") || "cloud";
|
||||
const lines = [`# DotsToSurface point cloud (${points.length} vertices)`, `o ${safe}`];
|
||||
for (const p of points) {
|
||||
lines.push(`v ${p[0]} ${p[1]} ${p[2]}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function parseObjPoints(text) {
|
||||
const points = [];
|
||||
for (const raw of String(text || "").split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (!/^v\s/i.test(line)) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const x = Number(parts[1]);
|
||||
const y = Number(parts[2]);
|
||||
const z = Number(parts[3]);
|
||||
if (![x, y, z].every(Number.isFinite)) continue;
|
||||
points.push([x, y, z]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function parseXyzPoints(text) {
|
||||
const points = [];
|
||||
for (const raw of String(text || "").split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#") || line.startsWith("//")) continue;
|
||||
const parts = line.split(/[\s,;]+/).filter(Boolean);
|
||||
if (parts.length < 3) continue;
|
||||
const x = Number(parts[0]);
|
||||
const y = Number(parts[1]);
|
||||
const z = Number(parts[2]);
|
||||
if (![x, y, z].every(Number.isFinite)) continue;
|
||||
points.push([x, y, z]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function parsePlyPoints(text) {
|
||||
const lines = String(text || "").split(/\r?\n/);
|
||||
if (!lines.length || !/^ply\b/i.test(lines[0].trim())) {
|
||||
return parseXyzPoints(text);
|
||||
}
|
||||
let i = 1;
|
||||
let vertexCount = 0;
|
||||
let format = "ascii";
|
||||
const props = [];
|
||||
let inVertexElement = false;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.startsWith("format ")) {
|
||||
format = lower.split(/\s+/)[1] || "ascii";
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith("element vertex")) {
|
||||
inVertexElement = true;
|
||||
vertexCount = Number(line.split(/\s+/)[2]) || 0;
|
||||
props.length = 0;
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith("element ")) {
|
||||
inVertexElement = false;
|
||||
continue;
|
||||
}
|
||||
if (inVertexElement && lower.startsWith("property ")) {
|
||||
if (lower.includes("list")) continue;
|
||||
const tokens = line.split(/\s+/);
|
||||
props.push(tokens[tokens.length - 1].toLowerCase());
|
||||
continue;
|
||||
}
|
||||
if (/^end_header\b/i.test(lower)) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!format.startsWith("ascii")) {
|
||||
throw new Error("Поддерживается только ASCII PLY (не binary).");
|
||||
}
|
||||
const ix = props.indexOf("x");
|
||||
const iy = props.indexOf("y");
|
||||
const iz = props.indexOf("z");
|
||||
if (ix < 0 || iy < 0 || iz < 0) {
|
||||
// Some PLY dumps put only xyz numbers after header without named props tracked — try sequential.
|
||||
return parseXyzPoints(lines.slice(i).join("\n"));
|
||||
}
|
||||
const points = [];
|
||||
const limit = vertexCount > 0 ? Math.min(lines.length, i + vertexCount) : lines.length;
|
||||
for (; i < limit; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
const x = Number(parts[ix]);
|
||||
const y = Number(parts[iy]);
|
||||
const z = Number(parts[iz]);
|
||||
if (![x, y, z].every(Number.isFinite)) continue;
|
||||
points.push([x, y, z]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function detectCloudFormat(filename, text) {
|
||||
const ext = String(filename || "").toLowerCase().split(".").pop();
|
||||
if (ext === "obj") return "obj";
|
||||
if (ext === "ply") return "ply";
|
||||
if (ext === "xyz" || ext === "txt" || ext === "csv") return "xyz";
|
||||
const head = String(text || "").slice(0, 200).trim().toLowerCase();
|
||||
if (head.startsWith("ply")) return "ply";
|
||||
if (/(^|\n)\s*v\s+[-+]?\d/i.test(String(text || "").slice(0, 2000))) return "obj";
|
||||
return "xyz";
|
||||
}
|
||||
|
||||
function parseCloudPoints(text, filename = "") {
|
||||
const format = detectCloudFormat(filename, text);
|
||||
let points;
|
||||
if (format === "obj") points = parseObjPoints(text);
|
||||
else if (format === "ply") points = parsePlyPoints(text);
|
||||
else points = parseXyzPoints(text);
|
||||
return { format, points };
|
||||
}
|
||||
|
||||
function downloadPointsLocally(points, format, filename) {
|
||||
let text;
|
||||
if (format === "ply") text = pointsToPly(points);
|
||||
else if (format === "obj") text = pointsToObj(points, filename.replace(/\.\w+$/, ""));
|
||||
else text = pointsToXyz(points);
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
triggerDownload(blob, exportFilename(filename, format));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request("/api/health"),
|
||||
catalog: () => request("/api/catalog"),
|
||||
presets: () => request("/api/presets"),
|
||||
builtinPresets: () => request("/api/builtin-presets"),
|
||||
defaultConfig: () => request("/api/default-config"),
|
||||
stageDefaults: (stageId) => request(`/api/stage-defaults/${encodeURIComponent(stageId)}`),
|
||||
validateConfig: (config) =>
|
||||
request("/api/validate-config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
}),
|
||||
wizard: (wizardProfile, wizardGoal) =>
|
||||
request("/api/wizard", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wizardProfile, wizardGoal }),
|
||||
}),
|
||||
demoTypes: () => request("/api/demo/types"),
|
||||
demo: (surfaceType) => request(`/api/demo?surfaceType=${encodeURIComponent(surfaceType)}`),
|
||||
userPresets: () => request("/api/user-presets"),
|
||||
saveUserPreset: (preset) =>
|
||||
request("/api/user-presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(preset),
|
||||
}),
|
||||
runPipeline: ({ file, config, presetId, demoSurface, geometryFormat = "json" }) => {
|
||||
const formData = new FormData();
|
||||
if (file) formData.append("file", file);
|
||||
if (presetId) formData.append("preset_id", presetId);
|
||||
if (config) formData.append("config_json", JSON.stringify(config));
|
||||
if (demoSurface) formData.append("demo_surface", demoSurface);
|
||||
formData.append("geometry_format", geometryFormat);
|
||||
return request("/api/run", { method: "POST", body: formData });
|
||||
},
|
||||
fetchGeometry: async (workId) => {
|
||||
const response = await fetch(`/api/geometry/${workId}`);
|
||||
if (!response.ok) throw new Error("Failed to load geometry");
|
||||
return response.arrayBuffer();
|
||||
},
|
||||
generatorCatalog: () => request("/api/generator/catalog"),
|
||||
generatorLayer: (kind, type, params) =>
|
||||
request("/api/generator/layer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind, type, params }),
|
||||
}),
|
||||
generatorResolveIntersections: (payload) =>
|
||||
request("/api/generator/resolve-intersections", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
generatorExport: async ({ points, layers, format = "xyz", filename, classLabel }) => {
|
||||
const safeName = exportFilename(filename, format);
|
||||
// .npy is binary PointNet format — always via backend.
|
||||
if (format !== "npy" && Array.isArray(points) && points.length) {
|
||||
downloadPointsLocally(points, format, safeName);
|
||||
return;
|
||||
}
|
||||
const { blob, filename: suggested } = await requestBlob("/api/generator/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ points, layers, format, filename: safeName, classLabel }),
|
||||
});
|
||||
triggerDownload(blob, safeName || suggested);
|
||||
},
|
||||
datasetGenerate: ({
|
||||
count = 5,
|
||||
seed = 42,
|
||||
outputDir = "sonar_dataset",
|
||||
objectScale = 1,
|
||||
beamCount = 45,
|
||||
lengthCount = 45,
|
||||
modelFile,
|
||||
} = {}) => {
|
||||
if (!modelFile) {
|
||||
return Promise.reject(new Error("Выберите файл модели .obj"));
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("count", String(count));
|
||||
formData.append("seed", String(seed));
|
||||
formData.append("outputDir", outputDir || "sonar_dataset");
|
||||
formData.append("objectScale", String(objectScale ?? 1));
|
||||
formData.append("beamCount", String(beamCount ?? 45));
|
||||
formData.append("lengthCount", String(lengthCount ?? 45));
|
||||
formData.append("model", modelFile, modelFile.name || "model.obj");
|
||||
return request("/api/dataset/generate", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
datasetPreview: ({ stem, outputDir = "sonar_dataset", maxPoints = 25000 } = {}) =>
|
||||
request("/api/dataset/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ stem, outputDir, maxPoints }),
|
||||
}),
|
||||
};
|
||||
|
||||
export { exportFilename, parseObjPoints, parseXyzPoints, parsePlyPoints, parseCloudPoints };
|
||||
+13
-4
@@ -38,7 +38,7 @@ async function onGenerateDemo() {
|
||||
<button type="button" @click="onGenerateDemo" :disabled="store.busy">Сгенерировать</button>
|
||||
<label class="file-label">
|
||||
Загрузить
|
||||
<input type="file" accept=".ply,.txt,.csv,.xyz,.bin" @change="store.setCurrentFile($event.target.files?.[0] || null)" />
|
||||
<input type="file" accept=".ply,.txt,.csv,.xyz,.bin,.obj" @change="store.setCurrentFile($event.target.files?.[0] || null)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,12 @@ async function onGenerateDemo() {
|
||||
|
||||
<div class="toolbar">
|
||||
<label><input type="checkbox" v-model="store.surfaceVisible" /> Показать mesh</label>
|
||||
<button type="button" class="primary" :disabled="store.busy" @click="onRun">Применить</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ primary: store.pipelineNeedsApply, applied: !store.pipelineNeedsApply }"
|
||||
:disabled="store.busy"
|
||||
@click="onRun"
|
||||
>{{ store.applyButtonLabel }}</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
@@ -65,8 +70,8 @@ async function onGenerateDemo() {
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
<input v-model="snapshotName" placeholder="Snapshot name" />
|
||||
<button type="button" @click="store.saveSnapshot(snapshotName)">Save snapshot</button>
|
||||
<input v-model="snapshotName" placeholder="Имя конфигурации" />
|
||||
<button type="button" @click="store.saveSnapshot(snapshotName)">Сохр. конф.</button>
|
||||
<button
|
||||
v-for="snap in store.snapshots"
|
||||
:key="snap.name"
|
||||
@@ -94,4 +99,8 @@ async function onGenerateDemo() {
|
||||
.file-label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; }
|
||||
.status { color: var(--muted-text); font-size: 13px; white-space: pre-wrap; margin: 0; }
|
||||
.presets input { flex: 1; min-width: 120px; }
|
||||
button.applied {
|
||||
opacity: 0.85;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,461 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useGeneratorStore } from "@/stores/generator";
|
||||
import LayerSettingsForm from "@/components/generator/LayerSettingsForm.vue";
|
||||
|
||||
const store = useGeneratorStore();
|
||||
const {
|
||||
busy,
|
||||
statusText,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
exportFormat,
|
||||
objectTypes,
|
||||
surfaceTypes,
|
||||
historyEntries,
|
||||
historyIndex,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = storeToRefs(store);
|
||||
|
||||
const selected = computed(() => store.selectedLayer);
|
||||
const selectedSchema = computed(() => {
|
||||
if (!selected.value) return null;
|
||||
return store.schemaFor(selected.value.kind, selected.value.type);
|
||||
});
|
||||
|
||||
const pendingObjectType = ref("pipe");
|
||||
const pendingSurfaceType = ref("ocean_floor");
|
||||
|
||||
/** Primary workflow open by default; secondary panels collapsed. */
|
||||
const open = reactive({
|
||||
add: true,
|
||||
layers: true,
|
||||
settings: false,
|
||||
history: false,
|
||||
actions: true,
|
||||
});
|
||||
|
||||
function toggle(key) {
|
||||
open[key] = !open[key];
|
||||
}
|
||||
|
||||
function onObjectTypeChange(event) {
|
||||
pendingObjectType.value = event.target.value;
|
||||
}
|
||||
|
||||
function onSurfaceTypeChange(event) {
|
||||
pendingSurfaceType.value = event.target.value;
|
||||
}
|
||||
|
||||
function addObject() {
|
||||
const type = pendingObjectType.value || objectTypes.value[0]?.type;
|
||||
if (type) store.addLayer("object", type);
|
||||
}
|
||||
|
||||
function addSurface() {
|
||||
const type = pendingSurfaceType.value || surfaceTypes.value[0]?.type;
|
||||
if (type) store.addLayer("surface", type);
|
||||
}
|
||||
|
||||
function onImportCloud(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) store.importCloudFile(file);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
watch(
|
||||
objectTypes,
|
||||
(items) => {
|
||||
if (items.length && !items.find((item) => item.type === pendingObjectType.value)) {
|
||||
pendingObjectType.value = items[0].type;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
surfaceTypes,
|
||||
(items) => {
|
||||
if (items.length && !items.find((item) => item.type === pendingSurfaceType.value)) {
|
||||
pendingSurfaceType.value = items[0].type;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel generator-sidebar">
|
||||
<h2>Генератор сцены</h2>
|
||||
<p class="status">{{ statusText }}</p>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.add }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.add"
|
||||
@click="toggle('add')"
|
||||
>
|
||||
<span>Добавить слой</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.add ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.add" class="accordion-body">
|
||||
<div class="row">
|
||||
<select
|
||||
:value="pendingObjectType"
|
||||
:disabled="busy || !objectTypes.length"
|
||||
@change="onObjectTypeChange"
|
||||
>
|
||||
<option v-for="item in objectTypes" :key="item.type" :value="item.type">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" :disabled="busy || !objectTypes.length" @click="addObject">
|
||||
Объект
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<select
|
||||
:value="pendingSurfaceType"
|
||||
:disabled="busy || !surfaceTypes.length"
|
||||
@change="onSurfaceTypeChange"
|
||||
>
|
||||
<option v-for="item in surfaceTypes" :key="item.type" :value="item.type">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" :disabled="busy || !surfaceTypes.length" @click="addSurface">
|
||||
Поверхность
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.layers }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.layers"
|
||||
@click="toggle('layers')"
|
||||
>
|
||||
<span>Слои</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.layers ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.layers" class="accordion-body">
|
||||
<ul v-if="layers.length" class="layer-list">
|
||||
<li
|
||||
v-for="layer in layers"
|
||||
:key="layer.id"
|
||||
:class="{ selected: layer.id === selectedLayerId }"
|
||||
@click="store.selectLayer(layer.id)"
|
||||
>
|
||||
<span class="swatch" :style="{ background: layer.color }" />
|
||||
<div class="layer-meta">
|
||||
<strong>{{ layer.name }}</strong>
|
||||
<small>{{ layer.points?.length || 0 }} т. · {{ layer.kind }}</small>
|
||||
</div>
|
||||
<label class="vis" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="layer.visible !== false"
|
||||
@change="store.setLayerVisible(layer.id, $event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="ghost" :disabled="busy" @click.stop="store.removeLayer(layer.id)">
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">Пока нет слоёв</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.settings }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.settings"
|
||||
@click="toggle('settings')"
|
||||
>
|
||||
<span>Настройки генерации</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.settings ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.settings" class="accordion-body">
|
||||
<LayerSettingsForm
|
||||
:schema="selectedSchema"
|
||||
:params="selected?.params || {}"
|
||||
:disabled="busy || !selected"
|
||||
@update="store.updateSelectedParams($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="primary full"
|
||||
:disabled="busy || !selected"
|
||||
@click="store.regenerateSelected()"
|
||||
>
|
||||
Перегенерировать слой
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.history }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.history"
|
||||
@click="toggle('history')"
|
||||
>
|
||||
<span>История</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.history ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.history" class="accordion-body">
|
||||
<div class="history-toolbar">
|
||||
<button type="button" :disabled="busy || !canUndo" title="Ctrl+Z" @click="store.undo()">
|
||||
Назад
|
||||
</button>
|
||||
<button type="button" :disabled="busy || !canRedo" title="Ctrl+Shift+Z" @click="store.redo()">
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
<ul class="history-list" aria-label="История действий">
|
||||
<li
|
||||
v-for="(entry, index) in historyEntries"
|
||||
:key="entry.id"
|
||||
:class="{
|
||||
current: index === historyIndex,
|
||||
future: index > historyIndex,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="history-item"
|
||||
:disabled="busy"
|
||||
@click="store.jumpToHistory(index)"
|
||||
>
|
||||
{{ entry.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">Ctrl+Z · Ctrl+Shift+Z / Ctrl+Y</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.actions }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.actions"
|
||||
@click="toggle('actions')"
|
||||
>
|
||||
<span>Действия</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.actions ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.actions" class="accordion-body">
|
||||
<label class="format">
|
||||
Формат
|
||||
<select :value="exportFormat" :disabled="busy" @change="store.setExportFormat($event.target.value)">
|
||||
<option value="xyz">XYZ</option>
|
||||
<option value="ply">PLY</option>
|
||||
<option value="obj">OBJ</option>
|
||||
<option value="npy">NPY (PointNet)</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="exportFormat === 'npy'" class="muted">
|
||||
NPY float64 (N×7): x y z r g b class · RGB=0 · class 1=труба, 0=остальное · без нормализации
|
||||
</p>
|
||||
<label class="format import-obj">
|
||||
Загрузить облако
|
||||
<input
|
||||
type="file"
|
||||
accept=".obj,.ply,.xyz,.txt,.csv,model/obj,text/plain"
|
||||
:disabled="busy"
|
||||
@change="onImportCloud($event)"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" :disabled="busy || !layers.length" @click="store.resolveIntersections()">
|
||||
Удалить пересечения
|
||||
</button>
|
||||
<button type="button" :disabled="busy || !selected" @click="store.saveSelectedLayer()">
|
||||
Сохранить слой
|
||||
</button>
|
||||
<button type="button" :disabled="busy || !layers.length" @click="store.saveAllLayersSeparately()">
|
||||
Сохранить каждый слой
|
||||
</button>
|
||||
<button type="button" class="primary" :disabled="busy || !layers.length" @click="store.saveScene()">
|
||||
Сохранить сцену
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.generator-sidebar {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
h2 { margin: 0; font-size: 18px; }
|
||||
.status {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--summary-secondary);
|
||||
}
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
.accordion {
|
||||
gap: 0;
|
||||
}
|
||||
.accordion-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--label-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.accordion-head:hover {
|
||||
color: var(--control-text);
|
||||
}
|
||||
.chevron {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
line-height: 1;
|
||||
}
|
||||
.accordion-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.layer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.layer-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 12px 1fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--chain-idle-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--chain-enabled-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.layer-list li.selected {
|
||||
border-color: var(--chain-selected-border);
|
||||
}
|
||||
.swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.layer-meta {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.layer-meta strong {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.layer-meta small {
|
||||
color: var(--muted-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.vis input { margin: 0; }
|
||||
.ghost {
|
||||
padding: 2px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
.full { width: 100%; }
|
||||
.format {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.format select { width: auto; min-width: 90px; }
|
||||
.history-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--chain-enabled-bg);
|
||||
}
|
||||
.history-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--control-text);
|
||||
}
|
||||
.history-list li.current .history-item {
|
||||
background: var(--control-bg);
|
||||
border-left: 3px solid var(--chain-selected-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.history-list li.future .history-item {
|
||||
color: var(--muted-text);
|
||||
opacity: 0.65;
|
||||
}
|
||||
.history-item:hover:not(:disabled) {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.import-obj {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
.import-obj input[type="file"] {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.accordion-body > button { width: 100%; }
|
||||
.row button { width: auto; }
|
||||
.layer-list button { width: auto; }
|
||||
.history-toolbar button { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useGeneratorStore } from "@/stores/generator";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSceneCloudViewer } from "@/composables/useSceneCloudViewer";
|
||||
import TransformToolbar from "@/components/generator/TransformToolbar.vue";
|
||||
|
||||
const store = useGeneratorStore();
|
||||
const {
|
||||
layers,
|
||||
selectedLayerId,
|
||||
interactionMode,
|
||||
translateStep,
|
||||
rotateStepDeg,
|
||||
viewerRevision,
|
||||
busy,
|
||||
} = storeToRefs(store);
|
||||
const { theme } = useTheme();
|
||||
const containerRef = ref(null);
|
||||
|
||||
const viewer = useSceneCloudViewer(containerRef, {
|
||||
getLayers: () => store.layers,
|
||||
getSelectedId: () => store.selectedLayerId,
|
||||
getMode: () => store.interactionMode,
|
||||
onTransform: (id, transform) => {
|
||||
store.setLayerTransform(id, transform, { refresh: false });
|
||||
},
|
||||
onTransformGestureEnd: (label) => {
|
||||
store.endTransformGesture(label);
|
||||
},
|
||||
});
|
||||
|
||||
const hasSelection = computed(() => !!store.selectedLayerId);
|
||||
|
||||
function sync(fit = false) {
|
||||
viewer.syncLayers(store.layers, { fit });
|
||||
viewer.setMode(store.interactionMode);
|
||||
}
|
||||
|
||||
watch(viewerRevision, () => sync(false));
|
||||
watch(selectedLayerId, () => {
|
||||
viewer.setMode(store.interactionMode);
|
||||
sync(false);
|
||||
});
|
||||
watch(interactionMode, (mode) => viewer.setMode(mode));
|
||||
watch(theme, (value) => {
|
||||
viewer.setBackground(value === "light" ? 0xe2e8f0 : 0x0b1118);
|
||||
});
|
||||
|
||||
function onKeydown(event) {
|
||||
if (store.busy) return;
|
||||
const tag = event.target?.tagName;
|
||||
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z")) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) store.redo();
|
||||
else store.undo();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && (event.key === "y" || event.key === "Y")) {
|
||||
event.preventDefault();
|
||||
store.redo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!store.selectedLayer) return;
|
||||
|
||||
let handled = true;
|
||||
if (store.interactionMode === "rotate") {
|
||||
const rad = (store.rotateStepDeg * Math.PI) / 180;
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
store.nudgeSelectedRotation(0, 0, rad);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
store.nudgeSelectedRotation(0, 0, -rad);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
store.nudgeSelectedRotation(rad, 0, 0);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
store.nudgeSelectedRotation(-rad, 0, 0);
|
||||
break;
|
||||
case "q":
|
||||
case "Q":
|
||||
store.nudgeSelectedRotation(0, rad, 0);
|
||||
break;
|
||||
case "e":
|
||||
case "E":
|
||||
store.nudgeSelectedRotation(0, -rad, 0);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
} else {
|
||||
const step = store.translateStep;
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
store.nudgeSelected(-step, 0, 0);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
store.nudgeSelected(step, 0, 0);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
store.nudgeSelected(0, step, 0);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
store.nudgeSelected(0, -step, 0);
|
||||
break;
|
||||
case "PageUp":
|
||||
store.nudgeSelected(0, 0, step);
|
||||
break;
|
||||
case "PageDown":
|
||||
store.nudgeSelected(0, 0, -step);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
}
|
||||
if (handled) event.preventDefault();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
viewer.setBackground(theme.value === "light" ? 0xe2e8f0 : 0x0b1118);
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
sync(true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="viewer-panel panel">
|
||||
<TransformToolbar
|
||||
:mode="interactionMode"
|
||||
:step="translateStep"
|
||||
:rotate-step-deg="rotateStepDeg"
|
||||
:has-selection="hasSelection"
|
||||
@update:mode="store.setInteractionMode($event)"
|
||||
@update:step="store.setTranslateStep($event)"
|
||||
@update:rotate-step-deg="store.setRotateStepDeg($event)"
|
||||
/>
|
||||
<div class="viewport-wrap">
|
||||
<div ref="containerRef" class="viewport" tabindex="0" />
|
||||
<div v-if="busy" class="busy-overlay">Генерация…</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<button type="button" @click="viewer.resetCamera()">Сброс камеры</button>
|
||||
<span class="muted">Слоёв: {{ layers.length }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.viewer-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
}
|
||||
.viewport-wrap {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--viewer-bg);
|
||||
}
|
||||
.viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 360px;
|
||||
outline: none;
|
||||
}
|
||||
.busy-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--dialog-backdrop) 70%, transparent);
|
||||
color: var(--control-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
.footer button {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
schema: { type: Object, default: null },
|
||||
params: { type: Object, default: () => ({}) },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
|
||||
function onChange(key, value, fieldType) {
|
||||
let next = value;
|
||||
if (fieldType === "number") {
|
||||
next = value === "" ? 0 : Number(value);
|
||||
}
|
||||
emit("update", { [key]: next });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="schema" class="settings-form">
|
||||
<div v-for="field in schema.params" :key="field.key" class="field">
|
||||
<label :for="`gen-${field.key}`">{{ field.label }}</label>
|
||||
<select
|
||||
v-if="field.type === 'select'"
|
||||
:id="`gen-${field.key}`"
|
||||
:value="params[field.key]"
|
||||
:disabled="disabled"
|
||||
@change="onChange(field.key, $event.target.value, 'select')"
|
||||
>
|
||||
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
:id="`gen-${field.key}`"
|
||||
type="number"
|
||||
:value="params[field.key]"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
:disabled="disabled"
|
||||
@change="onChange(field.key, $event.target.value, 'number')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted">Выберите слой</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
color: var(--label-text);
|
||||
}
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: { type: String, default: "orbit" },
|
||||
step: { type: Number, default: 0.05 },
|
||||
rotateStepDeg: { type: Number, default: 5 },
|
||||
hasSelection: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:mode", "update:step", "update:rotateStepDeg"]);
|
||||
|
||||
const stepLabel = computed(() => (props.mode === "rotate" ? "Шаг °" : "Шаг"));
|
||||
const stepValue = computed(() => (props.mode === "rotate" ? props.rotateStepDeg : props.step));
|
||||
const hint = computed(() => {
|
||||
if (props.mode === "rotate") {
|
||||
return "Стрелки / Q·E — вращение. Мышью — gizmo вращения.";
|
||||
}
|
||||
if (props.mode === "translate") {
|
||||
return "Стрелки — XY, PageUp/PageDown — Z. Мышью — gizmo перемещения.";
|
||||
}
|
||||
return "Орбита камеры. Выберите слой и режим Перемещение / Вращение.";
|
||||
});
|
||||
|
||||
function onStepChange(event) {
|
||||
const value = Number(event.target.value);
|
||||
if (props.mode === "rotate") {
|
||||
emit("update:rotateStepDeg", value || 5);
|
||||
} else {
|
||||
emit("update:step", value || 0.05);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="modes">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'orbit' }"
|
||||
@click="emit('update:mode', 'orbit')"
|
||||
>
|
||||
Орбита
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'translate' }"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('update:mode', 'translate')"
|
||||
>
|
||||
Перемещение
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'rotate' }"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('update:mode', 'rotate')"
|
||||
>
|
||||
Вращение
|
||||
</button>
|
||||
</div>
|
||||
<label class="step">
|
||||
{{ stepLabel }}
|
||||
<input
|
||||
type="number"
|
||||
:min="mode === 'rotate' ? 0.5 : 0.001"
|
||||
:max="mode === 'rotate' ? 90 : 2"
|
||||
:step="mode === 'rotate' ? 0.5 : 0.01"
|
||||
:value="stepValue"
|
||||
@change="onStepChange"
|
||||
/>
|
||||
</label>
|
||||
<p class="hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.modes { display: flex; gap: 6px; }
|
||||
.modes button.active {
|
||||
border-color: var(--chain-selected-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--label-text);
|
||||
}
|
||||
.step input {
|
||||
width: 72px;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--hint-text);
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
</style>
|
||||
+10
-2
@@ -5,7 +5,7 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { usePointCloudViewer } from "@/composables/usePointCloudViewer";
|
||||
import ViewerModebar from "./ViewerModebar.vue";
|
||||
|
||||
const VIEWPORT_HEIGHT_KEY = "dotstosirface-viewport-height";
|
||||
const VIEWPORT_HEIGHT_KEY = "dottosurface-viewport-height";
|
||||
const MIN_VIEWPORT_HEIGHT = 240;
|
||||
const MAX_VIEWPORT_HEIGHT = Math.min(window.innerHeight - 80, 1200);
|
||||
|
||||
@@ -16,6 +16,7 @@ const isFullscreen = ref(false);
|
||||
const viewportHeight = ref(loadViewportHeight());
|
||||
const {
|
||||
renderGeometry,
|
||||
setSurfaceVisible,
|
||||
resize,
|
||||
setBackground,
|
||||
resetCamera,
|
||||
@@ -83,7 +84,7 @@ function startResize(event) {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [store.viewerPoints, store.viewerTriangles, store.surfaceVisible],
|
||||
() => [store.viewerPoints, store.viewerTriangles],
|
||||
() => {
|
||||
renderGeometry(store.viewerPoints, store.viewerTriangles, store.surfaceVisible);
|
||||
resize();
|
||||
@@ -91,6 +92,13 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.surfaceVisible,
|
||||
(visible) => {
|
||||
setSurfaceVisible(visible);
|
||||
},
|
||||
);
|
||||
|
||||
watch(theme, () => {
|
||||
setBackground(viewerBackground());
|
||||
}, { immediate: true });
|
||||
+96
-8
@@ -55,13 +55,26 @@ export function usePointCloudViewer(containerRef) {
|
||||
}
|
||||
}
|
||||
|
||||
function fitCamera(points) {
|
||||
if (!points?.length) return;
|
||||
function boundsRadius(points) {
|
||||
const box = new THREE.Box3();
|
||||
for (const p of points) box.expandByPoint(new THREE.Vector3(p[0], p[1], p[2]));
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1;
|
||||
return {
|
||||
box,
|
||||
radius: Math.max(size.x, size.y, size.z) * 0.6 || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function pointSizeForRadius(radius, pointCount) {
|
||||
// Visible both on dense clouds and small demos; scale with scene size.
|
||||
const densityFactor = Math.min(1.4, Math.max(0.55, 9000 / Math.max(pointCount, 1)));
|
||||
return Math.max(radius * 0.012 * densityFactor, 0.002);
|
||||
}
|
||||
|
||||
function fitCamera(points) {
|
||||
if (!points?.length) return;
|
||||
const { box, radius } = boundsRadius(points);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
controls.target.copy(center);
|
||||
camera.position.copy(center.clone().add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8)));
|
||||
camera.near = Math.max(radius / 1000, 0.0001);
|
||||
@@ -71,6 +84,12 @@ export function usePointCloudViewer(containerRef) {
|
||||
storeDefaultCameraState();
|
||||
}
|
||||
|
||||
function setSurfaceVisible(visible) {
|
||||
if (meshObject) {
|
||||
meshObject.visible = !!visible;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGeometry(points, triangleIndices, surfaceVisible = true) {
|
||||
clearObjects();
|
||||
lastPoints = points;
|
||||
@@ -83,15 +102,20 @@ export function usePointCloudViewer(containerRef) {
|
||||
positions[i * 3 + 2] = points[i][2];
|
||||
}
|
||||
|
||||
const { radius } = boundsRadius(points);
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
pointCloud = new THREE.Points(
|
||||
pointsGeometry,
|
||||
new THREE.PointsMaterial({ color: 0x7dd3fc, size: 0.01, sizeAttenuation: true }),
|
||||
new THREE.PointsMaterial({
|
||||
color: 0x7dd3fc,
|
||||
size: pointSizeForRadius(radius, points.length),
|
||||
sizeAttenuation: true,
|
||||
}),
|
||||
);
|
||||
scene.add(pointCloud);
|
||||
|
||||
if (surfaceVisible && triangleIndices?.length) {
|
||||
if (triangleIndices?.length) {
|
||||
const indices = new Uint32Array(triangleIndices.length * 3);
|
||||
for (let i = 0; i < triangleIndices.length; i += 1) {
|
||||
indices[i * 3] = triangleIndices[i][0];
|
||||
@@ -99,7 +123,7 @@ export function usePointCloudViewer(containerRef) {
|
||||
indices[i * 3 + 2] = triangleIndices[i][2];
|
||||
}
|
||||
const meshGeometry = new THREE.BufferGeometry();
|
||||
meshGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
meshGeometry.setAttribute("position", new THREE.BufferAttribute(positions.slice(), 3));
|
||||
meshGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
meshGeometry.computeVertexNormals();
|
||||
meshObject = new THREE.Mesh(
|
||||
@@ -111,11 +135,73 @@ export function usePointCloudViewer(containerRef) {
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
meshObject.visible = !!surfaceVisible;
|
||||
scene.add(meshObject);
|
||||
}
|
||||
fitCamera(points);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render labeled cloud rows: [x, y, z, class].
|
||||
* highlightClass: null = both classes colored; 0/1 = emphasize that class.
|
||||
*/
|
||||
function renderLabeledCloud(rows, { highlightClass = null, fit = true } = {}) {
|
||||
clearObjects();
|
||||
const points = (rows || []).map((r) => [r[0], r[1], r[2]]);
|
||||
lastPoints = points;
|
||||
if (!points.length) return;
|
||||
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
const colors = new Float32Array(points.length * 3);
|
||||
const hl =
|
||||
highlightClass === null || highlightClass === undefined || highlightClass === ""
|
||||
? null
|
||||
: Number(highlightClass);
|
||||
|
||||
const colorAll = {
|
||||
0: new THREE.Color(0x64748b),
|
||||
1: new THREE.Color(0xf59e0b),
|
||||
};
|
||||
const colorHi = {
|
||||
0: new THREE.Color(0x38bdf8),
|
||||
1: new THREE.Color(0xfbbf24),
|
||||
};
|
||||
const colorDim = new THREE.Color(0x1e293b);
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
positions[i * 3] = rows[i][0];
|
||||
positions[i * 3 + 1] = rows[i][1];
|
||||
positions[i * 3 + 2] = rows[i][2];
|
||||
const cls = Math.round(Number(rows[i][3] ?? 0));
|
||||
let c;
|
||||
if (hl === null || Number.isNaN(hl)) {
|
||||
c = colorAll[cls] || colorAll[0];
|
||||
} else if (cls === hl) {
|
||||
c = colorHi[cls] || colorHi[1];
|
||||
} else {
|
||||
c = colorDim;
|
||||
}
|
||||
colors[i * 3] = c.r;
|
||||
colors[i * 3 + 1] = c.g;
|
||||
colors[i * 3 + 2] = c.b;
|
||||
}
|
||||
|
||||
const { radius } = boundsRadius(points);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
pointCloud = new THREE.Points(
|
||||
geometry,
|
||||
new THREE.PointsMaterial({
|
||||
size: pointSizeForRadius(radius, points.length) * (hl === null ? 1 : 1.15),
|
||||
sizeAttenuation: true,
|
||||
vertexColors: true,
|
||||
}),
|
||||
);
|
||||
scene.add(pointCloud);
|
||||
if (fit) fitCamera(points);
|
||||
}
|
||||
|
||||
function resetCamera() {
|
||||
if (defaultCameraState && controls) {
|
||||
camera.position.copy(defaultCameraState.position);
|
||||
@@ -136,7 +222,7 @@ export function usePointCloudViewer(containerRef) {
|
||||
const dataUrl = renderer.domElement.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = `dotstosirface-view-${Date.now()}.png`;
|
||||
link.download = `dottosurface-view-${Date.now()}.png`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
@@ -168,6 +254,8 @@ export function usePointCloudViewer(containerRef) {
|
||||
|
||||
return {
|
||||
renderGeometry,
|
||||
renderLabeledCloud,
|
||||
setSurfaceVisible,
|
||||
resize,
|
||||
setBackground,
|
||||
resetCamera,
|
||||
@@ -0,0 +1,293 @@
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
||||
|
||||
function parseColor(hex, fallback = 0x7dd3fc) {
|
||||
if (!hex) return fallback;
|
||||
try {
|
||||
return new THREE.Color(hex).getHex();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function isGizmoMode(mode) {
|
||||
return mode === "translate" || mode === "rotate";
|
||||
}
|
||||
|
||||
function rotateXyz(x, y, z, rx, ry, rz) {
|
||||
const cx = Math.cos(rx);
|
||||
const sx = Math.sin(rx);
|
||||
const cy = Math.cos(ry);
|
||||
const sy = Math.sin(ry);
|
||||
const cz = Math.cos(rz);
|
||||
const sz = Math.sin(rz);
|
||||
let yy = y * cx - z * sx;
|
||||
let zz = y * sx + z * cx;
|
||||
let xx = x * cy + zz * sy;
|
||||
zz = -x * sy + zz * cy;
|
||||
const x2 = xx * cz - yy * sz;
|
||||
const y2 = xx * sz + yy * cz;
|
||||
return new THREE.Vector3(x2, y2, zz);
|
||||
}
|
||||
|
||||
export function useSceneCloudViewer(containerRef, options = {}) {
|
||||
const {
|
||||
getLayers = () => [],
|
||||
getSelectedId = () => null,
|
||||
getMode = () => "orbit",
|
||||
onTransform = () => {},
|
||||
onTransformGestureEnd = () => {},
|
||||
} = options;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0b1118);
|
||||
const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 100000);
|
||||
camera.position.set(3.2, 2.4, 3.2);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
renderer.domElement.style.display = "block";
|
||||
|
||||
let orbit = null;
|
||||
let transformControls = null;
|
||||
let animationId = 0;
|
||||
let layerGroup = new THREE.Group();
|
||||
scene.add(layerGroup);
|
||||
const pivots = new Map();
|
||||
let fittedOnce = false;
|
||||
|
||||
function resize() {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
const width = el.clientWidth;
|
||||
const height = el.clientHeight;
|
||||
camera.aspect = width / Math.max(height, 1);
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
orbit?.update();
|
||||
renderer.render(scene, camera);
|
||||
animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function clearLayers() {
|
||||
while (layerGroup.children.length) {
|
||||
const child = layerGroup.children[0];
|
||||
layerGroup.remove(child);
|
||||
child.traverse?.((obj) => {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) obj.material.dispose();
|
||||
});
|
||||
}
|
||||
pivots.clear();
|
||||
}
|
||||
|
||||
function pointSizeForCount(pointCount, radius) {
|
||||
const densityFactor = Math.min(1.4, Math.max(0.55, 9000 / Math.max(pointCount, 1)));
|
||||
return Math.max(radius * 0.012 * densityFactor, 0.004);
|
||||
}
|
||||
|
||||
function applyPivotTransform(pivot, transform) {
|
||||
pivot.position.set(
|
||||
transform?.x || 0,
|
||||
transform?.y || 0,
|
||||
transform?.z || 0,
|
||||
);
|
||||
pivot.rotation.set(
|
||||
transform?.rx || 0,
|
||||
transform?.ry || 0,
|
||||
transform?.rz || 0,
|
||||
"XYZ",
|
||||
);
|
||||
}
|
||||
|
||||
function sceneBounds(layers) {
|
||||
const box = new THREE.Box3();
|
||||
let any = false;
|
||||
for (const layer of layers) {
|
||||
if (!layer.points?.length || layer.visible === false) continue;
|
||||
const rx = layer.transform?.rx || 0;
|
||||
const ry = layer.transform?.ry || 0;
|
||||
const rz = layer.transform?.rz || 0;
|
||||
const tx = layer.transform?.x || 0;
|
||||
const ty = layer.transform?.y || 0;
|
||||
const tz = layer.transform?.z || 0;
|
||||
for (const p of layer.points) {
|
||||
const v = rotateXyz(p[0], p[1], p[2], rx, ry, rz);
|
||||
box.expandByPoint(v.add(new THREE.Vector3(tx, ty, tz)));
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? box : null;
|
||||
}
|
||||
|
||||
function fitCamera(layers) {
|
||||
const box = sceneBounds(layers);
|
||||
if (!box) return;
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1;
|
||||
orbit.target.copy(center);
|
||||
camera.position.copy(center.clone().add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8)));
|
||||
camera.near = Math.max(radius / 1000, 0.0001);
|
||||
camera.far = radius * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
orbit?.update();
|
||||
fittedOnce = true;
|
||||
}
|
||||
|
||||
function syncLayers(layers, { fit = false } = {}) {
|
||||
const selectedId = getSelectedId();
|
||||
const mode = getMode();
|
||||
if (transformControls) {
|
||||
transformControls.detach();
|
||||
}
|
||||
clearLayers();
|
||||
|
||||
const visible = layers.filter((layer) => layer.visible !== false && layer.points?.length);
|
||||
const box = sceneBounds(visible);
|
||||
const radius = box
|
||||
? Math.max(...box.getSize(new THREE.Vector3()).toArray()) * 0.6 || 1
|
||||
: 1;
|
||||
|
||||
for (const layer of visible) {
|
||||
const positions = new Float32Array(layer.points.length * 3);
|
||||
for (let i = 0; i < layer.points.length; i += 1) {
|
||||
positions[i * 3] = layer.points[i][0];
|
||||
positions[i * 3 + 1] = layer.points[i][1];
|
||||
positions[i * 3 + 2] = layer.points[i][2];
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const points = new THREE.Points(
|
||||
geometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: parseColor(layer.color),
|
||||
size: pointSizeForCount(layer.points.length, radius),
|
||||
sizeAttenuation: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const pivot = new THREE.Object3D();
|
||||
applyPivotTransform(pivot, layer.transform);
|
||||
pivot.userData.layerId = layer.id;
|
||||
pivot.add(points);
|
||||
layerGroup.add(pivot);
|
||||
pivots.set(layer.id, pivot);
|
||||
}
|
||||
|
||||
if (isGizmoMode(mode) && selectedId && pivots.has(selectedId) && transformControls) {
|
||||
transformControls.setMode(mode);
|
||||
transformControls.attach(pivots.get(selectedId));
|
||||
}
|
||||
|
||||
if ((fit || !fittedOnce) && visible.length) {
|
||||
fitCamera(visible);
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (!orbit || !transformControls) return;
|
||||
const gizmo = isGizmoMode(mode);
|
||||
transformControls.enabled = gizmo;
|
||||
if (gizmo) {
|
||||
transformControls.setMode(mode);
|
||||
}
|
||||
const helper = transformControls.getHelper?.();
|
||||
if (helper) helper.visible = gizmo;
|
||||
orbit.enabled = !gizmo;
|
||||
const selectedId = getSelectedId();
|
||||
if (gizmo && selectedId && pivots.has(selectedId)) {
|
||||
transformControls.attach(pivots.get(selectedId));
|
||||
} else {
|
||||
transformControls.detach();
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransforms(layers) {
|
||||
for (const layer of layers) {
|
||||
const pivot = pivots.get(layer.id);
|
||||
if (!pivot) continue;
|
||||
applyPivotTransform(pivot, layer.transform);
|
||||
}
|
||||
}
|
||||
|
||||
function setBackground(color) {
|
||||
scene.background = new THREE.Color(color);
|
||||
}
|
||||
|
||||
function resetCamera() {
|
||||
fitCamera(getLayers());
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
el.appendChild(renderer.domElement);
|
||||
|
||||
orbit = new OrbitControls(camera, renderer.domElement);
|
||||
orbit.enableDamping = true;
|
||||
|
||||
transformControls = new TransformControls(camera, renderer.domElement);
|
||||
transformControls.setMode("translate");
|
||||
transformControls.addEventListener("dragging-changed", (event) => {
|
||||
if (isGizmoMode(getMode())) {
|
||||
orbit.enabled = !event.value;
|
||||
if (!event.value) {
|
||||
onTransformGestureEnd?.(getMode() === "rotate" ? "Вращение" : "Перемещение");
|
||||
}
|
||||
} else {
|
||||
orbit.enabled = true;
|
||||
}
|
||||
});
|
||||
transformControls.addEventListener("objectChange", () => {
|
||||
const obj = transformControls.object;
|
||||
if (!obj?.userData?.layerId) return;
|
||||
onTransform(obj.userData.layerId, {
|
||||
x: obj.position.x,
|
||||
y: obj.position.y,
|
||||
z: obj.position.z,
|
||||
rx: obj.rotation.x,
|
||||
ry: obj.rotation.y,
|
||||
rz: obj.rotation.z,
|
||||
});
|
||||
});
|
||||
scene.add(transformControls.getHelper());
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.7));
|
||||
const light = new THREE.DirectionalLight(0xffffff, 0.85);
|
||||
light.position.set(4, 6, 3);
|
||||
scene.add(light);
|
||||
scene.add(new THREE.AxesHelper(1.2));
|
||||
|
||||
resize();
|
||||
animate();
|
||||
window.addEventListener("resize", resize);
|
||||
syncLayers(getLayers(), { fit: true });
|
||||
setMode(getMode());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener("resize", resize);
|
||||
if (transformControls) {
|
||||
transformControls.detach();
|
||||
transformControls.dispose();
|
||||
}
|
||||
clearLayers();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
return {
|
||||
syncLayers,
|
||||
applyTransforms,
|
||||
setMode,
|
||||
setBackground,
|
||||
resetCamera,
|
||||
resize,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const THEME_KEY = "dotstosirface-theme";
|
||||
const THEME_KEY = "dottosurface-theme";
|
||||
const theme = ref("dark");
|
||||
|
||||
function applyTheme(value) {
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { initTheme } from "./composables/useTheme";
|
||||
import "./styles/main.css";
|
||||
|
||||
@@ -8,4 +9,5 @@ initTheme();
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import PipelineView from "@/views/PipelineView.vue";
|
||||
import GeneratorView from "@/views/GeneratorView.vue";
|
||||
import DatasetView from "@/views/DatasetView.vue";
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", name: "pipeline", component: PipelineView },
|
||||
{ path: "/generator", name: "generator", component: GeneratorView },
|
||||
{ path: "/dataset", name: "dataset", component: DatasetView },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
export const useDatasetStore = defineStore("dataset", {
|
||||
state: () => ({
|
||||
busy: false,
|
||||
previewBusy: false,
|
||||
statusText: "Выберите .obj модель и нажмите «Сгенерировать».",
|
||||
count: 5,
|
||||
seed: 42,
|
||||
outputDir: "sonar_dataset",
|
||||
resolvedOutputDir: null,
|
||||
modelFile: null,
|
||||
modelFileName: "",
|
||||
objectScale: 1,
|
||||
beamCount: 45,
|
||||
lengthCount: 45,
|
||||
lastResult: null,
|
||||
selectedStem: null,
|
||||
previewPoints: [],
|
||||
previewStem: null,
|
||||
previewMeta: null,
|
||||
highlightClass: null,
|
||||
classLabels: { 0: "background", 1: "object" },
|
||||
classCounts: {},
|
||||
logLines: [],
|
||||
}),
|
||||
getters: {
|
||||
stats(state) {
|
||||
return state.lastResult?.stats || null;
|
||||
},
|
||||
written(state) {
|
||||
return state.lastResult?.written || [];
|
||||
},
|
||||
previewDir(state) {
|
||||
return state.resolvedOutputDir || state.outputDir || "sonar_dataset";
|
||||
},
|
||||
selectedScene(state) {
|
||||
const stem = state.selectedStem;
|
||||
if (!stem) return null;
|
||||
return (state.lastResult?.written || []).find((item) => item.stem === stem) || null;
|
||||
},
|
||||
canGenerate(state) {
|
||||
return !!state.modelFile && !state.busy;
|
||||
},
|
||||
classOptions(state) {
|
||||
const labels = state.classLabels || { 0: "background", 1: "object" };
|
||||
const counts = state.classCounts || {};
|
||||
const keys = Object.keys(labels).length
|
||||
? Object.keys(labels)
|
||||
: Object.keys(counts);
|
||||
return keys
|
||||
.map((k) => Number(k))
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => ({
|
||||
id,
|
||||
label: labels[String(id)] || labels[id] || `class ${id}`,
|
||||
count: counts[String(id)] ?? counts[id] ?? 0,
|
||||
}));
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
pushLog(line) {
|
||||
this.logLines.push(String(line));
|
||||
if (this.logLines.length > 200) {
|
||||
this.logLines = this.logLines.slice(-200);
|
||||
}
|
||||
},
|
||||
setHighlightClass(value) {
|
||||
if (value === null || value === undefined || value === "" || value === "all") {
|
||||
this.highlightClass = null;
|
||||
return;
|
||||
}
|
||||
this.highlightClass = Number(value);
|
||||
},
|
||||
applyPreviewPayload(result, writtenMeta = null) {
|
||||
this.previewStem = result.stem;
|
||||
this.previewPoints = result.points || [];
|
||||
this.classCounts = result.classCounts || {};
|
||||
if (result.classLabels) {
|
||||
this.classLabels = result.classLabels;
|
||||
}
|
||||
this.previewMeta = {
|
||||
pointCount: result.pointCount,
|
||||
previewCount: result.previewCount,
|
||||
visibility: writtenMeta?.visibility,
|
||||
hasObject: writtenMeta?.hasObject,
|
||||
objectPointCount: writtenMeta?.objectPointCount,
|
||||
classCounts: result.classCounts || {},
|
||||
};
|
||||
},
|
||||
setModelFile(file) {
|
||||
if (!file) {
|
||||
this.modelFile = null;
|
||||
this.modelFileName = "";
|
||||
this.statusText = "Выберите .obj модель и нажмите «Сгенерировать».";
|
||||
return;
|
||||
}
|
||||
const name = String(file.name || "");
|
||||
if (!name.toLowerCase().endsWith(".obj")) {
|
||||
this.modelFile = null;
|
||||
this.modelFileName = "";
|
||||
this.statusText = "Нужен файл формата .obj";
|
||||
return;
|
||||
}
|
||||
this.modelFile = file;
|
||||
this.modelFileName = name;
|
||||
this.statusText = `Модель: ${name}`;
|
||||
this.pushLog(`Выбрана модель: ${name}`);
|
||||
},
|
||||
async generate() {
|
||||
if (this.busy) return;
|
||||
if (!this.modelFile) {
|
||||
this.statusText = "Сначала выберите файл модели .obj";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = "Генерация датасета…";
|
||||
this.pushLog(
|
||||
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}, dir=${this.outputDir}, model=${this.modelFileName}`,
|
||||
);
|
||||
try {
|
||||
const result = await api.datasetGenerate({
|
||||
count: Number(this.count) || 5,
|
||||
seed: Number(this.seed) || 0,
|
||||
outputDir: String(this.outputDir || "sonar_dataset"),
|
||||
objectScale: Number(this.objectScale) || 1,
|
||||
beamCount: Number(this.beamCount) || 45,
|
||||
lengthCount: Number(this.lengthCount) || 45,
|
||||
modelFile: this.modelFile,
|
||||
});
|
||||
this.lastResult = result;
|
||||
this.resolvedOutputDir = result?.outputDir || null;
|
||||
const s = result?.stats || {};
|
||||
this.statusText = `Готово: ${result.count} сцен → ${result.outputDir}`;
|
||||
this.pushLog(
|
||||
`Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), scale=${result.objectScale ?? this.objectScale}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
|
||||
);
|
||||
this.pushLog(
|
||||
`Записано ${result.count} сцен. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
||||
);
|
||||
this.pushLog(
|
||||
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
|
||||
);
|
||||
for (const item of result.written || []) {
|
||||
this.pushLog(
|
||||
`${item.stem}: pts=${item.pointCount}, object=${item.objectPointCount}, ${item.visibility}`,
|
||||
);
|
||||
}
|
||||
|
||||
const initialStem =
|
||||
result?.preview?.stem ||
|
||||
result?.written?.find((item) => item.hasObject)?.stem ||
|
||||
result?.written?.[0]?.stem ||
|
||||
null;
|
||||
|
||||
if (result?.preview?.points?.length && result.preview.stem === initialStem) {
|
||||
this.selectedStem = initialStem;
|
||||
const meta = result.written?.find((w) => w.stem === initialStem);
|
||||
this.applyPreviewPayload(
|
||||
{
|
||||
stem: initialStem,
|
||||
points: result.preview.points,
|
||||
pointCount: result.preview.pointCount,
|
||||
previewCount: result.preview.points.length,
|
||||
classCounts: result.preview.classCounts || {},
|
||||
classLabels: result.preview.classLabels || result.classLabels,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
} else if (initialStem) {
|
||||
await this.selectScene(initialStem);
|
||||
} else {
|
||||
this.selectedStem = null;
|
||||
this.previewStem = null;
|
||||
this.previewPoints = [];
|
||||
this.previewMeta = null;
|
||||
this.classCounts = {};
|
||||
}
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка: ${error.message}`;
|
||||
this.pushLog(`Ошибка: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async selectScene(stem) {
|
||||
if (!stem || this.previewBusy) return;
|
||||
if (stem === this.previewStem && this.previewPoints?.length) {
|
||||
this.selectedStem = stem;
|
||||
return;
|
||||
}
|
||||
this.previewBusy = true;
|
||||
this.selectedStem = stem;
|
||||
this.statusText = `Загрузка превью: ${stem}…`;
|
||||
try {
|
||||
const result = await api.datasetPreview({
|
||||
stem,
|
||||
outputDir: String(this.previewDir),
|
||||
});
|
||||
const writtenMeta = this.written.find((item) => item.stem === result.stem);
|
||||
this.applyPreviewPayload(result, writtenMeta);
|
||||
this.statusText = `Превью: ${result.stem} (${result.pointCount} точек)`;
|
||||
this.pushLog(`Превью загружено: ${result.stem} (${result.pointCount} pts)`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка превью: ${error.message}`;
|
||||
this.pushLog(`Ошибка превью: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.previewBusy = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { api, exportFilename, parseCloudPoints } from "@/api/client";
|
||||
|
||||
let layerSeq = 1;
|
||||
const MAX_HISTORY = 80;
|
||||
const IMPORT_COLORS = {
|
||||
obj: "#f472b6",
|
||||
ply: "#38bdf8",
|
||||
xyz: "#a3e635",
|
||||
};
|
||||
|
||||
function defaultsFromSchema(schema) {
|
||||
const params = {};
|
||||
for (const field of schema?.params || []) {
|
||||
params[field.key] = field.default;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function normalizeTransform(transform) {
|
||||
return {
|
||||
x: Number(transform?.x) || 0,
|
||||
y: Number(transform?.y) || 0,
|
||||
z: Number(transform?.z) || 0,
|
||||
rx: Number(transform?.rx) || 0,
|
||||
ry: Number(transform?.ry) || 0,
|
||||
rz: Number(transform?.rz) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rotateXyz(x, y, z, rx, ry, rz) {
|
||||
const cx = Math.cos(rx);
|
||||
const sx = Math.sin(rx);
|
||||
const cy = Math.cos(ry);
|
||||
const sy = Math.sin(ry);
|
||||
const cz = Math.cos(rz);
|
||||
const sz = Math.sin(rz);
|
||||
let yy = y * cx - z * sx;
|
||||
let zz = y * sx + z * cx;
|
||||
let xx = x * cy + zz * sy;
|
||||
zz = -x * sy + zz * cy;
|
||||
const x2 = xx * cz - yy * sz;
|
||||
const y2 = xx * sz + yy * cz;
|
||||
return [x2, y2, zz];
|
||||
}
|
||||
|
||||
function applyTransform(points, transform) {
|
||||
const t = normalizeTransform(transform);
|
||||
return points.map((p) => {
|
||||
const [x, y, z] = rotateXyz(p[0], p[1], p[2], t.rx, t.ry, t.rz);
|
||||
return [x + t.x, y + t.y, z + t.z];
|
||||
});
|
||||
}
|
||||
|
||||
function cloneLayers(layers) {
|
||||
// Pinia gives reactive proxies; structuredClone cannot clone Proxies.
|
||||
return JSON.parse(JSON.stringify(layers || []));
|
||||
}
|
||||
|
||||
function makeSnapshot(label, layers, selectedLayerId) {
|
||||
return {
|
||||
id: `h-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
label: String(label || "Действие"),
|
||||
selectedLayerId,
|
||||
layers: cloneLayers(layers),
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export const useGeneratorStore = defineStore("generator", {
|
||||
state: () => ({
|
||||
busy: false,
|
||||
statusText: "Добавьте объект или поверхность.",
|
||||
catalog: [],
|
||||
colors: {},
|
||||
layers: [],
|
||||
selectedLayerId: null,
|
||||
interactionMode: "orbit",
|
||||
translateStep: 0.05,
|
||||
rotateStepDeg: 5,
|
||||
exportFormat: "xyz",
|
||||
viewerRevision: 0,
|
||||
history: [makeSnapshot("Начало", [], null)],
|
||||
historyIndex: 0,
|
||||
_restoring: false,
|
||||
}),
|
||||
getters: {
|
||||
selectedLayer(state) {
|
||||
return state.layers.find((layer) => layer.id === state.selectedLayerId) || null;
|
||||
},
|
||||
objectTypes(state) {
|
||||
return state.catalog.filter((item) => item.kind === "object");
|
||||
},
|
||||
surfaceTypes(state) {
|
||||
return state.catalog.filter((item) => item.kind === "surface");
|
||||
},
|
||||
schemaFor() {
|
||||
return (kind, type) => this.catalog.find((item) => item.kind === kind && item.type === type) || null;
|
||||
},
|
||||
visibleLayers(state) {
|
||||
return state.layers.filter((layer) => layer.visible !== false && layer.points?.length);
|
||||
},
|
||||
canUndo(state) {
|
||||
return state.historyIndex > 0;
|
||||
},
|
||||
canRedo(state) {
|
||||
return state.historyIndex < state.history.length - 1;
|
||||
},
|
||||
historyEntries(state) {
|
||||
return state.history;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async bootstrap() {
|
||||
const payload = await api.generatorCatalog();
|
||||
this.catalog = payload.layers || [];
|
||||
this.colors = payload.colors || {};
|
||||
this.statusText = "Каталог загружен. Добавьте слой.";
|
||||
},
|
||||
bumpViewer() {
|
||||
this.viewerRevision += 1;
|
||||
},
|
||||
restoreSnapshot(snapshot) {
|
||||
if (!snapshot) return;
|
||||
this._restoring = true;
|
||||
try {
|
||||
this.layers = cloneLayers(snapshot.layers);
|
||||
this.selectedLayerId = snapshot.selectedLayerId;
|
||||
this.bumpViewer();
|
||||
} catch (error) {
|
||||
console.error("restoreSnapshot failed", error);
|
||||
this.statusText = `Не удалось восстановить историю: ${error.message}`;
|
||||
} finally {
|
||||
this._restoring = false;
|
||||
}
|
||||
},
|
||||
/** Record current scene as a named history step (Photoshop-style). */
|
||||
recordHistory(label = "Действие") {
|
||||
if (this._restoring) return;
|
||||
try {
|
||||
const snapshot = makeSnapshot(label, this.layers, this.selectedLayerId);
|
||||
this.history = this.history.slice(0, this.historyIndex + 1);
|
||||
this.history.push(snapshot);
|
||||
if (this.history.length > MAX_HISTORY) {
|
||||
this.history.splice(0, this.history.length - MAX_HISTORY);
|
||||
}
|
||||
this.historyIndex = this.history.length - 1;
|
||||
} catch (error) {
|
||||
console.error("recordHistory failed", error);
|
||||
this.statusText = `История не записана: ${error.message}`;
|
||||
}
|
||||
},
|
||||
undo() {
|
||||
if (!this.canUndo) {
|
||||
this.statusText = "Нечего отменять.";
|
||||
return;
|
||||
}
|
||||
this.historyIndex -= 1;
|
||||
const snap = this.history[this.historyIndex];
|
||||
this.restoreSnapshot(snap);
|
||||
this.statusText = `История: ${snap.label}`;
|
||||
},
|
||||
redo() {
|
||||
if (!this.canRedo) {
|
||||
this.statusText = "Нечего повторить.";
|
||||
return;
|
||||
}
|
||||
this.historyIndex += 1;
|
||||
const snap = this.history[this.historyIndex];
|
||||
this.restoreSnapshot(snap);
|
||||
this.statusText = `История: ${snap.label}`;
|
||||
},
|
||||
jumpToHistory(index) {
|
||||
const i = Math.floor(Number(index));
|
||||
if (!Number.isFinite(i) || i < 0 || i >= this.history.length) return;
|
||||
if (i === this.historyIndex) return;
|
||||
this.historyIndex = i;
|
||||
const snap = this.history[i];
|
||||
this.restoreSnapshot(snap);
|
||||
this.statusText = `История: ${snap.label}`;
|
||||
},
|
||||
selectLayer(id) {
|
||||
this.selectedLayerId = id;
|
||||
},
|
||||
setInteractionMode(mode) {
|
||||
if (mode === "translate" || mode === "rotate") {
|
||||
this.interactionMode = mode;
|
||||
} else {
|
||||
this.interactionMode = "orbit";
|
||||
}
|
||||
},
|
||||
setTranslateStep(step) {
|
||||
const value = Number(step);
|
||||
this.translateStep = Number.isFinite(value) && value > 0 ? value : 0.05;
|
||||
},
|
||||
setRotateStepDeg(step) {
|
||||
const value = Number(step);
|
||||
this.rotateStepDeg = Number.isFinite(value) && value > 0 ? value : 5;
|
||||
},
|
||||
setExportFormat(format) {
|
||||
const allowed = new Set(["xyz", "ply", "obj", "npy"]);
|
||||
this.exportFormat = allowed.has(format) ? format : "xyz";
|
||||
},
|
||||
updateSelectedParams(partial) {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer || layer.type === "imported") return;
|
||||
layer.params = { ...layer.params, ...partial };
|
||||
},
|
||||
setLayerVisible(id, visible) {
|
||||
const layer = this.layers.find((item) => item.id === id);
|
||||
if (!layer) return;
|
||||
layer.visible = !!visible;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(visible ? "Показать слой" : "Скрыть слой");
|
||||
},
|
||||
setLayerTransform(id, transform, { refresh = true, recordHistory = false, historyLabel = "Положение слоя" } = {}) {
|
||||
const layer = this.layers.find((item) => item.id === id);
|
||||
if (!layer) return;
|
||||
layer.transform = normalizeTransform({
|
||||
...layer.transform,
|
||||
...transform,
|
||||
});
|
||||
if (refresh) this.bumpViewer();
|
||||
if (recordHistory) this.recordHistory(historyLabel);
|
||||
},
|
||||
endTransformGesture(label = "Перемещение") {
|
||||
this.recordHistory(label);
|
||||
},
|
||||
nudgeSelected(dx, dy, dz) {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer) return;
|
||||
const t = normalizeTransform(layer.transform);
|
||||
this.setLayerTransform(layer.id, {
|
||||
...t,
|
||||
x: t.x + dx,
|
||||
y: t.y + dy,
|
||||
z: t.z + dz,
|
||||
}, { refresh: true, recordHistory: true, historyLabel: "Перемещение" });
|
||||
},
|
||||
nudgeSelectedRotation(drx, dry, drz) {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer) return;
|
||||
const t = normalizeTransform(layer.transform);
|
||||
this.setLayerTransform(layer.id, {
|
||||
...t,
|
||||
rx: t.rx + drx,
|
||||
ry: t.ry + dry,
|
||||
rz: t.rz + drz,
|
||||
}, { refresh: true, recordHistory: true, historyLabel: "Вращение" });
|
||||
},
|
||||
removeLayer(id) {
|
||||
this.layers = this.layers.filter((layer) => layer.id !== id);
|
||||
if (this.selectedLayerId === id) {
|
||||
this.selectedLayerId = this.layers[0]?.id || null;
|
||||
}
|
||||
this.statusText = "Слой удалён.";
|
||||
this.bumpViewer();
|
||||
this.recordHistory("Удаление слоя");
|
||||
},
|
||||
async addLayer(kind, type) {
|
||||
const schema = this.schemaFor(kind, type);
|
||||
if (!schema) {
|
||||
this.statusText = "Неизвестный тип слоя.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = `Генерация: ${schema.label}…`;
|
||||
try {
|
||||
const params = defaultsFromSchema(schema);
|
||||
const result = await api.generatorLayer(kind, type, params);
|
||||
const id = `layer-${layerSeq++}`;
|
||||
const colorKey = `${kind}:${type}`;
|
||||
this.layers.push({
|
||||
id,
|
||||
name: `${schema.label} ${layerSeq - 1}`,
|
||||
kind,
|
||||
type,
|
||||
label: result.label || schema.label,
|
||||
params: result.params || params,
|
||||
transform: { x: 0, y: 0, z: kind === "object" ? 0.4 : 0, rx: 0, ry: 0, rz: 0 },
|
||||
points: result.points || [],
|
||||
visible: true,
|
||||
color: result.color || this.colors[colorKey] || "#7dd3fc",
|
||||
});
|
||||
this.selectedLayerId = id;
|
||||
this.statusText = `${schema.label}: ${result.pointCount} точек.`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(`Добавление: ${schema.label}`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка генерации: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async importCloudFile(file) {
|
||||
if (!file) return;
|
||||
this.busy = true;
|
||||
this.statusText = `Загрузка: ${file.name}…`;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const { format, points } = parseCloudPoints(text, file.name);
|
||||
if (!points.length) {
|
||||
this.statusText = `В файле нет точек (${format.toUpperCase()}).`;
|
||||
return;
|
||||
}
|
||||
const id = `layer-${layerSeq++}`;
|
||||
const stem = String(file.name || format).replace(/\.[^.]+$/, "") || format;
|
||||
this.layers.push({
|
||||
id,
|
||||
name: stem,
|
||||
kind: "object",
|
||||
type: "imported",
|
||||
label: format.toUpperCase(),
|
||||
params: { count: points.length, seed: 0, noise: 0, sourceFormat: format },
|
||||
transform: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 },
|
||||
points,
|
||||
visible: true,
|
||||
color: IMPORT_COLORS[format] || "#f472b6",
|
||||
});
|
||||
this.selectedLayerId = id;
|
||||
this.statusText = `${format.toUpperCase()} загружен: ${points.length} точек.`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(`Импорт ${format.toUpperCase()}`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка загрузки: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async importObjFile(file) {
|
||||
return this.importCloudFile(file);
|
||||
},
|
||||
async regenerateSelected() {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer) return;
|
||||
if (layer.type === "imported") {
|
||||
this.statusText = "Импортированный слой нельзя перегенерировать — только сдвинуть/сохранить.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = `Перегенерация: ${layer.name}…`;
|
||||
try {
|
||||
const result = await api.generatorLayer(layer.kind, layer.type, layer.params);
|
||||
layer.params = result.params || layer.params;
|
||||
layer.points = result.points || [];
|
||||
layer.color = result.color || layer.color;
|
||||
this.statusText = `${layer.name}: ${result.pointCount} точек.`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(`Перегенерация: ${layer.name}`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async resolveIntersections() {
|
||||
if (!this.layers.length) {
|
||||
this.statusText = "Нет слоёв для обработки.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = "Удаление пересечений…";
|
||||
try {
|
||||
const payload = {
|
||||
layers: this.layers.map((layer) => ({
|
||||
id: layer.id,
|
||||
kind: layer.kind,
|
||||
type: layer.type,
|
||||
params: layer.params,
|
||||
transform: layer.transform,
|
||||
points: layer.points,
|
||||
label: layer.label,
|
||||
color: layer.color,
|
||||
})),
|
||||
clipSurfaceInsideObjects: true,
|
||||
clipObjectsVsObjects: true,
|
||||
};
|
||||
const result = await api.generatorResolveIntersections(payload);
|
||||
const byId = Object.fromEntries((result.layers || []).map((layer) => [layer.id, layer]));
|
||||
for (const layer of this.layers) {
|
||||
const updated = byId[layer.id];
|
||||
if (!updated) continue;
|
||||
layer.points = updated.points || [];
|
||||
layer.params = updated.params || layer.params;
|
||||
}
|
||||
this.statusText = `Пересечения удалены (снято точек: ${result.removedTotal || 0}).`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory("Удаление пересечений");
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка пересечений: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
layerExportPayload(layer) {
|
||||
return {
|
||||
id: layer.id,
|
||||
kind: layer.kind,
|
||||
type: layer.type,
|
||||
name: layer.name,
|
||||
label: layer.label,
|
||||
params: layer.params,
|
||||
transform: layer.transform,
|
||||
points: layer.points,
|
||||
color: layer.color,
|
||||
};
|
||||
},
|
||||
layerClassLabel(layer) {
|
||||
const type = String(layer?.type || "").toLowerCase();
|
||||
if (type === "pipe") return 1;
|
||||
const name = String(layer?.name || layer?.label || "").toLowerCase();
|
||||
if (name.includes("pipe") || name.includes("труб")) return 1;
|
||||
return 0;
|
||||
},
|
||||
async saveSelectedLayer() {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer?.points?.length) {
|
||||
this.statusText = "Выберите слой с точками.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
const filename = exportFilename(
|
||||
`${layer.type || "layer"}_${String(layer.id || "").replace(/^layer-/, "") || "cloud"}`,
|
||||
this.exportFormat,
|
||||
);
|
||||
if (this.exportFormat === "npy") {
|
||||
await api.generatorExport({
|
||||
layers: [this.layerExportPayload(layer)],
|
||||
format: "npy",
|
||||
filename,
|
||||
});
|
||||
} else {
|
||||
const worldPoints = applyTransform(layer.points, layer.transform);
|
||||
await api.generatorExport({
|
||||
points: worldPoints,
|
||||
format: this.exportFormat,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
this.statusText = `Скачан файл: ${filename}`;
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка сохранения: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async saveScene() {
|
||||
const layers = this.visibleLayers;
|
||||
if (!layers.length) {
|
||||
this.statusText = "Нет видимых слоёв.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
const filename = exportFilename("scene_cloud", this.exportFormat);
|
||||
if (this.exportFormat === "npy") {
|
||||
await api.generatorExport({
|
||||
layers: layers.map((layer) => this.layerExportPayload(layer)),
|
||||
format: "npy",
|
||||
filename,
|
||||
});
|
||||
} else {
|
||||
const merged = [];
|
||||
for (const layer of layers) {
|
||||
merged.push(...applyTransform(layer.points, layer.transform));
|
||||
}
|
||||
await api.generatorExport({
|
||||
points: merged,
|
||||
format: this.exportFormat,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
this.statusText = `Скачан файл: ${filename}`;
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка сохранения сцены: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async saveAllLayersSeparately() {
|
||||
const layers = this.layers.filter((layer) => layer.points?.length);
|
||||
if (!layers.length) {
|
||||
this.statusText = "Нет слоёв для сохранения.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
for (const layer of layers) {
|
||||
const filename = exportFilename(
|
||||
`${layer.type || "layer"}_${String(layer.id || "").replace(/^layer-/, "") || "cloud"}`,
|
||||
this.exportFormat,
|
||||
);
|
||||
if (this.exportFormat === "npy") {
|
||||
await api.generatorExport({
|
||||
layers: [this.layerExportPayload(layer)],
|
||||
format: "npy",
|
||||
filename,
|
||||
});
|
||||
} else {
|
||||
const worldPoints = applyTransform(layer.points, layer.transform);
|
||||
await api.generatorExport({
|
||||
points: worldPoints,
|
||||
format: this.exportFormat,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.statusText = `Скачано файлов: ${layers.length}.`;
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка пакетного сохранения: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -63,6 +63,18 @@ function metaForStage(stageId, stageMetaById) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable signature of pipeline composition + stage parameters. */
|
||||
function pipelineFingerprint(stageCards) {
|
||||
return JSON.stringify(
|
||||
(stageCards || []).map((card) => ({
|
||||
id: card.id,
|
||||
family: card.family,
|
||||
enabled: !!card.enabled,
|
||||
defaults: card.defaults || "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export const usePipelineStore = defineStore("pipeline", {
|
||||
state: () => ({
|
||||
busy: false,
|
||||
@@ -97,6 +109,8 @@ export const usePipelineStore = defineStore("pipeline", {
|
||||
viewerTriangles: [],
|
||||
geometryUrl: null,
|
||||
stageMetaById: {},
|
||||
/** Fingerprint of stageCards after last successful apply/run; null = never applied. */
|
||||
appliedFingerprint: null,
|
||||
}),
|
||||
getters: {
|
||||
selectedStage(state) {
|
||||
@@ -112,6 +126,15 @@ export const usePipelineStore = defineStore("pipeline", {
|
||||
const recon = state.stageCards.find((c) => c.family === "reconstruction");
|
||||
return recon ? recon.id : "surface_fallback";
|
||||
},
|
||||
pipelineNeedsApply(state) {
|
||||
return state.appliedFingerprint !== pipelineFingerprint(state.stageCards);
|
||||
},
|
||||
applyButtonLabel(state) {
|
||||
return state.appliedFingerprint !== null
|
||||
&& state.appliedFingerprint === pipelineFingerprint(state.stageCards)
|
||||
? "Применено"
|
||||
: "Применить";
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async bootstrap() {
|
||||
@@ -267,6 +290,7 @@ export const usePipelineStore = defineStore("pipeline", {
|
||||
this.viewerPoints = result.points || [];
|
||||
this.viewerTriangles = result.triangleIndices || [];
|
||||
}
|
||||
this.appliedFingerprint = pipelineFingerprint(this.stageCards);
|
||||
this.statusText = result.stdout || "Pipeline completed.";
|
||||
} finally {
|
||||
this.busy = false;
|
||||
@@ -309,7 +333,7 @@ export const usePipelineStore = defineStore("pipeline", {
|
||||
this.statusText = file ? `Выбран файл: ${file.name}` : "Файл не выбран.";
|
||||
},
|
||||
saveSnapshot(name) {
|
||||
const slot = name || `snapshot-${this.snapshots.length + 1}`;
|
||||
const slot = name || `конф-${this.snapshots.length + 1}`;
|
||||
this.snapshots = [
|
||||
...this.snapshots.filter((s) => s.name !== slot),
|
||||
{
|
||||
@@ -318,13 +342,14 @@ export const usePipelineStore = defineStore("pipeline", {
|
||||
metrics: { ...this.metrics },
|
||||
},
|
||||
];
|
||||
this.statusText = `Конфигурация «${slot}» сохранена (до перезагрузки страницы).`;
|
||||
},
|
||||
loadSnapshot(name) {
|
||||
const snapshot = this.snapshots.find((s) => s.name === name);
|
||||
if (!snapshot) return;
|
||||
this.stageCards = JSON.parse(JSON.stringify(snapshot.stageCards));
|
||||
this.validateChain();
|
||||
this.statusText = `Snapshot '${name}' loaded.`;
|
||||
this.statusText = `Конфигурация «${name}» загружена.`;
|
||||
},
|
||||
async saveCurrentPreset(title) {
|
||||
const stages = this.stageCards.map((card) => ({
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useDatasetStore } from "@/stores/dataset";
|
||||
import { usePointCloudViewer } from "@/composables/usePointCloudViewer";
|
||||
|
||||
const store = useDatasetStore();
|
||||
const viewerRef = ref(null);
|
||||
const { renderLabeledCloud } = usePointCloudViewer(viewerRef);
|
||||
|
||||
const statsRows = computed(() => {
|
||||
const s = store.stats;
|
||||
if (!s) return [];
|
||||
return [
|
||||
["Всего сцен", s.total],
|
||||
["С объектом", s.withObject],
|
||||
["Без объекта", s.withoutObject],
|
||||
["Почти скрыт", s.nearly_hidden || 0],
|
||||
["Частично видим", s.partial || 0],
|
||||
["Хорошо различим", s.visible || 0],
|
||||
["Отсутствует", s.absent || 0],
|
||||
];
|
||||
});
|
||||
|
||||
const viewerCaption = computed(() => {
|
||||
if (!store.previewStem) return "";
|
||||
const meta = store.previewMeta;
|
||||
const parts = [`Превью: ${store.previewStem}`];
|
||||
if (meta?.visibility) parts.push(meta.visibility);
|
||||
if (meta?.pointCount != null) parts.push(`${meta.pointCount} pts`);
|
||||
if (store.highlightClass !== null && store.highlightClass !== undefined) {
|
||||
const opt = store.classOptions.find((c) => c.id === store.highlightClass);
|
||||
parts.push(`класс ${store.highlightClass}${opt ? ` (${opt.label})` : ""}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
});
|
||||
|
||||
function refreshViewer({ fit = true } = {}) {
|
||||
renderLabeledCloud(store.previewPoints || [], {
|
||||
highlightClass: store.highlightClass,
|
||||
fit,
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.previewPoints,
|
||||
() => {
|
||||
refreshViewer({ fit: true });
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.highlightClass,
|
||||
() => {
|
||||
if (store.previewPoints?.length) {
|
||||
refreshViewer({ fit: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (store.previewPoints?.length) {
|
||||
refreshViewer({ fit: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
await store.generate();
|
||||
} catch {
|
||||
/* status already set */
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectScene(stem) {
|
||||
try {
|
||||
await store.selectScene(stem);
|
||||
} catch {
|
||||
/* status already set */
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectChange(event) {
|
||||
const stem = event.target?.value;
|
||||
if (stem) await onSelectScene(stem);
|
||||
}
|
||||
|
||||
function onModelFileChange(event) {
|
||||
const file = event.target?.files?.[0] || null;
|
||||
store.setModelFile(file);
|
||||
}
|
||||
|
||||
function onHighlightClassChange(event) {
|
||||
store.setHighlightClass(event.target?.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="layout dataset-layout">
|
||||
<aside class="sidebar dataset-sidebar">
|
||||
<section class="panel">
|
||||
<h2>Генератор датасета</h2>
|
||||
<p class="hint">
|
||||
Синтетические сцены эхолота для PointNet (сегментация: фон / object).
|
||||
Целевой объект — вершины выбранной .obj модели (класс 1).
|
||||
Файлы: <code>Area_X_scene_XXXX.npy</code> + <code>.obj</code>.
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span>Модель объекта (.obj)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".obj,model/obj,text/plain"
|
||||
:disabled="store.busy"
|
||||
@change="onModelFileChange"
|
||||
/>
|
||||
<span class="ref-caption">
|
||||
{{ store.modelFileName || "Файл не выбран" }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Относительный масштаб объекта</span>
|
||||
<input
|
||||
v-model.number="store.objectScale"
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="0.05"
|
||||
:disabled="store.busy"
|
||||
/>
|
||||
<span class="ref-caption">1.0 = размер после нормализации mesh; >1 увеличивает объект</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Кол-во лучей</span>
|
||||
<input
|
||||
v-model.number="store.beamCount"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1024"
|
||||
step="1"
|
||||
:disabled="store.busy"
|
||||
/>
|
||||
<span class="ref-caption">
|
||||
Ширина рельефа (X): N лучей = N точек по ширине сетки дна.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Длина</span>
|
||||
<input
|
||||
v-model.number="store.lengthCount"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1024"
|
||||
step="1"
|
||||
:disabled="store.busy"
|
||||
/>
|
||||
<span class="ref-caption">
|
||||
Длина рельефа (Y): L = число точек по длине сетки дна.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Число сцен</span>
|
||||
<input v-model.number="store.count" type="number" min="1" max="5000" step="1" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Seed</span>
|
||||
<input v-model.number="store.seed" type="number" min="0" step="1" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Каталог</span>
|
||||
<input v-model="store.outputDir" type="text" />
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="!store.canGenerate"
|
||||
@click="onGenerate"
|
||||
>
|
||||
{{ store.busy ? "Генерация…" : "Сгенерировать" }}
|
||||
</button>
|
||||
|
||||
<p class="status">{{ store.statusText }}</p>
|
||||
|
||||
<div v-if="statsRows.length" class="stats">
|
||||
<h3>Статистика</h3>
|
||||
<ul>
|
||||
<li v-for="([label, value]) in statsRows" :key="label">
|
||||
<span>{{ label }}</span>
|
||||
<strong>{{ value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="store.written.length" class="scene-picker">
|
||||
<h3>Сцена для превью</h3>
|
||||
<label class="field">
|
||||
<span>Выбор сцены</span>
|
||||
<select
|
||||
:value="store.selectedStem || ''"
|
||||
:disabled="store.previewBusy || store.busy"
|
||||
@change="onSelectChange"
|
||||
>
|
||||
<option
|
||||
v-for="item in store.written"
|
||||
:key="item.stem"
|
||||
:value="item.stem"
|
||||
>
|
||||
{{ item.stem }}
|
||||
— {{ item.visibility }}
|
||||
({{ item.pointCount }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Подсветка класса</span>
|
||||
<select
|
||||
:value="store.highlightClass === null ? 'all' : String(store.highlightClass)"
|
||||
:disabled="!store.previewPoints.length || store.previewBusy"
|
||||
@change="onHighlightClassChange"
|
||||
>
|
||||
<option value="all">Все классы</option>
|
||||
<option
|
||||
v-for="opt in store.classOptions"
|
||||
:key="opt.id"
|
||||
:value="String(opt.id)"
|
||||
>
|
||||
{{ opt.id }} — {{ opt.label }} ({{ opt.count }})
|
||||
</option>
|
||||
</select>
|
||||
<span class="ref-caption class-legend">
|
||||
<span class="swatch bg" /> background (0)
|
||||
<span class="swatch obj" /> object (1)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<ul class="file-list">
|
||||
<li
|
||||
v-for="item in store.written"
|
||||
:key="item.stem"
|
||||
:class="{ active: item.stem === store.selectedStem }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="scene-btn"
|
||||
:disabled="store.previewBusy || store.busy"
|
||||
@click="onSelectScene(item.stem)"
|
||||
>
|
||||
<code>{{ item.stem }}</code>
|
||||
<span class="meta">
|
||||
{{ item.visibility }}
|
||||
· {{ item.pointCount }} pts
|
||||
<template v-if="item.hasObject">
|
||||
· object {{ item.objectPointCount }}
|
||||
</template>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="content-column dataset-content">
|
||||
<div class="viewer-wrap">
|
||||
<div ref="viewerRef" class="viewer-canvas" />
|
||||
<div v-if="viewerCaption" class="viewer-label">
|
||||
{{ viewerCaption }}
|
||||
<span v-if="store.previewBusy"> · загрузка…</span>
|
||||
</div>
|
||||
</div>
|
||||
<section class="log-panel">
|
||||
<h3>Лог</h3>
|
||||
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dataset-layout {
|
||||
align-items: stretch;
|
||||
}
|
||||
.dataset-sidebar {
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.panel {
|
||||
padding: 14px 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hint code {
|
||||
font-size: 11px;
|
||||
}
|
||||
.ref-caption {
|
||||
font-size: 11px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
.class-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.swatch.bg {
|
||||
background: #64748b;
|
||||
}
|
||||
.swatch.obj {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--header-border);
|
||||
background: var(--button-bg);
|
||||
color: var(--control-text);
|
||||
}
|
||||
.primary {
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.status {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
.stats h3,
|
||||
.scene-picker h3,
|
||||
.log-panel h3 {
|
||||
margin: 8px 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats ul,
|
||||
.file-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stats li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.scene-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-list {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.file-list li {
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
}
|
||||
.file-list li.active .scene-btn {
|
||||
background: var(--chain-enabled-bg);
|
||||
border-color: var(--chain-selected-border);
|
||||
}
|
||||
.scene-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scene-btn:hover:not(:disabled) {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.scene-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.file-list .meta {
|
||||
color: var(--muted-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.dataset-content {
|
||||
min-height: 0;
|
||||
height: calc(100vh - 76px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 12px 0;
|
||||
}
|
||||
.viewer-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
overflow: hidden;
|
||||
background: #0b1118;
|
||||
}
|
||||
.viewer-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.viewer-label {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.log-panel {
|
||||
flex: 0 0 140px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.log {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { useGeneratorStore } from "@/stores/generator";
|
||||
import GeneratorSidebar from "@/components/generator/GeneratorSidebar.vue";
|
||||
import GeneratorViewer from "@/components/generator/GeneratorViewer.vue";
|
||||
|
||||
const store = useGeneratorStore();
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.bootstrap();
|
||||
} catch (error) {
|
||||
store.statusText = `Ошибка каталога: ${error.message}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="layout generator-layout">
|
||||
<aside class="sidebar">
|
||||
<GeneratorSidebar />
|
||||
</aside>
|
||||
<div class="content-column generator-content">
|
||||
<GeneratorViewer />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.generator-layout {
|
||||
align-items: stretch;
|
||||
}
|
||||
.generator-content {
|
||||
min-height: 0;
|
||||
height: calc(100vh - 76px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import PipelineDashboard from "@/components/PipelineDashboard.vue";
|
||||
import ViewerPanel from "@/components/viewer/ViewerPanel.vue";
|
||||
import MetricsStrip from "@/components/pipeline/MetricsStrip.vue";
|
||||
import WizardPanel from "@/components/WizardPanel.vue";
|
||||
|
||||
const store = usePipelineStore();
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.stageCards.length) {
|
||||
try {
|
||||
await store.bootstrap();
|
||||
} catch {
|
||||
// health banner in App.vue already surfaces API errors
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="layout">
|
||||
<aside class="sidebar">
|
||||
<PipelineDashboard />
|
||||
<WizardPanel />
|
||||
</aside>
|
||||
<div class="content-column">
|
||||
<ViewerPanel />
|
||||
<MetricsStrip />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT/docker"
|
||||
exec docker compose up --build "$@"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Engine + Desktop UI
|
||||
|
||||
**Слой:** `src/engine/` — C++ движок; `src/desktop/` — Qt desktop frontend.
|
||||
|
||||
## Engine (`src/engine/`)
|
||||
|
||||
| Папка | Назначение |
|
||||
|-------|------------|
|
||||
| `core/` | PipelineExecutor, config, stats, plugin registry |
|
||||
| `strategies/` | Стадии: preprocess, reconstruction, registration |
|
||||
| `adapters/` | Источники (файл, PLY), PCL, ROS2 |
|
||||
| `algorithms/` | Базовые алгоритмы |
|
||||
| `cli/` | Headless `--cli` (используется Backend API) |
|
||||
| `factories/` | Сборка executor по профилю |
|
||||
| `tests/` | Smoke tests |
|
||||
|
||||
## Desktop UI (`src/desktop/`)
|
||||
|
||||
| Путь | Назначение |
|
||||
|------|------------|
|
||||
| `mainwindow.cpp`, `glview.cpp` | Qt widgets + OpenGL |
|
||||
| `qml/` | QML dashboard (паритет с `frontend/web/`) |
|
||||
|
||||
## Entry point
|
||||
|
||||
[`main.cpp`](main.cpp):
|
||||
|
||||
- без `--cli` → Qt GUI
|
||||
- с `--cli` → [`engine/cli/pipeline_cli_runner.cpp`](engine/cli/pipeline_cli_runner.cpp)
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
qmake ../DotsToSurface.pro 'DEFINES+=PCL_ENABLED'
|
||||
make -j$(nproc)
|
||||
./DotsToSurface
|
||||
```
|
||||
|
||||
См. также: [../ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||
@@ -8,7 +8,7 @@
|
||||
#include <QVector3D>
|
||||
#include <QVector>
|
||||
|
||||
#include "../core/point_cloud_types.h"
|
||||
#include "../engine/core/point_cloud_types.h"
|
||||
|
||||
class GlView : public QOpenGLWidget, protected QOpenGLFunctions
|
||||
{
|
||||
@@ -28,10 +28,10 @@
|
||||
#include <QWidget>
|
||||
#include <QtMath>
|
||||
|
||||
#include "../adapters/sources/file_point_cloud_source.h"
|
||||
#include "../core/pipeline_config_validation.h"
|
||||
#include "../core/pipeline_stage_defaults.h"
|
||||
#include "../factories/pipeline/desktop_pipeline_factory.h"
|
||||
#include "../engine/adapters/sources/file_point_cloud_source.h"
|
||||
#include "../engine/core/pipeline_config_validation.h"
|
||||
#include "../engine/core/pipeline_stage_defaults.h"
|
||||
#include "../engine/factories/pipeline/desktop_pipeline_factory.h"
|
||||
#include "glview.h"
|
||||
|
||||
namespace
|
||||
@@ -180,7 +180,7 @@ MainWindow::MainWindow(QWidget *parent)
|
||||
, m_busy(false)
|
||||
, m_busyDepth(0)
|
||||
{
|
||||
setWindowTitle("DotsToSirface - QML Dashboard");
|
||||
setWindowTitle("DotsToSurface - QML Dashboard");
|
||||
setCentralWidget(m_glView);
|
||||
|
||||
const QSettings settings;
|
||||
@@ -8,9 +8,9 @@
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
#include "../algorithms/reconstruction/surface_reconstruction.h"
|
||||
#include "../core/pipeline_config.h"
|
||||
#include "../core/pipeline_executor.h"
|
||||
#include "../engine/algorithms/reconstruction/surface_reconstruction.h"
|
||||
#include "../engine/core/pipeline_config.h"
|
||||
#include "../engine/core/pipeline_executor.h"
|
||||
|
||||
class GlView;
|
||||
class QQuickWidget;
|
||||
@@ -331,10 +331,10 @@ QJsonObject makePresetFromConfig(const core::PipelineConfig &config, const QStri
|
||||
int runCliPipeline(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
app.setApplicationName("DotsToSirface");
|
||||
app.setApplicationName("DotsToSurface");
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription("DotsToSirface CLI pipeline runner");
|
||||
parser.setApplicationDescription("DotsToSurface CLI pipeline runner");
|
||||
parser.addHelpOption();
|
||||
parser.addOption(QCommandLineOption("cli", "Run in CLI mode."));
|
||||
parser.addOption(QCommandLineOption(QStringList() << "i" << "input", "Input point cloud file path.", "path"));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user