diff --git a/backend/dataset_generator.py b/backend/dataset_generator.py index e8d8bb8..be91dc3 100644 --- a/backend/dataset_generator.py +++ b/backend/dataset_generator.py @@ -20,6 +20,7 @@ from scene_generator import ( apply_transform, export_npy_float64, export_obj, + generate_pipe, parse_obj_labeled_points, parse_obj_points, ) @@ -37,6 +38,24 @@ TOTAL_FULL_SCENES = sum(n for _, n in AREA_LAYOUT) # 500 VISIBILITY_TIERS = ("nearly_hidden", "partial", "visible") +# Built-in object models for the dataset generator (no upload required). +OBJECT_PRESETS: dict[str, dict[str, Any]] = { + "pipe": { + "id": "pipe", + "label": "Трубопровод", + "filename": "pipeline.obj", + # Length is scene-sized at placement time (border→border); params kept for catalog. + "params": { + "length": 2.6, + "radius": 0.22, + "axis": "y", + "count": 4000, + "noise": 0.005, + "seed": 1, + }, + }, +} + # --------------------------------------------------------------------------- # Area naming @@ -62,9 +81,37 @@ def scene_index_to_area_name(index: int) -> tuple[int, int, str]: # --------------------------------------------------------------------------- -# Target object from user .obj +# Target object from user .obj or built-in preset # --------------------------------------------------------------------------- +def list_object_presets() -> list[dict[str, Any]]: + """Public preset catalog for the dataset UI.""" + return [ + { + "id": meta["id"], + "label": meta["label"], + "filename": meta["filename"], + } + for meta in OBJECT_PRESETS.values() + ] + + +def load_preset_object_points(preset_id: str) -> tuple[list[list[float]], str]: + """Build normalized object points for a built-in preset. Returns (points, filename).""" + key = str(preset_id or "").strip().lower() + meta = OBJECT_PRESETS.get(key) + if meta is None: + known = ", ".join(sorted(OBJECT_PRESETS)) or "(none)" + raise ValueError(f"Unknown object preset '{preset_id}'. Known: {known}") + if key == "pipe": + points = generate_pipe(dict(meta["params"])) + else: + raise ValueError(f"Preset '{preset_id}' has no generator.") + if len(points) < 3: + raise ValueError(f"Preset '{preset_id}' produced too few points.") + return normalize_object_points(points), str(meta["filename"]) + + def normalize_object_points(points: list[list[float]]) -> list[list[float]]: xs = [p[0] for p in points] ys = [p[1] for p in points] @@ -286,6 +333,169 @@ def _place_object_in_scene( return world, info +def _terrain_unevenness(meta: dict[str, Any]) -> float: + """Summarize seafloor relief strength as a ~0…1 scalar for silt scaling.""" + amp = float(meta.get("amplitude", 0.0)) + hills = meta.get("hills") or [] + valleys = meta.get("valleys") or [] + bumps = meta.get("bumps") or [] + hill_peak = max((float(h[2]) for h in hills), default=0.0) + valley_peak = max((float(v[2]) for v in valleys), default=0.0) + bump_peak = max((float(b[2]) for b in bumps), default=0.0) + score = ( + amp / 0.35 + + 0.5 * (hill_peak / 0.7) + + 0.3 * (valley_peak / 0.55) + + 0.25 * (bump_peak / 0.35) + + 0.04 * len(hills) + + 0.02 * len(bumps) + ) + return max(0.0, min(1.0, score / 2.2)) + + +def _place_pipeline_in_scene( + rng: random.Random, + meta: dict[str, Any], + visibility: str, + object_scale: float = 1.0, +) -> tuple[list[list[float]], dict[str, Any]]: + """Lay a straight pipeline from scan border to border; may be silted over. + + Length spans the full survey rectangle along X or Y (small yaw allowed). + For ``visible`` there is no silt. For partial / nearly_hidden, randomly + one silt mound or several (count from visibility and seafloor unevenness). + """ + size_x = float(meta["sizeX"]) + size_y = float(meta["sizeY"]) + min_span = min(size_x, size_y) + base_scale = max(0.01, float(object_scale)) + uneven = _terrain_unevenness(meta) + + # Diameter scales with scene and UI objectScale; length always edge-to-edge. + radius = max(0.05, min_span * 0.028 * base_scale) + radius = min(radius, min_span * 0.12) + + along_x = rng.random() < 0.5 + yaw = rng.uniform(-math.radians(10), math.radians(10)) + cos_y = math.cos(yaw) + sin_y = math.sin(yaw) + + if along_x: + length = size_x / max(abs(cos_y), 0.88) * 1.04 + cx = 0.0 + cy = rng.uniform(-size_y * 0.12, size_y * 0.12) + ux, uy = cos_y, sin_y + nx, ny = -sin_y, cos_y + else: + length = size_y / max(abs(cos_y), 0.88) * 1.04 + cx = rng.uniform(-size_x * 0.12, size_x * 0.12) + cy = 0.0 + ux, uy = -sin_y, cos_y + nx, ny = -cos_y, -sin_y + + def centerline_xy(t_norm: float) -> tuple[float, float]: + t = t_norm * length + return cx + ux * t, cy + uy * t + + # Ось — прямая в 3D: одна высота для всего цилиндра (не повторяет рельеф). + n_along = max(96, int(max(int(meta["beamCount"]), int(meta["lengthCount"])) * 3)) + n_circ = 28 + floor_samples = [ + _height_at(*centerline_xy(ia / max(n_along - 1, 1) - 0.5), meta) + for ia in range(n_along) + ] + # Положить трубу примерно на средний уровень дна, не изгибая ось. + axis_z = (sum(floor_samples) / max(len(floor_samples), 1)) + radius + + world: list[list[float]] = [] + for ia in range(n_along): + t_norm = ia / max(n_along - 1, 1) - 0.5 # [-0.5, 0.5] + px, py = centerline_xy(t_norm) + for ic in range(n_circ): + ang = (2.0 * math.pi * ic) / n_circ + rr = math.cos(ang) * radius + rz = math.sin(ang) * radius + world.append([px + nx * rr, py + ny * rr, axis_z + rz]) + + # Ил: только partial / nearly_hidden — случайно один холм или несколько. + silt_bumps: list[tuple[float, float, float, float]] = [] + silt_mode = "none" + if visibility in ("nearly_hidden", "partial"): + silt_mode = "several" if rng.random() < 0.5 else "one" + if silt_mode == "one": + n_mounds = 1 + elif visibility == "nearly_hidden": + n_mounds = rng.randint(2, 5) + (1 if uneven > 0.55 else 0) + else: + n_mounds = rng.randint(2, 4) + (1 if uneven > 0.65 else 0) + + if visibility == "nearly_hidden": + span = 0.22 + 0.28 * uneven + cover = 0.85 + 0.55 * uneven + else: + span = 0.14 + 0.22 * uneven + cover = 0.4 + 0.45 * uneven + + # Один холм — шире/выше; несколько — компактнее и разнесены. + for i in range(n_mounds): + if n_mounds == 1: + t_norm = rng.uniform(-span * 0.55, span * 0.55) + else: + t_norm = -span + (2.0 * span) * (i + 0.5) / n_mounds + t_norm += rng.uniform(-span * 0.12, span * 0.12) + t_norm = max(-span, min(span, t_norm)) + lateral = rng.uniform(-radius * (1.0 + uneven), radius * (1.0 + uneven)) + px, py = centerline_xy(t_norm) + sx = px + nx * lateral + sy = py + ny * lateral + local_floor = _height_at(sx, sy, meta) + neighbor = _height_at(sx + radius * 2, sy + radius * 2, meta) + local_relief = abs(local_floor - neighbor) / max(radius, 1e-3) + local_factor = 0.75 + min(0.55, 0.35 * local_relief + 0.25 * uneven) + size_boost = 1.35 if n_mounds == 1 else 1.0 + height = radius * cover * local_factor * size_boost * rng.uniform(0.85, 1.2) + mound_r = max( + radius * rng.uniform(2.8, 4.8) * (1.35 if n_mounds == 1 else 1.0), + min_span * rng.uniform(0.05, 0.1) * (0.85 + 0.3 * uneven), + ) + silt_bumps.append((sx, sy, height, mound_r)) + else: + cover = 0.0 + + if silt_bumps: + meta.setdefault("bumps", []).extend(silt_bumps) + + info = { + "visibility": visibility, + "kind": "pipe", + "transform": { + "x": cx, + "y": cy, + "z": 0.0, + "rx": 0.0, + "ry": 0.0, + "rz": yaw if along_x else (yaw + 0.5 * math.pi), + }, + "scale": base_scale, + "objectScale": base_scale, + "radius": radius, + "length": length, + "axis": "x" if along_x else "y", + "hasBend": False, + "bendAmp": 0.0, + "axisZ": round(axis_z, 4), + "burial": 0.0, + "terrainUnevenness": round(uneven, 4), + "siltMode": silt_mode, + "siltCover": round(cover, 4), + "siltBumpCount": len(silt_bumps), + "vertexCount": len(world), + "classLabel": "object", + "classId": 1, + } + return world, info + + def _rasterize_object_hits( world_pts: list[list[float]], meta: dict[str, Any], @@ -368,18 +578,72 @@ def _cast_echosounder_returns( # Balance plan + single scene # --------------------------------------------------------------------------- -def plan_scene_labels(count: int, seed: int) -> list[str]: +def _allocate_by_weights(total: int, weights: list[float]) -> list[int]: + """Split ``total`` into integer buckets proportional to non-negative weights.""" + n = len(weights) + if total <= 0 or n == 0: + return [0] * n + w = [max(0.0, float(x)) for x in weights] + s = sum(w) + if s <= 0: + out = [0] * n + out[0] = total + return out + raw = [total * wi / s for wi in w] + floors = [int(math.floor(r)) for r in raw] + rem = total - sum(floors) + order = sorted(range(n), key=lambda i: (raw[i] - floors[i], -i), reverse=True) + for i in range(rem): + floors[order[i % n]] += 1 + return floors + + +def _clamp_pct(value: float, *, name: str) -> float: + v = float(value) + if not math.isfinite(v): + raise ValueError(f"{name} must be a finite number") + if v < 0 or v > 100: + raise ValueError(f"{name} must be in [0, 100], got {v}") + return v + + +def plan_scene_labels( + count: int, + seed: int, + *, + absent_pct: float = 30.0, + nearly_hidden_pct: float = 20.0, + partial_pct: float = 40.0, + visible_pct: float = 40.0, +) -> list[str]: """Return visibility label per scene: absent | nearly_hidden | partial | visible. - ~50% absent; among present scenes, roughly equal nearly_hidden/partial/visible. + Defaults: 30% absent / 70% with object. Of scenes with an object: + 20% nearly_hidden, 40% partial, 40% visible. + Visibility percents are relative to with-object scenes (normalized if needed). """ count = max(0, int(count)) + absent_pct = _clamp_pct(absent_pct, name="absent_pct") + nearly_hidden_pct = _clamp_pct(nearly_hidden_pct, name="nearly_hidden_pct") + partial_pct = _clamp_pct(partial_pct, name="partial_pct") + visible_pct = _clamp_pct(visible_pct, name="visible_pct") + + n_without, n_with = _allocate_by_weights(count, [absent_pct, 100.0 - absent_pct]) + tier_weights = [nearly_hidden_pct, partial_pct, visible_pct] + if n_with > 0 and sum(tier_weights) <= 0: + raise ValueError( + "Visibility percents among with-object scenes must sum to > 0 " + "when there are scenes with an object." + ) + n_nearly, n_partial, n_visible = _allocate_by_weights(n_with, tier_weights) + + labels: list[str] = ( + ["absent"] * n_without + + ["nearly_hidden"] * n_nearly + + ["partial"] * n_partial + + ["visible"] * n_visible + ) rng = random.Random(int(seed) ^ 0xA5A5_5A5A) - n_with = (count + 1) // 2 # ceil → ~50% with object - n_without = count - n_with - labels: list[str] = ["absent"] * n_without - for i in range(n_with): - labels.append(VISIBILITY_TIERS[i % 3]) rng.shuffle(labels) return labels @@ -390,6 +654,7 @@ def generate_sonar_scene( visibility: str = "absent", object_points: list[list[float]] | None = None, object_scale: float = 1.0, + object_kind: str | None = None, beam_count: int = 45, length_count: int | None = None, ) -> dict[str, Any]: @@ -397,11 +662,13 @@ def generate_sonar_scene( visibility in absent|nearly_hidden|partial|visible. Scene size is always beam_count × length_count returns. + object_kind ``pipe`` places a border-to-border pipeline with optional silt. """ rng = random.Random(int(seed)) if visibility not in ("absent",) + VISIBILITY_TIERS: raise ValueError(f"Unknown visibility: {visibility}") - if visibility != "absent" and not object_points: + kind = (object_kind or "").strip().lower() or None + if visibility != "absent" and kind != "pipe" and not object_points: raise ValueError("object_points required when visibility is not absent.") meta = _build_seafloor_meta(rng, beam_count=beam_count, length_count=length_count) @@ -410,13 +677,21 @@ def generate_sonar_scene( object_info: dict[str, Any] | None = None object_hits: dict[tuple[int, int], float] | None = None if visibility != "absent": - world, object_info = _place_object_in_scene( - rng, - meta, - visibility, - object_points, - object_scale=object_scale, - ) + if kind == "pipe": + world, object_info = _place_pipeline_in_scene( + rng, + meta, + visibility, + object_scale=object_scale, + ) + else: + world, object_info = _place_object_in_scene( + rng, + meta, + visibility, + object_points, + object_scale=object_scale, + ) object_hits = _rasterize_object_hits(world, meta) object_info["hitCellCount"] = len(object_hits) object_info["keptCount"] = len(object_hits) @@ -533,6 +808,10 @@ def build_run_manifest( object_vertex_count: int, stats: dict[str, Any], written: list[dict[str, Any]], + absent_pct: float = 30.0, + nearly_hidden_pct: float = 20.0, + partial_pct: float = 40.0, + visible_pct: float = 40.0, ) -> dict[str, Any]: model_filename = _normalize_model_filename(object_name) return { @@ -551,6 +830,10 @@ def build_run_manifest( "beamCount": int(beam_count), "lengthCount": int(length_count), "objectName": model_filename, + "absentPct": float(absent_pct), + "nearlyHiddenPct": float(nearly_hidden_pct), + "partialPct": float(partial_pct), + "visiblePct": float(visible_pct), }, "objectVertexCount": int(object_vertex_count), "stats": stats, @@ -850,12 +1133,17 @@ def iter_generate_dataset( count: int = 5, seed: int = 42, output_dir: str | Path = "sonar_dataset", - object_points: list[list[float]], + object_points: list[list[float]] | None = None, object_name: str | None = None, + object_kind: str | None = None, object_scale: float = 1.0, object_scale_is_max: bool = False, beam_count: int = 45, length_count: int | None = None, + absent_pct: float = 30.0, + nearly_hidden_pct: float = 20.0, + partial_pct: float = 40.0, + visible_pct: float = 40.0, ): """Yield NDJSON-friendly progress events, then a final ``done`` payload. @@ -869,8 +1157,13 @@ def iter_generate_dataset( raise ValueError("count must be >= 1") if count > 5000: raise ValueError("count must be <= 5000") - if not object_points or len(object_points) < 3: - raise ValueError("A valid .obj model with at least 3 vertices is required.") + kind = (object_kind or "").strip().lower() or None + if kind == "pipe": + template: list[list[float]] = [] + else: + if not object_points or len(object_points) < 3: + raise ValueError("A valid .obj model with at least 3 vertices is required.") + template = normalize_object_points(object_points) object_scale = float(object_scale) if object_scale <= 0: raise ValueError("object_scale must be > 0") @@ -890,13 +1183,24 @@ def iter_generate_dataset( if length_count > 1024: raise ValueError("length_count (Длина) must be <= 1024") + absent_pct = _clamp_pct(absent_pct, name="absent_pct") + nearly_hidden_pct = _clamp_pct(nearly_hidden_pct, name="nearly_hidden_pct") + partial_pct = _clamp_pct(partial_pct, name="partial_pct") + visible_pct = _clamp_pct(visible_pct, name="visible_pct") + object_name = _normalize_model_filename(object_name) base = resolve_output_dir(output_dir) run_dir = make_generation_run_dir(base, object_name) - template = normalize_object_points(object_points) - labels = plan_scene_labels(count, seed) + labels = plan_scene_labels( + count, + seed, + absent_pct=absent_pct, + nearly_hidden_pct=nearly_hidden_pct, + partial_pct=partial_pct, + visible_pct=visible_pct, + ) written: list[dict[str, Any]] = [] stats = { "total": count, @@ -911,6 +1215,7 @@ def iter_generate_dataset( preview_points: list[list[float]] | None = None preview_stem: str | None = None preview_has_object = False + object_vertex_count = len(template) yield { "type": "start", @@ -936,9 +1241,12 @@ def iter_generate_dataset( visibility=visibility, object_points=template, object_scale=scene_scale, + object_kind=kind, beam_count=beam_count, length_count=length_count, ) + if scene.get("object") and scene["object"].get("vertexCount"): + object_vertex_count = int(scene["object"]["vertexCount"]) area, scene_no, stem = scene_index_to_area_name(i) paths = write_scene_files(scene, run_dir, stem) @@ -995,9 +1303,14 @@ def iter_generate_dataset( "beamCount": beam_count, "lengthCount": length_count, "objectName": object_name, + "objectKind": kind, "objectScale": object_scale, "objectScaleIsMax": object_scale_is_max, - "objectVertexCount": len(template), + "objectVertexCount": object_vertex_count, + "absentPct": absent_pct, + "nearlyHiddenPct": nearly_hidden_pct, + "partialPct": partial_pct, + "visiblePct": visible_pct, "classLabels": {"0": "background", "1": "object"}, "stats": stats, "written": written, @@ -1017,6 +1330,10 @@ def iter_generate_dataset( object_vertex_count=len(template), stats=stats, written=written, + absent_pct=absent_pct, + nearly_hidden_pct=nearly_hidden_pct, + partial_pct=partial_pct, + visible_pct=visible_pct, ) write_run_manifest(run_dir, manifest) result["settingsPath"] = str(run_dir / DATASET_RUN_FILENAME) @@ -1028,12 +1345,17 @@ def generate_dataset( count: int = 5, seed: int = 42, output_dir: str | Path = "sonar_dataset", - object_points: list[list[float]], + object_points: list[list[float]] | None = None, object_name: str | None = None, + object_kind: str | None = None, object_scale: float = 1.0, object_scale_is_max: bool = False, beam_count: int = 45, length_count: int | None = None, + absent_pct: float = 30.0, + nearly_hidden_pct: float = 20.0, + partial_pct: float = 40.0, + visible_pct: float = 40.0, ) -> dict[str, Any]: """Generate `count` unique scenes into a new timestamped run folder under output_dir.""" result: dict[str, Any] | None = None @@ -1043,10 +1365,15 @@ def generate_dataset( output_dir=output_dir, object_points=object_points, object_name=object_name, + object_kind=object_kind, object_scale=object_scale, object_scale_is_max=object_scale_is_max, beam_count=beam_count, length_count=length_count, + absent_pct=absent_pct, + nearly_hidden_pct=nearly_hidden_pct, + partial_pct=partial_pct, + visible_pct=visible_pct, ): if event.get("type") == "done": result = event["result"] diff --git a/backend/main.py b/backend/main.py index 5fe4704..e68553d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -22,7 +22,10 @@ from dataset_generator import ( iter_generate_dataset, list_dataset_runs, load_dataset_run, + list_object_presets, load_object_points_from_obj_text, + load_preset_object_points, + OBJECT_PRESETS, load_scene_preview, resolve_output_dir, ) @@ -137,11 +140,18 @@ class DatasetLoadBody(BaseModel): class MlePrepareBody(BaseModel): seed: int = 42 - sizeX: float = 40.0 - sizeY: float = 60.0 + sizeX: float | None = None + sizeY: float | None = None resX: int = 80 resY: int = 120 outputDir: str = "mle_runs" + reliefScalePct: float = 20.0 + auvX: float | None = None + auvY: float | None = None + auvHeadingDeg: float | None = None + surveyLength: float | None = None + auvDepth: float | None = None + swathAngleDeg: float | None = None settings: dict[str, Any] | None = None @@ -475,6 +485,11 @@ def generator_resolve_intersections(body: GeneratorResolveBody) -> dict[str, Any raise HTTPException(status_code=400, detail=str(exc)) from exc +@app.get("/api/dataset/object-presets") +def dataset_object_presets() -> list[dict[str, Any]]: + return list_object_presets() + + @app.post("/api/dataset/generate") async def dataset_generate( count: int = Form(5), @@ -484,15 +499,36 @@ async def dataset_generate( objectScaleIsMax: bool = Form(False), beamCount: int = Form(45), lengthCount: int | None = Form(None), - model: UploadFile = File(...), + absentPct: float = Form(30.0), + nearlyHiddenPct: float = Form(20.0), + partialPct: float = Form(40.0), + visiblePct: float = Form(40.0), + modelPreset: str | None = Form(None), + model: UploadFile | None = File(None), ) -> StreamingResponse: - filename = (model.filename or "").strip() - if not filename.lower().endswith(".obj"): - raise HTTPException(status_code=400, detail="Upload a .obj 3D model file.") + preset = (modelPreset or "").strip().lower() + filename = ((model.filename if model else None) or "").strip() + object_kind: str | None = None + object_points: list[list[float]] | None = None try: - raw = await model.read() - text = raw.decode("utf-8", errors="ignore") - object_points = load_object_points_from_obj_text(text) + if preset: + if preset not in OBJECT_PRESETS: + known = ", ".join(sorted(OBJECT_PRESETS)) or "(none)" + raise ValueError(f"Unknown object preset '{preset}'. Known: {known}") + object_kind = preset + filename = str(OBJECT_PRESETS[preset]["filename"]) + if preset != "pipe": + object_points, filename = load_preset_object_points(preset) + elif model is not None and filename: + if not filename.lower().endswith(".obj"): + raise ValueError("Upload a .obj 3D model file.") + raw = await model.read() + text = raw.decode("utf-8", errors="ignore") + object_points = load_object_points_from_obj_text(text) + else: + raise ValueError( + "Выберите предустановку объекта или загрузите файл модели .obj." + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -504,10 +540,15 @@ async def dataset_generate( output_dir=outputDir or "sonar_dataset", object_points=object_points, object_name=filename, + object_kind=object_kind, object_scale=objectScale, object_scale_is_max=objectScaleIsMax, beam_count=beamCount, length_count=lengthCount, + absent_pct=absentPct, + nearly_hidden_pct=nearlyHiddenPct, + partial_pct=partialPct, + visible_pct=visiblePct, ): yield json.dumps(event, ensure_ascii=False) + "\n" except ValueError as exc: @@ -802,6 +843,14 @@ def mle_spa() -> FileResponse: @app.post("/api/mle/prepare") def mle_prepare(body: MlePrepareBody) -> dict[str, Any]: try: + corridor = { + "auvX": body.auvX, + "auvY": body.auvY, + "headingDeg": body.auvHeadingDeg, + "surveyLength": body.surveyLength, + "auvDepth": body.auvDepth, + "swathAngleDeg": body.swathAngleDeg, + } result = prepare_mle_scene( seed=body.seed, size_x=body.sizeX, @@ -810,6 +859,8 @@ def mle_prepare(body: MlePrepareBody) -> dict[str, Any]: res_y=body.resY, output_dir=body.outputDir or "mle_runs", settings=body.settings, + relief_scale_pct=body.reliefScalePct, + corridor=corridor, ) if body.settings: try: diff --git a/backend/mle_simulator.py b/backend/mle_simulator.py index 7e5dd37..f991cfa 100644 --- a/backend/mle_simulator.py +++ b/backend/mle_simulator.py @@ -81,42 +81,103 @@ def _height_at( def build_seafloor_params( seed: int = 42, *, - size_x: float = 40.0, - size_y: float = 60.0, + size_x: float | None = None, + size_y: float | None = None, + relief_scale_pct: float = 20.0, + corridor: dict[str, Any] | None = None, ) -> dict[str, Any]: + """Build seafloor heightfield params. + + When ``corridor`` is provided (AUV survey swath), random unevenness is placed + only inside that yellow echosounder strip. Feature size scales with + ``relief_scale_pct`` percent of the strip width. + """ rng = random.Random(int(seed)) - size_x = max(4.0, float(size_x)) - size_y = max(4.0, float(size_y)) + relief_scale_pct = max(1.0, min(100.0, float(relief_scale_pct))) + + corr = corridor if isinstance(corridor, dict) else {} + corr = {k: v for k, v in corr.items() if v is not None} + auv_x = float(corr.get("auvX", 0.0)) + auv_y = float(corr.get("auvY", 0.0)) + heading_deg = float(corr.get("headingDeg", 0.0)) + survey_length = max(1.0, float(corr.get("surveyLength", 40.0))) + auv_depth = max(0.3, float(corr.get("auvDepth", 2.5))) + swath_deg = max(5.0, min(170.0, float(corr.get("swathAngleDeg", 90.0)))) + + half_swath = auv_depth * math.tan(math.radians(swath_deg) * 0.5) + strip_width = max(4.0, 2.0 * half_swath) + half_w = strip_width * 0.5 + heading = math.radians(heading_deg) + hx, hy = math.cos(heading), math.sin(heading) + nx, ny = -math.sin(heading), math.cos(heading) + + # AABB of the survey strip (+ padding) → terrain extent around origin. + pad = strip_width * 0.6 + 8.0 + xs: list[float] = [] + ys: list[float] = [] + for t in (0.0, survey_length): + for lat in (-half_w, half_w): + xs.append(auv_x + hx * t + nx * lat) + ys.append(auv_y + hy * t + ny * lat) + reach = max( + max(abs(v) for v in xs) + pad, + max(abs(v) for v in ys) + pad, + strip_width + 12.0, + survey_length * 0.35 + 12.0, + ) + auto_size = max(24.0, reach * 2.0) + size_x = max(4.0, float(size_x) if size_x is not None else auto_size) + size_y = max(4.0, float(size_y) if size_y is not None else auto_size) + + # Mild background undulation (not the main corridor features). 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), + amplitude = rng.uniform(0.05, 0.18) + frequency = rng.uniform(0.08, 0.25) + + # Characteristic feature size = pct of yellow strip width. + feature_scale = strip_width * (relief_scale_pct / 100.0) + feature_scale = max(0.15, feature_scale) + + def point_in_strip(t: float, lat: float) -> tuple[float, float]: + return ( + auv_x + hx * t + nx * lat, + auv_y + hy * t + ny * lat, ) - 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)) - ] + + n_hills = rng.randint(2, 4) + n_valleys = rng.randint(1, 3) + # More bumps when features are smaller so the strip stays filled. + density = max(0.35, min(1.6, 20.0 / max(relief_scale_pct, 1.0))) + n_bumps = int(round(rng.uniform(10, 18) * density)) + n_bumps = max(6, min(36, n_bumps)) + + hills = [] + for _ in range(n_hills): + t = rng.uniform(0.0, survey_length) + lat = rng.uniform(-half_w * 0.85, half_w * 0.85) + x, y = point_in_strip(t, lat) + rad = feature_scale * rng.uniform(0.9, 1.8) + amp = feature_scale * rng.uniform(0.25, 0.7) + hills.append((x, y, amp, rad)) + + valleys = [] + for _ in range(n_valleys): + t = rng.uniform(0.0, survey_length) + lat = rng.uniform(-half_w * 0.85, half_w * 0.85) + x, y = point_in_strip(t, lat) + rad = feature_scale * rng.uniform(0.8, 1.6) + amp = feature_scale * rng.uniform(0.18, 0.5) + valleys.append((x, y, amp, rad)) + + bumps = [] + for _ in range(n_bumps): + t = rng.uniform(0.0, survey_length) + lat = rng.uniform(-half_w * 0.98, half_w * 0.98) + x, y = point_in_strip(t, lat) + rad = feature_scale * rng.uniform(0.35, 1.15) + amp = feature_scale * rng.uniform(0.1, 0.4) + bumps.append((x, y, amp, rad)) + return { "seed": int(seed), "sizeX": size_x, @@ -127,6 +188,17 @@ def build_seafloor_params( "hills": hills, "valleys": valleys, "bumps": bumps, + "reliefScalePct": relief_scale_pct, + "stripWidth": strip_width, + "corridor": { + "auvX": auv_x, + "auvY": auv_y, + "headingDeg": heading_deg, + "surveyLength": survey_length, + "auvDepth": auv_depth, + "swathAngleDeg": swath_deg, + "stripWidth": strip_width, + }, } @@ -251,12 +323,14 @@ def save_survey_surface( def prepare_mle_scene( *, seed: int = 42, - size_x: float = 40.0, - size_y: float = 60.0, + size_x: float | None = None, + size_y: float | None = None, res_x: int = 80, res_y: int = 120, output_dir: str | Path = "mle_runs", settings: dict[str, Any] | None = None, + relief_scale_pct: float = 20.0, + corridor: 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) @@ -269,7 +343,33 @@ def prepare_mle_scene( n += 1 run_dir.mkdir(parents=True, exist_ok=False) - params = build_seafloor_params(seed, size_x=size_x, size_y=size_y) + # Prefer explicit corridor fields; fill gaps from settings snapshot. + corr: dict[str, Any] = {} + if isinstance(corridor, dict): + corr.update({k: v for k, v in corridor.items() if v is not None}) + if isinstance(settings, dict): + auv = settings.get("auv") if isinstance(settings.get("auv"), dict) else {} + beams = settings.get("beams") if isinstance(settings.get("beams"), dict) else {} + motion = settings.get("motion") if isinstance(settings.get("motion"), dict) else {} + defaults_from_settings = { + "auvX": auv.get("x", settings.get("auvX")), + "auvY": auv.get("y", settings.get("auvY")), + "headingDeg": auv.get("headingDeg", settings.get("auvHeadingDeg")), + "surveyLength": motion.get("surveyLength", settings.get("surveyLength")), + "auvDepth": auv.get("depth", settings.get("auvDepth")), + "swathAngleDeg": beams.get("swathAngleDeg", settings.get("swathAngleDeg")), + } + for key, value in defaults_from_settings.items(): + if corr.get(key) is None and value is not None: + corr[key] = value + + params = build_seafloor_params( + seed, + size_x=size_x, + size_y=size_y, + relief_scale_pct=relief_scale_pct, + corridor=corr, + ) 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" diff --git a/frontend/web/src/api/client.js b/frontend/web/src/api/client.js index c612742..d0abd8d 100644 --- a/frontend/web/src/api/client.js +++ b/frontend/web/src/api/client.js @@ -297,11 +297,19 @@ export const api = { objectScaleIsMax = false, beamCount = 45, lengthCount = 45, - modelFile, + absentPct = 30, + nearlyHiddenPct = 20, + partialPct = 40, + visiblePct = 40, + modelPreset = null, + modelFile = null, onProgress, } = {}) => { - if (!modelFile) { - return Promise.reject(new Error("Выберите файл модели .obj")); + const preset = modelPreset ? String(modelPreset).trim() : ""; + if (!preset && !modelFile) { + return Promise.reject( + new Error("Выберите предустановку объекта или загрузите файл модели .obj"), + ); } const formData = new FormData(); formData.append("count", String(count)); @@ -311,7 +319,15 @@ export const api = { formData.append("objectScaleIsMax", objectScaleIsMax ? "true" : "false"); formData.append("beamCount", String(beamCount ?? 45)); formData.append("lengthCount", String(lengthCount ?? 45)); - formData.append("model", modelFile, modelFile.name || "model.obj"); + formData.append("absentPct", String(absentPct ?? 30)); + formData.append("nearlyHiddenPct", String(nearlyHiddenPct ?? 20)); + formData.append("partialPct", String(partialPct ?? 40)); + formData.append("visiblePct", String(visiblePct ?? 40)); + if (preset) { + formData.append("modelPreset", preset); + } else { + formData.append("model", modelFile, modelFile.name || "model.obj"); + } const response = await fetch("/api/dataset/generate", { method: "POST", @@ -397,17 +413,39 @@ export const api = { }), mlePrepare: ({ seed = 42, - sizeX = 40, - sizeY = 60, + sizeX = null, + sizeY = null, resX = 80, resY = 120, outputDir = "mle_runs", + reliefScalePct = 20, + auvX = null, + auvY = null, + auvHeadingDeg = null, + surveyLength = null, + auvDepth = null, + swathAngleDeg = null, settings = null, } = {}) => request("/api/mle/prepare", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ seed, sizeX, sizeY, resX, resY, outputDir, settings }), + body: JSON.stringify({ + seed, + sizeX, + sizeY, + resX, + resY, + outputDir, + reliefScalePct, + auvX, + auvY, + auvHeadingDeg, + surveyLength, + auvDepth, + swathAngleDeg, + settings, + }), }), mleSaveSurface: ({ outputDir, vertices, faces, filename = "seafloor.obj" } = {}) => request("/api/mle/save-surface", { diff --git a/frontend/web/src/composables/useMleSimulator.js b/frontend/web/src/composables/useMleSimulator.js index f721206..00e5568 100644 --- a/frontend/web/src/composables/useMleSimulator.js +++ b/frontend/web/src/composables/useMleSimulator.js @@ -73,6 +73,7 @@ export function useMleSimulator(containerRef, surveyRef) { // —— main scene —— const SCENE_BG = 0x1a3d38; const SEAFLOOR_COLOR = 0x3f7a62; + const RELIEF_COLOR = 0xffc107; // bright gold for path unevenness const RAY_COLOR = 0xffb020; const scene = new THREE.Scene(); scene.background = new THREE.Color(SCENE_BG); @@ -115,6 +116,17 @@ export function useMleSimulator(containerRef, surveyRef) { let animationId = 0; let seafloorMesh = null; // unused for draw; height-field used for hits let seafloorGrid = null; + let pathReliefMesh = null; + let pathReliefEdges = null; + let lastSeafloorPayload = null; + let surveyCorridor = { + auvX: 0, + auvY: -20, + headingDeg: 0, + surveyLength: 40, + swathAngleDeg: 90, + auvDepth: 2.5, + }; let auvPivot = null; let objectPivot = null; let rayLines = null; @@ -190,15 +202,257 @@ export function useMleSimulator(containerRef, surveyRef) { }); } - function buildSeafloor(payload) { + function featureReliefAbs(x, y, params) { + if (!params) return 0; + let z = 0; + const bumpLists = [ + [params.hills || [], 1], + [params.valleys || [], -1], + [params.bumps || [], 1], + ]; + for (const [list, sign] of bumpLists) { + for (const item of list) { + const cx = Number(item[0]); + const cy = Number(item[1]); + const amp = Number(item[2]); + const rad = Math.max(Number(item[3]) || 0.1, 1e-6); + const d2 = (x - cx) ** 2 + (y - cy) ** 2; + if (d2 < rad * rad * 4) { + z += sign * amp * Math.exp(-d2 / (rad * rad)); + } + } + } + return Math.abs(z); + } + + function setSurveyCorridor(partial = {}) { + surveyCorridor = { ...surveyCorridor, ...partial }; + } + + function localRoughness(x, y) { + if (!meshInfo) return 0; + const z0 = sampleHeight(meshInfo, x, y); + const eps = 0.8; + return ( + Math.abs(sampleHeight(meshInfo, x + eps, y) - z0) + + Math.abs(sampleHeight(meshInfo, x - eps, y) - z0) + + Math.abs(sampleHeight(meshInfo, x, y + eps) - z0) + + Math.abs(sampleHeight(meshInfo, x, y - eps) - z0) + ); + } + + function reliefIntensity(x, y) { + const params = meshInfo?.params || lastSeafloorPayload?.params || {}; + const feature = featureReliefAbs(x, y, params); + const rough = localRoughness(x, y); + // Emphasize discrete bumps/hills and local slope along the future track. + return Math.min(1, feature / 0.45 + rough / 0.55); + } + + function clearPathRelief() { + clearObject(pathReliefMesh); + clearObject(pathReliefEdges); + pathReliefMesh = null; + pathReliefEdges = null; + } + + /** + * Preview of seafloor/object contacts the AUV will meet along the survey track. + * Starts at current echosounder footprint and extends forward by surveyLength. + */ + function buildPathReliefPreview() { + clearPathRelief(); + if (running || !auvPivot || !meshInfo) return; + + const length = Math.max(1, Number(surveyLength) || 40); + const nAlong = Math.max(28, Math.min(120, Math.ceil(length / 0.9) + 1)); + const nAcross = Math.max(24, Math.min(96, Math.max(Number(beamCount) || 45, 32))); + const swath = degToRad(Math.max(5, Math.min(170, Number(swathAngleDeg) || 90))); + const maxRange = Math.max(1, Number(detectionRangeM) || DETECTION_RANGE_DEFAULT); + const hx = Math.cos(headingRad); + const hy = Math.sin(headingRad); + const acrossX = -Math.sin(headingRad); + const acrossY = Math.cos(headingRad); + const startX = auvPivot.position.x; + const startY = auvPivot.position.y; + const depth = Math.max(0.3, Number(auvDepth) || 2.5); + + // Station 0: real beam hits (where rays touch seafloor/object now). + const currentHits = castBeamHits(); + const positions = new Float32Array(nAlong * nAcross * 3); + const colors = new Float32Array(nAlong * nAcross * 3); + const flat = new THREE.Color(0x2f6b52); + const mid = new THREE.Color(0xe6a820); + const hot = new THREE.Color(0xfff176); + const objTint = new THREE.Color(0xff6b2d); + + const writeVertex = (row, col, x, y, z, intensity, isObject) => { + const o = (row * nAcross + col) * 3; + positions[o] = x; + positions[o + 1] = y; + positions[o + 2] = z + 0.08; // slightly above so it reads over the wireframe + let c; + if (isObject) { + c = objTint; + } else if (intensity < 0.35) { + c = flat.clone().lerp(mid, intensity / 0.35); + } else { + c = mid.clone().lerp(hot, Math.min(1, (intensity - 0.35) / 0.65)); + } + // Keep the whole swath visible; boost alpha via brightness on relief. + colors[o] = c.r; + colors[o + 1] = c.g; + colors[o + 2] = c.b; + }; + + for (let col = 0; col < nAcross; col += 1) { + const u = nAcross === 1 ? 0.5 : col / (nAcross - 1); + if (currentHits.length) { + const src = currentHits[Math.min(currentHits.length - 1, Math.round(u * (currentHits.length - 1)))]; + const intensity = reliefIntensity(src.x, src.y); + writeVertex(0, col, src.x, src.y, src.z, Math.max(intensity, src.isObject ? 1 : 0.2), !!src.isObject); + } else { + const angle = -swath * 0.5 + swath * u; + const dirX = Math.sin(angle) * acrossX; + const dirY = Math.sin(angle) * acrossY; + const dirZ = -Math.cos(angle); + // fallback probe from AUV + let hitX = startX; + let hitY = startY; + let hitZ = sampleHeight(meshInfo, startX, startY); + const originZ = hitZ + depth; + const step = Math.max(0.3, maxRange / 180); + let px = startX; + let py = startY; + let pz = originZ; + for (let s = 0; s < 220; s += 1) { + px += dirX * step; + py += dirY * step; + pz += dirZ * step; + const floorZ = sampleHeight(meshInfo, px, py); + if (pz <= floorZ) { + hitX = px; + hitY = py; + hitZ = floorZ; + break; + } + } + writeVertex(0, col, hitX, hitY, hitZ, Math.max(0.15, reliefIntensity(hitX, hitY)), false); + } + } + + // Forward stations: predicted beam footprint along the future track. + for (let row = 1; row < nAlong; row += 1) { + const along = (row / (nAlong - 1)) * length; + const ax = startX + hx * along; + const ay = startY + hy * along; + const floorHere = sampleHeight(meshInfo, ax, ay); + const originZ = floorHere + depth; + for (let col = 0; col < nAcross; col += 1) { + const u = nAcross === 1 ? 0.5 : col / (nAcross - 1); + const angle = -swath * 0.5 + swath * u; + const dirX = Math.sin(angle) * acrossX; + const dirY = Math.sin(angle) * acrossY; + const dirZ = -Math.cos(angle); + let hitX = ax; + let hitY = ay; + let hitZ = floorHere; + let isObject = false; + const step = Math.max(0.3, maxRange / 180); + let px = ax; + let py = ay; + let pz = originZ; + for (let s = 0; s < 220; s += 1) { + px += dirX * step; + py += dirY * step; + pz += dirZ * step; + if (Math.hypot(px - ax, py - ay) + Math.abs(pz - originZ) > maxRange * 1.15) break; + const floorZ = sampleHeight(meshInfo, px, py); + if (pz <= floorZ) { + hitX = px; + hitY = py; + hitZ = floorZ; + break; + } + } + // Object occlusion preview: if object mesh is above floor along beam, tint as object. + if (objectPivot) { + const origin = new THREE.Vector3(ax, ay, originZ); + const dir = new THREE.Vector3(dirX, dirY, dirZ).normalize(); + raycaster.set(origin, dir); + raycaster.far = maxRange; + const intersects = raycaster.intersectObject(objectPivot, true); + if (intersects.length) { + const dFloor = Math.hypot(hitX - ax, hitY - ay, hitZ - originZ); + if (intersects[0].distance < dFloor) { + hitX = intersects[0].point.x; + hitY = intersects[0].point.y; + hitZ = intersects[0].point.z; + isObject = true; + } + } + } + writeVertex(row, col, hitX, hitY, hitZ, Math.max(0.12, reliefIntensity(hitX, hitY)), isObject); + } + } + + const indices = []; + for (let row = 0; row < nAlong - 1; row += 1) { + for (let col = 0; col < nAcross - 1; col += 1) { + const a = row * nAcross + col; + const b = a + 1; + const c = a + nAcross; + const d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + + pathReliefMesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + vertexColors: true, + metalness: 0.05, + roughness: 0.7, + transparent: true, + opacity: 0.88, + side: THREE.DoubleSide, + depthWrite: false, + }), + ); + pathReliefMesh.renderOrder = 2; + scene.add(pathReliefMesh); + + pathReliefEdges = new THREE.LineSegments( + new THREE.EdgesGeometry(geometry, 28), + new THREE.LineBasicMaterial({ + color: RELIEF_COLOR, + transparent: true, + opacity: 0.95, + }), + ); + pathReliefEdges.renderOrder = 3; + scene.add(pathReliefEdges); + } + + function buildSeafloor(payload, corridorOpts = null) { clearObject(seafloorMesh); clearObject(seafloorGrid); + clearPathRelief(); seafloorMesh = null; seafloorGrid = null; meshInfo = null; - if (!payload?.mesh?.heights?.length && !payload?.mesh?.vertices?.length) return; - const { heights, resX, resY } = payload.mesh; - const params = payload.params || {}; + if (payload) lastSeafloorPayload = payload; + if (corridorOpts) setSurveyCorridor(corridorOpts); + const src = payload || lastSeafloorPayload; + if (!src?.mesh?.heights?.length && !src?.mesh?.vertices?.length) return; + const { heights, resX, resY } = src.mesh; + const params = src.params || {}; const sizeX = Number(params.sizeX) || 40; const sizeY = Number(params.sizeY) || 60; meshInfo = { @@ -208,13 +462,13 @@ export function useMleSimulator(containerRef, surveyRef) { sizeX, sizeY, baseZ: params.baseZ, + params, }; // Lightweight terrain grid (LineSegments) — infinite skirt, low vertex count const pad = Math.max(sizeX, sizeY, detectionRangeM || 400) * 2.5; const extSizeX = sizeX + pad * 2; const extSizeY = sizeY + pad * 2; - // Coarse grid: enough to show relief, cheap to draw const nX = Math.min(64, Math.max(24, Math.round(extSizeX / Math.max(sizeX / 16, 4)) + 1)); const nY = Math.min(64, Math.max(24, Math.round(extSizeY / Math.max(sizeY / 16, 4)) + 1)); const halfX = extSizeX * 0.5; @@ -231,7 +485,6 @@ export function useMleSimulator(containerRef, surveyRef) { pts[o + 2] = z; } } - // Horizontal + vertical polylines as segments const segCount = nY * (nX - 1) + nX * (nY - 1); const linePos = new Float32Array(segCount * 2 * 3); let w = 0; @@ -258,7 +511,7 @@ export function useMleSimulator(containerRef, surveyRef) { new THREE.LineBasicMaterial({ color: SEAFLOOR_COLOR, transparent: true, - opacity: 0.9, + opacity: 0.85, }), ); scene.add(seafloorGrid); @@ -557,6 +810,29 @@ export function useMleSimulator(containerRef, surveyRef) { return normalizeModel(g, Math.max(0.05, Number(sizeM) || 2)); } + /** Straight horizontal cylinder: sizeM = diameter, lengthM = length along local X. */ + function makePipelineObject(sizeM = 2, lengthM = 20) { + const diameter = Math.max(0.05, Number(sizeM) || 2); + const length = Math.max(0.1, Number(lengthM) || 20); + const radius = diameter * 0.5; + const mesh = new THREE.Mesh( + new THREE.CylinderGeometry(radius, radius, length, 36, 1, false), + new THREE.MeshStandardMaterial({ + color: 0xf59e0b, + metalness: 0.28, + roughness: 0.55, + flatShading: false, + }), + ); + // Default cylinder axis is Y → rotate onto X (horizontal pipeline). + mesh.rotation.z = Math.PI / 2; + mesh.castShadow = true; + mesh.receiveShadow = true; + const g = new THREE.Group(); + g.add(mesh); + return g; + } + async function setAuvModel(file, sizeM = 10, x = 0, y = 0, depth = 2.5, headingDeg = 0) { clearObject(auvPivot); auvPivot = new THREE.Group(); @@ -573,17 +849,43 @@ export function useMleSimulator(containerRef, surveyRef) { placeAuv(x, y, depth, headingDeg); } - async function setObjectModel(file, sizeM = 2, x = 0, y = 0, zOffset = 0, rotXDeg = 0, rotYDeg = 0, rotZDeg = 0) { + let lastObjectFile = null; + let lastObjectKind = "file"; + let lastObjectSizeM = 2; + let lastObjectLengthM = 20; + + async function setObjectModel( + file, + sizeM = 2, + x = 0, + y = 0, + zOffset = 0, + rotXDeg = 0, + rotYDeg = 0, + rotZDeg = 0, + options = {}, + ) { clearObject(objectPivot); objectPivot = new THREE.Group(); const targetSize = Math.max(0.05, Number(sizeM) || 2); + const kind = options.kind === "pipe" ? "pipe" : "file"; + const lengthM = Math.max(0.1, Number(options.lengthM) || 20); + lastObjectFile = file || null; + lastObjectKind = kind; + lastObjectSizeM = targetSize; + lastObjectLengthM = lengthM; + let model = null; - try { - model = await loadObjFile(file, targetSize); - } catch { - model = null; + if (kind === "pipe") { + model = makePipelineObject(targetSize, lengthM); + } else { + try { + model = await loadObjFile(file, targetSize); + } catch { + model = null; + } + if (!model) model = makeFallbackObject(targetSize); } - if (!model) model = makeFallbackObject(targetSize); objectPivot.add(model); scene.add(objectPivot); placeObject(x, y, zOffset, rotXDeg, rotYDeg, rotZDeg); @@ -718,6 +1020,8 @@ export function useMleSimulator(containerRef, surveyRef) { appendSurveyStrip(hits); void persistSurveySurface(false); } + } else { + buildPathReliefPreview(); } } @@ -822,8 +1126,6 @@ export function useMleSimulator(containerRef, surveyRef) { async function applyScene({ seafloorPayload, auvFile, objectFile, params }) { runOutputDir = seafloorPayload?.outputDir || null; - buildSeafloor(seafloorPayload); - resetSurveySurface(); beamCount = params.beamCount ?? 45; swathAngleDeg = params.swathAngleDeg ?? 90; detectionRangeM = Math.max(1, Number(params.detectionRangeM) || DETECTION_RANGE_DEFAULT); @@ -831,6 +1133,15 @@ export function useMleSimulator(containerRef, surveyRef) { speed = Math.max(0.05, Number(params.speed) || 1.5); surveyLength = Math.max(1, Number(params.surveyLength) || 40); auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5); + buildSeafloor(seafloorPayload, { + auvX: params.auvX, + auvY: params.auvY, + headingDeg: params.auvHeadingDeg, + surveyLength, + swathAngleDeg, + auvDepth, + }); + resetSurveySurface(); await setAuvModel( auvFile, params.auvSizeM, @@ -848,6 +1159,10 @@ export function useMleSimulator(containerRef, surveyRef) { params.objectRotXDeg, params.objectRotYDeg, params.objectRotZDeg ?? params.objectYawDeg, + { + kind: params.objectKind, + lengthM: params.objectLengthM, + }, ); trailPoints.length = 0; traveled = 0; @@ -858,6 +1173,7 @@ export function useMleSimulator(containerRef, surveyRef) { function start() { if (!auvPivot || !meshInfo) return false; resetSurveySurface(); + clearPathRelief(); running = true; lastTs = 0; traveled = 0; @@ -869,6 +1185,7 @@ export function useMleSimulator(containerRef, surveyRef) { function stop() { running = false; void persistSurveySurface(true); + buildPathReliefPreview(); } function isRunning() { @@ -891,6 +1208,27 @@ export function useMleSimulator(containerRef, surveyRef) { if (params.surveyLength != null) surveyLength = Math.max(1, Number(params.surveyLength) || 40); if (params.auvDepth != null) auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5); + const corridorChanged = + params.auvX != null || + params.auvY != null || + params.auvHeadingDeg != null || + params.surveyLength != null || + params.swathAngleDeg != null || + params.auvDepth != null; + if (corridorChanged) { + setSurveyCorridor({ + auvX: params.auvX ?? surveyCorridor.auvX, + auvY: params.auvY ?? surveyCorridor.auvY, + headingDeg: params.auvHeadingDeg ?? surveyCorridor.headingDeg, + surveyLength, + swathAngleDeg, + auvDepth, + }); + if (lastSeafloorPayload && !running) { + buildPathReliefPreview(); + } + } + let moved = false; if (!running && auvPivot && (params.auvX != null || params.auvY != null || params.auvDepth != null || params.auvHeadingDeg != null)) { placeAuv( @@ -927,6 +1265,43 @@ export function useMleSimulator(containerRef, surveyRef) { if (!running) updateRays(false); moved = true; } + + const nextKind = params.objectKind === "pipe" ? "pipe" : params.objectKind != null ? "file" : null; + const nextSize = + params.objectSizeM != null ? Math.max(0.05, Number(params.objectSizeM) || 2) : null; + const nextLen = + params.objectLengthM != null ? Math.max(0.1, Number(params.objectLengthM) || 20) : null; + const rebuildObject = + objectPivot && + meshInfo && + !running && + ((nextKind != null && nextKind !== lastObjectKind) || + (nextSize != null && Math.abs(nextSize - lastObjectSizeM) > 1e-6) || + (nextLen != null && Math.abs(nextLen - lastObjectLengthM) > 1e-6)); + if (rebuildObject) { + void setObjectModel( + lastObjectFile, + nextSize ?? lastObjectSizeM, + params.objectX ?? objectPivot.position.x, + params.objectY ?? objectPivot.position.y, + params.objectZ != null ? params.objectZ : lastObjectZ, + params.objectRotXDeg != null ? params.objectRotXDeg : lastObjectRotXDeg, + params.objectRotYDeg != null ? params.objectRotYDeg : lastObjectRotYDeg, + params.objectRotZDeg != null + ? params.objectRotZDeg + : params.objectYawDeg != null + ? params.objectYawDeg + : lastObjectRotZDeg, + { + kind: nextKind ?? lastObjectKind, + lengthM: nextLen ?? lastObjectLengthM, + }, + ).then(() => { + if (!running) updateRays(false); + fitCamera(); + }); + moved = true; + } if (moved || params.fitCamera) fitCamera(); } @@ -975,6 +1350,7 @@ export function useMleSimulator(containerRef, surveyRef) { surveyControls?.dispose(); clearObject(seafloorMesh); clearObject(seafloorGrid); + clearPathRelief(); clearObject(auvPivot); clearObject(objectPivot); clearObject(rayLines); diff --git a/frontend/web/src/stores/dataset.js b/frontend/web/src/stores/dataset.js index 083eae7..4508114 100644 --- a/frontend/web/src/stores/dataset.js +++ b/frontend/web/src/stores/dataset.js @@ -1,21 +1,61 @@ import { defineStore } from "pinia"; import { api } from "@/api/client"; +import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings"; + +const STORAGE_KEY = "dottosurface.dataset.settings.v1"; + +const PERSIST_KEYS = [ + "count", + "seed", + "outputDir", + "modelSource", + "modelPreset", + "modelFileName", + "objectScale", + "objectScaleIsMax", + "beamCount", + "lengthCount", + "absentPct", + "nearlyHiddenPct", + "partialPct", + "visiblePct", +]; + +function loadPersistedDataset() { + const saved = loadUserSettings(STORAGE_KEY, {}); + const out = {}; + for (const key of PERSIST_KEYS) { + if (saved[key] == null) continue; + out[key] = saved[key]; + } + if (out.modelSource !== "file" && out.modelSource !== "preset") out.modelSource = "preset"; + if (!out.modelPreset) out.modelPreset = "pipe"; + return out; +} export const useDatasetStore = defineStore("dataset", { - state: () => ({ + state: () => { + const saved = loadPersistedDataset(); + return { busy: false, previewBusy: false, - statusText: "Выберите .obj модель и нажмите «Сгенерировать».", - count: 5, - seed: 42, - outputDir: "sonar_dataset", + statusText: "Выберите модель объекта и нажмите «Сгенерировать».", + count: saved.count ?? 5, + seed: saved.seed ?? 42, + outputDir: saved.outputDir ?? "sonar_dataset", resolvedOutputDir: null, + modelSource: saved.modelSource ?? "preset", + modelPreset: saved.modelPreset ?? "pipe", modelFile: null, - modelFileName: "", - objectScale: 1, - objectScaleIsMax: false, - beamCount: 45, - lengthCount: 45, + modelFileName: saved.modelFileName ?? "", + objectScale: saved.objectScale ?? 1, + objectScaleIsMax: !!saved.objectScaleIsMax, + beamCount: saved.beamCount ?? 45, + lengthCount: saved.lengthCount ?? 45, + absentPct: saved.absentPct ?? 30, + nearlyHiddenPct: saved.nearlyHiddenPct ?? 20, + partialPct: saved.partialPct ?? 40, + visiblePct: saved.visiblePct ?? 40, generateProgress: 0, browseBusy: false, availableRuns: [], @@ -29,7 +69,11 @@ export const useDatasetStore = defineStore("dataset", { classLabels: { 0: "background", 1: "object" }, classCounts: {}, logLines: [], - }), + alertVisible: false, + alertMessage: "", + _persistTimer: null, + }; + }, getters: { stats(state) { return state.lastResult?.stats || null; @@ -48,6 +92,19 @@ export const useDatasetStore = defineStore("dataset", { canGenerate(state) { return !state.busy; }, + selectedModelLabel(state) { + if (state.modelSource === "preset") { + if (state.modelPreset === "pipe") return "Трубопровод (предустановка)"; + return state.modelPreset ? `Предустановка: ${state.modelPreset}` : ""; + } + return state.modelFileName || ""; + }, + hasObjectModel(state) { + if (state.modelSource === "preset") { + return Boolean(state.modelPreset); + } + return Boolean(state.modelFile); + }, classOptions(state) { const labels = state.classLabels || { 0: "background", 1: "object" }; const counts = state.classCounts || {}; @@ -65,12 +122,35 @@ export const useDatasetStore = defineStore("dataset", { }, }, actions: { + persistSettings() { + const payload = {}; + for (const key of PERSIST_KEYS) { + payload[key] = this[key]; + } + saveUserSettings(STORAGE_KEY, payload); + }, + schedulePersist() { + if (!this._debouncedPersist) { + this._debouncedPersist = createDebouncedSaver(() => this.persistSettings(), 400); + } + this._debouncedPersist(); + }, pushLog(line) { this.logLines.push(String(line)); if (this.logLines.length > 200) { this.logLines = this.logLines.slice(-200); } }, + showAlert(message) { + const text = String(message || "").trim(); + if (!text) return; + this.alertMessage = text; + this.alertVisible = true; + }, + dismissAlert() { + this.alertVisible = false; + this.alertMessage = ""; + }, setHighlightClass(value) { if (value === null || value === undefined || value === "" || value === "all") { this.highlightClass = null; @@ -88,6 +168,10 @@ export const useDatasetStore = defineStore("dataset", { const objectScaleIsMax = settings.objectScaleIsMax ?? run?.objectScaleIsMax; const beamCount = settings.beamCount ?? run?.beamCount; const lengthCount = settings.lengthCount ?? run?.lengthCount; + const absentPct = settings.absentPct; + const nearlyHiddenPct = settings.nearlyHiddenPct; + const partialPct = settings.partialPct; + const visiblePct = settings.visiblePct; const parts = []; if (objectName) parts.push(`model=${objectName}`); @@ -102,6 +186,16 @@ export const useDatasetStore = defineStore("dataset", { : `scale=${objectScale}`, ); } + if (absentPct != null) parts.push(`без объекта=${absentPct}%`); + if ( + nearlyHiddenPct != null || + partialPct != null || + visiblePct != null + ) { + parts.push( + `видимость(с объектом)=${nearlyHiddenPct ?? "?"}/${partialPct ?? "?"}/${visiblePct ?? "?"}%`, + ); + } if (outputDir) parts.push(`dir=${outputDir}`); if (run?.objectVertexCount != null) { parts.push(`vertices=${run.objectVertexCount}`); @@ -134,7 +228,7 @@ export const useDatasetStore = defineStore("dataset", { if (!file) { this.modelFile = null; this.modelFileName = ""; - this.statusText = "Выберите .obj модель и нажмите «Сгенерировать»."; + this.statusText = "Выберите модель объекта и нажмите «Сгенерировать»."; return; } const name = String(file.name || ""); @@ -144,22 +238,52 @@ export const useDatasetStore = defineStore("dataset", { this.statusText = "Нужен файл формата .obj"; return; } + this.modelSource = "file"; this.modelFile = file; this.modelFileName = name; this.statusText = `Модель: ${name}`; this.pushLog(`Выбрана модель: ${name}`); + this.schedulePersist(); + }, + setModelSource(source) { + const next = source === "file" ? "file" : "preset"; + this.modelSource = next; + if (next === "preset") { + if (!this.modelPreset) this.modelPreset = "pipe"; + this.statusText = `Модель: ${this.selectedModelLabel}`; + this.pushLog(`Предустановка объекта: ${this.selectedModelLabel}`); + } else { + this.statusText = this.modelFileName + ? `Модель: ${this.modelFileName}` + : "Выберите файл модели .obj"; + } + this.schedulePersist(); + }, + setModelPreset(presetId) { + this.modelPreset = String(presetId || "pipe"); + this.modelSource = "preset"; + this.statusText = `Модель: ${this.selectedModelLabel}`; + this.pushLog(`Предустановка объекта: ${this.selectedModelLabel}`); + this.schedulePersist(); }, async generate() { if (this.busy) return; - if (!this.modelFile) { - this.statusText = "Сначала выберите файл модели .obj"; + if (!this.hasObjectModel) { + const msg = + this.modelSource === "file" + ? "Файл объекта не выбран. Выберите модель .obj перед генерацией." + : "Модель объекта не выбрана."; + this.statusText = msg; + this.pushLog(msg); + this.showAlert(msg); return; } this.busy = true; this.generateProgress = 0; this.statusText = "Генерация датасета…"; + const modelLabel = this.selectedModelLabel || this.modelFileName || this.modelPreset; this.pushLog( - `Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}${this.objectScaleIsMax ? " (макс.)" : ""}, dir=${this.outputDir}, model=${this.modelFileName}`, + `Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}${this.objectScaleIsMax ? " (макс.)" : ""}, без объекта=${this.absentPct}%, видимость=${this.nearlyHiddenPct}/${this.partialPct}/${this.visiblePct}%, dir=${this.outputDir}, model=${modelLabel}`, ); try { const result = await api.datasetGenerate({ @@ -170,7 +294,12 @@ export const useDatasetStore = defineStore("dataset", { objectScaleIsMax: !!this.objectScaleIsMax, beamCount: Number(this.beamCount) || 45, lengthCount: Number(this.lengthCount) || 45, - modelFile: this.modelFile, + absentPct: Number(this.absentPct) || 0, + nearlyHiddenPct: Number(this.nearlyHiddenPct) || 0, + partialPct: Number(this.partialPct) || 0, + visiblePct: Number(this.visiblePct) || 0, + modelPreset: this.modelSource === "preset" ? this.modelPreset : null, + modelFile: this.modelSource === "file" ? this.modelFile : null, onProgress: (event) => { if (event.type === "start") { this.generateProgress = 0; @@ -208,7 +337,7 @@ export const useDatasetStore = defineStore("dataset", { ? `scale=1…${result.objectScale ?? this.objectScale} (макс.)` : `scale=${result.objectScale ?? this.objectScale}`; this.pushLog( - `Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), ${scaleNote}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`, + `Модель: ${result.objectName || modelLabel} (${result.objectVertexCount || "?"} вершин), ${scaleNote}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`, ); this.pushLog( `Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`, diff --git a/frontend/web/src/stores/generator.js b/frontend/web/src/stores/generator.js index 54a1ddb..5707b35 100644 --- a/frontend/web/src/stores/generator.js +++ b/frontend/web/src/stores/generator.js @@ -1,8 +1,10 @@ import { defineStore } from "pinia"; import { api, exportFilename, parseCloudPoints } from "@/api/client"; +import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings"; let layerSeq = 1; const MAX_HISTORY = 80; +const STORAGE_KEY = "dottosurface.generator.settings.v1"; const IMPORT_COLORS = { obj: "#f472b6", ply: "#38bdf8", @@ -67,23 +69,54 @@ function makeSnapshot(label, layers, selectedLayerId) { }; } +function layerRecipe(layer) { + return { + id: layer.id, + name: layer.name, + kind: layer.kind, + type: layer.type, + label: layer.label, + params: layer.params || {}, + transform: normalizeTransform(layer.transform), + visible: layer.visible !== false, + color: layer.color, + imported: !!layer.imported, + }; +} + +function loadPersistedGenerator() { + const saved = loadUserSettings(STORAGE_KEY, {}); + return { + exportFormat: saved.exportFormat || "xyz", + interactionMode: saved.interactionMode || "orbit", + translateStep: Number(saved.translateStep) || 0.05, + rotateStepDeg: Number(saved.rotateStepDeg) || 5, + selectedLayerId: saved.selectedLayerId || null, + layerRecipes: Array.isArray(saved.layerRecipes) ? saved.layerRecipes : [], + }; +} + export const useGeneratorStore = defineStore("generator", { - state: () => ({ + state: () => { + const saved = loadPersistedGenerator(); + return { busy: false, statusText: "Добавьте объект или поверхность.", catalog: [], colors: {}, layers: [], - selectedLayerId: null, - interactionMode: "orbit", - translateStep: 0.05, - rotateStepDeg: 5, - exportFormat: "xyz", + selectedLayerId: saved.selectedLayerId, + interactionMode: saved.interactionMode, + translateStep: saved.translateStep, + rotateStepDeg: saved.rotateStepDeg, + exportFormat: saved.exportFormat, viewerRevision: 0, history: [makeSnapshot("Начало", [], null)], historyIndex: 0, _restoring: false, - }), + _pendingRecipes: saved.layerRecipes, + }; + }, getters: { selectedLayer(state) { return state.layers.find((layer) => layer.id === state.selectedLayerId) || null; @@ -111,11 +144,75 @@ export const useGeneratorStore = defineStore("generator", { }, }, actions: { + persistSettings() { + saveUserSettings(STORAGE_KEY, { + exportFormat: this.exportFormat, + interactionMode: this.interactionMode, + translateStep: this.translateStep, + rotateStepDeg: this.rotateStepDeg, + selectedLayerId: this.selectedLayerId, + layerRecipes: this.layers.map(layerRecipe), + }); + }, + schedulePersist() { + if (this._restoring) return; + if (!this._debouncedPersist) { + this._debouncedPersist = createDebouncedSaver(() => this.persistSettings(), 500); + } + this._debouncedPersist(); + }, async bootstrap() { const payload = await api.generatorCatalog(); this.catalog = payload.layers || []; this.colors = payload.colors || {}; this.statusText = "Каталог загружен. Добавьте слой."; + await this.restoreLayerRecipes(this._pendingRecipes || []); + this._pendingRecipes = []; + }, + async restoreLayerRecipes(recipes) { + if (!recipes.length) return; + this._restoring = true; + this.busy = true; + try { + const restored = []; + for (const recipe of recipes) { + if (recipe.imported) continue; + if (!recipe.kind || !recipe.type) continue; + try { + const result = await api.generatorLayer(recipe.kind, recipe.type, recipe.params || {}); + const id = recipe.id || `layer-${layerSeq++}`; + const num = Number(String(id).replace(/\D/g, "")) || layerSeq; + layerSeq = Math.max(layerSeq, num + 1); + restored.push({ + id, + name: recipe.name || result.label || recipe.type, + kind: recipe.kind, + type: recipe.type, + label: result.label || recipe.label, + params: result.params || recipe.params || {}, + transform: normalizeTransform(recipe.transform), + points: result.points || [], + visible: recipe.visible !== false, + color: recipe.color || result.color || "#7dd3fc", + }); + } catch { + /* skip broken recipe */ + } + } + this.layers = restored; + if (restored.length) { + const still = restored.find((l) => l.id === this.selectedLayerId); + this.selectedLayerId = still?.id || restored[0].id; + this.history = [makeSnapshot("Восстановлено", this.layers, this.selectedLayerId)]; + this.historyIndex = 0; + this.statusText = `Восстановлено слоёв: ${restored.length}.`; + this.bumpViewer(); + } + } finally { + this.busy = false; + this._restoring = false; + this.persistSettings(); + } }, bumpViewer() { this.viewerRevision += 1; @@ -145,6 +242,7 @@ export const useGeneratorStore = defineStore("generator", { this.history.splice(0, this.history.length - MAX_HISTORY); } this.historyIndex = this.history.length - 1; + this.schedulePersist(); } catch (error) { console.error("recordHistory failed", error); this.statusText = `История не записана: ${error.message}`; @@ -181,6 +279,7 @@ export const useGeneratorStore = defineStore("generator", { }, selectLayer(id) { this.selectedLayerId = id; + this.schedulePersist(); }, setInteractionMode(mode) { if (mode === "translate" || mode === "rotate") { @@ -188,18 +287,22 @@ export const useGeneratorStore = defineStore("generator", { } else { this.interactionMode = "orbit"; } + this.schedulePersist(); }, setTranslateStep(step) { const value = Number(step); this.translateStep = Number.isFinite(value) && value > 0 ? value : 0.05; + this.schedulePersist(); }, setRotateStepDeg(step) { const value = Number(step); this.rotateStepDeg = Number.isFinite(value) && value > 0 ? value : 5; + this.schedulePersist(); }, setExportFormat(format) { const allowed = new Set(["xyz", "ply", "obj", "npy"]); this.exportFormat = allowed.has(format) ? format : "xyz"; + this.schedulePersist(); }, updateSelectedParams(partial) { const layer = this.selectedLayer; diff --git a/frontend/web/src/stores/mle.js b/frontend/web/src/stores/mle.js index 964c23a..7762f4e 100644 --- a/frontend/web/src/stores/mle.js +++ b/frontend/web/src/stores/mle.js @@ -13,6 +13,8 @@ const DEFAULTS = { objectY: -20, objectZ: 0, objectSizeM: 2, + objectLengthM: 20, + objectKind: "pipe", // "pipe" | "file" objectRotXDeg: 0, objectRotYDeg: 0, objectRotZDeg: 0, @@ -22,6 +24,7 @@ const DEFAULTS = { speed: 1.5, surveyLength: 40, seed: 42, + reliefScalePct: 20, sizeX: 40, sizeY: 60, outputDir: "mle_runs", @@ -57,6 +60,8 @@ function coerceSettings(data) { objectY: data.object.y ?? data.objectY, objectZ: data.object.z ?? data.objectZ, objectSizeM: data.object.sizeM ?? data.objectSizeM, + objectLengthM: data.object.lengthM ?? data.objectLengthM, + objectKind: data.object.kind ?? data.objectKind, objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg, objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg, objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg, @@ -81,12 +86,17 @@ function coerceSettings(data) { data = { ...data, seed: data.seafloor.seed ?? data.seed, + reliefScalePct: data.seafloor.reliefScalePct ?? data.reliefScalePct, 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 (key === "objectKind") { + out[key] = String(data[key]) === "pipe" ? "pipe" : "file"; + continue; + } if (typeof DEFAULTS[key] === "number") { const n = Number(data[key]); if (Number.isFinite(n)) out[key] = n; @@ -131,6 +141,8 @@ export const useMleStore = defineStore("mle", { objectY: saved.objectY ?? DEFAULTS.objectY, objectZ: saved.objectZ ?? DEFAULTS.objectZ, objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM, + objectLengthM: saved.objectLengthM ?? DEFAULTS.objectLengthM, + objectKind: saved.objectKind ?? DEFAULTS.objectKind, objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg, objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg, objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg, @@ -142,6 +154,7 @@ export const useMleStore = defineStore("mle", { surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength, seed: saved.seed ?? DEFAULTS.seed, + reliefScalePct: saved.reliefScalePct ?? DEFAULTS.reliefScalePct, sizeX: saved.sizeX ?? DEFAULTS.sizeX, sizeY: saved.sizeY ?? DEFAULTS.sizeY, outputDir: saved.outputDir ?? DEFAULTS.outputDir, @@ -246,11 +259,20 @@ export const useMleStore = defineStore("mle", { this.statusText = "Объект: нужен файл .obj"; return; } + this.objectKind = "file"; this.objectFile = file; this.objectFileName = name; this.pushLog(`Объект: ${name}`); this.persistSettings(); }, + setObjectKind(kind) { + const next = kind === "pipe" ? "pipe" : "file"; + this.objectKind = next; + if (next === "pipe") { + this.pushLog("Объект: трубопровод (предустановка)"); + } + 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)); @@ -274,6 +296,8 @@ export const useMleStore = defineStore("mle", { y: this.objectY, z: this.objectZ, sizeM: this.objectSizeM, + lengthM: this.objectLengthM, + kind: this.objectKind, rotXDeg: this.objectRotXDeg, rotYDeg: this.objectRotYDeg, rotZDeg: this.objectRotZDeg, @@ -289,6 +313,7 @@ export const useMleStore = defineStore("mle", { }, seafloor: { seed: this.seed, + reliefScalePct: this.reliefScalePct, sizeX: this.sizeX, sizeY: this.sizeY, }, @@ -296,23 +321,34 @@ export const useMleStore = defineStore("mle", { }, async prepare() { if (this.busy || this.running) return; + // Новый случайный рельеф при каждой подготовке. + this.seed = Math.floor(Math.random() * 1_000_000); this.placeObjectAlongTrack(); this.persistSettings(); this.busy = true; this.statusText = "Генерация рельефа дна…"; + const reliefPct = Math.max(1, Math.min(100, Number(this.reliefScalePct) || 20)); + this.reliefScalePct = reliefPct; this.pushLog( - `Подготовка: seed=${this.seed}, size=${this.sizeX}×${this.sizeY}, beams=${this.beamCount}`, + `Подготовка: seed=${this.seed}, неровности=${reliefPct}% ширины полосы, beams=${this.beamCount}`, ); try { const result = await api.mlePrepare({ seed: Number(this.seed) || 42, - sizeX: Number(this.sizeX) || 40, - sizeY: Number(this.sizeY) || 60, + reliefScalePct: reliefPct, + auvX: Number(this.auvX) || 0, + auvY: Number(this.auvY) || 0, + auvHeadingDeg: Number(this.auvHeadingDeg) || 0, + surveyLength: Number(this.surveyLength) || 40, + auvDepth: Number(this.auvDepth) || 2.5, + swathAngleDeg: Number(this.swathAngleDeg) || 90, resX: 80, resY: 120, outputDir: String(this.outputDir || "mle_runs"), settings: this.settingsSnapshot(), }); + if (result?.params?.sizeX != null) this.sizeX = result.params.sizeX; + if (result?.params?.sizeY != null) this.sizeY = result.params.sizeY; this.scene = result; this.runName = result.runName; this.seafloorObjPath = result.seafloorObj; diff --git a/frontend/web/src/stores/pipeline.js b/frontend/web/src/stores/pipeline.js index a1e7d19..d4afcdc 100644 --- a/frontend/web/src/stores/pipeline.js +++ b/frontend/web/src/stores/pipeline.js @@ -4,6 +4,9 @@ import { filterPaletteGroupedModel, reconstructionPaletteModel, } from "@/catalog/pipelineUiCatalog"; +import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings"; + +const STORAGE_KEY = "dottosurface.pipeline.settings.v1"; function configFromCards(stageCards) { const preprocessPlugins = []; @@ -76,16 +79,20 @@ function pipelineFingerprint(stageCards) { } export const usePipelineStore = defineStore("pipeline", { - state: () => ({ + state: () => { + const saved = loadUserSettings(STORAGE_KEY, {}); + return { busy: false, statusText: "Загрузите облако или сгенерируйте демо.", stageCards: [], - selectedStageIndex: -1, - surfaceVisible: true, - demoSurfaceType: "Сфера", + selectedStageIndex: Number.isFinite(Number(saved.selectedStageIndex)) + ? Number(saved.selectedStageIndex) + : -1, + surfaceVisible: saved.surfaceVisible !== false, + demoSurfaceType: saved.demoSurfaceType || "Сфера", demoSurfaceTypes: ["Сфера", "Тор", "Волна", "Дно реки + труба"], - wizardProfile: "general", - wizardGoal: "balanced", + wizardProfile: saved.wizardProfile || "general", + wizardGoal: saved.wizardGoal || "balanced", presetItems: [], metrics: { inputPoints: 0, @@ -111,7 +118,9 @@ export const usePipelineStore = defineStore("pipeline", { stageMetaById: {}, /** Fingerprint of stageCards after last successful apply/run; null = never applied. */ appliedFingerprint: null, - }), + _savedStageCards: Array.isArray(saved.stageCards) ? saved.stageCards : null, + }; + }, getters: { selectedStage(state) { if (state.selectedStageIndex < 0 || state.selectedStageIndex >= state.stageCards.length) { @@ -137,6 +146,30 @@ export const usePipelineStore = defineStore("pipeline", { }, }, actions: { + persistSettings() { + saveUserSettings(STORAGE_KEY, { + selectedStageIndex: this.selectedStageIndex, + surfaceVisible: this.surfaceVisible, + demoSurfaceType: this.demoSurfaceType, + wizardProfile: this.wizardProfile, + wizardGoal: this.wizardGoal, + stageCards: (this.stageCards || []).map((card) => ({ + id: card.id, + title: card.title, + category: card.category, + family: card.family, + hint: card.hint, + defaults: card.defaults || "", + enabled: !!card.enabled, + })), + }); + }, + schedulePersist() { + if (!this._debouncedPersist) { + this._debouncedPersist = createDebouncedSaver(() => this.persistSettings(), 400); + } + this._debouncedPersist(); + }, async bootstrap() { const [presets, defaultConfig, catalog] = await Promise.all([ api.presets(), @@ -147,8 +180,26 @@ export const usePipelineStore = defineStore("pipeline", { (catalog.stageMeta || []).map((item) => [item.id, item]), ); this.presetItems = presets; - this.stageCards = cardsFromConfig(defaultConfig, this.stageMetaById); + const savedCards = this._savedStageCards; + this._savedStageCards = null; + if (savedCards?.length) { + this.stageCards = savedCards.map((card) => { + const meta = metaForStage(card.id, this.stageMetaById); + return { + id: card.id, + title: card.title || meta.title || card.id, + category: card.category || meta.category || "Custom", + family: card.family || meta.family || "preprocess", + hint: card.hint || meta.hint || "", + defaults: card.defaults ?? meta.defaults ?? "", + enabled: card.enabled !== false, + }; + }); + } else { + this.stageCards = cardsFromConfig(defaultConfig, this.stageMetaById); + } await this.validateChain(); + this.persistSettings(); }, async validateChain() { const result = await api.validateConfig(this.pipelineConfig); @@ -156,9 +207,11 @@ export const usePipelineStore = defineStore("pipeline", { this.recommendation = result.recommendation; this.warningsList = result.warningsList || []; if (result.stageCards) this.stageCards = result.stageCards; + this.schedulePersist(); }, setSelectedStageIndex(index) { this.selectedStageIndex = index; + this.schedulePersist(); }, addStage(stageId) { const meta = metaForStage(stageId, this.stageMetaById); @@ -235,6 +288,7 @@ export const usePipelineStore = defineStore("pipeline", { this.stageCards = cardsFromConfig(config, this.stageMetaById); await this.validateChain(); this.statusText = `Пресет '${preset.title || presetId}' загружен. Нажмите 'Применить' для запуска.`; + this.schedulePersist(); }, async applyWizard() { const result = await api.wizard(this.wizardProfile, this.wizardGoal); @@ -243,12 +297,23 @@ export const usePipelineStore = defineStore("pipeline", { this.recommendation = result.recommendation; this.warningsList = result.warningsList || []; this.statusText = `Wizard предложил пресет '${result.title}'.`; + this.schedulePersist(); }, setDemoSurfaceType(value) { this.demoSurfaceType = value; + this.schedulePersist(); }, setSurfaceVisible(value) { this.surfaceVisible = value; + this.schedulePersist(); + }, + setWizardProfile(value) { + this.wizardProfile = value; + this.schedulePersist(); + }, + setWizardGoal(value) { + this.wizardGoal = value; + this.schedulePersist(); }, async runPipeline({ useDemo = false, geometryFormat = "json" } = {}) { const runWithDemo = useDemo || (this.usingDemoInput && !this.currentFile); diff --git a/frontend/web/src/utils/userSettings.js b/frontend/web/src/utils/userSettings.js new file mode 100644 index 0000000..ab0a4f2 --- /dev/null +++ b/frontend/web/src/utils/userSettings.js @@ -0,0 +1,31 @@ +/** Lightweight localStorage helpers for UI settings across app tabs. */ + +export function loadUserSettings(key, fallback = {}) { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + const data = JSON.parse(raw); + return data && typeof data === "object" ? data : fallback; + } catch { + return fallback; + } +} + +export function saveUserSettings(key, payload) { + try { + localStorage.setItem(key, JSON.stringify(payload)); + } catch { + /* quota / private mode */ + } +} + +export function createDebouncedSaver(saveFn, delayMs = 400) { + let timer = null; + return (...args) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + saveFn(...args); + }, delayMs); + }; +} diff --git a/frontend/web/src/views/DatasetView.vue b/frontend/web/src/views/DatasetView.vue index c99a5a6..680bd47 100644 --- a/frontend/web/src/views/DatasetView.vue +++ b/frontend/web/src/views/DatasetView.vue @@ -68,6 +68,27 @@ onMounted(async () => { } }); +watch( + () => [ + store.count, + store.seed, + store.outputDir, + store.modelSource, + store.modelPreset, + store.objectScale, + store.objectScaleIsMax, + store.beamCount, + store.lengthCount, + store.absentPct, + store.nearlyHiddenPct, + store.partialPct, + store.visiblePct, + ], + () => { + store.schedulePersist(); + }, +); + async function onGenerate() { try { await store.generate(); @@ -94,6 +115,10 @@ function onModelFileChange(event) { store.setModelFile(file); } +function onModelSourceChange(event) { + store.setModelSource(event.target?.value); +} + function onHighlightClassChange(event) { store.setHighlightClass(event.target?.value); } @@ -175,21 +200,39 @@ function runOptionLabel(run) {
Синтетические сцены эхолота для PointNet (сегментация: фон / object).
- Целевой объект — вершины выбранной .obj модели (класс 1).
+ Целевой объект — предустановка «Трубопровод» или вершины загруженной .obj (класс 1).
Файлы: Area_X_scene_XXXX.npy + .obj.
+ При каждом «Подготовить» — новый случайный рельеф в жёлтой полосе обзора эхолота. + Размер неровностей задаётся в % от ширины этой полосы. +