diff --git a/.gitignore b/.gitignore index 2552316..13ca68d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2393805 --- /dev/null +++ b/ARCHITECTURE.md @@ -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) diff --git a/DotsToSirface.pro b/DotsToSirface.pro deleted file mode 100644 index 7973427..0000000 --- a/DotsToSirface.pro +++ /dev/null @@ -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 diff --git a/DotsToSirfaceTests.pro b/DotsToSirfaceTests.pro deleted file mode 100644 index b9de0f5..0000000 --- a/DotsToSirfaceTests.pro +++ /dev/null @@ -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 diff --git a/DotsToSurface.pro b/DotsToSurface.pro new file mode 100644 index 0000000..8713543 --- /dev/null +++ b/DotsToSurface.pro @@ -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 diff --git a/DotsToSurfaceTests.pro b/DotsToSurfaceTests.pro new file mode 100644 index 0000000..c5d946c --- /dev/null +++ b/DotsToSurfaceTests.pro @@ -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 diff --git a/README.md b/README.md index f6e04e8..7f3d053 100644 --- a/README.md +++ b/README.md @@ -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`; @@ -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 ``` diff --git a/assets/airplane_reference.png b/assets/airplane_reference.png new file mode 100644 index 0000000..19b3574 Binary files /dev/null and b/assets/airplane_reference.png differ diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..c2781b5 --- /dev/null +++ b/backend/README.md @@ -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) diff --git a/api/builtin_presets.py b/backend/builtin_presets.py similarity index 100% rename from api/builtin_presets.py rename to backend/builtin_presets.py diff --git a/backend/dataset_generator.py b/backend/dataset_generator.py new file mode 100644 index 0000000..4481d85 --- /dev/null +++ b/backend/dataset_generator.py @@ -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(" 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, + } diff --git a/api/demo_generator.py b/backend/demo_generator.py similarity index 100% rename from api/demo_generator.py rename to backend/demo_generator.py diff --git a/api/main.py b/backend/main.py similarity index 61% rename from api/main.py rename to backend/main.py index a5ba0ba..22fea00 100644 --- a/api/main.py +++ b/backend/main.py @@ -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") diff --git a/api/pipeline_insights.py b/backend/pipeline_insights.py similarity index 94% rename from api/pipeline_insights.py rename to backend/pipeline_insights.py index bdaff09..f01a392 100644 --- a/api/pipeline_insights.py +++ b/backend/pipeline_insights.py @@ -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, diff --git a/api/requirements.txt b/backend/requirements.txt similarity index 100% rename from api/requirements.txt rename to backend/requirements.txt diff --git a/backend/scene_generator.py b/backend/scene_generator.py new file mode 100644 index 0000000..78409fc --- /dev/null +++ b/backend/scene_generator.py @@ -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': ' 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 diff --git a/api/stage_meta.py b/backend/stage_meta.py similarity index 100% rename from api/stage_meta.py rename to backend/stage_meta.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 204063c..a05f694 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..db6292c --- /dev/null +++ b/docker/README.md @@ -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) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f802ab2..9808a00 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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: diff --git a/web-vue/.gitignore b/frontend/web/.gitignore similarity index 100% rename from web-vue/.gitignore rename to frontend/web/.gitignore diff --git a/frontend/web/README.md b/frontend/web/README.md new file mode 100644 index 0000000..42e0d18 --- /dev/null +++ b/frontend/web/README.md @@ -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) diff --git a/web-vue/index.html b/frontend/web/index.html similarity index 88% rename from web-vue/index.html rename to frontend/web/index.html index 77b8986..eb549a9 100644 --- a/web-vue/index.html +++ b/frontend/web/index.html @@ -3,7 +3,7 @@ - DotsToSirface + DotsToSurface
diff --git a/web-vue/package-lock.json b/frontend/web/package-lock.json similarity index 99% rename from web-vue/package-lock.json rename to frontend/web/package-lock.json index 796ffa2..fd09454 100644 --- a/web-vue/package-lock.json +++ b/frontend/web/package-lock.json @@ -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", diff --git a/web-vue/package.json b/frontend/web/package.json similarity index 93% rename from web-vue/package.json rename to frontend/web/package.json index 9d5969a..5bc1855 100644 --- a/web-vue/package.json +++ b/frontend/web/package.json @@ -1,5 +1,5 @@ { - "name": "dotstosirface-web", + "name": "dottosurface-web", "private": true, "version": "1.0.0", "type": "module", diff --git a/frontend/web/public/airplane_reference.png b/frontend/web/public/airplane_reference.png new file mode 100644 index 0000000..19b3574 Binary files /dev/null and b/frontend/web/public/airplane_reference.png differ diff --git a/frontend/web/src/App.vue b/frontend/web/src/App.vue new file mode 100644 index 0000000..711ec8f --- /dev/null +++ b/frontend/web/src/App.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/web/src/api/client.js b/frontend/web/src/api/client.js new file mode 100644 index 0000000..4581362 --- /dev/null +++ b/frontend/web/src/api/client.js @@ -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 }; diff --git a/web-vue/src/catalog/pipelineUiCatalog.js b/frontend/web/src/catalog/pipelineUiCatalog.js similarity index 100% rename from web-vue/src/catalog/pipelineUiCatalog.js rename to frontend/web/src/catalog/pipelineUiCatalog.js diff --git a/web-vue/src/catalog/theme.js b/frontend/web/src/catalog/theme.js similarity index 100% rename from web-vue/src/catalog/theme.js rename to frontend/web/src/catalog/theme.js diff --git a/web-vue/src/components/PipelineDashboard.vue b/frontend/web/src/components/PipelineDashboard.vue similarity index 85% rename from web-vue/src/components/PipelineDashboard.vue rename to frontend/web/src/components/PipelineDashboard.vue index 9a12f30..e5128b7 100644 --- a/web-vue/src/components/PipelineDashboard.vue +++ b/frontend/web/src/components/PipelineDashboard.vue @@ -38,7 +38,7 @@ async function onGenerateDemo() { @@ -46,7 +46,12 @@ async function onGenerateDemo() {
- +
@@ -65,8 +70,8 @@ async function onGenerateDemo() {
- - + + -

Upload a PLY file and click Run.

- - - - -
-

3D Viewer

-
-

Drag to rotate, wheel to zoom. Points and mesh are shown after pipeline run.

-
- - - - - - diff --git a/web/style.css b/web/style.css deleted file mode 100644 index 539dc62..0000000 --- a/web/style.css +++ /dev/null @@ -1,154 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: Inter, Segoe UI, Roboto, sans-serif; - background: #0f1720; - color: #e8eef5; -} - -.header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - padding: 20px 24px; - border-bottom: 1px solid #243447; - background: #152231; -} - -.header h1 { - margin: 0 0 4px; - font-size: 24px; -} - -.header p { - margin: 0; - color: #9db0c3; -} - -.health { - padding: 8px 12px; - border-radius: 8px; - background: #1f2f40; - font-size: 13px; -} - -.health.ok { - background: #173528; - color: #9be7b5; -} - -.health.error { - background: #3a1d24; - color: #ffb4c0; -} - -.layout { - display: grid; - grid-template-columns: 360px 1fr; - gap: 16px; - padding: 16px; - min-height: calc(100vh - 96px); -} - -.panel { - background: #152231; - border: 1px solid #243447; - border-radius: 12px; - padding: 16px; -} - -.panel h2 { - margin: 0 0 16px; - font-size: 18px; -} - -.field { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 14px; - font-size: 14px; -} - -.field input, -.field select, -button { - font: inherit; -} - -.field input, -.field select { - padding: 10px 12px; - border-radius: 8px; - border: 1px solid #31465d; - background: #0f1720; - color: inherit; -} - -button { - width: 100%; - padding: 12px 14px; - border: 0; - border-radius: 8px; - background: #3b82f6; - color: white; - cursor: pointer; - font-weight: 600; -} - -button:disabled { - opacity: 0.6; - cursor: wait; -} - -.status { - margin: 14px 0 0; - color: #9db0c3; - font-size: 14px; - line-height: 1.4; - white-space: pre-wrap; -} - -.metrics { - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid #243447; - display: grid; - gap: 8px; - font-size: 14px; -} - -.hidden { - display: none; -} - -.viewer { - display: flex; - flex-direction: column; - min-height: 70vh; -} - -#viewport { - flex: 1; - min-height: 520px; - border-radius: 10px; - overflow: hidden; - border: 1px solid #243447; - background: #0b1118; -} - -.hint { - margin: 12px 0 0; - color: #7f93a8; - font-size: 13px; -} - -@media (max-width: 960px) { - .layout { - grid-template-columns: 1fr; - } -}