Добавить имитаторы МЛЭ и ГБО с 3D-сценой, съёмкой рельефа и пресетами.
Вкладки позволяют готовить рельеф, двигать АНПА, накапливать поверхность по лучам и сохранять скриншоты окон; ГБО использует бортовые секторы 12–75° и чёрные зоны вне обзора. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,6 +50,10 @@ frontend/web/dist/
|
|||||||
# Generated PointNet sonar dataset
|
# Generated PointNet sonar dataset
|
||||||
sonar_dataset/
|
sonar_dataset/
|
||||||
|
|
||||||
|
# MLE simulator seafloor runs
|
||||||
|
mle_runs/
|
||||||
|
gbo_runs/
|
||||||
|
|
||||||
# OS/editor files
|
# OS/editor files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ Target class 1 = user-provided object; class 0 = seafloor.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ from scene_generator import (
|
|||||||
apply_transform,
|
apply_transform,
|
||||||
export_npy_float64,
|
export_npy_float64,
|
||||||
export_obj,
|
export_obj,
|
||||||
|
parse_obj_labeled_points,
|
||||||
parse_obj_points,
|
parse_obj_points,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -474,6 +477,10 @@ def resolve_output_dir(output_dir: str | Path = "sonar_dataset") -> Path:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
DATASET_RUN_FILENAME = "dataset_run.json"
|
||||||
|
DATASET_RUN_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
def _safe_object_stem(object_name: str | None) -> str:
|
def _safe_object_stem(object_name: str | None) -> str:
|
||||||
stem = Path(object_name or "object").stem
|
stem = Path(object_name or "object").stem
|
||||||
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in stem).strip("._-")
|
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
|
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]]:
|
def _downsample_points(points: list[list[float]], max_points: int) -> list[list[float]]:
|
||||||
max_points = max(100, int(max_points))
|
max_points = max(100, int(max_points))
|
||||||
if len(points) <= max_points:
|
if len(points) <= max_points:
|
||||||
@@ -584,6 +806,8 @@ def load_scene_preview(
|
|||||||
labeled = [[float(r[0]), float(r[1]), float(r[2]), float(r[6])] for r in rows]
|
labeled = [[float(r[0]), float(r[1]), float(r[2]), float(r[6])] for r in rows]
|
||||||
elif obj_path.is_file():
|
elif obj_path.is_file():
|
||||||
text = obj_path.read_text(encoding="utf-8", errors="ignore")
|
text = obj_path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
labeled = parse_obj_labeled_points(text)
|
||||||
|
if not labeled:
|
||||||
points = parse_obj_points(text)
|
points = parse_obj_points(text)
|
||||||
labeled = [[p[0], p[1], p[2], 0.0] for p in points]
|
labeled = [[p[0], p[1], p[2], 0.0] for p in points]
|
||||||
else:
|
else:
|
||||||
@@ -613,7 +837,11 @@ def write_scene_files(
|
|||||||
npy_path = output_dir / f"{stem}.npy"
|
npy_path = output_dir / f"{stem}.npy"
|
||||||
obj_path = output_dir / f"{stem}.obj"
|
obj_path = output_dir / f"{stem}.obj"
|
||||||
npy_path.write_bytes(export_npy_float64(scene["rows"]))
|
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}
|
return {"npy": str(npy_path), "obj": str(obj_path), "stem": stem}
|
||||||
|
|
||||||
|
|
||||||
@@ -662,6 +890,8 @@ def iter_generate_dataset(
|
|||||||
if length_count > 1024:
|
if length_count > 1024:
|
||||||
raise ValueError("length_count (Длина) must be <= 1024")
|
raise ValueError("length_count (Длина) must be <= 1024")
|
||||||
|
|
||||||
|
object_name = _normalize_model_filename(object_name)
|
||||||
|
|
||||||
base = resolve_output_dir(output_dir)
|
base = resolve_output_dir(output_dir)
|
||||||
run_dir = make_generation_run_dir(base, object_name)
|
run_dir = make_generation_run_dir(base, object_name)
|
||||||
template = normalize_object_points(object_points)
|
template = normalize_object_points(object_points)
|
||||||
@@ -773,6 +1003,23 @@ def iter_generate_dataset(
|
|||||||
"written": written,
|
"written": written,
|
||||||
"preview": preview,
|
"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}
|
yield {"type": "done", "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+195
-1
@@ -18,7 +18,15 @@ from pydantic import BaseModel
|
|||||||
from builtin_presets import BUILTIN_PRESETS, get_builtin_preset
|
from builtin_presets import BUILTIN_PRESETS, get_builtin_preset
|
||||||
from demo_generator import DEMO_SURFACE_TYPES, demo_payload
|
from demo_generator import DEMO_SURFACE_TYPES, demo_payload
|
||||||
from pipeline_insights import compute_insights
|
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 (
|
from scene_generator import (
|
||||||
catalog_payload as generator_catalog_payload,
|
catalog_payload as generator_catalog_payload,
|
||||||
export_npy_float64,
|
export_npy_float64,
|
||||||
@@ -123,6 +131,54 @@ class DatasetPreviewBody(BaseModel):
|
|||||||
maxPoints: int = 25000
|
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]:
|
def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
|
||||||
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
|
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
|
||||||
return preset
|
return preset
|
||||||
@@ -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
|
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")
|
@app.post("/api/generator/export")
|
||||||
def generator_export(body: GeneratorExportBody) -> Response:
|
def generator_export(body: GeneratorExportBody) -> Response:
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -718,5 +794,123 @@ def dataset_spa() -> FileResponse:
|
|||||||
return index()
|
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():
|
if (WEB_DIST / "assets").is_dir():
|
||||||
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -649,13 +649,87 @@ def export_ply(points: list[list[float]]) -> str:
|
|||||||
return header + body + ("\n" if points else "")
|
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"
|
safe_name = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "cloud")) or "cloud"
|
||||||
|
if classes is None:
|
||||||
lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"]
|
lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"]
|
||||||
for p in points:
|
for p in points:
|
||||||
lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
|
lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
|
||||||
return "\n".join(lines) + "\n"
|
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]]:
|
def parse_obj_points(text: str) -> list[list[float]]:
|
||||||
"""Extract vertex positions from Wavefront OBJ (ignores faces/materials)."""
|
"""Extract vertex positions from Wavefront OBJ (ignores faces/materials)."""
|
||||||
points: list[list[float]] = []
|
points: list[list[float]] = []
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ services:
|
|||||||
USER_PRESETS_DIR: /app/data/user-presets
|
USER_PRESETS_DIR: /app/data/user-presets
|
||||||
volumes:
|
volumes:
|
||||||
- ../presets:/app/presets:ro
|
- ../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
|
- dottosurface-user-presets:/app/data/user-presets
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
Генератор Датасета
|
Генератор Датасета
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ active: route.path.startsWith('/mle') }"
|
||||||
|
to="/mle"
|
||||||
|
>
|
||||||
|
Имитатор МЛЭ
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ active: route.path.startsWith('/gbo') }"
|
||||||
|
to="/gbo"
|
||||||
|
>
|
||||||
|
Имитатор ГБО
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|||||||
@@ -387,6 +387,70 @@ export const api = {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ stem, outputDir, maxPoints }),
|
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 };
|
export { exportFilename, parseObjPoints, parseXyzPoints, parsePlyPoints, parseCloudPoints };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from "vue-router";
|
|||||||
import PipelineView from "@/views/PipelineView.vue";
|
import PipelineView from "@/views/PipelineView.vue";
|
||||||
import GeneratorView from "@/views/GeneratorView.vue";
|
import GeneratorView from "@/views/GeneratorView.vue";
|
||||||
import DatasetView from "@/views/DatasetView.vue";
|
import DatasetView from "@/views/DatasetView.vue";
|
||||||
|
import MleView from "@/views/MleView.vue";
|
||||||
|
import GboView from "@/views/GboView.vue";
|
||||||
|
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -9,5 +11,7 @@ export default createRouter({
|
|||||||
{ path: "/", name: "pipeline", component: PipelineView },
|
{ path: "/", name: "pipeline", component: PipelineView },
|
||||||
{ path: "/generator", name: "generator", component: GeneratorView },
|
{ path: "/generator", name: "generator", component: GeneratorView },
|
||||||
{ path: "/dataset", name: "dataset", component: DatasetView },
|
{ path: "/dataset", name: "dataset", component: DatasetView },
|
||||||
|
{ path: "/mle", name: "mle", component: MleView },
|
||||||
|
{ path: "/gbo", name: "gbo", component: GboView },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
beamCount: 45,
|
beamCount: 45,
|
||||||
lengthCount: 45,
|
lengthCount: 45,
|
||||||
generateProgress: 0,
|
generateProgress: 0,
|
||||||
|
browseBusy: false,
|
||||||
|
availableRuns: [],
|
||||||
|
selectedRunPath: "",
|
||||||
lastResult: null,
|
lastResult: null,
|
||||||
selectedStem: null,
|
selectedStem: null,
|
||||||
previewPoints: [],
|
previewPoints: [],
|
||||||
@@ -43,7 +46,7 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
return (state.lastResult?.written || []).find((item) => item.stem === stem) || null;
|
return (state.lastResult?.written || []).find((item) => item.stem === stem) || null;
|
||||||
},
|
},
|
||||||
canGenerate(state) {
|
canGenerate(state) {
|
||||||
return !!state.modelFile && !state.busy;
|
return !state.busy;
|
||||||
},
|
},
|
||||||
classOptions(state) {
|
classOptions(state) {
|
||||||
const labels = state.classLabels || { 0: "background", 1: "object" };
|
const labels = state.classLabels || { 0: "background", 1: "object" };
|
||||||
@@ -75,6 +78,42 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
}
|
}
|
||||||
this.highlightClass = Number(value);
|
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) {
|
applyPreviewPayload(result, writtenMeta = null) {
|
||||||
this.previewStem = result.stem;
|
this.previewStem = result.stem;
|
||||||
this.previewPoints = result.points || [];
|
this.previewPoints = result.points || [];
|
||||||
@@ -174,6 +213,9 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
this.pushLog(
|
this.pushLog(
|
||||||
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
||||||
);
|
);
|
||||||
|
if (result.settingsPath) {
|
||||||
|
this.pushLog(`Настройки: ${result.settingsPath}`);
|
||||||
|
}
|
||||||
this.pushLog(
|
this.pushLog(
|
||||||
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
|
`Видимость: 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.previewMeta = null;
|
||||||
this.classCounts = {};
|
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) {
|
} catch (error) {
|
||||||
this.statusText = `Ошибка: ${error.message}`;
|
this.statusText = `Ошибка: ${error.message}`;
|
||||||
this.pushLog(`Ошибка: ${error.message}`);
|
this.pushLog(`Ошибка: ${error.message}`);
|
||||||
@@ -242,5 +297,73 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
this.previewBusy = false;
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -57,10 +57,15 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (store.previewPoints?.length) {
|
if (store.previewPoints?.length) {
|
||||||
refreshViewer({ fit: true });
|
refreshViewer({ fit: true });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await store.refreshAvailableRuns();
|
||||||
|
} catch {
|
||||||
|
/* logged in store */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onGenerate() {
|
async function onGenerate() {
|
||||||
@@ -92,11 +97,80 @@ function onModelFileChange(event) {
|
|||||||
function onHighlightClassChange(event) {
|
function onHighlightClassChange(event) {
|
||||||
store.setHighlightClass(event.target?.value);
|
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(" · ");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="layout dataset-layout">
|
<main class="layout dataset-layout">
|
||||||
<aside class="sidebar dataset-sidebar">
|
<aside class="sidebar dataset-sidebar">
|
||||||
|
<section class="panel browse-panel">
|
||||||
|
<h2>Просмотр датасета</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Выберите папку с ранее сгенерированными <code>.obj</code> / <code>.npy</code>.
|
||||||
|
Параметры из <code>dataset_run.json</code> выводятся в лог.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Папка датасета</span>
|
||||||
|
<div class="run-picker-row">
|
||||||
|
<select
|
||||||
|
:value="store.selectedRunPath || ''"
|
||||||
|
:disabled="store.browseBusy || store.busy"
|
||||||
|
@change="onSelectRun"
|
||||||
|
>
|
||||||
|
<option value="">— выберите папку —</option>
|
||||||
|
<option
|
||||||
|
v-for="run in store.availableRuns"
|
||||||
|
:key="run.loadPath || run.outputDir"
|
||||||
|
:value="run.loadPath || run.outputDir"
|
||||||
|
>
|
||||||
|
{{ runOptionLabel(run) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary refresh-btn"
|
||||||
|
:disabled="store.browseBusy || store.busy"
|
||||||
|
title="Обновить список папок"
|
||||||
|
@click="onRefreshRuns"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="ref-caption">
|
||||||
|
Каталог: <code>{{ store.outputDir || "sonar_dataset" }}</code>
|
||||||
|
<template v-if="store.resolvedOutputDir && store.selectedRunPath">
|
||||||
|
· открыто: <code>{{ store.resolvedOutputDir }}</code>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Генератор датасета</h2>
|
<h2>Генератор датасета</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -225,7 +299,7 @@ function onHighlightClassChange(event) {
|
|||||||
<span>Выбор сцены</span>
|
<span>Выбор сцены</span>
|
||||||
<select
|
<select
|
||||||
:value="store.selectedStem || ''"
|
:value="store.selectedStem || ''"
|
||||||
:disabled="store.previewBusy || store.busy"
|
:disabled="store.previewBusy || store.busy || store.browseBusy"
|
||||||
@change="onSelectChange"
|
@change="onSelectChange"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
@@ -271,7 +345,7 @@ function onHighlightClassChange(event) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="scene-btn"
|
class="scene-btn"
|
||||||
:disabled="store.previewBusy || store.busy"
|
:disabled="store.previewBusy || store.busy || store.browseBusy"
|
||||||
@click="onSelectScene(item.stem)"
|
@click="onSelectScene(item.stem)"
|
||||||
>
|
>
|
||||||
<code>{{ item.stem }}</code>
|
<code>{{ item.stem }}</code>
|
||||||
@@ -335,6 +409,38 @@ function onHighlightClassChange(event) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
.browse-panel {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
.run-picker-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.run-picker-row select {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.refresh-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--control-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.refresh-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.secondary:hover:not(:disabled) {
|
||||||
|
background: var(--chain-enabled-bg);
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { useGboStore } from "@/stores/gbo";
|
||||||
|
import { useGboSimulator } from "@/composables/useGboSimulator";
|
||||||
|
|
||||||
|
const store = useGboStore();
|
||||||
|
const viewerRef = ref(null);
|
||||||
|
const surveyRef = ref(null);
|
||||||
|
const sim = useGboSimulator(viewerRef, surveyRef);
|
||||||
|
const liveStatus = ref("");
|
||||||
|
|
||||||
|
function applyLiveParams(fit = true) {
|
||||||
|
if (!store.scene || store.running) return;
|
||||||
|
sim.updateParams({
|
||||||
|
beamCount: store.beamCount,
|
||||||
|
swathAngleDeg: store.swathAngleDeg,
|
||||||
|
detectionRangeM: store.detectionRangeM,
|
||||||
|
speed: store.speed,
|
||||||
|
surveyLength: store.surveyLength,
|
||||||
|
auvDepth: store.auvDepth,
|
||||||
|
auvX: store.auvX,
|
||||||
|
auvY: store.auvY,
|
||||||
|
auvHeadingDeg: store.auvHeadingDeg,
|
||||||
|
objectX: store.objectX,
|
||||||
|
objectY: store.objectY,
|
||||||
|
objectZ: store.objectZ,
|
||||||
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
|
fitCamera: fit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.hydrateFromServer();
|
||||||
|
if (store.auvFileName || store.objectFileName) {
|
||||||
|
const parts = [];
|
||||||
|
if (store.auvFileName) parts.push(`АНПА: ${store.auvFileName}`);
|
||||||
|
if (store.objectFileName) parts.push(`объект: ${store.objectFileName}`);
|
||||||
|
store.pushLog(`Восстановлены настройки (${parts.join(", ")}). Файлы моделей нужно выбрать снова.`);
|
||||||
|
}
|
||||||
|
sim.setCallbacks({
|
||||||
|
status: ({ traveled, x, y, z, surveyFaces }) => {
|
||||||
|
liveStatus.value = `путь ${traveled.toFixed(1)} м · (${x.toFixed(1)}, ${y.toFixed(1)}, ${z.toFixed(1)}) · поверхность ${surveyFaces || 0} граней`;
|
||||||
|
store.running = true;
|
||||||
|
},
|
||||||
|
finished: ({ traveled, saved }) => {
|
||||||
|
store.running = false;
|
||||||
|
store.statusText = `Съёмка завершена: ${traveled.toFixed(1)} м`;
|
||||||
|
store.pushLog(`Съёмка завершена: пройдено ${traveled.toFixed(1)} м`);
|
||||||
|
if (saved?.seafloorObj) {
|
||||||
|
store.seafloorObjPath = saved.seafloorObj;
|
||||||
|
store.pushLog(
|
||||||
|
`Поверхность съёмки сохранена: ${saved.seafloorObj} (${saved.vertexCount} вершин, ${saved.faceCount} граней)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
liveStatus.value = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
store.auvDepth,
|
||||||
|
store.auvX,
|
||||||
|
store.auvY,
|
||||||
|
store.auvHeadingDeg,
|
||||||
|
store.auvSizeM,
|
||||||
|
store.objectX,
|
||||||
|
store.objectY,
|
||||||
|
store.objectZ,
|
||||||
|
store.objectSizeM,
|
||||||
|
store.objectRotXDeg,
|
||||||
|
store.objectRotYDeg,
|
||||||
|
store.objectRotZDeg,
|
||||||
|
store.beamCount,
|
||||||
|
store.swathAngleDeg,
|
||||||
|
store.detectionRangeM,
|
||||||
|
store.speed,
|
||||||
|
store.surveyLength,
|
||||||
|
store.seed,
|
||||||
|
store.sizeX,
|
||||||
|
store.sizeY,
|
||||||
|
store.outputDir,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
store.persistSettings();
|
||||||
|
applyLiveParams(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onAuvFile(event) {
|
||||||
|
store.setAuvFile(event.target?.files?.[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onObjectFile(event) {
|
||||||
|
store.setObjectFile(event.target?.files?.[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPrepare() {
|
||||||
|
try {
|
||||||
|
const result = await store.prepare();
|
||||||
|
await sim.applyScene({
|
||||||
|
seafloorPayload: result,
|
||||||
|
auvFile: store.auvFile,
|
||||||
|
objectFile: store.objectFile,
|
||||||
|
params: {
|
||||||
|
auvX: store.auvX,
|
||||||
|
auvY: store.auvY,
|
||||||
|
auvDepth: store.auvDepth,
|
||||||
|
auvHeadingDeg: store.auvHeadingDeg,
|
||||||
|
auvSizeM: store.auvSizeM,
|
||||||
|
objectX: store.objectX,
|
||||||
|
objectY: store.objectY,
|
||||||
|
objectZ: store.objectZ,
|
||||||
|
objectSizeM: store.objectSizeM,
|
||||||
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
|
beamCount: store.beamCount,
|
||||||
|
swathAngleDeg: store.swathAngleDeg,
|
||||||
|
detectionRangeM: store.detectionRangeM,
|
||||||
|
speed: store.speed,
|
||||||
|
surveyLength: store.surveyLength,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.pushLog("Сцена готова. Нажмите «Старт» для движения АНПА.");
|
||||||
|
} catch {
|
||||||
|
/* logged in store */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStart() {
|
||||||
|
if (!store.scene) {
|
||||||
|
store.statusText = "Сначала подготовьте сцену";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sim.updateParams({
|
||||||
|
beamCount: store.beamCount,
|
||||||
|
swathAngleDeg: store.swathAngleDeg,
|
||||||
|
detectionRangeM: store.detectionRangeM,
|
||||||
|
speed: store.speed,
|
||||||
|
surveyLength: store.surveyLength,
|
||||||
|
auvDepth: store.auvDepth,
|
||||||
|
auvX: store.auvX,
|
||||||
|
auvY: store.auvY,
|
||||||
|
auvHeadingDeg: store.auvHeadingDeg,
|
||||||
|
objectX: store.objectX,
|
||||||
|
objectY: store.objectY,
|
||||||
|
objectZ: store.objectZ,
|
||||||
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
|
});
|
||||||
|
if (sim.start()) {
|
||||||
|
store.running = true;
|
||||||
|
store.statusText = "Съёмка…";
|
||||||
|
store.pushLog(
|
||||||
|
`Старт: скорость=${store.speed} м/с, курс=${store.auvHeadingDeg}°, глубина над дном=${store.auvDepth} м, лучей=${store.beamCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStop() {
|
||||||
|
sim.stop();
|
||||||
|
store.running = false;
|
||||||
|
store.statusText = "Остановлено";
|
||||||
|
store.pushLog("Съёмка остановлена");
|
||||||
|
liveStatus.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShotScene() {
|
||||||
|
const result = sim.captureSnapshot("scene");
|
||||||
|
if (result) {
|
||||||
|
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
|
||||||
|
} else {
|
||||||
|
store.pushLog("Скриншот сцены: окно ещё не готово");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShotSurvey() {
|
||||||
|
const result = sim.captureSnapshot("survey");
|
||||||
|
if (result) {
|
||||||
|
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
|
||||||
|
} else {
|
||||||
|
store.pushLog("Скриншот рельефа: окно ещё не готово");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="layout gbo-layout">
|
||||||
|
<aside class="sidebar gbo-sidebar">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Имитатор ГБО</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Гидролокатор бокового обзора (ГБО) на АНПА: рельеф дна, объект и движение.
|
||||||
|
На каждом борту — <strong>сплошной сектор</strong> от <strong>12°</strong> до <strong>75°</strong>
|
||||||
|
от надира (симметрично). Шаг озвучивания 0.5°. Между бортами —
|
||||||
|
<strong>слепая зона</strong> (|θ|<12°), чёрная.
|
||||||
|
В окне съёмки: сплошное покрытие секторов + чёрные зоны вне обзора
|
||||||
|
(надир |θ|<12° и |θ|>75°) + акустическая тень → <code>seafloor.obj</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Модели</h3>
|
||||||
|
<label class="field">
|
||||||
|
<span>3D модель АНПА (.obj)</span>
|
||||||
|
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onAuvFile" />
|
||||||
|
<span class="ref-caption">{{ store.auvFileName || "Не выбрана — будет упрощённая модель" }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>3D модель объекта на дне (.obj)</span>
|
||||||
|
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onObjectFile" />
|
||||||
|
<span class="ref-caption">{{ store.objectFileName || "Не выбрана — будет упрощённая модель" }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3>Положение АНПА</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>X</span>
|
||||||
|
<input v-model.number="store.auvX" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Y (старт)</span>
|
||||||
|
<input v-model.number="store.auvY" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Высота над дном, м</span>
|
||||||
|
<input v-model.number="store.auvDepth" type="number" min="0.3" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Курс, °</span>
|
||||||
|
<input v-model.number="store.auvHeadingDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Длина АНПА, м</span>
|
||||||
|
<input v-model.number="store.auvSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Положение объекта</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>X</span>
|
||||||
|
<input v-model.number="store.objectX" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Y</span>
|
||||||
|
<input v-model.number="store.objectY" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Высота над дном</span>
|
||||||
|
<input v-model.number="store.objectZ" type="number" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Размер объекта, м</span>
|
||||||
|
<input v-model.number="store.objectSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Поворот X, °</span>
|
||||||
|
<input v-model.number="store.objectRotXDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Поворот Y, °</span>
|
||||||
|
<input v-model.number="store.objectRotYDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Поворот Z, °</span>
|
||||||
|
<input v-model.number="store.objectRotZDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Лучи и движение</h3>
|
||||||
|
<p class="hint">Геометрия ГБО: сектор ±12°…±75° от надира на каждом борту, шаг 0.5°.</p>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Число лучей</span>
|
||||||
|
<input v-model.number="store.beamCount" type="number" min="1" max="256" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Угол обзора, °</span>
|
||||||
|
<input v-model.number="store.swathAngleDeg" type="number" min="10" max="170" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Дальность, м</span>
|
||||||
|
<input v-model.number="store.detectionRangeM" type="number" min="1" max="400" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Скорость, м/с</span>
|
||||||
|
<input v-model.number="store.speed" type="number" min="0.1" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Длина галса, м</span>
|
||||||
|
<input v-model.number="store.surveyLength" type="number" min="1" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Рельеф дна</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Seed</span>
|
||||||
|
<input v-model.number="store.seed" type="number" step="1" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Размер X</span>
|
||||||
|
<input v-model.number="store.sizeX" type="number" min="8" step="1" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Размер Y</span>
|
||||||
|
<input v-model.number="store.sizeY" type="number" min="8" step="1" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Каталог</span>
|
||||||
|
<input v-model="store.outputDir" type="text" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary"
|
||||||
|
:disabled="!store.canPrepare"
|
||||||
|
@click="onPrepare"
|
||||||
|
>
|
||||||
|
{{ store.busy ? "Подготовка…" : "Подготовить" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary"
|
||||||
|
:disabled="!store.canStart || store.running"
|
||||||
|
@click="onStart"
|
||||||
|
>
|
||||||
|
Старт
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="!store.running" @click="onStop">
|
||||||
|
Стоп
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status">{{ store.statusText }}</p>
|
||||||
|
<p v-if="liveStatus" class="live">{{ liveStatus }}</p>
|
||||||
|
<p v-if="store.seafloorObjPath" class="ref-caption">
|
||||||
|
Рельеф: <code>{{ store.seafloorObjPath }}</code>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="content-column gbo-content">
|
||||||
|
<div class="viewer-wrap">
|
||||||
|
<div ref="viewerRef" class="viewer-canvas" />
|
||||||
|
<div class="shot-bar scene-shot-bar" role="toolbar" aria-label="Скриншот сцены">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shot-btn"
|
||||||
|
title="Скриншот сцены"
|
||||||
|
aria-label="Скриншот сцены"
|
||||||
|
@click="onShotScene"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
|
||||||
|
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-label">
|
||||||
|
Имитатор ГБО
|
||||||
|
<span v-if="store.running"> · съёмка</span>
|
||||||
|
</div>
|
||||||
|
<div class="survey-panel">
|
||||||
|
<div class="survey-title">Съёмка ГБО → seafloor.obj</div>
|
||||||
|
<div class="survey-canvas-wrap">
|
||||||
|
<div ref="surveyRef" class="survey-canvas" />
|
||||||
|
<div class="shot-bar survey-shot-bar" role="toolbar" aria-label="Скриншот рельефа">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shot-btn"
|
||||||
|
title="Скриншот рельефа"
|
||||||
|
aria-label="Скриншот рельефа"
|
||||||
|
@click="onShotSurvey"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
|
||||||
|
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section class="log-panel">
|
||||||
|
<h3>Лог</h3>
|
||||||
|
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gbo-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.gbo-sidebar {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: 14px 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.panel h3 {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ref-caption {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field input[type="number"],
|
||||||
|
.field input[type="text"],
|
||||||
|
.field input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--control-text);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.primary:disabled,
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.status,
|
||||||
|
.live {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
.live {
|
||||||
|
color: var(--control-text);
|
||||||
|
}
|
||||||
|
.gbo-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.viewer-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a3d38;
|
||||||
|
}
|
||||||
|
.viewer-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.viewer-canvas :deep(canvas) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.viewer-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #e2e8f0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.survey-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: min(380px, 46%);
|
||||||
|
height: min(280px, 42%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #2a5a4a;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.survey-title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.survey-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.survey-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
touch-action: none;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #2a5a4a;
|
||||||
|
}
|
||||||
|
.survey-canvas :deep(canvas) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.shot-bar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.scene-shot-bar {
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
.survey-shot-bar {
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
.shot-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--modebar-icon, #e2e8f0);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.shot-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--control-text) 12%, transparent);
|
||||||
|
}
|
||||||
|
.shot-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.log-panel {
|
||||||
|
flex: 0 0 140px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.log-panel h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.log {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.gbo-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(220px, 38vh) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { useMleStore } from "@/stores/mle";
|
||||||
|
import { useMleSimulator } from "@/composables/useMleSimulator";
|
||||||
|
|
||||||
|
const store = useMleStore();
|
||||||
|
const viewerRef = ref(null);
|
||||||
|
const surveyRef = ref(null);
|
||||||
|
const sim = useMleSimulator(viewerRef, surveyRef);
|
||||||
|
const liveStatus = ref("");
|
||||||
|
|
||||||
|
function applyLiveParams(fit = true) {
|
||||||
|
if (!store.scene || store.running) return;
|
||||||
|
sim.updateParams({
|
||||||
|
beamCount: store.beamCount,
|
||||||
|
swathAngleDeg: store.swathAngleDeg,
|
||||||
|
detectionRangeM: store.detectionRangeM,
|
||||||
|
speed: store.speed,
|
||||||
|
surveyLength: store.surveyLength,
|
||||||
|
auvDepth: store.auvDepth,
|
||||||
|
auvX: store.auvX,
|
||||||
|
auvY: store.auvY,
|
||||||
|
auvHeadingDeg: store.auvHeadingDeg,
|
||||||
|
objectX: store.objectX,
|
||||||
|
objectY: store.objectY,
|
||||||
|
objectZ: store.objectZ,
|
||||||
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
|
fitCamera: fit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.hydrateFromServer();
|
||||||
|
if (store.auvFileName || store.objectFileName) {
|
||||||
|
const parts = [];
|
||||||
|
if (store.auvFileName) parts.push(`АНПА: ${store.auvFileName}`);
|
||||||
|
if (store.objectFileName) parts.push(`объект: ${store.objectFileName}`);
|
||||||
|
store.pushLog(`Восстановлены настройки (${parts.join(", ")}). Файлы моделей нужно выбрать снова.`);
|
||||||
|
}
|
||||||
|
sim.setCallbacks({
|
||||||
|
status: ({ traveled, x, y, z, surveyFaces }) => {
|
||||||
|
liveStatus.value = `путь ${traveled.toFixed(1)} м · (${x.toFixed(1)}, ${y.toFixed(1)}, ${z.toFixed(1)}) · поверхность ${surveyFaces || 0} граней`;
|
||||||
|
store.running = true;
|
||||||
|
},
|
||||||
|
finished: ({ traveled, saved }) => {
|
||||||
|
store.running = false;
|
||||||
|
store.statusText = `Съёмка завершена: ${traveled.toFixed(1)} м`;
|
||||||
|
store.pushLog(`Съёмка завершена: пройдено ${traveled.toFixed(1)} м`);
|
||||||
|
if (saved?.seafloorObj) {
|
||||||
|
store.seafloorObjPath = saved.seafloorObj;
|
||||||
|
store.pushLog(
|
||||||
|
`Поверхность съёмки сохранена: ${saved.seafloorObj} (${saved.vertexCount} вершин, ${saved.faceCount} граней)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
liveStatus.value = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
store.auvDepth,
|
||||||
|
store.auvX,
|
||||||
|
store.auvY,
|
||||||
|
store.auvHeadingDeg,
|
||||||
|
store.auvSizeM,
|
||||||
|
store.objectX,
|
||||||
|
store.objectY,
|
||||||
|
store.objectZ,
|
||||||
|
store.objectSizeM,
|
||||||
|
store.objectRotXDeg,
|
||||||
|
store.objectRotYDeg,
|
||||||
|
store.objectRotZDeg,
|
||||||
|
store.beamCount,
|
||||||
|
store.swathAngleDeg,
|
||||||
|
store.detectionRangeM,
|
||||||
|
store.speed,
|
||||||
|
store.surveyLength,
|
||||||
|
store.seed,
|
||||||
|
store.sizeX,
|
||||||
|
store.sizeY,
|
||||||
|
store.outputDir,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
store.persistSettings();
|
||||||
|
applyLiveParams(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onAuvFile(event) {
|
||||||
|
store.setAuvFile(event.target?.files?.[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onObjectFile(event) {
|
||||||
|
store.setObjectFile(event.target?.files?.[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPrepare() {
|
||||||
|
try {
|
||||||
|
const result = await store.prepare();
|
||||||
|
await sim.applyScene({
|
||||||
|
seafloorPayload: result,
|
||||||
|
auvFile: store.auvFile,
|
||||||
|
objectFile: store.objectFile,
|
||||||
|
params: {
|
||||||
|
auvX: store.auvX,
|
||||||
|
auvY: store.auvY,
|
||||||
|
auvDepth: store.auvDepth,
|
||||||
|
auvHeadingDeg: store.auvHeadingDeg,
|
||||||
|
auvSizeM: store.auvSizeM,
|
||||||
|
objectX: store.objectX,
|
||||||
|
objectY: store.objectY,
|
||||||
|
objectZ: store.objectZ,
|
||||||
|
objectSizeM: store.objectSizeM,
|
||||||
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
|
beamCount: store.beamCount,
|
||||||
|
swathAngleDeg: store.swathAngleDeg,
|
||||||
|
detectionRangeM: store.detectionRangeM,
|
||||||
|
speed: store.speed,
|
||||||
|
surveyLength: store.surveyLength,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.pushLog("Сцена готова. Нажмите «Старт» для движения АНПА.");
|
||||||
|
} catch {
|
||||||
|
/* logged in store */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStart() {
|
||||||
|
if (!store.scene) {
|
||||||
|
store.statusText = "Сначала подготовьте сцену";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sim.updateParams({
|
||||||
|
beamCount: store.beamCount,
|
||||||
|
swathAngleDeg: store.swathAngleDeg,
|
||||||
|
detectionRangeM: store.detectionRangeM,
|
||||||
|
speed: store.speed,
|
||||||
|
surveyLength: store.surveyLength,
|
||||||
|
auvDepth: store.auvDepth,
|
||||||
|
auvX: store.auvX,
|
||||||
|
auvY: store.auvY,
|
||||||
|
auvHeadingDeg: store.auvHeadingDeg,
|
||||||
|
objectX: store.objectX,
|
||||||
|
objectY: store.objectY,
|
||||||
|
objectZ: store.objectZ,
|
||||||
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
|
});
|
||||||
|
if (sim.start()) {
|
||||||
|
store.running = true;
|
||||||
|
store.statusText = "Съёмка…";
|
||||||
|
store.pushLog(
|
||||||
|
`Старт: скорость=${store.speed} м/с, курс=${store.auvHeadingDeg}°, глубина над дном=${store.auvDepth} м, лучей=${store.beamCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStop() {
|
||||||
|
sim.stop();
|
||||||
|
store.running = false;
|
||||||
|
store.statusText = "Остановлено";
|
||||||
|
store.pushLog("Съёмка остановлена");
|
||||||
|
liveStatus.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShotScene() {
|
||||||
|
const result = sim.captureSnapshot("scene");
|
||||||
|
if (result) {
|
||||||
|
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
|
||||||
|
} else {
|
||||||
|
store.pushLog("Скриншот сцены: окно ещё не готово");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShotSurvey() {
|
||||||
|
const result = sim.captureSnapshot("survey");
|
||||||
|
if (result) {
|
||||||
|
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
|
||||||
|
} else {
|
||||||
|
store.pushLog("Скриншот рельефа: окно ещё не готово");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="layout mle-layout">
|
||||||
|
<aside class="sidebar mle-sidebar">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Имитатор МЛЭ</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Многолучевой эхолот на АНПА: рельеф дна, объект, лучи и движение аппарата.
|
||||||
|
Объект по умолчанию ставится по курсу АНПА на дне. В правом нижнем окне
|
||||||
|
наращивается поверхность съёмки (дно + объект по первому отклику луча) —
|
||||||
|
она же сохраняется в <code>seafloor.obj</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Модели</h3>
|
||||||
|
<label class="field">
|
||||||
|
<span>3D модель АНПА (.obj)</span>
|
||||||
|
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onAuvFile" />
|
||||||
|
<span class="ref-caption">{{ store.auvFileName || "Не выбрана — будет упрощённая модель" }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>3D модель объекта на дне (.obj)</span>
|
||||||
|
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onObjectFile" />
|
||||||
|
<span class="ref-caption">{{ store.objectFileName || "Не выбрана — будет упрощённая модель" }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3>Положение АНПА</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>X</span>
|
||||||
|
<input v-model.number="store.auvX" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Y (старт)</span>
|
||||||
|
<input v-model.number="store.auvY" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Высота над дном, м</span>
|
||||||
|
<input v-model.number="store.auvDepth" type="number" min="0.3" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Курс, °</span>
|
||||||
|
<input v-model.number="store.auvHeadingDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Длина АНПА, м</span>
|
||||||
|
<input v-model.number="store.auvSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Положение объекта</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>X</span>
|
||||||
|
<input v-model.number="store.objectX" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Y</span>
|
||||||
|
<input v-model.number="store.objectY" type="number" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Высота над дном</span>
|
||||||
|
<input v-model.number="store.objectZ" type="number" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Размер объекта, м</span>
|
||||||
|
<input v-model.number="store.objectSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Поворот X, °</span>
|
||||||
|
<input v-model.number="store.objectRotXDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Поворот Y, °</span>
|
||||||
|
<input v-model.number="store.objectRotYDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Поворот Z, °</span>
|
||||||
|
<input v-model.number="store.objectRotZDeg" type="number" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Лучи и движение</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Число лучей</span>
|
||||||
|
<input v-model.number="store.beamCount" type="number" min="1" max="256" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Угол обзора, °</span>
|
||||||
|
<input v-model.number="store.swathAngleDeg" type="number" min="10" max="170" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Дальность, м</span>
|
||||||
|
<input v-model.number="store.detectionRangeM" type="number" min="1" max="400" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Скорость, м/с</span>
|
||||||
|
<input v-model.number="store.speed" type="number" min="0.1" step="0.1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Длина галса, м</span>
|
||||||
|
<input v-model.number="store.surveyLength" type="number" min="1" step="1" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Рельеф дна</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Seed</span>
|
||||||
|
<input v-model.number="store.seed" type="number" step="1" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Размер X</span>
|
||||||
|
<input v-model.number="store.sizeX" type="number" min="8" step="1" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Размер Y</span>
|
||||||
|
<input v-model.number="store.sizeY" type="number" min="8" step="1" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Каталог</span>
|
||||||
|
<input v-model="store.outputDir" type="text" :disabled="store.busy || store.running" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary"
|
||||||
|
:disabled="!store.canPrepare"
|
||||||
|
@click="onPrepare"
|
||||||
|
>
|
||||||
|
{{ store.busy ? "Подготовка…" : "Подготовить" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary"
|
||||||
|
:disabled="!store.canStart || store.running"
|
||||||
|
@click="onStart"
|
||||||
|
>
|
||||||
|
Старт
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="!store.running" @click="onStop">
|
||||||
|
Стоп
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status">{{ store.statusText }}</p>
|
||||||
|
<p v-if="liveStatus" class="live">{{ liveStatus }}</p>
|
||||||
|
<p v-if="store.seafloorObjPath" class="ref-caption">
|
||||||
|
Рельеф: <code>{{ store.seafloorObjPath }}</code>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="content-column mle-content">
|
||||||
|
<div class="viewer-wrap">
|
||||||
|
<div ref="viewerRef" class="viewer-canvas" />
|
||||||
|
<div class="shot-bar scene-shot-bar" role="toolbar" aria-label="Скриншот сцены">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shot-btn"
|
||||||
|
title="Скриншот сцены"
|
||||||
|
aria-label="Скриншот сцены"
|
||||||
|
@click="onShotScene"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
|
||||||
|
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-label">
|
||||||
|
Имитатор МЛЭ
|
||||||
|
<span v-if="store.running"> · съёмка</span>
|
||||||
|
</div>
|
||||||
|
<div class="survey-panel">
|
||||||
|
<div class="survey-title">Съёмка рельефа → seafloor.obj</div>
|
||||||
|
<div class="survey-canvas-wrap">
|
||||||
|
<div ref="surveyRef" class="survey-canvas" />
|
||||||
|
<div class="shot-bar survey-shot-bar" role="toolbar" aria-label="Скриншот рельефа">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shot-btn"
|
||||||
|
title="Скриншот рельефа"
|
||||||
|
aria-label="Скриншот рельефа"
|
||||||
|
@click="onShotSurvey"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
|
||||||
|
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section class="log-panel">
|
||||||
|
<h3>Лог</h3>
|
||||||
|
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mle-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mle-sidebar {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: 14px 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.panel h3 {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ref-caption {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field input[type="number"],
|
||||||
|
.field input[type="text"],
|
||||||
|
.field input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--control-text);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.primary:disabled,
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.status,
|
||||||
|
.live {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
.live {
|
||||||
|
color: var(--control-text);
|
||||||
|
}
|
||||||
|
.mle-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.viewer-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a3d38;
|
||||||
|
}
|
||||||
|
.viewer-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.viewer-canvas :deep(canvas) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.viewer-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #e2e8f0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.survey-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: min(380px, 46%);
|
||||||
|
height: min(280px, 42%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #2a5a4a;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.survey-title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.survey-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.survey-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
touch-action: none;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #2a5a4a;
|
||||||
|
}
|
||||||
|
.survey-canvas :deep(canvas) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.shot-bar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.scene-shot-bar {
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
.survey-shot-bar {
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
.shot-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--modebar-icon, #e2e8f0);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.shot-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--control-text) 12%, transparent);
|
||||||
|
}
|
||||||
|
.shot-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.log-panel {
|
||||||
|
flex: 0 0 140px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.log-panel h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.log {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.mle-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(220px, 38vh) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user