Добавить Vue 3 web-dashboard с расширенным API и Docker-сборкой.
Полноценный браузерный UI (Pinia, Three.js) с паритетом Qt: редактор цепочки, wizard, пресеты, метрики и 3D viewer; API расширен для catalog/validate/demo/user-presets; CLI отдаёт step metrics в JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-2
@@ -27,9 +27,9 @@ qrc_*.cpp
|
|||||||
*.out
|
*.out
|
||||||
*.app
|
*.app
|
||||||
|
|
||||||
# Qt deployment folders
|
# Qt deployment folders (root only)
|
||||||
|
/styles/
|
||||||
platforms/
|
platforms/
|
||||||
styles/
|
|
||||||
imageformats/
|
imageformats/
|
||||||
iconengines/
|
iconengines/
|
||||||
translations/
|
translations/
|
||||||
@@ -39,6 +39,8 @@ bearer/
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# OS/editor files
|
# OS/editor files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -208,10 +208,13 @@ CLI печатает краткую сводку по метрикам (`input`,
|
|||||||
|
|
||||||
Для запуска пайплайна с PCL в изолированной среде (без конфликтов с `libpq` на хосте) добавлены:
|
Для запуска пайплайна с PCL в изолированной среде (без конфликтов с `libpq` на хосте) добавлены:
|
||||||
|
|
||||||
- `docker/Dockerfile` — сборка `DotsToSirface` с `PCL_ENABLED` и запуск FastAPI;
|
- `docker/Dockerfile` — сборка `DotsToSirface` с `PCL_ENABLED` + Vue 3 dashboard;
|
||||||
- `docker/docker-compose.yml`;
|
- `docker/docker-compose.yml`;
|
||||||
- `api/main.py` — HTTP API (`/api/run`, `/api/presets`);
|
- `api/main.py` — HTTP API (`/api/run`, `/api/presets`, `/api/validate-config`, …);
|
||||||
- `web/` — браузерный 3D viewer (Three.js).
|
- `web-vue/` — Vue 3 + Pinia + Three.js (полный dashboard, паритет с Qt);
|
||||||
|
- `web/` — legacy MVP (fallback, если Vue `dist` не собран).
|
||||||
|
|
||||||
|
**Qt desktop (`./DotsToSirface`) сохраняется** как локальный fallback без Docker.
|
||||||
|
|
||||||
### Быстрый старт
|
### Быстрый старт
|
||||||
|
|
||||||
@@ -222,21 +225,30 @@ docker compose up --build
|
|||||||
|
|
||||||
Откройте в браузере: [http://localhost:8080](http://localhost:8080)
|
Откройте в браузере: [http://localhost:8080](http://localhost:8080)
|
||||||
|
|
||||||
1. Загрузите `.ply`
|
### Локальная разработка frontend
|
||||||
2. Выберите preset (или оставьте default)
|
|
||||||
3. Нажмите **Run pipeline**
|
|
||||||
4. В viewer появятся точки и mesh, справа — метрики пайплайна
|
|
||||||
|
|
||||||
### API
|
```bash
|
||||||
|
cd web-vue
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
API проксируется на `http://localhost:8080` (см. `vite.config.js`).
|
||||||
|
|
||||||
|
### API (основное)
|
||||||
|
|
||||||
- `GET /api/health` — статус сервиса
|
- `GET /api/health` — статус сервиса
|
||||||
- `GET /api/presets` — список JSON-пресетов
|
- `GET /api/catalog` — метаданные стадий
|
||||||
- `POST /api/run` — multipart: `file` + опционально `preset_id`
|
- `GET /api/builtin-presets` — LiDAR / RGBD / Synthetic / Fast / Robust
|
||||||
|
- `GET /api/presets` — встроенные + файловые + user presets
|
||||||
|
- `POST /api/validate-config` — warnings / chainHealth без запуска
|
||||||
|
- `POST /api/wizard` — suggested chain
|
||||||
|
- `GET /api/demo` — демо-облако точек
|
||||||
|
- `POST /api/user-presets` — сохранение пользовательских пресетов
|
||||||
|
- `POST /api/run` — multipart: `file` или `demo_surface` + `config_json`
|
||||||
|
- `GET /api/geometry/{workId}` — бинарная геометрия для больших облаков
|
||||||
|
|
||||||
CLI внутри контейнера пишет в `--output-json` не только метрики, но и геометрию:
|
CLI `--output-json` включает `points`, `triangleIndices`, `preprocessStepMetrics`.
|
||||||
`points` (массив `[x,y,z]`) и `triangleIndices` (массив `[i0,i1,i2]`).
|
|
||||||
|
|
||||||
Конфиг пайплайна по умолчанию: `docker/default_pipeline.json`.
|
|
||||||
|
|
||||||
## Демо
|
## Демо
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Built-in pipeline presets (mirrors MainWindow::applyPreset)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
BUILTIN_PRESETS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"title": "LiDAR-скан",
|
||||||
|
"idValue": "LiDAR_scan",
|
||||||
|
"config": {
|
||||||
|
"profile": "desktop_debug",
|
||||||
|
"preprocessPlugins": [
|
||||||
|
"pcl_remove_nan",
|
||||||
|
"keep_largest_cluster",
|
||||||
|
"pcl_statistical_outlier",
|
||||||
|
"pcl_voxel_grid",
|
||||||
|
],
|
||||||
|
"reconstructionPlugin": "surface_fallback",
|
||||||
|
"stageDefaults": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "RGB-D камера",
|
||||||
|
"idValue": "RGBD_camera",
|
||||||
|
"config": {
|
||||||
|
"profile": "desktop_debug",
|
||||||
|
"preprocessPlugins": [
|
||||||
|
"pcl_remove_nan",
|
||||||
|
"pcl_statistical_outlier",
|
||||||
|
"pcl_radius_outlier",
|
||||||
|
"pcl_voxel_grid",
|
||||||
|
],
|
||||||
|
"reconstructionPlugin": "pcl_greedy_triangulation",
|
||||||
|
"stageDefaults": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Синтетика",
|
||||||
|
"idValue": "Synthetic_clean",
|
||||||
|
"config": {
|
||||||
|
"profile": "desktop_debug",
|
||||||
|
"preprocessPlugins": ["pcl_remove_nan", "downsample_dense", "pcl_voxel_grid"],
|
||||||
|
"reconstructionPlugin": "pcl_greedy_triangulation",
|
||||||
|
"stageDefaults": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fast",
|
||||||
|
"idValue": "Fast",
|
||||||
|
"config": {
|
||||||
|
"profile": "desktop_debug",
|
||||||
|
"preprocessPlugins": ["pcl_remove_nan", "pcl_voxel_grid"],
|
||||||
|
"reconstructionPlugin": "surface_fallback",
|
||||||
|
"stageDefaults": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Robust",
|
||||||
|
"idValue": "Robust",
|
||||||
|
"config": {
|
||||||
|
"profile": "desktop_debug",
|
||||||
|
"preprocessPlugins": [
|
||||||
|
"pcl_remove_nan",
|
||||||
|
"pcl_voxel_grid",
|
||||||
|
"pcl_statistical_outlier",
|
||||||
|
"pcl_radius_outlier",
|
||||||
|
],
|
||||||
|
"reconstructionPlugin": "surface_fallback",
|
||||||
|
"stageDefaults": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_builtin_preset(preset_id: str) -> dict[str, Any] | None:
|
||||||
|
for preset in BUILTIN_PRESETS:
|
||||||
|
if preset["idValue"] == preset_id:
|
||||||
|
return preset
|
||||||
|
return None
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Demo point cloud generation (mirrors generateDemoPoints in mainwindow.cpp)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def generate_demo_points(surface_type: str, count: int = 350) -> list[list[float]]:
|
||||||
|
points: list[list[float]] = []
|
||||||
|
rng = random.Random()
|
||||||
|
|
||||||
|
for _ in range(count):
|
||||||
|
if surface_type == "Дно реки + труба":
|
||||||
|
if rng.random() < 0.35:
|
||||||
|
angle = rng.random() * 2.0 * math.pi
|
||||||
|
y = rng.random() * 2.6 - 1.3
|
||||||
|
pipe_radius = 0.22
|
||||||
|
jitter = rng.random() * 0.02 - 0.01
|
||||||
|
radial = pipe_radius + jitter
|
||||||
|
x = 0.0 + radial * math.cos(angle)
|
||||||
|
z = -0.62 + radial * math.sin(angle)
|
||||||
|
else:
|
||||||
|
x = rng.random() * 4.0 - 2.0
|
||||||
|
y = rng.random() * 3.0 - 1.5
|
||||||
|
waviness = 0.11 * math.cos(2.2 * y)
|
||||||
|
channel = 0.08 * x * x
|
||||||
|
noise = rng.random() * 0.04 - 0.02
|
||||||
|
z = -0.45 + channel + waviness + noise
|
||||||
|
elif surface_type == "Тор":
|
||||||
|
u = rng.random() * 2.0 * math.pi
|
||||||
|
v = rng.random() * 2.0 * math.pi
|
||||||
|
major_r = 1.0
|
||||||
|
minor_r = 0.35
|
||||||
|
jitter = rng.random() * 0.02 - 0.01
|
||||||
|
radial = minor_r + jitter
|
||||||
|
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)
|
||||||
|
elif surface_type == "Волна":
|
||||||
|
x = rng.random() * 2.4 - 1.2
|
||||||
|
y = rng.random() * 2.4 - 1.2
|
||||||
|
noise = rng.random() * 0.03 - 0.015
|
||||||
|
z = 0.35 * math.sin(2.5 * x) * math.cos(2.5 * y) + noise
|
||||||
|
else:
|
||||||
|
u = rng.random() * 2.0 - 1.0
|
||||||
|
theta = rng.random() * 2.0 * math.pi
|
||||||
|
r = 1.0 + rng.random() * 0.08 - 0.04
|
||||||
|
s = math.sqrt(max(0.0, 1.0 - u * u))
|
||||||
|
x = r * s * math.cos(theta)
|
||||||
|
y = r * s * math.sin(theta)
|
||||||
|
z = r * u
|
||||||
|
points.append([x, y, z])
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_SURFACE_TYPES = ["Сфера", "Тор", "Волна", "Дно реки + труба"]
|
||||||
|
|
||||||
|
|
||||||
|
def demo_payload(surface_type: str, count: int = 350) -> dict[str, Any]:
|
||||||
|
if surface_type not in DEMO_SURFACE_TYPES:
|
||||||
|
surface_type = "Сфера"
|
||||||
|
points = generate_demo_points(surface_type, count)
|
||||||
|
return {
|
||||||
|
"surfaceType": surface_type,
|
||||||
|
"pointCount": len(points),
|
||||||
|
"points": points,
|
||||||
|
"sourceLabel": f"demo: {surface_type}",
|
||||||
|
}
|
||||||
+306
-39
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
@@ -10,18 +11,26 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
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 stage_meta import STAGE_META, STAGE_META_BY_ID, catalog_payload, defaults_for_stage
|
||||||
|
|
||||||
APP_ROOT = Path(__file__).resolve().parent.parent
|
APP_ROOT = Path(__file__).resolve().parent.parent
|
||||||
WEB_ROOT = APP_ROOT / "web"
|
WEB_DIST = APP_ROOT / "web-vue" / "dist"
|
||||||
|
WEB_LEGACY = APP_ROOT / "web"
|
||||||
PRESETS_DIRS = [APP_ROOT / "presets", APP_ROOT / "docker"]
|
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(
|
DEFAULT_PIPELINE_CONFIG = Path(
|
||||||
os.environ.get("PIPELINE_CONFIG", APP_ROOT / "docker" / "default_pipeline.json")
|
os.environ.get("PIPELINE_CONFIG", APP_ROOT / "docker" / "default_pipeline.json")
|
||||||
)
|
)
|
||||||
DOTSTOSIRFACE_BIN = Path(os.environ.get("DOTSTOSIRFACE_BIN", "/usr/local/bin/DotsToSirface"))
|
DOTSTOSIRFACE_BIN = Path(os.environ.get("DOTSTOSIRFACE_BIN", "/usr/local/bin/DotsToSirface"))
|
||||||
|
|
||||||
app = FastAPI(title="DotsToSirface Web API", version="1.0.0")
|
app = FastAPI(title="DotsToSirface Web API", version="2.0.0")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -30,10 +39,36 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
USER_PRESETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineConfigBody(BaseModel):
|
||||||
|
profile: str = "desktop_debug"
|
||||||
|
preprocessPlugins: list[str] = []
|
||||||
|
reconstructionPlugin: str = "surface_fallback"
|
||||||
|
stageDefaults: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateConfigBody(BaseModel):
|
||||||
|
config: PipelineConfigBody
|
||||||
|
|
||||||
|
|
||||||
|
class WizardBody(BaseModel):
|
||||||
|
wizardProfile: str = "general"
|
||||||
|
wizardGoal: str = "balanced"
|
||||||
|
|
||||||
|
|
||||||
|
class UserPresetBody(BaseModel):
|
||||||
|
title: str
|
||||||
|
idValue: str | None = None
|
||||||
|
stages: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
|
def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
|
||||||
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
|
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
|
||||||
return preset
|
return preset
|
||||||
|
if "config" in preset and isinstance(preset["config"], dict):
|
||||||
|
return preset["config"]
|
||||||
|
|
||||||
preprocess_plugins: list[str] = []
|
preprocess_plugins: list[str] = []
|
||||||
reconstruction_plugin = "surface_fallback"
|
reconstruction_plugin = "surface_fallback"
|
||||||
@@ -62,6 +97,38 @@ def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def config_to_stage_cards(config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
cards: list[dict[str, Any]] = []
|
||||||
|
stage_defaults = config.get("stageDefaults", {})
|
||||||
|
for stage_id in config.get("preprocessPlugins", []):
|
||||||
|
meta = STAGE_META_BY_ID.get(stage_id, {})
|
||||||
|
cards.append(
|
||||||
|
{
|
||||||
|
"id": stage_id,
|
||||||
|
"title": meta.get("title", stage_id),
|
||||||
|
"category": meta.get("category", "Custom"),
|
||||||
|
"family": "preprocess",
|
||||||
|
"hint": meta.get("hint", ""),
|
||||||
|
"defaults": stage_defaults.get(stage_id, meta.get("defaults", "")),
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
recon_id = config.get("reconstructionPlugin", "surface_fallback")
|
||||||
|
recon_meta = STAGE_META_BY_ID.get(recon_id, {})
|
||||||
|
cards.append(
|
||||||
|
{
|
||||||
|
"id": recon_id,
|
||||||
|
"title": recon_meta.get("title", recon_id),
|
||||||
|
"category": recon_meta.get("category", "Реконструкция"),
|
||||||
|
"family": "reconstruction",
|
||||||
|
"hint": recon_meta.get("hint", ""),
|
||||||
|
"defaults": stage_defaults.get(recon_id, recon_meta.get("defaults", "")),
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cards
|
||||||
|
|
||||||
|
|
||||||
def list_preset_files() -> list[Path]:
|
def list_preset_files() -> list[Path]:
|
||||||
files: list[Path] = []
|
files: list[Path] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -76,18 +143,102 @@ def list_preset_files() -> list[Path]:
|
|||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def user_presets_path() -> Path:
|
||||||
|
return USER_PRESETS_DIR / "pipeline_presets.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_user_presets() -> list[dict[str, Any]]:
|
||||||
|
path = user_presets_path()
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def save_user_presets(presets: list[dict[str, Any]]) -> None:
|
||||||
|
with user_presets_path().open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(presets, handle, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def write_binary_geometry(work_dir: Path, result: dict[str, Any]) -> str | None:
|
||||||
|
points = result.get("points") or []
|
||||||
|
triangles = result.get("triangleIndices") or []
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
bin_path = work_dir / "geometry.bin"
|
||||||
|
with bin_path.open("wb") as handle:
|
||||||
|
handle.write(struct.pack("<I", len(points)))
|
||||||
|
for point in points:
|
||||||
|
handle.write(struct.pack("<3f", float(point[0]), float(point[1]), float(point[2])))
|
||||||
|
handle.write(struct.pack("<I", len(triangles)))
|
||||||
|
for tri in triangles:
|
||||||
|
handle.write(struct.pack("<3i", int(tri[0]), int(tri[1]), int(tri[2])))
|
||||||
|
return str(bin_path)
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline_command(input_path: Path, config: dict[str, Any], output_path: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
config_path = output_path.parent / "pipeline_config.json"
|
||||||
|
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
||||||
|
command = [
|
||||||
|
str(DOTSTOSIRFACE_BIN),
|
||||||
|
"--cli",
|
||||||
|
"--input",
|
||||||
|
str(input_path),
|
||||||
|
"--config-json",
|
||||||
|
str(config_path),
|
||||||
|
"--output-json",
|
||||||
|
str(output_path),
|
||||||
|
]
|
||||||
|
return subprocess.run(command, capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_result(result: dict[str, Any], config: dict[str, Any], stdout: str, work_id: str) -> dict[str, Any]:
|
||||||
|
insights = compute_insights(
|
||||||
|
config.get("preprocessPlugins", []),
|
||||||
|
config.get("reconstructionPlugin", "surface_fallback"),
|
||||||
|
)
|
||||||
|
result.update(insights)
|
||||||
|
result["stdout"] = stdout
|
||||||
|
result["workId"] = work_id
|
||||||
|
result["stageCards"] = config_to_stage_cards(config)
|
||||||
|
result.setdefault(
|
||||||
|
"metrics",
|
||||||
|
{
|
||||||
|
"inputPoints": result.get("inputPoints", 0),
|
||||||
|
"afterPreprocess": result.get("afterPreprocess", 0),
|
||||||
|
"triangles": result.get("triangles", 0),
|
||||||
|
"reconstructMs": result.get("reconstructionMs", 0),
|
||||||
|
"clusters": result.get("clusters", 0),
|
||||||
|
"removedPoints": result.get("removedPoints", 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"binary": str(DOTSTOSIRFACE_BIN),
|
"binary": str(DOTSTOSIRFACE_BIN),
|
||||||
"binaryExists": str(DOTSTOSIRFACE_BIN.is_file()),
|
"binaryExists": str(DOTSTOSIRFACE_BIN.is_file()),
|
||||||
|
"webDist": str(WEB_DIST.is_dir()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/catalog")
|
||||||
|
def catalog() -> dict[str, Any]:
|
||||||
|
return catalog_payload()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/builtin-presets")
|
||||||
|
def builtin_presets() -> list[dict[str, Any]]:
|
||||||
|
return BUILTIN_PRESETS
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/presets")
|
@app.get("/api/presets")
|
||||||
def presets() -> list[dict[str, Any]]:
|
def presets() -> list[dict[str, Any]]:
|
||||||
items: list[dict[str, Any]] = []
|
items = list(BUILTIN_PRESETS)
|
||||||
for path in list_preset_files():
|
for path in list_preset_files():
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
data = json.load(handle)
|
data = json.load(handle)
|
||||||
@@ -96,7 +247,20 @@ def presets() -> list[dict[str, Any]]:
|
|||||||
"id": path.stem,
|
"id": path.stem,
|
||||||
"filename": path.name,
|
"filename": path.name,
|
||||||
"title": data.get("title", path.stem),
|
"title": data.get("title", path.stem),
|
||||||
|
"idValue": data.get("idValue", path.stem),
|
||||||
"config": preset_to_pipeline_config(data),
|
"config": preset_to_pipeline_config(data),
|
||||||
|
"stages": data.get("stages"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for preset in load_user_presets():
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": preset.get("idValue", ""),
|
||||||
|
"title": preset.get("title", "User preset"),
|
||||||
|
"idValue": preset.get("idValue", ""),
|
||||||
|
"config": preset_to_pipeline_config(preset),
|
||||||
|
"stages": preset.get("stages"),
|
||||||
|
"user": True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
@@ -110,58 +274,140 @@ def default_config() -> dict[str, Any]:
|
|||||||
return json.load(handle)
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stage-defaults/{stage_id}")
|
||||||
|
def stage_defaults(stage_id: str) -> dict[str, str]:
|
||||||
|
return {"stageId": stage_id, "defaults": defaults_for_stage(stage_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/validate-config")
|
||||||
|
def validate_config(body: ValidateConfigBody) -> dict[str, Any]:
|
||||||
|
config = body.config.model_dump()
|
||||||
|
insights = compute_insights(config.get("preprocessPlugins", []), config.get("reconstructionPlugin", ""))
|
||||||
|
return {
|
||||||
|
"config": config,
|
||||||
|
"stageCards": config_to_stage_cards(config),
|
||||||
|
**insights,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/wizard")
|
||||||
|
def wizard_suggestion(body: WizardBody) -> dict[str, Any]:
|
||||||
|
preset_id = "Synthetic_clean"
|
||||||
|
if body.wizardProfile == "urban_scan":
|
||||||
|
preset_id = "LiDAR_scan"
|
||||||
|
elif body.wizardProfile == "indoor_object":
|
||||||
|
preset_id = "RGBD_camera"
|
||||||
|
if body.wizardGoal == "speed":
|
||||||
|
if preset_id == "LiDAR_scan":
|
||||||
|
preset_id = "Synthetic_clean"
|
||||||
|
elif preset_id == "RGBD_camera":
|
||||||
|
preset_id = "Fast"
|
||||||
|
elif body.wizardGoal == "quality":
|
||||||
|
if preset_id == "Synthetic_clean":
|
||||||
|
preset_id = "RGBD_camera"
|
||||||
|
preset = get_builtin_preset(preset_id)
|
||||||
|
if preset is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Wizard preset not found.")
|
||||||
|
config = preset["config"]
|
||||||
|
insights = compute_insights(config["preprocessPlugins"], config["reconstructionPlugin"])
|
||||||
|
return {
|
||||||
|
"presetId": preset_id,
|
||||||
|
"title": preset["title"],
|
||||||
|
"config": config,
|
||||||
|
"stageCards": config_to_stage_cards(config),
|
||||||
|
**insights,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/demo")
|
||||||
|
def demo(surfaceType: str = "Сфера", count: int = 350) -> dict[str, Any]:
|
||||||
|
return demo_payload(surfaceType, count)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/demo/types")
|
||||||
|
def demo_types() -> list[str]:
|
||||||
|
return DEMO_SURFACE_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/user-presets")
|
||||||
|
def get_user_presets() -> list[dict[str, Any]]:
|
||||||
|
return load_user_presets()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/user-presets")
|
||||||
|
def post_user_preset(body: UserPresetBody) -> dict[str, Any]:
|
||||||
|
presets = load_user_presets()
|
||||||
|
preset_id = body.idValue or f"user:{body.title.lower().replace(' ', '_')}"
|
||||||
|
preset = {
|
||||||
|
"title": body.title,
|
||||||
|
"idValue": preset_id,
|
||||||
|
"stages": body.stages,
|
||||||
|
}
|
||||||
|
replaced = False
|
||||||
|
for index, existing in enumerate(presets):
|
||||||
|
if existing.get("idValue") == preset_id or existing.get("title") == body.title:
|
||||||
|
presets[index] = preset
|
||||||
|
replaced = True
|
||||||
|
break
|
||||||
|
if not replaced:
|
||||||
|
presets.append(preset)
|
||||||
|
save_user_presets(presets)
|
||||||
|
return preset
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/run")
|
@app.post("/api/run")
|
||||||
async def run_pipeline(
|
async def run_pipeline(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile | None = File(default=None),
|
||||||
preset_id: str | None = Form(default=None),
|
preset_id: str | None = Form(default=None),
|
||||||
config_json: str | None = Form(default=None),
|
config_json: str | None = Form(default=None),
|
||||||
|
demo_surface: str | None = Form(default=None),
|
||||||
|
geometry_format: str = Form(default="json"),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not DOTSTOSIRFACE_BIN.is_file():
|
if not DOTSTOSIRFACE_BIN.is_file():
|
||||||
raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSIRFACE_BIN}")
|
raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSIRFACE_BIN}")
|
||||||
|
|
||||||
suffix = Path(file.filename or "cloud.ply").suffix or ".ply"
|
|
||||||
work_id = uuid.uuid4().hex
|
work_id = uuid.uuid4().hex
|
||||||
work_dir = Path(tempfile.gettempdir()) / "dotstosirface" / work_id
|
work_dir = Path(tempfile.gettempdir()) / "dotstosirface" / work_id
|
||||||
work_dir.mkdir(parents=True, exist_ok=True)
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
input_path = work_dir / f"input{suffix}"
|
|
||||||
config_path = work_dir / "pipeline_config.json"
|
|
||||||
output_path = work_dir / "result.json"
|
output_path = work_dir / "result.json"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = await file.read()
|
if demo_surface:
|
||||||
input_path.write_bytes(content)
|
demo = demo_payload(demo_surface)
|
||||||
|
input_path = work_dir / "demo.xyz"
|
||||||
|
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())
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Provide file or demo_surface.")
|
||||||
|
|
||||||
if config_json:
|
if config_json:
|
||||||
pipeline_config = json.loads(config_json)
|
pipeline_config = json.loads(config_json)
|
||||||
elif preset_id:
|
elif preset_id:
|
||||||
preset_path = next((p for p in list_preset_files() if p.stem == preset_id), None)
|
builtin = get_builtin_preset(preset_id)
|
||||||
if preset_path is None:
|
if builtin is not None:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown preset: {preset_id}")
|
pipeline_config = builtin["config"]
|
||||||
with preset_path.open("r", encoding="utf-8") as handle:
|
else:
|
||||||
pipeline_config = preset_to_pipeline_config(json.load(handle))
|
preset_path = next((p for p in list_preset_files() if p.stem == preset_id), None)
|
||||||
|
if preset_path is None:
|
||||||
|
user_match = next(
|
||||||
|
(p for p in load_user_presets() if p.get("idValue") == preset_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if user_match is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown preset: {preset_id}")
|
||||||
|
pipeline_config = preset_to_pipeline_config(user_match)
|
||||||
|
else:
|
||||||
|
with preset_path.open("r", encoding="utf-8") as handle:
|
||||||
|
pipeline_config = preset_to_pipeline_config(json.load(handle))
|
||||||
else:
|
else:
|
||||||
with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle:
|
with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle:
|
||||||
pipeline_config = json.load(handle)
|
pipeline_config = json.load(handle)
|
||||||
|
|
||||||
config_path.write_text(json.dumps(pipeline_config, indent=2), encoding="utf-8")
|
completed = run_pipeline_command(input_path, pipeline_config, output_path)
|
||||||
|
|
||||||
command = [
|
|
||||||
str(DOTSTOSIRFACE_BIN),
|
|
||||||
"--cli",
|
|
||||||
"--input",
|
|
||||||
str(input_path),
|
|
||||||
"--config-json",
|
|
||||||
str(config_path),
|
|
||||||
"--output-json",
|
|
||||||
str(output_path),
|
|
||||||
]
|
|
||||||
completed = subprocess.run(
|
|
||||||
command,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if completed.returncode != 0:
|
if completed.returncode != 0:
|
||||||
detail = completed.stderr.strip() or completed.stdout.strip() or "Pipeline failed."
|
detail = completed.stderr.strip() or completed.stdout.strip() or "Pipeline failed."
|
||||||
raise HTTPException(status_code=500, detail=detail)
|
raise HTTPException(status_code=500, detail=detail)
|
||||||
@@ -172,20 +418,41 @@ async def run_pipeline(
|
|||||||
with output_path.open("r", encoding="utf-8") as handle:
|
with output_path.open("r", encoding="utf-8") as handle:
|
||||||
result = json.load(handle)
|
result = json.load(handle)
|
||||||
|
|
||||||
result["stdout"] = completed.stdout.strip()
|
result = enrich_result(result, pipeline_config, completed.stdout.strip(), work_id)
|
||||||
result["workId"] = work_id
|
|
||||||
|
if geometry_format == "binary":
|
||||||
|
bin_path = write_binary_geometry(work_dir, result)
|
||||||
|
if bin_path:
|
||||||
|
result["geometryUrl"] = f"/api/geometry/{work_id}"
|
||||||
|
result.pop("points", None)
|
||||||
|
result.pop("triangleIndices", None)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid config JSON: {exc}") from exc
|
raise HTTPException(status_code=400, detail=f"Invalid config JSON: {exc}") from exc
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # pragma: no cover - defensive path for API boundary
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/geometry/{work_id}")
|
||||||
|
def get_geometry(work_id: str) -> Response:
|
||||||
|
bin_path = Path(tempfile.gettempdir()) / "dotstosirface" / 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("/")
|
@app.get("/")
|
||||||
def index() -> FileResponse:
|
def index() -> FileResponse:
|
||||||
return FileResponse(WEB_ROOT / "index.html")
|
if (WEB_DIST / "index.html").is_file():
|
||||||
|
return FileResponse(WEB_DIST / "index.html")
|
||||||
|
return FileResponse(WEB_LEGACY / "index.html")
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=WEB_ROOT), name="static")
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Pipeline chain insights (mirrors MainWindow::recomputeInsights)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def compute_insights(
|
||||||
|
preprocess_plugins: list[str],
|
||||||
|
reconstruction_plugin: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
risk_state = False
|
||||||
|
|
||||||
|
stages = [s for s in preprocess_plugins if s]
|
||||||
|
voxel_idx = stages.index("pcl_voxel_grid") if "pcl_voxel_grid" in stages else -1
|
||||||
|
remove_nan_idx = stages.index("pcl_remove_nan") if "pcl_remove_nan" in stages else -1
|
||||||
|
remove_nan_normals_idx = (
|
||||||
|
stages.index("pcl_remove_nan_normals") if "pcl_remove_nan_normals" in stages else -1
|
||||||
|
)
|
||||||
|
outlier_indexes = []
|
||||||
|
for stage_id in (
|
||||||
|
"pcl_statistical_outlier",
|
||||||
|
"pcl_radius_outlier",
|
||||||
|
"pcl_model_outlier",
|
||||||
|
"pcl_shadow_points",
|
||||||
|
):
|
||||||
|
if stage_id in stages:
|
||||||
|
outlier_indexes.append(stages.index(stage_id))
|
||||||
|
first_outlier_idx = min(outlier_indexes) if outlier_indexes else -1
|
||||||
|
|
||||||
|
if not stages:
|
||||||
|
risk_state = True
|
||||||
|
warnings.append("Добавьте хотя бы один этап препроцессинга перед реконструкцией.")
|
||||||
|
else:
|
||||||
|
if first_outlier_idx < 0:
|
||||||
|
warnings.append("Добавьте outlier removal для стабилизации триангуляции.")
|
||||||
|
if first_outlier_idx >= 0:
|
||||||
|
nan_idxs = [i for i in (remove_nan_idx, remove_nan_normals_idx) if i >= 0]
|
||||||
|
first_nan_preclean_idx = min(nan_idxs) if nan_idxs else -1
|
||||||
|
if first_nan_preclean_idx < 0 or first_nan_preclean_idx > first_outlier_idx:
|
||||||
|
warnings.append(
|
||||||
|
"Удалите NaN сразу после загрузки/обрезки: иначе статистические фильтры работают нестабильно."
|
||||||
|
)
|
||||||
|
if voxel_idx >= 0 and first_outlier_idx >= 0 and first_outlier_idx > voxel_idx:
|
||||||
|
warnings.append(
|
||||||
|
"OutlierRemoval стоит после VoxelGrid: лучше сначала очистить шум, затем прореживать."
|
||||||
|
)
|
||||||
|
if reconstruction_plugin == "pcl_greedy_triangulation" and voxel_idx < 0:
|
||||||
|
warnings.append("Greedy triangulation обычно работает лучше после voxel downsampling.")
|
||||||
|
|
||||||
|
if risk_state:
|
||||||
|
chain_health = "Risk"
|
||||||
|
recommendation = warnings[0] if warnings else "Проверьте порядок этапов пайплайна."
|
||||||
|
elif warnings:
|
||||||
|
chain_health = "Warning"
|
||||||
|
recommendation = warnings[0]
|
||||||
|
else:
|
||||||
|
chain_health = "OK"
|
||||||
|
recommendation = "Pipeline looks balanced. Use Compare snapshots for A/B tuning."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chainHealth": chain_health,
|
||||||
|
"warningsList": warnings,
|
||||||
|
"recommendation": recommendation,
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Stage metadata with default parameter strings (from MainWindow kStageMeta)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
STAGE_META: list[dict[str, str]] = [
|
||||||
|
{"id": "keep_largest_cluster", "title": "Крупнейший кластер", "category": "Обрезка", "family": "preprocess", "hint": "Удаляет мелкие фрагменты и оставляет основной объект.", "defaults": "clusterJoinDistanceScale=5.0"},
|
||||||
|
{"id": "pcl_remove_nan", "title": "Remove NaN Points", "category": "NaN (предочистка)", "family": "preprocess", "hint": "Удаляет точки с NaN/Inf в X/Y/Z сразу после загрузки/обрезки.", "defaults": ""},
|
||||||
|
{"id": "pcl_remove_nan_normals", "title": "Remove NaN Normals", "category": "NaN (предочистка)", "family": "preprocess", "hint": "Удаляет точки с невалидными нормалями.", "defaults": ""},
|
||||||
|
{"id": "pcl_pass_through", "title": "PassThrough", "category": "Обрезка", "family": "preprocess", "hint": "Фильтр по диапазону одной координатной оси.", "defaults": "axis=z,min=-1.0,max=1.0"},
|
||||||
|
{"id": "pcl_crop_box", "title": "CropBox", "category": "Обрезка", "family": "preprocess", "hint": "Обрезка по границам 3D-параллелепипеда.", "defaults": "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"},
|
||||||
|
{"id": "pcl_crop_hull", "title": "CropHull", "category": "Обрезка", "family": "preprocess", "hint": "Обрезка по выпуклой области.", "defaults": "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"},
|
||||||
|
{"id": "pcl_frustum_culling", "title": "Frustum Culling", "category": "Обрезка", "family": "preprocess", "hint": "Оставляет точки в пирамиде видимости.", "defaults": "near=0.1,far=5.0,hfov=70,vfov=50"},
|
||||||
|
{"id": "pcl_plane_clipper_3d", "title": "PlaneClipper3D", "category": "Обрезка", "family": "preprocess", "hint": "Отсечение по плоскости ax+by+cz+d=0.", "defaults": "a=0.0,b=0.0,c=1.0,d=0.0,keepPositive=true"},
|
||||||
|
{"id": "pcl_conditional_removal", "title": "Conditional Removal", "category": "Условия/Индексы", "family": "preprocess", "hint": "Удаление точек по диапазону Z.", "defaults": "zMin=-1.0,zMax=1.0"},
|
||||||
|
{"id": "pcl_extract_indices", "title": "Extract Indices", "category": "Условия/Индексы", "family": "preprocess", "hint": "Извлечение каждой N-й точки.", "defaults": "nth=2"},
|
||||||
|
{"id": "pcl_functor_filter", "title": "Functor Filter", "category": "Условия/Индексы", "family": "preprocess", "hint": "Фильтрация по расстоянию до начала координат.", "defaults": "radiusMax=2.5"},
|
||||||
|
{"id": "pcl_project_inliers", "title": "ProjectInliers", "category": "Нормали", "family": "preprocess", "hint": "Проецирование точек на плоскость.", "defaults": "a=0.0,b=0.0,c=1.0,d=0.0"},
|
||||||
|
{"id": "pcl_normal_refinement", "title": "Normal Refinement", "category": "Нормали", "family": "preprocess", "hint": "Уточнение геометрии локальным усреднением.", "defaults": "radius=0.1,iterations=1"},
|
||||||
|
{"id": "pcl_bilateral_filter", "title": "Bilateral Filter", "category": "Сглаживание", "family": "preprocess", "hint": "Двустороннее сглаживание.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
|
||||||
|
{"id": "pcl_fast_bilateral_filter", "title": "Fast Bilateral Filter", "category": "Сглаживание", "family": "preprocess", "hint": "Быстрая версия bilateral.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
|
||||||
|
{"id": "pcl_fast_bilateral_filter_omp", "title": "Fast Bilateral Filter OMP", "category": "Сглаживание", "family": "preprocess", "hint": "Многопоточный bilateral.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
|
||||||
|
{"id": "pcl_convolution", "title": "Convolution", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро свертки.", "defaults": "sigma=0.08,kernel=3"},
|
||||||
|
{"id": "pcl_gaussian_kernel", "title": "Gaussian Kernel", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро.", "defaults": "sigma=0.08,kernel=3"},
|
||||||
|
{"id": "pcl_gaussian_kernel_rgb", "title": "Gaussian Kernel RGB", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро RGB.", "defaults": "sigma=0.08,kernel=3"},
|
||||||
|
{"id": "pcl_voxel_grid_occlusion", "title": "VoxelGrid Occlusion Estimation", "category": "Морфология", "family": "preprocess", "hint": "Оценка окклюзии по вокселям.", "defaults": "leaf=0.12,minHits=2"},
|
||||||
|
{"id": "downsample_dense", "title": "Прореживание плотности", "category": "Прореживание", "family": "preprocess", "hint": "Снижает число точек на плотных облаках.", "defaults": "downsampleCellScale=0.8"},
|
||||||
|
{"id": "pcl_voxel_grid", "title": "PCL Voxel Grid", "category": "Прореживание", "family": "preprocess", "hint": "Воксельное прореживание.", "defaults": "leaf=0.02"},
|
||||||
|
{"id": "pcl_statistical_outlier", "title": "Статистическая фильтрация", "category": "Шум", "family": "preprocess", "hint": "Удаляет выбросы по статистике соседей.", "defaults": "meanK=24,stddev=1.2"},
|
||||||
|
{"id": "pcl_radius_outlier", "title": "Радиусная фильтрация", "category": "Шум", "family": "preprocess", "hint": "Удаляет точки без соседей в радиусе.", "defaults": "radius=0.04,minNeighbors=8"},
|
||||||
|
{"id": "pcl_model_outlier", "title": "Model Outlier Removal", "category": "Шум", "family": "preprocess", "hint": "Удаляет отклонения от модели.", "defaults": "threshold=0.03"},
|
||||||
|
{"id": "pcl_shadow_points", "title": "Shadow Points Removal", "category": "Шум", "family": "preprocess", "hint": "Удаляет теневые точки.", "defaults": "shadowThreshold=0.2"},
|
||||||
|
{"id": "pcl_approximate_voxel_grid", "title": "Approximate Voxel Grid", "category": "Прореживание", "family": "preprocess", "hint": "Ускоренное воксельное прореживание.", "defaults": "leaf=0.03"},
|
||||||
|
{"id": "pcl_voxel_grid_label", "title": "Voxel Grid Label", "category": "Прореживание", "family": "preprocess", "hint": "Воксели с метками.", "defaults": "leaf=0.04"},
|
||||||
|
{"id": "pcl_voxel_grid_covariance", "title": "Voxel Grid Covariance", "category": "Прореживание", "family": "preprocess", "hint": "Воксели с ковариациями.", "defaults": "leaf=0.04"},
|
||||||
|
{"id": "pcl_grid_minimum", "title": "Grid Minimum", "category": "Прореживание", "family": "preprocess", "hint": "Минимум Z в ячейке.", "defaults": "resolution=0.05"},
|
||||||
|
{"id": "pcl_farthest_point_sampling", "title": "Farthest Point Sampling", "category": "Прореживание", "family": "preprocess", "hint": "Наиболее удалённые точки.", "defaults": "sample=800"},
|
||||||
|
{"id": "pcl_normal_space_sampling", "title": "Normal Space Sampling", "category": "Прореживание", "family": "preprocess", "hint": "Выборка по нормалям.", "defaults": "sample=800"},
|
||||||
|
{"id": "pcl_sampling_surface_normal", "title": "Sampling Surface Normal", "category": "Прореживание", "family": "preprocess", "hint": "Выборка по нормалям поверхности.", "defaults": "sample=800"},
|
||||||
|
{"id": "surface_fallback", "title": "Fallback Surface", "category": "Реконструкция", "family": "reconstruction", "hint": "Базовая реконструкция.", "defaults": "neighborRadiusScale=3.5,runEveryNthFrame=1"},
|
||||||
|
{"id": "pcl_greedy_triangulation", "title": "PCL Greedy Triangulation", "category": "Реконструкция", "family": "reconstruction", "hint": "Жадная триангуляция.", "defaults": "searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8"},
|
||||||
|
{"id": "pcl_poisson_reconstruction", "title": "PCL Poisson Reconstruction", "category": "Реконструкция", "family": "reconstruction", "hint": "Poisson реконструкция.", "defaults": "poissonDepth=8,samplesPerNode=1.5"},
|
||||||
|
]
|
||||||
|
|
||||||
|
STAGE_META_BY_ID: dict[str, dict[str, str]] = {item["id"]: item for item in STAGE_META}
|
||||||
|
|
||||||
|
|
||||||
|
def defaults_for_stage(stage_id: str) -> str:
|
||||||
|
meta = STAGE_META_BY_ID.get(stage_id)
|
||||||
|
return meta["defaults"] if meta else ""
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_payload() -> dict[str, Any]:
|
||||||
|
# Catalog groups come from frontend module at build time; API exposes stage meta + ids.
|
||||||
|
return {
|
||||||
|
"stageMeta": STAGE_META,
|
||||||
|
"reconstructions": [
|
||||||
|
"surface_fallback",
|
||||||
|
"pcl_greedy_triangulation",
|
||||||
|
"pcl_poisson_reconstruction",
|
||||||
|
],
|
||||||
|
}
|
||||||
+11
-1
@@ -1,3 +1,12 @@
|
|||||||
|
FROM node:20-bookworm-slim AS web-builder
|
||||||
|
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web-vue/package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY web-vue/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
FROM ubuntu:24.04 AS builder
|
FROM ubuntu:24.04 AS builder
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -24,6 +33,7 @@ FROM ubuntu:24.04
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
ENV DOTSTOSIRFACE_BIN=/usr/local/bin/DotsToSirface
|
ENV DOTSTOSIRFACE_BIN=/usr/local/bin/DotsToSirface
|
||||||
ENV PIPELINE_CONFIG=/app/docker/default_pipeline.json
|
ENV PIPELINE_CONFIG=/app/docker/default_pipeline.json
|
||||||
|
ENV USER_PRESETS_DIR=/app/data/user-presets
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -49,8 +59,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /src/build/DotsToSirface /usr/local/bin/DotsToSirface
|
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 api /app/api
|
||||||
COPY web /app/web
|
|
||||||
COPY docker /app/docker
|
COPY docker /app/docker
|
||||||
COPY presets /app/presets
|
COPY presets /app/presets
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DOTSTOSIRFACE_BIN: /usr/local/bin/DotsToSirface
|
DOTSTOSIRFACE_BIN: /usr/local/bin/DotsToSirface
|
||||||
PIPELINE_CONFIG: /app/docker/default_pipeline.json
|
PIPELINE_CONFIG: /app/docker/default_pipeline.json
|
||||||
|
USER_PRESETS_DIR: /app/data/user-presets
|
||||||
volumes:
|
volumes:
|
||||||
- ../presets:/app/presets:ro
|
- ../presets:/app/presets:ro
|
||||||
|
- dotstosirface-user-presets:/app/data/user-presets
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dotstosirface-user-presets:
|
||||||
|
|||||||
@@ -120,6 +120,23 @@ void appendGeometryToJson(const core::PipelineResult &result, QJsonObject &root)
|
|||||||
root["triangleIndices"] = trianglesArray;
|
root["triangleIndices"] = trianglesArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void appendRunStatsToJson(const core::PipelineResult &result, QJsonObject &root)
|
||||||
|
{
|
||||||
|
QJsonArray stepMetrics;
|
||||||
|
for (const core::PipelineStats::PreprocessStepMetric &metric : result.stats.preprocessStepMetrics) {
|
||||||
|
QJsonObject row;
|
||||||
|
row["stageId"] = metric.stageId;
|
||||||
|
row["inputPoints"] = metric.inputPoints;
|
||||||
|
row["outputPoints"] = metric.outputPoints;
|
||||||
|
row["removedPoints"] = metric.removedPoints;
|
||||||
|
row["elapsedMs"] = static_cast<qint64>(metric.elapsedMs);
|
||||||
|
stepMetrics.push_back(row);
|
||||||
|
}
|
||||||
|
root["preprocessStepMetrics"] = stepMetrics;
|
||||||
|
root["clusters"] = result.stats.detectedClusters;
|
||||||
|
root["removedPoints"] = result.stats.removedClusterPoints + result.stats.removedDownsamplePoints;
|
||||||
|
}
|
||||||
|
|
||||||
struct AutotuneCliConfig
|
struct AutotuneCliConfig
|
||||||
{
|
{
|
||||||
QString baseConfigJsonPath;
|
QString baseConfigJsonPath;
|
||||||
@@ -520,6 +537,7 @@ int runCliPipeline(int argc, char *argv[])
|
|||||||
root["reconstruction"] = config.reconstructionPlugin;
|
root["reconstruction"] = config.reconstructionPlugin;
|
||||||
root["preprocessChain"] = config.preprocessPlugins.join(",");
|
root["preprocessChain"] = config.preprocessPlugins.join(",");
|
||||||
appendGeometryToJson(result, root);
|
appendGeometryToJson(result, root);
|
||||||
|
appendRunStatsToJson(result, root);
|
||||||
|
|
||||||
QFile file(outputJsonPath);
|
QFile file(outputJsonPath);
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DotsToSirface</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "dotstosirface-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^11.3.0",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"sortablejs": "^1.15.6",
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
import PipelineDashboard from "@/components/PipelineDashboard.vue";
|
||||||
|
import ViewerPanel from "@/components/viewer/ViewerPanel.vue";
|
||||||
|
import MetricsStrip from "@/components/pipeline/MetricsStrip.vue";
|
||||||
|
import WizardPanel from "@/components/WizardPanel.vue";
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const healthText = ref("Checking API...");
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await store.bootstrap();
|
||||||
|
const health = await api.health();
|
||||||
|
healthText.value = health.binaryExists === "True" || health.binaryExists === true
|
||||||
|
? "API online, pipeline binary ready"
|
||||||
|
: "API online, binary missing";
|
||||||
|
} catch (error) {
|
||||||
|
healthText.value = `API error: ${error.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-root">
|
||||||
|
<header class="header">
|
||||||
|
<h1>DotsToSirface</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="theme-toggle" @click="toggleTheme">
|
||||||
|
{{ theme === "light" ? "Тёмная тема" : "Светлая тема" }}
|
||||||
|
</button>
|
||||||
|
<div class="health">{{ healthText }}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<PipelineDashboard />
|
||||||
|
<WizardPanel />
|
||||||
|
</aside>
|
||||||
|
<div class="content-column">
|
||||||
|
<ViewerPanel />
|
||||||
|
<MetricsStrip />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
background: var(--header-bg);
|
||||||
|
}
|
||||||
|
.header h1 { margin: 0; font-size: 20px; }
|
||||||
|
.header-actions { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.theme-toggle { font-size: 13px; padding: 6px 10px; }
|
||||||
|
.health {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--health-bg);
|
||||||
|
color: var(--health-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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(payload.detail || `Request failed: ${path}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
|
||||||
|
function inferParamType(valueText) {
|
||||||
|
var v = (valueText === undefined || valueText === null) ? "" : String(valueText).trim()
|
||||||
|
if (v === "true" || v === "false")
|
||||||
|
return "bool"
|
||||||
|
if (/^-?\d+$/.test(v))
|
||||||
|
return "int"
|
||||||
|
if (/^-?(?:\d+\.\d*|\d*\.\d+)$/.test(v))
|
||||||
|
return "float"
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDefaults(defaultsText) {
|
||||||
|
var out = []
|
||||||
|
if (!defaultsText)
|
||||||
|
return out
|
||||||
|
var chunks = String(defaultsText).split(",")
|
||||||
|
for (var i = 0; i < chunks.length; ++i) {
|
||||||
|
var chunk = chunks[i].trim()
|
||||||
|
if (!chunk)
|
||||||
|
continue
|
||||||
|
var eq = chunk.indexOf("=")
|
||||||
|
var key = eq >= 0 ? chunk.slice(0, eq).trim() : chunk
|
||||||
|
var value = eq >= 0 ? chunk.slice(eq + 1).trim() : ""
|
||||||
|
out.push({ key: key, value: value, kind: inferParamType(value) })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeParams(items) {
|
||||||
|
var parts = []
|
||||||
|
for (var i = 0; i < items.length; ++i) {
|
||||||
|
var p = items[i]
|
||||||
|
parts.push(p.key + "=" + p.value)
|
||||||
|
}
|
||||||
|
return parts.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramDescription(key) {
|
||||||
|
if (key === "minCluster")
|
||||||
|
return "Минимальный размер кластера в точках."
|
||||||
|
if (key === "targetPoints")
|
||||||
|
return "Целевое количество точек после прореживания."
|
||||||
|
if (key === "leaf")
|
||||||
|
return "Размер вокселя для Voxel Grid."
|
||||||
|
if (key === "meanK")
|
||||||
|
return "Число соседей для статистической оценки."
|
||||||
|
if (key === "stddev")
|
||||||
|
return "Порог отклонения для удаления выбросов."
|
||||||
|
if (key === "radius")
|
||||||
|
return "Радиус поиска соседей."
|
||||||
|
if (key === "minNeighbors")
|
||||||
|
return "Минимум соседей, чтобы точка считалась валидной."
|
||||||
|
if (key === "threshold")
|
||||||
|
return "Порог отклонения точки от геометрической модели."
|
||||||
|
if (key === "shadowThreshold")
|
||||||
|
return "Порог для удаления теневых точек по нормалям."
|
||||||
|
if (key === "resolution")
|
||||||
|
return "Размер ячейки 2D-сетки для фильтра GridMinimum."
|
||||||
|
if (key === "sample")
|
||||||
|
return "Количество точек, которое нужно оставить после выборки."
|
||||||
|
if (key === "axis")
|
||||||
|
return "Ось фильтрации: x, y или z."
|
||||||
|
if (key === "min" || key === "max")
|
||||||
|
return "Граница диапазона для фильтра PassThrough."
|
||||||
|
if (key === "minX" || key === "minY" || key === "minZ" || key === "maxX" || key === "maxY" || key === "maxZ")
|
||||||
|
return "Границы CropBox/CropHull по соответствующим осям."
|
||||||
|
if (key === "near" || key === "far")
|
||||||
|
return "Ближняя/дальняя граница фрустума."
|
||||||
|
if (key === "hfov" || key === "vfov")
|
||||||
|
return "Горизонтальный/вертикальный угол обзора (в градусах)."
|
||||||
|
if (key === "a" || key === "b" || key === "c" || key === "d")
|
||||||
|
return "Коэффициенты плоскости ax+by+cz+d=0."
|
||||||
|
if (key === "keepPositive")
|
||||||
|
return "Оставлять ли точки на положительной стороне плоскости."
|
||||||
|
if (key === "zMin" || key === "zMax")
|
||||||
|
return "Допустимый диапазон координаты Z для conditional-фильтра."
|
||||||
|
if (key === "nth")
|
||||||
|
return "Оставлять каждую N-ю точку."
|
||||||
|
if (key === "radiusMax")
|
||||||
|
return "Максимальный радиус точки от начала координат."
|
||||||
|
if (key === "sigmaS" || key === "sigmaR")
|
||||||
|
return "Параметры bilateral-фильтра (пространство/интенсивность)."
|
||||||
|
if (key === "sigma" || key === "kernel")
|
||||||
|
return "Параметры гауссова ядра свертки."
|
||||||
|
if (key === "iterations")
|
||||||
|
return "Число итераций уточнения."
|
||||||
|
if (key === "minHits")
|
||||||
|
return "Минимальная заполненность вокселя для прохождения фильтра."
|
||||||
|
if (key === "neighborRadiusScale")
|
||||||
|
return "Масштаб радиуса поиска соседей для fallback-реконструкции."
|
||||||
|
if (key === "runEveryNthFrame")
|
||||||
|
return "Запуск реконструкции на каждом N-м кадре."
|
||||||
|
if (key === "searchRadius")
|
||||||
|
return "Радиус поиска соседей для greedy triangulation."
|
||||||
|
if (key === "mu")
|
||||||
|
return "Коэффициент плотности соседей для greedy triangulation."
|
||||||
|
if (key === "maxNearest")
|
||||||
|
return "Максимум соседей для greedy triangulation."
|
||||||
|
if (key === "maxSurfaceAngle")
|
||||||
|
return "Максимальный угол поверхности в радианах."
|
||||||
|
if (key === "poissonDepth")
|
||||||
|
return "Глубина октодерева для Poisson реконструкции."
|
||||||
|
if (key === "samplesPerNode")
|
||||||
|
return "Число выборок на узел для сглаживания Poisson."
|
||||||
|
return "Параметр этапа обработки."
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconstructionTitle(id) {
|
||||||
|
if (id === "surface_fallback")
|
||||||
|
return "Fallback Surface"
|
||||||
|
if (id === "pcl_greedy_triangulation")
|
||||||
|
return "PCL Greedy Triangulation"
|
||||||
|
if (id === "pcl_poisson_reconstruction")
|
||||||
|
return "PCL Poisson Reconstruction"
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseModel() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "crop",
|
||||||
|
title: "Обрезка",
|
||||||
|
hint: "Ограничение области интереса и удаление лишних фрагментов.",
|
||||||
|
accent: "#88d1ff",
|
||||||
|
filterBg: "#263e55",
|
||||||
|
filterBorder: "#4b7598"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nan_preclean",
|
||||||
|
title: "NaN (предочистка)",
|
||||||
|
hint: "Удаление NaN сразу после загрузки/обрезки, иначе статистические фильтры работают нестабильно.",
|
||||||
|
accent: "#8fe8ff",
|
||||||
|
filterBg: "#1f4450",
|
||||||
|
filterBorder: "#3f8394"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "conditions_indexes",
|
||||||
|
title: "Условия/Индексы",
|
||||||
|
hint: "Отбор точек по полям, диапазонам и индексным маскам.",
|
||||||
|
accent: "#9fd7ff",
|
||||||
|
filterBg: "#24405a",
|
||||||
|
filterBorder: "#4f7aa1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "noise",
|
||||||
|
title: "Шум",
|
||||||
|
hint: "Удаление выбросов и нестабильных точек перед геометрией.",
|
||||||
|
accent: "#a4f4b9",
|
||||||
|
filterBg: "#244534",
|
||||||
|
filterBorder: "#4b8f68"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "morphology",
|
||||||
|
title: "Морфология",
|
||||||
|
hint: "Геометрические операции локальной структуры облака.",
|
||||||
|
accent: "#9cf5d1",
|
||||||
|
filterBg: "#1f4a3e",
|
||||||
|
filterBorder: "#4b8f7d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "downsample",
|
||||||
|
title: "Прореживание",
|
||||||
|
hint: "Снижение плотности облака для скорости и устойчивости.",
|
||||||
|
accent: "#ffcb8a",
|
||||||
|
filterBg: "#4a3724",
|
||||||
|
filterBorder: "#8e6f47"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "smoothing",
|
||||||
|
title: "Сглаживание",
|
||||||
|
hint: "Снижение локального шума перед расчетом нормалей и реконструкцией.",
|
||||||
|
accent: "#ffd892",
|
||||||
|
filterBg: "#4e3a22",
|
||||||
|
filterBorder: "#917349"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "normals",
|
||||||
|
title: "Нормали",
|
||||||
|
hint: "Подготовка нормалей для продвинутой реконструкции (этап зарезервирован).",
|
||||||
|
accent: "#d2b7ff",
|
||||||
|
filterBg: "#3e3155",
|
||||||
|
filterBorder: "#6f5a95"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reconstruction",
|
||||||
|
title: "Реконструкция",
|
||||||
|
hint: "Построение поверхности по подготовленному облаку точек.",
|
||||||
|
accent: "#ff9dc4",
|
||||||
|
filterBg: "#4b2f40",
|
||||||
|
filterBorder: "#8d5a75"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterDefinitions() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Крупнейший кластер",
|
||||||
|
idValue: "keep_largest_cluster",
|
||||||
|
phaseId: "crop",
|
||||||
|
hint: "Оставляет основной объект и отбрасывает изолированные фрагменты."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Remove NaN Points",
|
||||||
|
idValue: "pcl_remove_nan",
|
||||||
|
phaseId: "nan_preclean",
|
||||||
|
family: "preprocess",
|
||||||
|
hint: "Удаляет NaN/Inf после загрузки и обрезки, до статистических фильтров."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Remove NaN Normals",
|
||||||
|
idValue: "pcl_remove_nan_normals",
|
||||||
|
phaseId: "nan_preclean",
|
||||||
|
family: "preprocess",
|
||||||
|
hint: "Удаляет невалидные нормали (в текущем формате — невалидную геометрию)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PassThrough",
|
||||||
|
idValue: "pcl_pass_through",
|
||||||
|
phaseId: "crop",
|
||||||
|
hint: "Фильтрует точки по диапазону выбранной оси."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "CropBox",
|
||||||
|
idValue: "pcl_crop_box",
|
||||||
|
phaseId: "crop",
|
||||||
|
hint: "Ограничивает облако заданным 3D-параллелепипедом."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "CropHull",
|
||||||
|
idValue: "pcl_crop_hull",
|
||||||
|
phaseId: "crop",
|
||||||
|
hint: "Обрезка по области интереса (в текущей версии через box-границы)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Frustum Culling",
|
||||||
|
idValue: "pcl_frustum_culling",
|
||||||
|
phaseId: "crop",
|
||||||
|
hint: "Оставляет точки в пирамиде видимости камеры."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PlaneClipper3D",
|
||||||
|
idValue: "pcl_plane_clipper_3d",
|
||||||
|
phaseId: "crop",
|
||||||
|
hint: "Отсекает точки по уравнению плоскости."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Conditional Removal",
|
||||||
|
idValue: "pcl_conditional_removal",
|
||||||
|
phaseId: "conditions_indexes",
|
||||||
|
hint: "Удаляет точки по логическому условию (диапазон Z)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Extract Indices",
|
||||||
|
idValue: "pcl_extract_indices",
|
||||||
|
phaseId: "conditions_indexes",
|
||||||
|
hint: "Извлекает точки по индексной маске (каждая N-я)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Functor Filter",
|
||||||
|
idValue: "pcl_functor_filter",
|
||||||
|
phaseId: "conditions_indexes",
|
||||||
|
hint: "Пользовательский предикат (радиус + проверка валидности)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "ProjectInliers",
|
||||||
|
idValue: "pcl_project_inliers",
|
||||||
|
phaseId: "normals",
|
||||||
|
hint: "Проецирует точки на геометрическую модель (плоскость)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Normal Refinement",
|
||||||
|
idValue: "pcl_normal_refinement",
|
||||||
|
phaseId: "normals",
|
||||||
|
hint: "Уточняет локальную геометрию по соседям."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Статистическая фильтрация",
|
||||||
|
idValue: "pcl_statistical_outlier",
|
||||||
|
phaseId: "noise",
|
||||||
|
hint: "Удаляет выбросы на основе распределения расстояний до соседей."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Радиусная фильтрация",
|
||||||
|
idValue: "pcl_radius_outlier",
|
||||||
|
phaseId: "noise",
|
||||||
|
hint: "Удаляет точки с недостаточным числом соседей в заданном радиусе."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Model Outlier Removal",
|
||||||
|
idValue: "pcl_model_outlier",
|
||||||
|
phaseId: "noise",
|
||||||
|
hint: "Удаляет точки, отклоняющиеся от геометрической модели."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Shadow Points Removal",
|
||||||
|
idValue: "pcl_shadow_points",
|
||||||
|
phaseId: "noise",
|
||||||
|
hint: "Удаляет теневые точки на основе нормалей поверхности."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Approximate Voxel Grid",
|
||||||
|
idValue: "pcl_approximate_voxel_grid",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Ускоренное воксельное прореживание для больших облаков."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Voxel Grid Label",
|
||||||
|
idValue: "pcl_voxel_grid_label",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Воксельное прореживание с поддержкой меток."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Voxel Grid Covariance",
|
||||||
|
idValue: "pcl_voxel_grid_covariance",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Воксельная сетка с ковариациями (для NDT-пайплайнов)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Grid Minimum",
|
||||||
|
idValue: "pcl_grid_minimum",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Оставляет точку с минимальным Z в каждой ячейке."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Farthest Point Sampling",
|
||||||
|
idValue: "pcl_farthest_point_sampling",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Выбирает наиболее удаленные друг от друга точки."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Normal Space Sampling",
|
||||||
|
idValue: "pcl_normal_space_sampling",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Равномерная выборка в пространстве нормалей."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sampling Surface Normal",
|
||||||
|
idValue: "pcl_sampling_surface_normal",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Выборка точек на основе нормалей поверхности."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bilateral Filter",
|
||||||
|
idValue: "pcl_bilateral_filter",
|
||||||
|
phaseId: "smoothing",
|
||||||
|
hint: "Двустороннее сглаживание с сохранением границ."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fast Bilateral Filter",
|
||||||
|
idValue: "pcl_fast_bilateral_filter",
|
||||||
|
phaseId: "smoothing",
|
||||||
|
hint: "Быстрое двустороннее сглаживание."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fast Bilateral Filter OMP",
|
||||||
|
idValue: "pcl_fast_bilateral_filter_omp",
|
||||||
|
phaseId: "smoothing",
|
||||||
|
hint: "Параллельная версия bilateral-фильтра."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Convolution",
|
||||||
|
idValue: "pcl_convolution",
|
||||||
|
phaseId: "smoothing",
|
||||||
|
hint: "Свертка облака точек с ядром."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gaussian Kernel",
|
||||||
|
idValue: "pcl_gaussian_kernel",
|
||||||
|
phaseId: "smoothing",
|
||||||
|
hint: "Гауссово ядро свертки."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gaussian Kernel RGB",
|
||||||
|
idValue: "pcl_gaussian_kernel_rgb",
|
||||||
|
phaseId: "smoothing",
|
||||||
|
hint: "Гауссово ядро с RGB-ориентированной семантикой."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "VoxelGrid Occlusion Estimation",
|
||||||
|
idValue: "pcl_voxel_grid_occlusion",
|
||||||
|
phaseId: "morphology",
|
||||||
|
hint: "Оценка окклюзии через заполненность вокселей."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fallback Surface",
|
||||||
|
idValue: "surface_fallback",
|
||||||
|
phaseId: "reconstruction",
|
||||||
|
family: "reconstruction",
|
||||||
|
hint: "Базовая реконструкция поверхности по облаку точек."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PCL Greedy Triangulation",
|
||||||
|
idValue: "pcl_greedy_triangulation",
|
||||||
|
phaseId: "reconstruction",
|
||||||
|
family: "reconstruction",
|
||||||
|
hint: "Жадная триангуляция с параметрами радиуса и углов."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PCL Poisson Reconstruction",
|
||||||
|
idValue: "pcl_poisson_reconstruction",
|
||||||
|
phaseId: "reconstruction",
|
||||||
|
family: "reconstruction",
|
||||||
|
hint: "Реконструкция поверхности методом Poisson из libpcl_surface."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Прореживание плотности",
|
||||||
|
idValue: "downsample_dense",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Снижает число точек для ускорения пайплайна на плотных облаках."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PCL Voxel Grid",
|
||||||
|
idValue: "pcl_voxel_grid",
|
||||||
|
phaseId: "downsample",
|
||||||
|
hint: "Равномерно прореживает облако с помощью воксельной сетки."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPaletteModel() {
|
||||||
|
return filterDefinitions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPaletteGroupedModel() {
|
||||||
|
var phases = phaseModel()
|
||||||
|
var filters = filterDefinitions()
|
||||||
|
var grouped = []
|
||||||
|
for (var p = 0; p < phases.length; ++p) {
|
||||||
|
var phase = phases[p]
|
||||||
|
var items = []
|
||||||
|
for (var i = 0; i < filters.length; ++i) {
|
||||||
|
if (filters[i].phaseId === phase.id)
|
||||||
|
items.push(filters[i])
|
||||||
|
}
|
||||||
|
grouped.push({
|
||||||
|
phaseId: phase.id,
|
||||||
|
phaseTitle: phase.title,
|
||||||
|
phaseHint: phase.hint,
|
||||||
|
phaseAccent: phase.accent,
|
||||||
|
phaseFilterBg: phase.filterBg,
|
||||||
|
phaseFilterBorder: phase.filterBorder,
|
||||||
|
items: items
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconstructionPaletteModel() {
|
||||||
|
return [
|
||||||
|
{ title: "Fallback Surface", idValue: "surface_fallback" },
|
||||||
|
{ title: "PCL Greedy Triangulation", idValue: "pcl_greedy_triangulation" },
|
||||||
|
{ title: "PCL Poisson Reconstruction", idValue: "pcl_poisson_reconstruction" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
inferParamType,
|
||||||
|
parseDefaults,
|
||||||
|
serializeParams,
|
||||||
|
paramDescription,
|
||||||
|
reconstructionTitle,
|
||||||
|
phaseModel,
|
||||||
|
filterDefinitions,
|
||||||
|
filterPaletteModel,
|
||||||
|
filterPaletteGroupedModel,
|
||||||
|
reconstructionPaletteModel,
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
export const panelBg = "#141821";
|
||||||
|
export const cardBg = "#111a2c";
|
||||||
|
export const cardBorder = "#33486f";
|
||||||
|
|
||||||
|
export const chainSelectedBorder = "#6ea8ff";
|
||||||
|
export const chainIdleBorder = "#384866";
|
||||||
|
export const chainEnabledBg = "#1f2a3d";
|
||||||
|
export const chainDisabledBg = "#23272f";
|
||||||
|
export const chainDragOverlay = "#39507d";
|
||||||
|
export const chainDragHandle = "#9fb4da";
|
||||||
|
|
||||||
|
export const reconstructionBg = "#2d2438";
|
||||||
|
export const reconstructionBorder = "#625481";
|
||||||
|
export const reconstructionAccent = "#d9c8ff";
|
||||||
|
export const reconstructionText = "#efe7ff";
|
||||||
|
export const reconstructionSubtext = "#c3b2e8";
|
||||||
|
|
||||||
|
export const paletteFilterBg = "#26344f";
|
||||||
|
export const paletteFilterBorder = "#3d547d";
|
||||||
|
export const paletteReconBg = "#3a2f44";
|
||||||
|
export const paletteReconBorder = "#625481";
|
||||||
|
export const paletteHeader = "#d6e3ff";
|
||||||
|
export const paletteFilterText = "#eaf0ff";
|
||||||
|
export const paletteReconText = "#efe7ff";
|
||||||
|
|
||||||
|
export const summaryPrimaryText = "#eaf0ff";
|
||||||
|
export const summarySecondaryText = "#cde0ff";
|
||||||
|
export const summaryRecommendation = "#9ec3ff";
|
||||||
|
export const summaryDetailText = "#b8cff8";
|
||||||
|
|
||||||
|
export const dialogHintText = "#c7d6f3";
|
||||||
|
export const dialogDescriptionText = "#aebfde";
|
||||||
|
export const dialogSectionText = "#dce7ff";
|
||||||
|
export const dialogBg = "#1a2234";
|
||||||
|
export const dialogBorder = "#3a4f78";
|
||||||
|
|
||||||
|
export const controlBg = "#23324d";
|
||||||
|
export const controlBorder = "#4a6494";
|
||||||
|
export const controlText = "#eaf0ff";
|
||||||
|
export const controlPlaceholder = "#9cb0d6";
|
||||||
|
export const controlHoverBg = "#2a3b5a";
|
||||||
|
|
||||||
|
export const buttonBg = "#2c3f61";
|
||||||
|
export const buttonBorder = "#5574a9";
|
||||||
|
export const buttonText = "#eef4ff";
|
||||||
|
export const buttonHoverBg = "#35507a";
|
||||||
|
export const buttonPressedBg = "#273b5c";
|
||||||
|
|
||||||
|
export const primaryButtonBg = "#ffb020";
|
||||||
|
export const primaryButtonBorder = "#ffd27a";
|
||||||
|
export const primaryButtonText = "#142033";
|
||||||
|
export const primaryButtonHoverBg = "#ffc247";
|
||||||
|
export const primaryButtonPressedBg = "#e39b0f";
|
||||||
|
|
||||||
|
export const spacingXs = 4;
|
||||||
|
export const spacingSm = 6;
|
||||||
|
export const spacingMd = 8;
|
||||||
|
export const spacingLg = 10;
|
||||||
|
export const radiusSm = 6;
|
||||||
|
export const radiusMd = 8;
|
||||||
|
export const compactControlHeight = 28;
|
||||||
|
|
||||||
|
export const dashboardSurfaceComboWidth = 170;
|
||||||
|
export const dashboardMetricsHeight = 148;
|
||||||
|
export const dashboardBottomMargin = 8;
|
||||||
|
export const palettePanelWidth = 260;
|
||||||
|
|
||||||
|
export const stageRowHeight = 48;
|
||||||
|
export const stageHandleWidth = 16;
|
||||||
|
export const reconstructionBadgeWidth = 28;
|
||||||
|
export const paletteItemHeight = 30;
|
||||||
|
|
||||||
|
export const dialogWidth = 390;
|
||||||
|
export const dialogLabelWidth = 128;
|
||||||
|
export const dialogResetButtonWidth = 168;
|
||||||
|
export const dialogOkButtonWidth = 76;
|
||||||
|
export const dialogContentSpacing = 4;
|
||||||
|
export const dialogRowSpacing = 6;
|
||||||
|
|
||||||
|
export const fontXs = 10;
|
||||||
|
export const fontSm = 11;
|
||||||
|
export const fontMd = 12;
|
||||||
|
export const fontLg = 14;
|
||||||
|
|
||||||
|
export const theme = {
|
||||||
|
panelBg,
|
||||||
|
cardBg,
|
||||||
|
cardBorder,
|
||||||
|
chainSelectedBorder,
|
||||||
|
chainIdleBorder,
|
||||||
|
chainEnabledBg,
|
||||||
|
chainDisabledBg,
|
||||||
|
chainDragOverlay,
|
||||||
|
chainDragHandle,
|
||||||
|
reconstructionBg,
|
||||||
|
reconstructionBorder,
|
||||||
|
reconstructionAccent,
|
||||||
|
reconstructionText,
|
||||||
|
reconstructionSubtext,
|
||||||
|
paletteFilterBg,
|
||||||
|
paletteFilterBorder,
|
||||||
|
paletteReconBg,
|
||||||
|
paletteReconBorder,
|
||||||
|
paletteHeader,
|
||||||
|
paletteFilterText,
|
||||||
|
paletteReconText,
|
||||||
|
summaryPrimaryText,
|
||||||
|
summarySecondaryText,
|
||||||
|
summaryRecommendation,
|
||||||
|
summaryDetailText,
|
||||||
|
dialogHintText,
|
||||||
|
dialogDescriptionText,
|
||||||
|
dialogSectionText,
|
||||||
|
dialogBg,
|
||||||
|
dialogBorder,
|
||||||
|
controlBg,
|
||||||
|
controlBorder,
|
||||||
|
controlText,
|
||||||
|
controlPlaceholder,
|
||||||
|
controlHoverBg,
|
||||||
|
buttonBg,
|
||||||
|
buttonBorder,
|
||||||
|
buttonText,
|
||||||
|
buttonHoverBg,
|
||||||
|
buttonPressedBg,
|
||||||
|
primaryButtonBg,
|
||||||
|
primaryButtonBorder,
|
||||||
|
primaryButtonText,
|
||||||
|
primaryButtonHoverBg,
|
||||||
|
primaryButtonPressedBg,
|
||||||
|
spacingXs,
|
||||||
|
spacingSm,
|
||||||
|
spacingMd,
|
||||||
|
spacingLg,
|
||||||
|
radiusSm,
|
||||||
|
radiusMd,
|
||||||
|
compactControlHeight,
|
||||||
|
dashboardSurfaceComboWidth,
|
||||||
|
dashboardMetricsHeight,
|
||||||
|
dashboardBottomMargin,
|
||||||
|
palettePanelWidth,
|
||||||
|
stageRowHeight,
|
||||||
|
stageHandleWidth,
|
||||||
|
reconstructionBadgeWidth,
|
||||||
|
paletteItemHeight,
|
||||||
|
dialogWidth,
|
||||||
|
dialogLabelWidth,
|
||||||
|
dialogResetButtonWidth,
|
||||||
|
dialogOkButtonWidth,
|
||||||
|
dialogContentSpacing,
|
||||||
|
dialogRowSpacing,
|
||||||
|
fontXs,
|
||||||
|
fontSm,
|
||||||
|
fontMd,
|
||||||
|
fontLg,
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
import PipelineCanvas from "@/components/pipeline/PipelineCanvas.vue";
|
||||||
|
import StageSettingsDialog from "@/components/pipeline/StageSettingsDialog.vue";
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
const selectedPreset = ref("");
|
||||||
|
const snapshotName = ref("");
|
||||||
|
const presetTitle = ref("");
|
||||||
|
const settings = ref(null);
|
||||||
|
|
||||||
|
async function onApplyPreset() {
|
||||||
|
if (!selectedPreset.value) return;
|
||||||
|
await store.applyPreset(selectedPreset.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRun() {
|
||||||
|
const useBinary = (store.currentFile?.size || 0) > 8 * 1024 * 1024;
|
||||||
|
await store.runPipeline({ geometryFormat: useBinary ? "binary" : "json" });
|
||||||
|
if (store.geometryUrl) {
|
||||||
|
const workId = store.geometryUrl.split("/").pop();
|
||||||
|
await store.loadGeometryFromUrl(workId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGenerateDemo() {
|
||||||
|
await store.runPipeline({ useDemo: true, geometryFormat: "json" });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel controls">
|
||||||
|
<div class="toolbar">
|
||||||
|
<select v-model="store.demoSurfaceType">
|
||||||
|
<option v-for="type in store.demoSurfaceTypes" :key="type" :value="type">{{ type }}</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="onGenerateDemo" :disabled="store.busy">Сгенерировать</button>
|
||||||
|
<label class="file-label">
|
||||||
|
Загрузить
|
||||||
|
<input type="file" accept=".ply,.txt,.csv,.xyz,.bin" @change="store.setCurrentFile($event.target.files?.[0] || null)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PipelineCanvas @open-settings="settings?.open($event)" />
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<label><input type="checkbox" v-model="store.surfaceVisible" /> Показать mesh</label>
|
||||||
|
<button type="button" class="primary" :disabled="store.busy" @click="onRun">Применить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar presets">
|
||||||
|
<select v-model="selectedPreset">
|
||||||
|
<option value="">Пресет...</option>
|
||||||
|
<option v-for="preset in store.presetItems" :key="preset.idValue || preset.id" :value="preset.idValue || preset.id">
|
||||||
|
{{ preset.title }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="onApplyPreset">Применить пресет</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar presets">
|
||||||
|
<input v-model="presetTitle" placeholder="Имя пресета" />
|
||||||
|
<button type="button" @click="store.saveCurrentPreset(presetTitle)" :disabled="!presetTitle">Сохранить пресет</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar presets">
|
||||||
|
<input v-model="snapshotName" placeholder="Snapshot name" />
|
||||||
|
<button type="button" @click="store.saveSnapshot(snapshotName)">Save snapshot</button>
|
||||||
|
<button
|
||||||
|
v-for="snap in store.snapshots"
|
||||||
|
:key="snap.name"
|
||||||
|
type="button"
|
||||||
|
@click="store.loadSnapshot(snap.name)"
|
||||||
|
>{{ snap.name }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status">{{ store.statusText }}</p>
|
||||||
|
<StageSettingsDialog ref="settings" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.file-label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; }
|
||||||
|
.status { color: var(--muted-text); font-size: 13px; white-space: pre-wrap; margin: 0; }
|
||||||
|
.presets input { flex: 1; min-width: 120px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup>
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wizard panel">
|
||||||
|
<h3>Quick Wizard</h3>
|
||||||
|
<label>Data profile</label>
|
||||||
|
<select v-model="store.wizardProfile">
|
||||||
|
<option value="general">general</option>
|
||||||
|
<option value="urban_scan">urban_scan</option>
|
||||||
|
<option value="indoor_object">indoor_object</option>
|
||||||
|
</select>
|
||||||
|
<label>Optimization goal</label>
|
||||||
|
<select v-model="store.wizardGoal">
|
||||||
|
<option value="speed">speed</option>
|
||||||
|
<option value="balanced">balanced</option>
|
||||||
|
<option value="quality">quality</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">Wizard builds a start chain and explains trade-offs.</p>
|
||||||
|
<button type="button" @click="store.applyWizard()">Generate suggested chain</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wizard { display: grid; gap: 8px; }
|
||||||
|
.wizard h3 { margin: 0; font-size: 14px; }
|
||||||
|
.hint { color: var(--hint-text); font-size: 12px; margin: 0; }
|
||||||
|
label { font-size: 12px; color: var(--label-text); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup>
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
|
||||||
|
function localizedHealth(value) {
|
||||||
|
if (value === "OK") return "ОК";
|
||||||
|
if (value === "Warning") return "Предупреждение";
|
||||||
|
if (value === "Risk") return "Риск";
|
||||||
|
return value || "-";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel metrics-strip">
|
||||||
|
<h3>Сводка</h3>
|
||||||
|
<div class="row">
|
||||||
|
<strong>Состояние: {{ localizedHealth(store.chainHealth) }}</strong>
|
||||||
|
<span>Треугольники: {{ store.metrics.triangles }}</span>
|
||||||
|
<span>Реконструкция, мс: {{ store.metrics.reconstructMs }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="recommendation">{{ store.recommendation }}</p>
|
||||||
|
<ul v-if="store.warningsList.length" class="warnings">
|
||||||
|
<li v-for="(warning, index) in store.warningsList" :key="index">{{ warning }}</li>
|
||||||
|
</ul>
|
||||||
|
<table v-if="store.stageMetrics.length" class="metrics-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Этап</th>
|
||||||
|
<th>Вход</th>
|
||||||
|
<th>Выход</th>
|
||||||
|
<th>Удалено</th>
|
||||||
|
<th>мс</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in store.stageMetrics" :key="row.stageId">
|
||||||
|
<td>{{ row.stageId }}</td>
|
||||||
|
<td>{{ row.inputPoints }}</td>
|
||||||
|
<td>{{ row.outputPoints }}</td>
|
||||||
|
<td>{{ row.removedPoints }}</td>
|
||||||
|
<td>{{ row.elapsedMs }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div>Вход: {{ store.metrics.inputPoints }}</div>
|
||||||
|
<div>После preprocess: {{ store.metrics.afterPreprocess }}</div>
|
||||||
|
<div>Кластеры: {{ store.metrics.clusters }}</div>
|
||||||
|
<div>Удалено: {{ store.metrics.removedPoints }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.metrics-strip h3 { margin: 0 0 8px; }
|
||||||
|
.row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; }
|
||||||
|
.recommendation { color: var(--summary-recommendation); font-size: 13px; }
|
||||||
|
.warnings { margin: 8px 0; padding-left: 18px; color: var(--warning-text); font-size: 13px; }
|
||||||
|
.metrics-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
|
||||||
|
.metrics-table th, .metrics-table td { border-bottom: 1px solid var(--card-border); padding: 4px; text-align: left; }
|
||||||
|
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; font-size: 13px; color: var(--summary-secondary); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup>
|
||||||
|
import StageChainView from "./StageChainView.vue";
|
||||||
|
import StagePalette from "./StagePalette.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["openSettings"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="canvas panel">
|
||||||
|
<div class="canvas-grid">
|
||||||
|
<StageChainView @open-settings="emit('openSettings', $event)" />
|
||||||
|
<StagePalette />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.canvas {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
max-height: min(420px, 48vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-grid > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.canvas-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import Sortable from "sortablejs";
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
const listRef = ref(null);
|
||||||
|
const emit = defineEmits(["openSettings"]);
|
||||||
|
|
||||||
|
function categoryColor(category) {
|
||||||
|
const map = {
|
||||||
|
"Обрезка": "#88d1ff",
|
||||||
|
"NaN (предочистка)": "#8fe8ff",
|
||||||
|
"Условия/Индексы": "#9fd7ff",
|
||||||
|
"Шум": "#a4f4b9",
|
||||||
|
"Морфология": "#9cf5d1",
|
||||||
|
"Прореживание": "#ffcb8a",
|
||||||
|
"Сглаживание": "#ffd892",
|
||||||
|
"Нормали": "#d2b7ff",
|
||||||
|
"Реконструкция": "#ff9dc4",
|
||||||
|
};
|
||||||
|
return map[category] || "#9fb4da";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!listRef.value) return;
|
||||||
|
Sortable.create(listRef.value, {
|
||||||
|
animation: 150,
|
||||||
|
handle: ".drag-handle",
|
||||||
|
onEnd(evt) {
|
||||||
|
if (evt.oldIndex == null || evt.newIndex == null) return;
|
||||||
|
store.moveStage(evt.oldIndex, evt.newIndex);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="listRef" class="chain-list">
|
||||||
|
<div
|
||||||
|
v-for="(card, index) in store.stageCards"
|
||||||
|
:key="`${card.id}-${index}`"
|
||||||
|
class="chain-row"
|
||||||
|
:class="{ selected: store.selectedStageIndex === index, disabled: !card.enabled }"
|
||||||
|
@click="store.setSelectedStageIndex(index)"
|
||||||
|
@dblclick="emit('openSettings', card)"
|
||||||
|
>
|
||||||
|
<span class="accent" :style="{ background: categoryColor(card.category) }" />
|
||||||
|
<span class="drag-handle" :class="{ hidden: card.family === 'reconstruction' }">↕</span>
|
||||||
|
<div class="meta">
|
||||||
|
<strong>{{ card.title }}</strong>
|
||||||
|
<small>{{ card.category }} | {{ card.id }}</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="card.family !== 'reconstruction'"
|
||||||
|
type="button"
|
||||||
|
class="mini"
|
||||||
|
@click.stop="store.removeStage(index)"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chain-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: min(420px, 48vh);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.chain-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4px 20px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--chain-idle-border);
|
||||||
|
background: var(--chain-enabled-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chain-row.selected { border-color: var(--chain-selected-border); }
|
||||||
|
.chain-row.disabled { background: var(--chain-disabled-bg); opacity: 0.7; }
|
||||||
|
.accent { height: 100%; border-radius: 2px; }
|
||||||
|
.drag-handle { text-align: center; color: var(--chain-handle-text); cursor: grab; }
|
||||||
|
.drag-handle.hidden { visibility: hidden; }
|
||||||
|
.meta { display: grid; gap: 2px; min-width: 0; }
|
||||||
|
.meta strong, .meta small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.meta small { color: var(--chain-handle-text); font-size: 11px; }
|
||||||
|
.mini { width: 28px; height: 28px; padding: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
function addFilter(item) {
|
||||||
|
store.addStage(item.idValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseHeaderStyle(group) {
|
||||||
|
if (theme.value === "light") {
|
||||||
|
return {
|
||||||
|
color: "#1e293b",
|
||||||
|
borderColor: group.phaseFilterBorder,
|
||||||
|
background: `color-mix(in srgb, ${group.phaseAccent} 16%, #ffffff)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
color: group.phaseAccent,
|
||||||
|
borderColor: group.phaseFilterBorder,
|
||||||
|
background: group.phaseFilterBg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="palette">
|
||||||
|
<section v-for="group in store.paletteGroups" :key="group.phaseId" class="phase">
|
||||||
|
<header :style="phaseHeaderStyle(group)">
|
||||||
|
{{ group.phaseTitle }}
|
||||||
|
</header>
|
||||||
|
<button
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.idValue"
|
||||||
|
type="button"
|
||||||
|
class="palette-item"
|
||||||
|
@click="addFilter(item)"
|
||||||
|
>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<small>{{ item.hint }}</small>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.palette {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: min(420px, 48vh);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.phase header {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--palette-filter-border);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.palette-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: var(--palette-filter-bg);
|
||||||
|
border-color: var(--palette-filter-border);
|
||||||
|
color: var(--control-text);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.palette-item span,
|
||||||
|
.palette-item small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.palette-item small { color: var(--palette-hint-text); font-size: 10px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
import { parseDefaults, serializeParams, paramDescription } from "@/catalog/pipelineUiCatalog";
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
const visible = ref(false);
|
||||||
|
const paramItems = ref([]);
|
||||||
|
const editingCard = ref(null);
|
||||||
|
|
||||||
|
function open(card) {
|
||||||
|
editingCard.value = card;
|
||||||
|
paramItems.value = parseDefaults(card.defaults || "");
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const index = store.stageCards.findIndex((c) => c.id === editingCard.value?.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
store.setStageDefaults(index, serializeParams(paramItems.value));
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetDefaults() {
|
||||||
|
if (!editingCard.value) return;
|
||||||
|
const defaults = await store.defaultsForStage(editingCard.value.id);
|
||||||
|
paramItems.value = parseDefaults(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-backdrop" @click.self="close">
|
||||||
|
<div class="dialog panel">
|
||||||
|
<h3>{{ editingCard?.title }}</h3>
|
||||||
|
<p class="hint">{{ editingCard?.hint }}</p>
|
||||||
|
<div v-for="(param, index) in paramItems" :key="param.key" class="param-row">
|
||||||
|
<label>{{ param.key }}</label>
|
||||||
|
<select v-if="param.kind === 'bool'" v-model="param.value">
|
||||||
|
<option value="false">false</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
</select>
|
||||||
|
<input v-else v-model="param.value" />
|
||||||
|
<small>{{ paramDescription(param.key) }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" @click="resetDefaults">Установить по умолчанию</button>
|
||||||
|
<button type="button" class="primary" @click="save">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-backdrop { position: fixed; inset: 0; background: var(--dialog-backdrop); display: grid; place-items: center; z-index: 20; }
|
||||||
|
.dialog { width: min(420px, 92vw); max-height: 80vh; overflow: auto; }
|
||||||
|
.hint { color: var(--dialog-hint-text); font-size: 13px; }
|
||||||
|
.param-row { display: grid; gap: 4px; margin-bottom: 10px; }
|
||||||
|
.param-row label { font-size: 12px; color: var(--dialog-label-text); }
|
||||||
|
.param-row small { color: var(--dialog-hint-text); font-size: 11px; }
|
||||||
|
.actions { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup>
|
||||||
|
const emit = defineEmits(["download", "resetCamera"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="modebar" role="toolbar" aria-label="Управление 3D-просмотром">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modebar-btn"
|
||||||
|
title="Сохранить снимок"
|
||||||
|
aria-label="Сохранить снимок"
|
||||||
|
@click="emit('download')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
|
||||||
|
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modebar-btn"
|
||||||
|
title="Сбросить камеру"
|
||||||
|
aria-label="Сбросить камеру"
|
||||||
|
@click="emit('resetCamera')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 10.5 10.5 4 17 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M10.5 4v16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modebar-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--modebar-icon, #636363);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modebar-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--control-text) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modebar-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import { usePipelineStore } from "@/stores/pipeline";
|
||||||
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
import { usePointCloudViewer } from "@/composables/usePointCloudViewer";
|
||||||
|
import ViewerModebar from "./ViewerModebar.vue";
|
||||||
|
|
||||||
|
const VIEWPORT_HEIGHT_KEY = "dotstosirface-viewport-height";
|
||||||
|
const MIN_VIEWPORT_HEIGHT = 240;
|
||||||
|
const MAX_VIEWPORT_HEIGHT = Math.min(window.innerHeight - 80, 1200);
|
||||||
|
|
||||||
|
const store = usePipelineStore();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const viewportRef = ref(null);
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
const viewportHeight = ref(loadViewportHeight());
|
||||||
|
const {
|
||||||
|
renderGeometry,
|
||||||
|
resize,
|
||||||
|
setBackground,
|
||||||
|
resetCamera,
|
||||||
|
downloadSnapshot,
|
||||||
|
} = usePointCloudViewer(viewportRef);
|
||||||
|
|
||||||
|
function loadViewportHeight() {
|
||||||
|
const saved = Number(localStorage.getItem(VIEWPORT_HEIGHT_KEY));
|
||||||
|
if (Number.isFinite(saved) && saved >= MIN_VIEWPORT_HEIGHT) {
|
||||||
|
return Math.min(saved, MAX_VIEWPORT_HEIGHT);
|
||||||
|
}
|
||||||
|
return 520;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveViewportHeight() {
|
||||||
|
localStorage.setItem(VIEWPORT_HEIGHT_KEY, String(viewportHeight.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewerBackground() {
|
||||||
|
return theme.value === "light" ? 0xe2e8f0 : 0x0b1118;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFullscreenChange() {
|
||||||
|
isFullscreen.value = document.fullscreenElement === viewportRef.value;
|
||||||
|
resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!viewportRef.value) return;
|
||||||
|
if (document.fullscreenElement === viewportRef.value) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
await viewportRef.value.requestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(event) {
|
||||||
|
if (isFullscreen.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const startY = event.clientY;
|
||||||
|
const startHeight = viewportHeight.value;
|
||||||
|
|
||||||
|
function onMove(moveEvent) {
|
||||||
|
const nextHeight = startHeight + (moveEvent.clientY - startY);
|
||||||
|
viewportHeight.value = Math.min(
|
||||||
|
Math.max(nextHeight, MIN_VIEWPORT_HEIGHT),
|
||||||
|
Math.min(window.innerHeight - 80, 1200),
|
||||||
|
);
|
||||||
|
resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
saveViewportHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = "ns-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [store.viewerPoints, store.viewerTriangles, store.surfaceVisible],
|
||||||
|
() => {
|
||||||
|
renderGeometry(store.viewerPoints, store.viewerTriangles, store.surfaceVisible);
|
||||||
|
resize();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(theme, () => {
|
||||||
|
setBackground(viewerBackground());
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(viewportHeight, () => {
|
||||||
|
resize();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resize();
|
||||||
|
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel viewer">
|
||||||
|
<h2>3D Viewer</h2>
|
||||||
|
<div
|
||||||
|
class="viewport-shell"
|
||||||
|
:class="{ fullscreen: isFullscreen }"
|
||||||
|
:style="isFullscreen ? undefined : { height: `${viewportHeight}px` }"
|
||||||
|
>
|
||||||
|
<div ref="viewportRef" class="viewport">
|
||||||
|
<ViewerModebar
|
||||||
|
@download="downloadSnapshot"
|
||||||
|
@reset-camera="resetCamera"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fullscreen-btn"
|
||||||
|
:title="isFullscreen ? 'Свернуть' : 'Развернуть на весь экран'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<svg v-if="!isFullscreen" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M8 3H3v5M16 3h5v5M16 21h5v-5M8 21H3v-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M9 9H3V3M15 9h6V3M15 15h6v6M9 15H3v6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!isFullscreen"
|
||||||
|
class="resize-handle"
|
||||||
|
title="Потяните, чтобы изменить высоту"
|
||||||
|
@mousedown="startResize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Drag to rotate, wheel to zoom.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.viewer { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.viewer h2 { margin: 0 0 8px; font-size: 18px; }
|
||||||
|
|
||||||
|
.viewport-shell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--viewer-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport:fullscreen {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
color: var(--modebar-icon, #636363);
|
||||||
|
opacity: 0.95;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
flex: 0 0 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle::after {
|
||||||
|
content: "";
|
||||||
|
width: 56px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--card-border);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover::after {
|
||||||
|
background: var(--chain-selected-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint { color: var(--hint-text); font-size: 13px; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { onBeforeUnmount, onMounted } from "vue";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
|
||||||
|
export function usePointCloudViewer(containerRef) {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0b1118);
|
||||||
|
const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 100000);
|
||||||
|
camera.position.set(2.5, 2, 2.5);
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||||
|
let controls = null;
|
||||||
|
let pointCloud = null;
|
||||||
|
let meshObject = null;
|
||||||
|
let animationId = 0;
|
||||||
|
let lastPoints = null;
|
||||||
|
let defaultCameraState = null;
|
||||||
|
|
||||||
|
function storeDefaultCameraState() {
|
||||||
|
defaultCameraState = {
|
||||||
|
position: camera.position.clone(),
|
||||||
|
target: controls?.target.clone() || new THREE.Vector3(),
|
||||||
|
near: camera.near,
|
||||||
|
far: camera.far,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const el = containerRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const width = el.clientWidth;
|
||||||
|
const height = el.clientHeight;
|
||||||
|
camera.aspect = width / Math.max(height, 1);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
controls?.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearObjects() {
|
||||||
|
if (pointCloud) {
|
||||||
|
scene.remove(pointCloud);
|
||||||
|
pointCloud.geometry.dispose();
|
||||||
|
pointCloud.material.dispose();
|
||||||
|
pointCloud = null;
|
||||||
|
}
|
||||||
|
if (meshObject) {
|
||||||
|
scene.remove(meshObject);
|
||||||
|
meshObject.geometry.dispose();
|
||||||
|
meshObject.material.dispose();
|
||||||
|
meshObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCamera(points) {
|
||||||
|
if (!points?.length) return;
|
||||||
|
const box = new THREE.Box3();
|
||||||
|
for (const p of points) box.expandByPoint(new THREE.Vector3(p[0], p[1], p[2]));
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1;
|
||||||
|
controls.target.copy(center);
|
||||||
|
camera.position.copy(center.clone().add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8)));
|
||||||
|
camera.near = Math.max(radius / 1000, 0.0001);
|
||||||
|
camera.far = radius * 100;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls?.update();
|
||||||
|
storeDefaultCameraState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeometry(points, triangleIndices, surfaceVisible = true) {
|
||||||
|
clearObjects();
|
||||||
|
lastPoints = points;
|
||||||
|
if (!points?.length) return;
|
||||||
|
|
||||||
|
const positions = new Float32Array(points.length * 3);
|
||||||
|
for (let i = 0; i < points.length; i += 1) {
|
||||||
|
positions[i * 3] = points[i][0];
|
||||||
|
positions[i * 3 + 1] = points[i][1];
|
||||||
|
positions[i * 3 + 2] = points[i][2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsGeometry = new THREE.BufferGeometry();
|
||||||
|
pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
|
pointCloud = new THREE.Points(
|
||||||
|
pointsGeometry,
|
||||||
|
new THREE.PointsMaterial({ color: 0x7dd3fc, size: 0.01, sizeAttenuation: true }),
|
||||||
|
);
|
||||||
|
scene.add(pointCloud);
|
||||||
|
|
||||||
|
if (surfaceVisible && triangleIndices?.length) {
|
||||||
|
const indices = new Uint32Array(triangleIndices.length * 3);
|
||||||
|
for (let i = 0; i < triangleIndices.length; i += 1) {
|
||||||
|
indices[i * 3] = triangleIndices[i][0];
|
||||||
|
indices[i * 3 + 1] = triangleIndices[i][1];
|
||||||
|
indices[i * 3 + 2] = triangleIndices[i][2];
|
||||||
|
}
|
||||||
|
const meshGeometry = new THREE.BufferGeometry();
|
||||||
|
meshGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
|
meshGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
meshGeometry.computeVertexNormals();
|
||||||
|
meshObject = new THREE.Mesh(
|
||||||
|
meshGeometry,
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x60a5fa,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.55,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
scene.add(meshObject);
|
||||||
|
}
|
||||||
|
fitCamera(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCamera() {
|
||||||
|
if (defaultCameraState && controls) {
|
||||||
|
camera.position.copy(defaultCameraState.position);
|
||||||
|
controls.target.copy(defaultCameraState.target);
|
||||||
|
camera.near = defaultCameraState.near;
|
||||||
|
camera.far = defaultCameraState.far;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastPoints?.length) {
|
||||||
|
fitCamera(lastPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSnapshot() {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const dataUrl = renderer.domElement.toDataURL("image/png");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.download = `dotstosirface-view-${Date.now()}.png`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBackground(color) {
|
||||||
|
scene.background = new THREE.Color(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = containerRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.appendChild(renderer.domElement);
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
||||||
|
const light = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||||
|
light.position.set(4, 6, 3);
|
||||||
|
scene.add(light);
|
||||||
|
resize();
|
||||||
|
animate();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
clearObjects();
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderGeometry,
|
||||||
|
resize,
|
||||||
|
setBackground,
|
||||||
|
resetCamera,
|
||||||
|
downloadSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
const THEME_KEY = "dotstosirface-theme";
|
||||||
|
const theme = ref("dark");
|
||||||
|
|
||||||
|
function applyTheme(value) {
|
||||||
|
document.documentElement.setAttribute("data-theme", value);
|
||||||
|
localStorage.setItem(THEME_KEY, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTheme() {
|
||||||
|
const saved = localStorage.getItem(THEME_KEY);
|
||||||
|
theme.value = saved === "light" ? "light" : "dark";
|
||||||
|
applyTheme(theme.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(theme, applyTheme);
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
function toggleTheme() {
|
||||||
|
theme.value = theme.value === "dark" ? "light" : "dark";
|
||||||
|
}
|
||||||
|
return { theme, toggleTheme };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { initTheme } from "./composables/useTheme";
|
||||||
|
import "./styles/main.css";
|
||||||
|
|
||||||
|
initTheme();
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.mount("#app");
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import {
|
||||||
|
filterPaletteGroupedModel,
|
||||||
|
reconstructionPaletteModel,
|
||||||
|
} from "@/catalog/pipelineUiCatalog";
|
||||||
|
|
||||||
|
function configFromCards(stageCards) {
|
||||||
|
const preprocessPlugins = [];
|
||||||
|
const stageDefaults = {};
|
||||||
|
let reconstructionPlugin = "surface_fallback";
|
||||||
|
for (const card of stageCards) {
|
||||||
|
if (!card.enabled) continue;
|
||||||
|
if (card.family === "preprocess") preprocessPlugins.push(card.id);
|
||||||
|
if (card.family === "reconstruction") reconstructionPlugin = card.id;
|
||||||
|
if (card.defaults) stageDefaults[card.id] = card.defaults;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profile: "desktop_debug",
|
||||||
|
preprocessPlugins,
|
||||||
|
reconstructionPlugin,
|
||||||
|
stageDefaults,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardsFromConfig(config, stageMetaById = {}) {
|
||||||
|
const cards = [];
|
||||||
|
const stageDefaults = config.stageDefaults || {};
|
||||||
|
for (const stageId of config.preprocessPlugins || []) {
|
||||||
|
const meta = stageMetaById[stageId] || {};
|
||||||
|
cards.push({
|
||||||
|
id: stageId,
|
||||||
|
title: meta.title || stageId,
|
||||||
|
category: meta.category || "Custom",
|
||||||
|
family: "preprocess",
|
||||||
|
hint: meta.hint || "",
|
||||||
|
defaults: stageDefaults[stageId] ?? meta.defaults ?? "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const reconId = config.reconstructionPlugin || "surface_fallback";
|
||||||
|
const reconMeta = stageMetaById[reconId] || {};
|
||||||
|
cards.push({
|
||||||
|
id: reconId,
|
||||||
|
title: reconMeta.title || reconId,
|
||||||
|
category: reconMeta.category || "Реконструкция",
|
||||||
|
family: "reconstruction",
|
||||||
|
hint: reconMeta.hint || "",
|
||||||
|
defaults: stageDefaults[reconId] ?? reconMeta.defaults ?? "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaForStage(stageId, stageMetaById) {
|
||||||
|
return stageMetaById[stageId] || {
|
||||||
|
id: stageId,
|
||||||
|
title: stageId,
|
||||||
|
category: "Custom",
|
||||||
|
family: "preprocess",
|
||||||
|
hint: "",
|
||||||
|
defaults: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePipelineStore = defineStore("pipeline", {
|
||||||
|
state: () => ({
|
||||||
|
busy: false,
|
||||||
|
statusText: "Загрузите облако или сгенерируйте демо.",
|
||||||
|
stageCards: [],
|
||||||
|
selectedStageIndex: -1,
|
||||||
|
surfaceVisible: true,
|
||||||
|
demoSurfaceType: "Сфера",
|
||||||
|
demoSurfaceTypes: ["Сфера", "Тор", "Волна", "Дно реки + труба"],
|
||||||
|
wizardProfile: "general",
|
||||||
|
wizardGoal: "balanced",
|
||||||
|
presetItems: [],
|
||||||
|
metrics: {
|
||||||
|
inputPoints: 0,
|
||||||
|
afterPreprocess: 0,
|
||||||
|
triangles: 0,
|
||||||
|
reconstructMs: 0,
|
||||||
|
clusters: 0,
|
||||||
|
removedPoints: 0,
|
||||||
|
},
|
||||||
|
stageMetrics: [],
|
||||||
|
chainHealth: "",
|
||||||
|
recommendation: "",
|
||||||
|
warningsList: [],
|
||||||
|
paletteGroups: filterPaletteGroupedModel(),
|
||||||
|
reconstructionOptions: reconstructionPaletteModel(),
|
||||||
|
snapshots: [],
|
||||||
|
currentFile: null,
|
||||||
|
usingDemoInput: false,
|
||||||
|
lastResult: null,
|
||||||
|
viewerPoints: [],
|
||||||
|
viewerTriangles: [],
|
||||||
|
geometryUrl: null,
|
||||||
|
stageMetaById: {},
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
selectedStage(state) {
|
||||||
|
if (state.selectedStageIndex < 0 || state.selectedStageIndex >= state.stageCards.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return state.stageCards[state.selectedStageIndex];
|
||||||
|
},
|
||||||
|
pipelineConfig(state) {
|
||||||
|
return configFromCards(state.stageCards);
|
||||||
|
},
|
||||||
|
reconstructionMethod(state) {
|
||||||
|
const recon = state.stageCards.find((c) => c.family === "reconstruction");
|
||||||
|
return recon ? recon.id : "surface_fallback";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async bootstrap() {
|
||||||
|
const [presets, defaultConfig, catalog] = await Promise.all([
|
||||||
|
api.presets(),
|
||||||
|
api.defaultConfig(),
|
||||||
|
api.catalog(),
|
||||||
|
]);
|
||||||
|
this.stageMetaById = Object.fromEntries(
|
||||||
|
(catalog.stageMeta || []).map((item) => [item.id, item]),
|
||||||
|
);
|
||||||
|
this.presetItems = presets;
|
||||||
|
this.stageCards = cardsFromConfig(defaultConfig, this.stageMetaById);
|
||||||
|
await this.validateChain();
|
||||||
|
},
|
||||||
|
async validateChain() {
|
||||||
|
const result = await api.validateConfig(this.pipelineConfig);
|
||||||
|
this.chainHealth = result.chainHealth;
|
||||||
|
this.recommendation = result.recommendation;
|
||||||
|
this.warningsList = result.warningsList || [];
|
||||||
|
if (result.stageCards) this.stageCards = result.stageCards;
|
||||||
|
},
|
||||||
|
setSelectedStageIndex(index) {
|
||||||
|
this.selectedStageIndex = index;
|
||||||
|
},
|
||||||
|
addStage(stageId) {
|
||||||
|
const meta = metaForStage(stageId, this.stageMetaById);
|
||||||
|
const family = meta.family || (stageId.startsWith("pcl_") || stageId === "downsample_dense" || stageId === "keep_largest_cluster" ? "preprocess" : "preprocess");
|
||||||
|
if (family === "reconstruction") {
|
||||||
|
const idx = this.stageCards.findIndex((c) => c.family === "reconstruction");
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.stageCards[idx] = {
|
||||||
|
id: stageId,
|
||||||
|
title: meta.title,
|
||||||
|
category: meta.category,
|
||||||
|
family: "reconstruction",
|
||||||
|
hint: meta.hint,
|
||||||
|
defaults: meta.defaults,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const reconIndex = this.stageCards.findIndex((c) => c.family === "reconstruction");
|
||||||
|
const insertAt = reconIndex >= 0 ? reconIndex : this.stageCards.length;
|
||||||
|
this.stageCards.splice(insertAt, 0, {
|
||||||
|
id: stageId,
|
||||||
|
title: meta.title,
|
||||||
|
category: meta.category,
|
||||||
|
family: "preprocess",
|
||||||
|
hint: meta.hint,
|
||||||
|
defaults: meta.defaults,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.validateChain();
|
||||||
|
},
|
||||||
|
removeStage(index) {
|
||||||
|
const card = this.stageCards[index];
|
||||||
|
if (!card || card.family === "reconstruction") return;
|
||||||
|
this.stageCards.splice(index, 1);
|
||||||
|
if (this.selectedStageIndex >= this.stageCards.length) {
|
||||||
|
this.selectedStageIndex = this.stageCards.length - 1;
|
||||||
|
}
|
||||||
|
this.validateChain();
|
||||||
|
},
|
||||||
|
moveStage(from, to) {
|
||||||
|
if (from === to) return;
|
||||||
|
const card = this.stageCards[from];
|
||||||
|
if (!card || card.family === "reconstruction") return;
|
||||||
|
const reconIndex = this.stageCards.findIndex((c) => c.family === "reconstruction");
|
||||||
|
const target = Math.min(to, reconIndex >= 0 ? reconIndex : this.stageCards.length - 1);
|
||||||
|
this.stageCards.splice(from, 1);
|
||||||
|
const insertAt = from < target ? target - 1 : target;
|
||||||
|
this.stageCards.splice(insertAt, 0, card);
|
||||||
|
this.selectedStageIndex = insertAt;
|
||||||
|
this.validateChain();
|
||||||
|
},
|
||||||
|
setStageEnabled(index, enabled) {
|
||||||
|
if (this.stageCards[index]) {
|
||||||
|
this.stageCards[index].enabled = enabled;
|
||||||
|
this.validateChain();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setStageDefaults(index, defaultsText) {
|
||||||
|
if (this.stageCards[index]) {
|
||||||
|
this.stageCards[index].defaults = defaultsText;
|
||||||
|
this.validateChain();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async defaultsForStage(stageId) {
|
||||||
|
const response = await api.stageDefaults(stageId);
|
||||||
|
return response.defaults;
|
||||||
|
},
|
||||||
|
async applyPreset(presetId) {
|
||||||
|
const preset = this.presetItems.find((p) => p.idValue === presetId || p.id === presetId);
|
||||||
|
if (!preset) throw new Error(`Preset not found: ${presetId}`);
|
||||||
|
const config = preset.config || preset;
|
||||||
|
this.stageCards = cardsFromConfig(config, this.stageMetaById);
|
||||||
|
await this.validateChain();
|
||||||
|
this.statusText = `Пресет '${preset.title || presetId}' загружен. Нажмите 'Применить' для запуска.`;
|
||||||
|
},
|
||||||
|
async applyWizard() {
|
||||||
|
const result = await api.wizard(this.wizardProfile, this.wizardGoal);
|
||||||
|
this.stageCards = result.stageCards;
|
||||||
|
this.chainHealth = result.chainHealth;
|
||||||
|
this.recommendation = result.recommendation;
|
||||||
|
this.warningsList = result.warningsList || [];
|
||||||
|
this.statusText = `Wizard предложил пресет '${result.title}'.`;
|
||||||
|
},
|
||||||
|
setDemoSurfaceType(value) {
|
||||||
|
this.demoSurfaceType = value;
|
||||||
|
},
|
||||||
|
setSurfaceVisible(value) {
|
||||||
|
this.surfaceVisible = value;
|
||||||
|
},
|
||||||
|
async runPipeline({ useDemo = false, geometryFormat = "json" } = {}) {
|
||||||
|
const runWithDemo = useDemo || (this.usingDemoInput && !this.currentFile);
|
||||||
|
if (!runWithDemo && !this.currentFile) {
|
||||||
|
this.statusText = "Загрузите файл или сгенерируйте демо.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
if (useDemo) {
|
||||||
|
this.usingDemoInput = true;
|
||||||
|
}
|
||||||
|
const result = await api.runPipeline({
|
||||||
|
file: runWithDemo ? null : this.currentFile,
|
||||||
|
demoSurface: runWithDemo ? this.demoSurfaceType : null,
|
||||||
|
config: this.pipelineConfig,
|
||||||
|
geometryFormat,
|
||||||
|
});
|
||||||
|
this.lastResult = result;
|
||||||
|
this.metrics = result.metrics || {
|
||||||
|
inputPoints: result.inputPoints,
|
||||||
|
afterPreprocess: result.afterPreprocess,
|
||||||
|
triangles: result.triangles,
|
||||||
|
reconstructMs: result.reconstructionMs,
|
||||||
|
clusters: result.clusters || 0,
|
||||||
|
removedPoints: result.removedPoints || 0,
|
||||||
|
};
|
||||||
|
this.stageMetrics = result.preprocessStepMetrics || [];
|
||||||
|
this.chainHealth = result.chainHealth || "";
|
||||||
|
this.recommendation = result.recommendation || "";
|
||||||
|
this.warningsList = result.warningsList || [];
|
||||||
|
if (result.geometryUrl) {
|
||||||
|
this.geometryUrl = result.geometryUrl;
|
||||||
|
this.viewerPoints = [];
|
||||||
|
this.viewerTriangles = [];
|
||||||
|
} else {
|
||||||
|
this.geometryUrl = null;
|
||||||
|
this.viewerPoints = result.points || [];
|
||||||
|
this.viewerTriangles = result.triangleIndices || [];
|
||||||
|
}
|
||||||
|
this.statusText = result.stdout || "Pipeline completed.";
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadGeometryFromUrl(workId) {
|
||||||
|
const buffer = await api.fetchGeometry(workId);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
let offset = 0;
|
||||||
|
const pointCount = view.getUint32(offset, true);
|
||||||
|
offset += 4;
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i < pointCount; i += 1) {
|
||||||
|
points.push([
|
||||||
|
view.getFloat32(offset, true),
|
||||||
|
view.getFloat32(offset + 4, true),
|
||||||
|
view.getFloat32(offset + 8, true),
|
||||||
|
]);
|
||||||
|
offset += 12;
|
||||||
|
}
|
||||||
|
const triCount = view.getUint32(offset, true);
|
||||||
|
offset += 4;
|
||||||
|
const triangles = [];
|
||||||
|
for (let i = 0; i < triCount; i += 1) {
|
||||||
|
triangles.push([
|
||||||
|
view.getInt32(offset, true),
|
||||||
|
view.getInt32(offset + 4, true),
|
||||||
|
view.getInt32(offset + 8, true),
|
||||||
|
]);
|
||||||
|
offset += 12;
|
||||||
|
}
|
||||||
|
this.viewerPoints = points;
|
||||||
|
this.viewerTriangles = triangles;
|
||||||
|
},
|
||||||
|
setCurrentFile(file) {
|
||||||
|
this.currentFile = file;
|
||||||
|
if (file) {
|
||||||
|
this.usingDemoInput = false;
|
||||||
|
}
|
||||||
|
this.statusText = file ? `Выбран файл: ${file.name}` : "Файл не выбран.";
|
||||||
|
},
|
||||||
|
saveSnapshot(name) {
|
||||||
|
const slot = name || `snapshot-${this.snapshots.length + 1}`;
|
||||||
|
this.snapshots = [
|
||||||
|
...this.snapshots.filter((s) => s.name !== slot),
|
||||||
|
{
|
||||||
|
name: slot,
|
||||||
|
stageCards: JSON.parse(JSON.stringify(this.stageCards)),
|
||||||
|
metrics: { ...this.metrics },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
loadSnapshot(name) {
|
||||||
|
const snapshot = this.snapshots.find((s) => s.name === name);
|
||||||
|
if (!snapshot) return;
|
||||||
|
this.stageCards = JSON.parse(JSON.stringify(snapshot.stageCards));
|
||||||
|
this.validateChain();
|
||||||
|
this.statusText = `Snapshot '${name}' loaded.`;
|
||||||
|
},
|
||||||
|
async saveCurrentPreset(title) {
|
||||||
|
const stages = this.stageCards.map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
family: card.family,
|
||||||
|
enabled: card.enabled,
|
||||||
|
defaults: card.defaults || "",
|
||||||
|
}));
|
||||||
|
await api.saveUserPreset({ title, stages });
|
||||||
|
this.presetItems = await api.presets();
|
||||||
|
this.statusText = `Пресет '${title}' сохранён.`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--panel-bg: #141821;
|
||||||
|
--card-bg: #111a2c;
|
||||||
|
--card-border: #33486f;
|
||||||
|
--header-bg: #152231;
|
||||||
|
--header-border: #33486f;
|
||||||
|
--health-bg: #173528;
|
||||||
|
--health-text: #9be7b5;
|
||||||
|
--chain-selected-border: #6ea8ff;
|
||||||
|
--chain-idle-border: #384866;
|
||||||
|
--chain-enabled-bg: #1f2a3d;
|
||||||
|
--chain-disabled-bg: #23272f;
|
||||||
|
--control-bg: #23324d;
|
||||||
|
--control-border: #4a6494;
|
||||||
|
--control-text: #eaf0ff;
|
||||||
|
--button-bg: #2c3f61;
|
||||||
|
--button-border: #5574a9;
|
||||||
|
--button-text: #eef4ff;
|
||||||
|
--primary-button-bg: #ffb020;
|
||||||
|
--primary-button-text: #142033;
|
||||||
|
--summary-primary: #eaf0ff;
|
||||||
|
--summary-secondary: #cde0ff;
|
||||||
|
--summary-recommendation: #9ec3ff;
|
||||||
|
--palette-filter-bg: #26344f;
|
||||||
|
--palette-filter-border: #3d547d;
|
||||||
|
--muted-text: #9db0c3;
|
||||||
|
--hint-text: #7f93a8;
|
||||||
|
--label-text: #dbe7ff;
|
||||||
|
--palette-hint-text: #b8cff8;
|
||||||
|
--chain-handle-text: #9fb4da;
|
||||||
|
--viewer-bg: #0b1118;
|
||||||
|
--dialog-backdrop: rgba(0, 0, 0, 0.55);
|
||||||
|
--dialog-hint-text: #aebfde;
|
||||||
|
--dialog-label-text: #c7d6f3;
|
||||||
|
--warning-text: #ffb4c0;
|
||||||
|
--modebar-icon: #8a96a3;
|
||||||
|
--modebar-icon-active: #447adb;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--spacing-sm: 6px;
|
||||||
|
--spacing-md: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--panel-bg: #eef2f7;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-border: #c8d3e0;
|
||||||
|
--header-bg: #ffffff;
|
||||||
|
--header-border: #d5dee8;
|
||||||
|
--health-bg: #ecfdf5;
|
||||||
|
--health-text: #047857;
|
||||||
|
--chain-selected-border: #3b82f6;
|
||||||
|
--chain-idle-border: #cbd5e1;
|
||||||
|
--chain-enabled-bg: #f8fafc;
|
||||||
|
--chain-disabled-bg: #f1f5f9;
|
||||||
|
--control-bg: #ffffff;
|
||||||
|
--control-border: #b8c5d6;
|
||||||
|
--control-text: #1e293b;
|
||||||
|
--button-bg: #e8eef5;
|
||||||
|
--button-border: #b8c5d6;
|
||||||
|
--button-text: #1e293b;
|
||||||
|
--primary-button-bg: #f59e0b;
|
||||||
|
--primary-button-text: #1e293b;
|
||||||
|
--summary-primary: #1e293b;
|
||||||
|
--summary-secondary: #475569;
|
||||||
|
--summary-recommendation: #2563eb;
|
||||||
|
--palette-filter-bg: #eef2f7;
|
||||||
|
--palette-filter-border: #c8d3e0;
|
||||||
|
--muted-text: #64748b;
|
||||||
|
--hint-text: #64748b;
|
||||||
|
--label-text: #334155;
|
||||||
|
--palette-hint-text: #64748b;
|
||||||
|
--chain-handle-text: #64748b;
|
||||||
|
--viewer-bg: #e2e8f0;
|
||||||
|
--dialog-backdrop: rgba(15, 23, 42, 0.35);
|
||||||
|
--dialog-hint-text: #64748b;
|
||||||
|
--dialog-label-text: #475569;
|
||||||
|
--warning-text: #dc2626;
|
||||||
|
--modebar-icon: #636363;
|
||||||
|
--modebar-icon-active: #447adb;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, Segoe UI, Roboto, sans-serif;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--control-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, select, input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--button-border);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--button-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--primary-button-bg);
|
||||||
|
color: var(--primary-button-text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"],
|
||||||
|
input[type="text"],
|
||||||
|
select {
|
||||||
|
background: var(--control-bg);
|
||||||
|
color: var(--control-text);
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"],
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 580px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: calc(100vh - 52px);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.content-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user