Добавить 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:
2026-06-18 14:53:53 +03:00
co-authored by Cursor
parent 45b2ed6e22
commit 18a58f2e85
33 changed files with 4304 additions and 56 deletions
+4 -2
View File
@@ -27,9 +27,9 @@ qrc_*.cpp
*.out
*.app
# Qt deployment folders
# Qt deployment folders (root only)
/styles/
platforms/
styles/
imageformats/
iconengines/
translations/
@@ -39,6 +39,8 @@ bearer/
*.log
*.tmp
*.temp
__pycache__/
*.pyc
# OS/editor files
.DS_Store
+26 -14
View File
@@ -208,10 +208,13 @@ CLI печатает краткую сводку по метрикам (`input`,
Для запуска пайплайна с PCL в изолированной среде (без конфликтов с `libpq` на хосте) добавлены:
- `docker/Dockerfile` — сборка `DotsToSirface` с `PCL_ENABLED` и запуск FastAPI;
- `docker/Dockerfile` — сборка `DotsToSirface` с `PCL_ENABLED` + Vue 3 dashboard;
- `docker/docker-compose.yml`;
- `api/main.py` — HTTP API (`/api/run`, `/api/presets`);
- `web/`браузерный 3D viewer (Three.js).
- `api/main.py` — HTTP API (`/api/run`, `/api/presets`, `/api/validate-config`, …);
- `web-vue/`Vue 3 + Pinia + Three.js (полный dashboard, паритет с Qt);
- `web/` — legacy MVP (fallback, если Vue `dist` не собран).
**Qt desktop (`./DotsToSirface`) сохраняется** как локальный fallback без Docker.
### Быстрый старт
@@ -222,21 +225,30 @@ docker compose up --build
Откройте в браузере: [http://localhost:8080](http://localhost:8080)
1. Загрузите `.ply`
2. Выберите preset (или оставьте default)
3. Нажмите **Run pipeline**
4. В viewer появятся точки и mesh, справа — метрики пайплайна
### Локальная разработка frontend
### API
```bash
cd web-vue
npm install
npm run dev
```
API проксируется на `http://localhost:8080` (см. `vite.config.js`).
### API (основное)
- `GET /api/health` — статус сервиса
- `GET /api/presets` — список JSON-пресетов
- `POST /api/run` — multipart: `file` + опционально `preset_id`
- `GET /api/catalog` — метаданные стадий
- `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` не только метрики, но и геометрию:
`points` (массив `[x,y,z]`) и `triangleIndices` (массив `[i0,i1,i2]`).
Конфиг пайплайна по умолчанию: `docker/default_pipeline.json`.
CLI `--output-json` включает `points`, `triangleIndices`, `preprocessStepMetrics`.
## Демо
+80
View File
@@ -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
+70
View File
@@ -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
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import struct
import subprocess
import tempfile
import uuid
@@ -10,18 +11,26 @@ from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, Response
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
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"]
USER_PRESETS_DIR = Path(os.environ.get("USER_PRESETS_DIR", "/app/data/user-presets"))
DEFAULT_PIPELINE_CONFIG = Path(
os.environ.get("PIPELINE_CONFIG", APP_ROOT / "docker" / "default_pipeline.json")
)
DOTSTOSIRFACE_BIN = Path(os.environ.get("DOTSTOSIRFACE_BIN", "/usr/local/bin/DotsToSirface"))
app = FastAPI(title="DotsToSirface Web API", version="1.0.0")
app = FastAPI(title="DotsToSirface Web API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -30,10 +39,36 @@ app.add_middleware(
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]:
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
return preset
if "config" in preset and isinstance(preset["config"], dict):
return preset["config"]
preprocess_plugins: list[str] = []
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]:
files: list[Path] = []
seen: set[str] = set()
@@ -76,18 +143,102 @@ def list_preset_files() -> list[Path]:
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")
def health() -> dict[str, str]:
return {
"status": "ok",
"binary": str(DOTSTOSIRFACE_BIN),
"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")
def presets() -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
items = list(BUILTIN_PRESETS)
for path in list_preset_files():
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
@@ -96,7 +247,20 @@ def presets() -> list[dict[str, Any]]:
"id": path.stem,
"filename": path.name,
"title": data.get("title", path.stem),
"idValue": data.get("idValue", path.stem),
"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
@@ -110,58 +274,140 @@ def default_config() -> dict[str, Any]:
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")
async def run_pipeline(
file: UploadFile = File(...),
file: UploadFile | None = File(default=None),
preset_id: 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]:
if not DOTSTOSIRFACE_BIN.is_file():
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_dir = Path(tempfile.gettempdir()) / "dotstosirface" / work_id
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"
try:
content = await file.read()
input_path.write_bytes(content)
if demo_surface:
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:
pipeline_config = json.loads(config_json)
elif preset_id:
preset_path = next((p for p in list_preset_files() if p.stem == preset_id), None)
if preset_path is None:
raise HTTPException(status_code=400, detail=f"Unknown preset: {preset_id}")
with preset_path.open("r", encoding="utf-8") as handle:
pipeline_config = preset_to_pipeline_config(json.load(handle))
builtin = get_builtin_preset(preset_id)
if builtin is not None:
pipeline_config = builtin["config"]
else:
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:
with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle:
pipeline_config = json.load(handle)
config_path.write_text(json.dumps(pipeline_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),
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
completed = run_pipeline_command(input_path, pipeline_config, output_path)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or "Pipeline failed."
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:
result = json.load(handle)
result["stdout"] = completed.stdout.strip()
result["workId"] = work_id
result = enrich_result(result, pipeline_config, completed.stdout.strip(), 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
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"Invalid config JSON: {exc}") from exc
except HTTPException:
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
@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("/")
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")
+66
View File
@@ -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,
}
+63
View File
@@ -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
View File
@@ -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
ENV DEBIAN_FRONTEND=noninteractive
@@ -24,6 +33,7 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV DOTSTOSIRFACE_BIN=/usr/local/bin/DotsToSirface
ENV PIPELINE_CONFIG=/app/docker/default_pipeline.json
ENV USER_PRESETS_DIR=/app/data/user-presets
ENV PYTHONUNBUFFERED=1
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/*
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 web /app/web
COPY docker /app/docker
COPY presets /app/presets
+5
View File
@@ -8,5 +8,10 @@ services:
environment:
DOTSTOSIRFACE_BIN: /usr/local/bin/DotsToSirface
PIPELINE_CONFIG: /app/docker/default_pipeline.json
USER_PRESETS_DIR: /app/data/user-presets
volumes:
- ../presets:/app/presets:ro
- dotstosirface-user-presets:/app/data/user-presets
volumes:
dotstosirface-user-presets:
+18
View File
@@ -120,6 +120,23 @@ void appendGeometryToJson(const core::PipelineResult &result, QJsonObject &root)
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
{
QString baseConfigJsonPath;
@@ -520,6 +537,7 @@ int runCliPipeline(int argc, char *argv[])
root["reconstruction"] = config.reconstructionPlugin;
root["preprocessChain"] = config.preprocessPlugins.join(",");
appendGeometryToJson(result, root);
appendRunStatsToJson(result, root);
QFile file(outputJsonPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store
+12
View File
@@ -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>
+1359
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+71
View File
@@ -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>
+54
View File
@@ -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();
},
};
+469
View File
@@ -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,
};
+156
View File
@@ -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>
+32
View File
@@ -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,
};
}
+24
View File
@@ -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 };
}
+11
View File
@@ -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");
+341
View File
@@ -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}' сохранён.`;
},
},
});
+158
View File
@@ -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; }
}
+21
View File
@@ -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",
},
},
});