diff --git a/.gitignore b/.gitignore index 13ca68d..55e1db8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,10 @@ frontend/web/dist/ # Generated PointNet sonar dataset sonar_dataset/ +# MLE simulator seafloor runs +mle_runs/ +gbo_runs/ + # OS/editor files .DS_Store Thumbs.db diff --git a/backend/dataset_generator.py b/backend/dataset_generator.py index de60852..e8d8bb8 100644 --- a/backend/dataset_generator.py +++ b/backend/dataset_generator.py @@ -9,8 +9,10 @@ Target class 1 = user-provided object; class 0 = seafloor. from __future__ import annotations +import json import math import random +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -18,6 +20,7 @@ from scene_generator import ( apply_transform, export_npy_float64, export_obj, + parse_obj_labeled_points, parse_obj_points, ) @@ -474,6 +477,10 @@ def resolve_output_dir(output_dir: str | Path = "sonar_dataset") -> Path: return out +DATASET_RUN_FILENAME = "dataset_run.json" +DATASET_RUN_VERSION = 1 + + def _safe_object_stem(object_name: str | None) -> str: stem = Path(object_name or "object").stem safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in stem).strip("._-") @@ -503,6 +510,221 @@ def make_generation_run_dir(base_dir: Path, object_name: str | None = None) -> P return path +def _normalize_model_filename(name: str | None) -> str | None: + """Keep the full uploaded basename, e.g. ``airplane2.obj``.""" + if not name: + return None + base = Path(str(name).strip()).name + return base or None + + +def build_run_manifest( + *, + run_dir: Path, + base_dir: Path, + count: int, + seed: int, + output_dir: str, + object_name: str | None, + object_scale: float, + object_scale_is_max: bool, + beam_count: int, + length_count: int, + object_vertex_count: int, + stats: dict[str, Any], + written: list[dict[str, Any]], +) -> dict[str, Any]: + model_filename = _normalize_model_filename(object_name) + return { + "version": DATASET_RUN_VERSION, + "generatedAt": datetime.now(timezone.utc).isoformat(), + "runName": run_dir.name, + "outputDir": str(run_dir), + "baseDir": str(base_dir), + "objectName": model_filename, + "settings": { + "count": int(count), + "seed": int(seed), + "outputDir": str(output_dir), + "objectScale": float(object_scale), + "objectScaleIsMax": bool(object_scale_is_max), + "beamCount": int(beam_count), + "lengthCount": int(length_count), + "objectName": model_filename, + }, + "objectVertexCount": int(object_vertex_count), + "stats": stats, + "written": written, + } + + +def write_run_manifest(run_dir: Path, manifest: dict[str, Any]) -> Path: + run_dir.mkdir(parents=True, exist_ok=True) + path = run_dir / DATASET_RUN_FILENAME + path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def read_run_manifest(run_dir: Path) -> dict[str, Any] | None: + path = run_dir / DATASET_RUN_FILENAME + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _scene_entry_from_files(run_dir: Path, stem: str) -> dict[str, Any]: + npy_path = run_dir / f"{stem}.npy" + obj_path = run_dir / f"{stem}.obj" + entry: dict[str, Any] = { + "stem": stem, + "visibility": "unknown", + "hasObject": None, + "pointCount": None, + "objectPointCount": None, + } + if npy_path.is_file(): + try: + rows = load_npy_float64_rows(npy_path) + object_point_count = sum(1 for row in rows if int(row[6]) == 1) + entry["pointCount"] = len(rows) + entry["objectPointCount"] = object_point_count + entry["hasObject"] = object_point_count > 0 + except (OSError, ValueError, IndexError): + pass + elif obj_path.is_file(): + try: + text = obj_path.read_text(encoding="utf-8", errors="ignore") + labeled = parse_obj_labeled_points(text) + if labeled: + object_point_count = sum(1 for row in labeled if int(row[3]) == 1) + entry["pointCount"] = len(labeled) + entry["objectPointCount"] = object_point_count + entry["hasObject"] = object_point_count > 0 + else: + points = parse_obj_points(text) + entry["pointCount"] = len(points) + except OSError: + pass + return entry + + +def scan_run_scenes(run_dir: Path) -> list[dict[str, Any]]: + stems: set[str] = set() + for pattern in ("*.obj", "*.npy"): + for path in run_dir.glob(pattern): + if path.is_file(): + stems.add(path.stem) + return [_scene_entry_from_files(run_dir, stem) for stem in sorted(stems)] + + +def _run_has_scene_files(path: Path) -> bool: + return any(path.glob("*.obj")) or any(path.glob("*.npy")) + + +def _relative_to_project(path: Path) -> str: + project_root = Path(__file__).resolve().parent.parent + try: + return str(path.relative_to(project_root)) + except ValueError: + return str(path) + + +def list_dataset_runs(output_dir: str | Path = "sonar_dataset") -> list[dict[str, Any]]: + """List dataset run folders under the base output directory.""" + base = resolve_output_dir(output_dir) + runs: list[dict[str, Any]] = [] + + if base.is_dir() and _run_has_scene_files(base): + manifest = read_run_manifest(base) + written = manifest.get("written") if manifest else scan_run_scenes(base) + runs.append( + { + "runName": "(корень)", + "outputDir": str(base), + "loadPath": _relative_to_project(base), + "sceneCount": len(written), + "hasSettings": manifest is not None, + "generatedAt": manifest.get("generatedAt") if manifest else None, + "objectName": (manifest or {}).get("objectName") + or (manifest or {}).get("settings", {}).get("objectName"), + } + ) + + if not base.is_dir(): + return runs + + for child in sorted(base.iterdir(), key=lambda p: p.name, reverse=True): + if not child.is_dir() or not _run_has_scene_files(child): + continue + manifest = read_run_manifest(child) + written = manifest.get("written") if manifest else scan_run_scenes(child) + runs.append( + { + "runName": child.name, + "outputDir": str(child), + "loadPath": _relative_to_project(child), + "sceneCount": len(written), + "hasSettings": manifest is not None, + "generatedAt": manifest.get("generatedAt") if manifest else None, + "objectName": (manifest or {}).get("objectName") + or (manifest or {}).get("settings", {}).get("objectName"), + } + ) + return runs + + +def load_dataset_run(output_dir: str | Path) -> dict[str, Any]: + """Load a dataset run folder: settings, stats and scene list.""" + run_dir = resolve_output_dir(output_dir) + if not run_dir.is_dir(): + raise FileNotFoundError(f"Dataset folder not found: {run_dir}") + if not _run_has_scene_files(run_dir): + raise FileNotFoundError(f"No scene files in dataset folder: {run_dir}") + + manifest = read_run_manifest(run_dir) + written = manifest.get("written") if manifest else scan_run_scenes(run_dir) + if not written: + raise FileNotFoundError(f"No scenes found in dataset folder: {run_dir}") + + settings = dict((manifest or {}).get("settings") or {}) + model_filename = ( + settings.get("objectName") + or (manifest.get("objectName") if manifest else None) + ) + stats = dict((manifest or {}).get("stats") or {}) + if not stats: + stats = { + "total": len(written), + "withObject": sum(1 for item in written if item.get("hasObject")), + "withoutObject": sum(1 for item in written if item.get("hasObject") is False), + } + + base_dir = Path(manifest["baseDir"]) if manifest and manifest.get("baseDir") else run_dir.parent + return { + "outputDir": str(run_dir), + "baseDir": str(base_dir), + "runName": manifest.get("runName") if manifest else run_dir.name, + "generatedAt": manifest.get("generatedAt") if manifest else None, + "hasSettings": manifest is not None, + "settings": settings, + "objectVertexCount": (manifest or {}).get("objectVertexCount"), + "stats": stats, + "written": written, + "count": settings.get("count") or len(written), + "seed": settings.get("seed"), + "beamCount": settings.get("beamCount"), + "lengthCount": settings.get("lengthCount"), + "objectScale": settings.get("objectScale"), + "objectScaleIsMax": settings.get("objectScaleIsMax"), + "objectName": model_filename, + "classLabels": {"0": "background", "1": "object"}, + } + + def _downsample_points(points: list[list[float]], max_points: int) -> list[list[float]]: max_points = max(100, int(max_points)) if len(points) <= max_points: @@ -584,8 +806,10 @@ def load_scene_preview( labeled = [[float(r[0]), float(r[1]), float(r[2]), float(r[6])] for r in rows] elif obj_path.is_file(): text = obj_path.read_text(encoding="utf-8", errors="ignore") - points = parse_obj_points(text) - labeled = [[p[0], p[1], p[2], 0.0] for p in points] + labeled = parse_obj_labeled_points(text) + if not labeled: + points = parse_obj_points(text) + labeled = [[p[0], p[1], p[2], 0.0] for p in points] else: raise FileNotFoundError(f"Scene not found: {safe}.npy / {safe}.obj") @@ -613,7 +837,11 @@ def write_scene_files( npy_path = output_dir / f"{stem}.npy" obj_path = output_dir / f"{stem}.obj" npy_path.write_bytes(export_npy_float64(scene["rows"])) - obj_path.write_text(export_obj(scene["points"], object_name=stem), encoding="utf-8") + classes = [int(r[6]) for r in scene["rows"]] + obj_path.write_text( + export_obj(scene["points"], object_name=stem, classes=classes), + encoding="utf-8", + ) return {"npy": str(npy_path), "obj": str(obj_path), "stem": stem} @@ -662,6 +890,8 @@ def iter_generate_dataset( if length_count > 1024: raise ValueError("length_count (Длина) must be <= 1024") + object_name = _normalize_model_filename(object_name) + base = resolve_output_dir(output_dir) run_dir = make_generation_run_dir(base, object_name) template = normalize_object_points(object_points) @@ -773,6 +1003,23 @@ def iter_generate_dataset( "written": written, "preview": preview, } + manifest = build_run_manifest( + run_dir=run_dir, + base_dir=base, + count=count, + seed=int(seed), + output_dir=str(output_dir), + object_name=object_name, + object_scale=object_scale, + object_scale_is_max=object_scale_is_max, + beam_count=beam_count, + length_count=length_count, + object_vertex_count=len(template), + stats=stats, + written=written, + ) + write_run_manifest(run_dir, manifest) + result["settingsPath"] = str(run_dir / DATASET_RUN_FILENAME) yield {"type": "done", "result": result} diff --git a/backend/main.py b/backend/main.py index ed6e2fe..5fe4704 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,7 +18,15 @@ from pydantic import BaseModel from builtin_presets import BUILTIN_PRESETS, get_builtin_preset from demo_generator import DEMO_SURFACE_TYPES, demo_payload from pipeline_insights import compute_insights -from dataset_generator import iter_generate_dataset, load_object_points_from_obj_text, load_scene_preview +from dataset_generator import ( + iter_generate_dataset, + list_dataset_runs, + load_dataset_run, + load_object_points_from_obj_text, + load_scene_preview, + resolve_output_dir, +) +from mle_simulator import load_last_settings, prepare_mle_scene, save_last_settings, save_survey_surface from scene_generator import ( catalog_payload as generator_catalog_payload, export_npy_float64, @@ -123,6 +131,54 @@ class DatasetPreviewBody(BaseModel): maxPoints: int = 25000 +class DatasetLoadBody(BaseModel): + outputDir: str + + +class MlePrepareBody(BaseModel): + seed: int = 42 + sizeX: float = 40.0 + sizeY: float = 60.0 + resX: int = 80 + resY: int = 120 + outputDir: str = "mle_runs" + settings: dict[str, Any] | None = None + + +class MleSaveSurfaceBody(BaseModel): + outputDir: str + vertices: list[list[float]] + faces: list[list[int]] + filename: str = "seafloor.obj" + + +class MleSettingsBody(BaseModel): + outputDir: str = "mle_runs" + settings: dict[str, Any] + + +class GboPrepareBody(BaseModel): + seed: int = 42 + sizeX: float = 40.0 + sizeY: float = 60.0 + resX: int = 80 + resY: int = 120 + outputDir: str = "gbo_runs" + settings: dict[str, Any] | None = None + + +class GboSaveSurfaceBody(BaseModel): + outputDir: str + vertices: list[list[float]] + faces: list[list[int]] + filename: str = "seafloor.obj" + + +class GboSettingsBody(BaseModel): + outputDir: str = "gbo_runs" + settings: 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 @@ -481,6 +537,26 @@ def dataset_preview(body: DatasetPreviewBody) -> dict[str, Any]: raise HTTPException(status_code=500, detail=f"Failed to load scene: {exc}") from exc +@app.get("/api/dataset/runs") +def dataset_runs(outputDir: str = "sonar_dataset") -> dict[str, Any]: + try: + base_path = resolve_output_dir(outputDir or "sonar_dataset") + runs = list_dataset_runs(outputDir or "sonar_dataset") + return {"baseDir": str(base_path), "runs": runs} + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to list dataset runs: {exc}") from exc + + +@app.post("/api/dataset/load") +def dataset_load(body: DatasetLoadBody) -> dict[str, Any]: + try: + return load_dataset_run(body.outputDir) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to load dataset run: {exc}") from exc + + @app.post("/api/generator/export") def generator_export(body: GeneratorExportBody) -> Response: from urllib.parse import quote @@ -718,5 +794,123 @@ def dataset_spa() -> FileResponse: return index() +@app.get("/mle") +def mle_spa() -> FileResponse: + return index() + + +@app.post("/api/mle/prepare") +def mle_prepare(body: MlePrepareBody) -> dict[str, Any]: + try: + result = prepare_mle_scene( + seed=body.seed, + size_x=body.sizeX, + size_y=body.sizeY, + res_x=body.resX, + res_y=body.resY, + output_dir=body.outputDir or "mle_runs", + settings=body.settings, + ) + if body.settings: + try: + save_last_settings(body.settings, output_dir=body.outputDir or "mle_runs") + except OSError: + pass + return result + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to prepare MLE scene: {exc}") from exc + + +@app.get("/api/mle/settings") +def mle_get_settings(outputDir: str = "mle_runs") -> dict[str, Any]: + return load_last_settings(output_dir=outputDir or "mle_runs") + + +@app.put("/api/mle/settings") +def mle_put_settings(body: MleSettingsBody) -> dict[str, Any]: + try: + return save_last_settings(body.settings or {}, output_dir=body.outputDir or "mle_runs") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to save MLE settings: {exc}") from exc + + +@app.post("/api/mle/save-surface") +def mle_save_surface(body: MleSaveSurfaceBody) -> dict[str, Any]: + try: + return save_survey_surface( + output_dir=body.outputDir, + vertices=body.vertices, + faces=body.faces, + filename=body.filename or "seafloor.obj", + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to save survey surface: {exc}") from exc + + +@app.get("/gbo") +def gbo_spa() -> FileResponse: + return index() + + +@app.post("/api/gbo/prepare") +def gbo_prepare(body: GboPrepareBody) -> dict[str, Any]: + try: + result = prepare_mle_scene( + seed=body.seed, + size_x=body.sizeX, + size_y=body.sizeY, + res_x=body.resX, + res_y=body.resY, + output_dir=body.outputDir or "gbo_runs", + settings=body.settings, + ) + if body.settings: + try: + save_last_settings(body.settings, output_dir=body.outputDir or "gbo_runs") + except OSError: + pass + return result + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to prepare GBO scene: {exc}") from exc + + +@app.get("/api/gbo/settings") +def gbo_get_settings(outputDir: str = "gbo_runs") -> dict[str, Any]: + return load_last_settings(output_dir=outputDir or "gbo_runs") + + +@app.put("/api/gbo/settings") +def gbo_put_settings(body: GboSettingsBody) -> dict[str, Any]: + try: + return save_last_settings(body.settings or {}, output_dir=body.outputDir or "gbo_runs") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to save GBO settings: {exc}") from exc + + +@app.post("/api/gbo/save-surface") +def gbo_save_surface(body: GboSaveSurfaceBody) -> dict[str, Any]: + try: + return save_survey_surface( + output_dir=body.outputDir, + vertices=body.vertices, + faces=body.faces, + filename=body.filename or "seafloor.obj", + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to save GBO survey surface: {exc}") from exc + + if (WEB_DIST / "assets").is_dir(): app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") diff --git a/backend/mle_simulator.py b/backend/mle_simulator.py new file mode 100644 index 0000000..7e5dd37 --- /dev/null +++ b/backend/mle_simulator.py @@ -0,0 +1,321 @@ +"""Multibeam echosounder (МЛЭ) simulator helpers: seafloor mesh + OBJ export.""" + +from __future__ import annotations + +import json +import math +import random +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def resolve_mle_dir(output_dir: str | Path = "mle_runs") -> Path: + out = Path(output_dir) + if not out.is_absolute(): + project_root = Path(__file__).resolve().parent.parent + out = project_root / out + return out + + +LAST_SETTINGS_FILENAME = "_last_settings.json" + + +def save_last_settings( + settings: dict[str, Any], + *, + output_dir: str | Path = "mle_runs", +) -> dict[str, Any]: + """Persist UI preset so it survives app restarts.""" + base = resolve_mle_dir(output_dir) + base.mkdir(parents=True, exist_ok=True) + payload = { + "savedAt": datetime.now(timezone.utc).isoformat(), + "settings": settings or {}, + } + path = base / LAST_SETTINGS_FILENAME + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return {"ok": True, "path": str(path), "savedAt": payload["savedAt"]} + + +def load_last_settings(*, output_dir: str | Path = "mle_runs") -> dict[str, Any]: + """Load last UI preset from mle_runs/_last_settings.json.""" + path = resolve_mle_dir(output_dir) / LAST_SETTINGS_FILENAME + if not path.is_file(): + return {"settings": None, "savedAt": None, "path": str(path)} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"settings": None, "savedAt": None, "path": str(path)} + settings = data.get("settings") if isinstance(data, dict) else None + if not isinstance(settings, dict): + settings = data if isinstance(data, dict) else None + saved_at = data.get("savedAt") if isinstance(data, dict) else None + return {"settings": settings, "savedAt": saved_at, "path": str(path)} + + +def _height_at( + x: float, + y: float, + *, + base_z: float, + amplitude: float, + frequency: float, + hills: list[tuple[float, float, float, float]], + valleys: list[tuple[float, float, float, float]], + bumps: list[tuple[float, float, float, float]], +) -> float: + z = base_z + amplitude * math.sin(frequency * x) * math.cos(frequency * 0.7 * y) + for hx, hy, hamp, hrad in hills: + d2 = (x - hx) ** 2 + (y - hy) ** 2 + z += hamp * math.exp(-d2 / max(hrad * hrad, 1e-6)) + for vx, vy, vamp, vrad in valleys: + d2 = (x - vx) ** 2 + (y - vy) ** 2 + z -= vamp * math.exp(-d2 / max(vrad * vrad, 1e-6)) + for bx, by, bamp, brad in bumps: + d2 = (x - bx) ** 2 + (y - by) ** 2 + z += bamp * math.exp(-d2 / max(brad * brad, 1e-6)) + return z + + +def build_seafloor_params( + seed: int = 42, + *, + size_x: float = 40.0, + size_y: float = 60.0, +) -> dict[str, Any]: + rng = random.Random(int(seed)) + size_x = max(4.0, float(size_x)) + size_y = max(4.0, float(size_y)) + base_z = rng.uniform(-8.0, -3.0) + amplitude = rng.uniform(0.15, 0.6) + frequency = rng.uniform(0.15, 0.55) + hills = [ + ( + rng.uniform(-size_x * 0.4, size_x * 0.4), + rng.uniform(-size_y * 0.4, size_y * 0.4), + rng.uniform(0.3, 1.4), + rng.uniform(2.0, 8.0), + ) + for _ in range(rng.randint(2, 5)) + ] + valleys = [ + ( + rng.uniform(-size_x * 0.4, size_x * 0.4), + rng.uniform(-size_y * 0.4, size_y * 0.4), + rng.uniform(0.2, 0.9), + rng.uniform(2.0, 7.0), + ) + for _ in range(rng.randint(1, 4)) + ] + bumps = [ + ( + rng.uniform(-size_x * 0.45, size_x * 0.45), + rng.uniform(-size_y * 0.45, size_y * 0.45), + rng.uniform(0.05, 0.4), + rng.uniform(0.4, 2.0), + ) + for _ in range(rng.randint(8, 20)) + ] + return { + "seed": int(seed), + "sizeX": size_x, + "sizeY": size_y, + "baseZ": base_z, + "amplitude": amplitude, + "frequency": frequency, + "hills": hills, + "valleys": valleys, + "bumps": bumps, + } + + +def sample_seafloor_grid( + params: dict[str, Any], + *, + res_x: int = 80, + res_y: int = 120, +) -> dict[str, Any]: + """Build a triangulated seafloor mesh over [-sizeX/2, sizeX/2] × [-sizeY/2, sizeY/2].""" + res_x = max(4, int(res_x)) + res_y = max(4, int(res_y)) + size_x = float(params["sizeX"]) + size_y = float(params["sizeY"]) + half_x = size_x * 0.5 + half_y = size_y * 0.5 + + vertices: list[list[float]] = [] + heights: list[list[float]] = [] + for j in range(res_y): + row: list[float] = [] + y = -half_y if res_y == 1 else (-half_y + size_y * j / (res_y - 1)) + for i in range(res_x): + x = -half_x if res_x == 1 else (-half_x + size_x * i / (res_x - 1)) + z = _height_at( + x, + y, + base_z=float(params["baseZ"]), + amplitude=float(params["amplitude"]), + frequency=float(params["frequency"]), + hills=params["hills"], + valleys=params["valleys"], + bumps=params["bumps"], + ) + vertices.append([x, y, z]) + row.append(z) + heights.append(row) + + faces: list[list[int]] = [] + for j in range(res_y - 1): + for i in range(res_x - 1): + a = j * res_x + i + b = a + 1 + c = a + res_x + d = c + 1 + faces.append([a + 1, c + 1, b + 1]) # 1-based OBJ indices + faces.append([b + 1, c + 1, d + 1]) + + return { + "resX": res_x, + "resY": res_y, + "vertices": vertices, + "faces": faces, + "heights": heights, + "vertexCount": len(vertices), + "faceCount": len(faces), + } + + +def export_mesh_obj( + vertices: list[list[float]], + faces: list[list[int]], + *, + object_name: str = "seafloor", +) -> str: + safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "seafloor")) or "seafloor" + lines = [ + f"# DotsToSurface MLE seafloor ({len(vertices)} vertices, {len(faces)} faces)", + f"o {safe}", + ] + for v in vertices: + lines.append(f"v {v[0]:.8f} {v[1]:.8f} {v[2]:.8f}") + for f in faces: + lines.append(f"f {f[0]} {f[1]} {f[2]}") + return "\n".join(lines) + "\n" + + +def write_mesh_obj_file( + path: Path, + vertices: list[list[float]], + faces: list[list[int]], + *, + object_name: str = "seafloor", +) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + export_mesh_obj(vertices, faces, object_name=object_name), + encoding="utf-8", + ) + return path + + +def save_survey_surface( + *, + output_dir: str | Path, + vertices: list[list[float]], + faces: list[list[int]], + filename: str = "seafloor.obj", +) -> dict[str, Any]: + """Overwrite the survey OBJ inside an existing MLE run folder.""" + run_dir = Path(output_dir) + if not run_dir.is_absolute(): + run_dir = resolve_mle_dir(run_dir) + if not run_dir.is_dir(): + raise FileNotFoundError(f"MLE run folder not found: {run_dir}") + if not vertices: + raise ValueError("vertices must not be empty") + obj_path = write_mesh_obj_file( + run_dir / filename, + vertices, + faces, + object_name="seafloor", + ) + return { + "outputDir": str(run_dir), + "seafloorObj": str(obj_path), + "vertexCount": len(vertices), + "faceCount": len(faces), + } + + +def prepare_mle_scene( + *, + seed: int = 42, + size_x: float = 40.0, + size_y: float = 60.0, + res_x: int = 80, + res_y: int = 120, + output_dir: str | Path = "mle_runs", + settings: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Generate seafloor for simulation; create run folder with empty survey OBJ.""" + base = resolve_mle_dir(output_dir) + base.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + run_dir = base / stamp + n = 2 + while run_dir.exists(): + run_dir = base / f"{stamp}_{n}" + n += 1 + run_dir.mkdir(parents=True, exist_ok=False) + + params = build_seafloor_params(seed, size_x=size_x, size_y=size_y) + mesh = sample_seafloor_grid(params, res_x=res_x, res_y=res_y) + # Survey OBJ starts empty and is filled from multibeam hits during motion. + obj_path = run_dir / "seafloor.obj" + obj_path.write_text( + "# DotsToSurface MLE survey surface (populated during AUV motion)\no seafloor\n", + encoding="utf-8", + ) + # Keep full terrain for reference / debugging (not the survey panel content). + write_mesh_obj_file( + run_dir / "terrain_full.obj", + mesh["vertices"], + mesh["faces"], + object_name="terrain_full", + ) + + manifest = { + "generatedAt": datetime.now(timezone.utc).isoformat(), + "runName": run_dir.name, + "outputDir": str(run_dir), + "seafloorObj": str(obj_path), + "params": params, + "mesh": { + "resX": mesh["resX"], + "resY": mesh["resY"], + "vertexCount": mesh["vertexCount"], + "faceCount": mesh["faceCount"], + }, + "settings": settings or {}, + } + (run_dir / "mle_run.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + return { + "runName": run_dir.name, + "outputDir": str(run_dir), + "seafloorObj": str(obj_path), + "params": params, + "mesh": { + "resX": mesh["resX"], + "resY": mesh["resY"], + "vertices": mesh["vertices"], + "faces": mesh["faces"], + "heights": mesh["heights"], + "vertexCount": mesh["vertexCount"], + "faceCount": mesh["faceCount"], + }, + } diff --git a/backend/scene_generator.py b/backend/scene_generator.py index 78409fc..f7b6316 100644 --- a/backend/scene_generator.py +++ b/backend/scene_generator.py @@ -649,13 +649,87 @@ def export_ply(points: list[list[float]]) -> str: return header + body + ("\n" if points else "") -def export_obj(points: list[list[float]], object_name: str = "cloud") -> str: +def export_obj( + points: list[list[float]], + object_name: str = "cloud", + classes: list[int | float] | None = None, +) -> str: safe_name = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "cloud")) or "cloud" - lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"] - for p in points: - lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}") + if classes is None: + lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"] + for p in points: + lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}") + return "\n".join(lines) + "\n" + + if len(classes) != len(points): + raise ValueError("classes length must match points length") + + class_names = {0: "background", 1: "object"} + grouped: dict[int, list[list[float]]] = {} + for point, cls in zip(points, classes): + grouped.setdefault(int(cls), []).append(point) + + lines = [ + f"# DotsToSurface labeled point cloud ({len(points)} vertices)", + "# Classes: background=0, object=1", + f"o {safe_name}", + ] + for cls_id in sorted(grouped.keys()): + group_name = class_names.get(cls_id, f"class_{cls_id}") + lines.append(f"o {group_name}") + lines.append(f"# class {cls_id}") + for p in grouped[cls_id]: + lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}") return "\n".join(lines) + "\n" + +def _class_from_obj_group(name: str) -> float | None: + key = (name or "").strip().lower() + if key == "background": + return 0.0 + if key == "object": + return 1.0 + if key.startswith("class_"): + try: + return float(key.split("_", 1)[1]) + except (IndexError, ValueError): + return None + return None + + +def parse_obj_labeled_points(text: str) -> list[list[float]]: + """Extract [x, y, z, class] from OBJ with class groups or ``# class N`` markers.""" + labeled: list[list[float]] = [] + current_class = 0.0 + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + lower = line.lower() + if lower.startswith("# class "): + try: + current_class = float(line.split()[-1]) + except ValueError: + pass + continue + if lower.startswith("o "): + cls = _class_from_obj_group(line[2:]) + if cls is not None: + current_class = cls + continue + if lower.startswith("v "): + parts = line.split() + if len(parts) < 4: + continue + try: + labeled.append( + [float(parts[1]), float(parts[2]), float(parts[3]), current_class] + ) + except ValueError: + continue + return labeled + + def parse_obj_points(text: str) -> list[list[float]]: """Extract vertex positions from Wavefront OBJ (ignores faces/materials).""" points: list[list[float]] = [] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9808a00..836469a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -11,6 +11,9 @@ services: USER_PRESETS_DIR: /app/data/user-presets volumes: - ../presets:/app/presets:ro + - ../sonar_dataset:/app/sonar_dataset + - ../mle_runs:/app/mle_runs + - ../gbo_runs:/app/gbo_runs - dottosurface-user-presets:/app/data/user-presets volumes: diff --git a/frontend/web/src/App.vue b/frontend/web/src/App.vue index 711ec8f..955c2ac 100644 --- a/frontend/web/src/App.vue +++ b/frontend/web/src/App.vue @@ -47,6 +47,20 @@ onMounted(async () => { > Генератор Датасета + + Имитатор МЛЭ + + + Имитатор ГБО +
diff --git a/frontend/web/src/api/client.js b/frontend/web/src/api/client.js index 96bb3c9..c612742 100644 --- a/frontend/web/src/api/client.js +++ b/frontend/web/src/api/client.js @@ -387,6 +387,70 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stem, outputDir, maxPoints }), }), + datasetRuns: (outputDir = "sonar_dataset") => + request(`/api/dataset/runs?outputDir=${encodeURIComponent(outputDir || "sonar_dataset")}`), + datasetLoad: ({ outputDir } = {}) => + request("/api/dataset/load", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outputDir }), + }), + mlePrepare: ({ + seed = 42, + sizeX = 40, + sizeY = 60, + resX = 80, + resY = 120, + outputDir = "mle_runs", + settings = null, + } = {}) => + request("/api/mle/prepare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ seed, sizeX, sizeY, resX, resY, outputDir, settings }), + }), + mleSaveSurface: ({ outputDir, vertices, faces, filename = "seafloor.obj" } = {}) => + request("/api/mle/save-surface", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outputDir, vertices, faces, filename }), + }), + mleLoadSettings: ({ outputDir = "mle_runs" } = {}) => + request(`/api/mle/settings?outputDir=${encodeURIComponent(outputDir || "mle_runs")}`), + mleSaveSettings: ({ outputDir = "mle_runs", settings = {} } = {}) => + request("/api/mle/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outputDir, settings }), + }), + gboPrepare: ({ + seed = 42, + sizeX = 40, + sizeY = 60, + resX = 80, + resY = 120, + outputDir = "gbo_runs", + settings = null, + } = {}) => + request("/api/gbo/prepare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ seed, sizeX, sizeY, resX, resY, outputDir, settings }), + }), + gboSaveSurface: ({ outputDir, vertices, faces, filename = "seafloor.obj" } = {}) => + request("/api/gbo/save-surface", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outputDir, vertices, faces, filename }), + }), + gboLoadSettings: ({ outputDir = "gbo_runs" } = {}) => + request(`/api/gbo/settings?outputDir=${encodeURIComponent(outputDir || "gbo_runs")}`), + gboSaveSettings: ({ outputDir = "gbo_runs", settings = {} } = {}) => + request("/api/gbo/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outputDir, settings }), + }), }; export { exportFilename, parseObjPoints, parseXyzPoints, parsePlyPoints, parseCloudPoints }; diff --git a/frontend/web/src/composables/useGboSimulator.js b/frontend/web/src/composables/useGboSimulator.js new file mode 100644 index 0000000..2913101 --- /dev/null +++ b/frontend/web/src/composables/useGboSimulator.js @@ -0,0 +1,1277 @@ +import { onBeforeUnmount, onMounted } from "vue"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; +import { api } from "@/api/client"; +import { buildSnapshotFilename, downloadPngDataUrl } from "@/utils/snapshot"; + +function degToRad(deg) { + return (Number(deg) || 0) * (Math.PI / 180); +} + +function normalizeModel(root, targetSize = 1.5) { + const box = new THREE.Box3().setFromObject(root); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z, 1e-6); + root.scale.multiplyScalar(targetSize / maxDim); + const box2 = new THREE.Box3().setFromObject(root); + root.position.sub(box2.getCenter(new THREE.Vector3())); + root.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + if (child.material) { + child.material = new THREE.MeshStandardMaterial({ + color: child.material.color || 0xcccccc, + metalness: 0.15, + roughness: 0.75, + flatShading: true, + }); + } + } + }); + return root; +} + +async function loadObjFile(file, sizeM = 1.5) { + if (!file) return null; + const text = await file.text(); + const target = Math.max(0.05, Number(sizeM) || 1.5); + return normalizeModel(new OBJLoader().parse(text), target); +} + +function sampleHeight(meshInfo, x, y) { + if (!meshInfo) return -5; + const { resX, resY, heights, sizeX, sizeY } = meshInfo; + const halfX = sizeX * 0.5; + const halfY = sizeY * 0.5; + // Clamp to edges — terrain continues "infinitely" outside the core patch + const u = Math.min(1, Math.max(0, (x + halfX) / sizeX)); + const v = Math.min(1, Math.max(0, (y + halfY) / sizeY)); + const fx = u * (resX - 1); + const fy = v * (resY - 1); + const i0 = Math.floor(fx); + const j0 = Math.floor(fy); + const i1 = Math.min(resX - 1, i0 + 1); + const j1 = Math.min(resY - 1, j0 + 1); + const tx = fx - i0; + const ty = fy - j0; + const h00 = heights[j0][i0]; + const h10 = heights[j0][i1]; + const h01 = heights[j1][i0]; + const h11 = heights[j1][i1]; + return (h00 * (1 - tx) + h10 * tx) * (1 - ty) + (h01 * (1 - tx) + h11 * tx) * ty; +} + +/** + * @param {import('vue').Ref} containerRef main 3D view + * @param {import('vue').Ref} surveyRef bottom-right survey surface panel + */ +export function useGboSimulator(containerRef, surveyRef) { + const DETECTION_RANGE_DEFAULT = 400; + + // —— main scene —— + const SCENE_BG = 0x1a3d38; + const SEAFLOOR_COLOR = 0x3f7a62; + const RAY_COLOR = 0xffb020; + const scene = new THREE.Scene(); + scene.background = new THREE.Color(SCENE_BG); + scene.fog = new THREE.Fog(SCENE_BG, 120, 900); + const camera = new THREE.PerspectiveCamera(55, 1, 0.05, 2500); + camera.position.set(18, -28, 14); + camera.up.set(0, 0, 1); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + scene.add(new THREE.HemisphereLight(0xd7ecff, 0x2a4034, 1.15)); + const dir = new THREE.DirectionalLight(0xffffff, 1.2); + dir.position.set(14, -10, 28); + scene.add(dir); + scene.add(new THREE.AmbientLight(0x6a7a90, 0.55)); + + // —— survey panel scene (content of seafloor.obj) —— + const SURVEY_BG = 0x2a5a4a; + const surveyScene = new THREE.Scene(); + surveyScene.background = new THREE.Color(SURVEY_BG); + surveyScene.fog = new THREE.Fog(SURVEY_BG, 40, 220); + const surveyCamera = new THREE.PerspectiveCamera(45, 1, 0.05, 2500); + surveyCamera.up.set(0, 0, 1); + surveyCamera.position.set(0, -25, 18); + const surveyRenderer = new THREE.WebGLRenderer({ antialias: true }); + surveyRenderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + surveyScene.add(new THREE.HemisphereLight(0xd8efff, 0x243830, 1.15)); + const surveyDir = new THREE.DirectionalLight(0xffffff, 1.05); + surveyDir.position.set(8, -10, 24); + surveyScene.add(surveyDir); + let surveyControls = null; + let surveyMesh = null; + let surveyShadowMesh = null; + let surveyBlindMesh = null; + let surveyBackdrop = null; + let surveyCameraFitted = false; + let surveyUserOrbit = false; + + // ГБО: сплошной сектор на каждом борту от 12° до 75° от надира + const GBO_INNER_DEG = 12; + const GBO_OUTER_DEG = 75; + const GBO_MAX_DEG = 88; // beyond sector → also uninsonified (black) + const GBO_SECTOR_STEP_DEG = 0.5; + const GBO_BLIND_EDGE_DEG = GBO_INNER_DEG; + const GBO_BLIND_SAMPLES = 9; + const GBO_OUTER_BLIND_SAMPLES = 7; + const GBO_DRAW_STEP_DEG = 5; + const raycaster = new THREE.Raycaster(); + raycaster.far = DETECTION_RANGE_DEFAULT; + + let controls = null; + let animationId = 0; + let seafloorMesh = null; // unused for draw; height-field used for hits + let seafloorGrid = null; + let auvPivot = null; + let objectPivot = null; + let rayLines = null; + let sectorFans = null; // translucent sector wedges port/starboard + let trailLine = null; + let meshInfo = null; + let running = false; + let lastTs = 0; + let traveled = 0; + let surveyLength = 40; + let speed = 1.5; + let headingRad = 0; + let beamCount = 45; + let swathAngleDeg = 90; + let detectionRangeM = DETECTION_RANGE_DEFAULT; + let auvDepth = 2.5; + let lastObjectZ = 0; + let lastObjectRotXDeg = 0; + let lastObjectRotYDeg = 0; + let lastObjectRotZDeg = 0; + const trailPoints = []; + let onStatus = () => {}; + let onFinished = () => {}; + let runOutputDir = null; + let saveBusy = false; + let lastSaveTs = 0; + let lastStripDist = -1e9; + + // Progressive survey surface — first-hit returns per GBO beam (not across blind zone) + let prevHitsByBeam = null; // { [beamId]: hit[] } + let prevBlindByRegion = null; // { nadir, portOuter, stbdOuter } + let prevShadowHits = null; + const surveyVertices = []; + const surveyVertexIsObject = []; + const surveyFaces = []; + const shadowVertices = []; + const shadowFaces = []; + const blindVertices = []; + const blindFaces = []; + let surveyDirty = false; + + function resize() { + const el = containerRef.value; + if (el) { + const w = el.clientWidth; + const h = el.clientHeight; + camera.aspect = w / Math.max(h, 1); + camera.updateProjectionMatrix(); + renderer.setSize(w, h, false); + renderer.domElement.style.width = "100%"; + renderer.domElement.style.height = "100%"; + renderer.domElement.style.display = "block"; + } + const sEl = surveyRef?.value; + if (sEl) { + const w = Math.max(1, sEl.clientWidth); + const h = Math.max(1, sEl.clientHeight); + surveyCamera.aspect = w / h; + surveyCamera.updateProjectionMatrix(); + surveyRenderer.setSize(w, h, false); + surveyRenderer.domElement.style.width = "100%"; + surveyRenderer.domElement.style.height = "100%"; + surveyRenderer.domElement.style.display = "block"; + if (surveyMesh || surveyBackdrop) fitSurveyCamera(false); + } + } + + function clearObject(obj, parent = scene) { + if (!obj) return; + parent.remove(obj); + obj.traverse?.((child) => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (Array.isArray(child.material)) child.material.forEach((m) => m.dispose()); + else child.material.dispose(); + } + }); + } + + function buildSeafloor(payload) { + clearObject(seafloorMesh); + clearObject(seafloorGrid); + seafloorMesh = null; + seafloorGrid = null; + meshInfo = null; + if (!payload?.mesh?.heights?.length && !payload?.mesh?.vertices?.length) return; + const { heights, resX, resY } = payload.mesh; + const params = payload.params || {}; + const sizeX = Number(params.sizeX) || 40; + const sizeY = Number(params.sizeY) || 60; + meshInfo = { + resX, + resY, + heights, + sizeX, + sizeY, + baseZ: params.baseZ, + }; + + // Lightweight terrain grid (LineSegments) — infinite skirt, low vertex count + const pad = Math.max(sizeX, sizeY, detectionRangeM || 400) * 2.5; + const extSizeX = sizeX + pad * 2; + const extSizeY = sizeY + pad * 2; + // Coarse grid: enough to show relief, cheap to draw + const nX = Math.min(64, Math.max(24, Math.round(extSizeX / Math.max(sizeX / 16, 4)) + 1)); + const nY = Math.min(64, Math.max(24, Math.round(extSizeY / Math.max(sizeY / 16, 4)) + 1)); + const halfX = extSizeX * 0.5; + const halfY = extSizeY * 0.5; + const pts = new Float32Array(nX * nY * 3); + for (let j = 0; j < nY; j += 1) { + const y = -halfY + (j / (nY - 1)) * extSizeY; + for (let i = 0; i < nX; i += 1) { + const x = -halfX + (i / (nX - 1)) * extSizeX; + const z = sampleHeight(meshInfo, x, y); + const o = (j * nX + i) * 3; + pts[o] = x; + pts[o + 1] = y; + pts[o + 2] = z; + } + } + // Horizontal + vertical polylines as segments + const segCount = nY * (nX - 1) + nX * (nY - 1); + const linePos = new Float32Array(segCount * 2 * 3); + let w = 0; + const writeSeg = (i0, j0, i1, j1) => { + const a = (j0 * nX + i0) * 3; + const b = (j1 * nX + i1) * 3; + linePos[w++] = pts[a]; + linePos[w++] = pts[a + 1]; + linePos[w++] = pts[a + 2]; + linePos[w++] = pts[b]; + linePos[w++] = pts[b + 1]; + linePos[w++] = pts[b + 2]; + }; + for (let j = 0; j < nY; j += 1) { + for (let i = 0; i < nX - 1; i += 1) writeSeg(i, j, i + 1, j); + } + for (let i = 0; i < nX; i += 1) { + for (let j = 0; j < nY - 1; j += 1) writeSeg(i, j, i, j + 1); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(linePos, 3)); + seafloorGrid = new THREE.LineSegments( + geometry, + new THREE.LineBasicMaterial({ + color: SEAFLOOR_COLOR, + transparent: true, + opacity: 0.9, + }), + ); + scene.add(seafloorGrid); + buildSurveyBackdrop(); + } + + function buildSurveyBackdrop() { + clearObject(surveyBackdrop, surveyScene); + surveyBackdrop = null; + if (!meshInfo) return; + // Core patch only — fills the survey panel without huge empty skirt margins + const sizeX = meshInfo.sizeX || 40; + const sizeY = meshInfo.sizeY || 60; + const nX = 28; + const nY = 28; + const halfX = sizeX * 0.5; + const halfY = sizeY * 0.5; + const pts = new Float32Array(nX * nY * 3); + for (let j = 0; j < nY; j += 1) { + const y = -halfY + (j / (nY - 1)) * sizeY; + for (let i = 0; i < nX; i += 1) { + const x = -halfX + (i / (nX - 1)) * sizeX; + const z = sampleHeight(meshInfo, x, y); + const o = (j * nX + i) * 3; + pts[o] = x; + pts[o + 1] = y; + pts[o + 2] = z; + } + } + const segCount = nY * (nX - 1) + nX * (nY - 1); + const linePos = new Float32Array(segCount * 2 * 3); + let w = 0; + const writeSeg = (i0, j0, i1, j1) => { + const a = (j0 * nX + i0) * 3; + const b = (j1 * nX + i1) * 3; + linePos[w++] = pts[a]; + linePos[w++] = pts[a + 1]; + linePos[w++] = pts[a + 2]; + linePos[w++] = pts[b]; + linePos[w++] = pts[b + 1]; + linePos[w++] = pts[b + 2]; + }; + for (let j = 0; j < nY; j += 1) { + for (let i = 0; i < nX - 1; i += 1) writeSeg(i, j, i + 1, j); + } + for (let i = 0; i < nX; i += 1) { + for (let j = 0; j < nY - 1; j += 1) writeSeg(i, j, i, j + 1); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(linePos, 3)); + surveyBackdrop = new THREE.LineSegments( + geometry, + new THREE.LineBasicMaterial({ + color: 0x3d7a62, + transparent: true, + opacity: 0.45, + }), + ); + surveyScene.add(surveyBackdrop); + if (!surveyCameraFitted) fitSurveyCamera(true); + } + + function resetSurveySurface() { + prevHitsByBeam = null; + prevBlindByRegion = null; + prevShadowHits = null; + surveyVertices.length = 0; + surveyVertexIsObject.length = 0; + surveyFaces.length = 0; + shadowVertices.length = 0; + shadowFaces.length = 0; + blindVertices.length = 0; + blindFaces.length = 0; + surveyDirty = false; + lastStripDist = -1e9; + surveyCameraFitted = false; + surveyUserOrbit = false; + clearObject(surveyMesh, surveyScene); + clearObject(surveyShadowMesh, surveyScene); + clearObject(surveyBlindMesh, surveyScene); + surveyMesh = null; + surveyShadowMesh = null; + surveyBlindMesh = null; + } + + function fitSurveyCamera(force = false) { + if (!surveyControls) return; + if (surveyUserOrbit && !force) return; + const box = new THREE.Box3(); + // Prefer survey content; fall back to core terrain patch (not infinite skirt) + if (surveyMesh) box.expandByObject(surveyMesh); + if (surveyShadowMesh) box.expandByObject(surveyShadowMesh); + if (surveyBlindMesh) box.expandByObject(surveyBlindMesh); + if (box.isEmpty() && meshInfo) { + const hx = (meshInfo.sizeX || 40) * 0.5; + const hy = (meshInfo.sizeY || 60) * 0.5; + const z0 = sampleHeight(meshInfo, 0, 0); + box.expandByPoint(new THREE.Vector3(-hx, -hy, z0 - 0.5)); + box.expandByPoint(new THREE.Vector3(hx, hy, z0 + 1.5)); + } else if (box.isEmpty() && surveyBackdrop) { + box.expandByObject(surveyBackdrop); + } + if (box.isEmpty()) return; + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + // Slight pad so geometry touches panel edges without clipping + const spanX = Math.max(size.x, 0.5); + const spanY = Math.max(size.y, 0.5); + const spanZ = Math.max(size.z, 0.5); + const span = Math.max(spanX, spanY); + const fov = degToRad(surveyCamera.fov); + const aspect = Math.max(surveyCamera.aspect || 1, 0.01); + const halfFov = Math.tan(fov * 0.5); + const distForY = (span * 0.5) / halfFov; + const distForX = (span * 0.5) / (halfFov * aspect); + // 0.92 → content reaches near the edges of the survey window + const dist = Math.max(distForX, distForY, spanZ * 1.2) * 0.92; + const dir = new THREE.Vector3(0.15, -0.95, 0.72).normalize(); + surveyCamera.near = Math.max(0.05, dist / 400); + surveyCamera.far = Math.max(500, dist * 10); + surveyCamera.updateProjectionMatrix(); + if (surveyScene.fog) { + surveyScene.fog.near = dist * 2.5; + surveyScene.fog.far = dist * 8; + } + surveyControls.target.copy(center); + surveyCamera.position.copy(center).addScaledVector(dir, dist); + surveyControls.update(); + surveyCameraFitted = true; + } + + function rebuildSurveyMesh() { + clearObject(surveyMesh, surveyScene); + surveyMesh = null; + if (surveyVertices.length < 3 || surveyFaces.length < 1) return; + const positions = new Float32Array(surveyVertices.length * 3); + const colors = new Float32Array(surveyVertices.length * 3); + const colFloor = new THREE.Color(0x4ade80); + const colObject = new THREE.Color(0xf59e0b); + for (let i = 0; i < surveyVertices.length; i += 1) { + positions[i * 3] = surveyVertices[i][0]; + positions[i * 3 + 1] = surveyVertices[i][1]; + positions[i * 3 + 2] = surveyVertices[i][2]; + const c = surveyVertexIsObject[i] ? colObject : colFloor; + colors[i * 3] = c.r; + colors[i * 3 + 1] = c.g; + colors[i * 3 + 2] = c.b; + } + const indices = []; + for (const f of surveyFaces) indices.push(f[0] - 1, f[1] - 1, f[2] - 1); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + surveyMesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + vertexColors: true, + metalness: 0.08, + roughness: 0.7, + flatShading: true, + side: THREE.DoubleSide, + }), + ); + surveyScene.add(surveyMesh); + if (!surveyCameraFitted) fitSurveyCamera(true); + } + + function rebuildBlindMesh() { + clearObject(surveyBlindMesh, surveyScene); + surveyBlindMesh = null; + if (blindVertices.length < 3 || blindFaces.length < 1) return; + const positions = new Float32Array(blindVertices.length * 3); + for (let i = 0; i < blindVertices.length; i += 1) { + positions[i * 3] = blindVertices[i][0]; + positions[i * 3 + 1] = blindVertices[i][1]; + positions[i * 3 + 2] = blindVertices[i][2] + 0.03; + } + const indices = []; + for (const f of blindFaces) indices.push(f[0] - 1, f[1] - 1, f[2] - 1); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + surveyBlindMesh = new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ + color: 0x000000, + side: THREE.DoubleSide, + }), + ); + surveyScene.add(surveyBlindMesh); + } + + function rebuildShadowMesh() { + clearObject(surveyShadowMesh, surveyScene); + surveyShadowMesh = null; + if (shadowVertices.length < 3 || shadowFaces.length < 1) return; + const positions = new Float32Array(shadowVertices.length * 3); + for (let i = 0; i < shadowVertices.length; i += 1) { + positions[i * 3] = shadowVertices[i][0]; + positions[i * 3 + 1] = shadowVertices[i][1]; + positions[i * 3 + 2] = shadowVertices[i][2] + 0.04; + } + const indices = []; + for (const f of shadowFaces) indices.push(f[0] - 1, f[1] - 1, f[2] - 1); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + surveyShadowMesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + color: 0x1a1028, + metalness: 0.05, + roughness: 0.95, + flatShading: true, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.75, + }), + ); + surveyScene.add(surveyShadowMesh); + } + + function stitchHitRows(prevRow, nextRow, vertexStore, faceStore, flagStore = null, flagValue = false) { + if (!prevRow || !nextRow || prevRow.length < 2 || prevRow.length !== nextRow.length) return false; + const base = vertexStore.length; + for (const h of prevRow) { + vertexStore.push([h.x, h.y, h.z]); + if (flagStore) flagStore.push(flagValue ? !!h.isObject : false); + } + for (const h of nextRow) { + vertexStore.push([h.x, h.y, h.z]); + if (flagStore) flagStore.push(flagValue ? !!h.isObject : false); + } + const n = nextRow.length; + for (let i = 0; i < n - 1; i += 1) { + const a = base + i + 1; + const b = base + i + 2; + const c = base + n + i + 1; + const d = base + n + i + 2; + faceStore.push([a, c, b]); + faceStore.push([b, c, d]); + } + return true; + } + + function appendSurveyStrip(ping) { + if (!ping?.beams) return; + const beamIds = Object.keys(ping.beams); + if (prevHitsByBeam) { + let rebuilt = false; + for (const id of beamIds) { + const prev = prevHitsByBeam[id]; + const next = ping.beams[id]; + if (!prev || !next) continue; + if ( + stitchHitRows(prev, next, surveyVertices, surveyFaces, surveyVertexIsObject, true) + ) { + rebuilt = true; + } + } + if (rebuilt) { + surveyDirty = true; + rebuildSurveyMesh(); + } + } + prevHitsByBeam = {}; + for (const id of beamIds) { + prevHitsByBeam[id] = ping.beams[id].map((h) => ({ + x: h.x, + y: h.y, + z: h.z, + isObject: !!h.isObject, + })); + } + + // Uninsonified zones (black): nadir gap + outside ±75° sectors + const regions = ping.blindRegions || {}; + const regionIds = Object.keys(regions); + if (prevBlindByRegion) { + let any = false; + for (const id of regionIds) { + const prev = prevBlindByRegion[id]; + const next = regions[id]; + if (!prev || !next?.length) continue; + if (stitchHitRows(prev, next, blindVertices, blindFaces)) any = true; + } + if (any) { + rebuildBlindMesh(); + if (!surveyCameraFitted) fitSurveyCamera(true); + } + } + prevBlindByRegion = {}; + for (const id of regionIds) { + prevBlindByRegion[id] = (regions[id] || []).map((h) => ({ x: h.x, y: h.y, z: h.z })); + } + + // Acoustic shadow (behind object) — within each sector only + const shadowRow = []; + const shadowGroups = []; + for (const id of beamIds) { + const start = shadowRow.length; + for (const h of ping.beams[id]) { + shadowRow.push(h.shadowFloor ? { x: h.shadowFloor.x, y: h.shadowFloor.y, z: h.shadowFloor.z } : null); + } + shadowGroups.push({ start, end: shadowRow.length }); + } + if (prevShadowHits && prevShadowHits.length === shadowRow.length) { + for (const g of shadowGroups) { + for (let i = g.start; i < g.end - 1; i += 1) { + const a = prevShadowHits[i]; + const b = prevShadowHits[i + 1]; + const c = shadowRow[i]; + const d = shadowRow[i + 1]; + if (!a || !b || !c || !d) continue; + const base = shadowVertices.length; + shadowVertices.push([a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z], [d.x, d.y, d.z]); + shadowFaces.push([base + 1, base + 3, base + 2]); + shadowFaces.push([base + 2, base + 3, base + 4]); + } + } + rebuildShadowMesh(); + } + prevShadowHits = shadowRow; + } + + function sectorAngles(sideSign) { + // Continuous sector [12°, 75°] on one side; sideSign = −1 port, +1 starboard + const angles = []; + const n = Math.max(2, Math.round((GBO_OUTER_DEG - GBO_INNER_DEG) / GBO_SECTOR_STEP_DEG) + 1); + for (let i = 0; i < n; i += 1) { + const t = i / (n - 1); + const a = GBO_INNER_DEG + t * (GBO_OUTER_DEG - GBO_INNER_DEG); + angles.push(sideSign * a); + } + // Port: from −12 toward −75 (more negative) — reverse so across-track order is L→R overall + if (sideSign < 0) angles.reverse(); + return angles; + } + + function drawSectorAngles(sideSign) { + // Sparser set for visual ray segments: boundaries + every GBO_DRAW_STEP_DEG + const angles = []; + for (let a = GBO_INNER_DEG; a <= GBO_OUTER_DEG + 1e-6; a += GBO_DRAW_STEP_DEG) { + angles.push(sideSign * Math.min(a, GBO_OUTER_DEG)); + } + if (Math.abs(angles[angles.length - 1]) !== GBO_OUTER_DEG) { + angles.push(sideSign * GBO_OUTER_DEG); + } + if (sideSign < 0) angles.reverse(); + return angles; + } + + function castRayHit(origin, across, angleDeg, maxRange) { + const angle = degToRad(angleDeg); + const beamDir = new THREE.Vector3( + Math.sin(angle) * across.x, + Math.sin(angle) * across.y, + -Math.cos(angle), + ).normalize(); + + let floorPoint = null; + let floorDist = Infinity; + // Adaptive step: finer near AUV for short nadir ranges, covers up to maxRange + const step = Math.max(0.15, Math.min(1.0, maxRange / 400)); + const maxSteps = Math.ceil(maxRange / step) + 4; + const probe = origin.clone().addScaledVector(beamDir, step * 0.25); + for (let s = 0; s < maxSteps; s += 1) { + probe.addScaledVector(beamDir, step); + const dist = origin.distanceTo(probe); + if (dist > maxRange) break; + const floorZ = sampleHeight(meshInfo, probe.x, probe.y); + if (probe.z <= floorZ) { + // Linear refine along last step for symmetry / accuracy + const prev = probe.clone().addScaledVector(beamDir, -step); + let lo = 0; + let hi = 1; + for (let k = 0; k < 6; k += 1) { + const mid = (lo + hi) * 0.5; + const p = prev.clone().lerp(probe, mid); + if (p.z <= sampleHeight(meshInfo, p.x, p.y)) hi = mid; + else lo = mid; + } + const hitP = prev.clone().lerp(probe, hi); + floorPoint = new THREE.Vector3( + hitP.x, + hitP.y, + sampleHeight(meshInfo, hitP.x, hitP.y), + ); + floorDist = origin.distanceTo(floorPoint); + break; + } + } + + let objPoint = null; + let objDist = Infinity; + if (objectPivot) { + raycaster.set(origin, beamDir); + const intersects = raycaster.intersectObject(objectPivot, true); + if (intersects.length && intersects[0].distance <= maxRange) { + objPoint = intersects[0].point.clone(); + objDist = intersects[0].distance; + } + } + + let hit; + let isObject = false; + let shadowFloor = null; + if (objPoint && objDist < floorDist) { + hit = objPoint; + isObject = true; + if (floorPoint) shadowFloor = floorPoint; + } else if (floorPoint && floorDist <= maxRange) { + hit = floorPoint; + } else if (objPoint) { + hit = objPoint; + isObject = true; + } else { + hit = origin.clone().addScaledVector(beamDir, maxRange); + } + hit.isObject = isObject; + hit.shadowFloor = shadowFloor; + hit.angleDeg = angleDeg; + return hit; + } + + function castBeamHits() { + if (!auvPivot || !meshInfo) { + return { beams: {}, blindRegions: {}, allHits: [], blindEdgeHits: [], drawHits: [] }; + } + const origin = auvPivot.position.clone(); + const maxRange = Math.max(1, Number(detectionRangeM) || DETECTION_RANGE_DEFAULT); + raycaster.far = maxRange; + const across = new THREE.Vector3(-Math.sin(headingRad), Math.cos(headingRad), 0).normalize(); + if (objectPivot) objectPivot.updateMatrixWorld(true); + + // Two continuous sectors: port (−75…−12) and starboard (+12…+75) + const beams = { + port: sectorAngles(-1).map((a) => castRayHit(origin, across, a, maxRange)), + stbd: sectorAngles(1).map((a) => castRayHit(origin, across, a, maxRange)), + }; + const allHits = [...beams.port, ...beams.stbd]; + const drawHits = [ + ...drawSectorAngles(-1).map((a) => castRayHit(origin, across, a, maxRange)), + ...drawSectorAngles(1).map((a) => castRayHit(origin, across, a, maxRange)), + ]; + + // Floor-only samples for uninsonified angular bands (drawn black on survey) + const sampleFloorBand = (fromDeg, toDeg, count) => { + const out = []; + const n = Math.max(2, count); + for (let i = 0; i < n; i += 1) { + const t = i / (n - 1); + const angleDeg = fromDeg + t * (toDeg - fromDeg); + const angle = degToRad(angleDeg); + const beamDir = new THREE.Vector3( + Math.sin(angle) * across.x, + Math.sin(angle) * across.y, + -Math.cos(angle), + ).normalize(); + const step = Math.max(0.15, Math.min(1.0, maxRange / 400)); + const maxSteps = Math.ceil(maxRange / step) + 4; + const probe = origin.clone().addScaledVector(beamDir, step * 0.25); + let floorPoint = origin.clone().addScaledVector(beamDir, maxRange); + for (let s = 0; s < maxSteps; s += 1) { + probe.addScaledVector(beamDir, step); + if (origin.distanceTo(probe) > maxRange) break; + const floorZ = sampleHeight(meshInfo, probe.x, probe.y); + if (probe.z <= floorZ) { + floorPoint = new THREE.Vector3(probe.x, probe.y, floorZ); + break; + } + } + out.push(floorPoint); + } + return out; + }; + + // Black: nadir gap |θ|<12° AND outside sectors |θ|>75° (up to ~88°) + const blindRegions = { + portOuter: sampleFloorBand(-GBO_MAX_DEG, -GBO_OUTER_DEG, GBO_OUTER_BLIND_SAMPLES), + nadir: sampleFloorBand(-GBO_BLIND_EDGE_DEG, GBO_BLIND_EDGE_DEG, GBO_BLIND_SAMPLES), + stbdOuter: sampleFloorBand(GBO_OUTER_DEG, GBO_MAX_DEG, GBO_OUTER_BLIND_SAMPLES), + }; + + const blindEdgeHits = [ + castRayHit(origin, across, -GBO_MAX_DEG, maxRange), + castRayHit(origin, across, -GBO_OUTER_DEG, maxRange), + castRayHit(origin, across, -GBO_BLIND_EDGE_DEG, maxRange), + castRayHit(origin, across, GBO_BLIND_EDGE_DEG, maxRange), + castRayHit(origin, across, GBO_OUTER_DEG, maxRange), + castRayHit(origin, across, GBO_MAX_DEG, maxRange), + ]; + + return { beams, blindRegions, allHits, blindEdgeHits, drawHits }; + } + + function clearSectorFans() { + if (!sectorFans) return; + scene.remove(sectorFans); + sectorFans.traverse?.((child) => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (Array.isArray(child.material)) child.material.forEach((m) => m.dispose()); + else child.material.dispose(); + } + }); + sectorFans = null; + } + + function updateRays(accumulateSurvey = false) { + if (rayLines) { + scene.remove(rayLines); + rayLines.geometry?.dispose(); + if (Array.isArray(rayLines.material)) rayLines.material.forEach((m) => m.dispose()); + else rayLines.material?.dispose(); + rayLines = null; + } + clearSectorFans(); + if (!auvPivot || !meshInfo) return; + const origin = auvPivot.position.clone(); + const ping = castBeamHits(); + + // Continuous sector wedges (port / starboard) + sectorFans = new THREE.Group(); + const fanMat = new THREE.MeshBasicMaterial({ + color: RAY_COLOR, + transparent: true, + opacity: 0.22, + side: THREE.DoubleSide, + depthWrite: false, + }); + const buildFan = (hits) => { + if (!hits || hits.length < 2) return; + const pos = []; + const indices = []; + pos.push(origin.x, origin.y, origin.z); + for (const h of hits) pos.push(h.x, h.y, h.z); + for (let i = 0; i < hits.length - 1; i += 1) { + indices.push(0, i + 1, i + 2); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); + geo.setIndex(indices); + geo.computeVertexNormals(); + sectorFans.add(new THREE.Mesh(geo, fanMat.clone())); + }; + buildFan(ping.beams.port); + buildFan(ping.beams.stbd); + scene.add(sectorFans); + + // Ray segments along the sector + black blind-zone edges + const drawHits = ping.drawHits?.length ? ping.drawHits : ping.allHits; + const blindEdges = ping.blindEdgeHits || []; + const nSeg = drawHits.length + blindEdges.length; + const positions = new Float32Array(nSeg * 2 * 3); + const colors = new Float32Array(nSeg * 2 * 3); + const amber = new THREE.Color(RAY_COLOR); + const black = new THREE.Color(0x000000); + let w = 0; + const writeSeg = (hit, col) => { + const o = w * 6; + positions[o] = origin.x; + positions[o + 1] = origin.y; + positions[o + 2] = origin.z; + positions[o + 3] = hit.x; + positions[o + 4] = hit.y; + positions[o + 5] = hit.z; + colors[o] = col.r; + colors[o + 1] = col.g; + colors[o + 2] = col.b; + colors[o + 3] = col.r; + colors[o + 4] = col.g; + colors[o + 5] = col.b; + w += 1; + }; + for (const h of drawHits) writeSeg(h, amber); + for (const h of blindEdges) writeSeg(h, black); + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + rayLines = new THREE.LineSegments( + geometry, + new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85 }), + ); + scene.add(rayLines); + + if (accumulateSurvey) { + if (traveled - lastStripDist >= 0.35 || lastStripDist < 0) { + lastStripDist = traveled; + appendSurveyStrip(ping); + void persistSurveySurface(false); + } + } + } + + async function persistSurveySurface(force = false) { + if (!runOutputDir || !surveyVertices.length || !surveyFaces.length) return; + const now = performance.now(); + if (!force && (saveBusy || now - lastSaveTs < 800)) return; + saveBusy = true; + lastSaveTs = now; + try { + const result = await api.gboSaveSurface({ + outputDir: runOutputDir, + vertices: surveyVertices.map((v) => [...v]), + faces: surveyFaces.map((f) => [...f]), + }); + surveyDirty = false; + return result; + } catch { + return null; + } finally { + saveBusy = false; + } + } + + function makeFallbackAuv(sizeM = 10) { + const g = new THREE.Group(); + const body = new THREE.Mesh( + new THREE.CylinderGeometry(0.22, 0.22, 1.6, 12), + new THREE.MeshStandardMaterial({ color: 0xffb020, metalness: 0.3, roughness: 0.45 }), + ); + body.rotation.z = Math.PI / 2; + g.add(body); + const nose = new THREE.Mesh( + new THREE.ConeGeometry(0.22, 0.45, 10), + new THREE.MeshStandardMaterial({ color: 0xff7a18 }), + ); + nose.rotation.z = -Math.PI / 2; + nose.position.x = 1.0; + g.add(nose); + return normalizeModel(g, Math.max(0.05, Number(sizeM) || 10)); + } + + function makeFallbackObject(sizeM = 2) { + const g = new THREE.Group(); + g.add( + new THREE.Mesh( + new THREE.BoxGeometry(1.2, 0.6, 0.4), + new THREE.MeshStandardMaterial({ color: 0xf59e0b, metalness: 0.2, roughness: 0.6 }), + ), + ); + return normalizeModel(g, Math.max(0.05, Number(sizeM) || 2)); + } + + async function setAuvModel(file, sizeM = 10, x = 0, y = 0, depth = 2.5, headingDeg = 0) { + clearObject(auvPivot); + auvPivot = new THREE.Group(); + const targetSize = Math.max(0.05, Number(sizeM) || 10); + let model = null; + try { + model = await loadObjFile(file, targetSize); + } catch { + model = null; + } + if (!model) model = makeFallbackAuv(targetSize); + auvPivot.add(model); + scene.add(auvPivot); + placeAuv(x, y, depth, headingDeg); + } + + async function setObjectModel(file, sizeM = 2, x = 0, y = 0, zOffset = 0, rotXDeg = 0, rotYDeg = 0, rotZDeg = 0) { + clearObject(objectPivot); + objectPivot = new THREE.Group(); + const targetSize = Math.max(0.05, Number(sizeM) || 2); + let model = null; + try { + model = await loadObjFile(file, targetSize); + } catch { + model = null; + } + if (!model) model = makeFallbackObject(targetSize); + objectPivot.add(model); + scene.add(objectPivot); + placeObject(x, y, zOffset, rotXDeg, rotYDeg, rotZDeg); + } + + function placeAuv(x, y, depth, headingDeg) { + if (!auvPivot) return; + const floorZ = sampleHeight(meshInfo, x, y); + auvPivot.position.set(x, y, floorZ + Math.max(0.3, Number(depth) || 2.5)); + headingRad = degToRad(headingDeg); + auvPivot.rotation.set(0, 0, headingRad); + auvDepth = Math.max(0.3, Number(depth) || 2.5); + } + + function placeObject(x, y, zOffset, rotXDeg = 0, rotYDeg = 0, rotZDeg = 0) { + if (!objectPivot) return; + const floorZ = sampleHeight(meshInfo, x, y); + lastObjectZ = Number(zOffset) || 0; + lastObjectRotXDeg = Number(rotXDeg) || 0; + lastObjectRotYDeg = Number(rotYDeg) || 0; + lastObjectRotZDeg = Number(rotZDeg) || 0; + objectPivot.position.set(x, y, floorZ + lastObjectZ + 0.15); + objectPivot.rotation.set( + degToRad(lastObjectRotXDeg), + degToRad(lastObjectRotYDeg), + degToRad(lastObjectRotZDeg), + ); + } + + function updateTrail() { + if (!auvPivot) return; + trailPoints.push(auvPivot.position.clone()); + if (trailPoints.length > 500) trailPoints.shift(); + if (trailLine) { + scene.remove(trailLine); + trailLine.geometry?.dispose(); + trailLine.material?.dispose(); + } + if (trailPoints.length < 2) return; + const positions = new Float32Array(trailPoints.length * 3); + for (let i = 0; i < trailPoints.length; i += 1) { + positions[i * 3] = trailPoints[i].x; + positions[i * 3 + 1] = trailPoints[i].y; + positions[i * 3 + 2] = trailPoints[i].z; + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + trailLine = new THREE.Line( + geometry, + new THREE.LineBasicMaterial({ color: 0xffb020, transparent: true, opacity: 0.7 }), + ); + scene.add(trailLine); + } + + function tick(ts) { + animationId = requestAnimationFrame(tick); + if (running && auvPivot && meshInfo) { + if (!lastTs) lastTs = ts; + const dt = Math.min(0.05, (ts - lastTs) / 1000); + lastTs = ts; + const step = speed * dt; + traveled += step; + const nx = auvPivot.position.x + Math.cos(headingRad) * step; + const ny = auvPivot.position.y + Math.sin(headingRad) * step; + const floorZ = sampleHeight(meshInfo, nx, ny); + auvPivot.position.set(nx, ny, floorZ + auvDepth); + updateRays(true); + updateTrail(); + onStatus({ + traveled, + x: nx, + y: ny, + z: auvPivot.position.z, + surveyFaces: surveyFaces.length, + surveyVertices: surveyVertices.length, + }); + if (traveled >= surveyLength) { + running = false; + void persistSurveySurface(true).then((saved) => { + onFinished({ traveled, saved }); + }); + } + } + controls?.update(); + surveyControls?.update(); + renderer.render(scene, camera); + if (surveyRef?.value) surveyRenderer.render(surveyScene, surveyCamera); + } + + function fitCamera() { + if (!controls || !meshInfo) return; + // Frame AUV + object + core seafloor (not the huge infinite skirt) + const box = new THREE.Box3(); + const halfX = meshInfo.sizeX * 0.5; + const halfY = meshInfo.sizeY * 0.5; + const z0 = meshInfo.baseZ ?? sampleHeight(meshInfo, 0, 0); + box.expandByPoint(new THREE.Vector3(-halfX, -halfY, z0)); + box.expandByPoint(new THREE.Vector3(halfX, halfY, z0 + Math.max(auvDepth, 2) + 2)); + if (auvPivot) { + auvPivot.updateMatrixWorld(true); + box.expandByObject(auvPivot); + } + if (objectPivot) { + objectPivot.updateMatrixWorld(true); + box.expandByObject(objectPivot); + } + + const pad = Math.max(2, Math.max(meshInfo.sizeX, meshInfo.sizeY) * 0.08, Number(auvDepth) * 0.15 || 0); + box.expandByScalar(pad); + const paddedSize = box.getSize(new THREE.Vector3()); + const paddedCenter = box.getCenter(new THREE.Vector3()); + const maxDim = Math.max(paddedSize.x, paddedSize.y, paddedSize.z, 1); + const fov = degToRad(camera.fov); + const dist = (maxDim * 0.65) / Math.tan(fov * 0.5); + + const offset = new THREE.Vector3(0.55, -0.9, 0.55).normalize().multiplyScalar(Math.max(dist, maxDim * 0.85)); + camera.near = Math.max(0.05, maxDim / 800); + camera.far = Math.max(2500, maxDim * 25, detectionRangeM * 2, pad * 20); + camera.updateProjectionMatrix(); + if (scene.fog) { + scene.fog.near = Math.max(40, maxDim * 1.2); + scene.fog.far = Math.max(scene.fog.near + 80, camera.far * 0.55); + } + camera.position.copy(paddedCenter).add(offset); + controls.target.copy(paddedCenter); + controls.update(); + } + + async function applyScene({ seafloorPayload, auvFile, objectFile, params }) { + runOutputDir = seafloorPayload?.outputDir || null; + buildSeafloor(seafloorPayload); + resetSurveySurface(); + beamCount = params.beamCount ?? 45; + swathAngleDeg = params.swathAngleDeg ?? 90; + detectionRangeM = Math.max(1, Number(params.detectionRangeM) || DETECTION_RANGE_DEFAULT); + raycaster.far = detectionRangeM; + speed = Math.max(0.05, Number(params.speed) || 1.5); + surveyLength = Math.max(1, Number(params.surveyLength) || 40); + auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5); + await setAuvModel( + auvFile, + params.auvSizeM, + params.auvX, + params.auvY, + params.auvDepth, + params.auvHeadingDeg, + ); + await setObjectModel( + objectFile, + params.objectSizeM, + params.objectX, + params.objectY, + params.objectZ, + params.objectRotXDeg, + params.objectRotYDeg, + params.objectRotZDeg ?? params.objectYawDeg, + ); + trailPoints.length = 0; + traveled = 0; + updateRays(false); + fitCamera(); + } + + function start() { + if (!auvPivot || !meshInfo) return false; + resetSurveySurface(); + running = true; + lastTs = 0; + traveled = 0; + trailPoints.length = 0; + updateRays(true); + return true; + } + + function stop() { + running = false; + void persistSurveySurface(true); + } + + function isRunning() { + return running; + } + + function setCallbacks({ status, finished } = {}) { + if (typeof status === "function") onStatus = status; + if (typeof finished === "function") onFinished = finished; + } + + function updateParams(params = {}) { + if (params.beamCount != null) beamCount = params.beamCount; + if (params.swathAngleDeg != null) swathAngleDeg = params.swathAngleDeg; + if (params.detectionRangeM != null) { + detectionRangeM = Math.max(1, Number(params.detectionRangeM) || DETECTION_RANGE_DEFAULT); + raycaster.far = detectionRangeM; + } + if (params.speed != null) speed = Math.max(0.05, Number(params.speed) || 1.5); + if (params.surveyLength != null) surveyLength = Math.max(1, Number(params.surveyLength) || 40); + if (params.auvDepth != null) auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5); + + let moved = false; + if (!running && auvPivot && (params.auvX != null || params.auvY != null || params.auvDepth != null || params.auvHeadingDeg != null)) { + placeAuv( + params.auvX ?? auvPivot.position.x, + params.auvY ?? auvPivot.position.y, + params.auvDepth ?? auvDepth, + params.auvHeadingDeg ?? (headingRad * 180) / Math.PI, + ); + updateRays(false); + moved = true; + } + if ( + objectPivot && + (params.objectX != null || + params.objectY != null || + params.objectZ != null || + params.objectRotXDeg != null || + params.objectRotYDeg != null || + params.objectRotZDeg != null || + params.objectYawDeg != null) + ) { + placeObject( + params.objectX ?? objectPivot.position.x, + params.objectY ?? objectPivot.position.y, + params.objectZ != null ? params.objectZ : lastObjectZ, + params.objectRotXDeg != null ? params.objectRotXDeg : lastObjectRotXDeg, + params.objectRotYDeg != null ? params.objectRotYDeg : lastObjectRotYDeg, + params.objectRotZDeg != null + ? params.objectRotZDeg + : params.objectYawDeg != null + ? params.objectYawDeg + : lastObjectRotZDeg, + ); + if (!running) updateRays(false); + moved = true; + } + if (moved || params.fitCamera) fitCamera(); + } + + onMounted(() => { + const el = containerRef.value; + if (el) { + el.appendChild(renderer.domElement); + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.target.set(0, 0, -3); + controls.update(); + } + const sEl = surveyRef?.value; + if (sEl) { + sEl.appendChild(surveyRenderer.domElement); + surveyControls = new OrbitControls(surveyCamera, surveyRenderer.domElement); + surveyControls.enableDamping = true; + surveyControls.enableZoom = true; + surveyControls.enablePan = true; + surveyControls.enableRotate = true; + surveyControls.target.set(0, 0, -4); + surveyControls.addEventListener("start", () => { + surveyUserOrbit = true; + }); + // Keep wheel zoom on the survey panel even while the main scene is busy + surveyRenderer.domElement.addEventListener( + "wheel", + (e) => { + e.stopPropagation(); + surveyUserOrbit = true; + }, + { passive: true }, + ); + surveyControls.update(); + } + resize(); + window.addEventListener("resize", resize); + animationId = requestAnimationFrame(tick); + }); + + onBeforeUnmount(() => { + running = false; + cancelAnimationFrame(animationId); + window.removeEventListener("resize", resize); + controls?.dispose(); + surveyControls?.dispose(); + clearObject(seafloorMesh); + clearObject(seafloorGrid); + clearObject(auvPivot); + clearObject(objectPivot); + clearObject(rayLines); + clearSectorFans(); + clearObject(trailLine); + clearObject(surveyMesh, surveyScene); + clearObject(surveyShadowMesh, surveyScene); + clearObject(surveyBlindMesh, surveyScene); + clearObject(surveyBackdrop, surveyScene); + renderer.dispose(); + surveyRenderer.dispose(); + renderer.domElement.remove(); + surveyRenderer.domElement.remove(); + }); + + return { + applyScene, + start, + stop, + isRunning, + setCallbacks, + updateParams, + resize, + fitCamera, + persistSurveySurface, + captureSnapshot(target = "scene") { + const isSurvey = target === "survey" || target === "relef" || target === "рельеф"; + const r = isSurvey ? surveyRenderer : renderer; + const cam = isSurvey ? surveyCamera : camera; + const sc = isSurvey ? surveyScene : scene; + if (!r?.domElement) return null; + r.render(sc, cam); + const windowKey = isSurvey ? "gbo-relef" : "gbo-scena"; + const windowLabel = isSurvey ? "Рельеф" : "Сцена"; + const filename = buildSnapshotFilename(windowKey); + downloadPngDataUrl(r.domElement.toDataURL("image/png"), filename); + return { filename, windowLabel, target: isSurvey ? "survey" : "scene" }; + }, + }; +} diff --git a/frontend/web/src/composables/useMleSimulator.js b/frontend/web/src/composables/useMleSimulator.js new file mode 100644 index 0000000..f721206 --- /dev/null +++ b/frontend/web/src/composables/useMleSimulator.js @@ -0,0 +1,1015 @@ +import { onBeforeUnmount, onMounted } from "vue"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; +import { api } from "@/api/client"; +import { buildSnapshotFilename, downloadPngDataUrl } from "@/utils/snapshot"; + +function degToRad(deg) { + return (Number(deg) || 0) * (Math.PI / 180); +} + +function normalizeModel(root, targetSize = 1.5) { + const box = new THREE.Box3().setFromObject(root); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z, 1e-6); + root.scale.multiplyScalar(targetSize / maxDim); + const box2 = new THREE.Box3().setFromObject(root); + root.position.sub(box2.getCenter(new THREE.Vector3())); + root.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + if (child.material) { + child.material = new THREE.MeshStandardMaterial({ + color: child.material.color || 0xcccccc, + metalness: 0.15, + roughness: 0.75, + flatShading: true, + }); + } + } + }); + return root; +} + +async function loadObjFile(file, sizeM = 1.5) { + if (!file) return null; + const text = await file.text(); + const target = Math.max(0.05, Number(sizeM) || 1.5); + return normalizeModel(new OBJLoader().parse(text), target); +} + +function sampleHeight(meshInfo, x, y) { + if (!meshInfo) return -5; + const { resX, resY, heights, sizeX, sizeY } = meshInfo; + const halfX = sizeX * 0.5; + const halfY = sizeY * 0.5; + // Clamp to edges — terrain continues "infinitely" outside the core patch + const u = Math.min(1, Math.max(0, (x + halfX) / sizeX)); + const v = Math.min(1, Math.max(0, (y + halfY) / sizeY)); + const fx = u * (resX - 1); + const fy = v * (resY - 1); + const i0 = Math.floor(fx); + const j0 = Math.floor(fy); + const i1 = Math.min(resX - 1, i0 + 1); + const j1 = Math.min(resY - 1, j0 + 1); + const tx = fx - i0; + const ty = fy - j0; + const h00 = heights[j0][i0]; + const h10 = heights[j0][i1]; + const h01 = heights[j1][i0]; + const h11 = heights[j1][i1]; + return (h00 * (1 - tx) + h10 * tx) * (1 - ty) + (h01 * (1 - tx) + h11 * tx) * ty; +} + +/** + * @param {import('vue').Ref} containerRef main 3D view + * @param {import('vue').Ref} surveyRef bottom-right survey surface panel + */ +export function useMleSimulator(containerRef, surveyRef) { + const DETECTION_RANGE_DEFAULT = 400; + + // —— main scene —— + const SCENE_BG = 0x1a3d38; + const SEAFLOOR_COLOR = 0x3f7a62; + const RAY_COLOR = 0xffb020; + const scene = new THREE.Scene(); + scene.background = new THREE.Color(SCENE_BG); + scene.fog = new THREE.Fog(SCENE_BG, 120, 900); + const camera = new THREE.PerspectiveCamera(55, 1, 0.05, 2500); + camera.position.set(18, -28, 14); + camera.up.set(0, 0, 1); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + scene.add(new THREE.HemisphereLight(0xd7ecff, 0x2a4034, 1.15)); + const dir = new THREE.DirectionalLight(0xffffff, 1.2); + dir.position.set(14, -10, 28); + scene.add(dir); + scene.add(new THREE.AmbientLight(0x6a7a90, 0.55)); + + // —— survey panel scene (content of seafloor.obj) —— + const SURVEY_BG = 0x2a5a4a; + const surveyScene = new THREE.Scene(); + surveyScene.background = new THREE.Color(SURVEY_BG); + surveyScene.fog = new THREE.Fog(SURVEY_BG, 40, 220); + const surveyCamera = new THREE.PerspectiveCamera(45, 1, 0.05, 2500); + surveyCamera.up.set(0, 0, 1); + surveyCamera.position.set(0, -25, 18); + const surveyRenderer = new THREE.WebGLRenderer({ antialias: true }); + surveyRenderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + surveyScene.add(new THREE.HemisphereLight(0xd8efff, 0x243830, 1.15)); + const surveyDir = new THREE.DirectionalLight(0xffffff, 1.05); + surveyDir.position.set(8, -10, 24); + surveyScene.add(surveyDir); + let surveyControls = null; + let surveyMesh = null; + let surveyShadowMesh = null; + let surveyBackdrop = null; + let surveyCameraFitted = false; + let surveyUserOrbit = false; + const raycaster = new THREE.Raycaster(); + raycaster.far = DETECTION_RANGE_DEFAULT; + + let controls = null; + let animationId = 0; + let seafloorMesh = null; // unused for draw; height-field used for hits + let seafloorGrid = null; + let auvPivot = null; + let objectPivot = null; + let rayLines = null; + let trailLine = null; + let meshInfo = null; + let running = false; + let lastTs = 0; + let traveled = 0; + let surveyLength = 40; + let speed = 1.5; + let headingRad = 0; + let beamCount = 45; + let swathAngleDeg = 90; + let detectionRangeM = DETECTION_RANGE_DEFAULT; + let auvDepth = 2.5; + let lastObjectZ = 0; + let lastObjectRotXDeg = 0; + let lastObjectRotYDeg = 0; + let lastObjectRotZDeg = 0; + const trailPoints = []; + let onStatus = () => {}; + let onFinished = () => {}; + let runOutputDir = null; + let saveBusy = false; + let lastSaveTs = 0; + let lastStripDist = -1e9; + + // Progressive survey surface (matches saved OBJ) — first-hit echosounder returns + let prevHits = null; + let prevShadowHits = null; + const surveyVertices = []; // [x,y,z] + const surveyVertexIsObject = []; // parallel flags for coloring + const surveyFaces = []; // 1-based OBJ indices + const shadowVertices = []; + const shadowFaces = []; + let surveyDirty = false; + + function resize() { + const el = containerRef.value; + if (el) { + const w = el.clientWidth; + const h = el.clientHeight; + camera.aspect = w / Math.max(h, 1); + camera.updateProjectionMatrix(); + renderer.setSize(w, h, false); + renderer.domElement.style.width = "100%"; + renderer.domElement.style.height = "100%"; + renderer.domElement.style.display = "block"; + } + const sEl = surveyRef?.value; + if (sEl) { + const w = Math.max(1, sEl.clientWidth); + const h = Math.max(1, sEl.clientHeight); + surveyCamera.aspect = w / h; + surveyCamera.updateProjectionMatrix(); + surveyRenderer.setSize(w, h, false); + surveyRenderer.domElement.style.width = "100%"; + surveyRenderer.domElement.style.height = "100%"; + surveyRenderer.domElement.style.display = "block"; + if (surveyMesh || surveyBackdrop) fitSurveyCamera(false); + } + } + + function clearObject(obj, parent = scene) { + if (!obj) return; + parent.remove(obj); + obj.traverse?.((child) => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (Array.isArray(child.material)) child.material.forEach((m) => m.dispose()); + else child.material.dispose(); + } + }); + } + + function buildSeafloor(payload) { + clearObject(seafloorMesh); + clearObject(seafloorGrid); + seafloorMesh = null; + seafloorGrid = null; + meshInfo = null; + if (!payload?.mesh?.heights?.length && !payload?.mesh?.vertices?.length) return; + const { heights, resX, resY } = payload.mesh; + const params = payload.params || {}; + const sizeX = Number(params.sizeX) || 40; + const sizeY = Number(params.sizeY) || 60; + meshInfo = { + resX, + resY, + heights, + sizeX, + sizeY, + baseZ: params.baseZ, + }; + + // Lightweight terrain grid (LineSegments) — infinite skirt, low vertex count + const pad = Math.max(sizeX, sizeY, detectionRangeM || 400) * 2.5; + const extSizeX = sizeX + pad * 2; + const extSizeY = sizeY + pad * 2; + // Coarse grid: enough to show relief, cheap to draw + const nX = Math.min(64, Math.max(24, Math.round(extSizeX / Math.max(sizeX / 16, 4)) + 1)); + const nY = Math.min(64, Math.max(24, Math.round(extSizeY / Math.max(sizeY / 16, 4)) + 1)); + const halfX = extSizeX * 0.5; + const halfY = extSizeY * 0.5; + const pts = new Float32Array(nX * nY * 3); + for (let j = 0; j < nY; j += 1) { + const y = -halfY + (j / (nY - 1)) * extSizeY; + for (let i = 0; i < nX; i += 1) { + const x = -halfX + (i / (nX - 1)) * extSizeX; + const z = sampleHeight(meshInfo, x, y); + const o = (j * nX + i) * 3; + pts[o] = x; + pts[o + 1] = y; + pts[o + 2] = z; + } + } + // Horizontal + vertical polylines as segments + const segCount = nY * (nX - 1) + nX * (nY - 1); + const linePos = new Float32Array(segCount * 2 * 3); + let w = 0; + const writeSeg = (i0, j0, i1, j1) => { + const a = (j0 * nX + i0) * 3; + const b = (j1 * nX + i1) * 3; + linePos[w++] = pts[a]; + linePos[w++] = pts[a + 1]; + linePos[w++] = pts[a + 2]; + linePos[w++] = pts[b]; + linePos[w++] = pts[b + 1]; + linePos[w++] = pts[b + 2]; + }; + for (let j = 0; j < nY; j += 1) { + for (let i = 0; i < nX - 1; i += 1) writeSeg(i, j, i + 1, j); + } + for (let i = 0; i < nX; i += 1) { + for (let j = 0; j < nY - 1; j += 1) writeSeg(i, j, i, j + 1); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(linePos, 3)); + seafloorGrid = new THREE.LineSegments( + geometry, + new THREE.LineBasicMaterial({ + color: SEAFLOOR_COLOR, + transparent: true, + opacity: 0.9, + }), + ); + scene.add(seafloorGrid); + buildSurveyBackdrop(); + } + + function buildSurveyBackdrop() { + clearObject(surveyBackdrop, surveyScene); + surveyBackdrop = null; + if (!meshInfo) return; + // Core patch only — fills the survey panel without huge empty skirt margins + const sizeX = meshInfo.sizeX || 40; + const sizeY = meshInfo.sizeY || 60; + const nX = 28; + const nY = 28; + const halfX = sizeX * 0.5; + const halfY = sizeY * 0.5; + const pts = new Float32Array(nX * nY * 3); + for (let j = 0; j < nY; j += 1) { + const y = -halfY + (j / (nY - 1)) * sizeY; + for (let i = 0; i < nX; i += 1) { + const x = -halfX + (i / (nX - 1)) * sizeX; + const z = sampleHeight(meshInfo, x, y); + const o = (j * nX + i) * 3; + pts[o] = x; + pts[o + 1] = y; + pts[o + 2] = z; + } + } + const segCount = nY * (nX - 1) + nX * (nY - 1); + const linePos = new Float32Array(segCount * 2 * 3); + let w = 0; + const writeSeg = (i0, j0, i1, j1) => { + const a = (j0 * nX + i0) * 3; + const b = (j1 * nX + i1) * 3; + linePos[w++] = pts[a]; + linePos[w++] = pts[a + 1]; + linePos[w++] = pts[a + 2]; + linePos[w++] = pts[b]; + linePos[w++] = pts[b + 1]; + linePos[w++] = pts[b + 2]; + }; + for (let j = 0; j < nY; j += 1) { + for (let i = 0; i < nX - 1; i += 1) writeSeg(i, j, i + 1, j); + } + for (let i = 0; i < nX; i += 1) { + for (let j = 0; j < nY - 1; j += 1) writeSeg(i, j, i, j + 1); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(linePos, 3)); + surveyBackdrop = new THREE.LineSegments( + geometry, + new THREE.LineBasicMaterial({ + color: 0x3d7a62, + transparent: true, + opacity: 0.45, + }), + ); + surveyScene.add(surveyBackdrop); + if (!surveyCameraFitted) fitSurveyCamera(true); + } + + function resetSurveySurface() { + prevHits = null; + prevShadowHits = null; + surveyVertices.length = 0; + surveyVertexIsObject.length = 0; + surveyFaces.length = 0; + shadowVertices.length = 0; + shadowFaces.length = 0; + surveyDirty = false; + lastStripDist = -1e9; + surveyCameraFitted = false; + surveyUserOrbit = false; + clearObject(surveyMesh, surveyScene); + clearObject(surveyShadowMesh, surveyScene); + surveyMesh = null; + surveyShadowMesh = null; + } + + function fitSurveyCamera(force = false) { + if (!surveyControls) return; + if (surveyUserOrbit && !force) return; + const box = new THREE.Box3(); + // Prefer survey content; fall back to core terrain patch (not infinite skirt) + if (surveyMesh) box.expandByObject(surveyMesh); + if (surveyShadowMesh) box.expandByObject(surveyShadowMesh); + if (box.isEmpty() && meshInfo) { + const hx = (meshInfo.sizeX || 40) * 0.5; + const hy = (meshInfo.sizeY || 60) * 0.5; + const z0 = sampleHeight(meshInfo, 0, 0); + box.expandByPoint(new THREE.Vector3(-hx, -hy, z0 - 0.5)); + box.expandByPoint(new THREE.Vector3(hx, hy, z0 + 1.5)); + } else if (box.isEmpty() && surveyBackdrop) { + box.expandByObject(surveyBackdrop); + } + if (box.isEmpty()) return; + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + // Slight pad so geometry touches panel edges without clipping + const spanX = Math.max(size.x, 0.5); + const spanY = Math.max(size.y, 0.5); + const spanZ = Math.max(size.z, 0.5); + const span = Math.max(spanX, spanY); + const fov = degToRad(surveyCamera.fov); + const aspect = Math.max(surveyCamera.aspect || 1, 0.01); + const halfFov = Math.tan(fov * 0.5); + const distForY = (span * 0.5) / halfFov; + const distForX = (span * 0.5) / (halfFov * aspect); + // 0.92 → content reaches near the edges of the survey window + const dist = Math.max(distForX, distForY, spanZ * 1.2) * 0.92; + const dir = new THREE.Vector3(0.15, -0.95, 0.72).normalize(); + surveyCamera.near = Math.max(0.05, dist / 400); + surveyCamera.far = Math.max(500, dist * 10); + surveyCamera.updateProjectionMatrix(); + if (surveyScene.fog) { + surveyScene.fog.near = dist * 2.5; + surveyScene.fog.far = dist * 8; + } + surveyControls.target.copy(center); + surveyCamera.position.copy(center).addScaledVector(dir, dist); + surveyControls.update(); + surveyCameraFitted = true; + } + + function rebuildSurveyMesh() { + clearObject(surveyMesh, surveyScene); + surveyMesh = null; + if (surveyVertices.length < 3 || surveyFaces.length < 1) return; + const positions = new Float32Array(surveyVertices.length * 3); + const colors = new Float32Array(surveyVertices.length * 3); + const colFloor = new THREE.Color(0x4ade80); + const colObject = new THREE.Color(0xf59e0b); + for (let i = 0; i < surveyVertices.length; i += 1) { + positions[i * 3] = surveyVertices[i][0]; + positions[i * 3 + 1] = surveyVertices[i][1]; + positions[i * 3 + 2] = surveyVertices[i][2]; + const c = surveyVertexIsObject[i] ? colObject : colFloor; + colors[i * 3] = c.r; + colors[i * 3 + 1] = c.g; + colors[i * 3 + 2] = c.b; + } + const indices = []; + for (const f of surveyFaces) indices.push(f[0] - 1, f[1] - 1, f[2] - 1); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + surveyMesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + vertexColors: true, + metalness: 0.08, + roughness: 0.7, + flatShading: true, + side: THREE.DoubleSide, + }), + ); + surveyScene.add(surveyMesh); + if (!surveyCameraFitted) fitSurveyCamera(true); + } + + function rebuildShadowMesh() { + clearObject(surveyShadowMesh, surveyScene); + surveyShadowMesh = null; + if (shadowVertices.length < 3 || shadowFaces.length < 1) return; + const positions = new Float32Array(shadowVertices.length * 3); + for (let i = 0; i < shadowVertices.length; i += 1) { + positions[i * 3] = shadowVertices[i][0]; + positions[i * 3 + 1] = shadowVertices[i][1]; + // Slightly above seafloor so shadow is visible over backdrop + positions[i * 3 + 2] = shadowVertices[i][2] + 0.04; + } + const indices = []; + for (const f of shadowFaces) indices.push(f[0] - 1, f[1] - 1, f[2] - 1); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + surveyShadowMesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + color: 0x0b1220, + metalness: 0.05, + roughness: 0.95, + flatShading: true, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.82, + }), + ); + surveyScene.add(surveyShadowMesh); + } + + function appendSurveyStrip(hits) { + if (!hits?.length) return; + if (prevHits && prevHits.length === hits.length && prevHits.length >= 2) { + const base = surveyVertices.length; + for (const h of prevHits) { + surveyVertices.push([h.x, h.y, h.z]); + surveyVertexIsObject.push(!!h.isObject); + } + for (const h of hits) { + surveyVertices.push([h.x, h.y, h.z]); + surveyVertexIsObject.push(!!h.isObject); + } + const n = hits.length; + for (let i = 0; i < n - 1; i += 1) { + const a = base + i + 1; + const b = base + i + 2; + const c = base + n + i + 1; + const d = base + n + i + 2; + surveyFaces.push([a, c, b]); + surveyFaces.push([b, c, d]); + } + surveyDirty = true; + rebuildSurveyMesh(); + } + prevHits = hits.map((h) => ({ + x: h.x, + y: h.y, + z: h.z, + isObject: !!h.isObject, + })); + + // Acoustic shadow strip: seafloor behind object where beams were occluded + const shadowRow = hits.map((h) => (h.shadowFloor ? { x: h.shadowFloor.x, y: h.shadowFloor.y, z: h.shadowFloor.z } : null)); + if (prevShadowHits && prevShadowHits.length === shadowRow.length) { + for (let i = 0; i < shadowRow.length - 1; i += 1) { + const a = prevShadowHits[i]; + const b = prevShadowHits[i + 1]; + const c = shadowRow[i]; + const d = shadowRow[i + 1]; + if (!a || !b || !c || !d) continue; + const base = shadowVertices.length; + shadowVertices.push([a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z], [d.x, d.y, d.z]); + // 1-based indices + shadowFaces.push([base + 1, base + 3, base + 2]); + shadowFaces.push([base + 2, base + 3, base + 4]); + } + rebuildShadowMesh(); + if (!surveyCameraFitted) fitSurveyCamera(true); + } + prevShadowHits = shadowRow; + } + + async function persistSurveySurface(force = false) { + if (!runOutputDir || !surveyVertices.length || !surveyFaces.length) return; + const now = performance.now(); + if (!force && (saveBusy || now - lastSaveTs < 800)) return; + saveBusy = true; + lastSaveTs = now; + try { + const result = await api.mleSaveSurface({ + outputDir: runOutputDir, + vertices: surveyVertices.map((v) => [...v]), + faces: surveyFaces.map((f) => [...f]), + }); + surveyDirty = false; + return result; + } catch { + return null; + } finally { + saveBusy = false; + } + } + + function makeFallbackAuv(sizeM = 10) { + const g = new THREE.Group(); + const body = new THREE.Mesh( + new THREE.CylinderGeometry(0.22, 0.22, 1.6, 12), + new THREE.MeshStandardMaterial({ color: 0xffb020, metalness: 0.3, roughness: 0.45 }), + ); + body.rotation.z = Math.PI / 2; + g.add(body); + const nose = new THREE.Mesh( + new THREE.ConeGeometry(0.22, 0.45, 10), + new THREE.MeshStandardMaterial({ color: 0xff7a18 }), + ); + nose.rotation.z = -Math.PI / 2; + nose.position.x = 1.0; + g.add(nose); + return normalizeModel(g, Math.max(0.05, Number(sizeM) || 10)); + } + + function makeFallbackObject(sizeM = 2) { + const g = new THREE.Group(); + g.add( + new THREE.Mesh( + new THREE.BoxGeometry(1.2, 0.6, 0.4), + new THREE.MeshStandardMaterial({ color: 0xf59e0b, metalness: 0.2, roughness: 0.6 }), + ), + ); + return normalizeModel(g, Math.max(0.05, Number(sizeM) || 2)); + } + + async function setAuvModel(file, sizeM = 10, x = 0, y = 0, depth = 2.5, headingDeg = 0) { + clearObject(auvPivot); + auvPivot = new THREE.Group(); + const targetSize = Math.max(0.05, Number(sizeM) || 10); + let model = null; + try { + model = await loadObjFile(file, targetSize); + } catch { + model = null; + } + if (!model) model = makeFallbackAuv(targetSize); + auvPivot.add(model); + scene.add(auvPivot); + placeAuv(x, y, depth, headingDeg); + } + + async function setObjectModel(file, sizeM = 2, x = 0, y = 0, zOffset = 0, rotXDeg = 0, rotYDeg = 0, rotZDeg = 0) { + clearObject(objectPivot); + objectPivot = new THREE.Group(); + const targetSize = Math.max(0.05, Number(sizeM) || 2); + let model = null; + try { + model = await loadObjFile(file, targetSize); + } catch { + model = null; + } + if (!model) model = makeFallbackObject(targetSize); + objectPivot.add(model); + scene.add(objectPivot); + placeObject(x, y, zOffset, rotXDeg, rotYDeg, rotZDeg); + } + + function placeAuv(x, y, depth, headingDeg) { + if (!auvPivot) return; + const floorZ = sampleHeight(meshInfo, x, y); + auvPivot.position.set(x, y, floorZ + Math.max(0.3, Number(depth) || 2.5)); + headingRad = degToRad(headingDeg); + auvPivot.rotation.set(0, 0, headingRad); + auvDepth = Math.max(0.3, Number(depth) || 2.5); + } + + function placeObject(x, y, zOffset, rotXDeg = 0, rotYDeg = 0, rotZDeg = 0) { + if (!objectPivot) return; + const floorZ = sampleHeight(meshInfo, x, y); + lastObjectZ = Number(zOffset) || 0; + lastObjectRotXDeg = Number(rotXDeg) || 0; + lastObjectRotYDeg = Number(rotYDeg) || 0; + lastObjectRotZDeg = Number(rotZDeg) || 0; + objectPivot.position.set(x, y, floorZ + lastObjectZ + 0.15); + objectPivot.rotation.set( + degToRad(lastObjectRotXDeg), + degToRad(lastObjectRotYDeg), + degToRad(lastObjectRotZDeg), + ); + } + + function castBeamHits() { + if (!auvPivot || !meshInfo) return []; + const origin = auvPivot.position.clone(); + const count = Math.max(1, Math.min(256, Number(beamCount) || 45)); + const swath = degToRad(Math.max(5, Math.min(170, Number(swathAngleDeg) || 90))); + const maxRange = Math.max(1, Number(detectionRangeM) || DETECTION_RANGE_DEFAULT); + raycaster.far = maxRange; + const across = new THREE.Vector3(-Math.sin(headingRad), Math.cos(headingRad), 0); + if (objectPivot) objectPivot.updateMatrixWorld(true); + + const hits = []; + for (let i = 0; i < count; i += 1) { + const t = count === 1 ? 0.5 : i / (count - 1); + const angle = -swath * 0.5 + swath * t; + const beamDir = new THREE.Vector3( + Math.sin(angle) * across.x, + Math.sin(angle) * across.y, + -Math.cos(angle), + ).normalize(); + + // Seafloor first-hit via height-field probe (grid is visual-only) + let floorPoint = null; + let floorDist = Infinity; + const step = Math.max(0.25, Math.min(1.5, maxRange / 250)); + const maxSteps = Math.ceil(maxRange / step) + 2; + const probe = origin.clone().addScaledVector(beamDir, step * 0.5); + for (let s = 0; s < maxSteps; s += 1) { + probe.addScaledVector(beamDir, step); + if (origin.distanceTo(probe) > maxRange) break; + const floorZ = sampleHeight(meshInfo, probe.x, probe.y); + if (probe.z <= floorZ) { + floorPoint = new THREE.Vector3(probe.x, probe.y, floorZ); + floorDist = origin.distanceTo(floorPoint); + break; + } + } + + // Object first-hit (mesh raycast) — echosounder return if closer than seafloor + let objPoint = null; + let objDist = Infinity; + if (objectPivot) { + raycaster.set(origin, beamDir); + const intersects = raycaster.intersectObject(objectPivot, true); + if (intersects.length && intersects[0].distance <= maxRange) { + objPoint = intersects[0].point.clone(); + objDist = intersects[0].distance; + } + } + + let hit; + let isObject = false; + let shadowFloor = null; + if (objPoint && objDist < floorDist) { + hit = objPoint; + isObject = true; + // Acoustic shadow: seafloor beyond the object along the same beam + if (floorPoint) shadowFloor = floorPoint; + } else if (floorPoint && floorDist <= maxRange) { + hit = floorPoint; + } else if (objPoint) { + hit = objPoint; + isObject = true; + } else { + hit = origin.clone().addScaledVector(beamDir, maxRange); + } + hit.isObject = isObject; + hit.shadowFloor = shadowFloor; + hits.push(hit); + } + return hits; + } + + function updateRays(accumulateSurvey = false) { + if (rayLines) { + scene.remove(rayLines); + rayLines.geometry?.dispose(); + rayLines.material?.dispose(); + rayLines = null; + } + if (!auvPivot || !meshInfo) return; + const origin = auvPivot.position.clone(); + const hits = castBeamHits(); + const positions = new Float32Array(hits.length * 2 * 3); + for (let i = 0; i < hits.length; i += 1) { + const o = i * 6; + positions[o] = origin.x; + positions[o + 1] = origin.y; + positions[o + 2] = origin.z; + positions[o + 3] = hits[i].x; + positions[o + 4] = hits[i].y; + positions[o + 5] = hits[i].z; + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + rayLines = new THREE.LineSegments( + geometry, + new THREE.LineBasicMaterial({ color: RAY_COLOR, transparent: true, opacity: 0.9 }), + ); + scene.add(rayLines); + if (accumulateSurvey) { + if (traveled - lastStripDist >= 0.35 || lastStripDist < 0) { + lastStripDist = traveled; + appendSurveyStrip(hits); + void persistSurveySurface(false); + } + } + } + + function updateTrail() { + if (!auvPivot) return; + trailPoints.push(auvPivot.position.clone()); + if (trailPoints.length > 500) trailPoints.shift(); + if (trailLine) { + scene.remove(trailLine); + trailLine.geometry?.dispose(); + trailLine.material?.dispose(); + } + if (trailPoints.length < 2) return; + const positions = new Float32Array(trailPoints.length * 3); + for (let i = 0; i < trailPoints.length; i += 1) { + positions[i * 3] = trailPoints[i].x; + positions[i * 3 + 1] = trailPoints[i].y; + positions[i * 3 + 2] = trailPoints[i].z; + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + trailLine = new THREE.Line( + geometry, + new THREE.LineBasicMaterial({ color: 0xffb020, transparent: true, opacity: 0.7 }), + ); + scene.add(trailLine); + } + + function tick(ts) { + animationId = requestAnimationFrame(tick); + if (running && auvPivot && meshInfo) { + if (!lastTs) lastTs = ts; + const dt = Math.min(0.05, (ts - lastTs) / 1000); + lastTs = ts; + const step = speed * dt; + traveled += step; + const nx = auvPivot.position.x + Math.cos(headingRad) * step; + const ny = auvPivot.position.y + Math.sin(headingRad) * step; + const floorZ = sampleHeight(meshInfo, nx, ny); + auvPivot.position.set(nx, ny, floorZ + auvDepth); + updateRays(true); + updateTrail(); + onStatus({ + traveled, + x: nx, + y: ny, + z: auvPivot.position.z, + surveyFaces: surveyFaces.length, + surveyVertices: surveyVertices.length, + }); + if (traveled >= surveyLength) { + running = false; + void persistSurveySurface(true).then((saved) => { + onFinished({ traveled, saved }); + }); + } + } + controls?.update(); + surveyControls?.update(); + renderer.render(scene, camera); + if (surveyRef?.value) surveyRenderer.render(surveyScene, surveyCamera); + } + + function fitCamera() { + if (!controls || !meshInfo) return; + // Frame AUV + object + core seafloor (not the huge infinite skirt) + const box = new THREE.Box3(); + const halfX = meshInfo.sizeX * 0.5; + const halfY = meshInfo.sizeY * 0.5; + const z0 = meshInfo.baseZ ?? sampleHeight(meshInfo, 0, 0); + box.expandByPoint(new THREE.Vector3(-halfX, -halfY, z0)); + box.expandByPoint(new THREE.Vector3(halfX, halfY, z0 + Math.max(auvDepth, 2) + 2)); + if (auvPivot) { + auvPivot.updateMatrixWorld(true); + box.expandByObject(auvPivot); + } + if (objectPivot) { + objectPivot.updateMatrixWorld(true); + box.expandByObject(objectPivot); + } + + const pad = Math.max(2, Math.max(meshInfo.sizeX, meshInfo.sizeY) * 0.08, Number(auvDepth) * 0.15 || 0); + box.expandByScalar(pad); + const paddedSize = box.getSize(new THREE.Vector3()); + const paddedCenter = box.getCenter(new THREE.Vector3()); + const maxDim = Math.max(paddedSize.x, paddedSize.y, paddedSize.z, 1); + const fov = degToRad(camera.fov); + const dist = (maxDim * 0.65) / Math.tan(fov * 0.5); + + const offset = new THREE.Vector3(0.55, -0.9, 0.55).normalize().multiplyScalar(Math.max(dist, maxDim * 0.85)); + camera.near = Math.max(0.05, maxDim / 800); + camera.far = Math.max(2500, maxDim * 25, detectionRangeM * 2, pad * 20); + camera.updateProjectionMatrix(); + if (scene.fog) { + scene.fog.near = Math.max(40, maxDim * 1.2); + scene.fog.far = Math.max(scene.fog.near + 80, camera.far * 0.55); + } + camera.position.copy(paddedCenter).add(offset); + controls.target.copy(paddedCenter); + controls.update(); + } + + async function applyScene({ seafloorPayload, auvFile, objectFile, params }) { + runOutputDir = seafloorPayload?.outputDir || null; + buildSeafloor(seafloorPayload); + resetSurveySurface(); + beamCount = params.beamCount ?? 45; + swathAngleDeg = params.swathAngleDeg ?? 90; + detectionRangeM = Math.max(1, Number(params.detectionRangeM) || DETECTION_RANGE_DEFAULT); + raycaster.far = detectionRangeM; + speed = Math.max(0.05, Number(params.speed) || 1.5); + surveyLength = Math.max(1, Number(params.surveyLength) || 40); + auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5); + await setAuvModel( + auvFile, + params.auvSizeM, + params.auvX, + params.auvY, + params.auvDepth, + params.auvHeadingDeg, + ); + await setObjectModel( + objectFile, + params.objectSizeM, + params.objectX, + params.objectY, + params.objectZ, + params.objectRotXDeg, + params.objectRotYDeg, + params.objectRotZDeg ?? params.objectYawDeg, + ); + trailPoints.length = 0; + traveled = 0; + updateRays(false); + fitCamera(); + } + + function start() { + if (!auvPivot || !meshInfo) return false; + resetSurveySurface(); + running = true; + lastTs = 0; + traveled = 0; + trailPoints.length = 0; + updateRays(true); + return true; + } + + function stop() { + running = false; + void persistSurveySurface(true); + } + + function isRunning() { + return running; + } + + function setCallbacks({ status, finished } = {}) { + if (typeof status === "function") onStatus = status; + if (typeof finished === "function") onFinished = finished; + } + + function updateParams(params = {}) { + if (params.beamCount != null) beamCount = params.beamCount; + if (params.swathAngleDeg != null) swathAngleDeg = params.swathAngleDeg; + if (params.detectionRangeM != null) { + detectionRangeM = Math.max(1, Number(params.detectionRangeM) || DETECTION_RANGE_DEFAULT); + raycaster.far = detectionRangeM; + } + if (params.speed != null) speed = Math.max(0.05, Number(params.speed) || 1.5); + if (params.surveyLength != null) surveyLength = Math.max(1, Number(params.surveyLength) || 40); + if (params.auvDepth != null) auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5); + + let moved = false; + if (!running && auvPivot && (params.auvX != null || params.auvY != null || params.auvDepth != null || params.auvHeadingDeg != null)) { + placeAuv( + params.auvX ?? auvPivot.position.x, + params.auvY ?? auvPivot.position.y, + params.auvDepth ?? auvDepth, + params.auvHeadingDeg ?? (headingRad * 180) / Math.PI, + ); + updateRays(false); + moved = true; + } + if ( + objectPivot && + (params.objectX != null || + params.objectY != null || + params.objectZ != null || + params.objectRotXDeg != null || + params.objectRotYDeg != null || + params.objectRotZDeg != null || + params.objectYawDeg != null) + ) { + placeObject( + params.objectX ?? objectPivot.position.x, + params.objectY ?? objectPivot.position.y, + params.objectZ != null ? params.objectZ : lastObjectZ, + params.objectRotXDeg != null ? params.objectRotXDeg : lastObjectRotXDeg, + params.objectRotYDeg != null ? params.objectRotYDeg : lastObjectRotYDeg, + params.objectRotZDeg != null + ? params.objectRotZDeg + : params.objectYawDeg != null + ? params.objectYawDeg + : lastObjectRotZDeg, + ); + if (!running) updateRays(false); + moved = true; + } + if (moved || params.fitCamera) fitCamera(); + } + + onMounted(() => { + const el = containerRef.value; + if (el) { + el.appendChild(renderer.domElement); + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.target.set(0, 0, -3); + controls.update(); + } + const sEl = surveyRef?.value; + if (sEl) { + sEl.appendChild(surveyRenderer.domElement); + surveyControls = new OrbitControls(surveyCamera, surveyRenderer.domElement); + surveyControls.enableDamping = true; + surveyControls.enableZoom = true; + surveyControls.enablePan = true; + surveyControls.enableRotate = true; + surveyControls.target.set(0, 0, -4); + surveyControls.addEventListener("start", () => { + surveyUserOrbit = true; + }); + // Keep wheel zoom on the survey panel even while the main scene is busy + surveyRenderer.domElement.addEventListener( + "wheel", + (e) => { + e.stopPropagation(); + surveyUserOrbit = true; + }, + { passive: true }, + ); + surveyControls.update(); + } + resize(); + window.addEventListener("resize", resize); + animationId = requestAnimationFrame(tick); + }); + + onBeforeUnmount(() => { + running = false; + cancelAnimationFrame(animationId); + window.removeEventListener("resize", resize); + controls?.dispose(); + surveyControls?.dispose(); + clearObject(seafloorMesh); + clearObject(seafloorGrid); + clearObject(auvPivot); + clearObject(objectPivot); + clearObject(rayLines); + clearObject(trailLine); + clearObject(surveyMesh, surveyScene); + clearObject(surveyShadowMesh, surveyScene); + clearObject(surveyBackdrop, surveyScene); + renderer.dispose(); + surveyRenderer.dispose(); + renderer.domElement.remove(); + surveyRenderer.domElement.remove(); + }); + + return { + applyScene, + start, + stop, + isRunning, + setCallbacks, + updateParams, + resize, + fitCamera, + persistSurveySurface, + captureSnapshot(target = "scene") { + const isSurvey = target === "survey" || target === "relef" || target === "рельеф"; + const r = isSurvey ? surveyRenderer : renderer; + const cam = isSurvey ? surveyCamera : camera; + const sc = isSurvey ? surveyScene : scene; + if (!r?.domElement) return null; + r.render(sc, cam); + const windowKey = isSurvey ? "mle-relef" : "mle-scena"; + const windowLabel = isSurvey ? "Рельеф" : "Сцена"; + const filename = buildSnapshotFilename(windowKey); + downloadPngDataUrl(r.domElement.toDataURL("image/png"), filename); + return { filename, windowLabel, target: isSurvey ? "survey" : "scene" }; + }, + }; +} diff --git a/frontend/web/src/router/index.js b/frontend/web/src/router/index.js index bacc1ce..c7a99fe 100644 --- a/frontend/web/src/router/index.js +++ b/frontend/web/src/router/index.js @@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from "vue-router"; import PipelineView from "@/views/PipelineView.vue"; import GeneratorView from "@/views/GeneratorView.vue"; import DatasetView from "@/views/DatasetView.vue"; +import MleView from "@/views/MleView.vue"; +import GboView from "@/views/GboView.vue"; export default createRouter({ history: createWebHistory(), @@ -9,5 +11,7 @@ export default createRouter({ { path: "/", name: "pipeline", component: PipelineView }, { path: "/generator", name: "generator", component: GeneratorView }, { path: "/dataset", name: "dataset", component: DatasetView }, + { path: "/mle", name: "mle", component: MleView }, + { path: "/gbo", name: "gbo", component: GboView }, ], }); diff --git a/frontend/web/src/stores/dataset.js b/frontend/web/src/stores/dataset.js index 7ba9cc4..083eae7 100644 --- a/frontend/web/src/stores/dataset.js +++ b/frontend/web/src/stores/dataset.js @@ -17,6 +17,9 @@ export const useDatasetStore = defineStore("dataset", { beamCount: 45, lengthCount: 45, generateProgress: 0, + browseBusy: false, + availableRuns: [], + selectedRunPath: "", lastResult: null, selectedStem: null, previewPoints: [], @@ -43,7 +46,7 @@ export const useDatasetStore = defineStore("dataset", { return (state.lastResult?.written || []).find((item) => item.stem === stem) || null; }, canGenerate(state) { - return !!state.modelFile && !state.busy; + return !state.busy; }, classOptions(state) { const labels = state.classLabels || { 0: "background", 1: "object" }; @@ -75,6 +78,42 @@ export const useDatasetStore = defineStore("dataset", { } this.highlightClass = Number(value); }, + logSettingsFromRun(run) { + const settings = run?.settings || {}; + const objectName = settings.objectName ?? run?.objectName; + const count = settings.count ?? run?.count; + const seed = settings.seed ?? run?.seed; + const outputDir = settings.outputDir; + const objectScale = settings.objectScale ?? run?.objectScale; + const objectScaleIsMax = settings.objectScaleIsMax ?? run?.objectScaleIsMax; + const beamCount = settings.beamCount ?? run?.beamCount; + const lengthCount = settings.lengthCount ?? run?.lengthCount; + + const parts = []; + if (objectName) parts.push(`model=${objectName}`); + if (count != null) parts.push(`count=${count}`); + if (seed != null) parts.push(`seed=${seed}`); + if (beamCount != null) parts.push(`beams=${beamCount}`); + if (lengthCount != null) parts.push(`length=${lengthCount}`); + if (objectScale != null) { + parts.push( + objectScaleIsMax + ? `scale=1…${objectScale} (макс.)` + : `scale=${objectScale}`, + ); + } + if (outputDir) parts.push(`dir=${outputDir}`); + if (run?.objectVertexCount != null) { + parts.push(`vertices=${run.objectVertexCount}`); + } + if (run?.generatedAt) parts.push(`at=${run.generatedAt}`); + + if (parts.length) { + this.pushLog(`Параметры датасета: ${parts.join(", ")}`); + } else { + this.pushLog("Параметры датасета в dataset_run.json не найдены"); + } + }, applyPreviewPayload(result, writtenMeta = null) { this.previewStem = result.stem; this.previewPoints = result.points || []; @@ -174,6 +213,9 @@ export const useDatasetStore = defineStore("dataset", { this.pushLog( `Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`, ); + if (result.settingsPath) { + this.pushLog(`Настройки: ${result.settingsPath}`); + } this.pushLog( `Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`, ); @@ -207,6 +249,19 @@ export const useDatasetStore = defineStore("dataset", { this.previewMeta = null; this.classCounts = {}; } + try { + await this.refreshAvailableRuns(); + const match = this.availableRuns.find( + (run) => + run.outputDir === this.resolvedOutputDir || + run.runName === result.runName, + ); + if (match) { + this.selectedRunPath = match.loadPath || match.outputDir; + } + } catch { + /* non-fatal */ + } } catch (error) { this.statusText = `Ошибка: ${error.message}`; this.pushLog(`Ошибка: ${error.message}`); @@ -242,5 +297,73 @@ export const useDatasetStore = defineStore("dataset", { this.previewBusy = false; } }, + async refreshAvailableRuns() { + try { + const payload = await api.datasetRuns(this.outputDir || "sonar_dataset"); + this.availableRuns = payload.runs || []; + return this.availableRuns; + } catch (error) { + this.pushLog(`Ошибка списка датасетов: ${error.message}`); + throw error; + } + }, + async loadDatasetRun(loadPath) { + if (!loadPath || this.browseBusy) return; + this.browseBusy = true; + this.selectedRunPath = loadPath; + this.statusText = `Загрузка датасета: ${loadPath}…`; + this.pushLog(`Просмотр датасета: ${loadPath}`); + try { + const run = await api.datasetLoad({ outputDir: loadPath }); + this.resolvedOutputDir = run.outputDir || loadPath; + this.lastResult = { + outputDir: run.outputDir, + runName: run.runName, + count: run.count, + seed: run.seed, + beamCount: run.beamCount, + lengthCount: run.lengthCount, + objectScale: run.objectScale, + objectScaleIsMax: run.objectScaleIsMax, + objectName: run.objectName, + stats: run.stats, + written: run.written || [], + classLabels: run.classLabels, + }; + if (run.hasSettings) { + this.logSettingsFromRun(run); + } else { + this.pushLog("dataset_run.json не найден — параметры генерации недоступны"); + } + + const s = run.stats || {}; + this.statusText = `Датасет: ${run.runName || loadPath} (${(run.written || []).length} сцен)`; + this.pushLog( + `Сцен: ${(run.written || []).length}, с объектом: ${s.withObject ?? "?"}, без: ${s.withoutObject ?? "?"}`, + ); + + const initialStem = + run.written?.find((item) => item.hasObject)?.stem || + run.written?.[0]?.stem || + null; + + if (initialStem) { + await this.selectScene(initialStem); + } else { + this.selectedStem = null; + this.previewStem = null; + this.previewPoints = []; + this.previewMeta = null; + this.classCounts = {}; + } + return run; + } catch (error) { + this.statusText = `Ошибка загрузки датасета: ${error.message}`; + this.pushLog(`Ошибка загрузки датасета: ${error.message}`); + throw error; + } finally { + this.browseBusy = false; + } + }, }, }); diff --git a/frontend/web/src/stores/gbo.js b/frontend/web/src/stores/gbo.js new file mode 100644 index 0000000..103767d --- /dev/null +++ b/frontend/web/src/stores/gbo.js @@ -0,0 +1,333 @@ +import { defineStore } from "pinia"; +import { api } from "@/api/client"; + +const STORAGE_KEY = "dottosurface.gbo.settings.v1"; + +const DEFAULTS = { + auvX: 0, + auvY: -20, + auvDepth: 2.5, + auvHeadingDeg: 0, + auvSizeM: 10, + objectX: 12, + objectY: -20, + objectZ: 0, + objectSizeM: 2, + objectRotXDeg: 0, + objectRotYDeg: 0, + objectRotZDeg: 0, + beamCount: 4, + swathAngleDeg: 150, + detectionRangeM: 400, + speed: 1.5, + surveyLength: 40, + seed: 42, + sizeX: 40, + sizeY: 60, + outputDir: "gbo_runs", + auvFileName: "", + objectFileName: "", +}; + +function coerceSettings(data) { + if (!data || typeof data !== "object") return {}; + const out = {}; + // Migrate legacy single-axis yaw + if (data.objectRotZDeg == null && data.objectYawDeg != null) { + data = { ...data, objectRotZDeg: data.objectYawDeg }; + } + if (data.object?.yawDeg != null && data.objectRotZDeg == null) { + data = { ...data, objectRotZDeg: data.object.yawDeg }; + } + // Flatten nested snapshot shape if loaded from server run settings + if (data.auv && typeof data.auv === "object") { + data = { + ...data, + auvX: data.auv.x ?? data.auvX, + auvY: data.auv.y ?? data.auvY, + auvDepth: data.auv.depth ?? data.auvDepth, + auvHeadingDeg: data.auv.headingDeg ?? data.auvHeadingDeg, + auvSizeM: data.auv.sizeM ?? data.auvSizeM, + }; + } + if (data.object && typeof data.object === "object") { + data = { + ...data, + objectX: data.object.x ?? data.objectX, + objectY: data.object.y ?? data.objectY, + objectZ: data.object.z ?? data.objectZ, + objectSizeM: data.object.sizeM ?? data.objectSizeM, + objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg, + objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg, + objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg, + }; + } + if (data.beams && typeof data.beams === "object") { + data = { + ...data, + beamCount: data.beams.count ?? data.beamCount, + swathAngleDeg: data.beams.swathAngleDeg ?? data.swathAngleDeg, + detectionRangeM: data.beams.detectionRangeM ?? data.detectionRangeM, + }; + } + if (data.motion && typeof data.motion === "object") { + data = { + ...data, + speed: data.motion.speed ?? data.speed, + surveyLength: data.motion.surveyLength ?? data.surveyLength, + }; + } + if (data.seafloor && typeof data.seafloor === "object") { + data = { + ...data, + seed: data.seafloor.seed ?? data.seed, + sizeX: data.seafloor.sizeX ?? data.sizeX, + sizeY: data.seafloor.sizeY ?? data.sizeY, + }; + } + for (const key of Object.keys(DEFAULTS)) { + if (data[key] == null) continue; + if (typeof DEFAULTS[key] === "number") { + const n = Number(data[key]); + if (Number.isFinite(n)) out[key] = n; + } else { + out[key] = String(data[key]); + } + } + return out; +} + +function loadPersisted() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + return coerceSettings(JSON.parse(raw)); + } catch { + return {}; + } +} + +export const useGboStore = defineStore("gbo", { + state: () => { + const saved = loadPersisted(); + return { + busy: false, + running: false, + statusText: "Загрузите модели АНПА и объекта, задайте параметры и нажмите «Подготовить».", + logLines: [], + + auvFile: null, + auvFileName: saved.auvFileName || "", + objectFile: null, + objectFileName: saved.objectFileName || "", + + auvX: saved.auvX ?? DEFAULTS.auvX, + auvY: saved.auvY ?? DEFAULTS.auvY, + auvDepth: saved.auvDepth ?? DEFAULTS.auvDepth, + auvHeadingDeg: saved.auvHeadingDeg ?? DEFAULTS.auvHeadingDeg, + auvSizeM: saved.auvSizeM ?? DEFAULTS.auvSizeM, + + objectX: saved.objectX ?? DEFAULTS.objectX, + objectY: saved.objectY ?? DEFAULTS.objectY, + objectZ: saved.objectZ ?? DEFAULTS.objectZ, + objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM, + objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg, + objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg, + objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg, + + beamCount: saved.beamCount ?? DEFAULTS.beamCount, + swathAngleDeg: saved.swathAngleDeg ?? DEFAULTS.swathAngleDeg, + detectionRangeM: saved.detectionRangeM ?? DEFAULTS.detectionRangeM, + speed: saved.speed ?? DEFAULTS.speed, + surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength, + + seed: saved.seed ?? DEFAULTS.seed, + sizeX: saved.sizeX ?? DEFAULTS.sizeX, + sizeY: saved.sizeY ?? DEFAULTS.sizeY, + outputDir: saved.outputDir ?? DEFAULTS.outputDir, + + scene: null, + runName: null, + seafloorObjPath: null, + _persistTimer: null, + }; + }, + getters: { + canPrepare(state) { + return !state.busy && !state.running; + }, + canStart(state) { + return !!state.scene && !state.busy; + }, + }, + actions: { + applySettingsPatch(patch) { + const data = coerceSettings(patch); + for (const [key, value] of Object.entries(data)) { + if (key in DEFAULTS) this[key] = value; + } + }, + settingsFlat() { + const payload = {}; + for (const key of Object.keys(DEFAULTS)) { + payload[key] = this[key]; + } + return payload; + }, + persistSettings({ syncServer = true } = {}) { + const payload = this.settingsFlat(); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch { + /* ignore */ + } + if (!syncServer) return; + if (this._persistTimer) clearTimeout(this._persistTimer); + this._persistTimer = setTimeout(() => { + this._persistTimer = null; + void api + .gboSaveSettings({ + outputDir: String(this.outputDir || "gbo_runs"), + settings: payload, + }) + .catch(() => {}); + }, 400); + }, + async hydrateFromServer() { + try { + const result = await api.gboLoadSettings({ + outputDir: String(this.outputDir || "gbo_runs"), + }); + if (result?.settings) { + const localRaw = localStorage.getItem(STORAGE_KEY); + // Prefer server if local empty; otherwise keep local (fresher for same browser) + if (!localRaw) { + this.applySettingsPatch(result.settings); + this.persistSettings({ syncServer: false }); + this.pushLog("Пресет настроек загружен с сервера."); + } + } + } catch { + /* offline / first run */ + } + }, + pushLog(line) { + this.logLines.push(String(line)); + if (this.logLines.length > 300) { + this.logLines = this.logLines.slice(-300); + } + }, + setAuvFile(file) { + if (!file) { + this.auvFile = null; + this.auvFileName = ""; + this.persistSettings(); + return; + } + const name = String(file.name || ""); + if (!name.toLowerCase().endsWith(".obj")) { + this.statusText = "Модель АНПА: нужен файл .obj"; + return; + } + this.auvFile = file; + this.auvFileName = name; + this.pushLog(`АНПА: ${name}`); + this.persistSettings(); + }, + setObjectFile(file) { + if (!file) { + this.objectFile = null; + this.objectFileName = ""; + this.persistSettings(); + return; + } + const name = String(file.name || ""); + if (!name.toLowerCase().endsWith(".obj")) { + this.statusText = "Объект: нужен файл .obj"; + return; + } + this.objectFile = file; + this.objectFileName = name; + this.pushLog(`Объект: ${name}`); + this.persistSettings(); + }, + placeObjectAlongTrack() { + const heading = ((Number(this.auvHeadingDeg) || 0) * Math.PI) / 180; + const ahead = Math.min(18, Math.max(6, Number(this.surveyLength) * 0.4 || 12)); + this.objectX = Number(this.auvX) + Math.cos(heading) * ahead; + this.objectY = Number(this.auvY) + Math.sin(heading) * ahead; + this.objectZ = 0; + this.objectRotZDeg = Number(this.auvHeadingDeg) || 0; + }, + settingsSnapshot() { + return { + ...this.settingsFlat(), + auv: { + x: this.auvX, + y: this.auvY, + depth: this.auvDepth, + headingDeg: this.auvHeadingDeg, + sizeM: this.auvSizeM, + }, + object: { + x: this.objectX, + y: this.objectY, + z: this.objectZ, + sizeM: this.objectSizeM, + rotXDeg: this.objectRotXDeg, + rotYDeg: this.objectRotYDeg, + rotZDeg: this.objectRotZDeg, + }, + beams: { + count: this.beamCount, + swathAngleDeg: this.swathAngleDeg, + detectionRangeM: this.detectionRangeM, + }, + motion: { + speed: this.speed, + surveyLength: this.surveyLength, + }, + seafloor: { + seed: this.seed, + sizeX: this.sizeX, + sizeY: this.sizeY, + }, + }; + }, + async prepare() { + if (this.busy || this.running) return; + this.placeObjectAlongTrack(); + this.persistSettings(); + this.busy = true; + this.statusText = "Генерация рельефа дна…"; + this.pushLog( + `Подготовка: seed=${this.seed}, size=${this.sizeX}×${this.sizeY}, beams=${this.beamCount}`, + ); + try { + const result = await api.gboPrepare({ + seed: Number(this.seed) || 42, + sizeX: Number(this.sizeX) || 40, + sizeY: Number(this.sizeY) || 60, + resX: 80, + resY: 120, + outputDir: String(this.outputDir || "gbo_runs"), + settings: this.settingsSnapshot(), + }); + this.scene = result; + this.runName = result.runName; + this.seafloorObjPath = result.seafloorObj; + this.statusText = `Рельеф сохранён: ${result.seafloorObj}`; + this.pushLog( + `Рельеф дна: ${result.mesh?.vertexCount || "?"} вершин → ${result.seafloorObj}`, + ); + return result; + } catch (error) { + this.statusText = `Ошибка: ${error.message}`; + this.pushLog(`Ошибка: ${error.message}`); + throw error; + } finally { + this.busy = false; + } + }, + }, +}); diff --git a/frontend/web/src/stores/mle.js b/frontend/web/src/stores/mle.js new file mode 100644 index 0000000..964c23a --- /dev/null +++ b/frontend/web/src/stores/mle.js @@ -0,0 +1,333 @@ +import { defineStore } from "pinia"; +import { api } from "@/api/client"; + +const STORAGE_KEY = "dottosurface.mle.settings.v1"; + +const DEFAULTS = { + auvX: 0, + auvY: -20, + auvDepth: 2.5, + auvHeadingDeg: 0, + auvSizeM: 10, + objectX: 12, + objectY: -20, + objectZ: 0, + objectSizeM: 2, + objectRotXDeg: 0, + objectRotYDeg: 0, + objectRotZDeg: 0, + beamCount: 45, + swathAngleDeg: 90, + detectionRangeM: 400, + speed: 1.5, + surveyLength: 40, + seed: 42, + sizeX: 40, + sizeY: 60, + outputDir: "mle_runs", + auvFileName: "", + objectFileName: "", +}; + +function coerceSettings(data) { + if (!data || typeof data !== "object") return {}; + const out = {}; + // Migrate legacy single-axis yaw + if (data.objectRotZDeg == null && data.objectYawDeg != null) { + data = { ...data, objectRotZDeg: data.objectYawDeg }; + } + if (data.object?.yawDeg != null && data.objectRotZDeg == null) { + data = { ...data, objectRotZDeg: data.object.yawDeg }; + } + // Flatten nested snapshot shape if loaded from server run settings + if (data.auv && typeof data.auv === "object") { + data = { + ...data, + auvX: data.auv.x ?? data.auvX, + auvY: data.auv.y ?? data.auvY, + auvDepth: data.auv.depth ?? data.auvDepth, + auvHeadingDeg: data.auv.headingDeg ?? data.auvHeadingDeg, + auvSizeM: data.auv.sizeM ?? data.auvSizeM, + }; + } + if (data.object && typeof data.object === "object") { + data = { + ...data, + objectX: data.object.x ?? data.objectX, + objectY: data.object.y ?? data.objectY, + objectZ: data.object.z ?? data.objectZ, + objectSizeM: data.object.sizeM ?? data.objectSizeM, + objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg, + objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg, + objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg, + }; + } + if (data.beams && typeof data.beams === "object") { + data = { + ...data, + beamCount: data.beams.count ?? data.beamCount, + swathAngleDeg: data.beams.swathAngleDeg ?? data.swathAngleDeg, + detectionRangeM: data.beams.detectionRangeM ?? data.detectionRangeM, + }; + } + if (data.motion && typeof data.motion === "object") { + data = { + ...data, + speed: data.motion.speed ?? data.speed, + surveyLength: data.motion.surveyLength ?? data.surveyLength, + }; + } + if (data.seafloor && typeof data.seafloor === "object") { + data = { + ...data, + seed: data.seafloor.seed ?? data.seed, + sizeX: data.seafloor.sizeX ?? data.sizeX, + sizeY: data.seafloor.sizeY ?? data.sizeY, + }; + } + for (const key of Object.keys(DEFAULTS)) { + if (data[key] == null) continue; + if (typeof DEFAULTS[key] === "number") { + const n = Number(data[key]); + if (Number.isFinite(n)) out[key] = n; + } else { + out[key] = String(data[key]); + } + } + return out; +} + +function loadPersisted() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + return coerceSettings(JSON.parse(raw)); + } catch { + return {}; + } +} + +export const useMleStore = defineStore("mle", { + state: () => { + const saved = loadPersisted(); + return { + busy: false, + running: false, + statusText: "Загрузите модели АНПА и объекта, задайте параметры и нажмите «Подготовить».", + logLines: [], + + auvFile: null, + auvFileName: saved.auvFileName || "", + objectFile: null, + objectFileName: saved.objectFileName || "", + + auvX: saved.auvX ?? DEFAULTS.auvX, + auvY: saved.auvY ?? DEFAULTS.auvY, + auvDepth: saved.auvDepth ?? DEFAULTS.auvDepth, + auvHeadingDeg: saved.auvHeadingDeg ?? DEFAULTS.auvHeadingDeg, + auvSizeM: saved.auvSizeM ?? DEFAULTS.auvSizeM, + + objectX: saved.objectX ?? DEFAULTS.objectX, + objectY: saved.objectY ?? DEFAULTS.objectY, + objectZ: saved.objectZ ?? DEFAULTS.objectZ, + objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM, + objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg, + objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg, + objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg, + + beamCount: saved.beamCount ?? DEFAULTS.beamCount, + swathAngleDeg: saved.swathAngleDeg ?? DEFAULTS.swathAngleDeg, + detectionRangeM: saved.detectionRangeM ?? DEFAULTS.detectionRangeM, + speed: saved.speed ?? DEFAULTS.speed, + surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength, + + seed: saved.seed ?? DEFAULTS.seed, + sizeX: saved.sizeX ?? DEFAULTS.sizeX, + sizeY: saved.sizeY ?? DEFAULTS.sizeY, + outputDir: saved.outputDir ?? DEFAULTS.outputDir, + + scene: null, + runName: null, + seafloorObjPath: null, + _persistTimer: null, + }; + }, + getters: { + canPrepare(state) { + return !state.busy && !state.running; + }, + canStart(state) { + return !!state.scene && !state.busy; + }, + }, + actions: { + applySettingsPatch(patch) { + const data = coerceSettings(patch); + for (const [key, value] of Object.entries(data)) { + if (key in DEFAULTS) this[key] = value; + } + }, + settingsFlat() { + const payload = {}; + for (const key of Object.keys(DEFAULTS)) { + payload[key] = this[key]; + } + return payload; + }, + persistSettings({ syncServer = true } = {}) { + const payload = this.settingsFlat(); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch { + /* ignore */ + } + if (!syncServer) return; + if (this._persistTimer) clearTimeout(this._persistTimer); + this._persistTimer = setTimeout(() => { + this._persistTimer = null; + void api + .mleSaveSettings({ + outputDir: String(this.outputDir || "mle_runs"), + settings: payload, + }) + .catch(() => {}); + }, 400); + }, + async hydrateFromServer() { + try { + const result = await api.mleLoadSettings({ + outputDir: String(this.outputDir || "mle_runs"), + }); + if (result?.settings) { + const localRaw = localStorage.getItem(STORAGE_KEY); + // Prefer server if local empty; otherwise keep local (fresher for same browser) + if (!localRaw) { + this.applySettingsPatch(result.settings); + this.persistSettings({ syncServer: false }); + this.pushLog("Пресет настроек загружен с сервера."); + } + } + } catch { + /* offline / first run */ + } + }, + pushLog(line) { + this.logLines.push(String(line)); + if (this.logLines.length > 300) { + this.logLines = this.logLines.slice(-300); + } + }, + setAuvFile(file) { + if (!file) { + this.auvFile = null; + this.auvFileName = ""; + this.persistSettings(); + return; + } + const name = String(file.name || ""); + if (!name.toLowerCase().endsWith(".obj")) { + this.statusText = "Модель АНПА: нужен файл .obj"; + return; + } + this.auvFile = file; + this.auvFileName = name; + this.pushLog(`АНПА: ${name}`); + this.persistSettings(); + }, + setObjectFile(file) { + if (!file) { + this.objectFile = null; + this.objectFileName = ""; + this.persistSettings(); + return; + } + const name = String(file.name || ""); + if (!name.toLowerCase().endsWith(".obj")) { + this.statusText = "Объект: нужен файл .obj"; + return; + } + this.objectFile = file; + this.objectFileName = name; + this.pushLog(`Объект: ${name}`); + this.persistSettings(); + }, + placeObjectAlongTrack() { + const heading = ((Number(this.auvHeadingDeg) || 0) * Math.PI) / 180; + const ahead = Math.min(18, Math.max(6, Number(this.surveyLength) * 0.4 || 12)); + this.objectX = Number(this.auvX) + Math.cos(heading) * ahead; + this.objectY = Number(this.auvY) + Math.sin(heading) * ahead; + this.objectZ = 0; + this.objectRotZDeg = Number(this.auvHeadingDeg) || 0; + }, + settingsSnapshot() { + return { + ...this.settingsFlat(), + auv: { + x: this.auvX, + y: this.auvY, + depth: this.auvDepth, + headingDeg: this.auvHeadingDeg, + sizeM: this.auvSizeM, + }, + object: { + x: this.objectX, + y: this.objectY, + z: this.objectZ, + sizeM: this.objectSizeM, + rotXDeg: this.objectRotXDeg, + rotYDeg: this.objectRotYDeg, + rotZDeg: this.objectRotZDeg, + }, + beams: { + count: this.beamCount, + swathAngleDeg: this.swathAngleDeg, + detectionRangeM: this.detectionRangeM, + }, + motion: { + speed: this.speed, + surveyLength: this.surveyLength, + }, + seafloor: { + seed: this.seed, + sizeX: this.sizeX, + sizeY: this.sizeY, + }, + }; + }, + async prepare() { + if (this.busy || this.running) return; + this.placeObjectAlongTrack(); + this.persistSettings(); + this.busy = true; + this.statusText = "Генерация рельефа дна…"; + this.pushLog( + `Подготовка: seed=${this.seed}, size=${this.sizeX}×${this.sizeY}, beams=${this.beamCount}`, + ); + try { + const result = await api.mlePrepare({ + seed: Number(this.seed) || 42, + sizeX: Number(this.sizeX) || 40, + sizeY: Number(this.sizeY) || 60, + resX: 80, + resY: 120, + outputDir: String(this.outputDir || "mle_runs"), + settings: this.settingsSnapshot(), + }); + this.scene = result; + this.runName = result.runName; + this.seafloorObjPath = result.seafloorObj; + this.statusText = `Рельеф сохранён: ${result.seafloorObj}`; + this.pushLog( + `Рельеф дна: ${result.mesh?.vertexCount || "?"} вершин → ${result.seafloorObj}`, + ); + return result; + } catch (error) { + this.statusText = `Ошибка: ${error.message}`; + this.pushLog(`Ошибка: ${error.message}`); + throw error; + } finally { + this.busy = false; + } + }, + }, +}); diff --git a/frontend/web/src/utils/snapshot.js b/frontend/web/src/utils/snapshot.js new file mode 100644 index 0000000..6d21fa1 --- /dev/null +++ b/frontend/web/src/utils/snapshot.js @@ -0,0 +1,29 @@ +/** Browser PNG snapshot helpers (Pipeline-style download). */ + +export function formatSnapshotStamp(date = new Date()) { + const p = (n) => String(n).padStart(2, "0"); + return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}_${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`; +} + +export function buildSnapshotFilename(windowName) { + const safe = + String(windowName || "view") + .trim() + .replace(/\s+/g, "-") + .replace(/[^\w.\-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .toLowerCase() || "view"; + return `dottosurface-${safe}-${formatSnapshotStamp()}.png`; +} + +export function downloadPngDataUrl(dataUrl, filename) { + const link = document.createElement("a"); + link.href = dataUrl; + link.download = filename; + link.rel = "noopener"; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + link.remove(); +} diff --git a/frontend/web/src/views/DatasetView.vue b/frontend/web/src/views/DatasetView.vue index 7549120..c99a5a6 100644 --- a/frontend/web/src/views/DatasetView.vue +++ b/frontend/web/src/views/DatasetView.vue @@ -57,10 +57,15 @@ watch( }, ); -onMounted(() => { +onMounted(async () => { if (store.previewPoints?.length) { refreshViewer({ fit: true }); } + try { + await store.refreshAvailableRuns(); + } catch { + /* logged in store */ + } }); async function onGenerate() { @@ -92,11 +97,80 @@ function onModelFileChange(event) { function onHighlightClassChange(event) { store.setHighlightClass(event.target?.value); } + +async function onRefreshRuns() { + try { + await store.refreshAvailableRuns(); + store.statusText = `Найдено запусков: ${store.availableRuns.length}`; + } catch { + /* status already set */ + } +} + +async function onSelectRun(event) { + const loadPath = event.target?.value; + if (!loadPath) return; + try { + await store.loadDatasetRun(loadPath); + } catch { + /* status already set */ + } +} + +function runOptionLabel(run) { + const parts = [run.runName, `${run.sceneCount} сцен`]; + if (run.objectName) parts.push(run.objectName); + if (run.hasSettings) parts.push("json"); + return parts.join(" · "); +}