Добавить имитаторы МЛЭ и ГБО с 3D-сценой, съёмкой рельефа и пресетами.

Вкладки позволяют готовить рельеф, двигать АНПА, накапливать поверхность по лучам и сохранять скриншоты окон; ГБО использует бортовые секторы 12–75° и чёрные зоны вне обзора.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 15:43:08 +03:00
co-authored by Cursor
parent 8b248e1d54
commit 6cfc16e53c
18 changed files with 5512 additions and 12 deletions
+4
View File
@@ -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
+248 -1
View File
@@ -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
View File
@@ -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")
+321
View File
@@ -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"],
},
}
+75 -1
View File
@@ -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]] = []
+3
View File
@@ -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:
+14
View File
@@ -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">
+64
View File
@@ -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
+4
View File
@@ -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 },
], ],
}); });
+124 -1
View File
@@ -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;
}
},
}, },
}); });
+333
View File
@@ -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;
}
},
},
});
+333
View File
@@ -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;
}
},
},
});
+29
View File
@@ -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();
}
+109 -3
View File
@@ -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;
+681
View File
@@ -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> (|θ|&lt;12°), чёрная.
В окне съёмки: сплошное покрытие секторов + чёрные зоны вне обзора
(надир |θ|&lt;12° и |θ|&gt;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>
+678
View File
@@ -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>