From 18a58f2e854ef2add4abcacffa509eab9759dd55 Mon Sep 17 00:00:00 2001 From: gitrusprus Date: Thu, 18 Jun 2026 14:53:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20Vue=203=20web-dashboard=20=D1=81=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=20API=20=D0=B8?= =?UTF-8?q?=20Docker-=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=BE=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полноценный браузерный UI (Pinia, Three.js) с паритетом Qt: редактор цепочки, wizard, пресеты, метрики и 3D viewer; API расширен для catalog/validate/demo/user-presets; CLI отдаёт step metrics в JSON. Co-authored-by: Cursor --- .gitignore | 6 +- README.md | 40 +- api/builtin_presets.py | 80 + api/demo_generator.py | 70 + api/main.py | 345 ++++- api/pipeline_insights.py | 66 + api/stage_meta.py | 63 + docker/Dockerfile | 12 +- docker/docker-compose.yml | 5 + src/cli/pipeline_cli_runner.cpp | 18 + web-vue/.gitignore | 3 + web-vue/index.html | 12 + web-vue/package-lock.json | 1359 +++++++++++++++++ web-vue/package.json | 23 + web-vue/src/App.vue | 71 + web-vue/src/api/client.js | 54 + web-vue/src/catalog/pipelineUiCatalog.js | 469 ++++++ web-vue/src/catalog/theme.js | 156 ++ web-vue/src/components/PipelineDashboard.vue | 97 ++ web-vue/src/components/WizardPanel.vue | 32 + .../src/components/pipeline/MetricsStrip.vue | 63 + .../components/pipeline/PipelineCanvas.vue | 41 + .../components/pipeline/StageChainView.vue | 95 ++ .../src/components/pipeline/StagePalette.vue | 81 + .../pipeline/StageSettingsDialog.vue | 68 + .../src/components/viewer/ViewerModebar.vue | 70 + web-vue/src/components/viewer/ViewerPanel.vue | 230 +++ .../src/composables/usePointCloudViewer.js | 176 +++ web-vue/src/composables/useTheme.js | 24 + web-vue/src/main.js | 11 + web-vue/src/stores/pipeline.js | 341 +++++ web-vue/src/styles/main.css | 158 ++ web-vue/vite.config.js | 21 + 33 files changed, 4304 insertions(+), 56 deletions(-) create mode 100644 api/builtin_presets.py create mode 100644 api/demo_generator.py create mode 100644 api/pipeline_insights.py create mode 100644 api/stage_meta.py create mode 100644 web-vue/.gitignore create mode 100644 web-vue/index.html create mode 100644 web-vue/package-lock.json create mode 100644 web-vue/package.json create mode 100644 web-vue/src/App.vue create mode 100644 web-vue/src/api/client.js create mode 100644 web-vue/src/catalog/pipelineUiCatalog.js create mode 100644 web-vue/src/catalog/theme.js create mode 100644 web-vue/src/components/PipelineDashboard.vue create mode 100644 web-vue/src/components/WizardPanel.vue create mode 100644 web-vue/src/components/pipeline/MetricsStrip.vue create mode 100644 web-vue/src/components/pipeline/PipelineCanvas.vue create mode 100644 web-vue/src/components/pipeline/StageChainView.vue create mode 100644 web-vue/src/components/pipeline/StagePalette.vue create mode 100644 web-vue/src/components/pipeline/StageSettingsDialog.vue create mode 100644 web-vue/src/components/viewer/ViewerModebar.vue create mode 100644 web-vue/src/components/viewer/ViewerPanel.vue create mode 100644 web-vue/src/composables/usePointCloudViewer.js create mode 100644 web-vue/src/composables/useTheme.js create mode 100644 web-vue/src/main.js create mode 100644 web-vue/src/stores/pipeline.js create mode 100644 web-vue/src/styles/main.css create mode 100644 web-vue/vite.config.js diff --git a/.gitignore b/.gitignore index 2102f3f..2552316 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 5dba240..f6e04e8 100644 --- a/README.md +++ b/README.md @@ -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`. ## Демо diff --git a/api/builtin_presets.py b/api/builtin_presets.py new file mode 100644 index 0000000..4a52503 --- /dev/null +++ b/api/builtin_presets.py @@ -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 diff --git a/api/demo_generator.py b/api/demo_generator.py new file mode 100644 index 0000000..d174108 --- /dev/null +++ b/api/demo_generator.py @@ -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}", + } diff --git a/api/main.py b/api/main.py index 8826817..a5ba0ba 100644 --- a/api/main.py +++ b/api/main.py @@ -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(" 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") diff --git a/api/pipeline_insights.py b/api/pipeline_insights.py new file mode 100644 index 0000000..bdaff09 --- /dev/null +++ b/api/pipeline_insights.py @@ -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, + } diff --git a/api/stage_meta.py b/api/stage_meta.py new file mode 100644 index 0000000..d5a4556 --- /dev/null +++ b/api/stage_meta.py @@ -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", + ], + } diff --git a/docker/Dockerfile b/docker/Dockerfile index 7cfe0fe..204063c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ee61644..f802ab2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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: diff --git a/src/cli/pipeline_cli_runner.cpp b/src/cli/pipeline_cli_runner.cpp index c5179ce..e21084e 100644 --- a/src/cli/pipeline_cli_runner.cpp +++ b/src/cli/pipeline_cli_runner.cpp @@ -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(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)) { diff --git a/web-vue/.gitignore b/web-vue/.gitignore new file mode 100644 index 0000000..0ca39c0 --- /dev/null +++ b/web-vue/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.DS_Store diff --git a/web-vue/index.html b/web-vue/index.html new file mode 100644 index 0000000..77b8986 --- /dev/null +++ b/web-vue/index.html @@ -0,0 +1,12 @@ + + + + + + DotsToSirface + + +
+ + + diff --git a/web-vue/package-lock.json b/web-vue/package-lock.json new file mode 100644 index 0000000..796ffa2 --- /dev/null +++ b/web-vue/package-lock.json @@ -0,0 +1,1359 @@ +{ + "name": "dotstosirface-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dotstosirface-web", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==" + }, + "node_modules/@vueuse/core": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.3.0.tgz", + "integrity": "sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "11.3.0", + "@vueuse/shared": "11.3.0", + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-11.3.0.tgz", + "integrity": "sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-11.3.0.tgz", + "integrity": "sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==", + "dependencies": { + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/web-vue/package.json b/web-vue/package.json new file mode 100644 index 0000000..9d5969a --- /dev/null +++ b/web-vue/package.json @@ -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" + } +} diff --git a/web-vue/src/App.vue b/web-vue/src/App.vue new file mode 100644 index 0000000..e16698a --- /dev/null +++ b/web-vue/src/App.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/web-vue/src/api/client.js b/web-vue/src/api/client.js new file mode 100644 index 0000000..9b0071d --- /dev/null +++ b/web-vue/src/api/client.js @@ -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(); + }, +}; diff --git a/web-vue/src/catalog/pipelineUiCatalog.js b/web-vue/src/catalog/pipelineUiCatalog.js new file mode 100644 index 0000000..3c70773 --- /dev/null +++ b/web-vue/src/catalog/pipelineUiCatalog.js @@ -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, +}; diff --git a/web-vue/src/catalog/theme.js b/web-vue/src/catalog/theme.js new file mode 100644 index 0000000..3a96f8a --- /dev/null +++ b/web-vue/src/catalog/theme.js @@ -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, +}; diff --git a/web-vue/src/components/PipelineDashboard.vue b/web-vue/src/components/PipelineDashboard.vue new file mode 100644 index 0000000..9a12f30 --- /dev/null +++ b/web-vue/src/components/PipelineDashboard.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/web-vue/src/components/WizardPanel.vue b/web-vue/src/components/WizardPanel.vue new file mode 100644 index 0000000..c3f625f --- /dev/null +++ b/web-vue/src/components/WizardPanel.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/web-vue/src/components/pipeline/MetricsStrip.vue b/web-vue/src/components/pipeline/MetricsStrip.vue new file mode 100644 index 0000000..92b88c1 --- /dev/null +++ b/web-vue/src/components/pipeline/MetricsStrip.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/web-vue/src/components/pipeline/PipelineCanvas.vue b/web-vue/src/components/pipeline/PipelineCanvas.vue new file mode 100644 index 0000000..006b84f --- /dev/null +++ b/web-vue/src/components/pipeline/PipelineCanvas.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/web-vue/src/components/pipeline/StageChainView.vue b/web-vue/src/components/pipeline/StageChainView.vue new file mode 100644 index 0000000..cab3dd6 --- /dev/null +++ b/web-vue/src/components/pipeline/StageChainView.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/web-vue/src/components/pipeline/StagePalette.vue b/web-vue/src/components/pipeline/StagePalette.vue new file mode 100644 index 0000000..3c31c6a --- /dev/null +++ b/web-vue/src/components/pipeline/StagePalette.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/web-vue/src/components/pipeline/StageSettingsDialog.vue b/web-vue/src/components/pipeline/StageSettingsDialog.vue new file mode 100644 index 0000000..b819ea7 --- /dev/null +++ b/web-vue/src/components/pipeline/StageSettingsDialog.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/web-vue/src/components/viewer/ViewerModebar.vue b/web-vue/src/components/viewer/ViewerModebar.vue new file mode 100644 index 0000000..cb44ae8 --- /dev/null +++ b/web-vue/src/components/viewer/ViewerModebar.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/web-vue/src/components/viewer/ViewerPanel.vue b/web-vue/src/components/viewer/ViewerPanel.vue new file mode 100644 index 0000000..9404d7b --- /dev/null +++ b/web-vue/src/components/viewer/ViewerPanel.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/web-vue/src/composables/usePointCloudViewer.js b/web-vue/src/composables/usePointCloudViewer.js new file mode 100644 index 0000000..05bdc36 --- /dev/null +++ b/web-vue/src/composables/usePointCloudViewer.js @@ -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, + }; +} diff --git a/web-vue/src/composables/useTheme.js b/web-vue/src/composables/useTheme.js new file mode 100644 index 0000000..1742720 --- /dev/null +++ b/web-vue/src/composables/useTheme.js @@ -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 }; +} diff --git a/web-vue/src/main.js b/web-vue/src/main.js new file mode 100644 index 0000000..104e51b --- /dev/null +++ b/web-vue/src/main.js @@ -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"); diff --git a/web-vue/src/stores/pipeline.js b/web-vue/src/stores/pipeline.js new file mode 100644 index 0000000..7df35e4 --- /dev/null +++ b/web-vue/src/stores/pipeline.js @@ -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}' сохранён.`; + }, + }, +}); diff --git a/web-vue/src/styles/main.css b/web-vue/src/styles/main.css new file mode 100644 index 0000000..981cae8 --- /dev/null +++ b/web-vue/src/styles/main.css @@ -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; } +} diff --git a/web-vue/vite.config.js b/web-vue/vite.config.js new file mode 100644 index 0000000..96ea663 --- /dev/null +++ b/web-vue/vite.config.js @@ -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", + }, + }, +});